《移动游戏产品的架构·商业化与发行工程》9.2 邮件、补偿与库存发放

活动配置出错、活动结束后补发、限量库存被超卖,最后都会落到邮件与补偿发放链路上。本节以《潮汐消消》2 小时补偿时效和《潮汐方舟》4 小时时效为背景,拆解三类发放共用的幂等键、库存预留、过期边界、补偿 SLA 与可审计账本,并给出可重放的发放函数。

发放链路是活动的事故收口

活动出问题之后,玩家不会关心是配置错、库存错还是网络错,他们只关心一件事:东西什么时候到账。

上一节讨论的配置错误率 3.2%(消消)和 1.1%(方舟),最终都会转化成发放动作。区别在于规模:消消每周两个活动、80 万 DAU,补偿往往是「大批量、低单价」;方舟每周一个活动、25 万 DAU,补偿往往是「小批量、高单价」。两种形态对发放系统的要求不同,但底层问题相同:

  • 同一次补偿不能因为重试发两遍。
  • 限量库存不能因为并发扣减被超卖。
  • 过期邮件不能继续被领取。
  • 补偿时效要能被度量,而不是只写在公告里。

案例事实表给了一个直接约束:消消的补偿发放时效是 2 小时内,方舟是 4 小时内。这不是「尽量快」的目标,而是产品承诺。超过这个时间,即使奖励最终到账,玩家体验也已经受损。

本节讨论三类发放:邮件、补偿、库存。它们共用一套账本和幂等规则,区别只在触发方式和库存约束。

三类发放,一个账本

类型触发方式是否需要领取关键约束
邮件运营配置或系统事件通常需要有效期、领取一次
补偿事故或客诉批量生成通常直接到账幂等键、SLA、可追溯
库存活动奖励或限量兑换视玩法而定总量、预留、释放

三类发放如果各写一套逻辑,会出现三个问题。第一,同一玩家可能同时收到邮件和直接补偿,重复计算。第二,库存扣减和到账不在同一个事务里,出现「扣了库存没到账」或「到账了没扣库存」。第三,客服无法判断某一笔奖励到底发没发。

统一账本的最小字段是:幂等键、玩家 id、物品 id、数量、状态、创建时间、过期时间、来源。幂等键是账本的主键或唯一索引,状态至少包含「已预留」「已到账」「已过期」「已释放」。账本不需要记录每一个业务细节,但必须能回答「这笔奖励为什么发、发没发、能不能重发」。

幂等键:重试与重复点击的边界

发放系统最先要放弃的幻想是「精确一次」。网络会超时,客户端会重试,运营会点两次按钮,消息队列会重复投递。工程上能保证的是「至少一次投递 + 幂等消费」,也就是最终效果一次。

幂等键的组成决定了去重粒度。常见的组合是:

<活动 id>:<玩家 id>:<发放原因>

例如 tide-spring:p-10086:mail 表示「春季活动给玩家 p-10086 的邮件奖励」。同一个键第二次到达时,系统不再次扣库存、不再次写到账,而是返回第一次的结果。这样客户端重试、运营重复点击、队列重投都落在同一个键上。

幂等键不能只用请求 id。请求 id 每次重试都会变,起不到去重作用;也不能只用玩家 id,否则同一玩家在同一活动里的多档奖励会被合并。键的粒度应该等于「业务上允许重复的最小单位」。

案例里消消的付费用户是 8.96 万、月订单 42 万笔,方舟付费用户 6.5 万、月订单 15 万笔。活动补偿的批量通常按「玩家 × 奖励档位」生成,而不是按订单生成;如果把订单号放进幂等键,同一玩家在同一活动里买两次就会收到两份补偿,反而不符合运营口径。

库存:先预留,后发货

限量库存的难点不是扣减,而是「扣减和到账之间的失败」。

一个常见的错误顺序是:先给玩家发奖,再异步扣库存。并发一高,库存被扣成负数,或者奖品发超。另一个错误顺序是:先扣库存,再发奖,发奖失败后库存不退,玩家投诉「明明还有库存却领不到」。

可用的顺序是「预留 + 确认」:

  1. 校验请求合法、未过期、未重复。
  2. 检查剩余库存是否足够。
  3. 预留库存,写账本状态为「已预留」。
  4. 发货成功后,把状态改为「已到账」。
  5. 发货失败或超时,释放预留,库存回到可用池。

本文的示例把「预留」和「写账」合并成一个纯函数,返回新的库存快照。真实系统里,这两步通常是一个数据库事务或一个带条件的更新:UPDATE inventory SET reserved = reserved + ? WHERE item_id = ? AND total - reserved >= ?。条件更新失败,就说明库存不足,不需要先查再改。

