《移动游戏产品的架构·商业化与发行工程》5.2 补单、掉单与用户申诉

掉单不是事故,而是每月固定发生的量。《潮汐消消》月订单 42 万笔、掉单率 0.35%,约 1,470 笔需要补单。本节把补单拆成定时查单、客户端补拉和用户申诉三条路径,用退避计划、升级阈值与申诉分诊规则,说明自动补发的收益和误发风险如何平衡。

掉单是流量,不是事故

支付回调丢失一次,对单笔订单是异常;乘上月订单量,它就是固定流量。消消一个月 42 万笔订单,按案例设定的 0.35% 掉单率计算,约 1,470 笔订单在支付成功后没有及时进入发货流程,摊到每天约 49 笔。方舟一个月 15 万笔,按 0.20% 计算约 300 笔,每天约 10 笔。

这组数字决定了补单系统的形态。49 笔一天不需要人工逐单处理,但也不能靠「玩家会来申诉」被动兜底,因为申诉率通常低于掉单率,沉默的玩家会直接流失。补单必须是主动的、可观测的、有上限的流水线,而不是客服工具里的一个手工按钮。

5.1 把订单状态机和幂等发货建好了。5.2 处理的是这些状态没有按预期推进时怎么办:订单停在 created、停在 paid、或者玩家已经付了钱但服务端连订单都没看到。

三条触发路径

补单不能只依赖一条路径。案例里同时保留三条,各自的证据强度、时效和成本不同。

路径触发者证据典型时效主要风险
定时查单服务端本地待处理订单 + 渠道查询结果分钟到小时级查询配额、渠道接口限流
客户端补拉玩家或客户端本地交易号 + 恢复购买接口玩家触发后秒级客户端伪造交易号
用户申诉客服系统订单号、支付凭证、账号信息小时到天级重复申诉、凭证伪造

三条路径最终都汇到同一个判断:这笔支付事实是否成立、订单是否已经发货、应该补发还是拒绝。判断逻辑必须共用,否则会出现「定时任务补发了一次,客服又补发一次」的双重发放。

定时查单是主路径。它不依赖玩家主动,覆盖沉默掉单;缺点是渠道查询接口通常有配额和频率限制,不能对每笔订单高频轮询。客户端补拉是体验路径,玩家点「恢复购买」时立刻触发一次查询,适合支付完成后短时间内没到账的场景。用户申诉是兜底路径,处理前两条路径都没覆盖的边角情况,也是唯一能拿到截图、聊天记录等外部证据的入口。

查单要有节奏

对一笔掉单订单无限重试,既打爆渠道接口,也无法提高成功率。案例的查单计划由五个参数控制:首次查询延迟、基础退避、退避上限、最大尝试次数、升级与关闭窗口。

参数示例值作用
首次查询延迟5 分钟给渠道回调留出正常到达时间,避免刚支付就查
基础退避1 分钟第一次重试的间隔,之后按 2 的幂增长
退避上限6 小时防止间隔无限增长,保证每天仍有查询机会
最大尝试次数12 次超过后不再自动查询,转人工
升级窗口24 小时订单超过一天仍未解决,进入人工队列
关闭窗口7 天超过窗口仍无支付事实,关闭订单并停止补单

这些是工程参数,不是事实表数字。真实取值要按渠道回执有效期、客服流程和查询配额确定,不能照抄。参数之间还有约束:首次查询延迟必须小于升级窗口,退避上限乘以剩余尝试次数不能超过关闭窗口,否则计划里会出现永远排不到的任务。

退避计划的价值不只是省查询量,更重要的是给渠道留出状态收敛的时间。支付成功但渠道侧订单还在处理中时,立刻查询可能返回「未找到」,这个结果不能当成「未支付」。案例把 not-found 和 pending 都归入重试,把 closed 单独归入关闭,就是为了避免把「还没好」误判成「不存在」。

申诉不是工单,是分诊

玩家提交申诉时,客服拿到的信息通常不完整:有人只写「我充了钱没到账」,有人贴了截图但没有订单号,有人把同一笔订单在一周内提交五次。如果每条申诉都建工单、都转人工,消消每月 1,470 笔掉单会迅速放大成数千条工单。

