《移动游戏产品的架构·商业化与发行工程》11.3 召回活动的频控与退订合规

召回频控不是少发几条,而是把同意、频次、静默时段与活跃度排成固定顺序。本节以《潮汐消消》每人每周不超过 2 条、《潮汐方舟》不超过 1 条为案例设定,给出退订状态、跨时区静默窗口与召回计划器的可运行实现,并讨论收紧频控的短期代价与退订立即生效的工程口径。

召回不是多发几条推送

召回活动面向的是「曾经活跃、最近沉默」的用户。它的目标不是把消息发出去,而是让一部分人重新打开应用,并且不因为这次打扰而退订、卸载或投诉。

这两个目标经常冲突。多发几条,短期回流人数可能上升;但每多一条,用户退订和卸载的概率也在上升。案例设定里,消消的召回 7 日回流率是 9.5%,方舟是 6.8%;推送点击率分别是 6.5% 和 9.0%。这些数字说明,召回的效果不是线性的:同样的通道、同样的文案,发给不同频次、不同沉默时长的用户,结果会差很多。

频控和退订合规要解决的就是这个边界:在什么条件下可以发、最多发多少、用户说不收之后怎么立即生效。它不是一个运营策略页,而是一组必须由服务端执行的硬规则。

频控的三个层次

频控至少有三层,混在一起会让口径无法解释:

层次约束对象典型口径作用
用户级一个用户在一段时间内的召回消息总数每人每周不超过 N 条防止整体打扰
渠道级同一渠道的发送频率推送每天不超过 1 条防止单通道过载
活动级同一活动对同一用户的重复触达一个活动只发 1 次防止重复轰炸

案例事实表给的是用户级口径:消消每人每周不超过 2 条,方舟不超过 1 条。这个数字只约束召回类消息,不约束支付回执、账号安全、客服通知等事务性消息。如果把事务性消息也算进频控,用户可能因为一笔退款回执而收不到活动召回;反过来,如果把营销消息伪装成事务性消息绕过频控,退订率会很快反映出来。

用户级频控需要一个明确的统计窗口。自然周、滚动 7 天、按用户首次接收时间起算的 7 天,结果都不一样。案例实现用「本周已发送条数」作为输入,把窗口的定义留给上游;函数只负责判断「还能不能发」,不负责定义「一周从哪里开始」。这样同一套逻辑可以适配自然周,也可以适配滚动窗口。

退订与同意状态

退订不是一个按钮,而是一组状态:

  • 全局退订:用户明确表示不再接收任何营销消息,所有召回渠道都停止。
  • 渠道退订:用户只关闭推送,但仍接受应用内消息或邮件。
  • 系统权限关闭:用户没有在应用内退订,但关闭了系统通知权限;推送实际上不可达。
  • 渠道未开通:产品没有接入该渠道,或者该渠道不在本次活动的允许列表里。

这四种状态要分开记录,不能合并成一个 subscribed 布尔值。全局退订是最高优先级,命中后不再判断其他条件;渠道退订只影响对应渠道;系统权限关闭应该由通道层反馈,而不是靠召回层猜测;渠道未开通则是配置问题,不是用户意愿。

退订的生效时间必须是「立即」。用户点了退订,下一批计划里就不能再出现他的名字;如果等到第二天批量同步,中间这段时间的发送会直接变成投诉。工程上通常把退订写成一条高优先级事件,召回计划器在读取同意状态时以最新事件为准。

退订入口也要可达。消息里带一个可点击的退订链接,或者应用内有清晰的「消息设置」页;不要要求用户联系客服、填写表单或等待审核。退订越难,用户越可能直接关闭系统权限或卸载,后者对召回能力的伤害更大。

静默时段与跨时区

静默时段解决的是「什么时候发」。同一个用户,晚上 10 点收到召回和下午 3 点收到召回,感受完全不同。案例实现把静默窗口定义为本地时间 [start, end),支持跨午夜,例如 21:00 到次日 09:00。

跨时区是容易被忽略的部分。服务端通常用 UTC 记录时间,但静默时段必须按用户本地时间判断。消消有 7% 的海外用户,方舟有 15% 的 Google Play 用户,如果统一按服务器时区判断,一部分用户会在凌晨被叫醒,另一部分用户在正常时段被误判为静默。

时区偏移要按用户记录,而不是按渠道或地区粗略估算。偏移非法(超出真实时区范围)时应该拒绝,而不是静默回落到 0;静默回落会让一批用户在最差的时间收到消息,而且日志里看不出来。