库存策略还要决定「部分满足」还是「全有或全无」。本文选择全有或全无:需要 10 个但只剩 9 个时,不扣 9 个,直接返回不足。原因是部分发放会让账本出现「应发 10、实发 9」的中间态,补偿和对账都要额外处理;对活动奖励来说,少发一个往往比不发更难看。

过期:边界要写进代码

邮件和补偿都有有效期。过期规则最常见的争议是「最后一秒」。

如果配置写「有效期到 11 月 6 日 10:00」,玩家在 10:00:00 点击领取,算不算?工程上应该统一为半开区间:now < expiresAt 可领,now >= expiresAt 不可领。这样「到点」就是明确的不可领,不需要解释「到点那一秒」。

过期还有两个工程细节。

第一,过期判断用服务端时间,不用客户端时间。客户端时间可以被修改,也可以因为时区跳变;服务端时间是唯一可信的时钟。

第二,过期不等于删除。账本里应该保留「已过期」状态,而不是把记录删掉。玩家申诉时,客服需要看到「这笔奖励曾经存在、在什么时间过期、是否领取过」。删除记录会让申诉变成无法举证。

案例里消消的补偿时效是 2 小时,方舟是 4 小时。时效约束的是「从事故确认到补偿到账」,不是「从玩家反馈到补偿到账」。这两个起点不同:玩家反馈可能分散在几天内,事故确认是运营和研发对齐后的一个时间点。SLA 应该用后者,否则客诉量一多,时效永远无法达标。

补偿 SLA:把承诺变成可计算的状态

补偿时效可以拆成三个状态:

状态含义判定
pending还在时效内,尚未发出已用时间 ≤ 目标时长
met已发出,且在时效内发出时间 − 事故确认时间 ≤ 目标时长
breached超时,无论是否发出已用时间 > 目标时长

这三个状态可以直接驱动运营看板:pending 的数量是当前待办,breached 的数量是事故,met 的比例是 SLA 达成率。比起「补偿要快」这种口号,状态机让时效变成可追踪的指标。

案例里消消的目标是 2 小时,方舟是 4 小时。方舟的时效更宽,不是因为它的系统更慢,而是因为它的补偿单价更高、影响面更小,允许更多人工复核。消消的补偿批量大、单价低,适合自动化模板;方舟的补偿批量小、单价高,适合「机器生成 + 人工抽检」。两种流程的 SLA 不同,是业务形态决定的,不是工程能力决定的。

把发放写成可重放的纯函数

下面的实现把「幂等检查」「过期检查」「库存预留」「补偿时效」拆成独立函数。applyGrant 接收当前库存和已处理的幂等键集合,返回发放结果与新的库存快照;它不写数据库,也不发消息。

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

export type GrantKind = "mail" | "compensation" | "inventory";
export type GrantStatus = "granted" | "duplicate" | "expired" | "out-of-stock";
export type CompensationState = "pending" | "met" | "breached";

export interface InventorySnapshot {
  readonly itemId: string;
  readonly total: number;
  readonly reserved: number;
}

export interface GrantRequest {
  readonly requestId: string;
  readonly playerId: string;
  readonly kind: GrantKind;
  readonly itemId: string;
  readonly quantity: number;
  readonly expiresAt: string;
  readonly idempotencyKey: string;
}

export interface GrantResult {
  readonly requestId: string;
  readonly idempotencyKey: string;
  readonly status: GrantStatus;
  readonly granted: number;
  readonly inventory: InventorySnapshot;
  readonly reason: string;
}

export interface CompensationAssessment {
  readonly incidentId: string;
  readonly state: CompensationState;
  readonly elapsedHours: number;
  readonly targetHours: number;
}

const HOUR_MS = 3_600_000;

function assertNonEmpty(name: string, value: string): void {
  if (value.trim() === "") {
    throw new RangeError(`${name} must not be empty`);
  }
}

function validateInventory(inventory: InventorySnapshot): void {
  assertNonEmpty("inventory.itemId", inventory.itemId);
  if (!Number.isInteger(inventory.total) || inventory.total < 0) {
    throw new RangeError(`inventory.total must be a non-negative integer, got ${inventory.total}`);
  }
  if (!Number.isInteger(inventory.reserved) || inventory.reserved < 0) {
    throw new RangeError(
      `inventory.reserved must be a non-negative integer, got ${inventory.reserved}`,
    );
  }
  if (inventory.reserved > inventory.total) {
    throw new RangeError("inventory.reserved cannot exceed inventory.total");
  }
}

function validateRequest(request: GrantRequest): void {
  assertNonEmpty("requestId", request.requestId);
  assertNonEmpty("playerId", request.playerId);
  assertNonEmpty("itemId", request.itemId);
  assertNonEmpty("idempotencyKey", request.idempotencyKey);
  if (!Number.isInteger(request.quantity) || request.quantity <= 0) {
    throw new RangeError(`quantity must be a positive integer, got ${request.quantity}`);
  }
  if (!Number.isFinite(Date.parse(request.expiresAt))) {
    throw new RangeError(`expiresAt is not a valid timestamp: ${request.expiresAt}`);
  }
}