案例的做法是先分诊,再决定是否建单。分诊按固定顺序检查六件事:

  1. 是否超出申诉窗口。超过 7 天的申诉直接拒绝,引导玩家走渠道退款,而不是补发。
  2. 是否重复申诉。同一玩家、同一订单在 24 小时内已有申诉,标记为重复,合并到原工单。
  3. 是否有订单号。没有订单号但有支付凭证,转人工匹配;两者都没有,要求补充凭证。
  4. 订单是否存在。订单不存在但有凭证,转人工核对渠道流水;没有凭证,要求补充。
  5. 订单是否已发货。已发货的申诉不是补发问题,应解释权益位置并检查账号是否登录错误。
  6. 订单金额是否在自动补发阈值内。已支付但未发货的订单,小额直接补发,大额转人工。

金额阈值要按产品设置。消消平均客单价 ¥14.5,主力档位 ¥18,本节示例把自动补发上限定在 ¥68;方舟平均客单价 ¥182,主力档位 ¥128,示例上限为 ¥328。阈值不是「越大越安全」,而是「误发的最大损失可以接受」。超过阈值的订单转人工,不是因为一定有问题,而是因为一旦误发,损失需要有人负责。

已退款的订单不能补发。玩家可能先申请退款,再以「没到账」为由提交申诉;如果分诊只看「已支付」,就会在退款完成后又发一次货。状态检查必须覆盖 refunded,并把 refunding 也排除在自动补发之外。

案例里的两条曲线

消消和方舟的补单压力形状不同。

指标《潮汐消消》《潮汐方舟》
月订单量42 万笔15 万笔
掉单率0.35%,约 1,470 笔/月0.20%,约 300 笔/月
日均掉单约 49 笔约 10 笔
平均客单价¥14.5¥182
退款率1.2%,约 5,040 笔/月0.8%,约 1,200 笔/月
主力档位¥18,占订单 46%¥128,占订单 38%

消消的掉单绝对量大,但单笔金额小,自动补发的比例可以很高。本节示例把 ¥68 以下设为自动补发,主力档位 ¥18 和大部分订单都能在无人工介入的情况下完成。方舟的掉单绝对量小,但平均客单价 ¥182,¥328 以上还有 ¥648 档位,人工复核的比例更高。

退款率对补单的影响经常被低估。消消每月约 5,040 笔退款,方舟约 1,200 笔。退款和掉单在客服侧看起来都像「钱和货对不上」,但处理方向相反:掉单要补发,退款要收回或冲销。分诊必须先确认订单状态,再决定走哪条流程,不能把两类申诉混在一个队列里。

把补单计划写成可运行的纯函数

下面的实现把查单节奏和申诉分诊拆成两组纯函数。查单部分计算每笔订单的下一次查询时间、当前应执行的动作;申诉部分根据订单快照和已有申诉给出分诊结论。两者都不访问网络和数据库。

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

export type ChannelId = "apple-ios" | "google-play" | "android-channel" | "tap-tap";

export interface PendingOrder {
  readonly orderId: string;
  readonly channel: ChannelId;
  readonly amountCents: number;
  readonly createdAtMs: number;
  readonly attempts: number;
  readonly lastAttemptMs: number | null;
}

export interface RecoveryPolicy {
  readonly nowMs: number;
  readonly firstQueryAfterMs: number;
  readonly baseBackoffMs: number;
  readonly maxBackoffMs: number;
  readonly maxAttempts: number;
  readonly escalateAfterMs: number;
  readonly closeAfterMs: number;
}

export function backoffMs(attempts: number, policy: RecoveryPolicy): number {
  if (attempts <= 0) return 0;
  const raw = policy.baseBackoffMs * 2 ** (attempts - 1);
  return Math.min(raw, policy.maxBackoffMs);
}

export function nextQueryAt(order: PendingOrder, policy: RecoveryPolicy): number | null {
  const age = policy.nowMs - order.createdAtMs;
  if (age < 0) return null;
  if (age >= policy.closeAfterMs) return null;
  if (order.attempts >= policy.maxAttempts) return null;
  if (order.attempts === 0) return order.createdAtMs + policy.firstQueryAfterMs;
  const last = order.lastAttemptMs ?? order.createdAtMs;
  return last + backoffMs(order.attempts, policy);
}

export type QueryOutcome = "paid" | "pending" | "not-found" | "closed" | "error";

export type QueryAction = "deliver" | "retry" | "escalate" | "close";

export interface QueryDecision {
  readonly action: QueryAction;
  readonly reason: string;
}

