一个报价页显示 97.84 元,重开页面后却变成 97.83 元。差值不大,排查成本往往不小:UI、缓存、接口和服务端都能给出一套“看起来正确”的算式,日志里又只剩最终金额。真正丢失的不是一分钱,而是计算过程。

本文用 HarmonyOS Demo PriceLedger 拆开这个问题。页面名为 QuoteReplayPage,报价编号 QT-0816,规则版本 price-v3,舍入策略 LINE_HALF_UP_V1。两件商品原价分别为 69.90 元和 49.90 元,会员折扣 0.85,优惠券 10.00 元,运费 6.00 元。逐行舍入的应付金额是 97.84 元;先合并后舍入会得到 97.83 元。本文数据是为了说明规则协议而固定的 Demo 字段,不是线上订单或支付测试记录。

一、金额异常通常发生在“在哪一步舍入”

这笔报价的原始小计是 119.80 元。若先将每一行乘以 0.85,再按两位小数做四舍五入:69.90 × 0.85 得到 59.415,落账为 59.42;49.90 × 0.85 得到 42.415,落账为 42.42。折后商品合计 101.84,减 10.00,再加 6.00,最终为 97.84。

另一种算法把 119.80 先乘 0.85,得到 101.83,再减券加运费,结果是 97.83。两条路径都没有出现明显的浮点长尾,甚至都能通过“保留两位小数”的肉眼检查。冲突来自业务规则:舍入发生在行项目,还是订单汇总。

所以金额类问题不能只问“用了什么高精度库”。库负责按指定规则计算,产品协议负责指定哪一步形成不可逆的账面值。若协议没写,换成任何库都只是在更准确地执行一条含糊规则。

PriceLedger 冻结以下调试事实:时间 16:18、报价 QT-0816、事件序号 28、规则 price-v3、策略 LINE_HALF_UP_V1、逐行结果 101.84、汇总结果 101.83、最终应付 97.84、快照摘要 8C41-7A2E、回放状态 VERIFIED。这些字段同时出现在正文、HiLog、运行页和诊断页中。

二、不要把已经失真的 Number 再交给高精度库

decimal.js 官方文档明确建议:长数字或对精度敏感的值优先以字符串传入。new Decimal(0.7 + 0.1) 接到的已经是 JavaScript Number 算出的近似值,库无法知道原始输入本来是两个十进制文本。高精度从数据入口开始,而不是从构造函数调用那一刻才开始。

接口 DTO、RDB 字段和本地事件都保存十进制字符串,例如 "69.90"。不要为了页面绑定先转成 number,计算时又转回字符串。那条路径格式上绕了一圈,精度信息却没有回来。

这段代码解决什么问题:把舍入模式和舍入位置收进一份不可变的报价规则,避免页面各自调用 toFixed()。

import Decimal from 'decimal.js'

const MoneyDecimal = Decimal.clone({
  precision: 28,
  rounding: Decimal.ROUND_HALF_UP
})

interface QuoteLine {
  sku: string
  amount: string
}

interface PriceRule {
  version: 'price-v3'
  policy: 'LINE_HALF_UP_V1'
  scale: 2
}

function roundMoney(value: Decimal, rule: PriceRule): Decimal {
  return value.toDecimalPlaces(rule.scale, Decimal.ROUND_HALF_UP)
}

function discountLines(lines: QuoteLine[], rate: string,
  rule: PriceRule): Decimal[] {
  const ratio = new MoneyDecimal(rate)
  return lines.map((line: QuoteLine) =>
    roundMoney(new MoneyDecimal(line.amount).times(ratio), rule))
}

Decimal.clone() 的意义不是炫技,而是隔离全局配置。若某个图表模块为了科学计数把默认精度改小,报价模块不应被悄悄影响。每个金额方法返回新的 Decimal,原对象不变,也方便把中间结果保留下来做诊断。

roundMoney 只允许接收规则对象,调用方不能临时决定保留几位。实际项目还应把币种、最小货币单位和负数处理写进规则。本文使用人民币两位小数只是 Demo 约定,不应推广成所有币种的固定事实。

