《移动游戏产品的架构·商业化与发行工程》4.3 支付回调与凭证校验

支付成功页不等于发货完成。本节以《潮汐消消》月订单 42 万笔、掉单率 0.35%、退款率 1.2% 为背景,把回调拆成签名、环境、商品、金额、时效五道校验,用不可变账本实现幂等,并说明回调乱序、重复与伪造成为常态时,校验从严、掉单补偿与人工复核之间的取舍。

支付成功页只是客户端的一厢情愿

玩家在收银台完成付款,SDK 回调客户端,客户端弹出一个「支付成功」,然后把界面切回游戏。这一刻,钱可能已经扣了,也可能只是商店的界面走完了流程;服务端可能已经收到通知,也可能一条都没收到。

把这三个时间点分开,问题才看得清:

  • T0 支付成功:商店或渠道确认扣款,玩家看到回执。
  • T1 回调到达:商店或渠道把结果通知给游戏服务端。
  • T2 发货完成:服务端校验通过,把道具写进玩家背包。

客户端只能可靠地知道 T0 附近发生的事情,它不知道 T1 是否到达,更不知道 T2 是否成功。如果客户端在 T0 之后直接改本地背包,就会制造三种后果:回调丢失时玩家以为已到账、重复回调时重复发货、伪造回调时凭空发货。

服务端的职责是:以 T1 的凭证为准,校验通过后才推进到 T2。本节讨论这条链路;商品怎么定价、走哪条通道,分别属于 4.1 和 4.2。

三个时间点与四种结局

一条回调到达服务端后,只有四种结局:

结局现象正确处理
正常凭证有效,订单首次出现记账、发货、返回成功
掉单玩家已付款,T1 一直没到主动查单或客户端补拉,不能干等
重复同一条通知到达多次幂等返回第一次的结果,不重复发货
异常签名错、金额不符、环境不对拒绝并留痕,不发货

这四种结局不是理论分类,而是每月都会发生的量。消消一个月 42 万笔订单,按事实表的掉单率 0.35% 计算,约 1,470 笔需要补单;退款率 1.2%,约 5,040 笔退款要进账本;支付欺诈率 0.08%,约 336 笔需要人工或风控介入。三个比例都不高,但乘上订单量,没有一条可以靠「应该不会发生」带过。

方舟一个月 15 万笔订单,按 0.20% 掉单、0.8% 退款、0.03% 欺诈计算,分别约 300 笔、1,200 笔和 45 笔。订单量只有消消的三分之一左右,但客单价 ¥182,单笔金额更大,人工复核的优先级反而更高。

五道校验,顺序不能乱

凭证校验不是「验个签名就完事」。一份来自商店或渠道的凭证,至少要过五道关:

顺序校验不通过的后果处理
1签名非空且可验证任何人都能伪造回调拒绝,记录来源 IP 与原始报文
2环境匹配沙盒凭证在生产环境发货拒绝;除灰度和测试白名单外不放开
3商品 id 匹配低价商品冒充高价商品拒绝,按订单期望值比对
4金额与币种匹配改价、汇率或篡改导致少付多得拒绝;促销必须建模为独立价格
5时间在有效窗口内重放几个月前的旧回执拒绝;未来时间同样拒绝

顺序有讲究:先做便宜且无副作用的检查,再做需要查库或调用商店接口的检查。签名和环境不依赖订单状态,可以最早拒绝;商品、金额要拿订单期望值比对;时效检查放在最后,因为它需要可信的当前时间。

这里有一个容易被误解的边界:本节代码里的 verifyReceipt 校验的是已经归一化的凭证字段,它不直接调用 Apple、Google 或渠道的验证接口。生产实现要先从商店服务端取回凭证详情,再把这些字段交给纯函数判断。把网络调用和判断逻辑分开,才能对判断逻辑做确定性测试;否则每个用例都要连沙盒。

另一个边界是「金额必须精确相等」。这条规则会让促销、优惠券和首购折扣很难做,但它是防篡改的底线。正确做法是把优惠建模成独立的 SKU 或明确的价格版本,让订单期望金额本身就是折后价,而不是在校验时放宽比较。

幂等不是「先查再写」

最危险的实现是这样:

