支付成功页只是客户端的一厢情愿
玩家在收银台完成付款,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 对应五道校验,顺序固定:
missing-signature:签名为空。最早的拒绝,不查任何外部状态。bad-environment:沙盒凭证进入生产策略。灰度环境通过allowSandbox显式放开。product-mismatch:凭证里的商品与订单期望不一致。amount-mismatch:金额或币种不一致。两者合并成一个 code,因为对账时它们都表现为「付的钱不对」。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 订单状态机与幂等发货 。
常见错误与边界
- 客户端收到 SDK 回调就直接发货。客户端无法证明扣款真实发生,也无法处理重复通知;必须以服务端凭证校验为准。
- 只验签名,不验商品和金额。签名只能证明报文来自商店,不能证明玩家买的是这件商品、付的是这个价。
- 把沙盒凭证放进生产环境。测试账号可以凭空生成回执,一旦放开就是无限刷道具。
- 用「先查再写」实现幂等。并发回调会同时查到不存在,然后各发一次货;唯一键必须由存储层保证。
- 用客户端时间判断凭证时效。客户端时钟可被修改,时效判断必须用服务端可信时间,并允许合理的时钟漂移。
- 为了做促销放宽金额校验。放宽一次,篡改就多一条路径;优惠应建模为独立 SKU 或价格版本。
- 退款只扣道具不看余额。道具可能已消耗,硬扣会产生负余额和大量工单;应按商品类型分别处理。
- 把风控信号当成永久封禁。误杀大额玩家的损失可能高于放过一笔小额欺诈;应先延迟发货并进入人工队列。
- 不保留拒绝现场。只记一个失败计数,配置修好后无法重放;原始报文、归一化字段和 code 都要留存。
- 认为校验通过就完成了发货。校验只证明支付真实,重复发货与状态推进仍要靠账本唯一键和订单状态机。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。