# Google Play IAP 服务端集成 ## 系统设计 ### 目标 - 客户端购买成功后,服务端使用 `purchaseToken` 二次校验 Google Play Developer API。 - RTDN 进入后,先做 Pub/Sub 来源校验,再二次查询 Google Play Developer API,同步本地订单、token、权益状态。 - 对订阅与一次性商品统一落库,保证幂等、防重放和可审计。 ### 核心流程 1. 客户端发起购买,BillingClient 中建议设置 `obfuscatedAccountId = 当前 userId`。 2. 客户端支付成功后调用 `/api/google-play/purchases/verify`。 3. 服务端调用 Google Play Developer API: - 订阅:`purchases.subscriptionsv2.get` - 一次性商品:`purchases.productsv2.getproductpurchasev2` 4. 服务端更新 `google_play_purchase_token`、`google_play_order`、`google_play_user_entitlement`。 5. 对需要确认的订单执行 acknowledge / consume。 6. Google Play RTDN 推送到 `/api/google-play/rtdn`。 7. webhook 校验 topic / subscription / OIDC JWT 后,再次查询 Developer API,再更新本地状态。 ### 幂等策略 - RTDN 事件以 `message_id` 幂等,重复消息直接忽略。 - 订单以 `order_key` 幂等: - 有 `google_order_id` 时用订单号。 - 没有订单号时回退为 `TOKEN:{purchaseToken}`。 - 购买 token 表以 `purchase_token` 唯一。 - 一次性商品发货前先看 `delivery_status`,已发货不重复发。 - 订阅不做“累加式延长”,而是直接把用户 VIP 到期时间同步为 Google 返回的最新 `expiryTime`,天然幂等。 ### 本地权益映射 - `subscription` -> `VIP_SUBSCRIPTION` - 一次性商品且 `duration_days > 0` 或 `unit` 包含 `vip/member/会员` -> `VIP_ONE_TIME` - 一次性商品且 `unit` 像 `coin/quota/credit/金币/次数`,或商品名/`durationValue` 可解析数值 -> `WALLET_TOP_UP` - 其他一次性商品 -> `NON_CONSUMABLE` ## 目录结构 ```text src/main/java/com/yolo/keyborad/ ├── config/ │ ├── GooglePlayHttpConfig.java │ └── GooglePlayProperties.java ├── controller/ │ └── GooglePlayController.java ├── googleplay/ │ ├── GooglePlayApiClient.java │ ├── GooglePlayApiException.java │ ├── GooglePlayConstants.java │ ├── GooglePlayEntitlementApplier.java │ ├── GooglePlayPubSubAuthService.java │ ├── GooglePlayServiceAccountTokenProvider.java │ ├── GooglePlayStateService.java │ └── model/ │ ├── GooglePlayPurchaseSnapshot.java │ ├── GooglePlaySyncCommand.java │ └── GooglePlaySyncResult.java ├── mapper/ │ ├── GooglePlayOrderMapper.java │ ├── GooglePlayPurchaseTokenMapper.java │ ├── GooglePlayRtdnEventMapper.java │ └── GooglePlayUserEntitlementMapper.java ├── model/ │ ├── dto/googleplay/ │ │ ├── GooglePlayPubSubPushRequest.java │ │ └── GooglePlayPurchaseVerifyReq.java │ ├── entity/googleplay/ │ │ ├── GooglePlayOrder.java │ │ ├── GooglePlayPurchaseToken.java │ │ ├── GooglePlayRtdnEvent.java │ │ └── GooglePlayUserEntitlement.java │ └── vo/googleplay/ │ └── GooglePlayPurchaseVerifyResp.java └── service/ ├── GooglePlayBillingService.java └── impl/ └── GooglePlayBillingServiceImpl.java ``` ## 建表 SQL - 文件:`src/main/resources/sql/google-play-iap.sql` 四张表职责: - `google_play_order`:每一笔 Google 订单/续费周期的最终状态与发货状态。 - `google_play_purchase_token`:一个 purchase token 的最新状态快照。 - `google_play_user_entitlement`:用户实际拥有的本地权益。 - `google_play_rtdn_event`:RTDN 审计与重试记录。 ## 接口定义 ### 1. 客户端购买校验 - `POST /api/google-play/purchases/verify` - 需要登录态与签名 请求体: ```json { "packageName": "com.example.app", "productId": "vip_monthly", "productType": "subscription", "purchaseToken": "xxxx" } ``` 响应体: ```json { "code": 0, "message": "ok", "data": { "userId": 1001, "productId": "vip_monthly", "productType": "SUBSCRIPTION", "purchaseToken": "xxxx", "orderId": "GPA.1234-5678-9012-34567", "orderState": "ACTIVE", "entitlementState": "ACTIVE", "deliveryStatus": "NOT_REQUIRED", "accessGranted": true, "acknowledged": true, "consumed": false, "expiryTime": "2026-04-18T10:00:00.000+00:00", "lastSyncedAt": "2026-03-18T10:01:00.000+00:00" } } ``` ### 2. RTDN Webhook - `POST /api/google-play/rtdn` - 不需要登录态,不走 app 签名,单独做 Pub/Sub 校验 Pub/Sub Push body: ```json { "message": { "messageId": "136969346945", "data": "base64-encoded-json" }, "subscription": "projects/your-project/subscriptions/google-play-rtdn-push" } ``` 关键请求头: - `Authorization: Bearer ` - `X-Goog-Topic: projects/your-project/topics/google-play-rtdn` 成功响应: ```json { "code": 0, "message": "ok", "data": true } ``` ## RTDN 状态处理 ### 订阅 - `SUBSCRIPTION_PURCHASED` - Developer API 状态通常为 `ACTIVE` - 新建/更新 token、order、VIP 权益 - 如未 acknowledge,则服务端补 acknowledge - `SUBSCRIPTION_RENEWED` - 再查 `subscriptionsv2.get` - 取最新 `expiryTime` 覆盖本地 VIP 到期时间 - 不做“在旧时间上加时长”,避免 RTDN 重放导致多发 - `SUBSCRIPTION_CANCELED` - 若 `subscriptionState=CANCELED` 但 `expiryTime` 仍未来 - 本地仍保留 VIP,`autoRenewEnabled=false` - 到期后等待 `EXPIRED` - `SUBSCRIPTION_IN_GRACE_PERIOD` - 本地继续保留 VIP - entitlement state 记为 `IN_GRACE_PERIOD` - `SUBSCRIPTION_ON_HOLD` - 立即取消本地 VIP 可用态 - 保留 token/order 记录,等待恢复或过期 - `SUBSCRIPTION_RESTARTED` / `SUBSCRIPTION_RECOVERED` - 再查最新状态 - 若恢复为 `ACTIVE`,重新开启 VIP - `SUBSCRIPTION_PAUSED` - 本地取消 VIP 可用态 - 记录 `autoResumeTime` - `SUBSCRIPTION_EXPIRED` - 本地关闭 VIP - entitlement state -> `EXPIRED` - `SUBSCRIPTION_REVOKED` - 本地关闭 VIP - entitlement state -> `REVOKED` - `SUBSCRIPTION_PENDING_PURCHASE_CANCELED` - 用当前 token 查到 `linkedPurchaseToken` 后,再同步旧 token - 旧订阅权益继续保持 ### 一次性商品 - `ONE_TIME_PRODUCT_PURCHASED` - 若映射为钱包充值:仅当 `delivery_status != DELIVERED` 才入账 - 若映射为一次性 VIP 或非消耗型权益:同样只发一次 - 钱包型商品在发货后执行 consume - 非消耗型商品执行 acknowledge - `ONE_TIME_PRODUCT_CANCELED` - 一般是 pending 订单被取消 - 本地标记为 `CANCELED`,不发货 - `VoidedPurchaseNotification` - 先二次查询 Developer API;若 404,则结合本地 token/order 记录做最终回滚 - 订阅按 `REVOKED` - 一次性商品按 `REFUNDED` - 钱包余额不足以回滚时,订单标记 `MANUAL_REVIEW`,日志显式报错 ## 安全校验 - RTDN 入口校验 `X-Goog-Topic` - 校验 `subscription` 字段 - 调用 `tokeninfo` 校验 Pub/Sub OIDC JWT - 校验 `aud` - 校验 `email` 与 `email_verified` - 校验 `iss` 必须是 Google Accounts - 客户端校验接口可要求 `obfuscatedExternalAccountId == userId` ## 测试样例 建议执行: ```bash mvn -q test -Dtest=GooglePlay* ``` 覆盖点: - 订阅首次购买与续费 - 订阅取消但未过期 - 订阅宽限期 - 一次性商品发货幂等 - RTDN 重放幂等