《互联网产品的客户端架构·交易、增长与平台化》9.3 拉新活动页的降级

活动页的失败方式比商品页多得多:配置拉不到、活动未开始、已经结束、名额耗尽、运营暂停。本节把这些情况分成「配置问题」与「业务状态」,给出缓存容忍、版本回退保护、降级目标页与降级后分享参数的规则,并附一个用假时钟与可控配置源验证的 L2 模型。

活动页的失败方式比商品页多

商品页失效通常只有三种:商品下架、库存为零、接口报错。活动页多一类维度——时间。同一个 URL,在活动开始前、进行中、结束后是三种不同的页面;再加上运营暂停、名额耗尽、配置拉取失败,一个活动页至少有六种状态。

这些状态如果都塞进一个 loading / error 二态里,用户会看到大量误导性的提示:活动还没开始却显示「网络错误」,名额已满却显示「重试」,活动结束后分享出去的链接打开是 404。

正确的做法是把「拉取失败」与「活动失效」分开:

  • 拉取失败是技术问题:可以重试,可以用缓存,可以降级到默认页。
  • 活动失效是业务状态:重试没有意义,必须给出明确的结束/未开始/售罄提示,并退到常规商品页。

把两类问题混在一起,重试逻辑就会对「活动已结束」反复请求,而真正的网络故障又拿不到重试提示。

配置缓存的三个约束

活动配置允许缓存,但必须满足三个约束,否则缓存会变成错误来源:

约束规则不满足的后果
活动匹配缓存配置的 id 必须等于当前活动把 A 活动的配置渲染到 B 活动页
新鲜度now - fetchedAt 不超过容忍时长活动已结束,页面仍在倒计时
版本单调服务端版本更低且缓存新鲜时保留缓存一次错误发布把活动配置回滚到旧版

第三条最容易被忽略。配置系统发布回滚、灰度实例版本不一致、CDN 返回旧对象,都会让端上收到一个更旧的 version。如果直接覆盖,用户会看到活动时间突然倒退、名额从「已满」变回「可领」。端上无法判断哪一份配置是「对的」,但可以坚持一条保守规则:在缓存有效期内,版本只向前,不向后。

五种降级理由与目标页

理由判定目标页分享参数倒计时
config_unavailable无网络且无可用缓存默认页只留渠道无
not_startednow < startAt活动配置的兜底页保留活动距开始
endednow >= endAt 或运营关闭兜底页保留活动,标记结束无
paused服务端状态为暂停兜底页保留活动无
quota_exhaustedclaimed >= quota兜底页保留活动无

注意 not_started 与 ended 都退到兜底页,但倒计时方向相反:前者是「还有多久开始」,后者没有倒计时。如果共用一套倒计时组件,活动结束后会显示「距开始 00:00」,这是最常见的文案错误。

另一个细节是「保留活动」的含义:活动仍然存在,只是当前不可参与,所以分享参数继续带 cid,让结束后的流量仍能被统计到该活动。只有 config_unavailable 才丢弃 cid——此时端上根本不知道活动是否真实存在,带一个未知 id 只会污染归因。

名额:端上只能提示,不能预占

claimed >= quota 是一个服务端事实。端上根据缓存配置显示「已抢光」是合理的,但不能据此禁用所有入口:缓存可能落后几秒,服务端可能刚刚释放了未支付订单的名额。

规则分三层:

  1. 展示层:剩余名额小于阈值时提示「名额紧张」,但仍允许点击。
  2. 提交层:点击后由服务端原子扣减,端上不预占、不本地减库存。
  3. 失败层:服务端返回「名额已满」时,把页面切到 quota_exhausted,并提示「本次没抢到」,而不是「网络错误,请重试」。

把「没抢到」显示成「重试」,会让用户在一个已经结束的活动上反复点击,这是拉新活动投诉的高频来源。

把状态判定写成可测试的模块

下面的模块属于 L2:配置通过注入的 loader 获取,时间通过 ManualClock 注入,测试可以精确控制「活动开始前一秒」「结束时刻」「缓存过期」这些边界,不需要真实等待。

// file: src/growth/campaign.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/growth/campaign.test.ts