export function decideQuery(
  order: PendingOrder,
  outcome: QueryOutcome,
  policy: RecoveryPolicy,
): QueryDecision {
  if (outcome === "paid") return { action: "deliver", reason: "channel confirms payment" };
  if (outcome === "closed") return { action: "close", reason: "channel closed the order" };
  const age = policy.nowMs - order.createdAtMs;
  if (age >= policy.closeAfterMs) return { action: "close", reason: "recovery window expired" };
  if (age >= policy.escalateAfterMs || order.attempts >= policy.maxAttempts) {
    return { action: "escalate", reason: "needs manual review" };
  }
  return { action: "retry", reason: outcome === "error" ? "query failed" : "not settled yet" };
}

export interface QueryTask {
  readonly orderId: string;
  readonly channel: ChannelId;
  readonly dueAtMs: number;
  readonly attempts: number;
}

export function planQueries(
  orders: readonly PendingOrder[],
  policy: RecoveryPolicy,
): readonly QueryTask[] {
  const tasks: QueryTask[] = [];
  for (const order of orders) {
    const dueAtMs = nextQueryAt(order, policy);
    if (dueAtMs === null || dueAtMs > policy.nowMs) continue;
    tasks.push({ orderId: order.orderId, channel: order.channel, dueAtMs, attempts: order.attempts });
  }
  return tasks.sort((a, b) => a.dueAtMs - b.dueAtMs || a.orderId.localeCompare(b.orderId));
}

export interface Appeal {
  readonly appealId: string;
  readonly orderId: string | null;
  readonly playerId: string;
  readonly amountCents: number;
  readonly createdAtMs: number;
  readonly hasReceipt: boolean;
}

export type AppealCode =
  | "auto-grant"
  | "manual-review"
  | "already-delivered"
  | "duplicate-appeal"
  | "need-proof"
  | "outside-window"
  | "refunded";

export interface AppealDecision {
  readonly code: AppealCode;
  readonly reason: string;
}

export interface AppealPolicy {
  readonly nowMs: number;
  readonly dedupeWindowMs: number;
  readonly autoGrantMaxCents: number;
  readonly appealWindowMs: number;
}

export interface OrderSnapshot {
  readonly state: "created" | "paid" | "delivering" | "delivered" | "refunding" | "refunded" | "closed";
}

export function triageAppeal(
  appeal: Appeal,
  order: OrderSnapshot | undefined,
  priorAppeals: readonly Appeal[],
  policy: AppealPolicy,
): AppealDecision {
  if (policy.nowMs - appeal.createdAtMs > policy.appealWindowMs) {
    return { code: "outside-window", reason: "appeal window expired" };
  }
  const duplicated = priorAppeals.some(
    (item) =>
      item.appealId !== appeal.appealId &&
      item.playerId === appeal.playerId &&
      item.orderId === appeal.orderId &&
      policy.nowMs - item.createdAtMs <= policy.dedupeWindowMs,
  );
  if (duplicated) return { code: "duplicate-appeal", reason: "same order appealed recently" };
  if (appeal.orderId === null) {
    return appeal.hasReceipt
      ? { code: "manual-review", reason: "receipt without order id" }
      : { code: "need-proof", reason: "order id and receipt are both missing" };
  }
  if (order === undefined) {
    return appeal.hasReceipt
      ? { code: "manual-review", reason: "order not found but receipt attached" }
      : { code: "need-proof", reason: "order not found and no receipt" };
  }
  if (order.state === "delivered") return { code: "already-delivered", reason: "order already delivered" };
  if (order.state === "refunded") return { code: "refunded", reason: "order already refunded" };
  if (order.state === "paid" || order.state === "delivering") {
    if (appeal.amountCents <= policy.autoGrantMaxCents) {
      return { code: "auto-grant", reason: "paid order within auto-grant threshold" };
    }
    return { code: "manual-review", reason: "paid order above auto-grant threshold" };
  }
  return { code: "manual-review", reason: `order is ${order.state}` };
}

export function formatTasks(tasks: readonly QueryTask[]): string {
  if (tasks.length === 0) return "no query due";
  return tasks.map((task) => `${task.orderId}@${task.channel}#${task.attempts}`).join(",");
}

代码里的三个判断

nextQueryAt 先看窗口再看尝试次数:订单超过关闭窗口、尝试次数达到上限、或者创建时间在未来,都返回 null,表示不再自动查询。首次查询用 createdAt + firstQueryAfterMs,后续查询用 lastAttempt + backoff。如果 lastAttemptMs 为空但尝试次数大于零,用创建时间兜底,避免数据不完整时算出 NaN。

decideQuery 把渠道结果映射成四个动作。paid 直接发货,closed 直接关闭,这两个不看账龄;其余结果先看是否超过关闭窗口,再看是否达到升级窗口或尝试上限,最后才是重试。顺序很重要:一笔已经超过关闭窗口的订单,即使渠道返回 error,也不应该继续重试。