export function applyGrant(
  request: GrantRequest,
  inventory: InventorySnapshot,
  seenKeys: ReadonlySet<string>,
  nowMs: number,
): GrantResult {
  validateRequest(request);
  validateInventory(inventory);
  if (!Number.isFinite(nowMs)) {
    throw new RangeError(`nowMs must be finite, got ${nowMs}`);
  }
  if (request.itemId !== inventory.itemId) {
    throw new RangeError(
      `request item ${request.itemId} does not match inventory ${inventory.itemId}`,
    );
  }

  const base = {
    requestId: request.requestId,
    idempotencyKey: request.idempotencyKey,
    inventory,
  };

  if (seenKeys.has(request.idempotencyKey)) {
    return { ...base, status: "duplicate", granted: 0, reason: "idempotency key already applied" };
  }
  if (Date.parse(request.expiresAt) <= nowMs) {
    return { ...base, status: "expired", granted: 0, reason: "grant expired before delivery" };
  }

  const remaining = inventory.total - inventory.reserved;
  if (request.quantity > remaining) {
    return {
      ...base,
      status: "out-of-stock",
      granted: 0,
      reason: `need ${request.quantity}, only ${remaining} left`,
    };
  }

  return {
    ...base,
    status: "granted",
    granted: request.quantity,
    inventory: { ...inventory, reserved: inventory.reserved + request.quantity },
    reason: "reserved and written to the grant ledger",
  };
}

export function assessCompensation(
  incidentId: string,
  raisedAt: string,
  sentAt: string | null,
  targetHours: number,
  nowMs: number,
): CompensationAssessment {
  assertNonEmpty("incidentId", incidentId);
  if (!Number.isFinite(targetHours) || targetHours <= 0) {
    throw new RangeError(`targetHours must be positive, got ${targetHours}`);
  }
  const raised = Date.parse(raisedAt);
  if (!Number.isFinite(raised)) {
    throw new RangeError(`raisedAt is not a valid timestamp: ${raisedAt}`);
  }
  if (!Number.isFinite(nowMs) || nowMs < raised) {
    throw new RangeError("nowMs must be finite and not earlier than raisedAt");
  }

  if (sentAt === null) {
    const elapsedHours = (nowMs - raised) / HOUR_MS;
    return {
      incidentId,
      state: elapsedHours <= targetHours ? "pending" : "breached",
      elapsedHours,
      targetHours,
    };
  }

  const sent = Date.parse(sentAt);
  if (!Number.isFinite(sent) || sent < raised) {
    throw new RangeError("sentAt must be a valid timestamp not earlier than raisedAt");
  }
  const elapsedHours = (sent - raised) / HOUR_MS;
  return {
    incidentId,
    state: elapsedHours <= targetHours ? "met" : "breached",
    elapsedHours,
    targetHours,
  };
}

export function formatGrantResult(result: GrantResult): string {
  const remaining = result.inventory.total - result.inventory.reserved;
  return `${result.requestId} ${result.status} granted=${result.granted} item=${result.inventory.itemId} remaining=${remaining} reason=${result.reason}`;
}

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

第一,幂等检查放在过期检查之前。一次已经成功的发放,即使后来过了有效期,重试也应该返回 duplicate,而不是 expired。否则客户端重试会看到「过期」,误以为奖励没发,进而触发客服工单。

第二,过期判断是 <= now。到期时刻本身不可领,和上一节活动窗口的半开区间保持一致。这个细节不值得在每个调用方重复解释,应该由发放函数统一。

第三,库存不足时返回原库存快照,不做部分扣减。调用方拿到 out-of-stock 后可以选择降级奖励、排队等待或提示玩家,但不会面对一个「扣了一半」的中间态。

第四,请求的物品必须与库存物品一致。这个检查看起来多余,却能拦住一类真实错误:运营配置了 A 物品,库存服务却按 B 物品扣减,最后账实不符。

第五,补偿时效允许多种输入:已发出时传 sentAt,未发出时传 null。函数只做状态判定,不负责发送;发送失败、重试、人工审批都留在调用方。这样时效计算可以独立测试,不会被消息队列的副作用干扰。

第六,示例没有引入数据库事务、锁或消息队列。这些是生产系统必须有的,但它们不应该进入业务规则的单元测试。纯函数负责「给定状态,应该得到什么结果」,基础设施负责「如何原子地保存这个结果」。

双案例对照:批量补偿与高价值补偿

