《互联网产品的客户端架构·交易、增长与平台化》8.1 会员状态与权益展示

会员页最容易把等级、有效期与权益状态混成一个字段:用户看到五条权益,实际只有两条可用。本节把会员快照、权益状态与会员价拆开,说明「可领取」为什么不等于「已拥有」、会员价为什么必须由服务端给出并在结算时重算,并给出一个可测试的 TypeScript 展示模型与到期提醒口径。

会员页不是一张静态价目表

会员中心通常长得很像一张价目表:左边是等级,右边是一列权益,每一条后面写着「已解锁」。用户看完的理解是「我现在有这些」,而系统内部的事实可能是:等级还在、权益已过期;权益入口还在、额度已用完;活动页写着「可领取」、用户其实还没领。这三种情况在界面上都表现为「一条可以点的权益」,在服务端却是三种不同的状态。

把它们压成一个 enabled: boolean,端上就只能靠猜。猜错的代价不是多一次点击,而是用户按会员价下单、支付时被恢复原价,或者到了店门口才发现免运费次数已经用完。会员展示的核心不是排版,而是把服务端的会员快照翻译成一组可解释的状态。这一节先拆清等级、有效期、权益状态三层,再讨论会员价与到期提醒,最后给出一个 L1 的展示模型。

三个概念必须分开

会员页上最容易被合并的三个字段是等级、有效期和权益状态。它们的来源与失效条件完全不同。

概念典型字段事实来源端上能否推断合并后的后果
等级tier服务端会员快照不能把过期等级当成当前等级
有效期expiresAt服务端会员快照只能按服务端时间推算本地时钟偏差导致提前或延后失效
权益状态权益发放记录服务端权益快照只能按规则归约把「可领取」展示成「已拥有」

第一条约束是:等级不等于有效会员。一个用户可以是黄金等级,但 expiresAt 已经过去。等级描述「他曾经买到哪一档」,有效期描述「这档权益现在还在不在」。界面上的会员标识应该由两者共同决定,而不是只看 tier。

第二条约束是:有效期只认服务端时间。端上可以用计时器让倒计时走动,但「是否过期」必须用服务端返回的时间基准加经过时间计算,不能直接读设备时钟。设备时间被手动修改、时区切换、跨设备登录,都会让本地判断漂移。第三条约束是:权益状态是逐条计算的。同一个会员可能同时拥有「已生效的会员价」「待领取的生日券」「已用完的免运费」。用一个会员级开关控制整页,必然有一半是错的。

权益状态的六个取值

把权益状态拆成六个值,足以覆盖绝大多数会员体系:

  • active:在有效期内、额度未用完、无需领取,当前可用。
  • expiring:仍然可用,但距离权益结束或会员到期不足提醒窗口。
  • claimable:需要用户主动领取,尚未领取。它可用,但还不属于用户。
  • used_up:额度已经用完,入口可以保留,但不能再消费。
  • not_started:权益尚未到生效时间,常见于预约型或分批发放的权益。
  • expired:权益自身到期,或会员整体失效。

关键在 owned 这个派生字段。它不是「入口是否展示」,而是「用户现在是否已经拥有并可消费」。按这个定义,active 与 expiring 为真,claimable 为假。很多页面把「待领取」画成已解锁,用户点进去才发现还要领一次,信任就是这样丢掉的。used_up 也不等于 expired。前者是「权益还在,次数没了」,后者是「权益本身没了」。两者的文案与下一步动作不同:前者可以引导升级或购买次数包,后者只能续费。

会员价与服务端价格的关系

会员价是交易端最容易越界的地方。常见的错误写法是:端上拿到原价与折扣率,自己乘出会员价,然后在商品页展示。它的问题不是算错,而是端上不知道服务端的全部规则:会员价可能只对部分 SKU 生效、可能与优惠券互斥、可能受限购或渠道价影响。因此会员价必须满足三条:

  1. 会员价由服务端给出。端上只负责判断「当前会员是否有资格展示这个价」,不负责计算这个价。
  2. 展示会员价必须要求结算确认。商品页的会员价是浏览态,结算页要用服务端返回的实付金额重新确认。
  3. 会员价不能高于原价。服务端配置错误或活动叠加时,端上应该回退到原价并说明原因,而不是展示一个「会员反而更贵」的数字。

