1对账不是“找不同”
渠道账单和我方流水对不上,是支付系统里每天都会发生的事。真正的工作量不在“发现对不上”,而在三件事:
- 先确认两笔说的是同一件事。 商户订单号、渠道流水号、退款单、原单号——键不同,配对结果就不同。
- 把差异分对类。 金额不符、手续费不符、币种不符、状态冲突、掉单、多单、重复出账、跨日清算、退款无原单……每一类的下一步完全不同。
- 给每条差异一个去处。 差异没有“下一步找谁”,看板上的 200 条差异就是 200 次要重新想一遍的会议。
这个项目做的是后端闭环:账单入库 → 分层确定性匹配 → 差异带归因 → 工单状态机推到结案,外加渠道回调的验签与防重放。单机一条命令能跑通全流程。
2为什么先配对、后比金额
假设本地有一笔 100 元的单,渠道账单是 99 元。如果先按“金额相等”匹配,这一对根本配不上,于是变成“本地单边 + 渠道单边”两条差异;原本只是一笔金额不符,现在要两个人分别查。误报直接翻倍。
正确的顺序是:先用业务键确认“这两笔是同一笔”,再在配对之后判金额、币种、手续费、状态。先配对后判定,一笔金额不符只会产出一条 AMOUNT_MISMATCH,主体明确,下一步也明确。
3L1 / L2 / L3 的分层
层顺序本身就是设计,不能调:
| 层 | 键 | 说明 |
|---|---|---|
L1a | 渠道 + 商户订单号 + 方向 | 严格键,命中即配对 |
L1b | 渠道 + 渠道流水号 | 账单里的商户单号被截断时,靠回调回填的流水号救回 |
L1c | 退款单号 / 原单号 + 等额 | 退款行带的是原单号,L1a 配不上它 |
L2a | 渠道 + 方向 + 金额 + 分钟归一 | 只在两侧残差集上跑 |
L2b | 渠道 + 方向 + 金额 + |Δt| ≤ 90s | 时间窗口更窄,仍要求唯一 |
L3 | 更晚账单文件里的严格键 | 跨日清算,报 DATE_SLIP |
两个容易写错的细节:
- 方向必须进键。 收款 100 元和退款 100 元金额相同,配错了钱就反向。
- “不猜”的判据是双向唯一。 一个本地单只看到一个候选行、且那个行也只被这一个本地单看到,才配;任一侧多出候选就标
AMBIGUOUS,并从这个本地单起停止往下走。降级只该发生在证据唯一时。
L3 用清算日而不是时间差判跨日:钱晚一天到账时交易时间通常不变,比 Δt 只会漏。那条行由本轮占走,它自己那天不会再报一次。
4差异归因:下一步找谁
匹配层回答“这是什么差错”,归因层回答“那接下来找谁”。11 类差异各有稳定的处置码,例如:
| 差异 | 处置码 | 下一步 |
|---|---|---|
| 金额不符 | RC-AMOUNT-DIVERGED | 核对本币应结金额与红包/优惠拆分口径,差额方向以账单为准 |
| 账单缺行 | RC-BILL-ROW-MISSING | 向渠道索取该清算日全量账单并核对清算日历 |
| 跨日清算 | RC-SETTLEMENT-DATE-SLIP | 核对渠道清算日历,通常无需资金处置 |
| 重复出账 | RC-DUPLICATE-SETTLEMENT | 核对是否重复清算,要渠道出账凭证 |
| 证据不足 | RC-EVIDENCE-INSUFFICIENT | 补足唯一标识后再配,不许按金额硬猜 |
归因是规则表,不是模型:11 类差错的下一步是稳定的制度,不是推理。接缝留给以后真要按证据分支的时候;闸门不认来源,只认结论是否落在词表内、note 是否为空话、severity 是否在档位里。任一条不满足就整条丢掉,按“无归因”落库——带着一个查不到来源的结论落库,比没有结论更危险。
5用注入的差错做裁判
回归不能靠“跑一遍看起来对”。项目用 scripts/gen_recon_corpus.py 造 30 天 × 2 渠道的语料,10 类差错各注入 20 条,另留一个整日不注入做对照。golden 测试拿引擎结果与已知清单比:
injected=200 found=200 recall=1.0000 fp=0 fn=0
命中分布也是断言不是打印:该有命中的 L1a/L1b/L3 必须 > 0,这份语料里没有出场机会的 L1c/L2a/L2b 必须 == 0,六层相加必须等于配对数。同 seed 两次运行 manifest 字节相同——这条既是可复现声明,也是脚本自己的自检。
6实测与边界
能跑通的是本地闭环:demo_seed.py 播种一天真格式数据,docs/demo.sh 用 curl 从 0 到 13 共 14 段走完“上传账单 → 匹配 → 差异 → 工单 → 结案 → 回调验签/防重放”。数据是合成的,容器与格式取自公开渠道文档。
明确不做的:
- 没有限流。 一个持有效 key 的调用方能把我打满;唯一闸门是请求体体积。
- 单机单文件 SQLite。 写锁是进程内的
asyncio.Lock,没有迁移工具、没有自助开 key 界面、不终结 TLS。 - 验签是类比实现。 微信 V2 结构(字典序规范串 + 对称密钥 HMAC-SHA256),不是 V3 平台证书链 RSA 验签与 AEAD 解密,也不是支付宝 RSA2。
- 没有部署、没有远端。 按“只本地收尾”的决定,不建远端、不 push、不部署;所有“公网可用”类主张都没有证据。
这个项目最值得讲的是顺序和判据:先配对后判定、双向唯一、跨日用清算日、差异必须带下一步。代码量不是重点,能不能把“宁可不猜”写成可执行的约束才是。
想亲手看两种策略的差异,去 支付对账实验;完整边界在项目的 docs/single-node-limitations.md。