多币种分账中的六个金额缺陷
2026-08-21 的一次审计发现了六个测试一直显示通过的币种缺陷,根源是文档里一句早已悄然不再成立的话。
文档里一句过时的话,招致了六个金额缺陷。2026-08-21 对 Dimesum 的一次多币种审计,在我们测试套件一直显示通过的代码中发现了它们,其中包括一个把 ¥6,000 欠款渲染成 -₹60.00 的认领页面。那句话是“分组按 INR 固定”。它在 W7 之前为真,一旦上线便为假,而一周之后仍留在 FOLLOWUPS.md 里。
一句过时的话比没有文档更危险。后来的审计会相信它,并跳过它所覆盖的路径,于是一行错误的话造成的破坏,是沉默永远做不到的。空白的文档会把你引向源码。错误的文档会把你引向完全不同的地方。
一条已经成真的待办,比一条仍未完成的更糟
Dimesum 把推迟的工作记在 FOLLOWUPS.md 里,每个决定一行,并附上推迟的原因。条目 A17 写道:分组按 INR 固定,一次出国旅行无法按其发生时的币种记录,而修复要等一个创始人的决定。W7 还是上线了多币种:一笔支出可以是任意币种,余额由迁移 ledger/00005 以 (group_id, member_id, currency) 为键。没有人划掉那一行。
下一次审计读了 A17,断定这块尚未构建,于是从未打开结算、认领或落地页的代码。三条忽略币种的金额路径,就凭一句话继续上线。并没有什么难题挡在路上。app/amount.py 的文档字符串里写着同样的说法,于是读者被告知了两次。
在关闭某条待办的那次提交里,就把它划掉。一条描述着已不再那样工作的代码的记录,会把你从本该阅读的文件引开,其代价比什么都不说更大。
同一个硬编码的 100 出现在三个不同的文件里
这里的金额是 int64 的最小单位加上一个 ISO 4217 代码,浮点数从不碰它。整数天生精确,因此唯一还有风险的运算,就是主单位与最小单位之间的换算。最小单位并不总是百分之一。JPY 根本没有最小单位,KWD 有三位小数,而每一个硬编码的 100,都是在赌用户没有出过国。
Python 边车中的 app/amount.py 是第一处被发现的地方,它在审计之前就作为地雷被清除,而非一个正在生效的缺陷。它会把从一句话里读到的任何数字乘以 100,于是一张 ¥1,200 的收据变成了 120,000 个最小单位。那读起来就是 ¥120,000。在一种没有最小单位的货币上,这个常量会把某人的账单放大一百倍,如今这个函数改为查找请求币种的指数。
审计在另一处发现了同一个常量,那正是一个人决定是否接受某笔余额的页面。位于 /m/{token} 认领落地页背后的 formatMinor,会除以 100,并在前面粘上一个卢比符号。一笔 ¥6,000 的欠款被打印成 -₹60.00:符号错了,金额只剩百分之一。它的 JSON 同胞 /v1/claims 也随之失效,把一个成员按币种分列的多行压平成一行,只保留了映射最后写入的那个币种。
第三个文件是 internal/ingestion/store.go,它是 W8 在扩展重复检测时发现的,而非审计。它“正负一卢比”的容差就是常量 100,也就是以 paise 计的一卢比,在根本没有最小单位的 JPY 上却成了正负 ¥100 的区间。如今这三处都读取指数。
直接比较裸整数,会让一日元看起来像一卢比
Dimesum 的去重层会把一条新录入与近期支出做比对,好让导入的收据不会对某人手工输入的一笔订单重复计费。那条容差区间旁边的查询完全没有币种过滤。于是 ¥1,000 匹配上了 ₹1,000,一次导入便提出要顶替一笔与它毫无关系的支出。自 ledger/00005 起分组就已是多币种,因此这种情形是可达的,而非理论上的。
裸的最小单位在不同币种之间不可比较,容差也一样。如今这个投影会按币种过滤,并以所比较币种的一个主单位来推导它的区间。
两个引擎都在回答“谁该付给谁”,而第二个忽略了币种
代价最大的缺陷是结构性的,而非算术性的。结算写入路径是在 internal/platform/simplify 上做规划的,那是第二个最小现金流引擎,它的 Balance 结构体没有币种字段。按币种的零和也让扁平求和为零,于是一笔 ¥300,000 的欠款和一笔 ₹500 的欠款相互抵消为零,没有任何守卫拒绝这份方案。这份方案随后把一个日元债权人和一个卢比债务人配到了一起。
写入让情况更糟。该服务在每一笔结算上都盖上 Currency: g.DefaultCurrency,于是一笔 ¥300,000 的欠款授权了一条 INR 的分录,而那笔日元欠款根本无法被清偿。
simplify 被删除,/settle-plan 被停用。如今 internal/platform/settle 是唯一的引擎:它把币种划分为可换算和不可换算两类,对净余额只换算一次,并让每一种不可换算的币种以其自身的面额来分配。一笔结算会声明它所清偿的币种,而一旦某个分组持有不止一种币种,这个字段就是必填的。
上限为零被当成了没有上限
超付守卫会拒绝一笔大于其所结算欠款的付款。该守卫写的是 outstanding > 0 && amount > outstanding,于是上限为零时会完全跳过这个比较。有人针对一笔并不存在的欠款记录一笔付款,正是这条规则存在的唯一意义,而这恰恰就是顺利溜过去的那种情形。
修复的是类型,而非条件。OutstandingMinor 现在是 *int64,于是“没人算过这个”就无法与“答案是零”写成同一个样子。Nil 会跳过检查,表示确实未知;任何有账本访问权限的调用方都会传入一个真实的数字。
为什么一个全绿的测试套件什么也证明不了
这些路径里的每个测试夹具都用 INR。在单一币种的测试下,一个忽略币种的比较是看不见的,因为只有一种币种时根本没有什么可混淆的。这些套件不是薄弱,而是狭窄,而本可以拓宽它们的那次审计,被 A17 打发走了。
端到端的账本校验器也有同样的盲区,而这才是值得记住的部分。这个校验器按成员汇总记账,却没有按币种分组,于是它对一个健康的双币种分组虚报警报,却在一个双重出错的分组上求和为零。假阴性才是危险的方向。建立在与代码相同假设之上的校验器,永远会认同代码。
| 缺陷 | 位置 | 一个 JPY 分组得到了什么 | 修复 |
|---|---|---|---|
一个没有币种的 Balance 结构体 | platform/simplify | 一个日元债权人和一个卢比债务人被配到一起 | simplify 被删除;settle 按币种分区 |
| 每一笔结算都被盖上分组默认币种 | 结算服务 | 一笔 ¥300,000 的欠款授权了一条 INR 分录 | 一笔结算会声明它所清偿的币种 |
超付守卫里的 outstanding > 0 | 结算服务 | 上限为零变成了完全没有上限 | *int64:nil 表示未知,zero 表示零 |
| 除以 100 并配上卢比符号 | 网关 formatMinor | 一笔 ¥6,000 的欠款被打印成 -₹60.00 | 符号和小数位取自币种 |
| 按币种的余额被压平成一行 | /v1/claims | 映射最后写入的那个币种 | 每种币种一个条目,与 GET /balances 一致 |
| 记账按成员汇总,币种被丢弃 | 端到端账本校验器 | 一个双重出错的分组被报告为收支平衡 | 按成员和币种分组 |
在你的金额代码里 grep 那个字面量 100
搜一搜那个常量,也搜一搜每一处把两笔金额并排比较、旁边却没有币种的地方。然后去修那件更便宜的事,也就是能防住下一个六个缺陷的那件:在关闭某条待办的那次提交里就把它划掉。还有,删掉一个重复的引擎,而不是去修它。对“谁该付给谁”存在两个答案,正是其中一个一直错下去的原因。
常见问题
什么是多币种分账?
多币种分账会以每笔支出发生时的币种来记录该笔支出,并按币种分别保留余额,而不是把所有金额换算成同一种货币。Dimesum 以分组、成员和币种为键来保存余额,因此一笔日元欠款和一笔卢比欠款不会合并成一个数字。换算只是用于生成结算方案的一种视图,从不作为存储金额。
为什么在金额代码里硬编码 100 很危险?
硬编码的 100 假定每种货币都有两位小数,而有些货币并非如此。JPY 没有最小单位,所以乘以 100 会把一张 ¥1,200 的收据变成 120,000 个最小单位,这是百倍的放大,而不是取整误差。KWD 有三位小数,所以同一个常量会差十倍。应改为读取 ISO 4217 的指数。
如何避免两个结算引擎给出不一致的结果?
与其去调和两个引擎,不如删掉其中一个,因为对“谁该付给谁”存在第二个答案,正是第一个答案一直错下去的原因。Dimesum 曾让 simplify 和 settle 并行运行,直到一次审计发现前者的余额类型上没有币种,从而让一份方案把一个日元债权人和一个卢比债务人配到了一起。simplify 被移除,/settle-plan 也被停用,而不是打补丁。
为什么测试套件在六个金额缺陷下仍然全部通过?
测试套件之所以一直通过,是因为受影响路径中的每个测试夹具都只用单一币种,而在只有一种币种时,一个忽略币种的比较不可能失败。端到端的账本校验器也抱有同样的假设:它按成员汇总记账,却没有按币种分组,于是把一个双重出错的分组报告为收支平衡。建立在代码自身假设之上的校验器,总会认同代码。
功能上线后该如何处理遗留的待办条目?
在关闭一项工作的那次提交里,就顺手划掉描述这项工作的待办条目。一条悄然成真的待办比一条仍未完成的更糟,因为它会把后来的读者引离它曾经描述的代码。Dimesum 在多币种上线后,仍让一条“分组按 INR 固定”的条目留存了一周,结果下一次审计就凭这句话跳过了三条金额路径。
热门文章
- 只增账本让分摊余额保持精确阅读约 6 分钟
- 为什么编辑共享支出必须重述分摊阅读约 5 分钟
- 结清账目:用更少的转账清理团体开销阅读约 6 分钟
- 如何分摊有一道菜没有共享的分项账单阅读约 6 分钟
- 合租房租分摊指南:如何和室友公平分房租阅读约 6 分钟