《互联网产品的客户端架构·交易、增长与平台化》7.3 优惠展示的诚实性

划线价、券后价与「预计省 X 元」不是三个装饰数字,而是三句可以被核验的事实陈述。本节给出划线价的展示条件、券后价与优惠金额的计算口径、不可用原因的具体文案,以及失效券保留但不给动作的展示方式,避免用不存在的原价制造假优惠。价格区因此可以按固定顺序渲染。

「原价 199,券后 79」为什么是一句假话

商品页最常见的三行字是:划线价 199、售价 99、券后价 79。如果 199 从未真实成交过,第一行就是假话;如果那张券其实不可用,第三行也是假话。两句假话叠加,用户会觉得自己被诱导,而不是被优惠。优惠展示的难点不在排版,而在每个数字都要能回答「依据是什么」:划线价要有历史成交或市场价依据,券后价要以券真正可用为前提,「预计省」要和用户实际少付的钱对得上。任何一个数字对不上,短期可能提升点击,长期一定变成投诉与信任损失。本节把价格展示拆成四类口径,并给出一个只做加法的展示模块:它不计算优惠,只决定「哪些数字可以出现、出现在哪里、配什么文案」。

四个价格口径

口径定义出现条件常见错误
划线价历史成交价或可核验市场价有来源、高于售价、核验在有效期内把从未卖过的吊牌价当原价
售价当前实际销售价始终展示用活动价冒充日常价
券后价售价减去真实可用优惠券可用,且抵扣已确认券不可用仍展示券后价
预计省划线价与预计到手价的差额划线价可信,差额为正把券面额当节省额

划线价的四个展示条件

  1. 来源是历史成交价或可核验的市场价,不能是后台手填的吊牌价;金额严格高于当前售价,等于或低于售价没有展示意义。
  2. 核验时间不晚于当前时间,避免用未来数据;核验时间距今不超过 90 天,超过就视为失效。第四条常被忽略:一年前的价格即使真实,也不能代表当前的市场参照。展示一个过期的划线价,和编造一个价格在用户体验上没有区别。

券后价与预计省的口径

券后价只在券可用时出现,计算方式是「售价 − 会员优惠 − 券抵扣」,并且不允许为负。券不可用时,价格区不显示券后价,而是显示「优惠券不可用」加一行具体原因。「预计省」的基准必须明确:

  • 有可信划线价时,省 = 划线价 − 预计到手商品金额,文案用「预计省」。
  • 没有可信划线价时,只报告本单真实发生的优惠,文案用「已优惠」,不能用「省」。
  • 券不可用时,券面额不计入任何节省金额。这条规则挡住的是最常见的假优惠:把「满 200 减 100」的券面额直接写成「省 100」,但用户当前订单只有 80 元,券根本用不了。

不可用原因与失效券

不可用原因要具体到用户能行动。失效券也不要从券包里删掉——用户需要看到「这张券确实存在过,只是不能用了」,否则会怀疑平台吞券;正确做法是保留卡片、降低视觉权重、去掉动作按钮,过期与已使用都不显示「去使用」。

原因文案用户能做什么
未达金额门槛还差 12.00 元可用凑单
未达件数门槛还差 1 件可用加购
商品不参与本单商品不参与换券
已过期已过期换券
已使用已使用无
被订单占用已被订单占用取消旧订单或等待

一个可运行的展示模块

下面的模块属于 L1:输入是售价、划线价、券信息与运费,输出是可直接渲染的价格行与券卡片状态。

// file: src/coupon/display.ts
// tier: L1
// verify: node --test test/coupon/display.test.ts
import { yuan } from "../core/trade-model.ts";
import type { CouponReason } from "./eligibility.ts";

export interface StrikePrice {
  cents: number;
  source: "history" | "market" | "none";
  verifiedAt: number;
}

export interface DisplayCoupon {
  faceCents: number;
  discountCents: number;
  usable: boolean;
  reason: CouponReason;
  thresholdCents: number;
  matchedCents: number;
  minItemCount: number;
  matchedCount: number;
  expiresAt: number;
}