if (ledger.find(transactionId) === null) {
  grantItems();
  ledger.insert(transactionId);
}

两个回调同时到达时,两个请求都可能查到「不存在」,然后都发货。查询和写入之间的窗口,就是重复发货的窗口。支付回调的重试机制会刻意制造这种并发:商店没收到成功响应,就会再发一次。

可靠的做法是让存储层承担唯一性:对 (route, transactionId) 建唯一索引,插入成功的那一次才发货,插入冲突的那一次读出已有记录并返回相同结果。代码里的 applyCallback 用不可变账本模拟这条规则:首次返回 applied,重复返回 duplicate,同一个键但商品或金额不同返回 conflict。

为什么键里要带 route?因为不同商店的 transaction id 可能撞号。把路由和交易号拼在一起,才能保证「Apple 的 tx-1001」和「某渠道的 tx-1001」不会被当成同一笔。

conflict 是最值得警惕的结局。同一个交易号、不同的金额或商品,通常意味着三种情况之一:客户端重试时换了商品、渠道复用了交易号、或者有人在篡改报文。无论哪一种,都不能覆盖原记录,而应该保留两条证据并告警。

掉单、退款与欺诈是三条账

把三个比例乘上订单量,得到三张不同性质的账:

账消消(42 万笔/月)方舟(15 万笔/月)处理方式
掉单0.35%,约 1,470 笔0.20%,约 300 笔主动查单、客户端补拉、人工补发
退款1.2%,约 5,040 笔0.8%,约 1,200 笔退款回调入账,按可回收性处理道具
欺诈0.08%,约 336 笔0.03%,约 45 笔风控标记、延迟发货、人工复核

掉单的关键是不要等。回调是「至少一次」的投递,不是「一定到达」的承诺。服务端要有定时对账任务,拿本地未完成订单去商店或渠道查询;客户端也要提供「恢复购买」或「补单」入口,让玩家能主动触发。补单窗口是工程参数,本节示例设为 7 天,实际值要按商店回执有效期和客服流程确定,不能照抄。

退款不是简单地把道具扣回来。消耗品可能已经被用掉,非消耗品可能已经解锁了关卡,订阅可能已经产生了权益。退款回调到达时,正确顺序是:先把订单标记为已退款,再按商品类型决定回收、扣减余额还是只做账务冲销。消消每月约 5,040 笔退款,如果每一笔都尝试扣回钻石,会产生大量「余额不足」的异常工单。

欺诈的比例最低,但单笔危害最大。常见信号包括:同一凭证在多个账号复用、金额与商品不匹配、短时间高频购买、设备与账号历史行为突变。风控信号不应该直接变成「永不发货」:误杀一个正常大额玩家,损失可能高于放过一笔小额欺诈。更稳妥的做法是先延迟发货、进入人工队列,同时保留凭证和订单快照。

把校验写成可运行的状态

下面的实现把校验和记账拆成两个纯函数。校验函数只返回带 code 的结果,不抛异常;记账函数返回新的账本,不修改输入。这样每个分支都能在 Node 里被直接断言。

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

export type RouteId = "apple-iap" | "google-play" | "channel-pay" | "tap-tap-pay";
export type ReceiptEnv = "sandbox" | "production";
export type Currency = "CNY" | "USD";

export interface Receipt {
  readonly route: RouteId;
  readonly transactionId: string;
  readonly productId: string;
  readonly amountCents: number;
  readonly currency: Currency;
  readonly purchasedAtMs: number;
  readonly env: ReceiptEnv;
  readonly signature: string;
}

export interface VerifyPolicy {
  readonly expectedProductId: string;
  readonly expectedAmountCents: number;
  readonly expectedCurrency: Currency;
  readonly maxAgeMs: number;
  readonly nowMs: number;
  readonly allowSandbox: boolean;
}

export type VerifyCode =
  | "ok"
  | "missing-signature"
  | "bad-environment"
  | "product-mismatch"
  | "amount-mismatch"
  | "future-dated"
  | "expired";

export interface VerifyResult {
  readonly ok: boolean;
  readonly code: VerifyCode;
  readonly detail: string;
}

export interface LedgerEntry {
  readonly key: string;
  readonly route: RouteId;
  readonly transactionId: string;
  readonly productId: string;
  readonly amountCents: number;
  readonly appliedAtMs: number;
}

