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

7.5 KiB
Raw Permalink Blame 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_tokengoogle_play_ordergoogle_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 > 0unit 包含 vip/member/会员 -> VIP_ONE_TIME
  • 一次性商品且 unitcoin/quota/credit/金币/次数,或商品名/durationValue 可解析数值 -> WALLET_TOP_UP
  • 其他一次性商品 -> NON_CONSUMABLE

目录结构

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_eventRTDN 审计与重试记录。

接口定义

1. 客户端购买校验

  • POST /api/google-play/purchases/verify
  • 需要登录态与签名

请求体:

{
  "packageName": "com.example.app",
  "productId": "vip_monthly",
  "productType": "subscription",
  "purchaseToken": "xxxx"
}

响应体:

{
  "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

{
  "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

成功响应:

{
  "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=CANCELEDexpiryTime 仍未来
    • 本地仍保留 VIPautoRenewEnabled=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
  • 校验 emailemail_verified
  • 校验 iss 必须是 Google Accounts
  • 客户端校验接口可要求 obfuscatedExternalAccountId == userId

测试样例

建议执行:

mvn -q test -Dtest=GooglePlay*

覆盖点:

  • 订阅首次购买与续费
  • 订阅取消但未过期
  • 订阅宽限期
  • 一次性商品发货幂等
  • RTDN 重放幂等