《互联网产品的客户端架构·交易、增长与平台化》7.1 优惠券的可用性判定

优惠券的「可用」不是布尔值,而是门槛、范围、有效期、领取状态与互斥关系共同裁决的结果。本节把判定顺序写成可测试的纯函数,用服务端时钟避免设备时间漂移,端上只做预筛与提示,并复盘一次因设备时间与范围并集导致的券失效事故。并给出可运行的 TypeScript 纯函数与测试。

结算页上的二十张券

用户点开结算页,券包里躺着二十张券。端上要做的事不是「算出一个最终答案」,而是先回答一个更窄的问题:哪些券值得放在可用区,哪些必须收进不可用区并说明原因。放错一张,用户点进去才发现不能用;原因写错一句,客服就要多接十个工单。优惠券的「可用」从来不是一个布尔值:它同时受门槛、适用范围、有效期、领取与使用状态、互斥关系约束,每一维度的失败都要给出不同的提示。端上可以快速预筛,但最终裁决必须来自服务端——因为库存、会员身份、风控与活动名额只有服务端知道。本节只做一件事:把可用性判定拆成固定顺序的纯函数,让「为什么不可用」成为一个可测试的输出,而不是散落在 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」,端上先按品类匹配、再减排除项,但门槛计算用的是并集后的金额,导致部分订单在端上显示可用、服务端判定不足门槛。

修复做了三件事:

  1. 进入结算与券包时,服务端返回 serverNow,端上只在这个基准上累加本地经过时间。
  2. 范围匹配统一成「先取交集、再减排除项」,门槛只按最终匹配金额计算。
  3. 提交前不再信任端上判定,服务端返回每张券的 usable 与 reason,端上整体替换。

这类问题的教训不是「端上不该判断」,而是端上判断必须有一个明确的权威时间与一套可复现的顺序。两者缺一,判定就会在边界上随机摇摆。

小结

优惠券的可用性是六个维度的联合判定,不是单个布尔值。端上可以做预筛、解释与排序,但不能做最终裁决;有效期必须用服务端时间,范围同时配置时要取交集,判定顺序要固定,原因要具体到差额与件数。evaluateCoupon 把这些规则收成一个纯函数,bestCoupon 在此之上给出稳定推荐,clockNow 把设备时间漂移挡在门外。它们不解决叠加,也不解决展示——那是接下来两节的事。

阅读导航:上一节:6.3 退款与售后状态 · 下一节:7.2 叠加规则与端上预演 。

常见错误与边界

  • 用设备时间判断券的有效期,跨时区或手动改时间后判定漂移。
  • 把「指定品类」与「指定商品」实现成并集,券的覆盖范围被悄悄放大。
  • 门槛按购物车全部金额计算,没有先减掉排除商品。
  • 只返回「不可用」,不返回原因,用户无法判断该凑单还是换券。
  • 把已使用与已过期混成一个状态,文案与后续动作都无法区分。
  • 端上判定可用后直接把券写进订单,提交时不重新校验。
  • 互斥关系在判定阶段就强行剔除,用户看不到「为什么这张券消失了」。
  • 空购物车或全部商品被排除时,仍然返回「未达门槛」而不是「本单不参与」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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