diff --git a/docs/google-play-iap-integration.md b/docs/google-play-iap-integration.md new file mode 100644 index 0000000..64b81c4 --- /dev/null +++ b/docs/google-play-iap-integration.md @@ -0,0 +1,257 @@ +# 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 重放幂等 diff --git a/src/main/java/com/yolo/keyborad/mapper/KeyboardAiCommentReportMapper.java b/src/main/java/com/yolo/keyborad/mapper/KeyboardAiCommentReportMapper.java new file mode 100644 index 0000000..cb7e12a --- /dev/null +++ b/src/main/java/com/yolo/keyborad/mapper/KeyboardAiCommentReportMapper.java @@ -0,0 +1,12 @@ +package com.yolo.keyborad.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yolo.keyborad.model.entity.KeyboardAiCommentReport; + +/* +* @author: ziin +* @date: 2026/3/20 15:07 +*/ + +public interface KeyboardAiCommentReportMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/model/entity/KeyboardAiCommentReport.java b/src/main/java/com/yolo/keyborad/model/entity/KeyboardAiCommentReport.java new file mode 100644 index 0000000..a567482 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/KeyboardAiCommentReport.java @@ -0,0 +1,99 @@ +package com.yolo.keyborad.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Date; +import lombok.Data; + +/* +* @author: ziin +* @date: 2026/3/20 15:07 +*/ + +/** + * AI角色评论举报记录表 + */ +@Schema(description="AI角色评论举报记录表") +@Data +@TableName(value = "keyboard_ai_comment_report") +public class KeyboardAiCommentReport { + /** + * 举报记录唯一ID + */ + @TableId(value = "id", type = IdType.AUTO) + @Schema(description="举报记录唯一ID") + private Long id; + + /** + * 被举报的评论Id + */ + @TableField(value = "comment_id") + @Schema(description="被举报的评论Id") + private Long commentId; + + /** + * 发起举报的用户ID(逻辑关联用户表) + */ + @TableField(value = "user_id") + @Schema(description="发起举报的用户ID(逻辑关联用户表)") + private Long userId; + + /** + * 举报类型:1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他 + */ + @TableField(value = "report_type") + @Schema(description="举报类型:1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他") + private String reportType; + + /** + * 用户填写的详细举报描述 + */ + @TableField(value = "report_desc") + @Schema(description="用户填写的详细举报描述") + private String reportDesc; + + /** + * 违规现场:举报时的评论的内容,用于审核取证 + */ + @TableField(value = "comment_context") + @Schema(description="违规现场:举报时的评论的内容,用于审核取证") + private String commentContext; + + /** + * 图片证据:用户上传的截图URL + */ + @TableField(value = "evidence_image_url") + @Schema(description="图片证据:用户上传的截图URL") + private String evidenceImageUrl; + + /** + * 处理状态:0=待处理, 1=违规确立(已处罚), 2=无效举报/已驳回, 3=已忽略 + */ + @TableField(value = "\"status\"") + @Schema(description="处理状态:0=待处理, 1=违规确立(已处罚), 2=无效举报/已驳回, 3=已忽略") + private Short status; + + /** + * 管理员处理备注(记录处理理由或处罚措施) + */ + @TableField(value = "admin_remark") + @Schema(description="管理员处理备注(记录处理理由或处罚措施)") + private String adminRemark; + + /** + * 举报提交时间 + */ + @TableField(value = "created_at") + @Schema(description="举报提交时间") + private Date createdAt; + + /** + * 最后更新时间 + */ + @TableField(value = "updated_at") + @Schema(description="最后更新时间") + private Date updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/service/KeyboardAiCommentReportService.java b/src/main/java/com/yolo/keyborad/service/KeyboardAiCommentReportService.java new file mode 100644 index 0000000..e322fb5 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/KeyboardAiCommentReportService.java @@ -0,0 +1,13 @@ +package com.yolo.keyborad.service; + +import com.yolo.keyborad.model.entity.KeyboardAiCommentReport; +import com.baomidou.mybatisplus.extension.service.IService; + /* +* @author: ziin +* @date: 2026/3/20 15:07 +*/ + +public interface KeyboardAiCommentReportService extends IService{ + + +} diff --git a/src/main/java/com/yolo/keyborad/service/impl/KeyboardAiCommentReportServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/KeyboardAiCommentReportServiceImpl.java new file mode 100644 index 0000000..225a8ac --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/KeyboardAiCommentReportServiceImpl.java @@ -0,0 +1,18 @@ +package com.yolo.keyborad.service.impl; + +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import java.util.List; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.yolo.keyborad.model.entity.KeyboardAiCommentReport; +import com.yolo.keyborad.mapper.KeyboardAiCommentReportMapper; +import com.yolo.keyborad.service.KeyboardAiCommentReportService; +/* +* @author: ziin +* @date: 2026/3/20 15:07 +*/ + +@Service +public class KeyboardAiCommentReportServiceImpl extends ServiceImpl implements KeyboardAiCommentReportService{ + +} diff --git a/src/main/resources/mapper/KeyboardAiCommentReportMapper.xml b/src/main/resources/mapper/KeyboardAiCommentReportMapper.xml new file mode 100644 index 0000000..9ebfb3c --- /dev/null +++ b/src/main/resources/mapper/KeyboardAiCommentReportMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + id, comment_id, user_id, report_type, report_desc, comment_context, evidence_image_url, + "status", admin_remark, created_at, updated_at + + \ No newline at end of file diff --git a/src/main/resources/sql/google-play-iap.sql b/src/main/resources/sql/google-play-iap.sql new file mode 100644 index 0000000..352f16a --- /dev/null +++ b/src/main/resources/sql/google-play-iap.sql @@ -0,0 +1,113 @@ +CREATE TABLE IF NOT EXISTS google_play_order ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + package_name VARCHAR(128) NOT NULL, + product_id VARCHAR(255) NOT NULL, + product_type VARCHAR(32) NOT NULL, + purchase_token VARCHAR(512) NOT NULL, + order_key VARCHAR(128) NOT NULL UNIQUE, + google_order_id VARCHAR(128), + linked_purchase_token VARCHAR(512), + order_state VARCHAR(64) NOT NULL, + acknowledgement_state VARCHAR(32) NOT NULL, + consumption_state VARCHAR(32) NOT NULL, + quantity INTEGER, + refundable_quantity INTEGER, + delivery_status VARCHAR(64) NOT NULL, + granted_quantity NUMERIC(18,2) NOT NULL DEFAULT 0, + entitlement_start_time TIMESTAMP, + entitlement_end_time TIMESTAMP, + last_event_time TIMESTAMP, + last_synced_at TIMESTAMP NOT NULL, + raw_response TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_google_play_order_purchase_token + ON google_play_order (purchase_token); + +CREATE INDEX IF NOT EXISTS idx_google_play_order_user_product + ON google_play_order (user_id, product_id); + +CREATE TABLE IF NOT EXISTS google_play_purchase_token ( + id BIGSERIAL PRIMARY KEY, + purchase_token VARCHAR(512) NOT NULL UNIQUE, + linked_purchase_token VARCHAR(512), + user_id BIGINT, + package_name VARCHAR(128) NOT NULL, + product_id VARCHAR(255) NOT NULL, + product_type VARCHAR(32) NOT NULL, + latest_order_key VARCHAR(128) NOT NULL, + latest_order_id VARCHAR(128), + token_state VARCHAR(64) NOT NULL, + acknowledgement_state VARCHAR(32) NOT NULL, + consumption_state VARCHAR(32) NOT NULL, + auto_renew_enabled BOOLEAN, + external_account_id VARCHAR(255), + external_profile_id VARCHAR(255), + region_code VARCHAR(16), + start_time TIMESTAMP, + expiry_time TIMESTAMP, + auto_resume_time TIMESTAMP, + canceled_state_reason VARCHAR(64), + last_event_type VARCHAR(128), + last_event_time TIMESTAMP, + last_synced_at TIMESTAMP NOT NULL, + raw_response TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_google_play_purchase_token_user + ON google_play_purchase_token (user_id); + +CREATE TABLE IF NOT EXISTS google_play_user_entitlement ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + entitlement_key VARCHAR(128) NOT NULL, + product_id VARCHAR(255) NOT NULL, + product_type VARCHAR(32) NOT NULL, + source_purchase_token VARCHAR(512) NOT NULL, + current_order_key VARCHAR(128) NOT NULL, + benefit_type VARCHAR(64) NOT NULL, + state VARCHAR(64) NOT NULL, + active BOOLEAN NOT NULL, + quantity NUMERIC(18,2) NOT NULL DEFAULT 0, + start_time TIMESTAMP, + end_time TIMESTAMP, + last_granted_at TIMESTAMP, + last_revoked_at TIMESTAMP, + metadata TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uk_google_play_user_entitlement UNIQUE (source_purchase_token, entitlement_key) +); + +CREATE INDEX IF NOT EXISTS idx_google_play_user_entitlement_user + ON google_play_user_entitlement (user_id, active); + +CREATE TABLE IF NOT EXISTS google_play_rtdn_event ( + id BIGSERIAL PRIMARY KEY, + message_id VARCHAR(128) NOT NULL UNIQUE, + subscription_name VARCHAR(255), + package_name VARCHAR(128), + event_type VARCHAR(32) NOT NULL, + notification_type INTEGER, + notification_name VARCHAR(128), + purchase_token VARCHAR(512), + product_id VARCHAR(255), + order_id VARCHAR(128), + event_time TIMESTAMP, + status VARCHAR(32) NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + raw_envelope TEXT NOT NULL, + raw_payload TEXT NOT NULL, + error_message TEXT, + processed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_google_play_rtdn_event_purchase_token + ON google_play_rtdn_event (purchase_token);