《互联网产品的客户端架构·交易、增长与平台化》7.2 叠加规则与端上预演

会员价、活动、优惠券与积分同时出现时,叠加顺序、互斥组与舍入规则决定了用户最终付多少钱。本节把顺序固定为会员、活动、券、积分,用组声明处理互斥,用整数分逐级计算,并给出端上预演与服务端结果不一致时的对账与二次确认策略。所有金额保持整数分,叠加结果可逐项对账。

三段优惠叠加后,为什么端上和服务端会差几十元

用户在一个订单里同时用了会员价、店铺活动和一张满减券,端上显示实付 80 元,提交后服务端返回 109 元。用户不会关心哪一步算错了,只会记住「页面写 80,扣我 109」。叠加问题的难点不是乘法,而是顺序与资格。同一组优惠换个顺序,结果可能差几元;一张券在端上可用、在服务端不可用,差额会直接变成客诉。更隐蔽的是舍入:每一步都四舍五入,最后一定对不上。

本节把叠加拆成三件事:固定顺序、声明互斥、逐级计算。端上可以预演,但必须能解释「和服务端差在哪」,并在用户要多付钱时停下来确认。

叠加顺序是一条产品规则

步骤参与项计算基数为什么放在这里
1会员价商品小计会员价改变的是「这件商品卖多少钱」
2活动优惠扣除会员优惠后的余额活动是平台或店铺的追加让利
3优惠券扣除活动后的余额券是用户主动选择的抵扣
4积分扣除全部优惠后的商品余额积分是用户资产,最后结算且不抵运费

顺序由类型决定,运营只能在同类型内调整 priority,不能把券挪到会员价前面。原因很实际:会员价是价格身份,活动是营销预算,券是用户权益,积分是用户资产。四者的资金来源与审计口径不同,顺序随意变化会让财务对账失去基准。门槛统一按商品小计判定,运费不计入,前面的优惠也不降低后面券的门槛。这个口径偏保守,但最容易解释:用户看到「满 100 可用」,不会因为先用了 10 元活动就突然不够门槛。

互斥不靠 if,而靠组与声明

两张券互斥,最差的实现是在业务代码里写 if (coupon.id === "A" && other.id === "B")。活动一多,这张网就没人维护得动。

本节用两个字段表达互斥:group 表示「我属于哪个互斥组」,exclusiveGroups 表示「我与哪些组不能共存」。同组互斥、跨组声明双向生效,判断逻辑只有三行。冲突发生时,resolvePromos 按「类型顺序 → 优先级 → id」保留靠前的一张,另一张进入 skipped 并标记 conflict。它不静默丢弃,而是把结果交给调用方:UI 可以提示「已为你保留优惠更大的券」,也可以让用户手动切换。

逐级计算与舍入

每一步都遵守三条规则:

  • 基数 = 商品小计 − 已应用优惠,永远不小于零。
  • 折扣券按基点向下取整,再受封顶约束;固定金额券的抵扣不超过剩余基数。
  • 积分最后抵扣,只能抵商品金额,不能抵运费;使用量超过可用量时按可用量计算。

向下取整而不是四舍五入,是为了让平台不占用户便宜:算出来 7.005 元,按 7.00 元抵扣,差额留给下一步或服务端。所有金额仍是整数分,不出现浮点。

一个可运行的叠加模块

下面的模块属于 L1:输入是商品行、优惠列表、运费与积分,输出是可对账的叠加结果。

// file: src/coupon/stack.ts
// tier: L1
// verify: node --test test/coupon/stack.test.ts
export type PromoKind = "member" | "activity" | "coupon";
export type StackSource = "preview" | "server";
export type StackSkipReason = "conflict" | "threshold" | "zero_base";

export interface Promo {
  id: string;
  kind: PromoKind;
  label: string;
  group: string;
  exclusiveGroups: string[];
  amountCents: number;
  percentOffBps: number;
  maxDiscountCents: number;
  thresholdCents: number;
  priority: number;
}

export interface StackLine {
  skuId: string;
  unitCents: number;
  quantity: number;
}

export interface PointsInput {
  available: number;
  use: number;
  valueCents: number;
}

export interface StackInput {
  lines: StackLine[];
  promos: Promo[];
  shippingCents: number;
  points: PointsInput;
  source: StackSource;
}