export interface DisplayInput {
  salePriceCents: number;
  strike: StrikePrice;
  now: number;
  coupon: DisplayCoupon | null;
  memberDiscountCents: number;
  shippingCents: number;
  maxStrikeAgeDays?: number;
}

export type PriceRowKind =
  | "strike"
  | "sale"
  | "coupon"
  | "saving"
  | "shipping"
  | "payable"
  | "note";

export interface PriceRow {
  kind: PriceRowKind;
  label: string;
  cents: number | null;
  strong: boolean;
  muted: boolean;
}

export interface StrikeView {
  show: boolean;
  cents: number | null;
  reason: string;
}

export interface SavingsView {
  basis: "strike" | "applied" | "none";
  cents: number;
  label: string;
}

export type CouponLifecycle = "unclaimed" | "usable" | "used" | "expired";

export interface CouponTileInput {
  id: string;
  title: string;
  validTo: number;
  claimed: boolean;
  usedAt: number | null;
  now: number;
}

export interface CouponTile {
  id: string;
  title: string;
  lifecycle: CouponLifecycle;
  copy: string;
  cta: string | null;
  muted: boolean;
}

// 划线价必须有可核验来源、高于售价、且在有效期内,否则一律不展示。
export function honestStrike(input: DisplayInput): StrikeView {
  assertSale(input);
  const maxAgeMs = (input.maxStrikeAgeDays ?? 90) * 86_400_000;
  if (input.strike.source === "none") {
    return { show: false, cents: null, reason: "无历史价格依据" };
  }
  if (
    !Number.isSafeInteger(input.strike.cents) ||
    input.strike.cents <= input.salePriceCents
  ) {
    return { show: false, cents: null, reason: "划线价不高于售价" };
  }
  if (!Number.isSafeInteger(input.strike.verifiedAt) || input.strike.verifiedAt > input.now) {
    return { show: false, cents: null, reason: "核验时间非法" };
  }
  if (input.now - input.strike.verifiedAt > maxAgeMs) {
    return { show: false, cents: null, reason: "历史价格已超过有效期" };
  }
  return { show: true, cents: input.strike.cents, reason: "" };
}

export function appliedDiscountCents(input: DisplayInput): number {
  assertSale(input);
  assertInt(input.memberDiscountCents, "会员优惠");
  let discount = input.memberDiscountCents;
  if (input.coupon !== null && input.coupon.usable) {
    assertInt(input.coupon.discountCents, "券抵扣");
    discount += input.coupon.discountCents;
  }
  return Math.min(discount, input.salePriceCents);
}

// 券后价只在券真正可用时出现;不可用时返回 null,由文案解释原因。
export function couponAfterCents(input: DisplayInput): number | null {
  if (input.coupon === null || !input.coupon.usable) return null;
  const discount = appliedDiscountCents(input);
  return Math.max(0, input.salePriceCents - discount);
}

export function estimatedPayableCents(input: DisplayInput): number {
  assertInt(input.shippingCents, "运费");
  const goods = input.salePriceCents - appliedDiscountCents(input);
  return goods + input.shippingCents;
}

// 「省」只在有可信划线价时使用;否则只报告本单真实发生的优惠。
export function savingsView(input: DisplayInput): SavingsView {
  const strike = honestStrike(input);
  const applied = appliedDiscountCents(input);
  if (strike.show && strike.cents !== null) {
    const goodsAfterDiscount = Math.max(0, input.salePriceCents - applied);
    const saved = Math.max(0, strike.cents - goodsAfterDiscount);
    if (saved > 0) return { basis: "strike", cents: saved, label: "预计省" };
  }
  if (applied > 0) return { basis: "applied", cents: applied, label: "已优惠" };
  return { basis: "none", cents: 0, label: "暂无优惠" };
}

