
账单页没有报错三个人的金额看起来也都像那么回事可底部“待收合计”总比应收少两分钱。更难受的是改一下汇率再撤销第二个人和第三个人有时会互换一分钱同一张账单反复打开快照哈希也会变化。这个问题不是把toFixed(2)多调用几次就能解决。Demo 叫SplitMint页面是RoundingAuditPage本轮任务号MONEY-1842我用一张美元账单把整个计算链重做了一遍。一、两分钱从哪里丢的先把账算明白原始账单是USD 183.47结算汇率7.0934服务费6.00%。精确乘积为CNY 1379.51166388结算总额按半入规则保留两位得到1379.51。三位参与者权重为3:2:2。如果每份先向下截断到分初稿是591.21 / 394.14 / 394.14合计1379.49余差正好 2 分。最终分配不能把 2 分随手加给第一个人。SplitMint 使用最大余数法比较每个人被截掉的小数残差优先把一分钱补给残差大的份额残差相同则按参与者稳定 ID 排序。最终金额固定为591.22 / 394.15 / 394.14合计1379.51。无论列表显示顺序如何变化只要输入快照不变结果和快照哈希就必须不变。这次选用decimal.js 10.6.0。npm 当前页面标注最新版为 10.6.0项目 README 说明它支持任意精度十进制、实例方法不会修改原值并建议高位数值用字符串传入避免 JavaScript Number 在进入库之前就损失精度。decimal.js npmdecimal.js 官方仓库。这些边界比“库能算小数”更重要如果先执行0.7 0.1再交给 Decimal误差已经发生后面再高精度也救不回来。二、工程里只允许一种金额入口字符串SplitMint把pages/RoundingAuditPage.ets、money/SettlementEngine.ets、money/RemainderAllocator.ets、snapshot/CalculationSnapshot.ets和model/Participant.ets分开。页面不参与数学运算只接收显示字符串引擎不关心 ArkUI 生命周期只接受不可变输入快照层负责代次和哈希保证重复点击、页面重建或异步汇率返回不会提交不同版本。第一段代码解决输入精度和舍入规则。配置使用 28 位有效数字与ROUND_HALF_UP所有外部值先通过正则校验再以字符串创建 Decimal。服务费以比例字符串表示不能把 UI 中的6.00当作已经除以 100 的值。中间计算不截断只有结算币种边界才保留两位。importDecimalfromdecimal.js;Decimal.set({precision:28,rounding:Decimal.ROUND_HALF_UP});exporttypeSettlementInput{sourceAmount:string;fxRate:string;serviceRate:string;};exportclassSettlementEngine{staticparse(input:SettlementInput):Decimal{[input.sourceAmount,input.fxRate,input.serviceRate].forEach((value){if(!/^\d(\.\d)?$/.test(value)){thrownewError(INVALID_DECIMAL:${value});}});constsourcenewDecimal(input.sourceAmount);constfxnewDecimal(input.fxRate);constfeeRationewDecimal(input.serviceRate).div(100);if(source.isNegative()||fx.lte(0)||feeRatio.isNegative()){thrownewError(OUT_OF_RANGE);}returnsource.times(fx).times(feeRatio.plus(1)).toDecimalPlaces(2,Decimal.ROUND_HALF_UP);}}这里返回的是 Decimal不是 Number。页面最终显示时调用toFixed(2)数据库保存时写入分单位整数或定点字符串不能中途toNumber()再写回。资源释放方面Decimal 实例是普通不可变对象不持有原生句柄真正要控制的是重复创建规模。列表每次重绘不应重新计算全账单计算只发生在输入快照变化时。三、余差归集要排序“残差”不能排序“金额”很多实现会把剩余分配给金额最大的人。权重简单时看不出问题但最大金额不一定拥有最大被截断残差。正确做法是先把总金额转换为整数分再计算每份的精确分值、向下取整值和残差剩余几分就按残差从大到小补几次。稳定 ID 是第二排序键避免同残差时受当前列表顺序影响。第二段代码解决 2 分的确定性归集。输入权重仍是字符串最终输出也是两位小数字符串。分配器先验证权重总和大于 0再以ROUND_FLOOR得到基础分remaining的上界小于参与人数因此最后循环很短。typeWeightedMember{id:string;name:string;weight:string};typeShare{id:string;name:string;amount:string;cents:number};exportfunctionallocate(total:Decimal,members:WeightedMember[]):Share[]{consttotalCentstotal.times(100).toDecimalPlaces(0,Decimal.ROUND_HALF_UP);constweightSummembers.reduce((sum,member)sum.plus(newDecimal(member.weight)),newDecimal(0));if(weightSum.lte(0))thrownewError(ZERO_WEIGHT);constrowsmembers.map((member){constexacttotalCents.times(member.weight).div(weightSum);constfloorexact.toDecimalPlaces(0,Decimal.ROUND_FLOOR);return{member,floor,residue:exact.minus(floor)};});letremainingtotalCents.minus(rows.reduce((sum,row)sum.plus(row.floor),newDecimal(0))).toNumber();constorder[...rows].sort((a,b){constresidueOrderb.residue.comparedTo(a.residue);returnresidueOrder!0?residueOrder:a.member.id.localeCompare(b.member.id);});for(leti0;iremaining;i1)order[i].floororder[i].floor.plus(1);returnrows.map((row)({id:row.member.id,name:row.member.name,cents:row.floor.toNumber(),amount:row.floor.div(100).toFixed(2)}));}这里只把最终整数分转换为 Number因为金额上限在产品规则内且整数分不会超过安全整数如果是企业级大额结算cents也应保留字符串或 Decimal。localeCompare的参与者 ID 必须是稳定 ASCII 业务键不应使用会随语言变化的姓名。成员增删时整个分配重新计算是预期行为不能试图在旧结果上修补否则余差排序会被上一次状态污染。四、快照幂等不是缓存命中而是提交资格页面快速改汇率时会启动多次计算。旧计算虽然耗时不长也可能在动画和数据保存之间晚返回。SplitMint 为每次输入变更增加calcEpoch输出携带标准化快照金额、汇率、服务费、按 ID 排序的权重和算法版本remainder-v2。同一快照重复计算 20 次结果哈希应始终为calc_v4_9f2c差异数为 0。第三段代码解决重复点击与页面重建。先标准化输入再生成签名只有代次仍是当前值且各份整数分之和等于结算总分才把结果提交给 ArkUI。离开页面时dispose()提升代次旧 Promise 即使返回也只能记录STALE_CALC_DROPPED。typeCalculationResult{taskId:string;hash:string;total:string;shares:Share[];epoch:number;};exportclassCalculationSnapshot{privatecalcEpoch0;asynccalculate(input:SettlementInput,members:WeightedMember[]):PromiseCalculationResult{constminethis.calcEpoch;consttotalSettlementEngine.parse(input);constordered[...members].sort((a,b)a.id.localeCompare(b.id));constsignatureJSON.stringify({...input,members:ordered,algo:remainder-v2});constsharesallocate(total,ordered);constshareCentsshares.reduce((sum,item)sumitem.cents,0);if(mine!this.calcEpoch)thrownewError(STALE_CALC:${mine});if(shareCents!total.times(100).toNumber())thrownewError(SUM_MISMATCH);return{taskId:MONEY-1842,hash:Hash.short(signature),total:total.toFixed(2),shares,epoch:mine};}dispose():void{this.calcEpoch1;}}Hash.short在 Demo 里只是稳定内容哈希的封装哈希不承担安全签名。要落库时还需保存原始输入、算法版本和每份整数分不能只保存结果哈希。另一个易错点是 JSON 字段顺序成员必须先按稳定 ID 排序输入字段也要固定结构否则数学结果相同序列化文本却不同。五、调试日志要同时证明金额守恒和结果稳定本轮 HiLog 固定五行taskMONEY-1842 sourceUSD183.47 fx7.0934 fee6.00%、rawCNY1379.51166388 settled1379.51、floor591.21,394.14,394.14 remainder2c、final591.22,394.15,394.14 sum1379.51、replays20 mismatches0 hashcalc_v4_9f2c stateSNAPSHOT_LOCKED。第一组证明换汇边界第二组证明余差来源第三组证明归集结果最后一组才证明重复计算没有漂移。IDE 图左侧是SplitMint的计算、归集与快照目录中间展示RemainderAllocator.ets的残差排序右侧模拟器显示同一美元金额、汇率、服务费和三人结果底部逐行给出上述日志。少量红色标注只指出字符串输入、2 分余差和 20 次零差异不把页面做成会计报表拼图。六、运行结果一分钱也必须有确定归属18:42 的RoundingAuditPage状态为SNAPSHOT_LOCKED。输入USD 183.47、汇率7.0934、服务费6.00%结算CNY 1379.51权重3:2:2林舟591.22、周宁394.15、陈屿394.14合计与结算金额完全一致。余差 2 分已经归集重复复算 20 次、差异 0快照哈希calc_v4_9f2c。手机页保留“重新计算”和“锁定快照”两个按钮顶部完整显示时间、5G、Wi‑Fi、信号、电量图标和 76% 数字。最重要的不是大号总额而是原始金额、汇率、服务费、权重、每人金额、余差与哈希能在一屏互相核对。两处红色批注分别指向 2 分归集和mismatches0没有遮挡结算内容。七、这套算法不能越过的产品边界第一舍入规则必须由业务确认。财务产品可能要求银行家舍入、向下取整或按币种最小单位处理不能因为ROUND_HALF_UP常见就写死所有场景。第二不同币种的小数位不同日元通常不按两位处理Demo 固定结算币种 CNY生产代码应从币种元数据读取精度。第三退款和负数分摊需要单独定义余差方向不能直接复用正数循环。第四汇率必须携带来源与生效时间本地快照不能只留一个7.0934。第五成员权重变化会生成新快照旧快照应只读保留不能覆盖历史结算。第六Decimal 的全局配置会影响同一运行环境内其他调用大型项目更适合使用Decimal.clone()创建业务专用构造器避免某个模块修改 precision 后悄悄改变另一个模块的结果。八、真正要消灭的不是浮点数而是不确定性这次两分钱问题最终拆成三层Decimal 保证输入和中间运算不提前丢精度最大余数法保证总额守恒且分配可解释快照代次与稳定排序保证同一输入永远得到同一结果。SNAPSHOT_LOCKED不是“按钮不能再点”而是金额、算法版本、参与者顺序、余差归属和生命周期都已经具备可重放证据。对账单产品来说一分钱很小对工程一致性来说它恰好足够暴露整条链路是否可信。