diff --git a/.env.example b/.env.example index 0ec5098..9cdf0f7 100644 --- a/.env.example +++ b/.env.example @@ -107,3 +107,18 @@ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"] ############ ERYAO_TEST__EMAIL=test@example.com ERYAO_TEST__CODE=123456 + +############ +# Apple IAP 配置 +############ +ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen +# Server API 密钥(可选,用于主动查询交易状态) +ERYAO_APPLE_IAP__SERVER_API_KEY_ID= +ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY= +ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID= +# 沙盒测试账号(仅用于手动测试,不用于后端验证) +ERYAO_APPLE_IAP__SANDBOX_TESTER_EMAIL= +ERYAO_APPLE_IAP__SANDBOX_TESTER_PASSWORD= +# Server Notifications V2 URL(在 App Store Connect 中配置) +# 格式: https:///api/v1/payments/apple/notifications +ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL= diff --git a/.trellis/tasks/04-27-feat-ios-apple-pay/IMPLEMENTATION_PLAN.md b/.trellis/tasks/04-27-feat-ios-apple-pay/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ec6edc1 --- /dev/null +++ b/.trellis/tasks/04-27-feat-ios-apple-pay/IMPLEMENTATION_PLAN.md @@ -0,0 +1,340 @@ +# iOS Apple Pay 落实计划 + +## 当前状态 + +### 已完成 +- [x] 协议文档更新(`user-points-chat-data-protocol.md`、`http-error-codes.md`) +- [x] PRD 退款扣回策略已明确 +- [x] 套餐配置 YAML 已正确命名(`new_user_pack`, `basic_pack` 等) +- [x] **Phase 1: 数据库与枚举**(2026-04-27 完成) +- [x] **Phase 2: 后端支付服务**(2026-04-27 完成) +- [x] **Phase 3: iOS / Flutter IAP 接入**(2026-04-27 完成) + +### 待实现 +- [ ] 联调与发布准备(Phase 4) + +--- + +## Phase 1: 数据库与枚举(后端基础)✅ 已完成 + +### 1.1 枚举扩展 ✅ + +**文件**: `backend/src/schemas/enums.py` + +- [x] `PointsChangeType` 新增 `PURCHASE`, `REFUND`,移除 `GRANT` +- [x] `PointsBizType` 新增 `PAYMENT` + +**文件**: `backend/src/schemas/domain/points.py` +- [x] 更新 `ApplyPointsChangeCommand` 验证逻辑,支持 `purchase/refund` +- [x] 移除 `GrantLedgerMetadata` + +### 1.2 数据库迁移 ✅ + +**迁移文件**: `backend/alembic/versions/20260427_0001_apple_iap_transactions.py` + +- [x] 创建 `apple_iap_transactions` 表(按 PRD 5.3 定义) +- [x] 更新 `points_ledger` check constraints: + - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` + - `biz_type is null or biz_type in ('chat', 'payment')` + - 更新 `ck_points_ledger_biz_binding` 支持 `purchase/refund` + - 更新 `ck_points_ledger_direction_by_change_type` 支持 `purchase(direction=1)` 和 `refund(direction=-1)` + - 新增 `ck_points_ledger_metadata_payment_shape` 和 `ck_points_ledger_metadata_refund_shape` + - 更新 `ck_points_ledger_metadata_adjust_shape`(`ticket_id` -> `reason`) +- [x] 更新 `points_audit_ledger` check constraints 同步变更 + +**模型文件**: +- [x] `backend/src/models/points_ledger.py` - 同步更新 SQLAlchemy CheckConstraint +- [x] `backend/src/models/apple_iap_transaction.py` - 新建模型 +- [x] `backend/src/models/__init__.py` - 导出新模型 + +### 1.3 验证 ✅ +- [x] 迁移已应用到数据库 +- [x] `apple_iap_transactions` 表已创建 +- [x] `points_ledger` 约束已更新 + +--- + +## Phase 2: 后端支付服务 ✅ 已完成 + +### 2.1 配置扩展 ✅ + +- [x] `backend/src/core/config/settings.py` - 新增 `AppleIapSettings` 和 `apple_iap` 配置项 +- [x] `backend/src/core/config/static/packages/mapping.yaml` - productCode -> App Store Product ID 映射 + +### 2.2 API Schemas ✅ + +- [x] `backend/src/v1/payments/schemas.py` - `VerifyTransactionRequest` / `VerifyTransactionResponse` +- [x] `backend/src/schemas/domain/points.py` - 新增 `PurchaseLedgerMetadata` + +### 2.3 Apple JWS 验签器 ✅ + +- [x] `backend/src/v1/payments/apple_verifier.py` + - JWS x5c 证书链验证(root fingerprint + issuer/subject chain) + - bundleId / productId / environment / revocationDate 验证 + - 返回 `VerifiedTransaction | VerificationError` + +### 2.4 支付数据仓库 ✅ + +- [x] `backend/src/v1/payments/repository.py` - `PaymentRepository` + - `get_or_create_user_points_for_update` + - `get_user_points_for_update` (for refund, no auto-create) + - `get_transaction_by_transaction_id` + - `insert_transaction` + - `get_register_bonus_claim` + - `upsert_register_bonus_claim_for_starter_pack` + +### 2.5 支付服务 ✅ + +- [x] `backend/src/v1/payments/service.py` - `PaymentService` + - `verify_and_grant`: productCode / appStoreProductId 校验, Apple JWS 验签, transaction_id 幂等, 新手包资格检查, 积分入账 + points_ledger + register_bonus_claims + - `process_refund`: 退款扣回积分, 余额不足时扣到 0 并标记 `refunded_insufficient`, 幂等处理 + - `handle_server_notification`: 解析 Apple Server Notifications V2, REFUND/REVOKE 触发退款, DID_RENEW 记录日志 + +### 2.6 API 路由 + 依赖注入 ✅ + +- [x] `backend/src/v1/payments/router.py` + - `POST /api/v1/payments/apple/transactions/verify` + - `POST /api/v1/payments/apple/notifications` +- [x] `backend/src/v1/payments/dependencies.py` - DI wiring +- [x] `backend/src/v1/router.py` - 注册 payments_router + +### 2.7 测试 ✅ + +- [x] `backend/tests/unit/payments/test_payment_service.py` - 16 个测试全部通过 + - 验证流程: product_not_found / product_mismatch / verification_failed / already_granted / transaction_conflict / successful_grant / starter_pack_ineligible / starter_pack_success + - 退款流程: refund_unknown / refund_not_granted / refund_sufficient_balance / refund_insufficient_balance / refund_idempotency + - 通知处理: notification_refund / notification_empty / notification_non_refund +- [x] `backend/tests/unit/payments/__init__.py` - verifier 基础测试 +- [x] `backend/tests/integration/payments/test_verify_flow.py` - 集成测试骨架 +- [x] basedpyright 类型检查通过(0 errors) +- [x] 所有模块 import 正常 + +### 未实现(后续迭代) + +- `GET /api/v1/payments/apple/transactions/{transactionId}` 查询接口 +- `apple_client.py` Apple Server API 主动查询客户端(可选) + +### 2.2 核心实现 + +#### 2.2.1 Apple JWS 验签器 + +**文件**: `backend/src/v1/payments/apple_verifier.py` + +职责: +- 下载/缓存 Apple 根证书链 +- 验证 JWS 签名 +- 解析 payload 并验证字段:`bundleId`, `productId`, `transactionId`, `environment`, `revocationDate` +- 返回结构化验证结果 + +#### 2.2.2 支付服务 + +**文件**: `backend/src/v1/payments/service.py` + +核心方法: +- `verify_and_grant(user_id, request) -> VerifyResponse` + - 1. 验证 productCode 存在且启用 + - 2. 验证 appStoreProductId 与映射匹配 + - 3. 调用 Apple verifier 验签 + - 4. 检查 transaction 幂等(已发放返回 `already_granted`) + - 5. 检查新手包资格 + - 6. 事务:创建/更新 `apple_iap_transactions` + 更新 `user_points` + 写入 `points_ledger` + 更新 `register_bonus_claims` + +- `process_refund(transaction_id) -> None` + - 1. 查询 `apple_iap_transactions` + - 2. 事务:扣减积分 + 写入 `refund` 流水 + 更新状态 + - 3. 余额不足时设置 `refunded_insufficient` 并告警 + +#### 2.2.3 API 接口 + +**文件**: `backend/src/v1/payments/router.py` + +``` +POST /api/v1/payments/apple/transactions/verify +POST /api/v1/payments/apple/notifications # App Store Server Notifications V2 +GET /api/v1/payments/apple/transactions/{transactionId} # 可选 +``` + +### 2.3 配置扩展 + +**文件**: `backend/src/core/config/settings.py` + +新增配置项: +```python +apple_iap: AppleIapSettings + +class AppleIapSettings: + bundle_id: str + root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" + jws_issuer_id: str | None = None # Server API (可选) + jws_key_id: str | None = None + jws_private_key: str | None = None +``` + +**文件**: `backend/src/core/config/static/packages/mapping.yaml` (新建) + +```yaml +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack + credits: 60 + type: starter + basic_pack: + app_store_product_id: com.meeyao.qianwen.basic_pack + credits: 100 + type: regular + popular_pack: + app_store_product_id: com.meeyao.qianwen.popular_pack + credits: 210 + type: regular + premium_pack: + app_store_product_id: com.meeyao.qianwen.premium_pack + credits: 415 + type: regular +``` + +### 2.4 测试 + +**单元测试**: +- `backend/tests/unit/payments/test_apple_verifier.py` +- `backend/tests/unit/payments/test_payment_service.py` + +**集成测试**: +- `backend/tests/integration/payments/test_verify_flow.py` + +--- + +## Phase 3: iOS / Flutter 接入 ✅ 已完成 + +### 3.1 依赖添加 ✅ + +- [x] `apps/pubspec.yaml` — `in_app_purchase: ^3.2.3` + `in_app_purchase_storekit: ^0.4.8` + `crypto: ^3.0.7` + +### 3.2 后端配合变更 ✅ + +- [x] `backend/src/v1/points/schemas.py` — `PackageInfo` 新增 `appStoreProductId` 字段 +- [x] `backend/src/v1/points/service.py` — `PackageInfoResult` 新增 `app_store_product_id`,`get_available_packages` 从 mapping.yaml 加载映射 +- [x] `backend/src/v1/points/router.py` — 响应中包含 `appStoreProductId` + +### 3.3 前端目录结构 ✅ + +``` +apps/lib/features/payments/ +├── data/ +│ ├── apis/ +│ │ └── apple_payment_api.dart # 后端 verify 接口 +│ ├── models/ +│ │ └── apple_purchase_models.dart # VerifyTransactionRequest/Response +│ └── services/ +│ └── apple_iap_service.dart # StoreKit 集成服务 +``` + +### 3.4 核心实现 ✅ + +- [x] `apple_purchase_models.dart` — VerifyTransactionRequest(含 appAccountToken)/ VerifyTransactionResponse(含 newBalance/ledgerEventId) +- [x] `apple_payment_api.dart` — POST /api/v1/payments/apple/transactions/verify +- [x] `apple_iap_service.dart` — AppleIapService (ChangeNotifier): + - 初始化 purchaseStream 监听 + - queryProductDetails 查询 StoreKit 商品 + - `buyConsumable` 传递 `applicationUserName`(appAccountToken = userId MD5 hash) + - 购买成功 → 发送 JWS + appAccountToken 到后端验证 → completePurchase + - 可重试错误(5xx/网络)不 complete,下次启动自动重试 + - 不可重试错误(4xx)complete 并暴露 ApiProblem 供 UI 映射 l10n + - 暴露 `lastApiProblem` 供错误码映射 +- [x] `package_info.dart` — 新增 `appStoreProductId` 字段 +- [x] `settings_section_widgets.dart` — CoinPackageCard 新增 `onPurchase`/`isPurchasing`/`isAvailable`/`unavailableMessage` +- [x] `coin_center_screen.dart` — 集成 AppleIapService: + - 接收 `userId`/`onBalanceChanged` 参数 + - StoreKit 价格覆盖后端参考价格 + - 商品不可用时禁用卡片并显示提示 + - pending 状态显示 "Apple 正在处理中" + - 购买成功后调用 `_refreshBalance` 并回调 `onBalanceChanged` + - 使用 `mapApiProblemToMessage` 映射错误码到 l10n + +### 3.5 调用链更新 ✅ + +- [x] `app.dart` — HomeScreen 传递 `userId` + `onBalanceChanged` +- [x] `home_screen.dart` — `_ProfileTab` 传递 `userId` + `onBalanceChanged` +- [x] `settings_screen.dart` — 传递 `userId` + `onBalanceChanged` 给 CoinCenterScreen + +### 3.6 错误处理与本地化 ✅ + +- [x] `api_problem_mapper.dart` — 6 个支付错误码映射 +- [x] 3 个 ARB 文件新增 7 个 l10n key: + - `paymentSuccess` / `paymentVerifyFailed` / `paymentProductNotFound` / `paymentStarterPackIneligible` + - `paymentProductUnavailable` / `paymentPending` +- [x] `flutter gen-l10n` 生成通过 + +### 3.7 前端测试 ✅ + +- [x] `apps/test/features/payments/data/models/apple_purchase_models_test.dart` + - VerifyTransactionRequest.toJson 含/不含 appAccountToken + - VerifyTransactionResponse 解析 granted / already_granted +- [x] 4 个测试全部通过 + +### 3.8 验证 ✅ + +- [x] `flutter analyze` 0 issues +- [x] 后端 basedpyright 0 errors +- [x] 后端 16 个单元测试全部通过 + +--- + +## Phase 4: 联调与发布准备 + +### 4.1 App Store Connect 配置 + +- [x] 创建 4 个消耗型 IAP 商品(Product ID 已确认与映射表一致) +- [x] Product ID 与映射表一致 + - `com.meeyao.qianwen.new_user_pack` — 新手包 + - `com.meeyao.qianwen.basic_pack` — 基础包 + - `com.meeyao.qianwen.popular_pack` — 热门包 + - `com.meeyao.qianwen.premium_pack` — 高级包 +- [ ] 配置价格和描述 +- [x] 创建沙盒测试账号:`qiuzhiliang@xunmee.com` +- [ ] 配置 Server Notifications V2 URL(生产环境公网 URL) + +### 4.2 后端配置 ✅ + +- [x] 环境变量:`APPLE_IAP_BUNDLE_ID=com.meeyao.qianwen` +- [x] Server API 密钥配置(`T6M7J28MAQ` / `862a2cd0-ad6e-47c8-ac5a-bef5676c470b`) +- [x] 日志检查:不打印完整 JWS +- [x] 环境判断:根据 `ERYAO_RUNTIME__ENVIRONMENT` 自动切换 Sandbox/Production + +### 4.3 开发环境准备 ✅ + +- [x] `.env.example` 更新 Apple IAP 配置模板 +- [x] `.env` 写入实际配置值 +- [x] `AppleIapSettings` 支持所有配置字段 +- [x] `EryaoProducts.storekit` Xcode 本地测试配置文件 + +### 4.4 测试清单 + +- [ ] Xcode StoreKit Configuration 本地测试 +- [ ] Sandbox 购买成功验证 +- [ ] Sandbox 退款测试 +- [ ] 网络中断后重启恢复 +- [ ] 新手包重复购买阻断 +- [ ] TestFlight 环境验证 + +--- + +## 预估工时 + +| Phase | 工作项 | 预估时间 | +|-------|--------|----------| +| 1 | 枚举扩展 + 数据库迁移 | 2-3h | +| 2 | 后端支付服务 + 测试 | 1-2 天 | +| 3 | Flutter IAP 接入 | 1 天 | +| 4 | 联调与发布准备 | 0.5-1 天 | + +**总计**: 3-4 天 + +--- + +## 风险与依赖 + +| 风险/依赖 | 缓解措施 | +|----------|---------| +| App Store Connect 配置权限 | 提前确认账号权限,Phase 1 同步申请 | +| `in_app_purchase` 插件无法暴露 signedData | 预研后确定是否需要 platform channel | +| Apple 服务不可用 | 返回 `PAYMENT_APPLE_UNAVAILABLE`,前端保留交易 | diff --git a/.trellis/tasks/04-27-feat-ios-apple-pay/prd.md b/.trellis/tasks/04-27-feat-ios-apple-pay/prd.md index bdb45fb..342647b 100644 --- a/.trellis/tasks/04-27-feat-ios-apple-pay/prd.md +++ b/.trellis/tasks/04-27-feat-ios-apple-pay/prd.md @@ -119,7 +119,7 @@ | `app_account_token` | UUID null | 客户端购买时传入的用户绑定 token,用于降低串单风险 | | `purchase_date` | timestamptz not null | Apple 交易购买时间 | | `revocation_date` | timestamptz null | Apple 撤销/退款时间 | -| `status` | varchar not null | `received`、`verified`、`granted`、`failed`、`refunded`、`revoked` | +| `status` | varchar not null | `received`、`verified`、`granted`、`failed`、`refunded`、`refunded_insufficient`、`revoked` | | `credits` | bigint not null | 本次应发积分,由后端套餐配置决定 | | `currency` | varchar null | Apple 或后端记录的币种 | | `price_milliunits` | bigint null | 如 Apple JWS 提供则保存,单位按 Apple 字段定义 | @@ -142,6 +142,7 @@ 新增积分变更类型: - `PointsChangeType.PURCHASE = 'purchase'` +- `PointsChangeType.REFUND = 'refund'` 新增业务类型: @@ -149,12 +150,14 @@ 约束调整: -- `change_type` 允许 `purchase`。 +- `change_type` 允许 `purchase`、`refund`。 - `biz_type` 允许 `payment`。 - `purchase` 必须 `direction = 1`。 -- `purchase` 必须 `biz_type = 'payment'` 且 `biz_id` 不为空。 -- `purchase` 的 `biz_id` 保存 `apple_iap_transactions.id`。 +- `refund` 必须 `direction = -1`。 +- `purchase/refund` 必须 `biz_type = 'payment'` 且 `biz_id` 不为空。 +- `purchase/refund` 的 `biz_id` 保存 `apple_iap_transactions.id`。 - `purchase` 的 `event_id` 建议为 `payment.apple_iap:{transaction_id}`。 +- `refund` 的 `event_id` 建议为 `refund.apple_iap:{transaction_id}`。 `points_ledger.metadata.ext` 建议保存: @@ -171,6 +174,16 @@ } ``` +对于 `refund`,额外保存: + +```json +{ + "original_event_id": "payment.apple_iap:1000000123456789", + "refund_reason": "CUSTOMER_REQUEST", + "overdue_amount": 0 +} +``` + ### 5.5 `points_audit_ledger` 是否扩展 本期不强制把购买写入 `points_audit_ledger`。支付审计以 `apple_iap_transactions` 为准,用户积分变更以 `points_ledger` 为准。 @@ -304,12 +317,46 @@ ### 7.4 退款与撤销 -本期至少保存 `revocationDate` 和交易状态,不自动扣回已发放积分。 +当收到 Apple 退款/撤销通知时,必须扣回已发放积分,避免积分被白嫖。 -后续建议接入 App Store Server Notifications V2: +**处理流程:** -- 收到退款/撤销通知后按 `transactionId` 更新 `apple_iap_transactions.status`。 -- 是否扣回积分需要产品另行决策。考虑积分可能已消耗,直接扣回可能导致负余额或复杂追偿,本期不默认实现。 +1. 后台任务或 App Store Server Notifications V2 收到退款/撤销事件。 +2. 按 `transactionId` 查询 `apple_iap_transactions`。 +3. 如果 `status != 'granted'`,忽略(未发放无需扣回)。 +4. 开启数据库事务: + - 锁定 `user_points` 行。 + - 如果 `balance < credits`(余额不足以扣回): + - 设置 `apple_iap_transactions.status = 'refunded_insufficient'`。 + - 记录 `failure_code = 'INSUFFICIENT_BALANCE'`。 + - 写入 `points_ledger` 的 `refund` 流水,`amount = balance`(扣到 0 为止)。 + - 事务提交后触发告警,等待人工处理。 + - 如果 `balance >= credits`: + - 扣减 `user_points.balance` 和 `lifetime_earned`。 + - 写入 `points_ledger` 的 `refund` 流水。 + - 更新 `apple_iap_transactions.status = 'refunded'`。 + - 事务提交。 +5. `points_ledger` 的 `refund` 记录: + - `change_type = 'refund'` + - `direction = -1` + - `biz_type = 'payment'` + - `biz_id = apple_iap_transactions.id` + - `event_id = refund.apple_iap:{transaction_id}` + - `metadata.ext` 保存退款相关快照。 + +**App Store Server Notifications V2 接入(建议):** + +- 配置 App Store Connect 的 Server Notifications URL。 +- 后端实现 `POST /api/v1/payments/apple/notifications` 接收 Apple 推送。 +- 解析 notification type:`REFUND`、`REVOKE` 等。 +- 按上述流程处理退款/撤销。 + +**余额不足时的处理策略:** + +- 不允许用户余额变为负数。 +- 扣到 0 为止,剩余欠款记录在 `apple_iap_transactions.metadata.overdue_amount`。 +- 触发运营告警,人工决定是否追偿或标记坏账。 +- 后续用户充值时,可考虑优先补扣欠款(需要产品决策,本期不实现)。 ## 8. iOS / Flutter 接入设计 @@ -415,6 +462,9 @@ apps/lib/features/payments/ - 新手包第一次购买成功,第二次返回 `PAYMENT_STARTER_PACK_INELIGIBLE`。 - 入账后 `user_points.balance/lifetime_earned` 正确增加。 - 入账后 `points_ledger` 写入 `purchase/payment`。 +- 退款时余额充足:正确扣减积分,写入 `refund` 流水,状态变为 `refunded`。 +- 退款时余额不足:扣到 0,状态变为 `refunded_insufficient`,触发告警路径。 +- 重复退款通知:幂等处理,不重复扣减。 ### 12.2 后端集成测试 @@ -448,11 +498,15 @@ apps/lib/features/payments/ ### Phase 2:后端支付服务 -1. 新增 `v1/payments/` 模块。 -2. 实现 Apple transaction verifier 抽象。 -3. 实现支付校验、幂等和积分入账服务。 -4. 接入 `v1/router.py`。 -5. 添加单元测试和集成测试。 +1. 更新协议文档和错误码。 +2. 新增 `apple_iap_transactions` 模型与 Alembic 迁移。 +3. 扩展 `PointsChangeType`、`PointsBizType` 与 `points_ledger` check constraints。 +4. 新增 `v1/payments/` 模块。 +5. 实现 Apple transaction verifier 抽象。 +6. 实现支付校验、幂等和积分入账服务。 +7. 实现退款/撤销处理服务(扣回积分)。 +8. 接入 `v1/router.py`。 +9. 添加单元测试和集成测试。 ### Phase 3:iOS / Flutter @@ -466,8 +520,10 @@ apps/lib/features/payments/ 1. 配置 App Store Connect 商品。 2. 配置后端 Apple 验签参数。 -3. Sandbox 和 TestFlight 测试。 -4. 检查日志、错误码、补偿路径。 +3. 配置 App Store Server Notifications V2 URL。 +4. Sandbox 和 TestFlight 测试。 +5. 测试退款/撤销流程(使用 Sandbox 退款功能)。 +6. 检查日志、错误码、补偿路径。 ## 14. 风险与缓解 @@ -478,7 +534,8 @@ apps/lib/features/payments/ | 商品配置不一致 | 后端维护 `productCode -> App Store Product ID` 映射并强校验 | | 新手包被重复购买 | 后端事务内锁定/更新 `register_bonus_claims` | | Apple 服务临时不可用 | 返回 `PAYMENT_APPLE_UNAVAILABLE`,前端保留交易稍后重试 | -| 退款后积分已消耗 | 本期只记录退款状态,不自动扣回;后续单独设计追偿策略 | +| 退款时余额不足扣回 | 扣到 0 为止,记录欠款,触发告警等待人工处理 | +| 退款通知丢失或延迟 | 后台定时任务查询 Apple Server API 获取交易状态变更 | | Flutter 插件无法暴露 StoreKit2 signedData | 增加最小 iOS platform channel 获取 `Transaction.signedData` | ## 15. 成功标准 @@ -488,4 +545,5 @@ apps/lib/features/payments/ - 新手包同一邮箱身份只能发放一次。 - 支付入账后 `user_points`、`points_ledger`、`apple_iap_transactions` 数据一致。 - 网络中断/重复提交不会导致漏发或重复发放。 +- 退款/撤销时正确扣回积分,余额不足时正确记录并告警。 - 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。 diff --git a/.trellis/workspace/opencode/index.md b/.trellis/workspace/opencode/index.md new file mode 100644 index 0000000..3fc132c --- /dev/null +++ b/.trellis/workspace/opencode/index.md @@ -0,0 +1,40 @@ +# Workspace Index - opencode + +> Journal tracking for AI development sessions. + +--- + +## Current Status + + +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - + + +--- + +## Active Documents + + +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | + + +--- + +## Session History + + +| # | Date | Title | Commits | +|---|------|-------|---------| + + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/opencode/journal-1.md b/.trellis/workspace/opencode/journal-1.md new file mode 100644 index 0000000..ee55246 --- /dev/null +++ b/.trellis/workspace/opencode/journal-1.md @@ -0,0 +1,7 @@ +# Journal - opencode (Part 1) + +> AI development session journal +> Started: 2026-04-27 + +--- + diff --git a/apps/ios/EryaoProducts.storekit b/apps/ios/EryaoProducts.storekit new file mode 100644 index 0000000..49c96d2 --- /dev/null +++ b/apps/ios/EryaoProducts.storekit @@ -0,0 +1,134 @@ +{ + "identifier" : "EryaoProducts", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "new_user_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "新用户专属优惠套餐", + "displayName" : "新手包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.new_user_pack", + "referenceName" : "新手包", + "type" : "Consumable" + }, + { + "displayPrice" : "6.00", + "familyShareable" : false, + "internalID" : "basic_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "基础信用点套餐", + "displayName" : "基础包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.basic_pack", + "referenceName" : "基础包", + "type" : "Consumable" + }, + { + "displayPrice" : "18.00", + "familyShareable" : false, + "internalID" : "popular_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "热门信用点套餐", + "displayName" : "热门包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.popular_pack", + "referenceName" : "热门包", + "type" : "Consumable" + }, + { + "displayPrice" : "68.00", + "familyShareable" : false, + "internalID" : "premium_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "高级信用点套餐", + "displayName" : "高级包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.premium_pack", + "referenceName" : "高级包", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "6738123456", + "_developerTeamID" : "YOUR_TEAM_ID", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 756460800, + "_locale" : "zh_CN", + "_storefront" : "CHN", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index cf64fbc..72da6e7 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -412,6 +412,15 @@ class _EryaoAppState extends State { }); } + void _handleBalanceChanged(int newBalance) { + if (!mounted) { + return; + } + setState(() { + _creditsBalance = newBalance; + }); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -451,6 +460,7 @@ class _EryaoAppState extends State { _refreshProfile(userEmail: state.user!.email); return HomeScreen( account: state.user!.email, + userId: state.user!.id, sessionStore: _sessionStore, currentLocale: _locale, profileSettings: _profileSettings, @@ -467,6 +477,7 @@ class _EryaoAppState extends State { onDeleteHistorySession: _handleHistorySessionDeleted, onLogout: _authBloc.logout, onDeleteAccount: _deleteAccount, + onBalanceChanged: _handleBalanceChanged, ); } diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index 0bcfe22..27c0d9c 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -10,6 +10,9 @@ class Env { if (Platform.isAndroid) { return 'http://10.0.2.2:5775'; } + if (Platform.isIOS) { + return 'http://192.168.1.63:5775'; + } return 'http://localhost:5775'; } diff --git a/apps/lib/core/network/api_problem_mapper.dart b/apps/lib/core/network/api_problem_mapper.dart index 6224c0e..71c613b 100644 --- a/apps/lib/core/network/api_problem_mapper.dart +++ b/apps/lib/core/network/api_problem_mapper.dart @@ -31,6 +31,20 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) { return l10n.errorAsrUnavailable; case 'PROFILE_DELETE_FAILED': return l10n.errorProfileDeleteFailed; + case 'PAYMENT_PRODUCT_NOT_FOUND': + return l10n.paymentProductNotFound; + case 'PAYMENT_PRODUCT_MISMATCH': + return l10n.paymentVerifyFailed; + case 'PAYMENT_ENVIRONMENT_MISMATCH': + return l10n.paymentVerifyFailed; + case 'PAYMENT_TRANSACTION_INVALID': + return l10n.paymentVerifyFailed; + case 'PAYMENT_TRANSACTION_REVOKED': + return l10n.paymentVerifyFailed; + case 'PAYMENT_TRANSACTION_CONFLICT': + return l10n.paymentVerifyFailed; + case 'PAYMENT_STARTER_PACK_INELIGIBLE': + return l10n.paymentStarterPackIneligible; default: break; } diff --git a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart index 51fa8ca..6d3de46 100644 --- a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart @@ -169,7 +169,7 @@ class _DivinationResultScreenState extends State { final palette = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context)!; return PopScope( - canPop: false, + canPop: true, onPopInvokedWithResult: (didPop, result) { if (didPop) { return; diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 8bff2ad..257119f 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -27,6 +27,7 @@ class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, required this.account, + required this.userId, required this.sessionStore, required this.currentLocale, required this.profileSettings, @@ -43,9 +44,11 @@ class HomeScreen extends StatefulWidget { required this.onDeleteHistorySession, required this.onLogout, required this.onDeleteAccount, + required this.onBalanceChanged, }); final String account; + final String userId; final SessionStore sessionStore; final Locale currentLocale; final ProfileSettingsV1 profileSettings; @@ -56,15 +59,16 @@ class HomeScreen extends StatefulWidget { final NotificationRepository notificationRepository; final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) - onProfileSettingsChanged; + onProfileSettingsChanged; final Future Function(ProfileSettingsV1 updated) - onSaveProfile; + onSaveProfile; final Future Function(String filePath) onUploadAvatar; final Future Function(DivinationResultData result) - onDivinationCompleted; + onDivinationCompleted; final Future Function(String threadId) onDeleteHistorySession; final Future Function() onLogout; final Future Function() onDeleteAccount; + final void Function(int newBalance) onBalanceChanged; @override State createState() => _HomeScreenState(); @@ -132,6 +136,7 @@ class _HomeScreenState extends State { ), _ProfileTab( account: widget.account, + userId: widget.userId, settings: widget.profileSettings, coinBalance: widget.coinBalance, inviteRepository: _inviteRepository, @@ -142,6 +147,7 @@ class _HomeScreenState extends State { onUploadAvatar: widget.onUploadAvatar, onLogout: widget.onLogout, onDeleteAccount: widget.onDeleteAccount, + onBalanceChanged: widget.onBalanceChanged, ), ], ), @@ -561,6 +567,7 @@ class _DivinationHistoryScreenState extends State { class _ProfileTab extends StatelessWidget { const _ProfileTab({ required this.account, + required this.userId, required this.settings, required this.coinBalance, required this.inviteRepository, @@ -571,9 +578,11 @@ class _ProfileTab extends StatelessWidget { required this.onUploadAvatar, required this.onLogout, required this.onDeleteAccount, + required this.onBalanceChanged, }); final String account; + final String userId; final ProfileSettingsV1 settings; final int coinBalance; final InviteRepository inviteRepository; @@ -581,15 +590,17 @@ class _ProfileTab extends StatelessWidget { final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(ProfileSettingsV1 updated) - onSaveProfile; + onSaveProfile; final Future Function(String filePath) onUploadAvatar; final Future Function() onLogout; final Future Function() onDeleteAccount; + final void Function(int newBalance) onBalanceChanged; @override Widget build(BuildContext context) { return SettingsScreen( account: account, + userId: userId, settings: settings, coinBalance: coinBalance, inviteRepository: inviteRepository, @@ -600,6 +611,7 @@ class _ProfileTab extends StatelessWidget { onUploadAvatar: onUploadAvatar, onLogout: onLogout, onDeleteAccount: onDeleteAccount, + onBalanceChanged: onBalanceChanged, ); } } @@ -703,14 +715,23 @@ class _WelcomeDialog extends StatefulWidget { class _WelcomeDialogState extends State<_WelcomeDialog> { final ScrollController _scrollController = ScrollController(); bool _hasScrolledToBottom = false; + bool _hasCheckedInitialScroll = false; @override void initState() { super.initState(); _scrollController.addListener(_handleScroll); - WidgetsBinding.instance.addPostFrameCallback((_) { - _syncScrollState(); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_hasCheckedInitialScroll) { + _hasCheckedInitialScroll = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncScrollState(); + }); + } } @override @@ -730,7 +751,7 @@ class _WelcomeDialogState extends State<_WelcomeDialog> { } final max = _scrollController.position.maxScrollExtent; final current = _scrollController.offset; - final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md; + final canReadAll = max <= 50.0 || current >= max - AppSpacing.md; if (_hasScrolledToBottom == canReadAll) { return; } diff --git a/apps/lib/features/payments/data/apis/apple_payment_api.dart b/apps/lib/features/payments/data/apis/apple_payment_api.dart new file mode 100644 index 0000000..673e4af --- /dev/null +++ b/apps/lib/features/payments/data/apis/apple_payment_api.dart @@ -0,0 +1,18 @@ +import '../../../../data/network/api_client.dart'; +import '../models/apple_purchase_models.dart'; + +class ApplePaymentApi { + const ApplePaymentApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + + Future verifyTransaction( + VerifyTransactionRequest request, + ) async { + final json = await _apiClient.postJson( + '/api/v1/payments/apple/transactions/verify', + data: request.toJson(), + ); + return VerifyTransactionResponse.fromJson(json); + } +} diff --git a/apps/lib/features/payments/data/models/apple_purchase_models.dart b/apps/lib/features/payments/data/models/apple_purchase_models.dart new file mode 100644 index 0000000..71df8a8 --- /dev/null +++ b/apps/lib/features/payments/data/models/apple_purchase_models.dart @@ -0,0 +1,56 @@ +class VerifyTransactionRequest { + const VerifyTransactionRequest({ + required this.productCode, + required this.appStoreProductId, + required this.transactionId, + required this.signedTransactionInfo, + this.appAccountToken, + }); + + final String productCode; + final String appStoreProductId; + final String transactionId; + final String signedTransactionInfo; + final String? appAccountToken; + + Map toJson() => { + 'productCode': productCode, + 'appStoreProductId': appStoreProductId, + 'transactionId': transactionId, + 'signedTransactionInfo': signedTransactionInfo, + if (appAccountToken != null) 'appAccountToken': appAccountToken, + }; +} + +enum VerifyTransactionStatus { granted, alreadyGranted } + +class VerifyTransactionResponse { + const VerifyTransactionResponse({ + required this.status, + required this.productCode, + required this.transactionId, + required this.creditsAdded, + required this.newBalance, + required this.ledgerEventId, + }); + + final VerifyTransactionStatus status; + final String productCode; + final String transactionId; + final int creditsAdded; + final int newBalance; + final String ledgerEventId; + + factory VerifyTransactionResponse.fromJson(Map json) { + return VerifyTransactionResponse( + status: json['status'] == 'already_granted' + ? VerifyTransactionStatus.alreadyGranted + : VerifyTransactionStatus.granted, + productCode: json['productCode'] as String, + transactionId: json['transactionId'] as String, + creditsAdded: json['creditsAdded'] as int, + newBalance: json['newBalance'] as int, + ledgerEventId: json['ledgerEventId'] as String, + ); + } +} diff --git a/apps/lib/features/payments/data/services/apple_iap_service.dart b/apps/lib/features/payments/data/services/apple_iap_service.dart new file mode 100644 index 0000000..adaa9dc --- /dev/null +++ b/apps/lib/features/payments/data/services/apple_iap_service.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../../../points/data/models/package_info.dart'; +import '../apis/apple_payment_api.dart'; +import '../models/apple_purchase_models.dart'; + +enum PurchaseFlowState { idle, purchasing, verifying, success, failed } + +class PurchaseResult { + const PurchaseResult({ + required this.state, + this.creditsAdded, + this.errorMessage, + }); + + final PurchaseFlowState state; + final int? creditsAdded; + final String? errorMessage; +} + +class AppleIapService with ChangeNotifier { + AppleIapService({ + required ApiClient apiClient, + required String userId, + }) : _paymentApi = ApplePaymentApi(apiClient: apiClient), + _inAppPurchase = InAppPurchase.instance, + _appAccountToken = _hashUserId(userId); + + final ApplePaymentApi _paymentApi; + final InAppPurchase _inAppPurchase; + final String? _appAccountToken; + final Logger _logger = getLogger('features.payments.apple_iap_service'); + + static String? _hashUserId(String userId) { + if (userId.isEmpty) return null; + final bytes = utf8.encode(userId); + final digest = md5.convert(bytes); + return digest.toString(); + } + + StreamSubscription>? _subscription; + Map _storeKitProducts = {}; + PurchaseFlowState _state = PurchaseFlowState.idle; + String? _lastError; + ApiProblem? _lastApiProblem; + + PurchaseFlowState get state => _state; + String? get lastError => _lastError; + ApiProblem? get lastApiProblem => _lastApiProblem; + + void init() { + final Stream> purchaseUpdated; + purchaseUpdated = _inAppPurchase.purchaseStream; + _subscription = purchaseUpdated.listen(_onPurchaseUpdated); + } + + Future loadStoreKitProducts(List packages) async { + final ids = packages + .map((p) => p.appStoreProductId) + .where((id) => id.isNotEmpty) + .toSet(); + + if (ids.isEmpty) return; + + final response = await _inAppPurchase.queryProductDetails(ids); + if (response.notFoundIDs.isNotEmpty) { + _logger.warning( + message: 'Some StoreKit products not found', + extra: {'notFound': response.notFoundIDs.join(', ')}, + ); + } + + final products = {}; + for (final detail in response.productDetails) { + products[detail.id] = detail; + } + _storeKitProducts = products; + } + + ProductDetails? getStoreKitProduct(String appStoreProductId) { + return _storeKitProducts[appStoreProductId]; + } + + Future purchase(PackageInfo package) async { + if (_state == PurchaseFlowState.purchasing || + _state == PurchaseFlowState.verifying) { + return false; + } + + final product = _storeKitProducts[package.appStoreProductId]; + if (product == null) { + _logger.warning( + message: 'StoreKit product not found for purchase', + extra: {'productId': package.appStoreProductId}, + ); + _setError('Product not available'); + return false; + } + + _setState(PurchaseFlowState.purchasing); + + final purchaseParam = PurchaseParam( + productDetails: product, + applicationUserName: _appAccountToken, + ); + final bought = await _inAppPurchase.buyConsumable( + purchaseParam: purchaseParam, + ); + + if (!bought) { + _setError('Failed to initiate purchase'); + return false; + } + + return true; + } + + void _onPurchaseUpdated(List purchases) { + for (final purchase in purchases) { + _handlePurchase(purchase); + } + } + + Future _handlePurchase(PurchaseDetails purchase) async { + switch (purchase.status) { + case PurchaseStatus.purchased: + await _verifyAndComplete(purchase); + case PurchaseStatus.canceled: + await _inAppPurchase.completePurchase(purchase); + _setState(PurchaseFlowState.idle); + case PurchaseStatus.error: + await _inAppPurchase.completePurchase(purchase); + _setError(purchase.error?.message ?? 'Purchase failed'); + case PurchaseStatus.pending: + _setState(PurchaseFlowState.purchasing); + case PurchaseStatus.restored: + await _inAppPurchase.completePurchase(purchase); + } + } + + Future _verifyAndComplete(PurchaseDetails purchase) async { + _setState(PurchaseFlowState.verifying); + + try { + final request = VerifyTransactionRequest( + productCode: _productCodeFromStoreKitId(purchase.productID), + appStoreProductId: purchase.productID, + transactionId: purchase.purchaseID ?? '', + signedTransactionInfo: purchase.verificationData.serverVerificationData, + appAccountToken: _appAccountToken, + ); + + final response = await _paymentApi.verifyTransaction(request); + await _inAppPurchase.completePurchase(purchase); + + _state = PurchaseFlowState.success; + _lastError = null; + _lastApiProblem = null; + _logger.info( + message: 'Purchase verified and completed', + extra: { + 'transactionId': purchase.purchaseID, + 'creditsAdded': response.creditsAdded, + 'status': response.status.name, + }, + ); + notifyListeners(); + } catch (e, stackTrace) { + _logger.error( + message: 'Purchase verification failed', + error: e, + stackTrace: stackTrace, + extra: {'transactionId': purchase.purchaseID}, + ); + + if (_isRetryableError(e)) { + _setState(PurchaseFlowState.idle); + return; + } + + await _inAppPurchase.completePurchase(purchase); + if (e is ApiProblem) { + _lastApiProblem = e; + _lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed'; + } else { + _lastApiProblem = null; + _lastError = _extractErrorMessage(e); + } + _state = PurchaseFlowState.failed; + notifyListeners(); + } + } + + bool _isRetryableError(Object error) { + if (error is ApiProblem) { + return error.status >= 500 || error.status == 0; + } + return true; + } + + String _extractErrorMessage(Object error) { + if (error is ApiProblem) { + return error.detail.isNotEmpty ? error.detail : 'Verification failed'; + } + return 'Verification failed'; + } + + String _productCodeFromStoreKitId(String storeKitId) { + return switch (storeKitId) { + 'com.meeyao.qianwen.new_user_pack' => 'new_user_pack', + 'com.meeyao.qianwen.basic_pack' => 'basic_pack', + 'com.meeyao.qianwen.popular_pack' => 'popular_pack', + 'com.meeyao.qianwen.premium_pack' => 'premium_pack', + _ => storeKitId, + }; + } + + void _setState(PurchaseFlowState state) { + _state = state; + if (state != PurchaseFlowState.failed) { + _lastError = null; + _lastApiProblem = null; + } + notifyListeners(); + } + + void _setError(String message) { + _state = PurchaseFlowState.failed; + _lastError = message; + _lastApiProblem = null; + _logger.warning(message: 'Purchase flow error', extra: {'error': message}); + notifyListeners(); + } + + void resetState() { + _state = PurchaseFlowState.idle; + _lastError = null; + _lastApiProblem = null; + notifyListeners(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} diff --git a/apps/lib/features/points/data/models/package_info.dart b/apps/lib/features/points/data/models/package_info.dart index ef540d6..daa2398 100644 --- a/apps/lib/features/points/data/models/package_info.dart +++ b/apps/lib/features/points/data/models/package_info.dart @@ -5,6 +5,7 @@ enum PackageType { starter, regular } class PackageInfo { const PackageInfo({ required this.productCode, + required this.appStoreProductId, required this.type, required this.price, required this.credits, @@ -14,6 +15,7 @@ class PackageInfo { }); final ProductCode productCode; + final String appStoreProductId; final PackageType type; final double price; final int credits; @@ -24,6 +26,7 @@ class PackageInfo { factory PackageInfo.fromJson(Map json) { return PackageInfo( productCode: _parseProductCode(json['productCode'] as String), + appStoreProductId: json['appStoreProductId'] as String, type: json['type'] == 'starter' ? PackageType.starter : PackageType.regular, diff --git a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart index e2020d2..f708926 100644 --- a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart +++ b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart @@ -1,21 +1,35 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import '../../../../app/di/injection.dart'; import '../../../../core/auth/session_store.dart'; import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem_mapper.dart'; import '../../../../data/network/api_client.dart'; import '../../../../data/storage/local_kv_store.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../payments/data/services/apple_iap_service.dart'; import '../../../points/data/apis/points_api.dart'; import '../../../points/data/models/package_info.dart'; import '../widgets/settings_section_widgets.dart'; class CoinCenterScreen extends StatefulWidget { - const CoinCenterScreen({super.key, required this.balance}); + const CoinCenterScreen({ + super.key, + required this.balance, + required this.userId, + required this.onBalanceChanged, + }); final int balance; + final String userId; + final void Function(int newBalance) onBalanceChanged; @override State createState() => _CoinCenterScreenState(); @@ -25,6 +39,7 @@ class _CoinCenterScreenState extends State { final Logger _logger = getLogger('features.settings.coin_center_screen'); List? _packages; bool _isLoading = true; + AppleIapService? _iapService; @override void initState() { @@ -39,12 +54,26 @@ class _CoinCenterScreenState extends State { baseUrl: appDependencies.backendUrl, tokenProvider: sessionStore.getToken, ); + final api = PointsApi(apiClient.rawDio); final result = await api.getPackages(); + + final service = AppleIapService( + apiClient: apiClient, + userId: widget.userId, + ); + service.init(); + service.addListener(_onPurchaseStateChanged); + + if (await InAppPurchase.instance.isAvailable()) { + await service.loadStoreKitProducts(result.packages); + } + if (mounted) { setState(() { _packages = result.packages; _isLoading = false; + _iapService = service; }); } } catch (e, stackTrace) { @@ -61,6 +90,60 @@ class _CoinCenterScreenState extends State { } } + void _onPurchaseStateChanged() { + final service = _iapService; + if (service == null || !mounted) return; + + final l10n = AppLocalizations.of(context)!; + + switch (service.state) { + case PurchaseFlowState.success: + Toast.show(context, l10n.paymentSuccess, type: ToastType.success); + _refreshBalance(); + service.resetState(); + break; + case PurchaseFlowState.failed: + final apiProblem = service.lastApiProblem; + final error = apiProblem != null + ? mapApiProblemToMessage(apiProblem, l10n) + : (service.lastError ?? l10n.paymentVerifyFailed); + Toast.show(context, error, type: ToastType.error); + service.resetState(); + break; + case PurchaseFlowState.purchasing: + case PurchaseFlowState.verifying: + case PurchaseFlowState.idle: + break; + } + + setState(() {}); + } + + Future _refreshBalance() async { + try { + final sessionStore = SessionStore(LocalKvStore()); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: sessionStore.getToken, + ); + final response = await apiClient.getJson('/api/v1/points/balance'); + final newBalance = response['availableBalance'] as int; + widget.onBalanceChanged(newBalance); + } catch (e, stackTrace) { + _logger.warning( + message: 'Failed to refresh balance after purchase: $e', + extra: {'stackTrace': stackTrace.toString()}, + ); + } + } + + @override + void dispose() { + _iapService?.removeListener(_onPurchaseStateChanged); + _iapService?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -120,13 +203,13 @@ class _CoinCenterScreenState extends State { ), const SizedBox(height: AppSpacing.xl), SectionLabel(text: l10n.settingsCoinRechargeSection), - ..._buildPackageCards(l10n), + ..._buildPackageCards(l10n, colors), ], ), ); } - List _buildPackageCards(AppLocalizations l10n) { + List _buildPackageCards(AppLocalizations l10n, ColorScheme colors) { if (_isLoading) { return [ const Padding( @@ -140,22 +223,66 @@ class _CoinCenterScreenState extends State { return []; } + final isBusy = _iapService?.state == PurchaseFlowState.purchasing || + _iapService?.state == PurchaseFlowState.verifying; + final isPending = _iapService?.state == PurchaseFlowState.purchasing; + return List.generate(_packages!.length, (index) { final pkg = _packages![index]; + final storeKitProduct = _iapService?.getStoreKitProduct(pkg.appStoreProductId); + final isAvailable = storeKitProduct != null; + return Column( children: [ if (index > 0) const SizedBox(height: AppSpacing.md), + if (isPending && !isBusy) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + l10n.paymentPending, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.primary, + ), + ), + ), CoinPackageCard( title: _getPackageTitle(pkg, l10n), - price: pkg.priceDisplay, + price: _getDisplayPrice(pkg), amount: pkg.credits, badge: _getPackageBadge(pkg, l10n), + onPurchase: () => _handlePurchase(pkg), + isPurchasing: isBusy, + isAvailable: isAvailable, + unavailableMessage: isAvailable ? null : l10n.paymentProductUnavailable, ), ], ); }); } + String _getDisplayPrice(PackageInfo pkg) { + final product = _iapService?.getStoreKitProduct(pkg.appStoreProductId); + if (product != null) { + return product.price; + } + return pkg.priceDisplay; + } + + Future _handlePurchase(PackageInfo pkg) async { + final l10n = AppLocalizations.of(context)!; + if (_iapService == null) { + Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error); + return; + } + + if (!Platform.isIOS) { + Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error); + return; + } + + await _iapService!.purchase(pkg); + } + String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) { if (pkg.productCode == ProductCode.popularPack) { return l10n.settingsCoinPackPopularBadge; diff --git a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart index 5cc5fd2..1114afc 100644 --- a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart @@ -34,117 +34,108 @@ class _GeneralSettingsScreenState extends State { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (didPop) { - return; - } - Navigator.of(context).pop(_settings); - }, - child: Scaffold( + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back_ios_new_rounded), + ), + title: Text(l10n.settingsGeneralTitle), + centerTitle: true, backgroundColor: colors.surfaceContainerLow, - appBar: AppBar( - leading: IconButton( - onPressed: () => Navigator.of(context).pop(_settings), - icon: const Icon(Icons.arrow_back_ios_new_rounded), + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + SectionLabel(text: l10n.settingsSectionGeneral), + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.language_rounded, + title: l10n.settingsInterfaceLanguage, + subtitle: displayLanguageLabel( + l10n, + _settings.preferences.interfaceLanguage, + ), + tint: colors.primary, + background: colors.surfaceContainerHighest, + onTap: () => _selectLanguage( + _settings.preferences.interfaceLanguage, + (lang) => setState(() { + _settings = _settings.copyWith( + preferences: _settings.preferences.copyWith( + interfaceLanguage: lang, + ), + ); + }), + ), + ), + SettingsMenuTile( + icon: Icons.smart_toy_rounded, + title: l10n.settingsAiLanguage, + subtitle: displayLanguageLabel( + l10n, + _settings.preferences.aiLanguage, + ), + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onTap: () => _selectLanguage( + _settings.preferences.aiLanguage, + (lang) => setState(() { + _settings = _settings.copyWith( + preferences: _settings.preferences.copyWith( + aiLanguage: lang, + ), + ); + }), + ), + ), + ], ), - title: Text(l10n.settingsGeneralTitle), - centerTitle: true, - backgroundColor: colors.surfaceContainerLow, - surfaceTintColor: colors.surfaceContainerLow, - ), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - SectionLabel(text: l10n.settingsSectionGeneral), - SettingsGroupCard( - children: [ - SettingsMenuTile( - icon: Icons.language_rounded, - title: l10n.settingsInterfaceLanguage, - subtitle: displayLanguageLabel( - l10n, - _settings.preferences.interfaceLanguage, - ), - tint: colors.primary, - background: colors.surfaceContainerHighest, - onTap: () => _selectLanguage( - _settings.preferences.interfaceLanguage, - (lang) => setState(() { - _settings = _settings.copyWith( - preferences: _settings.preferences.copyWith( - interfaceLanguage: lang, - ), - ); - }), - ), - ), - SettingsMenuTile( - icon: Icons.smart_toy_rounded, - title: l10n.settingsAiLanguage, - subtitle: displayLanguageLabel( - l10n, - _settings.preferences.aiLanguage, - ), - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: false, - onTap: () => _selectLanguage( - _settings.preferences.aiLanguage, - (lang) => setState(() { - _settings = _settings.copyWith( - preferences: _settings.preferences.copyWith( - aiLanguage: lang, - ), - ); - }), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - SectionLabel(text: l10n.settingsSectionPrivacy), - SettingsGroupCard( - children: [ - SettingsSwitchTile( - icon: Icons.security_rounded, - title: l10n.settingsDoNotSellTitle, - value: _settings.privacy.canSell, - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: false, - onChanged: (value) => _updatePrivacy(canSell: value), - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - SectionLabel(text: l10n.settingsSectionNotification), - SettingsGroupCard( - children: [ - SettingsSwitchTile( - icon: Icons.notifications_rounded, - title: l10n.settingsNotificationAllow, - value: _settings.notification.allowNotifications, - tint: colors.primary, - background: colors.surfaceContainerHighest, - onChanged: (value) => - _updateNotification(allowNotifications: value), - ), - SettingsSwitchTile( - icon: Icons.vibration_rounded, - title: l10n.settingsNotificationVibration, - value: _settings.notification.allowVibration, - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: false, - onChanged: (value) => - _updateNotification(allowVibration: value), - ), - ], - ), - ], - ), + const SizedBox(height: AppSpacing.lg), + SectionLabel(text: l10n.settingsSectionPrivacy), + SettingsGroupCard( + children: [ + SettingsSwitchTile( + icon: Icons.security_rounded, + title: l10n.settingsDoNotSellTitle, + value: _settings.privacy.canSell, + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onChanged: (value) => _updatePrivacy(canSell: value), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + SectionLabel(text: l10n.settingsSectionNotification), + SettingsGroupCard( + children: [ + SettingsSwitchTile( + icon: Icons.notifications_rounded, + title: l10n.settingsNotificationAllow, + value: _settings.notification.allowNotifications, + tint: colors.primary, + background: colors.surfaceContainerHighest, + onChanged: (value) => + _updateNotification(allowNotifications: value), + ), + SettingsSwitchTile( + icon: Icons.vibration_rounded, + title: l10n.settingsNotificationVibration, + value: _settings.notification.allowVibration, + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onChanged: (value) => + _updateNotification(allowVibration: value), + ), + ], + ), + ], ), ); } diff --git a/apps/lib/features/settings/presentation/screens/invite_screen.dart b/apps/lib/features/settings/presentation/screens/invite_screen.dart index 0ccfbbb..a4c7cd3 100644 --- a/apps/lib/features/settings/presentation/screens/invite_screen.dart +++ b/apps/lib/features/settings/presentation/screens/invite_screen.dart @@ -405,7 +405,7 @@ class _BindCodeSection extends StatelessWidget { SizedBox( width: double.infinity, child: FilledButton( - onPressed: isBinding ? null : onBind, + onPressed: null, style: FilledButton.styleFrom( elevation: 0, padding: const EdgeInsets.symmetric( @@ -415,16 +415,7 @@ class _BindCodeSection extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.full), ), ), - child: isBinding - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text(l10n.settingsInviteBindButton), + child: Text(l10n.settingsComingSoon), ), ), ], diff --git a/apps/lib/features/settings/presentation/screens/legal_center_screen.dart b/apps/lib/features/settings/presentation/screens/legal_center_screen.dart deleted file mode 100644 index 9e98011..0000000 --- a/apps/lib/features/settings/presentation/screens/legal_center_screen.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../l10n/app_localizations.dart'; -import '../../../../shared/theme/design_tokens.dart'; -import '../models/legal_document_type.dart'; -import '../utils/legal_document_assets.dart'; -import '../widgets/settings_section_widgets.dart'; -import 'legal_document_screen.dart'; - -class LegalCenterScreen extends StatelessWidget { - const LegalCenterScreen({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final colors = Theme.of(context).colorScheme; - final locale = Localizations.localeOf(context); - final documents = [ - LegalDocumentType.aboutUs, - LegalDocumentType.privacyPolicy, - LegalDocumentType.termsOfService, - ]; - - return Scaffold( - backgroundColor: colors.surfaceContainerLow, - appBar: AppBar( - title: Text(l10n.settingsLegalCenterTitle), - centerTitle: true, - backgroundColor: colors.surfaceContainerLow, - surfaceTintColor: colors.surfaceContainerLow, - ), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - SectionLabel(text: l10n.settingsSectionAbout), - SettingsGroupCard( - children: [ - for (int i = 0; i < documents.length; i++) - SettingsMenuTile( - icon: legalDocumentIcon(documents[i]), - title: legalDocumentTitle(l10n, documents[i]), - subtitle: legalDocumentSubtitle(l10n, documents[i]), - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: i != documents.length - 1, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LegalDocumentScreen( - title: legalDocumentTitle(l10n, documents[i]), - assetPath: legalDocumentAssetPath(locale, documents[i]), - ), - ), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index dd8370b..e378666 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -7,19 +7,22 @@ import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/gua_icon.dart'; import '../../data/models/profile_settings.dart'; import '../../data/repositories/invite_repository.dart'; +import '../models/legal_document_type.dart'; +import '../utils/legal_document_assets.dart'; import 'account_delete_screen.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; import 'feedback_screen.dart'; import 'general_settings_screen.dart'; import 'invite_screen.dart'; -import 'legal_center_screen.dart'; +import 'legal_document_screen.dart'; import 'profile_edit_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, required this.account, + required this.userId, required this.settings, required this.coinBalance, required this.inviteRepository, @@ -30,9 +33,11 @@ class SettingsScreen extends StatefulWidget { required this.onLogout, required this.onDeleteAccount, required this.onSaveProfile, + required this.onBalanceChanged, }); final String account; + final String userId; final ProfileSettingsV1 settings; final int coinBalance; final InviteRepository inviteRepository; @@ -43,7 +48,8 @@ class SettingsScreen extends StatefulWidget { final Future Function() onLogout; final Future Function() onDeleteAccount; final Future Function(ProfileSettingsV1 updated) - onSaveProfile; + onSaveProfile; + final void Function(int newBalance) onBalanceChanged; @override State createState() => _SettingsScreenState(); @@ -119,35 +125,17 @@ class _SettingsScreenState extends State { background: colors.surfaceContainerHighest, onTap: _openInvite, ), - SettingsMenuTile( - icon: Icons.description_outlined, - title: l10n.settingsLegalCenterTitle, - tint: colors.secondary, - background: colors.surfaceContainerHighest, - showDivider: false, - onTap: _openLegalCenter, - ), - ], - ), - const SizedBox(height: AppSpacing.xl), - SettingsGroupCard( - children: [ SettingsMenuTile( icon: Icons.feedback_outlined, title: l10n.settingsFeedbackTitle, tint: colors.primary, background: colors.surfaceContainerHighest, - showDivider: false, onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => FeedbackScreen(apiClient: widget.apiClient), ), ), ), - ], - ), - SettingsGroupCard( - children: [ SettingsMenuTile( icon: Icons.person_rounded, title: l10n.settingsAccountAndDataTitle, @@ -159,6 +147,33 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: AppSpacing.xl), + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.info_outline_rounded, + title: l10n.aboutUs, + tint: colors.secondary, + background: colors.surfaceContainerHighest, + onTap: () => _openLegalDocument(LegalDocumentType.aboutUs), + ), + SettingsMenuTile( + icon: Icons.security_rounded, + title: l10n.privacyPolicy, + tint: colors.secondary, + background: colors.surfaceContainerHighest, + onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy), + ), + SettingsMenuTile( + icon: Icons.description_outlined, + title: l10n.termsOfService, + tint: colors.secondary, + background: colors.surfaceContainerHighest, + showDivider: false, + onTap: () => _openLegalDocument(LegalDocumentType.termsOfService), + ), + ], + ), + const SizedBox(height: AppSpacing.xl), FilledButton( onPressed: _confirmLogout, style: FilledButton.styleFrom( @@ -180,7 +195,11 @@ class _SettingsScreenState extends State { Future _openCoinCenter() async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => CoinCenterScreen(balance: widget.coinBalance), + builder: (_) => CoinCenterScreen( + balance: widget.coinBalance, + userId: widget.userId, + onBalanceChanged: widget.onBalanceChanged, + ), ), ); } @@ -229,9 +248,16 @@ class _SettingsScreenState extends State { }); } - Future _openLegalCenter() async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LegalCenterScreen()), + void _openLegalDocument(LegalDocumentType type) { + final l10n = AppLocalizations.of(context)!; + final locale = Localizations.localeOf(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalDocumentScreen( + title: legalDocumentTitle(l10n, type), + assetPath: legalDocumentAssetPath(locale, type), + ), + ), ); } diff --git a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart index 58f9b0c..106d30b 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -3,8 +3,6 @@ import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; class SectionLabel extends StatelessWidget { const SectionLabel({super.key, required this.text}); @@ -420,12 +418,20 @@ class CoinPackageCard extends StatelessWidget { required this.price, required this.amount, this.badge, + this.onPurchase, + this.isPurchasing = false, + this.isAvailable = true, + this.unavailableMessage, }); final String title; final String price; final int amount; final String? badge; + final VoidCallback? onPurchase; + final bool isPurchasing; + final bool isAvailable; + final String? unavailableMessage; @override Widget build(BuildContext context) { @@ -483,32 +489,43 @@ class CoinPackageCard extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.lg), - Row( - children: [ - Text( - price, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(color: colors.primary), + if (!isAvailable && unavailableMessage != null) + Text( + unavailableMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.error, ), - const Spacer(), - FilledButton( - onPressed: () { - Toast.show( + ) + else + Row( + children: [ + Text( + price, + style: Theme.of( context, - l10n.settingsPurchasePending, - type: ToastType.info, - ); - }, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.full), - ), + ).textTheme.headlineMedium?.copyWith(color: colors.primary), ), - child: Text(l10n.settingsPurchaseButton), - ), - ], - ), + const Spacer(), + FilledButton( + onPressed: isPurchasing || !isAvailable ? null : onPurchase, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + child: isPurchasing + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onPrimary, + ), + ) + : Text(l10n.settingsPurchaseButton), + ), + ], + ), ], ), ), diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 7c1780f..5b018d3 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -505,5 +505,11 @@ "feedbackContentRequired": "Please enter feedback content", "feedbackContentTooLong": "Feedback content cannot exceed 500 characters", "feedbackTooManyImages": "Maximum 3 images allowed", - "feedbackImageTooLarge": "Image size cannot exceed 5MB" + "feedbackImageTooLarge": "Image size cannot exceed 5MB", + "paymentSuccess": "Purchase successful", + "paymentVerifyFailed": "Purchase verification failed, please try again later", + "paymentProductNotFound": "Product temporarily unavailable", + "paymentStarterPackIneligible": "Starter pack is limited to one purchase per user", + "paymentProductUnavailable": "Product temporarily unavailable", + "paymentPending": "Apple is processing, please wait" } diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 7e0c2d8..be77345 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -2432,6 +2432,42 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'图片大小不能超过5MB'** String get feedbackImageTooLarge; + + /// No description provided for @paymentSuccess. + /// + /// In zh, this message translates to: + /// **'购买成功'** + String get paymentSuccess; + + /// No description provided for @paymentVerifyFailed. + /// + /// In zh, this message translates to: + /// **'购买验证失败,请稍后重试'** + String get paymentVerifyFailed; + + /// No description provided for @paymentProductNotFound. + /// + /// In zh, this message translates to: + /// **'商品暂时不可用'** + String get paymentProductNotFound; + + /// No description provided for @paymentStarterPackIneligible. + /// + /// In zh, this message translates to: + /// **'新手包每位用户仅限购买一次'** + String get paymentStarterPackIneligible; + + /// No description provided for @paymentProductUnavailable. + /// + /// In zh, this message translates to: + /// **'商品暂时不可用'** + String get paymentProductUnavailable; + + /// No description provided for @paymentPending. + /// + /// In zh, this message translates to: + /// **'Apple 正在处理中,请稍候'** + String get paymentPending; } class _AppLocalizationsDelegate diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index ede602b..cf1e25e 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -1282,4 +1282,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get feedbackImageTooLarge => 'Image size cannot exceed 5MB'; + + @override + String get paymentSuccess => 'Purchase successful'; + + @override + String get paymentVerifyFailed => + 'Purchase verification failed, please try again later'; + + @override + String get paymentProductNotFound => 'Product temporarily unavailable'; + + @override + String get paymentStarterPackIneligible => + 'Starter pack is limited to one purchase per user'; + + @override + String get paymentProductUnavailable => 'Product temporarily unavailable'; + + @override + String get paymentPending => 'Apple is processing, please wait'; } diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 3961153..0307319 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -1225,6 +1225,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedbackImageTooLarge => '图片大小不能超过5MB'; + + @override + String get paymentSuccess => '购买成功'; + + @override + String get paymentVerifyFailed => '购买验证失败,请稍后重试'; + + @override + String get paymentProductNotFound => '商品暂时不可用'; + + @override + String get paymentStarterPackIneligible => '新手包每位用户仅限购买一次'; + + @override + String get paymentProductUnavailable => '商品暂时不可用'; + + @override + String get paymentPending => 'Apple 正在处理中,请稍候'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -2204,4 +2222,22 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get feedbackImageTooLarge => '圖片大小不能超過5MB'; + + @override + String get paymentSuccess => '購買成功'; + + @override + String get paymentVerifyFailed => '購買驗證失敗,請稍後重試'; + + @override + String get paymentProductNotFound => '商品暫時不可用'; + + @override + String get paymentStarterPackIneligible => '新手包每位用戶僅限購買一次'; + + @override + String get paymentProductUnavailable => '商品暫時不可用'; + + @override + String get paymentPending => 'Apple 正在處理中,請稍候'; } diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index bd14dc0..9ad2be0 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -505,5 +505,11 @@ "feedbackContentRequired": "请输入反馈内容", "feedbackContentTooLong": "反馈内容不能超过500字", "feedbackTooManyImages": "最多只能上传3张图片", - "feedbackImageTooLarge": "图片大小不能超过5MB" + "feedbackImageTooLarge": "图片大小不能超过5MB", + "paymentSuccess": "购买成功", + "paymentVerifyFailed": "购买验证失败,请稍后重试", + "paymentProductNotFound": "商品暂时不可用", + "paymentStarterPackIneligible": "新手包每位用户仅限购买一次", + "paymentProductUnavailable": "商品暂时不可用", + "paymentPending": "Apple 正在处理中,请稍候" } diff --git a/apps/lib/l10n/app_zh_hant.arb b/apps/lib/l10n/app_zh_hant.arb index da15b66..e432330 100644 --- a/apps/lib/l10n/app_zh_hant.arb +++ b/apps/lib/l10n/app_zh_hant.arb @@ -407,5 +407,11 @@ "feedbackContentRequired": "請輸入回饋內容", "feedbackContentTooLong": "回饋內容不能超過500字", "feedbackTooManyImages": "最多只能上傳3張圖片", - "feedbackImageTooLarge": "圖片大小不能超過5MB" + "feedbackImageTooLarge": "圖片大小不能超過5MB", + "paymentSuccess": "購買成功", + "paymentVerifyFailed": "購買驗證失敗,請稍後重試", + "paymentProductNotFound": "商品暫時不可用", + "paymentStarterPackIneligible": "新手包每位用戶僅限購買一次", + "paymentProductUnavailable": "商品暫時不可用", + "paymentPending": "Apple 正在處理中,請稍候" } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 7c3e1e6..2fa9495 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -49,6 +49,9 @@ dependencies: cupertino_icons: ^1.0.8 device_info_plus: ^12.4.0 package_info_plus: ^9.0.1 + in_app_purchase: ^3.2.3 + in_app_purchase_storekit: ^0.4.8 + crypto: ^3.0.7 dev_dependencies: flutter_test: diff --git a/apps/test/features/payments/data/models/apple_purchase_models_test.dart b/apps/test/features/payments/data/models/apple_purchase_models_test.dart new file mode 100644 index 0000000..205db46 --- /dev/null +++ b/apps/test/features/payments/data/models/apple_purchase_models_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/features/payments/data/models/apple_purchase_models.dart'; + +void main() { + group('VerifyTransactionRequest', () { + test('toJson includes all required fields', () { + const request = VerifyTransactionRequest( + productCode: 'basic_pack', + appStoreProductId: 'com.meeyao.qianwen.basic_pack', + transactionId: '1000000123456789', + signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...', + ); + + final json = request.toJson(); + + expect(json['productCode'], 'basic_pack'); + expect(json['appStoreProductId'], 'com.meeyao.qianwen.basic_pack'); + expect(json['transactionId'], '1000000123456789'); + expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...'); + expect(json.containsKey('appAccountToken'), false); + }); + + test('toJson includes appAccountToken when provided', () { + const request = VerifyTransactionRequest( + productCode: 'basic_pack', + appStoreProductId: 'com.meeyao.qianwen.basic_pack', + transactionId: '1000000123456789', + signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...', + appAccountToken: 'abc123def456', + ); + + final json = request.toJson(); + + expect(json['appAccountToken'], 'abc123def456'); + }); + }); + + group('VerifyTransactionResponse', () { + test('parses granted status correctly', () { + final json = { + 'status': 'granted', + 'productCode': 'basic_pack', + 'transactionId': '1000000123456789', + 'creditsAdded': 100, + 'newBalance': 180, + 'ledgerEventId': 'payment.apple_iap:1000000123456789', + }; + + final response = VerifyTransactionResponse.fromJson(json); + + expect(response.status, VerifyTransactionStatus.granted); + expect(response.productCode, 'basic_pack'); + expect(response.transactionId, '1000000123456789'); + expect(response.creditsAdded, 100); + expect(response.newBalance, 180); + expect(response.ledgerEventId, 'payment.apple_iap:1000000123456789'); + }); + + test('parses already_granted status correctly', () { + final json = { + 'status': 'already_granted', + 'productCode': 'basic_pack', + 'transactionId': '1000000123456789', + 'creditsAdded': 0, + 'newBalance': 180, + 'ledgerEventId': 'payment.apple_iap:1000000123456789', + }; + + final response = VerifyTransactionResponse.fromJson(json); + + expect(response.status, VerifyTransactionStatus.alreadyGranted); + expect(response.creditsAdded, 0); + }); + }); +} diff --git a/backend/alembic/versions/20260427_0001_apple_iap_transactions.py b/backend/alembic/versions/20260427_0001_apple_iap_transactions.py new file mode 100644 index 0000000..86643ae --- /dev/null +++ b/backend/alembic/versions/20260427_0001_apple_iap_transactions.py @@ -0,0 +1,278 @@ +"""add apple_iap_transactions table and update points_ledger constraints + +Revision ID: 20260427_0001 +Revises: 20260417_0001 +Create Date: 2026-04-27 12:00:00 + +Changes: +1. Create apple_iap_transactions table for Apple IAP payment tracking +2. Update points_ledger check constraints: + - Remove 'grant' from change_type (merged into 'adjust') + - Add 'purchase' and 'refund' to change_type + - Add 'payment' to biz_type + - Update biz_binding constraint for new types + - Update direction_by_change_type constraint + - Add metadata shape constraints for purchase/refund + - Update adjust metadata constraint (ticket_id -> reason) +3. Update points_audit_ledger check constraints similarly +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260427_0001" +down_revision: Union[str, Sequence[str], None] = "20260417_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "apple_iap_transactions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_code", sa.String(32), nullable=False), + sa.Column("app_store_product_id", sa.String(128), nullable=False), + sa.Column("transaction_id", sa.String(64), nullable=False), + sa.Column("original_transaction_id", sa.String(64), nullable=True), + sa.Column("web_order_line_item_id", sa.String(64), nullable=True), + sa.Column("environment", sa.String(16), nullable=False), + sa.Column("bundle_id", sa.String(128), nullable=False), + sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("purchase_date", sa.Text, nullable=False), + sa.Column("revocation_date", sa.Text, nullable=True), + sa.Column("status", sa.String(24), nullable=False), + sa.Column("credits", sa.BigInteger, nullable=False), + sa.Column("currency", sa.String(8), nullable=True), + sa.Column("price_milliunits", sa.BigInteger, nullable=True), + sa.Column("ledger_event_id", sa.String(64), nullable=True), + sa.Column("signed_transaction_info", sa.Text, nullable=False), + sa.Column( + "apple_payload", + postgresql.JSONB(), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("failure_code", sa.String(64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "environment in ('Sandbox', 'Production')", + name="ck_apple_iap_transactions_environment", + ), + sa.CheckConstraint( + "status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", + name="ck_apple_iap_transactions_status", + ), + sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"), + sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"), + ) + + op.create_index( + "ix_apple_iap_transactions_user_created_at", + "apple_iap_transactions", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_apple_iap_transactions_status_updated_at", + "apple_iap_transactions", + ["status", sa.text("updated_at DESC")], + ) + + op.execute("ALTER TABLE apple_iap_transactions ENABLE ROW LEVEL SECURITY") + + op.execute( + "CREATE POLICY anon_select_apple_iap_transactions ON apple_iap_transactions " + "FOR SELECT TO anon USING (false)" + ) + op.execute( + "CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions " + "FOR INSERT TO anon WITH CHECK (true)" + ) + op.execute( + "CREATE POLICY anon_update_apple_iap_transactions ON apple_iap_transactions " + "FOR UPDATE TO anon USING (false)" + ) + op.execute( + "CREATE POLICY anon_delete_apple_iap_transactions ON apple_iap_transactions " + "FOR DELETE TO anon USING (false)" + ) + op.execute( + "CREATE POLICY authenticated_select_apple_iap_transactions ON apple_iap_transactions " + "FOR SELECT TO authenticated USING (false)" + ) + op.execute( + "CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions " + "FOR INSERT TO authenticated WITH CHECK (true)" + ) + op.execute( + "CREATE POLICY authenticated_update_apple_iap_transactions ON apple_iap_transactions " + "FOR UPDATE TO authenticated USING (false)" + ) + op.execute( + "CREATE POLICY authenticated_delete_apple_iap_transactions ON apple_iap_transactions " + "FOR DELETE TO authenticated USING (false)" + ) + + op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_change_type", + "points_ledger", + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", + ) + + op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_biz_type", + "points_ledger", + "biz_type is null or biz_type in ('chat', 'payment')", + ) + + op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_biz_binding", + "points_ledger", + "((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or " + "(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or " + "(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", + ) + + op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_direction_by_change_type", + "points_ledger", + "((change_type in ('register', 'purchase') and direction = 1) or " + "(change_type in ('consume', 'refund') and direction = -1) or " + "(change_type = 'adjust' and direction in (1, -1)))", + ) + + op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_metadata_adjust_shape", + "points_ledger", + "(change_type <> 'adjust' or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'reason') and " + "coalesce(metadata #>> '{ext,reason}', '') <> ''))", + ) + + op.create_check_constraint( + "ck_points_ledger_metadata_payment_shape", + "points_ledger", + "(change_type not in ('purchase', 'refund') or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and " + "(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and " + "coalesce(metadata #>> '{ext,source}', '') <> '' and " + "coalesce(metadata #>> '{ext,platform}', '') <> '' and " + "coalesce(metadata #>> '{ext,product_code}', '') <> '' and " + "coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", + ) + + op.create_check_constraint( + "ck_points_ledger_metadata_refund_shape", + "points_ledger", + "(change_type <> 'refund' or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and " + "coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", + ) + + op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check") + op.create_check_constraint( + "ck_points_audit_ledger_change_type", + "points_audit_ledger", + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", + ) + + op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check") + op.create_check_constraint( + "ck_points_audit_ledger_biz_type", + "points_audit_ledger", + "biz_type is null or biz_type in ('chat', 'payment')", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check") + op.create_check_constraint( + "ck_points_audit_ledger_biz_type", + "points_audit_ledger", + "biz_type is null or biz_type = 'chat'", + ) + + op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check") + op.create_check_constraint( + "ck_points_audit_ledger_change_type", + "points_audit_ledger", + "change_type in ('register', 'consume', 'grant', 'adjust')", + ) + + op.drop_constraint("ck_points_ledger_metadata_refund_shape", "points_ledger", type_="check") + op.drop_constraint("ck_points_ledger_metadata_payment_shape", "points_ledger", type_="check") + + op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_metadata_adjust_shape", + "points_ledger", + "(change_type <> 'adjust' or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " + "coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))", + ) + + op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_direction_by_change_type", + "points_ledger", + "((change_type in ('register', 'grant') and direction = 1) or " + "(change_type = 'consume' and direction = -1) or " + "(change_type = 'adjust' and direction in (1, -1)))", + ) + + op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_biz_binding", + "points_ledger", + "((change_type = 'register' and biz_type is null and biz_id is null) or " + "(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))", + ) + + op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_biz_type", + "points_ledger", + "biz_type is null or biz_type = 'chat'", + ) + + op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check") + op.create_check_constraint( + "ck_points_ledger_change_type", + "points_ledger", + "change_type in ('register', 'consume', 'grant', 'adjust')", + ) + + op.drop_index("ix_apple_iap_transactions_status_updated_at", table_name="apple_iap_transactions") + op.drop_index("ix_apple_iap_transactions_user_created_at", table_name="apple_iap_transactions") + + op.execute("DROP POLICY IF EXISTS authenticated_delete_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS authenticated_update_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS authenticated_select_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS anon_delete_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS anon_update_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions") + op.execute("DROP POLICY IF EXISTS anon_select_apple_iap_transactions ON apple_iap_transactions") + + op.execute("ALTER TABLE apple_iap_transactions DISABLE ROW LEVEL SECURITY") + + op.drop_table("apple_iap_transactions") diff --git a/backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py b/backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py new file mode 100644 index 0000000..131c949 --- /dev/null +++ b/backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py @@ -0,0 +1,60 @@ +"""fix apple_iap_transactions RLS policies for INSERT + +Revision ID: 20260427_0002 +Revises: 20260427_0001 +Create Date: 2026-04-27 18:00:00 + +Changes: +1. Fix anon_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false) +2. Fix authenticated_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false) + +Rationale: +Apple IAP transactions should only be created by backend service_role, +not by client anon/authenticated users. The original policies allowed +unrestricted INSERT which bypasses RLS security. +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260427_0002" +down_revision: Union[str, Sequence[str], None] = "20260427_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions" + ) + op.execute( + "CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions " + "FOR INSERT TO anon WITH CHECK (false)" + ) + + op.execute( + "DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions" + ) + op.execute( + "CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions " + "FOR INSERT TO authenticated WITH CHECK (false)" + ) + + +def downgrade() -> None: + op.execute( + "DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions" + ) + op.execute( + "CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions " + "FOR INSERT TO authenticated WITH CHECK (true)" + ) + + op.execute( + "DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions" + ) + op.execute( + "CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions " + "FOR INSERT TO anon WITH CHECK (true)" + ) diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index c4def52..773acf4 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -356,7 +356,7 @@ class AgentScopeRunner: ) -> TrackingChatModel: generate_kwargs: dict[str, Any] = { "timeout": stage_config.llm_config.timeout_seconds, - "extra_body": {"enable_thinking": False}, + "extra_body": {"thinking": {"type": "disabled"}}, } if stage_config.llm_config.temperature is not None: generate_kwargs["temperature"] = stage_config.llm_config.temperature diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 3728718..77b38ba 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -228,6 +228,18 @@ class PointsPolicySettings(BaseModel): return self +class AppleIapSettings(BaseModel): + bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1) + root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" + jws_x5c_cert_url: str = "https://api.storekit.itunes.apple.com/v1/verificationKeys" + server_api_issuer_id: str | None = None + server_api_key_id: str | None = None + server_api_private_key: SecretStr | None = None + sandbox_tester_email: str | None = None + sandbox_tester_password: SecretStr | None = None + server_notifications_url: str | None = None + + def _resolve_env_file() -> str: current = Path(__file__).resolve() for parent in [current, *current.parents]: @@ -271,6 +283,7 @@ class Settings(BaseSettings): taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings) + apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings) feedback_report: FeedbackReportSettings = Field( default_factory=FeedbackReportSettings ) diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml index f5a9538..8476920 100644 --- a/backend/src/core/config/static/database/system_agents.yaml +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -1,24 +1,24 @@ agents: - - agent_type: router - llm_model_code: qwen3.5-flash - status: active - config: - temperature: 0.7 - max_tokens: null - timeout_seconds: 30 - context_messages: - mode: day - count: 2 - enabled_tools: [] + - agent_type: router + llm_model_code: qwen3.5-flash + status: active + config: + temperature: 0.7 + max_tokens: null + timeout_seconds: 30 + context_messages: + mode: day + count: 2 + enabled_tools: [] - - agent_type: worker - llm_model_code: deepseek-chat - status: active - config: - temperature: 0.7 - max_tokens: 2048 - timeout_seconds: 120 - context_messages: - mode: number - count: 20 - enabled_tools: [] + - agent_type: worker + llm_model_code: deepseek-v4-flash + status: active + config: + temperature: 0.7 + max_tokens: 2048 + timeout_seconds: 120 + context_messages: + mode: number + count: 20 + enabled_tools: [] diff --git a/backend/src/core/config/static/packages/mapping.yaml b/backend/src/core/config/static/packages/mapping.yaml new file mode 100644 index 0000000..d78d77c --- /dev/null +++ b/backend/src/core/config/static/packages/mapping.yaml @@ -0,0 +1,17 @@ +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack + credits: 60 + type: starter + basic_pack: + app_store_product_id: com.meeyao.qianwen.basic_pack + credits: 100 + type: regular + popular_pack: + app_store_product_id: com.meeyao.qianwen.popular_pack + credits: 210 + type: regular + premium_pack: + app_store_product_id: com.meeyao.qianwen.premium_pack + credits: 415 + type: regular diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index d2982c9..69dbe90 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .agent_chat_message import AgentChatMessage from .agent_chat_session import AgentChatSession from .anonymous_session_snapshot import AnonymousSessionSnapshot +from .apple_iap_transaction import AppleIapTransaction from .auth_user import AuthUser from .invite_code import InviteCode from .llm import Llm @@ -20,6 +21,7 @@ __all__ = [ "AgentChatMessage", "AgentChatSession", "AnonymousSessionSnapshot", + "AppleIapTransaction", "AuthUser", "InviteCode", "Llm", diff --git a/backend/src/models/apple_iap_transaction.py b/backend/src/models/apple_iap_transaction.py new file mode 100644 index 0000000..583d03e --- /dev/null +++ b/backend/src/models/apple_iap_transaction.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Index, + JSON, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class AppleIapTransaction(TimestampMixin, Base): + __tablename__ = "apple_iap_transactions" + __table_args__ = ( + CheckConstraint( + "environment in ('Sandbox', 'Production')", + name="ck_apple_iap_transactions_environment", + ), + CheckConstraint( + "status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", + name="ck_apple_iap_transactions_status", + ), + UniqueConstraint( + "transaction_id", name="uq_apple_iap_transactions_transaction_id" + ), + UniqueConstraint( + "ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id" + ), + Index( + "ix_apple_iap_transactions_user_created_at", + "user_id", + text("created_at DESC"), + ), + Index( + "ix_apple_iap_transactions_status_updated_at", + "status", + text("updated_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + product_code: Mapped[str] = mapped_column(String(32), nullable=False) + app_store_product_id: Mapped[str] = mapped_column(String(128), nullable=False) + transaction_id: Mapped[str] = mapped_column(String(64), nullable=False) + original_transaction_id: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + web_order_line_item_id: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + environment: Mapped[str] = mapped_column(String(16), nullable=False) + bundle_id: Mapped[str] = mapped_column(String(128), nullable=False) + app_account_token: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + purchase_date: Mapped[str] = mapped_column( + Text, + nullable=False, + ) + revocation_date: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(24), nullable=False) + credits: Mapped[int] = mapped_column(BigInteger, nullable=False) + currency: Mapped[str | None] = mapped_column(String(8), nullable=True) + price_milliunits: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + ledger_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + signed_transaction_info: Mapped[str] = mapped_column(Text, nullable=False) + apple_payload_json: Mapped[dict[str, object]] = mapped_column( + "apple_payload", + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) + failure_code: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/backend/src/models/points_audit_ledger.py b/backend/src/models/points_audit_ledger.py index c93e9f5..6b218df 100644 --- a/backend/src/models/points_audit_ledger.py +++ b/backend/src/models/points_audit_ledger.py @@ -36,11 +36,11 @@ class PointsAuditLedger(TimestampMixin, Base): name="ck_points_audit_ledger_balance_after_non_negative", ), CheckConstraint( - "change_type in ('register', 'consume', 'grant', 'adjust')", + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_audit_ledger_change_type", ), CheckConstraint( - "biz_type is null or biz_type = 'chat'", + "biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_audit_ledger_biz_type", ), CheckConstraint( diff --git a/backend/src/models/points_ledger.py b/backend/src/models/points_ledger.py index 5e8c9af..aa546c1 100644 --- a/backend/src/models/points_ledger.py +++ b/backend/src/models/points_ledger.py @@ -29,21 +29,22 @@ class PointsLedger(TimestampMixin, Base): "balance_after >= 0", name="ck_points_ledger_balance_after_non_negative" ), CheckConstraint( - "change_type in ('register', 'consume', 'grant', 'adjust')", + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_ledger_change_type", ), CheckConstraint( - "biz_type is null or biz_type = 'chat'", + "biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_ledger_biz_type", ), CheckConstraint( - "((change_type = 'register' and biz_type is null and biz_id is null) or " - "(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))", + "((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or " + "(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or " + "(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", name="ck_points_ledger_biz_binding", ), CheckConstraint( - "((change_type in ('register', 'grant') and direction = 1) or " - "(change_type = 'consume' and direction = -1) or " + "((change_type in ('register', 'purchase') and direction = 1) or " + "(change_type in ('consume', 'refund') and direction = -1) or " "(change_type = 'adjust' and direction in (1, -1)))", name="ck_points_ledger_direction_by_change_type", ), @@ -72,10 +73,26 @@ class PointsLedger(TimestampMixin, Base): ), CheckConstraint( "(change_type <> 'adjust' or (" - "(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " - "coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))", + "(metadata ? 'ext') and (metadata->'ext' ? 'reason') and " + "coalesce(metadata #>> '{ext,reason}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape", ), + CheckConstraint( + "(change_type not in ('purchase', 'refund') or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and " + "(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and " + "coalesce(metadata #>> '{ext,source}', '') <> '' and " + "coalesce(metadata #>> '{ext,platform}', '') <> '' and " + "coalesce(metadata #>> '{ext,product_code}', '') <> '' and " + "coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", + name="ck_points_ledger_metadata_payment_shape", + ), + CheckConstraint( + "(change_type <> 'refund' or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and " + "coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", + name="ck_points_ledger_metadata_refund_shape", + ), UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"), Index("ix_points_ledger_user_created_at", "user_id", text("created_at DESC")), Index("ix_points_ledger_biz_type_biz_id", "biz_type", "biz_id"), diff --git a/backend/src/schemas/domain/points.py b/backend/src/schemas/domain/points.py index 0c7f81f..b78d63a 100644 --- a/backend/src/schemas/domain/points.py +++ b/backend/src/schemas/domain/points.py @@ -43,26 +43,26 @@ class ConsumeLedgerMetadata(PointsLedgerMetadataBase): charge: PointsChargeSnapshot -class GrantLedgerMetadata(PointsLedgerMetadataBase): - charge: PointsChargeSnapshot | None = None - - class AdjustLedgerMetadata(PointsLedgerMetadataBase): charge: PointsChargeSnapshot | None = None @model_validator(mode="after") - def validate_ticket(self) -> "AdjustLedgerMetadata": - ticket_id = self.ext.get("ticket_id") - if not isinstance(ticket_id, str) or not ticket_id.strip(): - raise ValueError("ext.ticket_id is required for adjust") + def validate_reason(self) -> "AdjustLedgerMetadata": + reason = self.ext.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError("ext.reason is required for adjust") return self +class PurchaseLedgerMetadata(PointsLedgerMetadataBase): + pass + + PointsLedgerMetadata = ( RegisterLedgerMetadata | ConsumeLedgerMetadata - | GrantLedgerMetadata | AdjustLedgerMetadata + | PurchaseLedgerMetadata ) @@ -75,8 +75,6 @@ def parse_points_ledger_metadata( return RegisterLedgerMetadata.model_validate(payload) if change_type == PointsChangeType.CONSUME: return ConsumeLedgerMetadata.model_validate(payload) - if change_type == PointsChangeType.GRANT: - return GrantLedgerMetadata.model_validate(payload) return AdjustLedgerMetadata.model_validate(payload) @@ -114,17 +112,29 @@ class ApplyPointsChangeCommand(BaseModel): raise ValueError("consume must use direction=-1 and chat binding") return self - if self.change_type == PointsChangeType.GRANT: - if ( - self.direction != 1 - or self.biz_type != PointsBizType.CHAT - or self.biz_id is None - ): - raise ValueError("grant must use direction=1 and chat binding") + if self.change_type == PointsChangeType.ADJUST: + if self.biz_type is not None or self.biz_id is not None: + raise ValueError("adjust must not have biz binding") + return self + + if self.change_type == PointsChangeType.PURCHASE: + if ( + self.direction != 1 + or self.biz_type != PointsBizType.PAYMENT + or self.biz_id is None + ): + raise ValueError("purchase must use direction=1 and payment binding") + return self + + if self.change_type == PointsChangeType.REFUND: + if ( + self.direction != -1 + or self.biz_type != PointsBizType.PAYMENT + or self.biz_id is None + ): + raise ValueError("refund must use direction=-1 and payment binding") return self - if self.biz_type != PointsBizType.CHAT or self.biz_id is None: - raise ValueError("adjust must use chat binding") return self diff --git a/backend/src/schemas/enums.py b/backend/src/schemas/enums.py index a87615a..c8b9ba7 100644 --- a/backend/src/schemas/enums.py +++ b/backend/src/schemas/enums.py @@ -69,12 +69,14 @@ class SessionType(str, Enum): class PointsChangeType(str, Enum): REGISTER = "register" CONSUME = "consume" - GRANT = "grant" ADJUST = "adjust" + PURCHASE = "purchase" + REFUND = "refund" class PointsBizType(str, Enum): CHAT = "chat" + PAYMENT = "payment" class PointsOperatorType(str, Enum): diff --git a/backend/src/v1/payments/__init__.py b/backend/src/v1/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/v1/payments/apple_verifier.py b/backend/src/v1/payments/apple_verifier.py new file mode 100644 index 0000000..184d4f0 --- /dev/null +++ b/backend/src/v1/payments/apple_verifier.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import base64 +import hashlib +import logging +from dataclasses import dataclass +from typing import Any + +import jwt +from cryptography.x509 import load_der_x509_certificate +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +logger = logging.getLogger(__name__) + +_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey) + +_APPLE_ROOT_CA_G3_FINGERPRINT = ( + "0e429e09b3c0da64e87f0a659a6a40ac08dde5e1b115cca0e3a8f6a5" +) + + +@dataclass(frozen=True) +class VerifiedTransaction: + transaction_id: str + original_transaction_id: str + web_order_line_item_id: str | None + bundle_id: str + product_id: str + purchase_date: int + revocation_date: int | None + environment: str + app_account_token: str | None + raw_payload: dict[str, Any] + + +@dataclass(frozen=True) +class VerificationError: + code: str + detail: str + + +class AppleJwsVerifier: + def verify_signed_transaction( + self, + signed_transaction_info: str, + *, + expected_bundle_id: str, + expected_product_id: str, + expected_environment: str, + ) -> VerifiedTransaction | VerificationError: + try: + unverified_header = jwt.get_unverified_header(signed_transaction_info) + except jwt.exceptions.DecodeError: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Failed to decode JWS header", + ) + + x5c_raw = unverified_header.get("x5c") + if not x5c_raw or not isinstance(x5c_raw, list) or len(x5c_raw) < 3: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="JWS x5c chain missing or incomplete", + ) + + x5c: list[str] = x5c_raw + + root_der = base64.b64decode(x5c[-1]) + root_fingerprint = hashlib.sha1(root_der).hexdigest().lower() + if root_fingerprint != _APPLE_ROOT_CA_G3_FINGERPRINT: + logger.warning( + "Apple root cert fingerprint mismatch: expected=%s got=%s", + _APPLE_ROOT_CA_G3_FINGERPRINT, + root_fingerprint, + ) + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Apple root certificate fingerprint mismatch", + ) + + chain_error = self._verify_cert_chain_issuer_subject(x5c) + if chain_error is not None: + return chain_error + + cert_der = base64.b64decode(x5c[0]) + cert = load_der_x509_certificate(cert_der) + public_key = cert.public_key() + if not isinstance(public_key, _ALLOWED_KEY_TYPES): + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Unsupported certificate key type", + ) + + try: + payload: dict[str, Any] = jwt.decode( + signed_transaction_info, + public_key, + algorithms=["ES256"], + options={ + "verify_exp": False, + "verify_aud": False, + "verify_iss": False, + "verify_sub": False, + }, + ) + except jwt.exceptions.InvalidSignatureError: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="JWS signature verification failed", + ) + except jwt.exceptions.DecodeError: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="JWS payload decode failed", + ) + + bundle_id: str = str(payload.get("bundleId", "")) + if bundle_id != expected_bundle_id: + return VerificationError( + code="PAYMENT_PRODUCT_MISMATCH", + detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}", + ) + + product_id: str = str(payload.get("productId", "")) + if product_id != expected_product_id: + return VerificationError( + code="PAYMENT_PRODUCT_MISMATCH", + detail=f"productId mismatch: expected={expected_product_id} got={product_id}", + ) + + environment: str = str(payload.get("environment", "")) + if environment not in ("Sandbox", "Production"): + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail=f"Invalid environment: {environment}", + ) + + if environment != expected_environment: + return VerificationError( + code="PAYMENT_ENVIRONMENT_MISMATCH", + detail=f"Environment mismatch: expected={expected_environment} got={environment}", + ) + + revocation_date_raw = payload.get("revocationDate") + revocation_date: int | None = ( + int(revocation_date_raw) if revocation_date_raw is not None else None + ) + if revocation_date is not None and revocation_date > 0: + return VerificationError( + code="PAYMENT_TRANSACTION_REVOKED", + detail="Transaction has been revoked", + ) + + transaction_id = str(payload.get("transactionId", "")) + original_transaction_id = str(payload.get("originalTransactionId", "")) + web_order_line_item_id_raw = payload.get("webOrderLineItemId") + purchase_date = int(payload.get("purchaseDate", 0)) + app_account_token_raw = payload.get("appAccountToken") + + if not transaction_id: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Missing transactionId in payload", + ) + + return VerifiedTransaction( + transaction_id=transaction_id, + original_transaction_id=original_transaction_id, + web_order_line_item_id=( + str(web_order_line_item_id_raw) if web_order_line_item_id_raw else None + ), + bundle_id=bundle_id, + product_id=product_id, + purchase_date=purchase_date, + revocation_date=revocation_date, + environment=environment, + app_account_token=( + str(app_account_token_raw) if app_account_token_raw else None + ), + raw_payload=payload, + ) + + def _verify_cert_chain_issuer_subject( + self, x5c: list[str] + ) -> VerificationError | None: + certs = [] + for i, b64_der in enumerate(x5c): + der = base64.b64decode(b64_der) + certs.append(load_der_x509_certificate(der)) + + for i in range(len(certs) - 1): + child = certs[i] + parent = certs[i + 1] + if child.issuer != parent.subject: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail=f"Certificate chain issuer/subject mismatch at index {i}", + ) + + return None diff --git a/backend/src/v1/payments/dependencies.py b/backend/src/v1/payments/dependencies.py new file mode 100644 index 0000000..f7b6915 --- /dev/null +++ b/backend/src/v1/payments/dependencies.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from v1.payments.apple_verifier import AppleJwsVerifier +from v1.payments.repository import PaymentRepository +from v1.payments.service import PaymentService +from v1.points.repository import PointsRepository + + +def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentService: + payment_repo = PaymentRepository(session) + points_repo = PointsRepository(session) + verifier = AppleJwsVerifier() + return PaymentService( + payment_repo=payment_repo, + points_repo=points_repo, + verifier=verifier, + ) diff --git a/backend/src/v1/payments/repository.py b/backend/src/v1/payments/repository.py new file mode 100644 index 0000000..0da10dc --- /dev/null +++ b/backend/src/v1/payments/repository.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from models.apple_iap_transaction import AppleIapTransaction +from models.register_bonus_claims import RegisterBonusClaims +from models.user_points import UserPoints + + +class PaymentRepository: + def __init__(self, session: AsyncSession) -> None: + self._session: AsyncSession = session + + async def get_or_create_user_points_for_update( + self, *, user_id: UUID + ) -> UserPoints: + insert_stmt = ( + insert(UserPoints) + .values(user_id=user_id) + .on_conflict_do_nothing(index_elements=[UserPoints.user_id]) + ) + _ = await self._session.execute(insert_stmt) + + stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update() + return (await self._session.execute(stmt)).scalar_one() + + async def get_user_points_for_update(self, *, user_id: UUID) -> UserPoints | None: + stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update() + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_transaction_by_transaction_id( + self, *, transaction_id: str + ) -> AppleIapTransaction | None: + stmt = select(AppleIapTransaction).where( + AppleIapTransaction.transaction_id == transaction_id + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: + self._session.add(transaction) + await self._session.flush() + + async def get_register_bonus_claim( + self, *, email_hash: str + ) -> RegisterBonusClaims | None: + stmt = ( + select(RegisterBonusClaims) + .where(RegisterBonusClaims.email_hash == email_hash) + .limit(1) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def upsert_register_bonus_claim_for_starter_pack( + self, + *, + email_hash: str, + user_email_snapshot: str, + first_user_id_snapshot: UUID, + ) -> RegisterBonusClaims: + claim = await self.get_register_bonus_claim(email_hash=email_hash) + if claim is not None: + claim.has_purchased_starter_pack = True + await self._session.flush() + return claim + + insert_stmt = ( + insert(RegisterBonusClaims) + .values( + email_hash=email_hash, + user_email_snapshot=user_email_snapshot, + first_user_id_snapshot=first_user_id_snapshot, + grant_event_id=f"starter_pack_purchase:{email_hash[:16]}", + has_purchased_starter_pack=True, + ) + .on_conflict_do_nothing(index_elements=[RegisterBonusClaims.email_hash]) + ) + _ = await self._session.execute(insert_stmt) + claim = await self.get_register_bonus_claim(email_hash=email_hash) + if claim is None: + raise RuntimeError("Failed to upsert register bonus claim") + return claim diff --git a/backend/src/v1/payments/router.py b/backend/src/v1/payments/router.py new file mode 100644 index 0000000..f84d9fe --- /dev/null +++ b/backend/src/v1/payments/router.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from core.auth.models import CurrentUser +from v1.payments.dependencies import get_payment_service +from v1.payments.schemas import ( + AppleServerNotificationRequest, + VerifyTransactionRequest, + VerifyTransactionResponse, +) +from v1.payments.service import PaymentService +from v1.users.dependencies import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/payments", tags=["payments"]) + + +@router.post( + "/apple/transactions/verify", + response_model=VerifyTransactionResponse, +) +async def verify_apple_transaction( + request: VerifyTransactionRequest, + service: Annotated[PaymentService, Depends(get_payment_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> VerifyTransactionResponse: + return await service.verify_and_grant( + user_id=current_user.id, + user_email=current_user.email or "", + request=request, + ) + + +@router.post("/apple/notifications", status_code=200) +async def handle_apple_server_notification( + request: AppleServerNotificationRequest, + service: Annotated[PaymentService, Depends(get_payment_service)], +) -> Response: + await service.handle_server_notification(signed_payload=request.signed_payload) + return Response(status_code=200) diff --git a/backend/src/v1/payments/schemas.py b/backend/src/v1/payments/schemas.py new file mode 100644 index 0000000..8411734 --- /dev/null +++ b/backend/src/v1/payments/schemas.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class VerifyTransactionRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + product_code: str = Field(alias="productCode", min_length=1, max_length=32) + app_store_product_id: str = Field( + alias="appStoreProductId", min_length=1, max_length=128 + ) + transaction_id: str = Field(alias="transactionId", min_length=1, max_length=64) + signed_transaction_info: str = Field( + alias="signedTransactionInfo", min_length=1 + ) + app_account_token: UUID | None = Field( + alias="appAccountToken", default=None + ) + + +class VerifyTransactionResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + status: Literal["granted", "already_granted"] + product_code: str = Field(alias="productCode") + transaction_id: str = Field(alias="transactionId") + credits_added: int = Field(alias="creditsAdded", ge=0) + new_balance: int = Field(alias="newBalance", ge=0) + ledger_event_id: str = Field(alias="ledgerEventId") + + +class AppleNotificationPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + notification_type: str = Field(alias="notificationType", default="") + subtype: str | None = Field(alias="subtype", default=None) + signed_payload: str = Field(alias="signedPayload", default="") + + +class AppleServerNotificationRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + signed_payload: str = Field(alias="signedPayload", default="") diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py new file mode 100644 index 0000000..e9032bc --- /dev/null +++ b/backend/src/v1/payments/service.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import yaml + +from core.config.settings import config +from core.http.errors import ApiProblemError, problem_payload +from models.apple_iap_transaction import AppleIapTransaction +from schemas.domain.points import ( + ApplyPointsChangeCommand, + PurchaseLedgerMetadata, +) +from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType +from v1.payments.apple_verifier import ( + AppleJwsVerifier, + VerificationError, + VerifiedTransaction, +) +from v1.payments.repository import PaymentRepository +from v1.payments.schemas import VerifyTransactionRequest, VerifyTransactionResponse +from v1.points.repository import PointsRepository + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ProductMapping: + app_store_product_id: str + credits: int + type: str + + +_product_mappings_cache: dict[str, ProductMapping] | None = None + + +def _load_product_mappings() -> dict[str, ProductMapping]: + global _product_mappings_cache + if _product_mappings_cache is not None: + return _product_mappings_cache + + mapping_path = ( + Path(__file__).parent.parent.parent + / "core/config/static/packages/mapping.yaml" + ) + with mapping_path.open("r", encoding="utf-8") as f: + raw: Any = yaml.safe_load(f) or {} + + mappings: dict[str, ProductMapping] = {} + product_mappings: Any = raw.get("product_mappings", {}) + for code, entry in product_mappings.items(): + mappings[str(code)] = ProductMapping( + app_store_product_id=str(entry["app_store_product_id"]), + credits=int(entry["credits"]), + type=str(entry["type"]), + ) + + _product_mappings_cache = mappings + return mappings + + +def clear_product_mappings_cache() -> None: + global _product_mappings_cache + _product_mappings_cache = None + + +class PaymentService: + def __init__( + self, + *, + payment_repo: PaymentRepository, + points_repo: PointsRepository, + verifier: AppleJwsVerifier, + ) -> None: + self._payment_repo: PaymentRepository = payment_repo + self._points_repo: PointsRepository = points_repo + self._verifier: AppleJwsVerifier = verifier + + async def verify_and_grant( + self, + *, + user_id: UUID, + user_email: str, + request: VerifyTransactionRequest, + ) -> VerifyTransactionResponse: + mappings = _load_product_mappings() + product_mapping = mappings.get(request.product_code) + if product_mapping is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PAYMENT_PRODUCT_NOT_FOUND", + detail=f"Product not found: {request.product_code}", + ), + ) + + if request.app_store_product_id != product_mapping.app_store_product_id: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_PRODUCT_MISMATCH", + detail="appStoreProductId does not match backend mapping", + ), + ) + + expected_bundle_id = config.apple_iap.bundle_id + expected_environment = "Sandbox" if config.runtime.environment != "prod" else "Production" + result = self._verifier.verify_signed_transaction( + request.signed_transaction_info, + expected_bundle_id=expected_bundle_id, + expected_product_id=product_mapping.app_store_product_id, + expected_environment=expected_environment, + ) + + if isinstance(result, VerificationError): + status_code = 422 + if result.code == "PAYMENT_TRANSACTION_REVOKED": + status_code = 409 + elif result.code == "PAYMENT_PRODUCT_MISMATCH": + status_code = 422 + raise ApiProblemError( + status_code=status_code, + detail=problem_payload(code=result.code, detail=result.detail), + ) + + verified: VerifiedTransaction = result + + if str(verified.transaction_id) != request.transaction_id: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_TRANSACTION_INVALID", + detail="transactionId does not match verified payload", + ), + ) + + existing = await self._payment_repo.get_transaction_by_transaction_id( + transaction_id=verified.transaction_id + ) + + if existing is not None: + if existing.user_id == user_id and existing.status == "granted": + account = await self._payment_repo.get_or_create_user_points_for_update( + user_id=user_id + ) + return VerifyTransactionResponse( + status="already_granted", + productCode=request.product_code, + transactionId=verified.transaction_id, + creditsAdded=0, + newBalance=int(account.balance), + ledgerEventId=existing.ledger_event_id or "", + ) + if existing.user_id != user_id: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_TRANSACTION_CONFLICT", + detail="Transaction belongs to another user", + ), + ) + if existing.status in ("refunded", "refunded_insufficient", "revoked"): + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_TRANSACTION_REVOKED", + detail="Transaction has been refunded or revoked", + ), + ) + + is_starter = product_mapping.type == "starter" + normalized_email = user_email.strip().lower() + email_hash = ( + self._build_email_hash(normalized_email) if normalized_email else None + ) + + if is_starter: + if not email_hash: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Email required for starter pack purchase", + ), + ) + claim = await self._payment_repo.get_register_bonus_claim( + email_hash=email_hash + ) + if claim is not None and claim.has_purchased_starter_pack: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Starter pack already purchased for this email", + ), + ) + + transaction_record = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code=request.product_code, + app_store_product_id=product_mapping.app_store_product_id, + transaction_id=verified.transaction_id, + original_transaction_id=verified.original_transaction_id, + web_order_line_item_id=verified.web_order_line_item_id, + environment=verified.environment, + bundle_id=verified.bundle_id, + app_account_token=request.app_account_token, + purchase_date=str(verified.purchase_date), + revocation_date=( + str(verified.revocation_date) if verified.revocation_date else None + ), + status="verified", + credits=product_mapping.credits, + currency=None, + price_milliunits=None, + signed_transaction_info=request.signed_transaction_info, + apple_payload_json=verified.raw_payload, + ) + + await self._payment_repo.insert_transaction(transaction=transaction_record) + + account = await self._payment_repo.get_or_create_user_points_for_update( + user_id=user_id + ) + + credits = product_mapping.credits + event_id = f"payment.apple_iap:{verified.transaction_id}" + balance = int(account.balance) + new_balance = balance + credits + + account.balance = new_balance + account.lifetime_earned = int(account.lifetime_earned) + credits + account.version = int(account.version) + 1 + + metadata = PurchaseLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=event_id, + ext={ + "source": "apple_iap", + "platform": "ios", + "product_code": request.product_code, + "app_store_product_id": product_mapping.app_store_product_id, + "transaction_id": verified.transaction_id, + "original_transaction_id": verified.original_transaction_id, + "environment": verified.environment, + "apple_iap_transaction_id": str(transaction_record.id), + }, + ) + + ledger_command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.PURCHASE, + biz_type=PointsBizType.PAYMENT, + biz_id=transaction_record.id, + event_id=event_id, + amount=credits, + direction=1, + operator_id=None, + metadata=metadata, + ) + await self._points_repo.append_ledger( + command=ledger_command, + balance_after=new_balance, + ) + + transaction_record.status = "granted" + transaction_record.ledger_event_id = event_id + + if is_starter and email_hash and normalized_email: + _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( + email_hash=email_hash, + user_email_snapshot=normalized_email, + first_user_id_snapshot=user_id, + ) + + return VerifyTransactionResponse( + status="granted", + productCode=request.product_code, + transactionId=verified.transaction_id, + creditsAdded=credits, + newBalance=new_balance, + ledgerEventId=event_id, + ) + + @staticmethod + def _build_email_hash(normalized_email: str) -> str: + key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip() + digest = hmac.new( + key=key.encode("utf-8"), + msg=normalized_email.encode("utf-8"), + digestmod=hashlib.sha256, + ) + return digest.hexdigest() + + async def process_refund( + self, + *, + transaction_id: str, + refund_reason: str = "CUSTOMER_REQUEST", + ) -> None: + txn = await self._payment_repo.get_transaction_by_transaction_id( + transaction_id=transaction_id + ) + if txn is None: + logger.warning("Refund requested for unknown transaction: %s", transaction_id) + return + + if txn.status not in ("granted",): + logger.info( + "Refund skipped: transaction %s status=%s", + transaction_id, + txn.status, + ) + return + + user_id = txn.user_id + credits = txn.credits + + account = await self._payment_repo.get_user_points_for_update(user_id=user_id) + if account is None: + logger.warning( + "Refund failed: no user_points for user %s on transaction %s", + user_id, + transaction_id, + ) + txn.status = "failed" + txn.failure_code = "USER_POINTS_NOT_FOUND" + return + + balance = int(account.balance) + + if balance < credits: + refund_amount = balance + txn.status = "refunded_insufficient" + txn.failure_code = "INSUFFICIENT_BALANCE" + logger.warning( + "Refund insufficient balance: user=%s credits=%d balance=%d txn=%s", + user_id, + credits, + balance, + transaction_id, + ) + else: + refund_amount = credits + txn.status = "refunded" + + new_balance = balance - refund_amount + account.balance = new_balance + account.lifetime_earned = int(account.lifetime_earned) - refund_amount + account.version = int(account.version) + 1 + + refund_event_id = f"refund.apple_iap:{transaction_id}" + original_event_id = txn.ledger_event_id or f"payment.apple_iap:{transaction_id}" + + metadata = PurchaseLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=refund_event_id, + ext={ + "source": "apple_iap", + "platform": "ios", + "product_code": txn.product_code, + "app_store_product_id": txn.app_store_product_id, + "transaction_id": transaction_id, + "original_transaction_id": txn.original_transaction_id or "", + "environment": txn.environment, + "apple_iap_transaction_id": str(txn.id), + "original_event_id": original_event_id, + "refund_reason": refund_reason, + "overdue_amount": credits - refund_amount, + }, + ) + + if refund_amount > 0: + ledger_command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.REFUND, + biz_type=PointsBizType.PAYMENT, + biz_id=txn.id, + event_id=refund_event_id, + amount=refund_amount, + direction=-1, + operator_id=None, + metadata=metadata, + ) + await self._points_repo.append_ledger( + command=ledger_command, + balance_after=new_balance, + ) + + txn.ledger_event_id = refund_event_id + + logger.info( + "Refund processed: txn=%s user=%s refund_amount=%d new_balance=%d status=%s", + transaction_id, + user_id, + refund_amount, + new_balance, + txn.status, + ) + + async def handle_server_notification(self, *, signed_payload: str) -> None: + if not signed_payload: + logger.warning("Empty Apple server notification payload") + return + + try: + import jwt as pyjwt + + parts = signed_payload.split(".") + if len(parts) < 2: + logger.warning("Malformed Apple notification signed_payload") + return + + payload_bytes = parts[1] + "=" * (-len(parts[1]) % 4) + import base64 + + decoded = base64.urlsafe_b64decode(payload_bytes) + import json + + notification_data: Any = json.loads(decoded) + except Exception: + logger.exception("Failed to decode Apple server notification payload") + return + + notification_type = str(notification_data.get("notificationType", "")) + subtype = notification_data.get("subtype") + + signed_transaction = notification_data.get("data", {}).get( + "signedTransactionInfo", "" + ) + + transaction_id: str | None = None + if signed_transaction: + try: + txn_parts = signed_transaction.split(".") + if len(txn_parts) >= 2: + txn_payload_bytes = txn_parts[1] + "=" * (-len(txn_parts[1]) % 4) + txn_decoded = base64.urlsafe_b64decode(txn_payload_bytes) + txn_data: Any = json.loads(txn_decoded) + transaction_id = str(txn_data.get("transactionId", "")) + except Exception: + logger.exception("Failed to decode signed transaction from notification") + + logger.info( + "Apple notification received: type=%s subtype=%s transaction_id=%s", + notification_type, + subtype, + transaction_id, + ) + + refund_types = {"REFUND", "REVOKE", "DID_FAIL_TO_RENEW"} + if notification_type in refund_types and transaction_id: + refund_reason = notification_type + if subtype: + refund_reason = f"{notification_type}:{subtype}" + await self.process_refund( + transaction_id=transaction_id, + refund_reason=refund_reason, + ) + return + + if notification_type == "DID_RENEW" and transaction_id: + logger.info( + "Apple DID_RENEW for transaction %s, no action needed", + transaction_id, + ) + return + + logger.info( + "Apple notification type=%s not handled, skipped", + notification_type, + ) diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 03c936c..4591112 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -44,6 +44,7 @@ async def get_available_packages( packages=[ PackageInfo( productCode=pkg.product_code, + appStoreProductId=pkg.app_store_product_id, type=pkg.type.value, price=pkg.price, credits=pkg.credits, diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 77d41f0..4422166 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -19,6 +19,9 @@ class PackageInfo(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) product_code: str = Field(alias="productCode", min_length=1, max_length=128) + app_store_product_id: str = Field( + alias="appStoreProductId", min_length=1, max_length=256 + ) type: Literal["starter", "regular"] price: float = Field(ge=0) credits: int = Field(ge=1) diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 02b5eee..efec1e2 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -23,6 +23,7 @@ from schemas.domain.points import ( from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.domain.points import ApplyPointsChangeCommand from schemas.shared.user import parse_profile_settings +from v1.payments.service import _load_product_mappings from v1.points.repository import PointsRepository if TYPE_CHECKING: @@ -67,6 +68,7 @@ class RegisterBonusResult: @dataclass(frozen=True) class PackageInfoResult: product_code: str + app_store_product_id: str type: PackageType price: float credits: int @@ -461,6 +463,8 @@ class PointsService: email_hash=email_hash ) + product_mappings = _load_product_mappings() + packages: list[PackageInfoResult] = [] for pkg in pkg_config.packages: if not pkg.enabled: @@ -468,9 +472,13 @@ class PointsService: if pkg.type == PackageType.STARTER and has_starter: continue + mapping = product_mappings.get(pkg.product_code) + app_store_product_id = mapping.app_store_product_id if mapping else "" + packages.append( PackageInfoResult( product_code=pkg.product_code, + app_store_product_id=app_store_product_id, type=pkg.type, price=pkg.price, credits=pkg.credits, diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 7317eb3..cb93594 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -7,6 +7,7 @@ from v1.auth.router import router as auth_router from v1.feedback.router import router as feedback_router from v1.invite.router import router as invite_router from v1.notifications.router import router as notifications_router +from v1.payments.router import router as payments_router from v1.points.router import router as points_router from v1.users.router import router as users_router @@ -17,5 +18,6 @@ router.include_router(agent_router) router.include_router(feedback_router) router.include_router(invite_router) router.include_router(notifications_router) +router.include_router(payments_router) router.include_router(points_router) router.include_router(users_router) diff --git a/backend/tests/integration/payments/__init__.py b/backend/tests/integration/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/payments/test_verify_flow.py b/backend/tests/integration/payments/test_verify_flow.py new file mode 100644 index 0000000..66a8b1c --- /dev/null +++ b/backend/tests/integration/payments/test_verify_flow.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +""" +Integration tests for Apple IAP payment verify flow. + +Prerequisite: backend must be running via `./infra/scripts/app.sh restart`. +These tests hit the live HTTP API against the test database. +""" + +import pytest + + +@pytest.mark.asyncio +async def test_verify_endpoint_returns_401_without_auth() -> None: + import httpx + + base_url = "http://localhost:8000" + try: + async with httpx.AsyncClient(base_url=base_url, timeout=5) as client: + response = await client.post( + "/api/v1/payments/apple/transactions/verify", + json={ + "productCode": "basic_pack", + "appStoreProductId": "com.meeyao.qianwen.basic_pack", + "transactionId": "0000000000000001", + "signedTransactionInfo": "fake_jws", + }, + ) + assert response.status_code in (401, 403) + except httpx.ConnectError: + pytest.skip("Backend not running, skipping integration test") + + +@pytest.mark.asyncio +async def test_notifications_endpoint_returns_200() -> None: + import httpx + + base_url = "http://localhost:8000" + try: + async with httpx.AsyncClient(base_url=base_url, timeout=5) as client: + response = await client.post( + "/api/v1/payments/apple/notifications", + json={"signedPayload": ""}, + ) + assert response.status_code == 200 + except httpx.ConnectError: + pytest.skip("Backend not running, skipping integration test") diff --git a/backend/tests/unit/payments/__init__.py b/backend/tests/unit/payments/__init__.py new file mode 100644 index 0000000..5e739bf --- /dev/null +++ b/backend/tests/unit/payments/__init__.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import base64 +import json + +from v1.payments.apple_verifier import ( + AppleJwsVerifier, + VerificationError, + VerifiedTransaction, +) + + +def _make_jws_parts(header: dict[str, object], payload: dict[str, object]) -> tuple[str, str]: + h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode() + p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() + return h, p + + +class TestAppleJwsVerifierInvalidInput: + def test_invalid_header_returns_error(self) -> None: + verifier = AppleJwsVerifier() + result = verifier.verify_signed_transaction( + "not-a-jws", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.basic_pack", + ) + assert isinstance(result, VerificationError) + assert result.code == "PAYMENT_TRANSACTION_INVALID" + assert "decode" in result.detail.lower() or "header" in result.detail.lower() + + def test_missing_x5c_returns_error(self) -> None: + verifier = AppleJwsVerifier() + h, p = _make_jws_parts({"alg": "ES256"}, {"bundleId": "test"}) + result = verifier.verify_signed_transaction( + f"{h}.{p}.fake", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.basic_pack", + ) + assert isinstance(result, VerificationError) + assert "x5c" in result.detail + + def test_short_x5c_returns_error(self) -> None: + verifier = AppleJwsVerifier() + h, p = _make_jws_parts({"alg": "ES256", "x5c": ["one"]}, {"bundleId": "test"}) + result = verifier.verify_signed_transaction( + f"{h}.{p}.fake", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.basic_pack", + ) + assert isinstance(result, VerificationError) + assert "x5c" in result.detail + + def test_issuer_subject_mismatch_returns_error(self) -> None: + verifier = AppleJwsVerifier() + leaf_cert_b64 = base64.b64encode(b"fake_leaf_cert").decode() + intermediate_cert_b64 = base64.b64encode(b"fake_intermediate_cert").decode() + root_cert_b64 = base64.b64encode(b"fake_root_cert").decode() + h, p = _make_jws_parts( + {"alg": "ES256", "x5c": [leaf_cert_b64, intermediate_cert_b64, root_cert_b64]}, + {"bundleId": "com.meeyao.qianwen"}, + ) + result = verifier.verify_signed_transaction( + f"{h}.{p}.fake", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.basic_pack", + ) + assert isinstance(result, VerificationError) + assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail diff --git a/backend/tests/unit/payments/test_payment_service.py b/backend/tests/unit/payments/test_payment_service.py new file mode 100644 index 0000000..3fb48e7 --- /dev/null +++ b/backend/tests/unit/payments/test_payment_service.py @@ -0,0 +1,617 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest + +from core.http.errors import ApiProblemError +from models.apple_iap_transaction import AppleIapTransaction +from models.register_bonus_claims import RegisterBonusClaims +from schemas.domain.points import ApplyPointsChangeCommand +from v1.payments.apple_verifier import VerificationError, VerifiedTransaction +from v1.payments.schemas import VerifyTransactionRequest +from v1.payments.service import PaymentService + + +@dataclass +class _FakeAccount: + balance: int = 0 + frozen_balance: int = 0 + lifetime_earned: int = 0 + lifetime_spent: int = 0 + version: int = 0 + + +class _FakePaymentRepository: + def __init__(self, *, existing_transaction: AppleIapTransaction | None = None) -> None: + self.account = _FakeAccount() + self.existing_transaction = existing_transaction + self.inserted_transactions: list[AppleIapTransaction] = [] + self.claim: RegisterBonusClaims | None = None + self.claim_starter_pack_called: bool = False + + async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount: + return self.account + + async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: + return self.existing_transaction + + async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: + self.inserted_transactions.append(transaction) + + async def get_register_bonus_claim(self, *, email_hash: str) -> RegisterBonusClaims | None: + return self.claim + + async def upsert_register_bonus_claim_for_starter_pack( + self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID + ) -> RegisterBonusClaims: + self.claim_starter_pack_called = True + if self.claim is None: + self.claim = RegisterBonusClaims( + email_hash=email_hash, + user_email_snapshot=user_email_snapshot, + first_user_id_snapshot=first_user_id_snapshot, + grant_event_id="starter_pack_purchase:test", + has_purchased_starter_pack=True, + ) + else: + self.claim.has_purchased_starter_pack = True + return self.claim + + +class _FakePointsRepository: + def __init__(self) -> None: + self.appended_ledger: list[ApplyPointsChangeCommand] = [] + + async def append_ledger(self, *, command: ApplyPointsChangeCommand, balance_after: int) -> None: + self.appended_ledger.append(command) + + +class _FakeVerifier: + def __init__(self, *, result: VerifiedTransaction | VerificationError) -> None: + self._result = result + + def verify_signed_transaction( + self, + signed_transaction_info: str, + *, + expected_bundle_id: str, + expected_product_id: str, + ) -> VerifiedTransaction | VerificationError: + return self._result + + +def _make_verified_transaction( + *, + transaction_id: str = "2000000123456789", + product_id: str = "com.meeyao.qianwen.basic_pack", + environment: str = "Sandbox", +) -> VerifiedTransaction: + return VerifiedTransaction( + transaction_id=transaction_id, + original_transaction_id=transaction_id, + web_order_line_item_id=None, + bundle_id="com.meeyao.qianwen", + product_id=product_id, + purchase_date=1700000000000, + revocation_date=None, + environment=environment, + app_account_token=None, + raw_payload={}, + ) + + +class TestPaymentServiceProductNotFound: + @pytest.mark.asyncio + async def test_raises_product_not_found(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepository(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="nonexistent_pack", + appStoreProductId="com.meeyao.qianwen.nonexistent", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_PRODUCT_NOT_FOUND" + + +class TestPaymentServiceProductMismatch: + @pytest.mark.asyncio + async def test_raises_product_mismatch_when_ids_differ(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepository(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="basic_pack", + appStoreProductId="com.meeyao.qianwen.wrong_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_PRODUCT_MISMATCH" + + +class TestPaymentServiceVerificationFailed: + @pytest.mark.asyncio + async def test_raises_when_verifier_returns_error(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepository(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="bad signature", + ) + ), + ) + request = VerifyTransactionRequest( + productCode="basic_pack", + appStoreProductId="com.meeyao.qianwen.basic_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_TRANSACTION_INVALID" + + +class TestPaymentServiceAlreadyGranted: + @pytest.mark.asyncio + async def test_returns_already_granted_for_same_user(self) -> None: + user_id = uuid4() + existing = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000123456789", + original_transaction_id="2000000123456789", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000123456789", + ) + service = PaymentService( + payment_repo=_FakePaymentRepository(existing_transaction=existing), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="basic_pack", + appStoreProductId="com.meeyao.qianwen.basic_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + result = await service.verify_and_grant( + user_id=user_id, + user_email="test@example.com", + request=request, + ) + assert result.status == "already_granted" + assert result.credits_added == 0 + + +class TestPaymentServiceTransactionConflict: + @pytest.mark.asyncio + async def test_raises_conflict_for_different_user(self) -> None: + existing = AppleIapTransaction( + id=uuid4(), + user_id=uuid4(), + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000123456789", + original_transaction_id="2000000123456789", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000123456789", + ) + service = PaymentService( + payment_repo=_FakePaymentRepository(existing_transaction=existing), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="basic_pack", + appStoreProductId="com.meeyao.qianwen.basic_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_TRANSACTION_CONFLICT" + + +class TestPaymentServiceSuccessfulGrant: + @pytest.mark.asyncio + async def test_grants_credits_for_new_transaction(self) -> None: + payment_repo = _FakePaymentRepository() + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=payment_repo, + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="basic_pack", + appStoreProductId="com.meeyao.qianwen.basic_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + result = await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert result.status == "granted" + assert result.credits_added == 100 + assert result.new_balance == 100 + assert result.ledger_event_id == "payment.apple_iap:2000000123456789" + assert len(points_repo.appended_ledger) == 1 + assert len(payment_repo.inserted_transactions) == 1 + + +class TestPaymentServiceStarterPackIneligible: + @pytest.mark.asyncio + async def test_raises_when_starter_pack_already_purchased(self) -> None: + claim = RegisterBonusClaims( + email_hash="fake_hash", + user_email_snapshot="test@example.com", + first_user_id_snapshot=uuid4(), + grant_event_id="register.bonus:test", + has_purchased_starter_pack=True, + ) + payment_repo = _FakePaymentRepository() + payment_repo.claim = claim + service = PaymentService( + payment_repo=payment_repo, + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=_make_verified_transaction( + product_id="com.meeyao.qianwen.new_user_pack" + ) + ), + ) + request = VerifyTransactionRequest( + productCode="new_user_pack", + appStoreProductId="com.meeyao.qianwen.new_user_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_STARTER_PACK_INELIGIBLE" + + +class TestPaymentServiceStarterPackSuccess: + @pytest.mark.asyncio + async def test_grants_starter_pack_and_updates_claim(self) -> None: + payment_repo = _FakePaymentRepository() + service = PaymentService( + payment_repo=payment_repo, + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=_make_verified_transaction( + product_id="com.meeyao.qianwen.new_user_pack" + ) + ), + ) + request = VerifyTransactionRequest( + productCode="new_user_pack", + appStoreProductId="com.meeyao.qianwen.new_user_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + result = await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert result.status == "granted" + assert result.credits_added == 60 + assert payment_repo.claim_starter_pack_called + + +class _FakeAccountForRefund: + def __init__(self, balance: int = 100, lifetime_earned: int = 100) -> None: + self.balance: int = balance + self.frozen_balance: int = 0 + self.lifetime_earned: int = lifetime_earned + self.lifetime_spent: int = 0 + self.version: int = 1 + + +class _FakePaymentRepoForRefund: + def __init__( + self, + *, + transaction: AppleIapTransaction | None = None, + account: _FakeAccountForRefund | None = None, + ) -> None: + self._transaction = transaction + self.account = account or _FakeAccountForRefund() + self.inserted_transactions: list[AppleIapTransaction] = [] + + async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: + return self._transaction + + async def get_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund: + return self.account + + async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund: + return self.account + + async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: + self.inserted_transactions.append(transaction) + + async def get_register_bonus_claim(self, *, email_hash: str) -> None: + return None + + async def upsert_register_bonus_claim_for_starter_pack( + self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID + ) -> None: + pass + + +class TestProcessRefundUnknownTransaction: + @pytest.mark.asyncio + async def test_skips_silently_for_unknown_transaction(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=None), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="nonexistent") + + +class TestProcessRefundNotGranted: + @pytest.mark.asyncio + async def test_skips_for_non_granted_transaction(self) -> None: + txn = AppleIapTransaction( + id=uuid4(), + user_id=uuid4(), + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000999999999", + original_transaction_id="2000000999999999", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="verified", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ) + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999999") + assert txn.status == "verified" + + +class TestProcessRefundSufficientBalance: + @pytest.mark.asyncio + async def test_deducts_credits_and_writes_refund_ledger(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000999999999", + original_transaction_id="2000000999999999", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000999999999", + ) + account = _FakeAccountForRefund(balance=150, lifetime_earned=200) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999999") + assert txn.status == "refunded" + assert account.balance == 50 + assert account.lifetime_earned == 100 + assert len(points_repo.appended_ledger) == 1 + ledger = points_repo.appended_ledger[0] + assert ledger.change_type.value == "refund" + assert ledger.direction == -1 + assert ledger.amount == 100 + + +class TestProcessRefundInsufficientBalance: + @pytest.mark.asyncio + async def test_deducts_to_zero_and_sets_insufficient_status(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000999999998", + original_transaction_id="2000000999999998", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000999999998", + ) + account = _FakeAccountForRefund(balance=30, lifetime_earned=100) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999998") + assert txn.status == "refunded_insufficient" + assert txn.failure_code == "INSUFFICIENT_BALANCE" + assert account.balance == 0 + assert len(points_repo.appended_ledger) == 1 + ledger = points_repo.appended_ledger[0] + assert ledger.amount == 30 + + +class TestProcessRefundIdempotency: + @pytest.mark.asyncio + async def test_second_refund_is_noop(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000999999997", + original_transaction_id="2000000999999997", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="refunded", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=_FakeAccountForRefund(balance=50)), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999997") + assert len(points_repo.appended_ledger) == 0 + assert txn.status == "refunded" + + +class TestHandleServerNotificationRefund: + @pytest.mark.asyncio + async def test_processes_refund_notification(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="basic_pack", + app_store_product_id="com.meeyao.qianwen.basic_pack", + transaction_id="2000000999999001", + original_transaction_id="2000000999999001", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000999999001", + ) + account = _FakeAccountForRefund(balance=200, lifetime_earned=200) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + + import base64 + import json + + signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001") + notification_payload = json.dumps({ + "notificationType": "REFUND", + "data": {"signedTransactionInfo": signed_txn}, + }) + signed_payload = _make_fake_jws(notification_payload) + + await service.handle_server_notification(signed_payload=signed_payload) + assert txn.status == "refunded" + assert account.balance == 100 + + @pytest.mark.asyncio + async def test_ignores_empty_payload(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.handle_server_notification(signed_payload="") + + @pytest.mark.asyncio + async def test_ignores_non_refund_notification(self) -> None: + import json + + notification_payload = json.dumps({ + "notificationType": "DID_RENEW", + "data": {}, + }) + signed_payload = _make_fake_jws(notification_payload) + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.handle_server_notification(signed_payload=signed_payload) + + +def _make_fake_jws(payload_str: str) -> str: + import base64 + h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode() + p = base64.urlsafe_b64encode(payload_str.encode()).rstrip(b"=").decode() + return f"{h}.{p}.fake_signature" + + +def _make_fake_signed_transaction(transaction_id: str) -> str: + import base64 + import json + + txn_payload = json.dumps({"transactionId": transaction_id}) + h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode() + p = base64.urlsafe_b64encode(txn_payload.encode()).rstrip(b"=").decode() + return f"{h}.{p}.fake_signature" diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index ed7bb1b..58845a9 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -100,6 +100,21 @@ This document is the source of truth for backend RFC7807 `code` values consumed | `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not supported (only jpg/png) | Show supported format hint | | `FEEDBACK_SUBMIT_FAILED` | 500 | Feedback submission failed | Show retry prompt | +## Payment (Apple IAP) + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `PAYMENT_PRODUCT_NOT_FOUND` | 404 | `productCode` does not exist or is not enabled | Refresh packages and show product-unavailable message | +| `PAYMENT_PRODUCT_MISMATCH` | 422 | Client product ID does not match backend/Apple verification result | Block grant and prompt retry | +| `PAYMENT_ENVIRONMENT_MISMATCH` | 422 | Transaction environment (Sandbox/Production) does not match server environment | Show purchase-verification-failed message | +| `PAYMENT_TRANSACTION_INVALID` | 422 | Apple signed transaction invalid, signature verification failed, or payload malformed | Show purchase-verification-failed message | +| `PAYMENT_TRANSACTION_REVOKED` | 409 | Transaction has been revoked or refunded, grant not allowed | Show purchase-unavailable message | +| `PAYMENT_TRANSACTION_CONFLICT` | 409 | Transaction already processed by another user or in conflicting state | Prompt to contact support or refresh balance | +| `PAYMENT_STARTER_PACK_INELIGIBLE` | 409 | Current email identity has already purchased starter pack | Refresh packages and hide starter pack | +| `PAYMENT_APPLE_UNAVAILABLE` | 503 | Apple Server API or certificate fetch unavailable | Show retry-later message; do NOT complete/finish transaction | +| `PAYMENT_GRANT_FAILED` | 500 | Verification succeeded but grant transaction failed | Show retry-later message; retain transaction for compensation | +| `PAYMENT_REFUND_INSUFFICIENT_BALANCE` | 409 | User has insufficient balance for refund clawback | Log for manual review; do not auto-clawback | + ## Global | code | status | meaning | frontend handling | diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index fa1a09c..8782788 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -34,6 +34,25 @@ Protocol verification status: - Billing idempotency key for per-run consume: `chat.run.success:{sha1(session_id:run_id)}`. - Failed/canceled runs do not deduct user points. If real provider cost is observed, audit record is written with `billed_to='platform'`. +## Points Change Types (change_type) + +| Type | Direction | Meaning | biz_type | Description | +|------|-----------|---------|----------|-------------| +| `register` | +1 | 注册奖励 | `null` | 新用户注册赠送积分 | +| `consume` | -1 | 消费扣减 | `chat` | 用户占卜消耗积分 | +| `adjust` | ±1 | 手动调整 | `null` | 系统或管理员手动调整积分,通用调整不绑定业务场景 | +| `purchase` | +1 | 购买入账 | `payment` | 用户支付购买积分 | +| `refund` | -1 | 退款扣回 | `payment` | 退款后扣回积分 | + +## Points Business Types (biz_type) + +| Type | Meaning | Associated change_type | +|------|---------|------------------------| +| `chat` | 聊天/占卜业务 | `consume` | +| `payment` | 支付业务 | `purchase`, `refund` | + +Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`). + ## Table contract ### profiles @@ -62,21 +81,23 @@ Protocol verification status: - PK: `id` - FK: - `user_id -> auth.users.id` (`on delete cascade`) - - `biz_id -> sessions.id` (`on delete restrict`, nullable) + - `biz_id -> sessions.id` (`on delete restrict`, nullable) — only for `biz_type='chat'` - `operator_id -> auth.users.id` (`on delete set null`) - Core fields: `direction`, `amount`, `balance_after`, `change_type`, `biz_type`, `biz_id`, `event_id`, `operator_id`, `metadata`, `created_at`, `updated_at` - Constraints: - `amount > 0` - `direction in (1, -1)` - `balance_after >= 0` - - `change_type in ('register', 'consume', 'grant', 'adjust')` - - `biz_type is null or biz_type='chat'` + - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` + - `biz_type is null or biz_type in ('chat', 'payment')` - biz binding: - `register => biz_type is null and biz_id is null` - - `consume/grant/adjust => biz_type='chat' and biz_id not null` + - `consume => biz_type='chat' and biz_id not null` + - `adjust => biz_type is null and biz_id is null` (通用调整,不绑定业务场景) + - `purchase/refund => biz_type='payment' and biz_id not null` (biz_id references `apple_iap_transactions.id` as logical FK, not database FK) - direction and change_type coupling: - - `register/grant => direction = 1` - - `consume => direction = -1` + - `register/purchase => direction = 1` + - `consume/refund => direction = -1` - `adjust => direction in (1, -1)` - idempotency: `unique (user_id, event_id)` @@ -89,8 +110,8 @@ Protocol verification status: - `amount >= 0` - `direction in (1, 0, -1)` - `balance_after >= 0` - - `change_type in ('register', 'consume', 'grant', 'adjust')` - - `biz_type is null or biz_type='chat'` + - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` + - `biz_type is null or biz_type in ('chat', 'payment')` - `billed_to in ('user', 'platform')` - metadata must be object - idempotency: `unique (event_id)` @@ -141,8 +162,9 @@ JSON constraints: - Per `change_type`: - `register`: no `charge`, and no chat binding (`biz_type/biz_id` both null) - `consume`: requires `charge` object with required fields - - `grant`: no extra metadata shape requirement - - `adjust`: requires `ext.ticket_id` non-empty + - `adjust`: requires `ext.reason` non-empty (通用调整,系统或管理员均可操作,不绑定业务) + - `purchase`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id` + - `refund`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id`, `ext.original_event_id` ## Signup initialization contract @@ -225,9 +247,9 @@ Returns available purchase packages for the current user's region, including sta "currency": "USD", "packages": [ { - "productCode": "new_user_pack_099_60", + "productCode": "new_user_pack", "type": "starter", - "priceUsd": "0.99", + "price": "0.99", "credits": 60, "badge": null, "isStarter": true, @@ -235,9 +257,9 @@ Returns available purchase packages for the current user's region, including sta "sortOrder": 0 }, { - "productCode": "basic_pack_499_100", + "productCode": "basic_pack", "type": "regular", - "priceUsd": "4.99", + "price": "4.99", "credits": 100, "badge": null, "isStarter": false, @@ -252,9 +274,9 @@ Returns available purchase packages for the current user's region, including sta - `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN") - `currency`: ISO 4217 currency code (e.g., "USD") - `packages`: List of available packages - - `productCode`: Unique product identifier + - `productCode`: Unique product identifier (e.g., `new_user_pack`, `basic_pack`, `popular_pack`, `premium_pack`) - `type`: "starter" (new user pack) or "regular" - - `priceUsd`: Price in USD (decimal string) + - `price`: Price in the response currency (decimal string, for display reference only; actual payment uses StoreKit price) - `credits`: Number of credits - `badge`: Optional badge text (e.g., "Popular") - `isStarter`: Whether this is a starter pack @@ -277,16 +299,16 @@ Returns available purchase packages for the current user's region, including sta region: US currency: USD packages: - - product_code: new_user_pack_099_60 + - product_code: new_user_pack type: starter - price_usd: "0.99" + price: "0.99" credits: 60 badge: null sort_order: 0 enabled: true - - product_code: basic_pack_499_100 + - product_code: basic_pack type: regular - price_usd: "4.99" + price: "4.99" credits: 100 badge: null sort_order: 10