export interface AppliedPromo {
  id: string;
  kind: PromoKind;
  label: string;
  discountCents: number;
}

export interface SkippedPromo {
  id: string;
  reason: StackSkipReason;
}

export interface StackResult {
  source: StackSource;
  subtotalCents: number;
  discountCents: number;
  pointsCents: number;
  shippingCents: number;
  payableCents: number;
  applied: AppliedPromo[];
  skipped: SkippedPromo[];
}

export interface StackReconciliation {
  consistent: boolean;
  requiresConfirm: boolean;
  diffs: string[];
  result: StackResult;
}

// 叠加顺序由类型决定,运营只能在同一类型内调优先级,不能把券放到会员价前面。
const KIND_RANK: Record<PromoKind, number> = {
  member: 0,
  activity: 1,
  coupon: 2,
};

function comparePromo(a: Promo, b: Promo): number {
  return (
    KIND_RANK[a.kind] - KIND_RANK[b.kind] ||
    a.priority - b.priority ||
    a.id.localeCompare(b.id)
  );
}

function conflicts(a: Promo, b: Promo): boolean {
  if (a.group !== "" && a.group === b.group) return true;
  if (a.group !== "" && b.exclusiveGroups.includes(a.group)) return true;
  if (b.group !== "" && a.exclusiveGroups.includes(b.group)) return true;
  return false;
}

// 冲突时保留排序更靠前的一张,另一张进入 skipped,由调用方提示用户。
export function resolvePromos(promos: Promo[]): {
  kept: Promo[];
  skipped: SkippedPromo[];
} {
  const ids = new Set<string>();
  for (const promo of promos) {
    assertPromo(promo);
    if (ids.has(promo.id)) throw new Error(`优惠 id 重复:${promo.id}`);
    ids.add(promo.id);
  }
  const ordered = [...promos].sort(comparePromo);
  const kept: Promo[] = [];
  const skipped: SkippedPromo[] = [];
  for (const promo of ordered) {
    if (kept.some((current) => conflicts(current, promo))) {
      skipped.push({ id: promo.id, reason: "conflict" });
      continue;
    }
    kept.push(promo);
  }
  return { kept, skipped };
}

export function applyPromos(input: StackInput): StackResult {
  if (input.source !== "preview" && input.source !== "server") {
    throw new Error(`未知的叠加来源:${String(input.source)}`);
  }
  if (!Number.isSafeInteger(input.shippingCents) || input.shippingCents < 0) {
    throw new Error("运费必须是非负安全整数");
  }
  const subtotalCents = subtotalOf(input.lines);
  const { kept, skipped } = resolvePromos(input.promos);
  const applied: AppliedPromo[] = [];
  let discountCents = 0;

  for (const promo of kept) {
    // 门槛统一按商品小计判定,运费不计入,前面的优惠也不降低门槛。
    if (subtotalCents < promo.thresholdCents) {
      skipped.push({ id: promo.id, reason: "threshold" });
      continue;
    }
    const base = subtotalCents - discountCents;
    if (base <= 0) {
      skipped.push({ id: promo.id, reason: "zero_base" });
      continue;
    }
    const raw = promo.percentOffBps > 0
      ? Math.floor((base * promo.percentOffBps) / 10_000)
      : promo.amountCents;
    const capped = promo.maxDiscountCents > 0
      ? Math.min(raw, promo.maxDiscountCents)
      : raw;
    const discount = Math.min(capped, base);
    if (discount <= 0) {
      skipped.push({ id: promo.id, reason: "zero_base" });
      continue;
    }
    discountCents += discount;
    applied.push({
      id: promo.id,
      kind: promo.kind,
      label: promo.label,
      discountCents: discount,
    });
  }

  const pointsCents = pointsDeduction(input.points, subtotalCents - discountCents);
  const payableCents =
    subtotalCents - discountCents - pointsCents + input.shippingCents;
  if (!Number.isSafeInteger(payableCents) || payableCents < 0) {
    throw new Error("实付金额非法");
  }
  return {
    source: input.source,
    subtotalCents,
    discountCents,
    pointsCents,
    shippingCents: input.shippingCents,
    payableCents,
    applied,
    skipped,
  };
}