triageAppeal 的顺序是「窗口、重复、凭证、订单、状态、金额」。窗口和重复检查不需要查订单,放在最前面可以挡掉大量无效申诉;凭证检查决定是否有足够信息进入人工;状态检查防止已发货和已退款订单被再次补发;金额检查才决定自动还是人工。这个顺序与 5.1 的转移表是互补的:状态机回答「现在允许做什么」,分诊回答「这笔申诉应该走哪条路」。

取舍:自动补发越快,误发窗口越大

自动补发的收益是客服成本。消消每月 1,470 笔掉单,如果每笔都要人工核对,按每单几分钟估算就是上百个工时。把小额订单自动补发,可以把大部分工作量压到机器侧。

代价是误发风险。订单可能只是延迟,不是丢失;自动补发后,原发货链路恢复,玩家就拿到了两份。案例用三道闸门控制这个风险:补发前再次确认订单状态仍是 paid 或 delivering;补发使用 5.1 的 grantId 幂等记账;补发后把订单推进到 delivered,让后续到达的原发货请求变成重复。三道闸门都不能省,少一道就会把「补单」变成「多发」。

另一个代价是时效与查询成本的矛盾。首次查询延迟越短,玩家感知越好,但渠道还没收敛,查询会返回大量 pending 和 not-found;延迟越长,查询越准,但玩家等待越久。本节示例取 5 分钟,是因为支付回调的正常到达时间通常在秒级到分钟级,5 分钟足够过滤掉大部分瞬时延迟,又不至于让玩家等太久。这个值必须按渠道实际到达分布调整,不能直接复制。

最后是人工队列的成本。升级窗口 24 小时、自动补发上限 ¥68 / ¥328,都是把「不确定」推给人的阈值。阈值越保守,人工队列越长;阈值越激进,误发越多。案例的选择是让金额决定风险,而不是让订单量决定风险:小额自动,大额人工,这样人工队列的规模随金额分布变化,而不是随掉单量线性增长。

小结

  • 掉单是固定流量:消消约 1,470 笔/月、日均 49 笔;方舟约 300 笔/月、日均 10 笔。
  • 补单有三条路径:定时查单、客户端补拉、用户申诉;三条路径必须共用同一套支付事实和发货幂等判断。
  • 查单节奏由首次延迟、指数退避、退避上限、最大尝试、升级与关闭窗口共同决定,参数之间要满足约束。
  • 申诉先分诊后建单:窗口、重复、凭证、订单、状态、金额,顺序不能乱。
  • 自动补发用状态复核、grantId 幂等和状态推进三道闸门控制误发,代价是人工队列和查询成本。

阅读导航:上一节:5.1 订单状态机与幂等发货 · 下一节:5.3 渠道对账与收入确认 。

常见错误与边界

  1. 只靠玩家申诉发现掉单。沉默玩家不会申诉,掉单会直接变成流失;必须有服务端定时查单作为主路径。
  2. 对掉单订单无限重试。渠道接口有配额,无限重试既打爆接口,也无法提高成功率;必须有退避、上限和关闭窗口。
  3. 把渠道返回的 not-found 当成未支付。渠道状态可能尚未收敛,not-found 应归入重试,只有明确的 closed 才关闭订单。
  4. 补发前不重新检查订单状态。订单可能在排队期间被原链路发货或退款,直接补发会造成重复发放或退款后补发。
  5. 补发不走幂等记账。客服手工补发和定时任务补发各发一次,玩家拿到两份;补发必须复用 5.1 的行项目账和 grantId。
  6. 申诉不查重。同一玩家、同一订单在一周内提交多次,会生成多条工单并可能触发多次补发;必须按玩家、订单和时间窗合并。
  7. 已退款订单仍自动补发。退款完成后玩家再以「没到账」申诉,分诊只看支付状态就会重复发货;状态检查必须覆盖退款中与已退款。
  8. 自动补发阈值按订单量而不是金额设置。小额高频产品会把阈值设得过高,大额低频产品会把阈值设得过低;阈值应由单笔最大可接受损失决定。
  9. 首次查询延迟设为 0。支付刚完成就查询,渠道通常还没落账,会制造大量无效查询和误判;应给回调留出正常到达时间。
  10. 补单计划没有可观测输出。只看最终补发量,无法区分「没有掉单」和「任务没跑」;应输出到期任务数、重试数、升级数和关闭数。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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