《移动游戏产品的架构·商业化与发行工程》4.2 支付通道抽象:苹果 IAP、Google Play 与渠道支付

同一件 ¥18 商品,在 App Store、Google Play、国内安卓渠道与 TapTap 上走的是四条不同的支付路径。本节把通道差异收进一张能力表,用纯函数完成路由与净收入计算,说明 30%、40%、15% 分成如何影响定价,并讨论统一抽象抹不平的订阅、退款与恢复购买边界。

同一件 ¥18,四条扣款路径

玩家在 iPhone 上买 200 钻石,付 ¥18;另一个玩家在国内安卓渠道包里买同一件商品,也付 ¥18。客户端都显示「支付成功」,但财务看到的净收入不是同一个数:前者按 App Store 的 30% 分成,后者按国内安卓渠道平均 40% 分成。如果代码把「支付成功」当成一个布尔值,这个差异就会在月底对账时才暴露。

支付通道抽象要解决的不是「让四个 SDK 长得一样」,而是三件事:

  1. 给定平台与商品类型,稳定地选出唯一一条通道。
  2. 把这条通道的能力与费率变成可查询的数据,而不是散落在 if 里的隐含假设。
  3. 在扣款之前就能算出净收入,让定价和投放模型有统一口径。

本节负责路由与费率,回调真伪和幂等发货留到 4.3。

通道差异不止是 SDK

四个通道的差异至少有七个维度,任何一项处理错,都会变成资损或客诉:

维度Apple IAPGoogle Play国内安卓渠道TapTap
商品创建App Store ConnectPlay 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 IAP30%¥5.40¥12.60
Google Play30%¥5.40¥12.60
国内安卓渠道40%¥7.20¥10.80
TapTap15%¥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。

取舍:抽象抹不平的三件事

统一接口能抹平函数签名,抹不平业务语义。至少有三种差异必须在抽象层之外保留:

  1. 订阅生命周期。Apple 与 Google 的续订、宽限期、取消和账单重试各有自己的状态机;TapTap 的能力也不同。抽象层最多把状态归一化成「有效 / 宽限 / 过期 / 取消」,不能假装四家完全一致。任何涉及续订的运营策略,都要按通道单独验证。
  2. 退款流程。商店处理的退款,游戏侧通常只收到通知;渠道处理的退款,可能需要客服在渠道后台操作。退款率消消 1.2%、方舟 0.8%,意味着每月都有真实工单。抽象层若把退款简化为「扣回道具」,会漏掉「道具已消耗」「玩家已卸载」这些无法扣回的情况。
  3. 恢复购买与促销能力。Apple 与 Google 支持恢复购买,国内安卓渠道与 TapTap 在案例里不支持;首购优惠、推介价格、促销代码也各平台不同。能力表只能告诉调用方「有没有」,不能把没有的能力变出来。

代价是抽象层会保留一些「不整齐」的分支。这是必要的:把差异藏进一个万能接口,短期代码好看,长期会在订阅续费和退款对账上付出更大的代价。判断标准很简单:能被归一化的用统一接口,不能被归一化的用能力查询加显式分支。

能力表本身也有漂移风险。商店政策、SDK 版本、商务合同都会变,今天的 30% 明天可能不同。工程上要把能力表当作受版本控制的配置,变更走 review;同时用契约测试断言每个路由的能力字段完整,避免新增通道时漏填。

路由不验证收据

路由回答「这笔钱应该由谁收、扣多少」,不回答「这笔钱真的收到了」。客户端上报的平台和商品 id 都不可信;服务端可以根据订单上下文重新计算路由,但最终仍要用商店或渠道的收据证明扣款真实发生、金额与商品匹配。收据校验、回调幂等和退款处理是 4.3 的内容。

把两件事混在一起,常见结果是:路由选对了,但收到一张伪造回执就发货;或者回执校验很严,却把 iOS 的订阅订单路由到了渠道支付。职责分开,才能分别测试。

小结

  • 支付通道的差异不止 SDK,还包括商品创建、收据形态、回调、退款、订阅和恢复购买。
  • 能力表要明确写出「不支持什么」,客户端、服务端、财务消费同一份数据。
  • 路由是平台加商品类型的纯函数;能力不满足时拒绝,不静默降级到另一条通道。
  • 费率不改变标价,只改变净收入;消消与方舟按事实表加权的综合费率分别约 34.3% 与 34.0%。
  • 统一接口只能抹平签名,订阅生命周期、退款流程和恢复购买必须保留显式分支与能力查询。

阅读导航:上一节:4.1 商品目录、定价档位与区域价格 · 下一节:4.3 支付回调与凭证校验 。

常见错误与边界

  1. 在业务代码里到处写 if (platform === "ios")。通道一多,判断散落在十几处,新增平台必然漏改;应集中到一张能力表和一次路由。
  2. 能力不满足时静默换通道。iOS 订阅不可能改走 Google Play,降级只会把「不支持」伪装成「成功」,在续订时爆炸。
  3. 把平台占比当成路由输入。占比是经营数字,不是单笔订单的属性;用它做路由会让同一玩家在不同时间走不同通道。
  4. 用浮点数计算金额和分成。0.1 元类的误差累积后对账出现分差;金额用整数分,只在展示时转换。
  5. 只记录路由结果,不记录费率与净额。月底对账时无法解释「同样 ¥18 为什么净收入不同」,也无法定位能力表漂移。
  6. 让客户端决定最终路由。客户端可以上报环境,但服务端必须按订单上下文重新计算,否则篡改平台字段就能绕过通道限制。
  7. 把退款当成异常而不是事件。消消退款率 1.2%,每月都有真实退款;不进入账本就会重复发货或错误扣回。
  8. 默认所有平台都支持恢复购买。国内安卓渠道与 TapTap 在案例里不支持,统一显示恢复按钮只会产生空结果和客诉。
  9. 认为路由正确就等于收款正确。路由只决定走哪条通道,收据真伪、金额匹配和幂等发货仍要由 4.3 的校验链路证明。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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