跳转到内容

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。

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-
basePlanIdproductType='subs' 时是在用户可用 SubscriptionOfferDetails 中匹配 base plansubscriptionsv2 的 base plan
offerId否传值时精确匹配优惠;省略时匹配 offerId == null 的 base-plan offersubscriptionsv2 的 offer
accountId否setObfuscatedAccountId(用户标识)验签 API obfuscatedExternalAccountId
profileId否 · 传则须 UUIDsetObfuscatedProfileId(订单标识 · 放 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;Android obfuscatedProfileId 虽接受更宽字符集(^[A-Za-z0-9_-]{1,64}$),但为跨端一致也要求 UUID(避免「Android 能跑、iOS 炸」的跨端地雷)。
  • 🔴 内置 google_play provider 会校验:传入的 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? },由 Native PayBridge 显式构造(不把 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() 返回为准。

业务后端需实现以下端点(本 Feature 仅定义契约 · 不实现该后端):

端点谁调入参出参
创单端点provider.createOrder(业务 JS fetch)payParams{ orderToken(🔴 必须 UUID · 双端一致), ...你约定的 json 字段 }
回执端点(receiptUrl)PayBridge(host init 配置)ReceiptPayloadHTTP 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

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。