结算页上的二十张券
用户点开结算页,券包里躺着二十张券。端上要做的事不是「算出一个最终答案」,而是先回答一个更窄的问题:哪些券值得放在可用区,哪些必须收进不可用区并说明原因。放错一张,用户点进去才发现不能用;原因写错一句,客服就要多接十个工单。优惠券的「可用」从来不是一个布尔值:它同时受门槛、适用范围、有效期、领取与使用状态、互斥关系约束,每一维度的失败都要给出不同的提示。端上可以快速预筛,但最终裁决必须来自服务端——因为库存、会员身份、风控与活动名额只有服务端知道。本节只做一件事:把可用性判定拆成固定顺序的纯函数,让「为什么不可用」成为一个可测试的输出,而不是散落在 UI 里的 if。
可用性判定的六个维度
| 维度 | 典型规则 | 端上能否预筛 | 失败时的提示 |
|---|---|---|---|
| 领取状态 | 未领取、已领取、已使用 | 可以 | 领取后可用 / 已使用 |
| 占用状态 | 是否被未支付订单锁定 | 可以,但可能过期 | 已被订单占用 |
| 有效期 | 生效时间、失效时间 | 可以,必须用服务端时间 | 活动未开始 / 已过期 |
| 适用范围 | 品类、商品、排除项 | 可以,依赖购物车快照 | 本单商品不参与 |
| 件数门槛 | 至少 N 件 | 可以 | 还差 N 件可用 |
| 金额门槛 | 满 X 元 | 可以,基数口径要固定 | 还差 X 元可用 |
这张表的关键不在「端上能不能算」,而在算出来之后用来做什么。端上算出的结果只用于排序、置灰与文案,不能写进订单,也不能替代提交时的服务端校验。
端上判定到什么程度
一个可执行的边界是:
- 端上负责「预筛」:把明显不可用的券移出推荐位,避免用户逐张点击。
- 端上负责「解释」:不可用原因要具体到差额、件数或范围,而不是笼统的「不可用」。
- 端上负责「排序」:在可用券里按优惠额、到期时间给出默认推荐。
- 服务端负责「裁决」:提交订单时重新判定,端上结果一律被覆盖。
判定顺序决定文案质量
同一张券可能同时不满足多个条件。先报哪个原因,直接决定用户看到什么。本节采用固定短路顺序:领取状态 → 使用/占用状态 → 有效期 → 适用范围 → 件数门槛 → 金额门槛。理由是从「用户能立刻改变」到「用户很难改变」排序:没领就去领,已用就换一张;时间与范围无法当场改变;差额与件数则最适合给出具体提示。
如果顺序反过来,先报「还差 20 元」,用户凑单后才发现券已过期,体验会更差。顺序不是审美问题,而是把最可行动的失败原因放在最前面。
一个可运行的可用性模块
下面的模块属于 L1:纯函数、整数分、时间由参数注入。它不读取 Date.now(),也不发请求。
// file: src/coupon/eligibility.ts
// tier: L1
// verify: node --test test/coupon/eligibility.test.ts
export type CouponKind = "threshold" | "no_threshold" | "percent";
export type CouponReason =
| "usable"
| "not_claimed"
| "used"
| "locked"
| "not_started"
| "expired"
| "out_of_scope"
| "below_item_count"
| "below_threshold";
export interface CouponScope {
categoryIds: string[];
skuIds: string[];
excludeSkuIds: string[];
}
export interface CouponRule {
id: string;
kind: CouponKind;
discountCents: number;
percentOffBps: number;
maxDiscountCents: number;
thresholdCents: number;
minItemCount: number;
scope: CouponScope;
validFrom: number;
validTo: number;
exclusiveWith: string[];
}
export interface CouponState {
claimed: boolean;
usedAt: number | null;
lockedByOrderId: string | null;
}
export interface CouponLine {
skuId: string;
categoryId: string;
unitCents: number;
quantity: number;
}
export interface CouponCart {
lines: CouponLine[];
now: number;
}
export interface ScopeMatch {
cents: number;
count: number;
skuIds: string[];
}
export interface Eligibility {
couponId: string;
usable: boolean;
reason: CouponReason;
matchedCents: number;
matchedCount: number;
discountCents: number;
conflictsWith: string[];
}
export interface RankedCoupon extends Eligibility {
expiresAt: number;
}
export interface ServerClock {
serverNow: number;
receivedAt: number;
}
// 有效期只认服务端时间;端上计时器只负责在这条基准上累加。
export function clockNow(clock: ServerClock, elapsedMs: number): number {
if (!Number.isSafeInteger(clock.serverNow)) {
throw new Error("服务端时间必须是安全整数毫秒");
}
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
throw new Error("经过时间非法");
}
return clock.serverNow + Math.floor(elapsedMs);
}
// 指定商品与指定品类同时配置时取交集,避免运营把两者误写成并集。
export function matchScope(scope: CouponScope, lines: CouponLine[]): ScopeMatch {
const includeSku = new Set(scope.skuIds);
const includeCategory = new Set(scope.categoryIds);
const excludeSku = new Set(scope.excludeSkuIds);
let cents = 0;
let count = 0;
const skuIds: string[] = [];
for (const line of lines) {
assertLine(line);
if (excludeSku.has(line.skuId)) continue;
const skuOk = includeSku.size === 0 || includeSku.has(line.skuId);
const categoryOk = includeCategory.size === 0 || includeCategory.has(line.categoryId);
if (!skuOk || !categoryOk) continue;
const lineTotal = line.unitCents * line.quantity;
if (!Number.isSafeInteger(lineTotal)) throw new Error(`行金额溢出:${line.skuId}`);
cents += lineTotal;
if (!Number.isSafeInteger(cents)) throw new Error("匹配金额溢出");
count += line.quantity;
skuIds.push(line.skuId);
}
return { cents, count, skuIds };
}
export function discountFor(rule: CouponRule, matchedCents: number): number {
if (!Number.isSafeInteger(matchedCents) || matchedCents < 0) {
throw new Error("匹配金额必须是非负安全整数");
}
if (rule.kind === "percent") {
const raw = Math.floor((matchedCents * rule.percentOffBps) / 10_000);
const capped = rule.maxDiscountCents > 0
? Math.min(raw, rule.maxDiscountCents)
: raw;
return Math.min(capped, matchedCents);
}
return Math.min(rule.discountCents, matchedCents);
}
// 判定按固定顺序短路:状态、时间、范围、件数、金额,原因始终可复现。
export function evaluateCoupon(
rule: CouponRule,
state: CouponState,
cart: CouponCart,
others: CouponRule[] = [],
): Eligibility {
assertRule(rule);
if (!Number.isSafeInteger(cart.now)) throw new Error("判定时间必须是安全整数毫秒");
const base = {
couponId: rule.id,
matchedCents: 0,
matchedCount: 0,
discountCents: 0,
conflictsWith: conflictIds(rule, others),
};
if (!state.claimed) return { ...base, usable: false, reason: "not_claimed" };
if (state.usedAt !== null) return { ...base, usable: false, reason: "used" };
if (state.lockedByOrderId !== null) return { ...base, usable: false, reason: "locked" };
if (cart.now < rule.validFrom) return { ...base, usable: false, reason: "not_started" };
if (cart.now >= rule.validTo) return { ...base, usable: false, reason: "expired" };
const match = matchScope(rule.scope, cart.lines);
if (match.count === 0) {
return { ...base, usable: false, reason: "out_of_scope" };
}
if (match.count < rule.minItemCount) {
return {
...base,
usable: false,
reason: "below_item_count",
matchedCents: match.cents,
matchedCount: match.count,
};
}
if (match.cents < rule.thresholdCents) {
return {
...base,
usable: false,
reason: "below_threshold",
matchedCents: match.cents,
matchedCount: match.count,
};
}
return {
...base,
usable: true,
reason: "usable",
matchedCents: match.cents,
matchedCount: match.count,
discountCents: discountFor(rule, match.cents),
};
}
export function conflictIds(rule: CouponRule, rules: CouponRule[]): string[] {
const result: string[] = [];
for (const other of rules) {
if (other.id === rule.id) continue;
if (
rule.exclusiveWith.includes(other.id) ||
other.exclusiveWith.includes(rule.id)
) {
result.push(other.id);
}
}
return result.sort();
}
// 可用的券里选优惠最大;同额时先用到期的,再按 id 稳定排序。
export function bestCoupon(
rules: CouponRule[],
states: CouponState[],
cart: CouponCart,
): RankedCoupon | null {
if (rules.length !== states.length) {
throw new Error("券规则与券状态数量不一致");
}
const ranked: RankedCoupon[] = [];
for (let index = 0; index < rules.length; index += 1) {
const rule = rules[index];
const state = states[index];
const result = evaluateCoupon(rule, state, cart, rules);
if (!result.usable) continue;
ranked.push({ ...result, expiresAt: rule.validTo });
}
ranked.sort(
(a, b) =>
b.discountCents - a.discountCents ||
a.expiresAt - b.expiresAt ||
a.couponId.localeCompare(b.couponId),
);
return ranked[0] ?? null;
}
const REASON_TEXT: Record<CouponReason, string> = {
usable: "可用",
not_claimed: "尚未领取",
used: "已使用",
locked: "已被订单占用",
not_started: "未到生效时间",
expired: "已过期",
out_of_scope: "本单商品不参与",
below_item_count: "未达到件数门槛",
below_threshold: "未达到金额门槛",
};
export function reasonText(reason: CouponReason): string {
return REASON_TEXT[reason];
}
function assertRule(rule: CouponRule): void {
if (!Number.isSafeInteger(rule.discountCents) || rule.discountCents < 0) {
throw new Error("券面额必须是非负安全整数");
}
if (!Number.isInteger(rule.percentOffBps) || rule.percentOffBps < 0 || rule.percentOffBps > 10_000) {
throw new Error("折扣基点必须在 0 到 10000 之间");
}
if (!Number.isSafeInteger(rule.maxDiscountCents) || rule.maxDiscountCents < 0) {
throw new Error("折扣封顶必须是非负安全整数");
}
if (!Number.isSafeInteger(rule.thresholdCents) || rule.thresholdCents < 0) {
throw new Error("金额门槛必须是非负安全整数");
}
if (!Number.isInteger(rule.minItemCount) || rule.minItemCount < 0) {
throw new Error("件数门槛必须是非负整数");
}
if (!Number.isSafeInteger(rule.validFrom) || !Number.isSafeInteger(rule.validTo)) {
throw new Error("有效期必须是安全整数毫秒");
}
if (rule.validTo <= rule.validFrom) {
throw new Error("有效期结束必须晚于开始");
}
}
function assertLine(line: CouponLine): void {
if (!Number.isSafeInteger(line.unitCents) || line.unitCents < 0) {
throw new Error(`单价非法:${line.skuId}`);
}
if (!Number.isInteger(line.quantity) || line.quantity < 1) {
throw new Error(`数量非法:${line.skuId}`);
}
}
三个设计值得单独说明。范围同时配置商品与品类时取交集。 运营配置「品类 c1」和「商品 a」时,最安全的解释是「a 且属于 c1」。如果实现成并集,一张本该只覆盖少量商品的券会瞬间覆盖整个品类,预算会在几分钟内被领完。
有效期结束是开区间。 now >= validTo 即失效,避免「23:59:59 还能用、00:00:00 突然不能用」的边界争议。所有时间都来自调用方传入的服务端时间,端上只负责在基准上累加。互斥只输出标记,不在这里裁决。 conflictsWith 告诉上层「这张券和谁冲突」,真正的叠加顺序留给 7.2。判定与叠加是两个问题,混在一起会让两边都难测试。
测试与真实输出
本机运行结果如下:
$ node --test test/coupon/eligibility.test.ts
✔ 服务端时钟按经过时间推进并拒绝非法值 (1.41275ms)
✔ 范围同时配置商品与品类时取交集 (0.51425ms)
✔ 排除项先于门槛计算 (0.211166ms)
✔ 满减券达到门槛可用并算出优惠额 (0.103292ms)
✔ 差一分钱就不可用,且保留匹配金额供文案使用 (0.086917ms)
✔ 件数门槛按符合条件的件数计算 (0.081292ms)
✔ 未领取、已使用、被订单占用分别返回原因 (0.090708ms)
✔ 有效期按服务端时间判定,结束时间为开区间 (0.083ms)
✔ 折扣券按基点向下取整并受封顶约束 (0.085542ms)
✔ 无门槛券的优惠不超过匹配金额 (0.120417ms)
✔ 不在范围或空购物车返回 out_of_scope (0.082417ms)
✔ 互斥标记双向生效并稳定排序 (0.078459ms)
✔ 最佳券按优惠额、到期时间与 id 依次排序 (8.970917ms)
✔ 规则与状态数量不一致时拒绝挑选 (0.145334ms)
✔ 非法金额、数量与有效期被拒绝 (0.158917ms)
✔ 判定结果有稳定的中文文案 (0.063792ms)
ℹ tests 16
ℹ suites 0
ℹ pass 16
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 126.915625
十六条用例覆盖:服务端时钟、范围交集、排除项、门槛边界、件数门槛、三种状态、有效期开区间、折扣取整与封顶、无门槛券封顶、空购物车、互斥双向、最佳券排序、数量不一致、非法输入与稳定文案。
一次「设备时间」导致的券失效事故
某交易客户端的券包直接使用设备时间判断有效期。端上为了省一次请求,在进入券包时读一次 Date.now(),之后不再校准。
一次跨零点活动上线后,客服收到两类互相矛盾的反馈:一部分用户说券提前失效,另一部分说券过期后仍显示可用。复盘发现,前一类用户的手机时间比服务端快了几分钟,后一类慢了几分钟。更麻烦的是,端上把「范围配置」实现成了并集:运营配置了「品类 c1」和「排除商品 b」,端上先按品类匹配、再减排除项,但门槛计算用的是并集后的金额,导致部分订单在端上显示可用、服务端判定不足门槛。
修复做了三件事:
- 进入结算与券包时,服务端返回
serverNow,端上只在这个基准上累加本地经过时间。 - 范围匹配统一成「先取交集、再减排除项」,门槛只按最终匹配金额计算。
- 提交前不再信任端上判定,服务端返回每张券的
usable与reason,端上整体替换。
这类问题的教训不是「端上不该判断」,而是端上判断必须有一个明确的权威时间与一套可复现的顺序。两者缺一,判定就会在边界上随机摇摆。
小结
优惠券的可用性是六个维度的联合判定,不是单个布尔值。端上可以做预筛、解释与排序,但不能做最终裁决;有效期必须用服务端时间,范围同时配置时要取交集,判定顺序要固定,原因要具体到差额与件数。evaluateCoupon 把这些规则收成一个纯函数,bestCoupon 在此之上给出稳定推荐,clockNow 把设备时间漂移挡在门外。它们不解决叠加,也不解决展示——那是接下来两节的事。
阅读导航:上一节:6.3 退款与售后状态 · 下一节:7.2 叠加规则与端上预演 。
常见错误与边界
- 用设备时间判断券的有效期,跨时区或手动改时间后判定漂移。
- 把「指定品类」与「指定商品」实现成并集,券的覆盖范围被悄悄放大。
- 门槛按购物车全部金额计算,没有先减掉排除商品。
- 只返回「不可用」,不返回原因,用户无法判断该凑单还是换券。
- 把已使用与已过期混成一个状态,文案与后续动作都无法区分。
- 端上判定可用后直接把券写进订单,提交时不重新校验。
- 互斥关系在判定阶段就强行剔除,用户看不到「为什么这张券消失了」。
- 空购物车或全部商品被排除时,仍然返回「未达门槛」而不是「本单不参与」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。