《互联网产品的客户端架构·交易、增长与平台化》3.2 价格明细的端上重算边界

价格明细不是五个孤立的数字,而是一份必须同版本的结算快照。本节拆开商品小计、优惠、运费、税费与实付五段,说明端上只能做展示层分摊与预演、不能产出最终金额,用最大余数法把优惠分摊到整数分,并用版本号拒绝过期明细,附可运行代码、测试输出与复盘推演。

价格明细为什么不能拆成五个数字

购物车底部通常有五行:商品小计、优惠、运费、税费、实付。它们看起来是五个独立的数字,实际上是一份快照的五个字段。用户改数量时五段一起变;优惠券失效时五段一起失效;请求超时时五段一起过期。

一旦端上把它们当成五个可以分别计算的数字,就会出现三类问题:

  • 商品小计用新数量,优惠还用旧门槛,实付算出负数。
  • 运费按旧地址算,税费按新地址算,两段来自不同时刻。
  • 端上算出 99 元,服务端实付 109 元,用户觉得被欺骗。

所以本节先定一条边界:端上可以预演,但不能产出最终金额。 展示层可以为了让用户即时看到变化而重算,但必须标记来源,提交前用服务端明细整体替换。

五段明细,五种归属

把五段拆开看,每一段由谁计算、端上能不能碰,差别很大。

段典型来源端上能否计算端上职责
商品小计单价 × 数量可以预演、校验
优惠券、活动、会员价不可以展示服务端结果
运费地址、重量、商家规则不可以展示、切换地址后重拉
税费税率、完税价、地区不可以展示
实付服务端汇总不可以展示、提交

商品小计是唯一端上可以独立算准的段:单价和数量都在端上,乘法没有隐藏规则。其余四段都依赖服务端才知道的条件——券是否叠加、地址是否包邮、税率按哪个地区、会员价是否生效。

因此端上重算的正确用法是:用户改数量时,立即用小计 + 已知的运费/税/优惠做一次预演,让数字动起来;同时发起一次结算请求,等服务端明细回来再替换。预演负责体验,服务端负责正确。

端上能算的只有展示层

「展示层分摊」是端上唯一需要做的金额计算。用户想知道「这 300 元优惠里,A 商品便宜了多少」,端上必须把总优惠按行小计比例分摊到每一行。

这个分摊只用于展示,不参与实付。它必须满足两个条件:

  1. 各行分摊之和精确等于总优惠,不能多一分也不能少一分。
  2. 分摊结果稳定,同样的输入每次得到同样的输出。

用浮点数按比例乘再四舍五入,几乎一定违反第一条:三行各 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 元。金额不大,但「明细加起来不等于总数」会直接摧毁用户对账单的信任。

修复做了三件事:

  1. 分摊改成最大余数法,余数按小数部分从大到小分配,保证合计精确。
  2. 分摊只用于展示,订单详情的实付金额一律读服务端字段。
  3. 增加一条断言:分摊之和与总优惠不一致时,页面不渲染明细,直接展示加载失败并上报。

这个问题的教训不是「四舍五入不好」,而是任何分摊都必须有一个「合计对齐」的收口。只要允许每一行独立舍入,就一定会出现差一分钱。

预演与最终金额的边界

预演和最终金额的差别,不只是精度,还有责任。

  • 预演错了,用户看到数字跳变,体验受损。
  • 最终金额错了,用户按错误金额付款,产生资金损失与退款。

所以两者的代码路径必须分开:预演走 previewBreakdown,结果标记 preview;提交只能读 server 明细。展示层可以同时显示两者,但必须让用户知道哪个是预估。

一个常见的错误是「预演结果看起来对,就直接当最终金额用」。它通常能跑很久不出问题,直到某个用户用了端上不知道的会员价或地区税率。那一刻,端上算得越自信,错得越彻底。

小结

价格明细是一份五段同源的快照,不是五个可以分别计算的数字。端上可以算商品小计、可以做展示层分摊、可以预演,但不能产出最终金额。

buildBreakdown 把五段组装成一份带版本号的对象;allocateDiscount 用最大余数法保证分摊合计精确;mergeBreakdown 用版本号整份替换,拒绝拼字段;canSubmit 把「只有服务端明细能提交」写成一行可检查的规则。

下一节讨论购物车里另一类状态:勾选、失效与限购。它们不直接算钱,却决定哪些商品进入金额。

阅读导航:上一节:3.1 购物车归属:本地车、服务端车与合并 · 下一节:3.3 失效商品、限购与勾选状态 。

常见错误与边界

  • 端上按商品价、券、运费自己算出实付并直接展示,与服务端结果不一致。
  • 五段金额分别来自不同请求,拼出一份从未存在过的明细。
  • 用浮点数按比例分摊优惠,每行独立四舍五入,合计差一分钱。
  • 税费先算浮点再 toFixed(2),半分边界向下舍入。
  • 预演结果不标来源,用户以为看到的就是最终价格。
  • 同版本号但金额不同的明细被静默接受,端上挑一个显示。
  • 旧版本明细覆盖新版本,用户看到已经失效的优惠。
  • 展示层各自拼五段,某一页漏掉税费或运费。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查