// 积分最后抵,只能抵商品金额,不能抵运费;用不完的积分不扣减。
export function pointsDeduction(points: PointsInput, goodsCents: number): number {
  assertInt(points.available, "可用积分");
  assertInt(points.use, "使用积分");
  assertInt(points.valueCents, "积分单价");
  if (goodsCents < 0) throw new Error("可抵扣商品金额不能为负");
  const used = Math.min(points.use, points.available);
  const value = used * points.valueCents;
  if (!Number.isSafeInteger(value)) throw new Error("积分抵扣金额溢出");
  return Math.min(value, goodsCents);
}

// 端上预演与服务端结果逐项比对;服务端更贵或预演优惠消失时必须二次确认。
export function reconcileStack(
  preview: StackResult,
  server: StackResult,
): StackReconciliation {
  if (preview.source !== "preview") throw new Error("左侧必须是端上预演");
  if (server.source !== "server") throw new Error("右侧必须是服务端结果");
  const diffs: string[] = [];
  if (preview.subtotalCents !== server.subtotalCents) {
    diffs.push(`subtotal:${preview.subtotalCents}->${server.subtotalCents}`);
  }
  if (preview.discountCents !== server.discountCents) {
    diffs.push(`discount:${preview.discountCents}->${server.discountCents}`);
  }
  if (preview.pointsCents !== server.pointsCents) {
    diffs.push(`points:${preview.pointsCents}->${server.pointsCents}`);
  }
  if (preview.shippingCents !== server.shippingCents) {
    diffs.push(`shipping:${preview.shippingCents}->${server.shippingCents}`);
  }
  if (preview.payableCents !== server.payableCents) {
    diffs.push(`payable:${preview.payableCents}->${server.payableCents}`);
  }

  const previewApplied = new Map(preview.applied.map((item) => [item.id, item]));
  const serverApplied = new Map(server.applied.map((item) => [item.id, item]));
  const ids = [...new Set([...previewApplied.keys(), ...serverApplied.keys()])].sort();
  let lostPreviewPromo = false;
  for (const id of ids) {
    const before = previewApplied.get(id)?.discountCents ?? 0;
    const after = serverApplied.get(id)?.discountCents ?? 0;
    if (before !== after) diffs.push(`applied:${id}:${before}->${after}`);
    if (before > 0 && after < before) lostPreviewPromo = true;
  }

  const priceIncreased = server.payableCents > preview.payableCents;
  return {
    consistent: diffs.length === 0,
    requiresConfirm: priceIncreased || lostPreviewPromo,
    diffs,
    result: server,
  };
}

