价格明细为什么不能拆成五个数字
购物车底部通常有五行:商品小计、优惠、运费、税费、实付。它们看起来是五个独立的数字,实际上是一份快照的五个字段。用户改数量时五段一起变;优惠券失效时五段一起失效;请求超时时五段一起过期。
一旦端上把它们当成五个可以分别计算的数字,就会出现三类问题:
- 商品小计用新数量,优惠还用旧门槛,实付算出负数。
- 运费按旧地址算,税费按新地址算,两段来自不同时刻。
- 端上算出 99 元,服务端实付 109 元,用户觉得被欺骗。
所以本节先定一条边界:端上可以预演,但不能产出最终金额。 展示层可以为了让用户即时看到变化而重算,但必须标记来源,提交前用服务端明细整体替换。
五段明细,五种归属
把五段拆开看,每一段由谁计算、端上能不能碰,差别很大。
| 段 | 典型来源 | 端上能否计算 | 端上职责 |
|---|---|---|---|
| 商品小计 | 单价 × 数量 | 可以 | 预演、校验 |
| 优惠 | 券、活动、会员价 | 不可以 | 展示服务端结果 |
| 运费 | 地址、重量、商家规则 | 不可以 | 展示、切换地址后重拉 |
| 税费 | 税率、完税价、地区 | 不可以 | 展示 |
| 实付 | 服务端汇总 | 不可以 | 展示、提交 |
商品小计是唯一端上可以独立算准的段:单价和数量都在端上,乘法没有隐藏规则。其余四段都依赖服务端才知道的条件——券是否叠加、地址是否包邮、税率按哪个地区、会员价是否生效。
因此端上重算的正确用法是:用户改数量时,立即用小计 + 已知的运费/税/优惠做一次预演,让数字动起来;同时发起一次结算请求,等服务端明细回来再替换。预演负责体验,服务端负责正确。
端上能算的只有展示层
「展示层分摊」是端上唯一需要做的金额计算。用户想知道「这 300 元优惠里,A 商品便宜了多少」,端上必须把总优惠按行小计比例分摊到每一行。
这个分摊只用于展示,不参与实付。它必须满足两个条件:
- 各行分摊之和精确等于总优惠,不能多一分也不能少一分。
- 分摊结果稳定,同样的输入每次得到同样的输出。
用浮点数按比例乘再四舍五入,几乎一定违反第一条:三行各 10 元、总优惠 1 元,每行 0.333… 元,四舍五入后三行各 0.33 元,合计 0.99 元,少了一分。反过来可能多一分。
正确做法是最大余数法:先按比例取整数部分,再把剩下的分逐一分给小数部分最大的行。全程整数运算,结果可复现。
税费也要按整数分算
税费常被写成 金额 * 税率,再 toFixed(2)。这在边界值上会出错:0.5 分可能因为浮点表示变成 0.4999999,向下取整后少一分。
本节的规则是:税率用基点(万分之一)表示,计算 基数 × 基点 得到整数,再按「半分向上」处理。这样 0.5 分一定进位,不依赖浮点行为。
税率本身仍然来自服务端。端上算税只是为了预演,不能把预演结果提交。
版本号把五段绑成一份快照
价格明细必须带版本号。原因不是「方便缓存」,而是五段必须同源。
设想端上先后收到两个响应:第一个带新优惠、旧运费,第二个带旧优惠、新运费。如果端上按字段合并,就会拼出一份从未存在过的明细。用户看到的实付,既不是第一次的结果,也不是第二次的结果。
正确做法是整份替换:版本号更大的明细整体覆盖旧明细;版本号相同但金额不同,说明服务端自相矛盾,端上应当报错而不是挑一个显示;版本号更小的直接丢弃。
版本号相同且金额一致,才允许视为同一份快照。这个判断很便宜,却能拦住大部分「明细字段来自不同请求」的问题。
一个可运行的价格明细模块
下面的模块属于 L1:纯函数,金额全部是整数分,版本号与来源显式建模。
// file: src/cart/price-breakdown.ts
// tier: L1
// verify: node --test test/cart/price-breakdown.test.ts
import type { Money } from "../core/trade-model.ts";
export type BreakdownSource = "server" | "preview";
export type BreakdownKey = "subtotal" | "discount" | "shipping" | "tax" | "payable";
export interface BreakdownLine { skuId: string; unitCents: number; quantity: number; }
export interface LineShare { skuId: string; subtotalCents: number; discountCents: number; }
export interface BreakdownRow { key: BreakdownKey; label: string; cents: number; }
export interface BreakdownInput {
version: number;
lines: BreakdownLine[];
discountCents: number;
shippingCents: number;
taxCents: number;
source?: BreakdownSource;
}
export interface PriceBreakdown {
version: number;
currency: Money["currency"];
source: BreakdownSource;
subtotalCents: number;
discountCents: number;
shippingCents: number;
taxCents: number;
payableCents: number;
lines: LineShare[];
}
// 五段明细只在这里组装一次;任何展示层都读这个对象,不各自拼金额。
export function buildBreakdown(input: BreakdownInput): PriceBreakdown {
if (!Number.isInteger(input.version) || input.version < 0) {
throw new Error("明细版本号必须是非负整数");
}
assertInt(input.discountCents, "优惠金额");
assertInt(input.shippingCents, "运费");
assertInt(input.taxCents, "税费");
const source = input.source ?? "server";
if (source !== "server" && source !== "preview") {
throw new Error(`未知的明细来源:${String(source)}`);
}
const subtotals: number[] = [];
for (const line of input.lines) {
assertInt(line.unitCents, `单价 ${line.skuId}`);
if (!Number.isInteger(line.quantity) || line.quantity < 1) {
throw new Error(`数量非法:${line.skuId}`);
}
const lineTotal = line.unitCents * line.quantity;
if (!Number.isSafeInteger(lineTotal)) {
throw new Error(`行金额溢出:${line.skuId}`);
}
subtotals.push(lineTotal);
}
const subtotalCents = subtotals.reduce((sum, value) => sum + value, 0);
if (!Number.isSafeInteger(subtotalCents)) throw new Error("商品小计溢出");
if (input.discountCents > subtotalCents) {
throw new Error("优惠金额不能超过商品小计");
}
const shares = allocateDiscount(subtotals, input.discountCents);
const payableCents =
subtotalCents - input.discountCents + input.shippingCents + input.taxCents;
if (!Number.isSafeInteger(payableCents) || payableCents < 0) {
throw new Error("实付金额非法");
}
return {
version: input.version,
currency: "CNY",
source,
subtotalCents,
discountCents: input.discountCents,
shippingCents: input.shippingCents,
taxCents: input.taxCents,
payableCents,
lines: input.lines.map((line, index) => ({
skuId: line.skuId,
subtotalCents: subtotals[index],
discountCents: shares[index],
})),
};
}
// 展示层分摊:最大余数法,保证各行分摊之和精确等于优惠总额。
export function allocateDiscount(subtotals: number[], discountCents: number): number[] {
assertInt(discountCents, "优惠金额");
let total = 0;
for (const value of subtotals) {
assertInt(value, "行小计");
total += value;
if (!Number.isSafeInteger(total)) throw new Error("行小计累加溢出");
}
if (discountCents > total) throw new Error("优惠金额不能超过商品小计");
if (discountCents === 0 || subtotals.length === 0) return subtotals.map(() => 0);
const shares = subtotals.map((value) => Math.floor((discountCents * value) / total));
let remainder = discountCents - shares.reduce((sum, value) => sum + value, 0);
const order = subtotals
.map((value, index) => ({ index, fraction: (discountCents * value) % total }))
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
for (const entry of order) {
if (remainder === 0) break;
shares[entry.index] += 1;
remainder -= 1;
}
return shares;
}
// 税费按整数分计算,0.5 分向上进位;不使用浮点乘法。
export function roundTax(netCents: number, rateBps: number): number {
assertInt(netCents, "计税基数");
if (!Number.isInteger(rateBps) || rateBps < 0 || rateBps > 10_000) {
throw new Error("税率必须在 0 到 10000 个基点之间");
}
const numerator = netCents * rateBps;
if (!Number.isSafeInteger(numerator)) throw new Error("税费计算溢出");
return Math.floor((numerator + 5_000) / 10_000);
}
export function breakdownRows(breakdown: PriceBreakdown): BreakdownRow[] {
return [
{ key: "subtotal", label: "商品小计", cents: breakdown.subtotalCents },
{ key: "discount", label: "优惠", cents: -breakdown.discountCents },
{ key: "shipping", label: "运费", cents: breakdown.shippingCents },
{ key: "tax", label: "税费", cents: breakdown.taxCents },
{ key: "payable", label: "实付", cents: breakdown.payableCents },
];
}
// 合计的是四个组成段,payable 是结果行,不能再加一次。
export function sumRows(rows: BreakdownRow[]): number {
return rows.filter((row) => row.key !== "payable")
.reduce((sum, row) => sum + row.cents, 0);
}
// 端上只做预演:沿用服务端版本号,标记 preview,提交前必须换成服务端明细。
export function previewBreakdown(
server: PriceBreakdown,
lines: BreakdownLine[],
): PriceBreakdown {
const subtotalCents = lines.reduce(
(sum, line) => sum + line.unitCents * line.quantity,
0,
);
return buildBreakdown({
version: server.version,
lines,
discountCents: Math.min(server.discountCents, subtotalCents),
shippingCents: server.shippingCents,
taxCents: server.taxCents,
source: "preview",
});
}
export function canSubmit(breakdown: PriceBreakdown): boolean {
return breakdown.source === "server";
}
export function sameSnapshot(a: PriceBreakdown, b: PriceBreakdown): boolean {
return a.version === b.version && sameAmounts(a, b);
}
export function assertSnapshot(breakdown: PriceBreakdown, expectedVersion: number): void {
if (breakdown.version !== expectedVersion) {
throw new Error(`明细版本不一致:期望 ${expectedVersion},实际 ${breakdown.version}`);
}
}
// 服务端明细按版本号单调推进;同版本不同金额说明服务端自相矛盾,必须报错。
export function mergeBreakdown(
current: PriceBreakdown,
incoming: PriceBreakdown,
): { breakdown: PriceBreakdown; discarded: boolean } {
if (incoming.version < current.version) return { breakdown: current, discarded: true };
if (incoming.version === current.version) {
if (!sameAmounts(current, incoming)) {
throw new Error(`版本 ${incoming.version} 的明细金额不一致`);
}
return { breakdown: current, discarded: false };
}
return { breakdown: incoming, discarded: false };
}
function sameAmounts(a: PriceBreakdown, b: PriceBreakdown): boolean {
return a.subtotalCents === b.subtotalCents &&
a.discountCents === b.discountCents &&
a.shippingCents === b.shippingCents &&
a.taxCents === b.taxCents &&
a.payableCents === b.payableCents;
}
function assertInt(value: number, name: string): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`${name} 必须是非负安全整数`);
}
}
几个关键函数的分工:
buildBreakdown:唯一组装五段的地方。它校验金额、计算小计、分摊优惠、汇总实付。allocateDiscount:最大余数法分摊,保证各行之和精确等于总优惠。roundTax:整数分税,半分向上。breakdownRows/sumRows:渲染层只读这两个函数,避免各页面自己拼五段。previewBreakdown:端上预演,沿用服务端版本号,但把来源标成preview。mergeBreakdown:按版本号整份替换,拒绝同版本不同金额。
canSubmit 只有一行,却是整节最重要的约束:只有 source === "server" 的明细才允许进入提交。端上预演再准,也不能拿来下单。
测试与真实输出
本机运行结果如下:
$ node --test test/cart/price-breakdown.test.ts
✔ 五段明细按固定公式计算出实付 (0.742375ms)
✔ 行小计等于单价乘数量 (0.432208ms)
✔ 优惠不能超过商品小计 (0.212458ms)
✔ 非整数单价被拒绝 (0.082625ms)
✔ 空明细允许只有运费与税费 (0.073833ms)
✔ 等额小计按最大余数分摊,余数给靠前的行 (0.07075ms)
✔ 整除时分摊与比例完全一致 (0.064792ms)
✔ 有余数时按小数部分从大到小分配 (0.052333ms)
✔ 零优惠分摊全为零,零小计不允许有优惠 (0.08475ms)
✔ 展示行固定五段且合计等于实付 (0.172042ms)
✔ 税费按整数分计算,半分向上进位 (0.0805ms)
✔ 税率的零值与全额边界 (0.0585ms)
✔ 预览沿用服务端版本号并标记 preview (0.094167ms)
✔ 预览时优惠自动收敛到新小计 (0.07275ms)
✔ 只有服务端明细允许提交 (0.042166ms)
✔ 同一版本号但金额不同时拒绝合并 (0.133041ms)
✔ 旧版本明细被丢弃 (0.051333ms)
✔ 新版本明细覆盖当前值 (0.048625ms)
✔ 同版本且金额一致视为同一快照 (0.050792ms)
✔ 快照版本不一致时断言失败 (0.108916ms)
✔ 非法版本号被拒绝 (0.046709ms)
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 126.461
二十一条用例覆盖:五段公式、行小计、优惠上限、非法单价、空明细、三种分摊、零优惠、五段渲染、税费进位、税率边界、预览标记、预览收敛、提交资格、同版本冲突、旧版本丢弃、新版本覆盖、快照判断、版本断言、非法版本号。
一次「分摊差一分钱」的复盘
「回声」的订单详情页曾经自己算优惠分摊:按行小计比例乘优惠总额,再对每行 toFixed(2)。上线后客诉不多,但财务对账每周都对不上,差额通常是一分钱。
排查发现,问题出在三行商品的订单上。总优惠 10 元,三行小计分别是 33.33、33.33、33.34 元。端上按比例算出每行 3.333、3.333、3.334 元,四舍五入后是 3.33、3.33、3.33,合计 9.99 元。少掉的一分钱没有落到任何一行。
财务看到的是服务端的 10 元,用户看到的是三行加起来 9.99 元。金额不大,但「明细加起来不等于总数」会直接摧毁用户对账单的信任。
修复做了三件事:
- 分摊改成最大余数法,余数按小数部分从大到小分配,保证合计精确。
- 分摊只用于展示,订单详情的实付金额一律读服务端字段。
- 增加一条断言:分摊之和与总优惠不一致时,页面不渲染明细,直接展示加载失败并上报。
这个问题的教训不是「四舍五入不好」,而是任何分摊都必须有一个「合计对齐」的收口。只要允许每一行独立舍入,就一定会出现差一分钱。
预演与最终金额的边界
预演和最终金额的差别,不只是精度,还有责任。
- 预演错了,用户看到数字跳变,体验受损。
- 最终金额错了,用户按错误金额付款,产生资金损失与退款。
所以两者的代码路径必须分开:预演走 previewBreakdown,结果标记 preview;提交只能读 server 明细。展示层可以同时显示两者,但必须让用户知道哪个是预估。
一个常见的错误是「预演结果看起来对,就直接当最终金额用」。它通常能跑很久不出问题,直到某个用户用了端上不知道的会员价或地区税率。那一刻,端上算得越自信,错得越彻底。
小结
价格明细是一份五段同源的快照,不是五个可以分别计算的数字。端上可以算商品小计、可以做展示层分摊、可以预演,但不能产出最终金额。
buildBreakdown 把五段组装成一份带版本号的对象;allocateDiscount 用最大余数法保证分摊合计精确;mergeBreakdown 用版本号整份替换,拒绝拼字段;canSubmit 把「只有服务端明细能提交」写成一行可检查的规则。
下一节讨论购物车里另一类状态:勾选、失效与限购。它们不直接算钱,却决定哪些商品进入金额。
阅读导航:上一节:3.1 购物车归属:本地车、服务端车与合并 · 下一节:3.3 失效商品、限购与勾选状态 。
常见错误与边界
- 端上按商品价、券、运费自己算出实付并直接展示,与服务端结果不一致。
- 五段金额分别来自不同请求,拼出一份从未存在过的明细。
- 用浮点数按比例分摊优惠,每行独立四舍五入,合计差一分钱。
- 税费先算浮点再
toFixed(2),半分边界向下舍入。 - 预演结果不标来源,用户以为看到的就是最终价格。
- 同版本号但金额不同的明细被静默接受,端上挑一个显示。
- 旧版本明细覆盖新版本,用户看到已经失效的优惠。
- 展示层各自拼五段,某一页漏掉税费或运费。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。