三段优惠叠加后,为什么端上和服务端会差几十元
用户在一个订单里同时用了会员价、店铺活动和一张满减券,端上显示实付 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 遍历优惠对象,属性顺序依赖插入顺序。运营调整配置后,同样的输入在不同设备上得到不同结果,问题极难复现。
修复做了四件事:
- 顺序固定为会员、活动、券、积分,类型内才允许调优先级。
- 积分只抵商品余额,运费单独相加。
- 所有遍历改为显式排序,结果与输入数组顺序无关。
- 预演与服务端结果逐项对账;服务端更贵或预演优惠消失时,必须二次确认。
这类问题的教训是:叠加不是「把优惠加起来」,而是一套必须能被审计的计算流程。顺序、基数、舍入、互斥,每一项都要有唯一答案。
小结
叠加规则由三部分组成:顺序固定、互斥可声明、计算逐级且不出现负数。会员价先改变商品价格,活动与券依次在余额上抵扣,积分最后只抵商品金额;门槛统一按商品小计判定;冲突保留排序靠前的一张并显式记录。applyPromos 产出可对账的结果,reconcileStack 把端上预演与服务端结果的差异变成可处理的信号:更贵就确认,更便宜就提示,优惠消失必须让用户知道。端上预演的价值是即时反馈,不是替代服务端。
阅读导航:上一节:7.1 优惠券的可用性判定 · 下一节:7.3 优惠展示的诚实性 。
常见错误与边界
- 端上按「券面额直接减」,忽略会员价与活动已经改变了计算基数。
- 折扣券每一步都四舍五入,最终实付与服务端差一分到几元。
- 把积分放在运费之前抵扣,用户以为运费也能用积分。
- 互斥写在业务
if里,新增活动时没人知道还有哪些组合被挡住。 - 冲突时静默丢弃一张券,用户只看到金额变化,不知道为什么。
- 用对象属性遍历决定叠加顺序,配置调整后结果不可复现。
- 服务端结果更贵时直接跳转支付,不做二次确认。
- 预演优惠在服务端消失但实付恰好相同,就当作一致,不提示用户。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。