export type ApplyStatus = "applied" | "duplicate" | "conflict";

export interface ApplyOutcome {
  readonly status: ApplyStatus;
  readonly ledger: readonly LedgerEntry[];
  readonly detail: string;
}

export function receiptKey(receipt: Pick<Receipt, "route" | "transactionId">): string {
  return `${receipt.route}:${receipt.transactionId}`;
}

export function verifyReceipt(receipt: Receipt, policy: VerifyPolicy): VerifyResult {
  if (receipt.signature.trim() === "") {
    return { ok: false, code: "missing-signature", detail: "signature is empty" };
  }
  if (receipt.env === "sandbox" && !policy.allowSandbox) {
    return { ok: false, code: "bad-environment", detail: "sandbox receipt in production policy" };
  }
  if (receipt.productId !== policy.expectedProductId) {
    return {
      ok: false,
      code: "product-mismatch",
      detail: `expected ${policy.expectedProductId}, got ${receipt.productId}`,
    };
  }
  if (receipt.currency !== policy.expectedCurrency || receipt.amountCents !== policy.expectedAmountCents) {
    return {
      ok: false,
      code: "amount-mismatch",
      detail: `expected ${policy.expectedCurrency} ${policy.expectedAmountCents}, got ${receipt.currency} ${receipt.amountCents}`,
    };
  }
  if (receipt.purchasedAtMs > policy.nowMs) {
    return { ok: false, code: "future-dated", detail: "purchase time is in the future" };
  }
  if (policy.nowMs - receipt.purchasedAtMs > policy.maxAgeMs) {
    return {
      ok: false,
      code: "expired",
      detail: `receipt age ${policy.nowMs - receipt.purchasedAtMs}ms exceeds ${policy.maxAgeMs}ms`,
    };
  }
  return { ok: true, code: "ok", detail: "receipt accepted" };
}

export function applyCallback(
  ledger: readonly LedgerEntry[],
  receipt: Receipt,
  nowMs: number,
): ApplyOutcome {
  const key = receiptKey(receipt);
  const existing = ledger.find((entry) => entry.key === key);
  if (existing !== undefined) {
    if (existing.productId === receipt.productId && existing.amountCents === receipt.amountCents) {
      return { status: "duplicate", ledger, detail: `${key} already applied` };
    }
    return {
      status: "conflict",
      ledger,
      detail: `${key} was applied with a different product or amount`,
    };
  }
  const entry: LedgerEntry = {
    key,
    route: receipt.route,
    transactionId: receipt.transactionId,
    productId: receipt.productId,
    amountCents: receipt.amountCents,
    appliedAtMs: nowMs,
  };
  return { status: "applied", ledger: [...ledger, entry], detail: `${key} applied` };
}

export function formatVerify(result: VerifyResult): string {
  return `${result.ok ? "ok" : "reject"}:${result.code} ${result.detail}`;
}

代码里的四种结果

verifyReceipt 的输出只有两类:ok 或带明确 code 的拒绝。拒绝不是异常,而是回调链路的正常输出。支付回调来自外部系统,天然会包含重复、乱序、过期和伪造;用 throw 表达这些情况,会让上层不得不用 try/catch 包住每一个分支,最后往往变成「catch 住就当成功」。

五个 code 对应五道校验,顺序固定:

  1. missing-signature:签名为空。最早的拒绝,不查任何外部状态。
  2. bad-environment:沙盒凭证进入生产策略。灰度环境通过 allowSandbox 显式放开。
  3. product-mismatch:凭证里的商品与订单期望不一致。
  4. amount-mismatch:金额或币种不一致。两者合并成一个 code,因为对账时它们都表现为「付的钱不对」。
  5. future-dated 与 expired:时间不在窗口内。未来时间单独一个 code,便于区分时钟漂移和重放。

applyCallback 的输出是 applied、duplicate、conflict 三选一,并返回新的账本。它不负责发货,也不负责校验;调用方必须先 verifyReceipt 通过,再 applyCallback。这个顺序不能反:先记账后校验,会把伪造凭证写进账本,后续再想清理就要做数据修复。

