abei 是我业余在做的一个开源记账工具:账单从邮箱收进来,各家的导出解析成统一的流水,AI 预填分类,最后由人确认入账。
导入时最先撞上的问题是去重。同一笔钱会以好几种样子出现:同一份账单导了两次;微信的一笔消费,又出现在绑定银行卡的账单里;一笔转账在两个账户里各记一次;退款和原单金额相同、方向相反。
动手设计匹配引擎之前,我先看了九个记账产品和开源项目是怎么做的。下面是那次调研的整理,调研日期是 2026 年 9 月 1 日。标了「未核实」的条目,是没找到公开文档、要以实际账单样本为准的。
一句话结论
去重不是一个布尔判断,而是五类不同的关系各自打分:同源重复、跨源同一笔、转账配对、退款关联、合并支付。
国外产品只解决了第一类和第三类。跨源同一笔(微信、支付宝对银行卡)是中国场景独有的难点,没有成熟产品可以照搬。
abei 的做法是:一个匹配引擎,加可配置的策略,每一次匹配的决定都落库,并且可以回滚。它替代了原来散在三处的去重逻辑。
一、各产品的去重判据
| 产品 | 主判据 | 时间窗口 | 金额容差 | 命中后的动作 | 用户看不看得到 |
|---|---|---|---|---|---|
| Firefly III Data Importer | external_id(映射到 External identifier 列);也可比对描述和备注的组合 | 无窗口,等值比对 | 零容差 | 直接不导入 | 只在导入日志里,账本里没有痕迹 |
| Firefly III 核心 | journal_meta.import_hash_v2,即整行 JSON 的 sha256;开了 errorIfDuplicate 就报错 | 无 | 零容差 | 报错拒绝 | 报错信息 |
| Actual Budget | 先看 imported_id;没命中再模糊匹配(金额相同 + 日期 ±7 天 + 收款方,再退化为金额 + 日期 ±7 天) | ±7 天 | 金额严格相等 | 更新已有的那笔(包括把日期改成导入侧的日期),而不是丢掉新的一行 | 交易被就地更新 |
| YNAB | 导入时用 import_id(格式 YNAB:{amount}:{date}:{occurrence},账户内唯一);界面上把导入行和手记行配成 Matched | 官方文档没给具体天数(社区常说 ±10 天,未核实) | 金额一致 | 标成 Matched,仍要人点 Approve;可以 Unmatch | 明确的配对和确认交互 |
| beangulp(Beancount 的导入框架) | similar 模块的启发式:日期窗口 + 金额比例容差 + 账户集合的子集关系 | window_days=2 | epsilon=0.05,即 5% 相对容差 | 只给交易打 __duplicate__ 标记,不删也不合并 | 由用户在文本里裁决 |
| GnuCash 导入匹配器 | 贝叶斯打分,三档阈值 | 匹配天数可配 | 打分制 | 红、黄、绿三区,对应默认新增、待人选、默认对上 | 导入匹配窗口里逐行可改 |
| Plaid | pending 变 posted 不是状态变更,而是一笔新交易,用 pending_transaction_id 指回旧的那笔,旧的那笔另外通知删除 | 通常 1 到 5 个工作日,极端 14 天 | 不适用 | 由接入方自己合并 | 由接入方决定 |
| Lunch Money、Tiller | 以人工去重工具为主;Tiller 用 Import Tag 列标记某一次 CSV 导入,可以整批删掉重来 | 无 | 无 | 人工 | 完全可见 |
| BeeCount 等国产开源 | 导入前强制预览,整批原子回滚(未核实,没找到公开文档) | — | — | — | — |
补充几条:
- Firefly 的整行 hash 对规整化极敏感:描述、标签、备注任何一处改动,同一笔就不再判重;反过来,同商户、同金额、同一天的两笔真实消费会被误判成重复。
- Firefly 社区反复出现「删掉的交易再导入,仍被判重」或者「反而重复导入」的讨论,说明删除和去重索引的生命周期没有对齐(见 discussions #10096、issue #5083)。
- Actual 的「匹配后更新已有的那笔」是关键的设计选择:它承认先手记、后导入是常态,导入侧的数据更权威。
二、中国场景的三个独有难点
1. 跨源同一笔:平台侧对银行侧
微信、支付宝的一笔消费,会同时出现在平台账单和绑定银行卡的账单里。国外产品没有这个概念,Plaid 是一个账户一份流水。
社区里(deb-sig/double-entry-generator 的 Discussion #162)给过两条路线:
| 路线 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 中转账户 | 银行侧记成「银行 → 中转账户」,平台侧记成「中转账户 → 商户」,中转账户余额恒为 0,可以自查 | 信息全保留,能对账 | 账户树变复杂,普通用户看不懂 |
| 规则去重(主流) | 保留信息量最大的平台侧记录,忽略银行侧对应的那一行 | 简单,账目干净 | 从银行侧推余额会缺一段,需要配对留痕 |
关键信号是:秒级时间、金额,加上平台侧「支付方式」字段里的银行卡尾号。同一个讨论里也有人提出按秒级时间戳加金额比对的自动判重思路。
有状态的自动配对(记住配过的对、支持撤销)目前还是空白,bean-sieve 这类工具在尝试(未核实)。
2. 退款不能当成重复
退款和原单同商户、同金额、方向相反。正确的做法是保留反向的那条记录,再把两条关联起来,而不是删掉一条。
支付宝账单带「成功退款」和资金状态字段,可以按字段判定,不用靠猜(未核实:字段名以实际样本为准)。
3. 合并支付,一对多
一次支付拆成多行账单,或者多笔合并成一次扣款。没有成熟产品自动处理,子集和匹配的误报率太高。结论:只提示,不自动合并。
另外,微信把充值、提现、理财通、信用卡还款标成中性交易,支付宝有「不计收支」标记,两者都不该进收支统计(未核实:以实际账单样本的字段为准)。
三、命中之后,界面上怎么处理
| 档位 | 代表 | 适用的关系 | 必须配套的东西 |
|---|---|---|---|
| 静默跳过 | Firefly 的导入器 | 硬键完全命中的同源重复 | 必须能查到跳过了什么、撞上了哪一笔。Firefly 正是反例:跳过之后无处可查 |
| 提示,等人确认 | YNAB 的 Matched(配对后仍要 Approve,可以拆开)、GnuCash 的黄区 | 跨源同一笔、软信号的重复 | 证据看得懂,一键拆开 |
| 阻断,打分 | GnuCash 的红黄绿三区 | 金额高或者不确定性高的 | 分数要能解释 |
撤销要有两级:整批撤销(像 Tiller 的 Import Tag,一次导入整体撤掉)和单条撤销。
四、哪些该让用户配置
按「用户真的会去调」排序:
- 按渠道设时间窗口:微信、支付宝是小时级,银行是天级。
- 金额容差:绝对值和相对比例取大的那个。beangulp 用 5% 相对容差,银行场景通常要绝对值。
- 唯一键字段的优先级:交易订单号,其次商家订单号,最后是指纹 hash。
- 各渠道谁更权威:跨源冲突时谁是主记录,默认平台侧。
- 严格程度的开关:像 Firefly 的
strictIdChecking、GnuCash 的三个阈值。 - 不开放各字段权重的细调:用户调不动,只会调坏。
五、abei 的做法
一个匹配引擎,五类关系各自打分
输入是规范化之后的一行:渠道、精确到秒的发生时间、金额、交易对方、商品、支付方式、业务类型、状态、收支方向、平台订单号、商家订单号、原始行的 hash。
| 关系 | 判据 | 默认动作 |
|---|---|---|
| 同源重复 | 硬键(交易订单号)命中 | 静默跳过并写日志;只有软信号命中的,等人确认 |
| 跨源同一笔 | 秒级时间 + 金额 + 卡尾号 | 分数高的自动折叠;平台侧是主记录,银行侧作为资金来源留痕 |
| 转账配对 | 两侧账户 + 金额 + 时间窗口 | 自动配对抵消;配不上的进「未配平」队列 |
| 退款关联 | 反向金额 + 同商户 + 退款字段 | 自动关联,不合并,也不记成收入 |
| 合并支付 | 子集和 | 永远只提示,不自动合并 |
每一次匹配输出一个决定:是哪种关系、指向哪几笔、分数、证据、建议的动作。证据必须能一条条读懂,比如「订单号一致」「时间差 3 秒」「卡尾号一致」。
状态与回滚
一次匹配从「待定」出发,要么自动合并,要么进入待复核;复核之后是确认或者否决;确认过的也可以再拆开。
- 合并日志只追加、不修改,支持单条和整批回滚(借 Tiller 的 Import Tag 的意思)。
- 被否决的配对留下来当负样本,同一对以后不再打扰。
- AI 只在待复核的那些上给建议;硬键命中的路径不经过 AI。
默认阈值
这些是默认值,还在讨论,之后可以在管理端按渠道配置。
| 关系 | 时间窗口 | 金额容差 |
|---|---|---|
| 同源重复 | 无(硬键等值) | 0 |
| 跨源同一笔 | ±2 小时 | 0 |
| 转账配对 | ±3 天 | 0 |
| 退款关联 | ±90 天 | 0 |
不采用的做法
- 不用 Firefly 式的整行 hash:改用结构化的外部键。账单行上已有的外部键和指纹比 Firefly 的做法稳,直接带到账本表上,再加一条「同一用户下外部键唯一」的约束。
- 不静默跳过、不留痕迹:任何一次跳过,都要能查到撞上的是哪一笔。
- 不把字段权重开放给用户。
来源
Firefly III
Actual Budget
YNAB
beangulp 和 Beancount
GnuCash
Plaid
Tiller
中文场景