Files
keyboard_backend/docs/google-play-iap-integration.md
ziin 99cf132d76 feat(iap): 新增 Google Play 内购与 AI 评论举报支持
完成 Google Play 内购集成所需的全链路实现,包括:
- 数据库表结构(google-play-iap.sql)
- 实体、Mapper、Service 及 XML 配置
- AI 评论举报实体与业务层
- 集成文档(google-play-iap-integration.md)
2026-03-20 15:32:31 +08:00

258 lines
7.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 重放幂等