formatVerify 只做一件事:把结果压成一行稳定文本。它用于日志、告警和客服工具,不用于玩家展示。玩家看到的应该是「到账成功」或「支付处理中」,而不是 amount-mismatch 这种内部 code。

取舍:校验越严,掉单越多

每加一道校验,都会同时降低欺诈和增加误杀。把金额校验从「允许误差」改成「精确相等」,能挡住篡改,但促销配置错一个字段就会让一批正常订单被拒;把时间窗口从 30 天缩到 1 天,能减少重放,但跨时区、商店延迟和客服补单都会变成掉单。

这是一个没有免费选项的取舍。案例里的做法是:

  • 默认严格,例外显式。金额、币种、商品 id 精确匹配;促销走独立价格版本,不靠放宽校验。
  • 拒绝可逆。每次拒绝都保留原始报文、归一化字段和 code,修复配置后可以重新校验,而不是让玩家重新付一次。
  • 拒绝可观测。按 code、通道、商品维度统计拒绝量;某个 code 突然上升,通常意味着配置发布或商店政策变化,而不是玩家突然变坏。
  • 补偿有上限。补单任务按订单创建时间分批,超过窗口的进入人工队列;不做无限重试,避免把商店接口打爆。

代价是系统更复杂:需要账本、唯一索引、对账任务、告警和人工工具。但支付链路的复杂度不会因为代码写得简单而消失,它只会在月底对账或玩家投诉时以更贵的形式出现。

校验通过不等于发货完成

最后划清边界。verifyReceipt 证明的是「这张凭证对应一笔真实扣款,且商品、金额、时间与订单期望一致」。它不证明「这个玩家还没有收到过这件商品」,也不证明「发货一定成功」。前者靠账本唯一键,后者靠订单状态机与幂等发货,属于 5.1 的内容。

退款回调同样不是终点。退款之后道具能不能回收、余额会不会变成负数、订阅权益何时失效,都需要订单状态机和账务规则共同决定。本节只保证:进入账本的每一笔支付都有可验证的来源,重复到达不会重复生效,异常到达不会静默通过。

小结

  • 客户端只知道支付成功页,服务端必须以商店或渠道凭证为准,校验通过后才发货。
  • 回调有正常、掉单、重复、异常四种结局;消消每月约 1,470 笔掉单、5,040 笔退款、336 笔欺诈。
  • 五道校验按「便宜且无副作用优先」排序:签名、环境、商品、金额、时效。
  • 幂等靠存储层唯一键,不靠「先查再写」;键由路由与交易号组成,冲突要留证告警。
  • 校验越严误杀越多,必须让拒绝可逆、可观测,并用主动查单和补单窗口兜住掉单。

阅读导航:上一节:4.2 支付通道抽象:苹果 IAP、Google Play 与渠道支付 · 下一节:5.1 订单状态机与幂等发货 。

常见错误与边界

  1. 客户端收到 SDK 回调就直接发货。客户端无法证明扣款真实发生,也无法处理重复通知;必须以服务端凭证校验为准。
  2. 只验签名,不验商品和金额。签名只能证明报文来自商店,不能证明玩家买的是这件商品、付的是这个价。
  3. 把沙盒凭证放进生产环境。测试账号可以凭空生成回执,一旦放开就是无限刷道具。
  4. 用「先查再写」实现幂等。并发回调会同时查到不存在,然后各发一次货;唯一键必须由存储层保证。
  5. 用客户端时间判断凭证时效。客户端时钟可被修改,时效判断必须用服务端可信时间,并允许合理的时钟漂移。
  6. 为了做促销放宽金额校验。放宽一次,篡改就多一条路径;优惠应建模为独立 SKU 或价格版本。
  7. 退款只扣道具不看余额。道具可能已消耗,硬扣会产生负余额和大量工单;应按商品类型分别处理。
  8. 把风控信号当成永久封禁。误杀大额玩家的损失可能高于放过一笔小额欺诈;应先延迟发货并进入人工队列。
  9. 不保留拒绝现场。只记一个失败计数,配置修好后无法重放;原始报文、归一化字段和 code 都要留存。
  10. 认为校验通过就完成了发货。校验只证明支付真实,重复发货与状态推进仍要靠账本唯一键和订单状态机。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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