Files
keyboard_backend/docs/google-play-iap-integration.md

258 lines
7.5 KiB
Markdown
Raw Permalink Normal View History

# 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 <OIDC JWT>`
- `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 重放幂等