function subtotalOf(lines: StackLine[]): number {
  let total = 0;
  for (const line of 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}`);
    total += lineTotal;
    if (!Number.isSafeInteger(total)) throw new Error("商品小计溢出");
  }
  return total;
}

function assertPromo(promo: Promo): void {
  if (promo.kind !== "member" && promo.kind !== "activity" && promo.kind !== "coupon") {
    throw new Error(`未知的优惠类型:${String(promo.kind)}`);
  }
  assertInt(promo.amountCents, `优惠金额 ${promo.id}`);
  assertInt(promo.maxDiscountCents, `优惠封顶 ${promo.id}`);
  assertInt(promo.thresholdCents, `优惠门槛 ${promo.id}`);
  if (!Number.isInteger(promo.percentOffBps) || promo.percentOffBps < 0 || promo.percentOffBps > 10_000) {
    throw new Error(`折扣基点非法:${promo.id}`);
  }
  if (!Number.isInteger(promo.priority)) throw new Error(`优先级非法:${promo.id}`);
}

function assertInt(value: number, name: string): void {
  if (!Number.isSafeInteger(value) || value < 0) {
    throw new Error(`${name} 必须是非负安全整数`);
  }
}

applyPromos 是唯一组装优惠的地方,resolvePromos 只负责互斥,pointsDeduction 只负责积分,reconcileStack 只负责预演与服务端的差异。四个函数各管一件事,测试才能分别盯住顺序、互斥、舍入与对账。

测试与真实输出

本机运行结果如下:

$ node --test test/coupon/stack.test.ts
✔ 固定优惠按会员、活动、券的顺序叠加 (1.180208ms)
✔ 门槛按商品小计判定,运费不计入 (0.126125ms)
✔ 同一互斥组只保留排序靠前的一张 (0.090666ms)
✔ 跨组声明的互斥双向生效 (0.124458ms)
✔ 折扣券在扣除前序优惠后的余额上计算并向下取整 (0.090167ms)
✔ 折扣券受封顶约束 (0.085042ms)
✔ 优惠总额不会把商品金额减成负数 (8.5705ms)
✔ 积分最后抵扣且不能抵运费 (0.126667ms)
✔ 使用积分超过可用值时按可用值抵扣 (0.078917ms)
✔ 结果明细与实付金额互相对账 (0.151958ms)
✔ 重复的优惠 id 被拒绝 (0.275042ms)
✔ 非法优惠金额与运费被拒绝 (0.0975ms)
✔ 预演与服务端一致时不需要确认 (0.177667ms)
✔ 服务端更贵时要求二次确认并列出金额差异 (0.113416ms)
✔ 服务端更便宜时记录差异但不要求确认 (0.08675ms)
✔ 预演优惠在服务端消失时即使实付不变也要确认 (0.062584ms)
✔ 对账只接受预演对服务端 (0.129792ms)
✔ 叠加结果不受优惠数组顺序影响 (0.108083ms)
✔ 没有优惠时实付等于商品小计加运费 (0.062375ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 124.740875

十九条用例覆盖:三类优惠顺序、门槛基数、同组与跨组互斥、折扣取整与封顶、负值保护、积分边界、结果对账、重复 id、非法输入、三种对账结论、顺序无关与空优惠。

一次「积分抵运费」的复盘

某交易客户端的结算页为了「让用户看到最低价」,把会员优惠、活动、券和积分依次相减,最后用 Math.max(0, ...) 兜底。上线后出现两类问题。

第一类是多付。端上先减券再减活动,服务端先减活动再减券。固定金额券看起来没有顺序,但折扣券的基数不同,最终差了几元。端上还把这笔差额当成「服务端算错了」,没有提示用户,直接跳转到支付。

第二类是少付。端上把积分抵扣算在运费之前,用户用积分把运费也抵掉了,服务端只允许抵商品金额,实付比页面高。用户认为页面承诺了更低价格。复盘还发现一个工程问题:叠加代码用 for...in 遍历优惠对象,属性顺序依赖插入顺序。运营调整配置后,同样的输入在不同设备上得到不同结果,问题极难复现。

修复做了四件事:

  1. 顺序固定为会员、活动、券、积分,类型内才允许调优先级。
  2. 积分只抵商品余额,运费单独相加。
  3. 所有遍历改为显式排序,结果与输入数组顺序无关。
  4. 预演与服务端结果逐项对账;服务端更贵或预演优惠消失时,必须二次确认。

这类问题的教训是:叠加不是「把优惠加起来」,而是一套必须能被审计的计算流程。顺序、基数、舍入、互斥,每一项都要有唯一答案。

小结

叠加规则由三部分组成:顺序固定、互斥可声明、计算逐级且不出现负数。会员价先改变商品价格,活动与券依次在余额上抵扣,积分最后只抵商品金额;门槛统一按商品小计判定;冲突保留排序靠前的一张并显式记录。applyPromos 产出可对账的结果,reconcileStack 把端上预演与服务端结果的差异变成可处理的信号:更贵就确认,更便宜就提示,优惠消失必须让用户知道。端上预演的价值是即时反馈,不是替代服务端。

阅读导航:上一节:7.1 优惠券的可用性判定 · 下一节:7.3 优惠展示的诚实性 。

常见错误与边界

  • 端上按「券面额直接减」,忽略会员价与活动已经改变了计算基数。
  • 折扣券每一步都四舍五入,最终实付与服务端差一分到几元。
  • 把积分放在运费之前抵扣,用户以为运费也能用积分。
  • 互斥写在业务 if 里,新增活动时没人知道还有哪些组合被挡住。
  • 冲突时静默丢弃一张券,用户只看到金额变化,不知道为什么。
  • 用对象属性遍历决定叠加顺序,配置调整后结果不可复现。
  • 服务端结果更贵时直接跳转支付,不做二次确认。
  • 预演优惠在服务端消失但实付恰好相同,就当作一致,不提示用户。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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