HarmonyOS 7 + decimal.js-ArkTS:金额计算的舍入合同与 JSON 往返精度门禁【鸿蒙心迹】
金额问题很少在普通整数上暴露。它通常藏在边界值里:商品原始单价 2.675,运费 1.005,优惠 0.10。如果先转成 JavaScript Number,再分别 toFixed(2),演示环境会得到 2.67 与 1.00;而业务要求的“每项按 ROUND_HALF_UP 保留两位”应是 2.68 与 1.01,订单应付从错误的 3.57 变成 3.59。
本文不把 decimal.js 当成“换一个计算库就结束”。示例项目为 MoneyFence,页面为 RoundingAuditPage,检查 ID 是 PRICE-CHECK-0144,订单号 ORDER-2401,策略版本 money-v3,币种 CNY,scale=2,rounding=ROUND_HALF_UP,舍入阶段为 LINE_ITEM。核心目标是让输入、计算、展示、JSON 和回读都遵守同一份合同。

一、问题不是 0.1 + 0.2,而是何时舍入
二进制浮点无法精确表示很多十进制小数,这是基础事实;但金额差异还取决于“在哪一步舍入”。先汇总再舍入、每行舍入后汇总、税额单独舍入,结果可能不同。只说“统一保留两位”没有说明计算阶段,团队仍会各自实现。
MoneyFence 明确采用行项目舍入:商品行 2.675 按两位 HALF_UP 得 2.68,运费 1.005 得 1.01,优惠 0.10 保持两位,最终 2.68 + 1.01 - 0.10 = 3.59。这个规则只是示例合同,不代表所有财务场景;税务、支付与结算必须由业务和合规共同确定。
状态机采用 LOADED → NORMALIZED → CALCULATED → ROUNDTRIP_VERIFIED。任何字段出现指数形式、空字符串、超长小数或非有限值都在 NORMALIZED 前拒绝,不让“解析成功但语义未知”的金额进入计算。
二、第一道门禁:金额从字符串进入
如果接口把金额作为 JSON number 传来,精度风险在到达 decimal.js 前已经发生。示例合同要求金额字段是规范十进制字符串,数量是受限整数。允许 0、0.10、2.675,拒绝前导加号、科学计数法、千分位和多余空格。
这段代码解决什么问题:对外部金额字符串做语法、长度和小数位上限校验,并保持原始十进制语义进入 Decimal。
import Decimal from 'decimal.js';
const MONEY_PATTERN = /^(0|[1-9]\d{0,9})(\.\d{1,6})?$/;
function parseMoney(raw: string, field: string): Decimal {
if (raw !== raw.trim() || !MONEY_PATTERN.test(raw)) {
throw new Error(`MONEY_FORMAT:${field}`);
}
const value = new Decimal(raw);
if (!value.isFinite() || value.isNegative()) {
throw new Error(`MONEY_RANGE:${field}`);
}
return value;
}
function parseQuantity(raw: number): number {
if (!Number.isSafeInteger(raw) || raw < 1 || raw > 999) {
throw new Error('QUANTITY_RANGE');
}
return raw;
}
正则限制的是业务输入,不是 decimal.js 能力边界。库可以表达更大、更长的数,但订单页面没有必要接受无限位数。易错点是先 Number(raw) 再 new Decimal(number),这会把已经近似的二进制值带入高精度对象;应始终用经过校验的字符串构造。若服务端允许负优惠,应为优惠字段单独定义范围,而不是放宽所有金额。
三、舍入策略要作为数据,而不是散落的常量
很多项目在各处调用 toFixed(2),后来切换银行家舍入或改成订单末尾舍入,只能全局搜索。示例把币种、scale、舍入模式、阶段和版本放进 MoneyPolicy,每份审计结果都记录策略摘要。
这段代码解决什么问题:将 CNY 两位、ROUND_HALF_UP 和 LINE_ITEM 阶段固化成可版本化合同,并提供唯一舍入入口。
interface MoneyPolicy {
version: string;
currency: 'CNY';
scale: number;
rounding: number;
stage: 'LINE_ITEM' | 'ORDER_TOTAL';
}
const POLICY: MoneyPolicy = {
version: 'money-v3',
currency: 'CNY',
scale: 2,
rounding: Decimal.ROUND_HALF_UP,
stage: 'LINE_ITEM'
};
function quantize(value: Decimal, policy: MoneyPolicy): Decimal {
return value.toDecimalPlaces(policy.scale, policy.rounding);
}
function formatMoney(value: Decimal, policy: MoneyPolicy): string {
return quantize(value, policy).toFixed(policy.scale);
}
toDecimalPlaces 负责按指定模式得到新 Decimal,toFixed 只负责固定两位输出。不能把 toFixed 返回的字符串再转 Number,否则尾随零会丢失,JSON 中的金额形态也重新变得不稳定。策略版本进入日志和持久化快照,回读旧订单时按当时策略解释,不用当前版本重新计算历史结果。
四、计算图要明确每个舍入点
订单 ORDER-2401 有一件商品,原始单价 2.675,数量 1;运费 1.005;优惠 0.10。LINE_ITEM 策略要求商品扩展金额与运费分别舍入,优惠按输入精度规范为两位,最后只做精确加减。
这段代码解决什么问题:用 Decimal 构造可审计计算图,并同时输出原始值、舍入值和最终金额。
interface PriceInput {
unitPrice: string;
quantity: number;
shipping: string;
coupon: string;
}
interface PriceResult {
itemRaw: string;
itemRounded: string;
shippingRounded: string;
couponRounded: string;
payable: string;
}
function calculate(input: PriceInput): PriceResult {
const unit = parseMoney(input.unitPrice, 'unitPrice');
const qty = parseQuantity(input.quantity);
const itemRaw = unit.times(qty);
const item = quantize(itemRaw, POLICY);
const shipping = quantize(parseMoney(input.shipping, 'shipping'), POLICY);
const coupon = quantize(parseMoney(input.coupon, 'coupon'), POLICY);
const payable = item.plus(shipping).minus(coupon);
if (payable.isNegative()) throw new Error('PAYABLE_NEGATIVE');
return {
itemRaw: itemRaw.toString(), itemRounded: item.toFixed(2),
shippingRounded: shipping.toFixed(2), couponRounded: coupon.toFixed(2),
payable: payable.toFixed(2)
};
}
状态从 NORMALIZED 到 CALCULATED 时一次性提交 PriceResult,页面不再重复计算。若 quantity 改为 2,必须创建新 revision,不能只改显示数量。实际项目还要明确折扣与税的先后顺序、是否允许负行项目、退款如何取整;decimal.js 只能按指令计算,不能替团队决定财务规则。