还有一个容易忽略的边界:商品不参与会员价时,不要显示「会员价 ¥129」再划掉。那会让用户以为会员权益失效。正确做法是明确写「该商品不参与会员价」,把预期降下来。

到期与续费提示

到期提醒的目标不是制造紧迫感,而是让用户在权益变化前做出知情选择。三个口径需要固定:

  • 提前量:提前 7 天进入提醒窗口,提前 3 天升级为紧急提醒。窗口太长会变成骚扰,太短用户来不及续费。
  • 自动续费不重复提醒:用户已经授权自动续费,再每天弹「即将到期」只会让人关通知。只在扣款失败或到期后提醒。
  • 文案必须说明「什么会变、什么不变」。例如「到期后会员价与免运费停止,已支付订单不受影响」。只说「即将到期」而不说后果,用户无法判断要不要续。提醒状态还需要持久化。同一个阶段只提醒一次,阶段只能向后升级,不能因为用户重新打开 App 又从「提前提醒」开始。这部分放在 8.3 展开。

一个可运行的会员展示模块

下面的模块属于 L1:只有类型、常量与纯函数,时间通过参数注入,不读设备时钟,也不发请求。它做四件事:计算会员状态、逐条归约权益状态、给出会员价视图、生成到期提醒。

// file: src/member/membership.ts
// tier: L1
// verify: node --test test/member/membership.test.ts
export type MemberTier = "free" | "silver" | "gold" | "platinum";
export type MembershipState = "none" | "active" | "expiring" | "expired";
export type BenefitState =
  | "active" | "expiring" | "claimable" | "used_up" | "not_started" | "expired";