最容易出错的地方是 amount: number。哪怕界面上显示 69.90,序列化成 JSON 后也可能只剩 69.9;更大的问题是上游已经完成二进制浮点运算。DTO 使用 string,校验正负号、整数位长度和小数位,再构造 Decimal,边界才清楚。

三、把计算过程写成账本,而不是写成一条长表达式

一条 subtotal.times(rate).minus(coupon).plus(shipping) 很短,却隐藏了舍入点。遇到差异时只能反复加日志。PriceLedger 把每一步变成带类型的事件:原价、折扣落行、优惠券和运费都保留输入、输出和规则版本。

账本不是为了把代码写长。它让“97.84 从哪里来”可以重放,也让新规则上线后仍能解释旧报价。只保存最终金额,相当于保存了编译产物却删掉源代码。

这段代码解决什么问题:用明确步骤计算 QT-0816,并同时生成可序列化的金额事件。

type MoneyStep = 'LINE_DISCOUNT' | 'COUPON' | 'SHIPPING'

interface MoneyEvent {
  seq: number
  step: MoneyStep
  input: string
  operand: string
  output: string
  ruleVersion: string
}

interface QuoteResult {
  payable: string
  events: MoneyEvent[]
}

export function priceQuote(): QuoteResult {
  const rule: PriceRule = {
    version: 'price-v3', policy: 'LINE_HALF_UP_V1', scale: 2
  }
  const lines: QuoteLine[] = [
    { sku: 'A-69', amount: '69.90' },
    { sku: 'B-49', amount: '49.90' }
  ]
  const discounted = discountLines(lines, '0.85', rule)
  const events: MoneyEvent[] = discounted.map((value: Decimal, index: number) => ({
    seq: index + 1,
    step: 'LINE_DISCOUNT',
    input: lines[index].amount,
    operand: '0.85',
    output: value.toFixed(2),
    ruleVersion: rule.version
  }))

  const lineTotal = Decimal.sum(...discounted)
  const afterCoupon = lineTotal.minus('10.00')
  const payable = afterCoupon.plus('6.00')
  events.push({ seq: 3, step: 'COUPON', input: lineTotal.toFixed(2),
    operand: '-10.00', output: afterCoupon.toFixed(2), ruleVersion: rule.version })
  events.push({ seq: 4, step: 'SHIPPING', input: afterCoupon.toFixed(2),
    operand: '6.00', output: payable.toFixed(2), ruleVersion: rule.version })
  return { payable: payable.toFixed(2), events }
}

这里的状态变化是:两条原价先分别进入 LINE_DISCOUNT,落成 59.42 和 42.42;二者合计后再进入 COUPON,最后进入 SHIPPING。优惠券不是负商品行,运费也不是折扣后的商品,因此两者不能随意交换顺序。

事件中的 input、operand、output 全是字符串。序列化时不把 Decimal 实例直接塞进状态对象,也不依赖第三方库内部字段。decimal.js 的 d/e/s 等内部表示被官方说明为只读实现细节,不应该成为持久化协议。

正式项目还要给事件增加币种、报价时区、规则摘要和来源。本文的 seq 从 1 开始,界面诊断固定展示整份报价的业务序号 28;两者不是同一个概念。一个是步骤序号,一个是报价流里的事件版本,命名必须区分。

四、回放不是重新算一次,而是验证每个边界

如果回放只拿当前代码重新计算最终金额,旧订单遇到规则升级仍可能被新逻辑“解释”。正确做法是按事件保存的 ruleVersion 找到对应执行器,逐步检查上一步输出是否等于下一步输入。

PriceLedger 的 price-v3 在应用发布时冻结。将来增加 price-v4,不能修改 v3 的舍入位置。历史事件继续交给 v3 回放,新报价才使用 v4。无法找到规则时,状态应是 UNSUPPORTED_RULE,而不是默默套最新版本。

这段代码解决什么问题:重放字符串账本,并把篡改、顺序错位和规则缺失转成可诊断状态。

type ReplayState = 'VERIFIED' | 'MISMATCH' | 'UNSUPPORTED_RULE'

interface ReplayReport {
  state: ReplayState
  payable?: string
  failedSeq?: number
}