export function reasonCopy(coupon: DisplayCoupon, now: number): string {
  if (!Number.isSafeInteger(now)) throw new Error("展示时间必须是安全整数毫秒");
  switch (coupon.reason) {
    case "usable":
      return "可用";
    case "not_claimed":
      return "领取后可用";
    case "used":
      return "已使用";
    case "locked":
      return "已被订单占用";
    case "not_started":
      return "活动未开始";
    case "expired":
      return "已过期";
    case "out_of_scope":
      return "本单商品不参与";
    case "below_item_count": {
      const gap = Math.max(0, coupon.minItemCount - coupon.matchedCount);
      return `还差 ${gap} 件可用`;
    }
    case "below_threshold": {
      const gap = Math.max(0, coupon.thresholdCents - coupon.matchedCents);
      return `还差 ${yuan(gap)} 元可用`;
    }
  }
}

// 价格区按固定顺序排列,任何一层都不显示负数。
export function priceRows(input: DisplayInput): PriceRow[] {
  assertSale(input);
  const rows: PriceRow[] = [];
  const strike = honestStrike(input);
  if (strike.show && strike.cents !== null) {
    rows.push({ kind: "strike", label: "划线价", cents: strike.cents, strong: false, muted: true });
  }
  rows.push({ kind: "sale", label: "售价", cents: input.salePriceCents, strong: true, muted: false });

  if (input.coupon !== null) {
    const after = couponAfterCents(input);
    if (after !== null) {
      rows.push({ kind: "coupon", label: "券后价", cents: after, strong: true, muted: false });
    } else {
      rows.push({ kind: "coupon", label: "优惠券不可用", cents: null, strong: false, muted: true });
      rows.push({
        kind: "note",
        label: reasonCopy(input.coupon, input.now),
        cents: null,
        strong: false,
        muted: true,
      });
    }
  }

  const savings = savingsView(input);
  if (savings.cents > 0) {
    rows.push({ kind: "saving", label: savings.label, cents: savings.cents, strong: false, muted: false });
  }
  if (input.shippingCents > 0) {
    rows.push({
      kind: "shipping",
      label: "运费",
      cents: input.shippingCents,
      strong: false,
      muted: false,
    });
  }
  rows.push({
    kind: "payable",
    label: "预计到手价",
    cents: estimatedPayableCents(input),
    strong: true,
    muted: false,
  });
  return rows;
}

// 失效券留在券包里,但去掉动作按钮,避免用户点了才发现不能用。
export function couponTile(input: CouponTileInput): CouponTile {
  if (!Number.isSafeInteger(input.validTo) || !Number.isSafeInteger(input.now)) {
    throw new Error("券有效期必须是安全整数毫秒");
  }
  let lifecycle: CouponLifecycle;
  if (input.usedAt !== null) {
    lifecycle = "used";
  } else if (input.now >= input.validTo) {
    lifecycle = "expired";
  } else if (!input.claimed) {
    lifecycle = "unclaimed";
  } else {
    lifecycle = "usable";
  }
  const copy: Record<CouponLifecycle, string> = {
    unclaimed: "待领取",
    usable: "可使用",
    used: "已使用",
    expired: "已过期",
  };
  const cta: Record<CouponLifecycle, string | null> = {
    unclaimed: "去领取",
    usable: "去使用",
    used: null,
    expired: null,
  };
  return {
    id: input.id,
    title: input.title,
    lifecycle,
    copy: copy[lifecycle],
    cta: cta[lifecycle],
    muted: lifecycle === "used" || lifecycle === "expired",
  };
}

function assertSale(input: DisplayInput): void {
  assertInt(input.salePriceCents, "售价");
  if (!Number.isSafeInteger(input.now)) throw new Error("展示时间必须是安全整数毫秒");
}

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

测试与真实输出(本机)