export interface BenefitGrant {
  code: string; title: string; from: number; to: number | null;
  quotaTotal: number | null; quotaUsed: number;
  requiresClaim: boolean; claimed: boolean;
}
export interface MemberSnapshot {
  userId: string; tier: MemberTier; joinedAt: number;
  expiresAt: number | null; autoRenew: boolean; benefits: BenefitGrant[];
}
export interface BenefitView {
  code: string; title: string; state: BenefitState; owned: boolean;
  remainingMs: number | null; remainingQuota: number | null; text: string;
}
export interface MembershipView {
  state: MembershipState; tier: MemberTier; tierText: string;
  daysLeft: number | null; expiresAt: number | null;
  autoRenew: boolean; headline: string;
}
export interface PriceQuote {
  listCents: number; memberCents: number | null; payableCents: number;
  source: "list" | "member"; requiresConfirm: boolean; note: string;
}
export type NoticeLevel = "none" | "info" | "urgent" | "expired";
export interface RenewalNotice {
  level: NoticeLevel; daysLeft: number | null; title: string; detail: string;
}
const DAY = 86_400_000;
const TIER_TEXT: Record<MemberTier, string> = {
  free: "普通用户", silver: "白银会员", gold: "黄金会员", platinum: "铂金会员",
};
const MEMBERSHIP_TEXT: Record<MembershipState, string> = {
  none: "未开通会员", active: "会员有效",
  expiring: "会员即将到期", expired: "会员已到期",
};
const BENEFIT_TEXT: Record<BenefitState, string> = {
  active: "已生效", expiring: "即将到期", claimable: "待领取",
  used_up: "已用完", not_started: "未开始", expired: "已失效",
};
export function tierText(tier: MemberTier): string {
  return TIER_TEXT[tier];
}
// 会员状态只由服务端快照与服务端时间决定,端上计时器不是事实来源。
export function membershipState(
  member: MemberSnapshot,
  now: number,
  warnMs = 7 * DAY,
): MembershipState {
  assertMember(member);
  assertTime(now, "当前时间");
  if (member.tier === "free" && member.expiresAt === null) return "none";
  if (member.expiresAt === null) return "active";
  if (now >= member.expiresAt) return "expired";
  if (member.expiresAt - now <= warnMs) return "expiring";
  return "active";
}
export function membershipView(
  member: MemberSnapshot,
  now: number,
  warnMs = 7 * DAY,
): MembershipView {
  const state = membershipState(member, now, warnMs);
  return {
    state,
    tier: member.tier,
    tierText: tierText(member.tier),
    daysLeft: member.expiresAt === null
      ? null : Math.max(0, Math.ceil((member.expiresAt - now) / DAY)),
    expiresAt: member.expiresAt,
    autoRenew: member.autoRenew,
    headline: MEMBERSHIP_TEXT[state],
  };
}
// 「可领取」不等于「已拥有」;owned 只覆盖真正生效中的权益。
export function benefitView(
  grant: BenefitGrant,
  memberState: MembershipState,
  now: number,
  warnMs = 7 * DAY,
): BenefitView {
  assertGrant(grant);
  assertTime(now, "当前时间");
  let state: BenefitState;
  if (memberState === "none" || memberState === "expired") {
    state = "expired";
  } else if (now < grant.from) {
    state = "not_started";
  } else if (grant.to !== null && now >= grant.to) {
    state = "expired";
  } else if (grant.quotaTotal !== null && grant.quotaUsed >= grant.quotaTotal) {
    state = "used_up";
  } else if (grant.requiresClaim && !grant.claimed) {
    state = "claimable";
  } else if (grant.to !== null && grant.to - now <= warnMs) {
    state = "expiring";
  } else {
    state = "active";
  }
  return {
    code: grant.code,
    title: grant.title,
    state,
    owned: state === "active" || state === "expiring",
    remainingMs: grant.to === null ? null : Math.max(0, grant.to - now),
    remainingQuota: grant.quotaTotal === null
      ? null : Math.max(0, grant.quotaTotal - grant.quotaUsed),
    text: BENEFIT_TEXT[state],
  };
}
export function benefitViews(
  member: MemberSnapshot,
  now: number,
  warnMs = 7 * DAY,
): BenefitView[] {
  const state = membershipState(member, now, warnMs);
  return member.benefits.map((grant) => benefitView(grant, state, now, warnMs));
}
// 会员价是服务端给出的展示值,端上不自行计算;结算时必须重新确认。
export function memberPriceQuote(
  listCents: number,
  memberCents: number | null,
  member: MemberSnapshot,
  now: number,
  warnMs = 7 * DAY,
): PriceQuote {
  assertCents(listCents, "原价");
  if (memberCents !== null) assertCents(memberCents, "会员价");
  const state = membershipState(member, now, warnMs);
  const base: PriceQuote = {
    listCents, memberCents, payableCents: listCents,
    source: "list", requiresConfirm: false, note: "该商品不参与会员价",
  };
  if (memberCents === null) return base;
  if (state === "none" || state === "expired") {
    return { ...base, note: "会员已失效,按原价展示" };
  }
  if (memberCents > listCents) {
    return { ...base, note: "会员价高于原价,按原价展示" };
  }
  return {
    ...base, payableCents: memberCents, source: "member",
    requiresConfirm: true, note: "会员价,结算时以服务端价格为准",
  };
}
// 提醒只回答「要不要提醒」;是否已提醒由调用方持久化的 lastStage 决定。
export function renewalNotice(
  member: MemberSnapshot,
  now: number,
  warnDays = 7,
  urgentDays = 3,
): RenewalNotice {
  assertMember(member);
  assertTime(now, "当前时间");
  if (!Number.isInteger(warnDays) || warnDays < 1) {
    throw new Error("提醒窗口必须是正整数天");
  }
  if (!Number.isInteger(urgentDays) || urgentDays < 0 || urgentDays > warnDays) {
    throw new Error("紧急窗口必须在 0 到提醒窗口之间");
  }
  const state = membershipState(member, now);
  if (state === "none") {
    return { level: "none", daysLeft: null, title: "", detail: "" };
  }
  if (state === "expired") {
    return {
      level: "expired", daysLeft: 0, title: "会员已到期",
      detail: "会员价与专属权益已停止,续费后恢复;已支付订单不受影响。",
    };
  }
  const daysLeft = Math.ceil((member.expiresAt! - now) / DAY);
  if (member.autoRenew) {
    return {
      level: "none", daysLeft, title: "",
      detail: "已开启自动续费,到期前不重复提醒。",
    };
  }
  if (daysLeft > warnDays) {
    return { level: "none", daysLeft, title: "", detail: "" };
  }
  if (daysLeft <= urgentDays) {
    return {
      level: "urgent", daysLeft, title: `会员将在 ${daysLeft} 天内到期`,
      detail: "续费后权益立即延续;到期未续费将恢复原价。",
    };
  }
  return {
    level: "info", daysLeft, title: `会员还有 ${daysLeft} 天到期`,
    detail: "到期后会员价、免运费等权益将停止,已支付订单不受影响。",
  };
}
function assertMember(member: MemberSnapshot): void {
  if (!member.userId) throw new Error("会员快照缺少用户标识");
  if (!(member.tier in TIER_TEXT)) throw new Error(`未知会员等级:${String(member.tier)}`);
  assertTime(member.joinedAt, "开通时间");
  if (member.expiresAt !== null) {
    assertTime(member.expiresAt, "到期时间");
    if (member.expiresAt <= member.joinedAt) throw new Error("会员到期时间必须晚于开通时间");
  }
  if (typeof member.autoRenew !== "boolean") throw new Error("自动续费标记必须是布尔值");
  if (!Array.isArray(member.benefits)) throw new Error("权益清单必须是数组");
  for (const grant of member.benefits) assertGrant(grant);
}
function assertGrant(grant: BenefitGrant): void {
  if (!grant.code || !grant.title) throw new Error("权益缺少编码或标题");
  assertTime(grant.from, "权益开始时间");
  if (grant.to !== null) {
    assertTime(grant.to, "权益结束时间");
    if (grant.to <= grant.from) throw new Error(`权益结束必须晚于开始:${grant.code}`);
  }
  if (grant.quotaTotal !== null) {
    if (!Number.isInteger(grant.quotaTotal) || grant.quotaTotal < 1) {
      throw new Error(`权益总额度必须是正整数:${grant.code}`);
    }
    if (!Number.isInteger(grant.quotaUsed) || grant.quotaUsed < 0 || grant.quotaUsed > grant.quotaTotal) {
      throw new Error(`权益已用额度非法:${grant.code}`);
    }
  } else if (grant.quotaUsed !== 0) {
    throw new Error(`无限额度权益不应记录已用额度:${grant.code}`);
  }
  if (typeof grant.requiresClaim !== "boolean" || typeof grant.claimed !== "boolean") {
    throw new Error(`权益领取标记非法:${grant.code}`);
  }
}
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}必须是安全整数毫秒`);
  }
}

四个函数对应四个展示问题。membershipState 只回答会员整体处于哪个阶段。它把「普通用户且没有到期时间」视为 none,把「付费等级但没有到期时间」视为长期有效,其余按服务端时间判断。判断顺序很重要:先看是否过期,再看是否临近到期,最后才是有效。

benefitView 把单条权益归约为六种状态。注意它的输入包含 memberState,所以会员整体失效时,所有权益会一起变成 expired,不需要每条权益各自维护一个过期标记。owned 只覆盖 active 与 expiring,这是「可领取不算已有」的代码表达。

memberPriceQuote 不计算会员价,只决定展示哪一份价格。服务端没有给会员价时展示原价;会员失效时展示原价;会员价高于原价时也展示原价。只有会员有效且会员价不高于原价时,才返回 source: "member",并且把 requiresConfirm 设为真。

renewalNotice 只回答「现在要不要提醒、提醒到哪一档」。它不负责去重,因为去重需要持久化 lastStage,那是调用方的事。纯函数把「判断」与「副作用」分开,测试才能稳定。

测试与真实输出

本机运行结果如下:

$ node --test test/member/membership.test.ts
✔ 会员等级有稳定的中文文案 (0.563541ms)
✔ 普通用户没有会员状态 (0.211125ms)
✔ 有效期内的会员处于 active 并给出剩余天数 (0.068375ms)
✔ 到期前七天进入 expiring,但仍可使用权益 (0.064667ms)
✔ 到期时间按开区间判定,等于到期时刻即失效 (0.080625ms)
✔ 没有到期时间的付费会员视为长期有效 (0.062291ms)
✔ 生效中的权益标记为已拥有并计算剩余额度 (0.097584ms)
✔ 尚未开始的权益不能展示成已有 (0.055375ms)
✔ 待领取的权益只是可领取,不算已拥有 (0.067417ms)
✔ 额度用完的权益不再标记为可用 (0.099791ms)
✔ 权益自身到期与会员到期分别判定 (0.07375ms)
✔ 权益临近到期时标记为 expiring (0.053583ms)
✔ 会员失效后所有权益一起失效 (0.07025ms)
✔ 有效会员展示服务端会员价并要求结算确认 (0.09725ms)
✔ 会员失效后回退原价并说明原因 (0.100834ms)
✔ 会员价高于原价时按原价展示 (0.053625ms)
✔ 商品不参与会员价时不制造优惠感 (0.043458ms)
✔ 到期提醒按窗口分成 info 与 urgent (0.113625ms)
✔ 窗口外的会员不提醒,自动续费也不重复提醒 (0.065667ms)
✔ 到期后的提醒明确已支付订单不受影响 (0.049542ms)
✔ 非法时间、等级与额度被拒绝 (0.24425ms)
✔ 会员价的金额必须是安全整数分 (0.070042ms)
ℹ tests 22
ℹ suites 0
ℹ pass 22
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 129.161708

二十二条用例分别覆盖:等级文案、普通用户、有效会员、临近到期、到期边界、长期会员、权益生效、未开始、待领取、额度用完、权益自身到期、权益临期、会员失效连带、会员价生效、会员价回退、会员价倒挂、商品不参与、提醒分档、自动续费、到期文案、非法输入、金额校验。其中三条是这次设计的核心:「待领取」守住 owned 的语义;到期时间按开区间判定,避免 23:59:59 与 00:00:00 的争议;会员价高于原价时回退原价,把配置错误挡在展示层。

一次「预计权益」被展示成「已有」的复盘

「回声」的会员中心曾经用一张静态配置表渲染权益:运营在后台勾选「黄金会员享有免运费」,端上就把这条权益画成已解锁。上线后出现一类投诉:用户刚开通会员,免运费显示「已生效」,下单时却仍然收了运费。

复盘发现三层问题叠加:端上把「等级包含该权益」当成了「用户已拥有该权益」,没有读取权益发放记录;免运费权益需要用户先领取一张运费券,端上没有展示「待领取」状态;会员快照缓存了 24 小时,用户开通后仍然看到旧快照。修复做了三件事:权益状态改为逐条读取服务端发放记录;把「可领取」单独展示,并给出领取入口;会员快照改为短缓存,开通、续费、领取后主动失效。回归时重点检查了「开通后立即下单」这条路径。

这个复盘说明:会员页展示的不是权益目录,而是权益账本。目录回答「这一档有什么」,账本回答「这个人现在有什么」。前者可以静态配置,后者必须来自服务端快照。

小结

会员展示要拆成三层:等级回答「哪一档」,有效期回答「还在不在」,权益状态回答「这一条现在能不能用」。三层合并成一个布尔值,必然在某一种组合上撒谎。

会员价由服务端给出,端上只决定展示哪一份,并且必须要求结算确认;到期提醒要说明后果、控制节奏、避免重复;「待领取」不能画成「已拥有」。

下一节讨论会员体系里的另一本账:积分。它比会员状态更容易被端上「先减一下」,也更容易在过期与回滚时出错。

阅读导航:上一节:7.3 优惠展示的诚实性 · 下一节:8.2 积分的加减与过期 。

常见错误与边界

  • 只用 tier 判断会员是否有效,忽略 expiresAt,把过期等级当成当前等级。
  • 读设备时钟判断到期,用户改时间或跨时区后状态漂移。
  • 把「等级包含某权益」当成「用户已拥有该权益」,不读发放记录。
  • 把「待领取」画成已解锁,用户点进去才发现还要领一次。
  • 端上按折扣率自算会员价,与服务端结算价不一致。
  • 会员价高于原价时仍然展示,制造「会员更贵」的观感。
  • 到期提醒窗口过长或重复提醒,用户关掉通知后再也收不到关键通知。
  • 会员快照缓存过久,开通、续费、领取后仍展示旧状态。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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