function replayV3(events: MoneyEvent[]): ReplayReport {
  if (events.some((event: MoneyEvent) => event.ruleVersion !== 'price-v3')) {
    return { state: 'UNSUPPORTED_RULE' }
  }
  let current = new MoneyDecimal(events[0].output)
  for (let index = 1; index < events.length; index++) {
    const event = events[index]
    if (!current.equals(event.input)) {
      return { state: 'MISMATCH', failedSeq: event.seq }
    }
    current = new MoneyDecimal(event.output)
  }
  return { state: 'VERIFIED', payable: current.toFixed(2) }
}

const report = replayV3(priceQuote().events)
console.info(`[16:18] QT-0816 policy=LINE_HALF_UP_V1`)
console.info(`[16:18] payable=97.84 replay=${report.payable} ${report.state}`)

上面的最小实现重点是链路连续性,完整实现还要重新执行每个运算并核对 output,而不是信任事件里写好的结果。摘要 8C41-7A2E 在 Demo 中只承担界面一致性字段;若要做防篡改,必须使用经过安全评审的哈希或签名方案,并明确密钥和服务端信任边界,不能把短摘要当安全证明。

当回放从 CHECKING 进入 VERIFIED,页面才显示“规则一致”。出现 MISMATCH 时不要自动修正本地报价,否则会抹掉证据。应保留失败步骤、当前快照和服务端版本,再决定重新拉取还是阻止提交。

上图是与本文字段对应的开发环境演示配图,不是实际 DevEco Studio 截图或支付验证证据。左侧为 PriceLedger 工程,中间展示规则与回放代码,右侧模拟器显示 QT-0816 和 97.84 元,底部 HiLog 固定为 16:18、LINE_HALF_UP_V1 与 VERIFIED。红色标注只指向舍入点和回放结论。

五、页面状态只接收字符串金额

UI 最常见的二次破坏,是为了格式化又执行一次 Number(payable).toFixed(2)。97.84 可能暂时不出问题,但大额、长小数或后续计算会重新回到二进制浮点路径。页面既然拿到协议化字符串,就应该把它当展示值。

这段代码解决什么问题:让 QuoteReplayPage 明确区分报价、回放和错误状态,不在组件层重算金额。

@Entry
@Component
struct QuoteReplayPage {
  @State quoteId: string = 'QT-0816'
  @State payable: string = '--'
  @State policy: string = 'LINE_HALF_UP_V1'
  @State replayState: ReplayState = 'VERIFIED'
  @State eventSeq: number = 28

  aboutToAppear(): void {
    const quote = priceQuote()
    const report = replayV3(quote.events)
    this.payable = quote.payable
    this.replayState = report.state
  }

  build() {
    Column({ space: 16 }) {
      Text(`报价 ${this.quoteId}`).fontSize(20).fontWeight(FontWeight.Bold)
      Text(`¥${this.payable}`).fontSize(40).fontWeight(FontWeight.Bold)
      Text(`规则 ${this.policy} · 事件 ${this.eventSeq}`)
      Text(this.replayState)
        .fontColor(this.replayState === 'VERIFIED' ? '#168653' : '#C62828')
      Button('提交报价').enabled(this.replayState === 'VERIFIED')
    }.padding(24).width('100%')
  }
}

组件的状态变化很少:进入页面后从固定输入生成报价,回放完成后一次性写入 payable 和 replayState。真实项目通常是异步读取,需增加 LOADING,并在页面离开或报价 ID 变化时拒绝旧请求写回。这里不复用“代次隔离”作为文章主线,因为本批关注的是金额协议;生命周期保护仍是生产代码的基本要求。

提交按钮的禁用只负责交互反馈,提交函数内部还要再次检查状态、规则版本与快照 ID。否则辅助入口、自动化操作或状态延迟仍可能绕过页面条件。

运行页固定显示 16:18、QT-0816、原价小计 119.80、会员折扣 0.85、优惠券 10.00、运费 6.00、应付 97.84,以及 VERIFIED。红色说明“逐行舍入后提交”指向应付金额,不暗示这是一笔真实交易。

六、诊断页要同时展示两条计算路径

只显示“差异 0.01”仍不够。研发需要看到差异在哪一步产生。PriceLedger 诊断页并排显示:逐行舍入 59.42 + 42.42 = 101.84;汇总舍入 119.80 × 0.85 = 101.83。优惠券和运费在两条路径里相同,因此差异定位到折扣落账阶段。