export interface Clock {
  now(): number;
}

export class ManualClock implements Clock {
  #now: number;

  constructor(start: number) {
    if (!Number.isFinite(start)) throw new Error("起始时间非法");
    this.#now = start;
  }

  now(): number {
    return this.#now;
  }

  advance(ms: number): void {
    if (!Number.isFinite(ms) || ms < 0) throw new Error("推进时长非法");
    this.#now += ms;
  }

  set(ms: number): void {
    if (!Number.isFinite(ms)) throw new Error("设置时间非法");
    this.#now = ms;
  }
}

export type CampaignStatus = "active" | "paused" | "closed";

export interface CampaignConfig {
  id: string; version: number; status: CampaignStatus;
  startAt: number; endAt: number; quota: number; claimed: number;
  campaignPath: string; fallbackPath: string; channel: string; nearExhausted: number;
}

export type ConfigErrorKind = "network" | "timeout" | "server" | "not_found";

export class ConfigError extends Error {
  kind: ConfigErrorKind;

  constructor(kind: ConfigErrorKind, message: string) {
    super(message);
    this.kind = kind;
  }
}

export type ConfigLoader = (campaignId: string, at: number) => Promise<CampaignConfig>;

export interface ConfigCache { config: CampaignConfig | null; fetchedAt: number }

export interface CampaignOptions {
  staleMs: number; retryBaseMs: number; maxAttempts: number; defaultPath: string;
}

export type CampaignPhase = "upcoming" | "running" | "paused" | "ended" | "sold_out";

export type DegradeReason =
  | "config_unavailable"
  | "not_started"
  | "ended"
  | "paused"
  | "quota_exhausted";

export interface CampaignView {
  campaignId: string; phase: CampaignPhase | null;
  page: "campaign" | "fallback"; path: string;
  configSource: "network" | "cache" | "none"; staleMs: number | null;
  degraded: DegradeReason | null; shareParams: Record<string, string>;
  countdownMs: number | null;
  retry: { retryable: boolean; delayMs: number } | null;
  notices: string[];
}

function assertConfig(config: CampaignConfig): void {
  if (config.id.trim() === "") throw new Error("活动配置非法:缺少活动 id");
  if (!Number.isFinite(config.startAt) || !Number.isFinite(config.endAt)) {
    throw new Error("活动配置非法:时间窗口不是有限数字");
  }
  if (config.endAt <= config.startAt) throw new Error("活动配置非法:结束时间早于开始时间");
  if (!Number.isInteger(config.quota) || config.quota < 0) {
    throw new Error("活动配置非法:名额必须是非负整数");
  }
  if (!Number.isInteger(config.claimed) || config.claimed < 0) {
    throw new Error("活动配置非法:已领取数必须是非负整数");
  }
  if (!config.campaignPath.startsWith("/") || !config.fallbackPath.startsWith("/")) {
    throw new Error("活动配置非法:路径必须是站内绝对路径");
  }
}

// 阶段判定只看服务端配置与服务端时间,端上不自行推断活动是否开始。
export function evaluatePhase(config: CampaignConfig, now: number): CampaignPhase {
  assertConfig(config);
  if (config.status === "paused") return "paused";
  if (config.status === "closed" && now >= config.startAt) return "ended";
  if (now < config.startAt) return "upcoming";
  if (now >= config.endAt) return "ended";
  if (config.claimed >= config.quota) return "sold_out";
  return "running";
}

function degradeOf(phase: CampaignPhase): DegradeReason {
  if (phase === "upcoming") return "not_started";
  if (phase === "paused") return "paused";
  if (phase === "sold_out") return "quota_exhausted";
  return "ended";
}

function shareParamsFor(
  config: CampaignConfig | null,
  page: "campaign" | "fallback",
): Record<string, string> {
  if (config === null) return { ch: "direct" };
  return {
    ch: config.channel,
    cid: config.id,
    landing: page === "campaign" ? config.campaignPath : config.fallbackPath,
  };
}