DevEco Studio 演示图左侧是 MoneyFence 工程,中间显示 parseMoney、POLICY 和 calculate,右侧模拟器显示 2.675→2.68、1.005→1.01、优惠 0.10、应付 3.59,底部 HiLog 为 check=PRICE-CHECK-0144 policy=money-v3 status=CALCULATED。红色标注只解释舍入点和字符串入口。
五、JSON 往返是第二条精度边界
内存中使用 Decimal 不代表持久化安全。若 JSON.stringify 前把金额转成 Number,回读仍可能丢失十进制形态;若只存 3.59,又无法复查原始 2.675 如何得到 2.68。示例保存输入字符串、各舍入节点、策略版本和最终值,再按同一合同回读校验。
这段代码解决什么问题:把 Decimal 结果序列化为字符串证据,并在 JSON 回读后重算和比对摘要。
interface PriceEvidence {
checkId: string;
orderId: string;
policyVersion: string;
input: PriceInput;
result: PriceResult;
}
function serializeEvidence(e: PriceEvidence): string {
return JSON.stringify(e);
}
function verifyRoundTrip(json: string): PriceEvidence {
const parsed = JSON.parse(json) as PriceEvidence;
if (parsed.policyVersion !== POLICY.version) {
throw new Error('POLICY_VERSION_MISMATCH');
}
const recalculated = calculate(parsed.input);
const expected = JSON.stringify(recalculated);
const actual = JSON.stringify(parsed.result);
if (expected !== actual) throw new Error('ROUNDTRIP_MISMATCH');
return parsed;
}
这里比较的是字段顺序稳定的项目自建对象,不应拿任意外部 JSON 字符串直接比较。生产中更适合按字段比对或生成规范摘要。回读验证成功后状态进入 ROUNDTRIP_VERIFIED;失败时保留原始证据并阻止提交,不能用“重新算一遍的新结果”静默覆盖旧值,否则恰好抹掉需要调查的差异。
六、两分钱差异如何在页面上被解释