这种展示方式比打印 Decimal 的内部数组更有用。业务人员能确认规则,开发者能定位实现,测试能把步骤写成断言。若未来规则变为“优惠券按商品比例分摊”,诊断页也应显示每行分摊余数如何处理,而不是只改最终金额。

诊断图使用同一份冻结数据:规则 price-v3、策略 LINE_HALF_UP_V1、逐行 101.84、汇总 101.83、差异 0.01、最终 97.84、事件 28、摘要 8C41-7A2E、状态 VERIFIED。03 面向提交者说明结果,04 面向研发解释结果,两张图不承担同一职责。

七、金额字符串也需要输入约束

“都改成字符串”并不等于安全。空串、指数形式、前导加号、超长整数、负零和超过币种精度的小数都需要明确策略。PriceLedger 的入口只接受普通十进制格式,整数位最多 12 位,小数最多 4 位;计算完成后按规则落成两位。超出范围直接返回输入错误,不尝试截断。

金额为负是否允许,要按字段区分。商品原价通常不接受负数;优惠调整可以是负数;退款金额也许用正值配合事件类型表达。让所有字段共享一个“金额正则”,会把业务含义压扁。

NaN 和 Infinity 在 decimal.js 中是可表示状态,不能因为构造成功就认为输入有效。边界层必须调用 isFinite(),并对范围做比较。错误信息记录字段名和规则,不记录完整订单敏感内容。

八、缓存、RDB 与接口必须共享一种表示

若内存里用 Decimal,RDB 用 REAL,接口用 string,三层仍可能产生漂移。金额列可以保存规范化十进制字符串,另存币种与 scale;需要排序和范围查询时,再设计可索引的最小单位整数列。两列同时存在必须指定哪一列是事实源,并在写入时校验一致。

最小单位整数适合两位货币,但不能取代规则历史。97.84 可以保存为 9784,仍无法解释 9784 为什么不是 9783。事件账本解决来源,整数列解决存储和索引,两者不是竞争方案。

网络请求也要避免服务端和端侧各自重算。提交报价时发送 quoteId、ruleVersion、snapshot 和客户端展示金额;服务端以自己的规则结果为准并返回差异。端侧发现不一致后提示报价已更新,不能用客户端金额直接覆盖结算金额。

九、测试要覆盖舍入边界,而不是只测整数

最关键的用例就是两个 .415:59.415 和 42.415 都需要 HALF_UP 到下一分。再加入 0、负调整、超长输入、三位和四位小数、非法指数、空串、规则缺失、事件乱序、上一步输出被改动等案例。

性质测试可以检查:同一规则重复回放结果稳定;序列化再反序列化不改变金额字符串;任一事件 output 被修改都不能 VERIFIED;规则版本变化不会调用旧执行器;UI 展示值与提交 DTO 完全一致。

不要用 expect(Number(result)).toBeCloseTo(...) 验金额。近似断言会把一分钱差异当成可接受误差。金额协议应该比较规范化字符串,或比较明确 scale 下的整数。

十、三方库升级不能顺手带过

decimal.js 是项目依赖,不是语言内建能力。升级前应固定版本,阅读变更记录,跑金额黄金用例,并确认构建产物实际加载的版本。规则版本和依赖版本也不是一回事:升级库但业务规则不变,历史结果必须保持一致;业务规则变化则应新建执行器。

包体审计还要检查许可证、类型声明和产物入口。decimal.js 官方仓库提供 TypeScript 声明且采用 MIT 许可证;团队仍需按自身发布流程确认三方声明和归档要求。本文不假设把 npm 包名写进 oh-package.json5 就一定适配所有 ArkTS 工程,接入应以当前工具链的编译结果为准。

十一、验收标准要能被非开发人员读懂

这类功能的验收不该写“使用高精度计算”。更可执行的表述是:QT-0816 按 LINE_HALF_UP_V1 逐行舍入,折后行合计 101.84;优惠券和运费处理后应付 97.84;页面、缓存、提交 DTO 与事件回放四处一致;改成汇总舍入会得到 97.83,并在诊断页标出 0.01 差异;旧规则缺失时禁止提交。