// 拉取失败不等于降级:只要缓存还新鲜,就继续用缓存渲染,并同时给出重试建议。
export async function resolveCampaign(
  campaignId: string,
  loader: ConfigLoader,
  cache: ConfigCache,
  clock: Clock,
  options: CampaignOptions,
  attempt = 1,
): Promise<CampaignView> {
  const now = clock.now();
  const notices: string[] = [];
  let fetched: CampaignConfig | null = null;
  let failure: ConfigError | null = null;

  try {
    fetched = await loader(campaignId, now);
  } catch (error) {
    failure =
      error instanceof ConfigError
        ? error
        : new ConfigError("server", error instanceof Error ? error.message : "未知错误");
  }

  let config: CampaignConfig | null = null;
  let source: CampaignView["configSource"] = "none";
  let staleMs: number | null = null;

  const cacheUsable =
    cache.config !== null &&
    cache.config.id === campaignId &&
    now - cache.fetchedAt <= options.staleMs;

  if (fetched !== null) {
    if (cacheUsable && cache.config !== null && fetched.version < cache.config.version) {
      config = cache.config;
      source = "cache";
      staleMs = now - cache.fetchedAt;
      notices.push("服务端配置版本回退,继续使用缓存");
    } else {
      config = fetched;
      source = "network";
    }
  } else if (cacheUsable) {
    config = cache.config;
    source = "cache";
    staleMs = now - cache.fetchedAt;
    notices.push("活动配置拉取失败,使用缓存");
  }

  const retry = failure === null ? null : retryHint(failure.kind, attempt, options);

  if (config === null) {
    return {
      campaignId,
      phase: null,
      page: "fallback",
      path: options.defaultPath,
      configSource: "none",
      staleMs: null,
      degraded: "config_unavailable",
      shareParams: { ch: "direct" },
      countdownMs: null,
      retry,
      notices: [...notices, "没有可用配置,降级到默认页"],
    };
  }

  const phase = evaluatePhase(config, now);
  const page = phase === "running" ? "campaign" : "fallback";
  const degraded = page === "fallback" ? degradeOf(phase) : null;
  const remaining = config.quota - config.claimed;
  if (page === "campaign" && remaining <= config.nearExhausted) {
    notices.push("名额接近耗尽");
  }
  let countdownMs: number | null = null;
  if (phase === "running") countdownMs = config.endAt - now;
  if (phase === "upcoming") countdownMs = config.startAt - now;

  return {
    campaignId,
    phase,
    page,
    path: page === "campaign" ? config.campaignPath : config.fallbackPath,
    configSource: source,
    staleMs,
    degraded,
    shareParams: shareParamsFor(config, page),
    countdownMs,
    retry,
    notices,
  };
}

export function retryHint(
  kind: ConfigErrorKind,
  attempt: number,
  options: CampaignOptions,
): { retryable: boolean; delayMs: number } {
  if (!Number.isInteger(attempt) || attempt < 1) throw new Error("重试次数必须是正整数");
  if (kind === "not_found" || attempt >= options.maxAttempts) {
    return { retryable: false, delayMs: 0 };
  }
  const delayMs = Math.min(options.retryBaseMs * 2 ** (attempt - 1), 8_000);
  return { retryable: true, delayMs };
}

三个设计点。

resolveCampaign 不抛业务异常。 配置拉取失败、活动结束、名额耗尽都返回一个带 degraded 的视图,调用方只需要根据 page 决定渲染哪个页面。抛异常会让每个调用点都写一遍 try/catch,最后总有一个入口漏掉。

重试建议与降级结果放在同一个视图里。 retry 字段告诉调用方「这次失败值不值得再试、多久后试」,但页面已经可以渲染。两者分开会让「有缓存但请求失败」这种常见情况无法表达。