$ node --test test/coupon/display.test.ts
✔ 划线价高于售价且可核验时展示 (0.671542ms)
✔ 没有历史价格依据时不展示划线价 (0.136042ms)
✔ 划线价不高于售价时不展示 (0.064833ms)
✔ 历史价格超过核验有效期时不展示 (0.063708ms)
✔ 核验时间晚于当前时间时视为非法 (0.084208ms)
✔ 券可用时给出券后价 (0.114417ms)
✔ 券不可用时不给出券后价 (0.057625ms)
✔ 券面额大于售价时券后价为零,不出现负数 (0.057958ms)
✔ 未达金额门槛时给出精确差额 (0.131125ms)
✔ 未达件数门槛时给出还差件数 (0.113833ms)
✔ 已使用与已过期的文案不同 (0.066792ms)
✔ 有可信划线价时按划线价计算预计省 (0.081958ms)
✔ 没有可信划线价时只报告已发生的优惠 (0.057292ms)
✔ 不可用券的面额不计入优惠 (0.053625ms)
✔ 会员优惠计入已优惠金额 (0.047167ms)
✔ 没有任何优惠时显示暂无优惠 (0.487459ms)
✔ 价格行按固定顺序排列 (0.13825ms)
✔ 不可用券出现在价格区并附带原因 (0.084417ms)
✔ 价格行不出现负数 (0.103667ms)
✔ 预计到手价包含运费 (0.033459ms)
✔ 过期券留在券包但没有动作按钮 (0.072542ms)
✔ 未领取券显示去领取 (0.038292ms)
✔ 已使用优先于过期判定 (0.0375ms)
ℹ tests 23
ℹ suites 0
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 119.617042

二十三条用例覆盖:划线价的五种边界、券后价的可用性与零值、金额与件数差额文案、已使用与已过期、三种节省口径、价格行顺序与负数保护、运费、失效券与未领取券的按钮状态。

一次「假划线价」的复盘

某交易客户端为了提升大促点击率,在商品页展示「原价 199,券后 79」。199 来自运营填写的吊牌价,这件商品从未以该价格成交;券后价则直接按券面额计算,没有检查券是否可用。

活动期间点击率确实上去了,但退款与投诉集中在两句话上:「根本没卖过 199」「券根本用不了」。复盘发现三个问题:划线价没有来源字段,端上无法判断它是否可信;券后价在券不可用时仍然展示,因为展示层只读了券面额;「预计省 120」把划线价差额与券面额重复计算,实际只省了 20。

修复做了四件事:划线价必须带来源与核验时间,超过 90 天不展示;券后价只在券可用时出现;节省金额只按可信基准与实际抵扣计算;不可用券保留在券包但去掉按钮,并展示具体原因。修复后点击率回落了一截,但「价格不符」类投诉明显减少。这个取舍值得:优惠展示的目标不是让数字看起来最低,而是让用户相信看到的数字。

小结

优惠展示是事实陈述,不是视觉装饰。划线价要有来源、高于售价且在核验有效期内;券后价只在券可用时出现;「预计省」只在有可信基准时使用,否则退回「已优惠」;不可用原因要具体到差额、件数或范围;失效券保留但降权、去掉动作。priceRows 把价格区固定成一套顺序,savingsView 把节省口径收成一个函数,couponTile 把失效券的处理从页面逻辑里拿出来。三者共同保证:页面上出现的每个数字,都能回答「依据是什么」。

阅读导航:上一节:7.2 叠加规则与端上预演 · 下一节:8.1 会员状态与权益展示 。

常见错误与边界

  • 把后台手填的吊牌价直接当划线价,商品从未以该价格成交。
  • 划线价一年前核验过一次就一直展示,早已失去参照意义。
  • 券不可用时仍展示券后价,用户点进去才发现用不了。
  • 把券面额直接写成「省 X 元」,不检查门槛与实际抵扣。
  • 有券后价又有「预计省」,两个数字口径不同却并排展示。
  • 不可用原因只写「不可用」,用户不知道该凑单还是换券。
  • 失效券从券包消失,用户怀疑平台收回已领取的权益。
  • 已使用与已过期的按钮状态相同,都还能点「去使用」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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