账单页没有报错,三个人的金额看起来也都像那么回事,可底部“待收合计”总比应收少两分钱。更难受的是,改一下汇率再撤销,第二个人和第三个人有时会互换一分钱;同一张账单反复打开,快照哈希也会变化。这个问题不是把 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 npm;decimal.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 的值。中间计算不截断,只有结算币种边界才保留两位。

import Decimal from 'decimal.js';

Decimal.set({ precision: 28, rounding: Decimal.ROUND_HALF_UP });

export type SettlementInput = {
  sourceAmount: string; fxRate: string; serviceRate: string;
};

export class SettlementEngine {
  static parse(input: SettlementInput): Decimal {
    [input.sourceAmount, input.fxRate, input.serviceRate].forEach((value) => {
      if (!/^\d+(\.\d+)?$/.test(value)) {
        throw new Error(`INVALID_DECIMAL:${value}`);
      }
    });
    const source = new Decimal(input.sourceAmount);
    const fx = new Decimal(input.fxRate);
    const feeRatio = new Decimal(input.serviceRate).div('100');
    if (source.isNegative() || fx.lte(0) || feeRatio.isNegative()) {
      throw new Error('OUT_OF_RANGE');
    }
    return source.times(fx).times(feeRatio.plus(1)).toDecimalPlaces(
      2, Decimal.ROUND_HALF_UP);
  }
}

这里返回的是 Decimal,不是 Number。页面最终显示时调用 toFixed(2),数据库保存时写入分单位整数或定点字符串,不能中途 toNumber() 再写回。资源释放方面,Decimal 实例是普通不可变对象,不持有原生句柄;真正要控制的是重复创建规模。列表每次重绘不应重新计算全账单,计算只发生在输入快照变化时。

三、余差归集要排序“残差”,不能排序“金额”

很多实现会把剩余分配给金额最大的人。权重简单时看不出问题,但最大金额不一定拥有最大被截断残差。正确做法是先把总金额转换为整数分,再计算每份的精确分值、向下取整值和残差;剩余几分,就按残差从大到小补几次。稳定 ID 是第二排序键,避免同残差时受当前列表顺序影响。

第二段代码解决 2 分的确定性归集。输入权重仍是字符串,最终输出也是两位小数字符串。分配器先验证权重总和大于 0,再以 ROUND_FLOOR 得到基础分;remaining 的上界小于参与人数,因此最后循环很短。

type WeightedMember = { id: string; name: string; weight: string };
type Share = { id: string; name: string; amount: string; cents: number };

export function allocate(total: Decimal, members: WeightedMember[]): Share[] {
  const totalCents = total.times(100).toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
  const weightSum = members.reduce(
    (sum, member) => sum.plus(new Decimal(member.weight)), new Decimal(0));
  if (weightSum.lte(0)) throw new Error('ZERO_WEIGHT');

  const rows = members.map((member) => {
    const exact = totalCents.times(member.weight).div(weightSum);
    const floor = exact.toDecimalPlaces(0, Decimal.ROUND_FLOOR);
    return { member, floor, residue: exact.minus(floor) };
  });
  let remaining = totalCents.minus(
    rows.reduce((sum, row) => sum.plus(row.floor), new Decimal(0))).toNumber();

  const order = [...rows].sort((a, b) => {
    const residueOrder = b.residue.comparedTo(a.residue);
    return residueOrder !== 0 ? residueOrder : a.member.id.localeCompare(b.member.id);
  });
  for (let i = 0; i < remaining; i += 1) order[i].floor = order[i].floor.plus(1);

  return rows.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。

type CalculationResult = {
  taskId: string; hash: string; total: string; shares: Share[]; epoch: number;
};

export class CalculationSnapshot {
  private calcEpoch = 0;

  async calculate(input: SettlementInput, members: WeightedMember[]): Promise<CalculationResult> {
    const mine = ++this.calcEpoch;
    const total = SettlementEngine.parse(input);
    const ordered = [...members].sort((a, b) => a.id.localeCompare(b.id));
    const signature = JSON.stringify({ ...input, members: ordered, algo: 'remainder-v2' });
    const shares = allocate(total, ordered);
    const shareCents = shares.reduce((sum, item) => sum + item.cents, 0);
    if (mine !== this.calcEpoch) throw new Error(`STALE_CALC:${mine}`);
    if (shareCents !== total.times(100).toNumber()) throw new Error('SUM_MISMATCH');
    return { taskId: 'MONEY-1842', hash: Hash.short(signature),
      total: total.toFixed(2), shares, epoch: mine };
  }

  dispose(): void { this.calcEpoch += 1; }
}

Hash.short 在 Demo 里只是稳定内容哈希的封装,哈希不承担安全签名。要落库时还需保存原始输入、算法版本和每份整数分,不能只保存结果哈希。另一个易错点是 JSON 字段顺序:成员必须先按稳定 ID 排序,输入字段也要固定结构,否则数学结果相同,序列化文本却不同。

五、调试日志要同时证明金额守恒和结果稳定

本轮 HiLog 固定五行:task=MONEY-1842 source=USD183.47 fx=7.0934 fee=6.00%、rawCNY=1379.51166388 settled=1379.51、floor=591.21,394.14,394.14 remainder=2c、final=591.22,394.15,394.14 sum=1379.51、replays=20 mismatches=0 hash=calc_v4_9f2c state=SNAPSHOT_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 分归集和 mismatches=0,没有遮挡结算内容。

七、这套算法不能越过的产品边界

第一,舍入规则必须由业务确认。财务产品可能要求银行家舍入、向下取整或按币种最小单位处理,不能因为 ROUND_HALF_UP 常见就写死所有场景。第二,不同币种的小数位不同,日元通常不按两位处理;Demo 固定结算币种 CNY,生产代码应从币种元数据读取精度。第三,退款和负数分摊需要单独定义余差方向,不能直接复用正数循环。

第四,汇率必须携带来源与生效时间,本地快照不能只留一个 7.0934。第五,成员权重变化会生成新快照,旧快照应只读保留,不能覆盖历史结算。第六,Decimal 的全局配置会影响同一运行环境内其他调用;大型项目更适合使用 Decimal.clone() 创建业务专用构造器,避免某个模块修改 precision 后悄悄改变另一个模块的结果。

八、真正要消灭的不是浮点数,而是不确定性

这次两分钱问题最终拆成三层:Decimal 保证输入和中间运算不提前丢精度,最大余数法保证总额守恒且分配可解释,快照代次与稳定排序保证同一输入永远得到同一结果。SNAPSHOT_LOCKED 不是“按钮不能再点”,而是金额、算法版本、参与者顺序、余差归属和生命周期都已经具备可重放证据。对账单产品来说,一分钱很小;对工程一致性来说,它恰好足够暴露整条链路是否可信。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