到期不是一个布尔值
会员到期在代码里经常被写成一个布尔值:isExpired = now > expiresAt。这个判断本身没错,但它只回答了「会员还在不在」,没有回答另外三个更重要的问题:
- 到期后具体少了哪些权益?
- 应该在什么时候提醒用户?
- 到期前已经下单、还没支付的订单,会员价还算不算?
这三个问题的答案都不在 isExpired 里。把降级当成一个开关,最常见的后果是:权益页突然全部变灰,用户不知道发生了什么;提醒在到期当天才发,用户来不及续费;待支付订单在支付瞬间被改价,用户认为平台毁约。降级是一个有阶段、有范围、有快照的过程。这一节把它拆成三层:权益比较、提醒节奏、订单价格。
降级影响的三层:权益、价格、订单
先看会员到期到底改变了什么。不同对象的变化规则完全不同。
| 对象 | 到期后的变化 | 事实来源 | 端上能否自行决定 |
|---|---|---|---|
| 权益清单 | 高等级权益逐条失效,基础权益保留 | 服务端等级配置 | 不能,只能比较 |
| 新订单价格 | 会员价停止,按原价展示 | 服务端当前价格 | 不能,必须重新报价 |
| 已支付订单 | 按下单快照结算,不受影响 | 订单快照 | 不能,快照不可变 |
| 待支付订单 | 保留到支付截止时间,超时重算 | 订单快照 + 当前报价 | 不能,必须显式确认 |
第一层是权益。降级不是「所有权益清零」,而是「按等级差集逐条移除」。白银降到普通用户,失去会员价与免运费,但基础购物权益还在;黄金降到白银,只失去积分加速。端上要能列出「失去什么、保留什么」,而不是给一个笼统的「权益已变更」。第二层是新订单价格。会员到期后,新下单不再享受会员价,这是合理的。但端上不能自己把价格从会员价改成原价就完事,必须重新向服务端报价,并让用户看到变化。第三层是订单。已支付订单的价格已经发生,不能因为会员到期而改写;待支付订单则处在一个中间态,需要明确的保留窗口。
提醒节奏:30/7/1/到期,只升不降
到期提醒的目标是让用户在权益变化前做出知情选择,而不是制造焦虑。一个可执行的节奏是:
- T-30 天:提前提醒,告诉用户权益将在 30 天后变化,可以续费。
- T-7 天:周提醒,强调续费可保留当前权益。
- T-1 天:最后提醒,说明明天到期、续费立即延续。
- 到期当天:到期提醒,说明哪些权益已停止、已支付订单不受影响。
四个阶段必须满足「只升不降」:同一阶段只提醒一次;阶段只能向后升级;用户重新打开 App 不会从 T-30 重新开始。实现上用一个持久化的 lastStage 比较阶段序号即可。还有两个开关:用户主动关闭提醒时不再打扰;开启自动续费时不再重复提醒,只在扣款失败或到期后通知。提醒不是越多越好,关掉通知的用户连扣款失败都收不到,那才是真正的损失。
已下单订单的会员价:快照与保留窗口
这是降级里最容易产生纠纷的部分。用户在下单时享受了会员价,但支付时会员已经到期,应该按哪个价格收?按订单状态分开处理:
| 订单状态 | 价格规则 | 理由 |
|---|---|---|
| 已支付 | 永久按下单快照价 | 交易已经完成,不能追溯改价 |
| 待支付,未超保留期 | 按下单快照价 | 用户已形成合理预期 |
| 待支付,已超保留期 | 按当前报价重算并确认 | 保留期是平台承担的成本边界 |
| 已取消 | 不涉及应付金额 | 没有待履行的交易 |
「保留期」是一个业务参数,常见做法是 15 到 30 分钟,与订单支付超时保持一致。它的意义是给用户一个明确的承诺窗口:在这个窗口内,会员价有效;超过窗口,平台不再承担会员价与当前价之间的差额。端上要做的不是替用户决定,而是把规则展示清楚:待支付订单在保留期内显示「会员价保留至 xx:xx」;超过保留期后显示「会员价保留期已过,按当前价格重新确认」,并且必须让用户确认后才可支付。
文案口径:说清楚「什么时候变」「什么不变」
降级文案最容易犯两个错:只说「即将到期」不说后果;或者把「到期」说成「涨价」,制造对立。一个合格的文案要回答三个问题:
- 什么时候变:具体日期或剩余天数。
- 什么会变:会员价、免运费、积分加速等具体权益。
- 什么不变:已支付订单、已领取但未使用的权益、历史订单记录。
例如:「会员将于 5 天后到期,到期后会员价与免运费将停止;已支付订单不受影响。」这句话比「您的会员即将到期,请尽快续费」多了两条关键信息,用户才能判断要不要现在续。到期后的文案也要避免「全部失效」的恐吓。基础权益还在,已支付订单还在,这些都应该说清楚。降级不是清空账号,而是回到上一档。
一个可运行的降级与提醒模块
下面的模块属于 L1:纯函数、时间注入、无网络。它做四件事:比较两个等级的权益差、按阶段生成提醒、按订单状态决定价格、生成降级说明文案。
// file: src/member/downgrade.ts
// tier: L1
// verify: node --test test/member/downgrade.test.ts
export type Tier = "free" | "silver" | "gold" | "platinum";
export type ReminderStage = "none" | "advance" | "week" | "final" | "expired";
export type OrderStatus = "pending_payment" | "paid" | "cancelled";
export interface TierBenefit { code: string; title: string; minTier: Tier; }
export interface BenefitLoss { code: string; title: string; text: string; }
export interface BenefitComparison {
from: Tier; to: Tier; lost: BenefitLoss[]; kept: TierBenefit[];
}
export interface ReminderInput {
expiresAt: number | null; now: number; autoRenew: boolean;
optedOut: boolean; lastStage: ReminderStage;
}
export interface ReminderDecision {
stage: ReminderStage; shouldNotify: boolean; daysLeft: number | null;
title: string; detail: string;
}
export interface OrderPriceSnapshot {
orderId: string; status: OrderStatus; createdAt: number; holdUntil: number;
tierAtPurchase: Tier; listCents: number; memberCents: number | null;
snapshotCents: number;
}
export interface CurrentMemberQuote { tier: Tier; memberCents: number | null; }
export interface OrderPriceView {
orderId: string; payableCents: number; source: "snapshot" | "current" | "none";
locked: boolean; requiresConfirm: boolean; note: string;
}
export interface DowngradeNotice {
tier: Tier; expired: boolean; title: string; detail: string;
}
const DAY = 86_400_000;
const RANK: Record<Tier, number> = { free: 0, silver: 1, gold: 2, platinum: 3 };
const TIER_TEXT: Record<Tier, string> = {
free: "普通用户", silver: "白银会员", gold: "黄金会员", platinum: "铂金会员",
};
const STAGE_RANK: Record<ReminderStage, number> = {
none: 0, advance: 1, week: 2, final: 3, expired: 4,
};
const BENEFITS: TierBenefit[] = [
{ code: "basic", title: "基础购物权益", minTier: "free" },
{ code: "member_price", title: "会员价", minTier: "silver" },
{ code: "free_shipping", title: "免运费", minTier: "silver" },
{ code: "points_boost", title: "积分加速", minTier: "gold" },
{ code: "priority_service", title: "优先客服", minTier: "platinum" },
];
export function tierText(tier: Tier): string {
assertTier(tier);
return TIER_TEXT[tier];
}
export function benefitsOf(tier: Tier): TierBenefit[] {
assertTier(tier);
return BENEFITS.filter((b) => RANK[b.minTier] <= RANK[tier]).map((b) => ({ ...b }));
}
// 降级比较只回答「少了什么、留下什么」,不把升级写成降级。
export function benefitDiff(from: Tier, to: Tier): BenefitComparison {
assertTier(from);
assertTier(to);
const after = new Set(benefitsOf(to).map((b) => b.code));
const lost: BenefitLoss[] = [];
const kept: TierBenefit[] = [];
for (const benefit of benefitsOf(from)) {
if (after.has(benefit.code)) {
kept.push(benefit);
} else {
lost.push({
code: benefit.code, title: benefit.title,
text: `降级后不再享有${benefit.title}`,
});
}
}
return { from, to, lost, kept };
}
// 提醒只升不降:同一阶段不重复,跨阶段只提醒一次。
export function reminderFor(input: ReminderInput): ReminderDecision {
assertReminder(input);
if (input.expiresAt === null) {
return none(null, "长期有效,无到期提醒。");
}
const daysLeft = daysLeftOf(input.expiresAt, input.now);
if (input.optedOut) return none(daysLeft, "用户已关闭到期提醒。");
if (input.autoRenew) return none(daysLeft, "已开启自动续费,到期前不重复提醒。");
let stage: ReminderStage = "none";
if (daysLeft <= 0) stage = "expired";
else if (daysLeft <= 1) stage = "final";
else if (daysLeft <= 7) stage = "week";
else if (daysLeft <= 30) stage = "advance";
if (stage === "none" || STAGE_RANK[stage] <= STAGE_RANK[input.lastStage]) {
return { stage, shouldNotify: false, daysLeft, title: "", detail: "" };
}
const copy = stageCopy(stage, daysLeft);
return { stage, shouldNotify: true, daysLeft, ...copy };
}
// 已支付订单永久保留快照价;待支付订单只保留到支付截止时间。
export function orderPriceAfterExpiry(
order: OrderPriceSnapshot,
current: CurrentMemberQuote,
now: number,
): OrderPriceView {
assertOrder(order);
assertCurrent(current);
assertTime(now, "查询时间");
if (order.status === "cancelled") {
return view(order.orderId, 0, "none", true, false, "订单已取消,不涉及价格。");
}
if (order.status === "paid") {
return view(order.orderId, order.snapshotCents, "snapshot", true, false,
"已支付订单按下单快照结算,不受会员到期影响。");
}
if (now <= order.holdUntil) {
return view(order.orderId, order.snapshotCents, "snapshot", true, false,
"会员价保留至支付截止时间。");
}
const renewed = current.tier !== "free" && current.memberCents !== null
&& current.memberCents <= order.listCents;
if (renewed) {
return view(order.orderId, current.memberCents as number, "current", false, true,
"已续费,按当前会员价重新确认后支付。");
}
return view(order.orderId, order.listCents, "current", false, true,
"会员价保留期已过,按原价重新确认后支付。");
}
export function downgradeNotice(
tier: Tier, expiresAt: number | null, now: number,
): DowngradeNotice {
assertTier(tier);
assertTime(now, "查询时间");
if (expiresAt === null) {
return { tier, expired: false, title: "长期有效", detail: "当前等级没有到期时间,不触发降级。" };
}
assertTime(expiresAt, "到期时间");
if (now >= expiresAt) {
return {
tier, expired: true, title: "会员已到期",
detail: "当前按普通用户权益展示;已支付订单按下单快照结算。",
};
}
const daysLeft = daysLeftOf(expiresAt, now);
return {
tier, expired: false, title: `会员将于 ${daysLeft} 天后到期`,
detail: "到期后会员价与免运费停止;已支付订单不受影响。",
};
}
function none(daysLeft: number | null, detail: string): ReminderDecision {
return { stage: "none", shouldNotify: false, daysLeft, title: "", detail };
}
function view(
orderId: string, payableCents: number, source: OrderPriceView["source"],
locked: boolean, requiresConfirm: boolean, note: string,
): OrderPriceView {
return { orderId, payableCents, source, locked, requiresConfirm, note };
}
function stageCopy(
stage: Exclude<ReminderStage, "none">,
days: number,
): { title: string; detail: string } {
if (stage === "expired") {
return {
title: "会员已到期",
detail: "会员价与专属权益已停止,续费后恢复;已支付订单不受影响。",
};
}
if (stage === "final") {
return {
title: `会员将在 ${days} 天内到期`,
detail: "续费后权益立即延续;到期未续费将恢复原价。",
};
}
if (stage === "week") {
return {
title: `会员还有 ${days} 天到期`,
detail: "续费可保留当前权益;已支付订单不受影响。",
};
}
return {
title: `会员还有 ${days} 天到期`,
detail: "可提前续费;到期后会员价与免运费将停止。",
};
}
function daysLeftOf(expiresAt: number, now: number): number {
return Math.max(0, Math.ceil((expiresAt - now) / DAY));
}
function assertTier(tier: Tier): void {
if (!(tier in RANK)) throw new Error(`未知会员等级:${String(tier)}`);
}
function assertReminder(input: ReminderInput): void {
assertTime(input.now, "提醒时间");
if (input.expiresAt !== null) assertTime(input.expiresAt, "到期时间");
if (typeof input.autoRenew !== "boolean" || typeof input.optedOut !== "boolean") {
throw new Error("提醒开关必须是布尔值");
}
if (!(input.lastStage in STAGE_RANK)) {
throw new Error(`未知提醒阶段:${String(input.lastStage)}`);
}
}
function assertOrder(order: OrderPriceSnapshot): void {
if (!order.orderId) throw new Error("订单快照缺少订单号");
if (!(order.status in ORDER_STATUS)) {
throw new Error(`未知订单状态:${String(order.status)}`);
}
assertTier(order.tierAtPurchase);
assertTime(order.createdAt, "下单时间");
assertTime(order.holdUntil, "支付截止时间");
if (order.holdUntil < order.createdAt) throw new Error("支付截止时间不能早于下单时间");
assertCents(order.listCents, "原价");
if (order.memberCents !== null) assertCents(order.memberCents, "会员价");
assertCents(order.snapshotCents, "快照价");
if (order.snapshotCents > order.listCents) throw new Error("快照价不能高于原价");
}
function assertCurrent(current: CurrentMemberQuote): void {
assertTier(current.tier);
if (current.memberCents !== null) assertCents(current.memberCents, "当前会员价");
}
const ORDER_STATUS: Record<OrderStatus, string> = {
pending_payment: "待支付", paid: "已支付", cancelled: "已取消",
};
function assertCents(cents: number, label: string): void {
if (!Number.isSafeInteger(cents) || cents < 0) {
throw new Error(`${label}必须是非负安全整数分`);
}
}
function assertTime(at: number, label: string): void {
if (!Number.isSafeInteger(at) || at < 0) {
throw new Error(`${label}必须是安全整数毫秒`);
}
}
benefitsOf / benefitDiff 回答「少了什么、留下什么」。权益按 minTier 过滤,降级时取差集。升级不会产生损失,这一点由等级序号保证。reminderFor 回答「现在要不要提醒、提醒到哪一档」。它只做判断,不负责发送;阶段比较用 lastStage,保证只升不降。自动续费与用户关闭提醒都在这里短路。orderPriceAfterExpiry 回答「这笔订单现在按什么价」。已支付订单锁快照;待支付订单在保留期内锁快照,超期后按当前报价重算并要求确认。downgradeNotice 回答「怎么跟用户说」,把到期时间、剩余天数与「已支付订单不变」放进同一段文案,避免端上各自拼句子。
测试与真实输出
本机运行结果如下:
$ node --test test/member/downgrade.test.ts
✔ 等级文案稳定,普通用户只有基础权益 (0.99125ms)
✔ 白银会员获得会员价与免运费 (0.075041ms)
✔ 黄金会员在白银基础上增加积分加速 (0.058125ms)
✔ 铂金会员拥有全部权益 (0.077583ms)
✔ 白银降为普通用户时列出两项损失并保留基础权益 (0.1195ms)
✔ 黄金降为白银只损失积分加速 (0.080167ms)
✔ 升级不产生任何损失 (0.054667ms)
✔ 距到期超过三十天不提醒 (0.126292ms)
✔ 距到期三十天内进入提前提醒 (0.086708ms)
✔ 距到期七天内进入周提醒 (0.101333ms)
✔ 距到期一天内进入最后提醒 (0.0655ms)
✔ 到期时刻按已到期处理 (0.097792ms)
✔ 开启自动续费后不再重复提醒 (0.062ms)
✔ 用户关闭提醒后不再打扰 (0.050125ms)
✔ 同一阶段重复调用不重复提醒 (0.045166ms)
✔ 提醒阶段可以向后升级一次 (0.045459ms)
✔ 已支付订单在会员到期后仍按下单快照结算 (0.13725ms)
✔ 待支付订单在保留期内仍锁定会员价 (0.05675ms)
✔ 保留期过后未续费按原价重新确认 (0.044708ms)
✔ 保留期过后已续费则使用当前会员价 (0.038417ms)
✔ 已取消订单不展示应付金额 (0.040917ms)
✔ 降级文案区分未到期、已到期与长期有效 (0.066292ms)
✔ 剩余天数向上取整,不足一天按一天显示 (0.038542ms)
✔ 非法等级、订单时间与快照价格被拒绝 (0.228ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 135.885875
二十四条用例覆盖:等级文案、各档权益、降级差集、升级无损、四档提醒、到期边界、自动续费、关闭提醒、阶段去重、阶段升级、已支付锁价、待支付保留期、超期重算、续费后重算、取消订单、文案口径、天数取整、非法输入。其中四条是这次设计的核心:已支付订单永久按下单快照结算,守住交易不可追溯的底线;待支付订单在保留期内锁定会员价,给用户明确的承诺窗口;超期未续费按原价重新确认,把平台成本边界写清楚;同一阶段不重复提醒,避免反复打扰。
一次「到期后把已支付订单改价」的复盘
「回声」早期的订单详情页在渲染价格时,直接读取当前会员状态:如果会员有效,就显示会员价;如果会员失效,就显示原价。这个逻辑在大多数订单上没问题,因为已支付订单的会员通常还在有效期内。一次会员到期后,客服收到投诉:用户三个月前用会员价买的订单,详情页突然显示原价,用户以为平台要补差价。复盘发现,订单详情页没有使用订单快照,而是实时计算「当前会员价」,会员一到期,历史订单的展示价就变了。更严重的是,退款页也用了同一套逻辑,退款金额按当前价计算,比用户实付金额少。
修复做了三件事:订单详情与退款页统一读取下单快照;快照中记录 tierAtPurchase 与 snapshotCents,不再依赖当前会员状态;待支付订单增加保留期字段,超期后显式提示重新确认。回归时重点检查了「会员到期后查看历史订单」「会员到期后申请退款」「待支付订单跨过保留期」三条路径。这个复盘的教训是:订单价格是快照,不是当前状态的投影。一旦用当前会员状态去渲染历史订单,会员到期就会变成一次「历史改价」,用户完全无法理解。
小结
权益降级要拆成三层:权益按等级差集逐条失效,新订单按当前报价,历史订单按下单快照。提醒按 30/7/1/到期四档推进,只升不降;自动续费与用户关闭提醒要短路。
已支付订单永久锁价,待支付订单只保留到支付截止时间,超期后必须重新报价并让用户确认。文案要说清楚「什么时候变、什么会变、什么不变」。
下一节离开会员体系,进入增长链路:分享卡片的参数与归因。
阅读导航:上一节:8.2 积分的加减与过期 · 下一节:9.1 分享卡片的参数与归因 。
常见错误与边界
- 用
now > expiresAt判断一切,忽略权益差集与订单快照。 - 到期后把整页权益全部置灰,基础权益也被误伤。
- 提醒只在到期当天发送,用户来不及续费。
- 同一阶段反复提醒,用户关掉通知后连扣款失败都收不到。
- 自动续费用户仍然收到「即将到期」提醒,制造不必要的焦虑。
- 用当前会员状态渲染历史订单价格,会员到期后历史订单「被改价」。
- 待支付订单没有保留期,支付瞬间按新价格扣款,用户认为平台毁约。
- 降级文案只说「即将到期」不说后果,用户无法判断要不要续费。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。