IAP 推荐架构
🔴 业务方设计 In-App Purchase 时必读 · 本章节给出 SuperSDK + 业务后端的推荐架构(F038 v0.3 哑管道)· 让 IAP 从『客户端主导(脆弱)』演进到『服务端主导(健壮)』。
v0.3 核心(哑管道):provider = 两个方法
{ createOrder(payParams)→json, pay(json)→三态 }。createOrder 产出的json对 SDK 传输层完全不透明 —— SDK 只按provider名路由、把 json 原样透传给 pay,不解析任何字段。IAP 的pay由 SDK 内置google_play(宿主 Apple provider 自实现)原生扣款 · 自读 json;三方业务两方法都自己写。凭证由 Native PayBridge 守门:投回执 + 白名单剥离三态给 H5(凭证不出 Native)。
1. 架构对比:client-driven vs server-driven
Section titled “1. 架构对比:client-driven vs server-driven”❌ 客户端主导(脆弱 · 不推荐生产)
H5 → SDK → 支付平台 → SDK → H5 拿到 purchase 凭证 ↓ H5 POST /verify 给业务后端 ← 唯一通知路径 ↓ 业务后端 校验 + 发货 + ack致命弱点:用户付款后若客户端崩溃 / 网络故障 / 进程被杀 / 故意不上报 → 业务后端永远不知道用户买了什么 → 用户被扣钱但没发货;且凭证经 H5 业务 JS · 可被恶意 H5 篡改 / 重定向。
✅ 服务端主导(健壮 · v0.3 哑管道)
H5 → SDK.pay.purchase({provider, payParams}) └─ json = provider.createOrder(payParams) [业务实现 · 可调后端建单 · orderToken 业务当场持有] └─ provider.pay(json) [IAP=SDK 内置原生自读 json · 三方=业务 JS] ↓ provider.pay → PayBridge(内部 NativePayResult 含 credential) ↓ ├──→ PayBridge 白名单构造三态 → H5(finished · 不含凭证) ├──→ PayBridge 投凭证 POST receiptUrl(H5 不经手 · 幂等+重试) │ └──→ 后端独立验签(不信任 SDK claim)+ 幂等发货 └──→ 平台推送 RTDN / ASSN(authoritative 兜底) ↓ H5 用 orderToken 轮询业务查单接口(success/processing/failed/unknown)服务端 = 真相源 · 凭证 PayBridge 守门直投 + 平台主动推 · 客户端故障不影响发货 · 凭证不出 Native。
2. 哑管道时序(IAP · Android)
Section titled “2. 哑管道时序(IAP · Android)”sequenceDiagram
participant H5
participant JS as supersdk.js(哑管道)
participant Bridge as PayBridge(原生·守门)
participant Prov as GooglePayProvider(内置 pay)
participant BE as 业务后端
participant Google as Google Play
H5->>JS: pay.purchase({provider:'google_play', payParams})
JS->>JS: json = provider.createOrder(payParams) [override 或 identity · 业务可建单]
Note over JS: SDK 不解析 json · 原样透传
JS->>Bridge: appbridge.nativePay('google_play', JSON.stringify(json), cb)
Bridge->>Prov: payService.pay('google_play', json, cb)
Prov->>Prov: 自读 json.productId(必填)/accountId/profileId
Prov->>Google: launchBillingFlow<br/>setObfuscatedAccountId(accountId)<br/>setObfuscatedProfileId(profileId)
Google-->>Prov: PurchaseResult
Prov-->>Bridge: NativePayResult { status, orderToken?, credential{purchaseToken,...} }
alt finished + credential
Bridge->>BE: POST receiptUrl(credential · 幂等键 orderToken||purchaseToken) [守门]
Bridge-->>H5: 白名单 { status:'finished', orderToken? }(无凭证)
else cancelled / error
Bridge-->>H5: 白名单三态 { status, phase?, code? }
end
Google->>BE: RTDN(authoritative 兜底)
H5->>BE: 用 orderToken 轮询查单
BE-->>H5: { status: success/processing/failed/unknown }iOS 同构:iOS 无内置 ApplePayProvider · 宿主 provider 自读 json 实现 pay,把 credential 一并返回给 PayBridge · 由 PayBridge 投回执(不是宿主自投)· 双端 receipt 路径一致、均可测。
3. provider 两方法契约(v0.3 哑管道核心)
Section titled “3. provider 两方法契约(v0.3 哑管道核心)”pay.purchase内部:json = provider.createOrder(payParams)→provider.pay(json)→ 三态(公共面仍仅pay.purchase一个入口)。- createOrder:provider 实现 · 产出对 SDK 不透明的 json(业务可在此调后端建单 · orderToken 是业务自己产的 · 当场就持有 · 无需 SDK 回调还给业务)。缺省=identity(json=payParams)。
- pay:消费 json · 返回三态(内部含 credential 给 PayBridge)。IAP=SDK 内置原生;三方=业务 JS 自写(自行 checkout+轮询)。
- 🔴 SDK 不读 json 任何字段 · 不碰 productId/accountId/profileId/orderToken · 仅
JSON.stringify透传(JSB 通用管道nativePay(provider, jsonString, callbackId))。
JSB 通用管道:appbridge.nativePay(provider, jsonString, callbackId)(取代旧位置参数 purchaseV2/purchaseV3)。一个方法承载所有内置 IAP provider · 参数为不透明 JSON 字符串。
4. google provider 输入键(google_play provider 的文档化输入 · 非 SDK 传输层)
Section titled “4. google provider 输入键(google_play provider 的文档化输入 · 非 SDK 传输层)”内置 google_play provider 的 pay(SDK 原生实现)自读 json 的以下键 —— 这是 google provider 的输入契约(谁 override 其 createOrder 就照此产),不是 SDK 传输层契约:
| json 键 | 必填 | 内置 pay 映射 | 后端取回 |
|---|---|---|---|
productId | 是 | launchBillingFlow 商品(缺失 → phase:'pay' error) | - |
productType | 否 | 'inapp'(默认)→ ProductType.INAPP;'subs'→ ProductType.SUBS | - |
basePlanId | productType='subs' 时是 | 在用户可用 SubscriptionOfferDetails 中匹配 base plan | subscriptionsv2 的 base plan |
offerId | 否 | 传值时精确匹配优惠;省略时匹配 offerId == null 的 base-plan offer | subscriptionsv2 的 offer |
accountId | 否 | setObfuscatedAccountId(用户标识) | 验签 API obfuscatedExternalAccountId |
profileId | 否 · 传则须 UUID | setObfuscatedProfileId(订单标识 · 放 orderToken) | 验签 API obfuscatedExternalProfileId |
- 字段读取与槽映射是
provider.pay实现内部职责(在 Native provider 内 · 非 SDK 传输层)· 键为空则不设对应槽。 - 订阅购买前强制重查:不缓存/复用
ProductDetails或offerToken;每次购买重新查询,再用productId + basePlanId + nullable offerId精确匹配。无匹配时返回OFFER_UNAVAILABLE且不拉起 Billing UI;匹配后才把最新 token 写入BillingFlowParams.ProductDetailsParams。 - 🔴 orderToken(profileId)必须是 UUID 格式 —— 跨端硬约束:iOS
appAccountToken由 StoreKit 强制 UUID;AndroidobfuscatedProfileId虽接受更宽字符集(^[A-Za-z0-9_-]{1,64}$),但为跨端一致也要求 UUID(避免「Android 能跑、iOS 炸」的跨端地雷)。 - 🔴 内置
google_playprovider 会校验:传入的profileId(orderToken)非 UUID → 该 provider 直接返回{ status:'error', phase:'pay', code:'INVALID_ORDER_TOKEN' }(provider 校验自己的输入契约 · 不静默放行,以免 Android 通过而 iOS 在 StoreKit 处崩)。 - iOS 宿主 Apple provider 自定义其 pay 输入键;典型把订单标识键映射为
Product.PurchaseOption.appAccountToken = UUID(orderToken)(StoreKit 单槽 · 强制 UUID · 非 UUID 会被 StoreKit 拒)· host provider 应自校验并按INVALID_ORDER_TOKEN(phase:pay)返回 · 用户身份由后端经订单反查。 - 🔴 业务 createOrder 产 orderToken 时务必用 UUID:推荐
crypto.randomUUID()(后端创单端点签发亦同 · 见下方示例)。
5. pay 三态语义(白名单 · 回调给 H5)
Section titled “5. pay 三态语义(白名单 · 回调给 H5)”| status | 含义 | 是否含凭证 | H5 处理 |
|---|---|---|---|
finished | 平台已受理 · 流程正常结束 ≠ 支付成功 | ❌ 不含 | 用 orderToken 去业务查单定真相;Google PENDING / ITEM_ALREADY_OWNED 也归此态 |
cancelled | 用户取消 | ❌ | 静默 |
error | 失败 · 带 phase(create/pay)+ code | ❌ | create=createOrder 失败未拉起·可重发;pay=平台计费错误(IAP=SDK 映射 / 三方=业务自定) |
🔴 闭合白名单:发给 H5 的对象只含
{ status, phase?, code?, message?, orderToken? },由 NativePayBridge显式构造(不把 provider 内部结果原样透传)· 凭证(purchaseToken/signature/originalJson)绝不到达 H5。orderToken是回带给 H5 的唯一业务键(供查单)。
6. 凭证回执直投契约(PayBridge 守门)
Section titled “6. 凭证回执直投契约(PayBridge 守门)”🔴 凭证全程不经 H5 业务 JS · 也不出 Native 业务回调:
provider.pay把含 credential 的 NativePayResult 返回给PayBridge;PayBridge 在 finished 且有 credential 时把凭证 POST 到宿主 App 初始化时配置的回执端点(receiptUrl)· 再白名单构造三态发 H5。H5 不可运行时设置 receiptUrl(防恶意 H5 重定向凭证)。
回执端点配置(host-only · H5 不可设):
- Android:
SDKPayService.Builder.setReceiptEndpoint(absoluteUrl)(host 构建 PayService 时配置) - iOS:
SDKPayService初始化receiptEndpoint参数
ReceiptPayload schema(PayBridge 构造):
{ "purchaseToken": "...", "originalJson": "...", "signature": "...", "productId": "...", "provider": "google_play", "platform": "android", "orderToken": "..." }投递契约:
- 幂等键 =
orderToken存在则用之 · 否则退化purchaseToken(全局唯一)· 端点须幂等 - timeout 默认 10s · retry 指数退避(默认 ≤3 次)
- 带身份头
X-Aid/X-Device-Id/X-SDK-Version - HTTP 2xx 判受理 · 投递失败不阻断三态回调(
SLogger.e记录 · 依赖 RTDN/查单兜底) - 后端须独立向 Google/Apple 重新验签(不信任 SDK claim)
- RTDN / ASSN 作权威二次确认与兜底:PayBridge 回执投递失败/延迟时自愈;后端验签 API 响应取回
obfuscatedExternalAccountId/ProfileId(Android)/appAccountToken(iOS)
7. 🔴 宿主 provider checklist(iOS · 必读)
Section titled “7. 🔴 宿主 provider checklist(iOS · 必读)”SDK 不内置 ApplePayProvider(沿用 F035/F036 边界)· iOS 支付由宿主实现。v0.3 下宿主 Apple PayProvider 必须:
- 实现
providerName(自定义名 · 如"app_store")· H5 须用getProviders()返回的此名调purchase/registerProvider(SDK 无内置 Apple provider 名) - 实现
pay(json):自读 json 自定义键(SDK 传输不碰字段)· 拉起 StoreKit - 🔴 把订单标识键映射为
Product.PurchaseOption.appAccountToken = UUID(orderToken)(StoreKit 单槽 · 强制 UUID) - 🔴 自校验 orderToken 是 UUID —— 非 UUID 会被 StoreKit 拒(
UUID(uuidString:)返 nil / 抛错)· host provider 须先校验并按{ phase:'pay', code:'INVALID_ORDER_TOKEN' }返回(与内置google_play同 code · 跨端一致 · 不放行到 StoreKit 崩) - 用户标识 iOS 无独立槽 · 不映射(用户身份经 orderToken 后端反查)
- 🔴 把
credential(purchaseToken/JWS 等)返回给 PayBridge(不自投回执 · 由 PayBridge 守门投递)· success 但 credential 为 nil → 按 error 返回 - 异常用等价日志打印(对齐 Android
SLogger.e)· 不吞异常
参考:Android 内置 Google provider 名 =
"google_play"(GooglePayProvider.getProviderName());iOS 宿主 Apple provider 名自定义 · 两端 H5 均以getProviders()返回为准。
8. 业务后端要做的事(checklist)
Section titled “8. 业务后端要做的事(checklist)”业务后端需实现以下端点(本 Feature 仅定义契约 · 不实现该后端):
| 端点 | 谁调 | 入参 | 出参 |
|---|---|---|---|
| 创单端点 | provider.createOrder(业务 JS fetch) | payParams | { orderToken(🔴 必须 UUID · 双端一致), ...你约定的 json 字段 } |
| 回执端点(receiptUrl) | PayBridge(host init 配置) | ReceiptPayload | HTTP 2xx = 受理 |
| 查单端点 | H5(orderToken 轮询) | orderToken | { status: success/processing/failed/unknown } |
Android Google Play:
- 🔴 创单端点签发 UUID orderToken(非 UUID → 内置 google_play provider 返回 INVALID_ORDER_TOKEN · 且 iOS 同 orderToken 会被 StoreKit 拒)
- 接 PayBridge 回执端点 · 用 Service Account 调
androidpublisher.purchases.{products|subscriptions}.get独立验签 - 从响应读
obfuscatedExternalAccountId(对应 accountId)+obfuscatedExternalProfileId(对应 orderToken)+orderId - Play Console 配置 RTDN topic(Cloud Pub/Sub)兜底 · idempotent 发货 +
acknowledge(3 天 ack 窗口)
iOS Apple:
- 创单端点签发 UUID orderToken
- 接 PayBridge 回执端点 · 用 App Store Server API 调
/inApps/v1/transactions/{transactionId}独立验签 - 从响应读
appAccountToken(对应 orderToken)+transactionId· 经 orderToken→订单反查用户 - 配置 ASSN V2 endpoint 兜底 · idempotent 发货 · 通知客户端
Transaction.finish()
📚 官方文档:Google Play RTDN · Google Play Developer API · App Store Server Notifications V2 · App Store Server API
9. 常见疑问
Section titled “9. 常见疑问”Q: SDK 为什么不解析参数? A: v0.3 哑管道原则。把 productId/accountId/profileId/orderToken 语义焊进 SDK 传输层会让 SDK 越来越复杂、难以适配新支付方式。改由 provider 实现方(createOrder 产 ↔ pay 消费)自洽 · SDK 只路由 + 透传 JSON。
Q: orderToken 怎么回到 H5 查单?
A: 它是你 createOrder 自己产的 · 当场就持有(无需 SDK 回调还给你)。provider.pay 也会把它放进 NativePayResult,PayBridge 白名单透传到三态的 orderToken 字段。
Q: 为什么 iOS 不传 accountId?
A: StoreKit 仅一个槽 appAccountToken,承载 orderToken(订单维度)· 用户身份经后端 orderToken→订单反查。
Q: orderToken 必须 UUID 吗?(跨端地雷)
A: 🔴 是。iOS appAccountToken 被 StoreKit 强制 UUID;Android obfuscatedProfileId 虽接受更宽字符集,但若用非 UUID(如 order_123)会出现「Android 能跑、iOS 在 StoreKit 处崩」的跨端地雷。因此统一要求 UUID:内置 google_play 与 iOS host provider 都会校验,非 UUID 返回 { phase:'pay', code:'INVALID_ORDER_TOKEN' }。业务 createOrder 产 orderToken 用 crypto.randomUUID()(后端创单端点亦签发 UUID)。
Q: finished 了为什么还要查单?
A: finished 只表示客户端流程结束(含 Google PENDING / ITEM_ALREADY_OWNED)· 真相在后端(验签 + RTDN/ASSN)。客户端发货会被伪造/重放。
Q: 凭证回执 POST 失败丢单吗? A: 不会。投递失败不阻断三态;后端 RTDN/ASSN 兜底终会落地;H5 查单从 processing→success。