03 是 9:16 纯手机运行页:19:36、Wi-Fi、5G、74% 电量,检查 PRICE-CHECK-0144,策略 money-v3,商品 2.675 → 2.68,运费 1.005 → 1.01,优惠 0.10,最终 CNY 3.59,状态 CALCULATED。页面同时显示 Number 对照值 3.57,但用红色说明“仅用于发现风险,不参与提交”。
不要把对照实现留在正式支付路径。它只适合诊断页和单元测试,目的是告诉开发者差异来自两个边界值各少 0.01,而不是后端随机改价。面向普通用户只显示业务确认的 3.59 和必要明细,不展示库名、舍入常量或二进制误差术语。

04 是 JSON 往返详情页:19:37、73% 电量,状态 ROUNDTRIP_VERIFIED,输入字段全部是字符串,policyVersion=money-v3,scale=2,rounding=ROUND_HALF_UP,stage=LINE_ITEM,重算值与保存值均为 3.59,字段比对 5/5。它与运行页明显不同,用于解释“计算正确后如何保证持久化仍正确”。
七、失败要按输入、策略和往返分类
输入类包括非法格式、超长整数、超过六位小数、负值和不安全数量;策略类包括版本缺失、未知舍入模式和不支持的币种;往返类包括金额变成 number、尾随零丢失、字段被修改和旧策略误用。三类错误的处理不同,不能统一弹“计算失败”。
输入错误应定位字段并阻止计算;策略错误属于配置或版本问题,应阻止整单;往返错误说明存储、网络或迁移改变了证据,必须保留原 JSON。日志建议写:MoneyFence check=PRICE-CHECK-0144 stage=ROUNDTRIP field=shipping expected=1.01 actual=1.00 policy=money-v3,不记录用户名、地址或支付凭证。
还要考虑空值和缺省。运费不存在不等于字符串空;合同可以明确用 "0.00",也可以用可空字段,但解析层必须区分。优惠字段缺失时是否默认为零,也应写入版本策略。隐式默认最危险,因为回读时无法知道当初字段是真正为零,还是数据丢失。
八、黄金向量比随机测试更能守住边界
单元测试至少包含 1.005→1.01、2.675→2.68、0.004→0.00、0.005→0.01、最大允许整数、六位小数、非法科学计数法和负值。再加入 LINE_ITEM 与 ORDER_TOTAL 得出不同结果的组合,证明策略阶段确实生效。
黄金向量要保存输入字符串、策略版本和每个节点输出,不只保存最终总额。若升级 decimal.js、ArkTS 编译器或序列化层,重新运行全部向量;任何变化都需要明确审核,不能以“只有一分钱”为理由更新基线。财务差异通常成批出现,一分钱乘以订单量就不再是小数。
属性测试也有价值:同一 Decimal 序列化再回读应相等;加零不改变结果;数量为整数时 unit.times(qty) 与重复相加一致;支付金额永不为负。随机生成仍必须限制在业务域,避免用超大数测试出库能力却与产品无关。
九、依赖接入也要有边界
decimal.js 是第三方任意精度十进制库,项目应锁定明确版本、保留许可证并通过 ohpm/npm 兼容流程验证 ArkTS 构建。不要依赖全局 Decimal.set 被某个模块永久保持:另一个库或测试修改全局配置,会改变没有显式传 rounding 的调用。本文关键舍入都显式传入 scale 和 mode,就是为了降低隐藏全局状态。
升级依赖时,要先跑黄金向量,再看包体与类型声明变化。若团队封装 Money 类型,应禁止业务层直接导入 Decimal,把创建、舍入、格式化和序列化集中在一个模块。这样未来更换库或引入服务端统一计算时,页面代码不需要理解底层精度细节。
性能也不应被夸大。订单页几十个 Decimal 运算通常不是瓶颈,但大批量报表需要单独评估。不要为了“更快”把中间值转回 Number;若确实需要批处理,可以在保持字符串合同的前提下移到 Worker/TaskPool,并重新验证对象传递与序列化边界。
十、最终落地判断
1. 展示格式与计算值必须分层
3.59 是计算证据,¥3.59 是本地化展示,两者不要存进同一个字段。货币符号、千分位和语言环境只属于视图层;持久化继续保存规范十进制字符串与 currency。若把 ¥3.59 回传服务端,解析就依赖语言环境;若把展示格式用于下一次计算,还可能把逗号或非断行空格带进金额。
页面格式化也不能反向改变值。列表为了紧凑显示两位,小票可能显示币种代码,语音播报又是另一种形式,它们都从同一个 MoneyView 读取。测试要分别验证计算与展示:计算断言 payable === "3.59",展示断言当前区域设置的文本;不要用截图测试替代金额断言。
2. LINE_ITEM 与 ORDER_TOTAL 需要真实反例
假设两行商品各为 0.005。LINE_ITEM 策略会各自舍入到 0.01,合计 0.02;ORDER_TOTAL 先求和得 0.010,再舍入仍是 0.01。两者都数学自洽,但业务结果差一分。团队如果没有记录 stage,只保存 0.02,以后无法判断差异是 bug 还是策略。
因此 policyVersion 不能只代表库版本,它要标识业务顺序。money-v3 包含币种、scale、rounding 和 stage;更改任何一项都应生成新版本。旧订单继续按 v3 回读,新订单使用 v4。不能发布后把 v3 常量原地修改,否则历史重算会得出不同金额,ROUNDTRIP_VERIFIED 也失去意义。
3. 服务端合同必须和客户端使用同一术语
客户端用 ROUND_HALF_UP,服务端如果写“普通四舍五入”仍然不够。要约定输入是字符串、最大位数、舍入阶段、负数规则、税费顺序和输出 scale。服务端返回 3.590 时,数值相等但 scale 不同;合同可以规范为固定两位后再比较,也可以保留服务端原串并验证数值与格式两项。
差异发生时,客户端不应该偷偷采用本地结果完成支付。价格权威归属必须由业务定义:通常服务端报价是权威,客户端计算用于预览和一致性审计。若本地 3.59 与服务端 3.57 不同,页面应刷新报价或阻止提交,并记录策略摘要;不能选一个对用户更有利的数字继续。
4. JSON Schema 也应把金额限定为字符串
接口文档里写 price: string 还不够,Schema 可以附加 pattern、最大长度和示例,生成代码时阻止 number 混入。对于内部事件、缓存和埋点也要沿用同一类型,不能网络层是字符串,状态仓库又为了方便改成 number。精度事故常发生在第二次转换,而不是第一次解析。
日志模板同样如此。结构化日志把 payable 存字符串,另存 scale=2 与 currency=CNY;分析平台若自动推断数字类型,可能再度改变形态。需要做一次采集到查询端的往返测试,确认 2.675 和 3.590 不被改写。
5. 策略迁移要双算,但不能双写
准备从 LINE_ITEM 切到 ORDER_TOTAL 时,可以在灰度期对同一输入同时计算 v3 与 v4,把差异作为诊断字段;真正订单仍只采用一个权威版本。若同时把两种结果写进业务字段,下游不知道该读哪个。双算用于观察分布,单写用于保持事实唯一。
迁移报告应按差异绝对值、订单类型和边界输入分桶。大部分订单相同不代表策略可直接切换,要重点看一分钱、两分钱和负调整项。确认后,新订单标记 v4,旧订单不回填。需要重算历史报表时,也应显式选择按原策略还是按新策略模拟。
6. 并发编辑要带输入 revision
数量、优惠券和运费可能由不同异步来源更新。页面先计算 quantity=1,随后优惠券回调更新 coupon,再有旧运费请求返回。如果每个回调都直接改 payable,会把不同版本输入拼在一起。MoneyFence 应把完整 PriceInput 和 revision 一起送入 calculate,结果提交前再确认 revision 未变化。
这种代次门禁与十进制精度是两个独立维度:decimal.js 保证同一输入的计算精确,revision 保证提交的确实是当前输入。只有前者,仍可能精确地算错一份过期订单。诊断页需要同时显示 inputRevision 和 policyVersion,方便区分计算规则差异与异步竞态。
7. 退款与负数不能沿用正价正则
示例 parseMoney 拒绝负值,适合正向订单输入;退款、冲正和会计分录可能需要负数。不要为了复用一个函数就全面允许负号,而应建立 parsePositiveMoney、parseAdjustment 等语义入口,并给每类设置独立范围。这样商品单价永远非负,退款金额却可按业务规则表达。
负数舍入还涉及方向语义。HALF_UP 对正负边界都有明确定义,但“向上”在自然语言里可能被误解为数值增大或绝对值增大。代码和合同直接使用库常量名称与示例向量,例如 -1.005 的期望值,避免产品、服务端和客户端各自解释。
8. 依赖封装要阻止 Decimal 泄漏到页面
如果组件状态直接保存 Decimal 实例,跨线程、序列化和热重载时都会增加不必要的边界。更稳妥的方式是金额领域层内部使用 Decimal,对外只暴露规范字符串和不可变结果。页面不能调用 plus,也不能自行选择 rounding;所有计算都通过 MoneyService。
封装还便于依赖升级。decimal.js 的类型声明、模块导入方式或构建配置变化时,只改领域模块。黄金向量覆盖该模块的公共函数,页面测试只关心结果与错误码。若第三方库不可用,替换实现也不会要求修改每个页面。
9. 发布包要保留依赖与策略证据
构建产物应记录 decimal.js 的解析版本、锁文件摘要和 money-v3 策略摘要。线上出现差异时,单看应用版本号无法判断某次依赖解析是否变化。策略摘要可以由固定字段规范化后哈希,不包含订单数据;依赖摘要来自锁文件,不在运行时联网获取。
这不是要把财务规则交给第三方库。恰恰相反,库版本只是计算引擎,policy 才是业务合同。两者分别记录,才能回答“规则没变但库变了”或“库没变但舍入阶段变了”。混成一个版本号,会让回滚判断失去依据。
10. 发布前从 3.59 逆向追一次
验收人员看到 04 的 ROUNDTRIP_VERIFIED 后,应能追到保存 JSON 中的 "3.59",再追到 itemRounded="2.68"、shippingRounded="1.01"、couponRounded="0.10",再追到原始字符串 "2.675" 与 "1.005",最后确认 policyVersion=money-v3。任何一步出现 number,都意味着门禁有缺口。
还要故意把 JSON 中 shipping 改为 1.00,验证回读进入 ROUNDTRIP_MISMATCH 且不覆盖原证据;把 policyVersion 改为 v2,验证先报版本不匹配;把 input 改成 1e-3,验证在解析层拒绝。只有负路径也稳定,VERIFIED 才不是一张只会显示绿色的装饰页。
这套方案的关键不是 new Decimal(),而是五个一致:输入用字符串;舍入点有明确阶段;模式与 scale 版本化;持久化仍用字符串;回读必须重算验证。任一环回到 Number,精度门禁就不完整。
对于 ORDER-2401,诊断链应完整回答:原始 2.675 为什么变成 2.68,1.005 为什么变成 1.01,优惠在哪一步扣减,最终为什么是 3.59,JSON 回读后如何确认没有变成 3.57。能够回答这些问题,金额计算才从“看起来对”变成可审计的工程合同。
最后还要约束人工修复流程。线上发现金额差异时,运营工具不能直接编辑 payable 而不更新输入和策略证据;修复应创建新的 adjustment、记录原因、操作者和原订单策略,并再次通过同一舍入与往返门禁。直接覆盖 3.57 为 3.59 虽然让页面数字正确,却破坏了计算链,也让后续退款、对账和审计无法重现。精度治理真正保护的不是某个小数,而是每次金额变化都有来源、有规则、有版本和可验证结果。
在代码评审中,也应把任何 Number(price)、一元加号、隐式减法和金额字段的 parseFloat 视为需要说明的危险点。不是说这些语法永远不能出现,而是它们不能跨入金额领域层;若只用于排序、图表比例或非权威估算,变量名和类型必须明确表达用途。通过静态搜索与领域封装共同限制,才能避免后续维护者绕过现有门禁,重新把字符串金额带回二进制浮点路径。
参考资料:
更多推荐



所有评论(0)