消消每周两个活动、参与率 42%,补偿往往是「大批量、低单价」,适合模板化、自动审批、按活动维度批量生成幂等键;方舟每周一个活动、参与率 61%,补偿「小批量、高单价」,更适合人工复核后再发。两者的 SLA 分别是 2 小时和 4 小时,正好对应两种流程的节奏。

账本之外:邮件过期与客诉

即使发放逻辑正确,邮件过期仍然会产生客诉。玩家会说「我没看到邮件」「我点了但没到账」「邮件过期了能不能补」。这三种说法对应三种不同的证据:

玩家说法需要查的证据常见结论
没看到邮件邮件创建时间、展示埋点邮件未创建,或客户端未拉取
点了没到账领取请求、账本状态、库存流水请求失败、重复领取或发货超时
过期能不能补过期时间、是否领取、补偿政策按政策补发或拒绝

这三种情况不应该用同一套自动补偿处理。第一种可能是客户端问题,第二种可能是发放链路问题,第三种是政策问题。把它们混在一起,会让真正的事故被「已补偿」掩盖。

一个可用的做法是给每类客诉定义明确的证据包和处置动作:能自动判定的自动处理,不能自动判定的进入人工队列;人工队列的结论要回写到账本,形成可复用的规则。这样补偿系统不只是「发东西」,还是「积累判断」。

取舍:全量预留牺牲库存周转

「先预留、后发货」的代价是库存周转变慢。预留期间,库存对其他人不可见;如果发货链路慢,或者玩家迟迟不领取,这部分库存会被长时间占用。极端情况下,活动看起来「还有库存」,实际可用库存已经被预留光。

替代方案是「超卖 + 事后补偿」:先尽量发,发超了再补。它的库存周转快,但会把库存问题转化成补偿问题,而补偿又要受 SLA 约束。对限量稀缺物品,超卖的体验损失通常大于预留的周转损失;对无限量或可再生的奖励,预留反而多余。

所以库存策略应该按物品分层,而不是全站一套。限量皮肤、实物奖品、稀缺兑换券适合严格预留;普通金币、经验加成适合宽松扣减甚至不扣库存。案例里消消的活动奖励以低单价道具为主,方舟的奖励更接近高价值内容,两者的库存策略不应该相同。

另一个代价是复杂度。预留意味着状态机、超时释放、对账任务和人工干预入口;不预留只需要一个扣减。团队规模小的时候,可以先从「单物品、单事务、无预留」开始,等出现第一次超卖再引入预留。但一旦引入,就要把释放逻辑和账本审计一起做完,不能只做一半。

小结

  • 邮件、补偿、库存应该共用一套账本,账本至少记录幂等键、玩家、物品、数量、状态和过期时间。
  • 幂等键按「活动 × 玩家 × 原因」设计,重试、重复点击和队列重投都落在同一个键上。
  • 库存采用「先预留、后发货」,库存不足时全有或全无,避免部分发放的中间态。
  • 过期判断用服务端时间和半开区间,过期记录保留而不是删除,方便申诉举证。
  • 补偿 SLA 拆成 pending、met、breached 三态,用事故确认时间而不是玩家反馈时间作为起点。
  • 批量补偿和高价值补偿应该有不同的流程与 SLA;库存预留的严格程度也应该按物品分层。

阅读导航:上一节:9.1 活动配置、时间窗口与灰度 · 下一节:9.3 活动结算与数据复盘 。

常见错误与边界

  1. 用请求 id 做幂等键。每次重试的请求 id 都不同,去重完全失效;键要落在业务维度上。
  2. 先发货再扣库存。并发下库存会被扣成负数,或者奖品发超,最后只能用补偿掩盖。
  3. 库存不足时部分扣减。账本出现「应发 10、实发 9」的中间态,对账和客诉都要额外处理。
  4. 过期判断用客户端时间。玩家改系统时间就能领取已过期奖励,服务端时间才是唯一依据。
  5. 到期时刻仍可领取。闭区间会让「到点」产生歧义,统一用 now < expiresAt 更清楚。
  6. 过期后删除账本记录。玩家申诉时无法举证,客服只能凭截图判断,容易产生重复补偿。
  7. 幂等检查放在过期检查之后。成功发放的重试会被判成过期,客户端误以为没发,触发工单。
  8. 补偿 SLA 从玩家反馈开始算。反馈时间分散且不可控,SLA 永远无法稳定达标;应从事故确认开始。
  9. 所有物品都用同一套库存策略。限量物品需要严格预留,普通道具不需要,混用会牺牲周转或增加超卖。
  10. 补偿只发不记账。没有账本就无法回答「发没发、发了几次、为什么发」,也无法阻止重复补偿。
  11. 把补偿当成事故处理的终点。补偿只解决玩家侧损失,配置错误、库存错误和链路错误仍然需要复盘和修复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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