资讯动态

HarmonyOS ArkTS + decimal.js:优惠叠加中的金额舍入、序列化与订单回放一致性【鸿蒙心迹】

发布时间:2026/10/3 7:16:35 来源:尧图企业网站定制
一个报价页显示 97.84 元重开页面后却变成 97.83 元。差值不大排查成本往往不小UI、缓存、接口和服务端都能给出一套“看起来正确”的算式日志里又只剩最终金额。真正丢失的不是一分钱而是计算过程。本文用 HarmonyOS DemoPriceLedger拆开这个问题。页面名为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.4249.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)接到的已经是 JavaScriptNumber算出的近似值库无法知道原始输入本来是两个十进制文本。高精度从数据入口开始而不是从构造函数调用那一刻才开始。接口 DTO、RDB 字段和本地事件都保存十进制字符串例如69.90。不要为了页面绑定先转成 number计算时又转回字符串。那条路径格式上绕了一圈精度信息却没有回来。这段代码解决什么问题把舍入模式和舍入位置收进一份不可变的报价规则避免页面各自调用toFixed()。importDecimalfromdecimal.jsconstMoneyDecimalDecimal.clone({precision:28,rounding:Decimal.ROUND_HALF_UP})interfaceQuoteLine{sku:stringamount:string}interfacePriceRule{version:price-v3policy:LINE_HALF_UP_V1scale:2}functionroundMoney(value:Decimal,rule:PriceRule):Decimal{returnvalue.toDecimalPlaces(rule.scale,Decimal.ROUND_HALF_UP)}functiondiscountLines(lines:QuoteLine[],rate:string,rule:PriceRule):Decimal[]{constrationewMoneyDecimal(rate)returnlines.map((line:QuoteLine)roundMoney(newMoneyDecimal(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并同时生成可序列化的金额事件。typeMoneyStepLINE_DISCOUNT|COUPON|SHIPPINGinterfaceMoneyEvent{seq:numberstep:MoneyStep input:stringoperand:stringoutput:stringruleVersion:string}interfaceQuoteResult{payable:stringevents:MoneyEvent[]}exportfunctionpriceQuote():QuoteResult{construle:PriceRule{version:price-v3,policy:LINE_HALF_UP_V1,scale:2}constlines:QuoteLine[][{sku:A-69,amount:69.90},{sku:B-49,amount:49.90}]constdiscounteddiscountLines(lines,0.85,rule)constevents:MoneyEvent[]discounted.map((value:Decimal,index:number)({seq:index1,step:LINE_DISCOUNT,input:lines[index].amount,operand:0.85,output:value.toFixed(2),ruleVersion:rule.version}))constlineTotalDecimal.sum(...discounted)constafterCouponlineTotal.minus(10.00)constpayableafterCoupon.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而不是默默套最新版本。这段代码解决什么问题重放字符串账本并把篡改、顺序错位和规则缺失转成可诊断状态。typeReplayStateVERIFIED|MISMATCH|UNSUPPORTED_RULEinterfaceReplayReport{state:ReplayState payable?:stringfailedSeq?:number}functionreplayV3(events:MoneyEvent[]):ReplayReport{if(events.some((event:MoneyEvent)event.ruleVersion!price-v3)){return{state:UNSUPPORTED_RULE}}letcurrentnewMoneyDecimal(events[0].output)for(letindex1;indexevents.length;index){consteventevents[index]if(!current.equals(event.input)){return{state:MISMATCH,failedSeq:event.seq}}currentnewMoneyDecimal(event.output)}return{state:VERIFIED,payable:current.toFixed(2)}}constreportreplayV3(priceQuote().events)console.info([16:18] QT-0816 policyLINE_HALF_UP_V1)console.info([16:18] payable97.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 明确区分报价、回放和错误状态不在组件层重算金额。EntryComponentstruct QuoteReplayPage{StatequoteId:stringQT-0816Statepayable:string--Statepolicy:stringLINE_HALF_UP_V1StatereplayState:ReplayStateVERIFIEDStateeventSeq:number28aboutToAppear():void{constquotepriceQuote()constreportreplayV3(quote.events)this.payablequote.payablethis.replayStatereport.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.replayStateVERIFIED?#168653:#C62828)Button(提交报价).enabled(this.replayStateVERIFIED)}.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 与接口必须共享一种表示若内存里用 DecimalRDB 用 REAL接口用 string三层仍可能产生漂移。金额列可以保存规范化十进制字符串另存币种与 scale需要排序和范围查询时再设计可索引的最小单位整数列。两列同时存在必须指定哪一列是事实源并在写入时校验一致。最小单位整数适合两位货币但不能取代规则历史。97.84 可以保存为 9784仍无法解释 9784 为什么不是 9783。事件账本解决来源整数列解决存储和索引两者不是竞争方案。网络请求也要避免服务端和端侧各自重算。提交报价时发送 quoteId、ruleVersion、snapshot 和客户端展示金额服务端以自己的规则结果为准并返回差异。端侧发现不一致后提示报价已更新不能用客户端金额直接覆盖结算金额。九、测试要覆盖舍入边界而不是只测整数最关键的用例就是两个.41559.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 说明HarmonyOS ArkTS 官方入口HarmonyOS 三方库与 OHPM 服务入口本文使用 decimal.js 的公开 API 演示十进制规则页面与数据均为可复现的示例设计不代表真实支付系统。支付、税务、退款和跨币种场景还涉及服务端结算、法律口径与风控要求应由对应系统定义最终事实源。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