静默时段的处理结果是「延后」,不是「丢弃」。延后需要知道下一个可用窗口,还要判断延后之后是否还在活动有效期内。如果活动已经结束,延后没有意义,应该转为抑制并记录原因。案例实现只返回 defer,把「延后到什么时候」留给调度层,是为了让判定保持纯函数;调度层可以按小时重跑,也可以在下一次计划时重新判断。

召回对象的选择

召回对象不是「所有不活跃用户」。至少要先排除四类人:

  1. 已经全局退订或渠道退订的用户。
  2. 当前渠道未开通、未授权或不在允许列表里的用户。
  3. 本周已经达到频控上限的用户。
  4. 仍然活跃、只是短时间未登录的用户。

第四类最容易被忽略。一个用户昨天刚玩过,今天收到「好久不见」的召回,会觉得产品不认识他。案例实现用 inactiveDays 做门槛,只有沉默超过阈值的用户才进入召回候选。阈值应该按产品节奏定:消消每周 2 个活动,召回节奏可以稍快;方舟每周 1 个活动,召回应该更克制。

候选集还要考虑价值分层。付费用户、接近付费门槛的用户、自然量用户,召回成本和收益不同;但分层不能变成「只召回高价值用户」,否则低价值用户会彻底流失,长期影响自然量和口碑。案例里消消的自然量占 55%,方舟占 35%,自然量用户对召回打扰更敏感,频控应该更严而不是更松。

决策顺序

把上述条件排成固定顺序,才能保证同一个用户在多次计划里得到一致的结果:

顺序判断结果原因码
1全局退订suppressglobal-opt-out
2渠道退订suppresschannel-opt-out
3渠道未允许suppresschannel-disabled
4本周已达上限suppressweekly-cap
5仍在活跃窗口suppressrecently-active
6处于静默时段deferquiet-hours
7以上都通过sendeligible

顺序不是随便排的。合规判断必须在前,因为退订是用户意愿,不能被频控或活跃度覆盖;频控在活跃度之前,是因为它是硬约束,先判可以让日志口径稳定;静默时段放在最后,因为它只影响「什么时候发」,不影响「能不能发」。

如果顺序反过来,会出现两类问题:先判断活跃度,会把退订用户也计入「最近活跃」的统计;先判断静默时段,会把已经退订的用户放进延后队列,调度层还要再过滤一次。固定顺序的价值不在于哪条规则更重要,而在于所有路径都能解释。

代码:召回计划器

下面的实现把候选用户、活动、策略和时间作为输入,输出每个用户的决策与原因。它不发送消息,不读写数据库,也不处理延后队列;这些都属于调度层。

// file: src/biz/recall.ts
// tier: L1
// verify: node --test test/biz/recall.test.ts

export type RecallChannel = "push" | "sms" | "inapp" | "email";
export type RecallDecision = "send" | "defer" | "suppress";

export interface ConsentState {
  readonly push: boolean;
  readonly sms: boolean;
  readonly inapp: boolean;
  readonly email: boolean;
  readonly globalOptOut: boolean;
}

export interface RecallCandidate {
  readonly userId: string;
  readonly lastActiveAtMs: number;
  readonly timezoneOffsetMinutes: number;
  readonly consent: ConsentState;
  readonly sentThisWeek: number;
}

export interface RecallPolicy {
  readonly weeklyCap: number;
  readonly quietStartHour: number;
  readonly quietEndHour: number;
  readonly inactiveDays: number;
  readonly allowedChannels: readonly RecallChannel[];
}

export interface RecallCampaign {
  readonly id: string;
  readonly channel: RecallChannel;
}

export interface RecallPlan {
  readonly userId: string;
  readonly campaign: string;
  readonly channel: RecallChannel;
  readonly decision: RecallDecision;
  readonly reason: string;
  readonly localHour: number;
}

export interface RecallSummary {
  readonly send: number;
  readonly defer: number;
  readonly suppress: number;
  readonly total: number;
}

const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const KNOWN_CHANNELS: readonly RecallChannel[] = ["push", "sms", "inapp", "email"];

function assertInteger(name: string, value: number, min: number, max: number): void {
  if (!Number.isInteger(value) || value < min || value > max) {
    throw new RangeError(`${name} must be an integer between ${min} and ${max}, got ${value}`);
  }
}

function assertFinite(name: string, value: number): void {
  if (!Number.isFinite(value)) {
    throw new RangeError(`${name} must be a finite number, got ${value}`);
  }
}