分享参数由降级结果统一生成。 页面退到兜底页时,landing 改成兜底路径,cid 与 ch 保留;配置不可用时只留 ch。这样 9.1 的解析逻辑不需要知道活动是否降级,只按参数归因。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/growth/campaign.test.ts
✔ 进行中的活动从网络配置渲染活动页 (0.718541ms)
✔ 活动开始时刻即为进行中 (0.0775ms)
✔ 未开始的活动降级到常规页并给出开始倒计时 (0.081333ms)
✔ 到达结束时刻立即降级 (0.064ms)
✔ 运营暂停的活动不展示活动页 (0.087709ms)
✔ 名额恰好耗尽时判定售罄 (0.067333ms)
✔ 名额接近耗尽时保留活动页但给出提示 (0.087917ms)
✔ 假时钟推进后倒计时同步减少 (0.087125ms)
✔ 拉取失败但缓存新鲜时继续渲染并提示重试 (0.530959ms)
✔ 缓存超过容忍时长后不再使用 (0.133459ms)
✔ 没有缓存且拉取失败时降级到默认页 (0.082833ms)
✔ 活动不存在是终态错误,不重试 (0.069875ms)
✔ 服务端错误按指数退避,达到上限后停止 (0.064875ms)
✔ 服务端配置版本回退时保留较新的缓存 (0.070708ms)
✔ 降级页仍保留活动与渠道参数用于归因 (0.063416ms)
✔ 配置不可用时分享参数不携带未知活动 (0.075958ms)
✔ 其他活动的缓存不会被误用 (0.083584ms)
✔ 配置时间窗口倒挂时拒绝渲染 (0.193375ms)
✔ 名额不是整数时拒绝渲染 (0.052125ms)
✔ 非法重试次数被拒绝 (0.047292ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 135.090708

二十条用例覆盖了阶段判定、缓存容忍、版本回退、名额边界与重试计划。ManualClock 让「推进两天后倒计时减少」成为一次函数调用,而不是真的等两天;可控 loader 让「网络失败但缓存新鲜」和「缓存也过期」成为两个独立用例。

一次「活动结束后仍在分享」的复盘

某拉新活动在周五 24:00 结束。活动页本身做了结束判定,午夜后访问会跳转常规商品页。但分享卡片是用户在活动期间生成的,链接里的 cid 一直有效。

周末两天,分享链接仍在群里流转。用户点开后落到常规商品页,页面正常,但归因系统把所有订单都记到了已经结束的活动上。活动复盘显示「结束后仍产生大量订单」,运营误以为活动余温很强,第二次追加了预算。

复盘发现两个问题:

  1. 活动结束后,分享参数没有标记状态,归因无法区分「活动期间点击、活动期间下单」与「活动结束后点击」。
  2. 落地页只做了页面跳转,没有把「活动已结束」这一事实传给归因链路。

修复后,降级结果里的 shareParams 保留 cid,同时由服务端在归因时按点击时间判断是否在活动窗口内;落地页把 degraded: "ended" 写进埋点,复盘可以按「结束后的流量」单独看。第二次活动没有再加预算,因为数据显示那部分订单的转化率与自然流量没有差异。

这个复盘的教训是:降级不是把页面换掉就结束了,降级结果本身要成为归因的输入。 页面退到哪里、参数保留什么、埋点记录什么,必须是同一个决策的三个输出。

小结

活动页的降级要先分清技术失败与业务失效:前者可以缓存和重试,后者只能明确告知并退到常规页。

配置缓存坚持活动匹配、新鲜度、版本单调三条约束;五种降级理由对应不同的目标页、分享参数与倒计时;名额只能在服务端原子扣减,端上负责把「没抢到」和「网络错误」分开。

下一章进入平台化:当同一套交易能力要服务多个业务方时,边界该怎么划。

阅读导航:上一节:9.2 邀请关系与绑定时机 · 下一节:10.1 实验分流与参数覆盖 。

常见错误与边界

  • 把活动结束当成网络错误,提示用户「重试」。
  • 缓存配置不校验活动 id,A 活动的配置渲染到 B 活动页。
  • 接受版本回退的配置,活动时间与名额突然倒退。
  • 活动未开始时显示「距开始 00:00」,与已结束共用倒计时组件。
  • 配置不可用时仍带 cid 分享,把流量归因到不存在的活动。
  • 端上根据缓存名额预占库存,与服务端扣减结果不一致。
  • 名额已满时提示「网络错误,请重试」,用户反复点击。
  • 降级只换页面,不记录降级原因,复盘无法区分活动期与结束后的流量。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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