到这里,97.84 不再是一个孤立的 Text。它有原始字符串、舍入位置、规则版本、事件顺序和回放结论。decimal.js 提供可靠的十进制运算,但真正让结果可维护的,是把“何时舍入、如何保存、按哪个版本重放”写成协议。

十二、规则迁移不能覆盖旧快照

金额规则升级最容易出现一种“看起来很整洁”的错误:把 price-v3 的函数原地改成新算法,然后批量刷新缓存。新报价统一了,历史报价却失去原来的解释器。用户打开旧订单时,页面可能按照新算法显示 97.83,服务端保存的成交金额仍是 97.84。

更稳妥的迁移以新增为主。price-v3 的执行器、黄金用例和事件结构保持只读,新建 price-v4 承担新策略。报价创建时冻结版本;订单转化时把版本带入结算快照;页面重开时按快照版本回放,而不是读取“当前默认版本”。默认版本只决定新报价的起点。

如果新版本还修改事件字段,不要让回放器直接猜旧字段含义。先做显式的事件升级:输入 v3 事件,输出一份标注来源的 v4 兼容视图,但原始事件仍保留。升级失败应停在 MIGRATION_REQUIRED,提示重新获取报价,不能丢掉字段后继续算。

灰度期间还要防止同一个 quoteId 在两个规则下被分别缓存。缓存键至少包含 quoteId、ruleVersion 和输入摘要。只用 quoteId,会让先写入的 v3 结果覆盖 v4,或反过来造成回滚。诊断页应能看出“计算规则”和“页面代码版本”是两个维度。

十三、重复点击和网络重试不能创建两本账

报价计算可以本地完成,提交报价却可能遇到超时。用户连续点击两次,客户端也可能自动重试。若每次提交都生成新的事件序列,服务端会看到两套金额相同、身份不同的账本,后续订单归因变得困难。

提交动作需要稳定的幂等键,例如由 quoteId、snapshot 和业务动作组成。第一次提交后按钮进入 SUBMITTING,成功进入 ACCEPTED;超时则保留同一幂等键重试。新的商品数量、优惠券或地址变化会生成新 snapshot,才允许产生新的提交身份。

幂等不等于允许客户端决定成交价。服务端仍应校验规则版本、输入和金额。若返回 PRICE_CHANGED,端侧把旧报价标记为 STALE,展示新旧差异,由用户确认后创建新快照。直接把服务端 97.83 写进旧的 v3 账本,会制造一条内部不连续的历史。

这里还要区分“请求重复”和“用户确实创建了第二份报价”。幂等窗口、业务主键与用户动作语义需要服务端共同设计,不能单靠 ArkUI 按钮防抖。页面防抖改善体验,协议幂等保证事实一致。

十四、可观察性要记录规则,不记录消费隐私

金额排查需要足够证据,但订单日志很容易携带商品、地址、账号和优惠信息。PriceLedger 的常规日志只输出 quoteId 的内部短标识、规则版本、步骤类型、输入输出摘要、回放状态和失败序号。完整事件保存在受控本地诊断或服务端账务系统,不进入普通 HiLog。

可统计的指标包括:各规则版本的 MISMATCH 比例、规则缺失次数、报价过期次数、客户端与服务端金额差异区间。不要上传商品明细和完整券码来换取“更好排查”。如果差异只发生在某一版本,规则标签已经足以缩小范围。

日志级别也应区分。用户快速返回导致页面未展示结果,不是金额异常;规则缺失是配置错误;回放链断裂才是数据一致性告警。把全部失败都记为 error,会让真正的一分钱差异淹没在生命周期噪声里。

对账工具最好允许输入脱敏事件,而不是直接连接生产订单。复现 QT-0816 只需要金额字符串、步骤、规则和序号,不需要知道用户买了什么。这样既能验证 97.84 的形成过程,也能把测试样本纳入持续集成。

十五、参考资料与能力边界

本文使用 decimal.js 的公开 API 演示十进制规则,页面与数据均为可复现的示例设计,不代表真实支付系统。支付、税务、退款和跨币种场景还涉及服务端结算、法律口径与风控要求,应由对应系统定义最终事实源。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