function validatePolicy(policy: RecallPolicy): void {
  assertInteger("weeklyCap", policy.weeklyCap, 0, 100);
  assertInteger("quietStartHour", policy.quietStartHour, 0, 23);
  assertInteger("quietEndHour", policy.quietEndHour, 0, 23);
  assertInteger("inactiveDays", policy.inactiveDays, 0, 3_650);
  for (const channel of policy.allowedChannels) {
    if (!KNOWN_CHANNELS.includes(channel)) {
      throw new RangeError(`allowedChannels contains an unknown channel: ${channel}`);
    }
  }
}

function hasConsent(consent: ConsentState, channel: RecallChannel): boolean {
  switch (channel) {
    case "push":
      return consent.push;
    case "sms":
      return consent.sms;
    case "inapp":
      return consent.inapp;
    case "email":
      return consent.email;
  }
}

export function localHourAt(nowMs: number, offsetMinutes: number): number {
  assertFinite("nowMs", nowMs);
  assertInteger("offsetMinutes", offsetMinutes, -720, 840);
  const shifted = nowMs + offsetMinutes * 60_000;
  const hours = Math.floor(shifted / HOUR_MS);
  return ((hours % 24) + 24) % 24;
}

export function isQuietHour(hour: number, startHour: number, endHour: number): boolean {
  assertInteger("hour", hour, 0, 23);
  assertInteger("startHour", startHour, 0, 23);
  assertInteger("endHour", endHour, 0, 23);
  if (startHour === endHour) {
    return false;
  }
  if (startHour < endHour) {
    return hour >= startHour && hour < endHour;
  }
  return hour >= startHour || hour < endHour;
}

export function evaluateCandidate(
  candidate: RecallCandidate,
  campaign: RecallCampaign,
  policy: RecallPolicy,
  nowMs: number,
): RecallPlan {
  validatePolicy(policy);
  assertFinite("nowMs", nowMs);
  assertFinite("lastActiveAtMs", candidate.lastActiveAtMs);
  if (candidate.lastActiveAtMs > nowMs) {
    throw new RangeError("lastActiveAtMs cannot be in the future");
  }
  assertInteger("sentThisWeek", candidate.sentThisWeek, 0, 10_000);
  if (!KNOWN_CHANNELS.includes(campaign.channel)) {
    throw new RangeError(`unknown recall channel: ${campaign.channel}`);
  }
  const localHour = localHourAt(nowMs, candidate.timezoneOffsetMinutes);
  const base = {
    userId: candidate.userId,
    campaign: campaign.id,
    channel: campaign.channel,
    localHour,
  };
  if (candidate.consent.globalOptOut) {
    return { ...base, decision: "suppress", reason: "global-opt-out" };
  }
  if (!hasConsent(candidate.consent, campaign.channel)) {
    return { ...base, decision: "suppress", reason: "channel-opt-out" };
  }
  if (!policy.allowedChannels.includes(campaign.channel)) {
    return { ...base, decision: "suppress", reason: "channel-disabled" };
  }
  if (candidate.sentThisWeek >= policy.weeklyCap) {
    return { ...base, decision: "suppress", reason: "weekly-cap" };
  }
  if (nowMs - candidate.lastActiveAtMs < policy.inactiveDays * DAY_MS) {
    return { ...base, decision: "suppress", reason: "recently-active" };
  }
  if (isQuietHour(localHour, policy.quietStartHour, policy.quietEndHour)) {
    return { ...base, decision: "defer", reason: "quiet-hours" };
  }
  return { ...base, decision: "send", reason: "eligible" };
}

export function planRecall(
  candidates: readonly RecallCandidate[],
  campaign: RecallCampaign,
  policy: RecallPolicy,
  nowMs: number,
): readonly RecallPlan[] {
  return candidates.map((candidate) => evaluateCandidate(candidate, campaign, policy, nowMs));
}

export function summarizeRecall(plans: readonly RecallPlan[]): RecallSummary {
  return {
    send: plans.filter((plan) => plan.decision === "send").length,
    defer: plans.filter((plan) => plan.decision === "defer").length,
    suppress: plans.filter((plan) => plan.decision === "suppress").length,
    total: plans.length,
  };
}

export function formatRecallPlan(
  campaign: RecallCampaign,
  plans: readonly RecallPlan[],
): string {
  const summary = summarizeRecall(plans);
  return `campaign=${campaign.id} channel=${campaign.channel} total=${summary.total} send=${summary.send} defer=${summary.defer} suppress=${summary.suppress}`;
}

代码里有几个刻意的边界。

