同一件 ¥18,四条扣款路径
玩家在 iPhone 上买 200 钻石,付 ¥18;另一个玩家在国内安卓渠道包里买同一件商品,也付 ¥18。客户端都显示「支付成功」,但财务看到的净收入不是同一个数:前者按 App Store 的 30% 分成,后者按国内安卓渠道平均 40% 分成。如果代码把「支付成功」当成一个布尔值,这个差异就会在月底对账时才暴露。
支付通道抽象要解决的不是「让四个 SDK 长得一样」,而是三件事:
- 给定平台与商品类型,稳定地选出唯一一条通道。
- 把这条通道的能力与费率变成可查询的数据,而不是散落在
if里的隐含假设。 - 在扣款之前就能算出净收入,让定价和投放模型有统一口径。
本节负责路由与费率,回调真伪和幂等发货留到 4.3。
通道差异不止是 SDK
四个通道的差异至少有七个维度,任何一项处理错,都会变成资损或客诉:
| 维度 | Apple IAP | Google Play | 国内安卓渠道 | TapTap |
|---|---|---|---|---|
| 商品创建 | App Store Connect | Play Console | 各渠道后台 | TapTap 后台 |
| 收据形态 | 签名回执 | 购买令牌 | 渠道订单号 | 渠道订单号 |
| 回调 | 服务端通知 | 实时开发者通知 | 渠道回调 | 渠道回调 |
| 退款 | 商店处理 | 商店处理 | 渠道处理 | 渠道处理 |
| 订阅 | 支持 | 支持 | 案例不承接 | 支持 |
| 恢复购买 | 支持 | 支持 | 不支持 | 不支持 |
| 案例费率 | 30% | 30% | 30%–50%,按 40% 计 | 15% |
这张表里最容易被忽略的是「恢复购买」。消耗品不需要恢复;一次性解锁和订阅在换机、重装后需要把已购状态找回来。Apple 与 Google 提供恢复入口,国内安卓渠道与 TapTap 在案例里没有对应能力。如果客户端把「恢复购买」做成所有平台都显示的按钮,安卓渠道玩家点下去只会得到一个空结果。
另一个容易忽略的是退款处理方。商店或渠道处理退款时,游戏侧收到的是「已退款」通知,而不是一笔新的扣款。退款率在事实表里是消消 1.2%、方舟 0.8%,它不是异常,而是必须进入账本的正常事件。路由层要知道退款由谁发起、回调长什么样,才能在 4.3 的校验里正确处理。
能力表:先把不能做的写下来
把上表压缩成代码可消费的能力记录,就是路由层的地基。能力表的价值不在于描述「支持什么」,而在于明确「不支持什么」:
apple-iap fee=30% subscription=yes restore=yes refund=store
google-play fee=30% subscription=yes restore=yes refund=store
channel-pay fee=40% subscription=no restore=no refund=channel
tap-tap-pay fee=15% subscription=yes restore=no refund=channel
国内安卓的费率事实表给的是 30%–50%,案例按平均 40% 计。这个区间意味着不同渠道、不同商务条款下净收入会波动;路由层用平均值做预估,真实结算仍以渠道账单为准。Apple 的小企业计划 15% 不适用于本案例,因此 Apple 与 Google 都按 30% 计,不引入第二套费率。
能力表必须是「可查询的数据」,不能是注释。原因很实际:客户端要根据 supportsRestore 决定是否显示恢复按钮,服务端要根据 supportsSubscription 拒绝不可能成立的订单,财务要根据 feeRate 做净收入预估。三处消费同一份数据,才不会出现「客户端显示订阅、服务端拒绝」的分裂。
路由是纯函数,不是 SDK 里的 if
路由的第一输入是平台,第二输入是商品类型。平台决定钱由谁收,商品类型决定这条通道能不能承接:
| 平台 | 商品类型 | 路由 | 结果 |
|---|---|---|---|
| iOS | 消耗品 / 非消耗品 / 订阅 | apple-iap | 接受 |
| 海外安卓 | 消耗品 / 非消耗品 / 订阅 | google-play | 接受 |
| 国内安卓 | 消耗品 / 非消耗品 | channel-pay | 接受 |
| 国内安卓 | 订阅 | channel-pay | 拒绝:不承接订阅 |
| TapTap | 消耗品 / 非消耗品 / 订阅 | tap-tap-pay | 接受 |
为什么输入里没有 region?因为平台的取值已经把区域编码进去了:android-cn 与 android-overseas 是两条不同的通道,ios 和 tap-tap 在案例里不区分区域。若把区域再作为独立维度,就会出现 ios + 中国 与 ios + 海外 两个都合法、费率却相同的组合,增加不必要的状态。用平台枚举承担区域语义,代价是新增区域时要加一个平台值,而不是改一条配置。
这里有一个重要的设计选择:能力不满足时返回拒绝,而不是静默降级到另一条通道。iOS 上的订阅不可能改走 Google Play,国内安卓的订阅也不应该偷偷变成一次性商品。降级会把「通道不支持」伪装成「购买成功」,最后在续订或退款时爆炸。
平台占比不是路由输入。消消的平台占比是国内安卓 55%、iOS 30%、TapTap 8%、海外 7%,这些数字用于容量规划和收入预估,不能用来决定「这一笔订单走哪条路」。每一笔订单的平台由客户端运行环境决定,路由函数只做确定性映射。
费率如何进入定价
费率不改变玩家看到的价格,但改变公司的净收入。以 ¥18 为例,四条通道扣完分成后的净额差别很大:
| 通道 | 费率 | 分成 | 净收入 |
|---|---|---|---|
| Apple IAP | 30% | ¥5.40 | ¥12.60 |
| Google Play | 30% | ¥5.40 | ¥12.60 |
| 国内安卓渠道 | 40% | ¥7.20 | ¥10.80 |
| TapTap | 15% | ¥2.70 | ¥15.30 |
同样是 ¥18,TapTap 比国内安卓渠道多留下 ¥4.50。这不是「TapTap 更赚钱」的结论,而是提醒定价模型不能用单一费率。消消的内购月流水约 609 万元,如果按事实表的平台占比与费率加权(海外 7% 按 30% 计):
0.55 × 40% + 0.30 × 30% + 0.08 × 15% + 0.07 × 30%
= 22% + 9% + 1.2% + 2.1%
= 34.3%
综合通道成本约 34.3%,对应净收入约 400 万元。方舟的加权口径是 0.45 × 30% + 0.40 × 40% + 0.15 × 30% = 13.5% + 16% + 4.5% = 34.0%,对应内购月流水约 2,730 万元的净收入约 1,802 万元。
消消的国内安卓占 55%,按平均 40% 计,是费率最高的板块;TapTap 只占 8%,15% 的低费率拉不回大盘。综合费率约 34.3%。
方舟没有 TapTap 量,iOS 45% 与 Google Play 15% 都按 30%,国内安卓 40% 按 40%,综合约 34.0%。两边接近,但方舟的订单更集中在大额档。
两个产品的综合费率几乎一样,组成却不同。这个结果对定价的含义是:不要按通道给玩家加价。同一区域同一商品对不同平台用户收不同价格,既违反商店规则,也会被玩家截图对比。正确的做法是把通道成本放进整体定价和买量回收模型,而不是在收银台上按平台加一道手续费。
把路由写成可运行的能力表
下面的实现把平台映射、能力校验和净收入计算拆成三个纯函数。它不 import 任何 SDK,也不发网络请求;路由结果是一份可序列化的记录,便于写进订单和日志。
// file: src/biz/payment-route.ts
// tier: L1
// verify: node --test test/biz/payment-route.test.ts
export type Platform = "ios" | "android-cn" | "android-overseas" | "tap-tap";
export type ProductKind = "consumable" | "non-consumable" | "subscription";
export type RouteId = "apple-iap" | "google-play" | "channel-pay" | "tap-tap-pay";
export type RefundPolicy = "store-managed" | "channel-managed";
export interface RouteCapability {
readonly id: RouteId;
readonly label: string;
readonly feeRate: number;
readonly supportsSubscription: boolean;
readonly supportsRestore: boolean;
readonly refundPolicy: RefundPolicy;
}
export const ROUTE_CAPABILITIES: Record<RouteId, RouteCapability> = {
"apple-iap": {
id: "apple-iap",
label: "Apple App Store",
feeRate: 0.3,
supportsSubscription: true,
supportsRestore: true,
refundPolicy: "store-managed",
},
"google-play": {
id: "google-play",
label: "Google Play",
feeRate: 0.3,
supportsSubscription: true,
supportsRestore: true,
refundPolicy: "store-managed",
},
"channel-pay": {
id: "channel-pay",
label: "国内安卓渠道",
feeRate: 0.4,
supportsSubscription: false,
supportsRestore: false,
refundPolicy: "channel-managed",
},
"tap-tap-pay": {
id: "tap-tap-pay",
label: "TapTap",
feeRate: 0.15,
supportsSubscription: true,
supportsRestore: false,
refundPolicy: "channel-managed",
},
};
export interface RouteDecision {
readonly ok: boolean;
readonly route?: RouteId;
readonly feeRate?: number;
readonly reason: string;
}
export interface Quote {
readonly grossCents: number;
readonly feeCents: number;
readonly netCents: number;
}
function routeOf(platform: Platform): RouteId {
switch (platform) {
case "ios":
return "apple-iap";
case "android-overseas":
return "google-play";
case "android-cn":
return "channel-pay";
case "tap-tap":
return "tap-tap-pay";
}
throw new Error(`unknown platform: ${String(platform)}`);
}
export function routeFor(platform: Platform, kind: ProductKind): RouteDecision {
const route = routeOf(platform);
const capability = ROUTE_CAPABILITIES[route];
if (kind === "subscription" && !capability.supportsSubscription) {
return {
ok: false,
reason: `${capability.label} 不承接订阅,改用一次性商品或换平台`,
};
}
return {
ok: true,
route,
feeRate: capability.feeRate,
reason: `${platform}/${kind} -> ${capability.label}`,
};
}
export function quote(grossCents: number, route: RouteId): Quote {
if (!Number.isInteger(grossCents) || grossCents < 0) {
throw new Error(`grossCents must be a non-negative integer, got ${grossCents}`);
}
const feeCents = Math.round(grossCents * ROUTE_CAPABILITIES[route].feeRate);
return { grossCents, feeCents, netCents: grossCents - feeCents };
}
export function formatDecision(platform: Platform, kind: ProductKind, grossCents: number): string {
const decision = routeFor(platform, kind);
if (!decision.ok || decision.route === undefined) {
return `${platform}/${kind}: rejected (${decision.reason})`;
}
const q = quote(grossCents, decision.route);
const percent = (ROUTE_CAPABILITIES[decision.route].feeRate * 100).toFixed(0);
return `${platform}/${kind}: ${decision.route} fee=${percent}% gross=${q.grossCents} net=${q.netCents}`;
}
代码里的三个决定
第一,平台映射用穷举 switch。 routeOf 覆盖四个平台,没有 default 分支。新增平台时,调用方会立刻看到未处理的分支,而不是被一个兜底值悄悄吞掉。支付路由最怕「默认走某条通道」,因为默认值一旦错,错误会以真实扣款的形式出现。
第二,能力校验放在路由之后。 先确定钱由谁收,再问这条通道能不能承接该商品类型。顺序反过来,会得到「因为不支持订阅,所以换一个平台」的荒谬结果。拒绝结果里保留明确原因,客户端可以提示「当前渠道暂不支持订阅」,而不是显示一个无法点击的按钮。
第三,金额用整数分,费率用小数。 quote 只接受非负整数分,拒绝浮点金额;分成用 Math.round 取整到分。999 分按 40% 算是 399.6 分,四舍五入为 400 分,净额 599 分。这里选择「四舍五入」而不是「向上取整」,是因为报价只是预估,真实分成以渠道账单为准;把预估做得过分保守,会让定价模型系统性低估收入。
formatDecision 输出一行稳定文本,包含平台、商品类型、路由、费率、总额和净额。它主要用于日志和客服排查:当玩家说「我付了 18 块」,客服可以拿订单里的平台直接复现这条路由,而不必猜测客户端当时走了哪个 SDK。
取舍:抽象抹不平的三件事
统一接口能抹平函数签名,抹不平业务语义。至少有三种差异必须在抽象层之外保留:
- 订阅生命周期。Apple 与 Google 的续订、宽限期、取消和账单重试各有自己的状态机;TapTap 的能力也不同。抽象层最多把状态归一化成「有效 / 宽限 / 过期 / 取消」,不能假装四家完全一致。任何涉及续订的运营策略,都要按通道单独验证。
- 退款流程。商店处理的退款,游戏侧通常只收到通知;渠道处理的退款,可能需要客服在渠道后台操作。退款率消消 1.2%、方舟 0.8%,意味着每月都有真实工单。抽象层若把退款简化为「扣回道具」,会漏掉「道具已消耗」「玩家已卸载」这些无法扣回的情况。
- 恢复购买与促销能力。Apple 与 Google 支持恢复购买,国内安卓渠道与 TapTap 在案例里不支持;首购优惠、推介价格、促销代码也各平台不同。能力表只能告诉调用方「有没有」,不能把没有的能力变出来。
代价是抽象层会保留一些「不整齐」的分支。这是必要的:把差异藏进一个万能接口,短期代码好看,长期会在订阅续费和退款对账上付出更大的代价。判断标准很简单:能被归一化的用统一接口,不能被归一化的用能力查询加显式分支。
能力表本身也有漂移风险。商店政策、SDK 版本、商务合同都会变,今天的 30% 明天可能不同。工程上要把能力表当作受版本控制的配置,变更走 review;同时用契约测试断言每个路由的能力字段完整,避免新增通道时漏填。
路由不验证收据
路由回答「这笔钱应该由谁收、扣多少」,不回答「这笔钱真的收到了」。客户端上报的平台和商品 id 都不可信;服务端可以根据订单上下文重新计算路由,但最终仍要用商店或渠道的收据证明扣款真实发生、金额与商品匹配。收据校验、回调幂等和退款处理是 4.3 的内容。
把两件事混在一起,常见结果是:路由选对了,但收到一张伪造回执就发货;或者回执校验很严,却把 iOS 的订阅订单路由到了渠道支付。职责分开,才能分别测试。
小结
- 支付通道的差异不止 SDK,还包括商品创建、收据形态、回调、退款、订阅和恢复购买。
- 能力表要明确写出「不支持什么」,客户端、服务端、财务消费同一份数据。
- 路由是平台加商品类型的纯函数;能力不满足时拒绝,不静默降级到另一条通道。
- 费率不改变标价,只改变净收入;消消与方舟按事实表加权的综合费率分别约 34.3% 与 34.0%。
- 统一接口只能抹平签名,订阅生命周期、退款流程和恢复购买必须保留显式分支与能力查询。
阅读导航:上一节:4.1 商品目录、定价档位与区域价格 · 下一节:4.3 支付回调与凭证校验 。
常见错误与边界
- 在业务代码里到处写
if (platform === "ios")。通道一多,判断散落在十几处,新增平台必然漏改;应集中到一张能力表和一次路由。 - 能力不满足时静默换通道。iOS 订阅不可能改走 Google Play,降级只会把「不支持」伪装成「成功」,在续订时爆炸。
- 把平台占比当成路由输入。占比是经营数字,不是单笔订单的属性;用它做路由会让同一玩家在不同时间走不同通道。
- 用浮点数计算金额和分成。0.1 元类的误差累积后对账出现分差;金额用整数分,只在展示时转换。
- 只记录路由结果,不记录费率与净额。月底对账时无法解释「同样 ¥18 为什么净收入不同」,也无法定位能力表漂移。
- 让客户端决定最终路由。客户端可以上报环境,但服务端必须按订单上下文重新计算,否则篡改平台字段就能绕过通道限制。
- 把退款当成异常而不是事件。消消退款率 1.2%,每月都有真实退款;不进入账本就会重复发货或错误扣回。
- 默认所有平台都支持恢复购买。国内安卓渠道与 TapTap 在案例里不支持,统一显示恢复按钮只会产生空结果和客诉。
- 认为路由正确就等于收款正确。路由只决定走哪条通道,收据真伪、金额匹配和幂等发货仍要由 4.3 的校验链路证明。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。