「原价 199,券后 79」为什么是一句假话
商品页最常见的三行字是:划线价 199、售价 99、券后价 79。如果 199 从未真实成交过,第一行就是假话;如果那张券其实不可用,第三行也是假话。两句假话叠加,用户会觉得自己被诱导,而不是被优惠。优惠展示的难点不在排版,而在每个数字都要能回答「依据是什么」:划线价要有历史成交或市场价依据,券后价要以券真正可用为前提,「预计省」要和用户实际少付的钱对得上。任何一个数字对不上,短期可能提升点击,长期一定变成投诉与信任损失。本节把价格展示拆成四类口径,并给出一个只做加法的展示模块:它不计算优惠,只决定「哪些数字可以出现、出现在哪里、配什么文案」。
四个价格口径
| 口径 | 定义 | 出现条件 | 常见错误 |
|---|---|---|---|
| 划线价 | 历史成交价或可核验市场价 | 有来源、高于售价、核验在有效期内 | 把从未卖过的吊牌价当原价 |
| 售价 | 当前实际销售价 | 始终展示 | 用活动价冒充日常价 |
| 券后价 | 售价减去真实可用优惠 | 券可用,且抵扣已确认 | 券不可用仍展示券后价 |
| 预计省 | 划线价与预计到手价的差额 | 划线价可信,差额为正 | 把券面额当节省额 |
划线价的四个展示条件
- 来源是历史成交价或可核验的市场价,不能是后台手填的吊牌价;金额严格高于当前售价,等于或低于售价没有展示意义。
- 核验时间不晚于当前时间,避免用未来数据;核验时间距今不超过 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 元」,不检查门槛与实际抵扣。
- 有券后价又有「预计省」,两个数字口径不同却并排展示。
- 不可用原因只写「不可用」,用户不知道该凑单还是换券。
- 失效券从券包消失,用户怀疑平台收回已领取的权益。
- 已使用与已过期的按钮状态相同,都还能点「去使用」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。