第一,策略参数全部做整数校验。周上限、静默小时、沉默天数都必须是整数;传入小数或负数直接抛 RangeError,而不是四舍五入。频控是合规规则,模糊的参数比没有参数更危险。

第二,时区偏移限制在 -720 到 840 分钟。这个范围覆盖了现实中常见的时区,超出范围的值通常意味着上游传错,应该拒绝而不是回落到 UTC。

第三,静默窗口用 [start, end),结束小时本身不算静默。21:00 到 09:00 的窗口,09:00 可以发,08:59 仍然延后。这个边界要和调度层的重跑节奏对齐,否则会出现「刚好卡在边界」的重复发送。

第四,决策顺序写死在函数里,不通过配置调整。合规判断的顺序不应该成为运营可配置项;如果允许调整,迟早会有人把频控放到退订之前。

第五,defer 和 suppress 分开。延后是「这次不发,下次可能发」,抑制是「这次及后续都不发,直到状态变化」;两者在报表里的含义完全不同,不能合并成一个「未发送」。

双案例对照

消消每人每周不超过 2 条,匹配它每周 2 个活动的节奏;7 日回流率 9.5%,说明高频召回还能带来增量,但退订和卸载要同步监控。它的自然量占 55%,对打扰更敏感,频控不能只看回流。

方舟每人每周不超过 1 条,匹配它每周 1 个活动的节奏;7 日回流率 6.8%,单次召回的价值更高,但用户基数小,一次误发的影响更大。它的付费率 6.5%、ARPPU ¥420,召回对象的价值分层更值得做。

取舍:频控越严,短期召回越少

收紧频控会直接减少发送量,短期回流人数也会下降。消消从每周 2 条降到 1 条,理论上少了一半触达机会;方舟本来就只有 1 条,再收紧等于放弃召回。这个代价是可见的,也是运营最不愿意接受的。

但不收紧的代价更隐蔽:退订率上升、系统权限关闭、卸载率上升,最后连事务性消息都送不到。案例里的折中是「频控 + 分层」:频控保证下限,分层决定把有限的名额给谁。高价值、高沉默、同意状态清晰的用户优先;低价值、刚活跃、近期被触达过的用户延后。

另一个取舍是静默时段。延后会让消息集中在允许窗口的开始时刻,形成新的峰值;如果调度层不做打散,用户可能在 09:00 同时收到多条。案例实现只负责判定,打散和排队属于调度层,这也是为什么 defer 不直接返回具体发送时间。

小结

  • 召回频控是服务端硬规则,不是运营页上的建议值;案例口径是消消每周不超过 2 条、方舟不超过 1 条。
  • 频控分用户级、渠道级、活动级三层,统计窗口要在上游定义清楚。
  • 退订分全局、渠道、系统权限、渠道未开通四类,不能合并成一个布尔值。
  • 静默时段按用户本地时间判断,支持跨午夜,非法时区偏移应该拒绝。
  • 决策顺序固定为合规、频控、活跃度、静默时段;defer 与 suppress 必须分开统计。

阅读导航:上一节:11.2 深链路由与冷启动恢复 · 下一节:12.1 隐私政策、权限与数据采集清单 。

常见错误与边界

  1. 把召回频控当成运营建议值。没有服务端硬约束,活动一多就会突破上限,退订率随之上行。
  2. 把事务性消息也算进召回频控。支付回执、账号安全被频控挡住,用户会认为产品不可靠。
  3. 统计窗口定义不清。自然周和滚动 7 天混用,同一个用户可能在两天内被算成两个周期。
  4. 用一个布尔值表示退订。全局退订、渠道退订、系统权限关闭的处理方式不同,合并后会误发。
  5. 退订不是立即生效。批量同步的间隙足够发出一条消息,用户会把退订当成无效。
  6. 退订入口藏得太深。用户找不到退订,就会关闭系统权限或卸载,召回能力损失更大。
  7. 静默时段按服务器时区判断。海外用户会在凌晨被叫醒,国内用户可能在正常时段被误判。
  8. 时区偏移非法时静默回落到 UTC。错误被掩盖,一批用户会在最差的时间收到消息。
  9. 静默窗口边界写成闭区间。结束小时仍然延后,调度层重跑时容易重复发送。
  10. 把 defer 和 suppress 合并成「未发送」。延后和抑制的后续动作不同,合并后无法解释报表。
  11. 只召回高价值用户。低价值用户彻底流失,长期影响自然量和口碑。
  12. 召回对象包含刚活跃的用户。用户刚玩过就收到「好久不见」,会觉得产品不认识他。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查