feat: 实现 iOS Apple Pay 内购支付功能

前端:
- 集成 in_app_purchase 插件,实现 IAP 支付流程
- 添加支付模块 (payments/) 处理产品获取、购买、验证
- 积分中心页面集成 Apple Pay 购买入口
- 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面
- 修复欢迎引导页滚动检测阈值问题
- 修复解卦结果页 iOS 侧滑返回手势被阻止的问题
- 邀请码绑定按钮临时禁用(待后端实现)

后端:
- 新增 apple_iap_transactions 表记录交易
- 实现 Apple 服务器端验证 (App Store Server API)
- 支付成功后自动发放积分
- 支持 Sandbox/Production 环境切换
- 添加退款处理和交易状态机

协议:
- 更新积分流水协议,支持 purchase/refund 类型
- 新增 PAYMENT_* 错误码
This commit is contained in:
ZL-Q
2026-04-28 10:45:29 +08:00
parent b453ff7345
commit 87f92987b2
58 changed files with 3741 additions and 336 deletions
+15
View File
@@ -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://<your-domain>/api/v1/payments/apple/notifications
ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL=
@@ -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,下次启动自动重试
- 不可重试错误(4xxcomplete 并暴露 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`,前端保留交易 |
+74 -16
View File
@@ -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 3iOS / 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` 数据一致。
- 网络中断/重复提交不会导致漏发或重复发放。
- 退款/撤销时正确扣回积分,余额不足时正确记录并告警。
- 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。
+40
View File
@@ -0,0 +1,40 @@
# Workspace Index - opencode
> Journal tracking for AI development sessions.
---
## Current Status
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 0
- **Last Active**: -
<!-- @@@/auto:current-status -->
---
## Active Documents
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~0 | Active |
<!-- @@@/auto:active-documents -->
---
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits |
|---|------|-------|---------|
<!-- @@@/auto:session-history -->
---
## Notes
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions
+7
View File
@@ -0,0 +1,7 @@
# Journal - opencode (Part 1)
> AI development session journal
> Started: 2026-04-27
---
+134
View File
@@ -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
}
}
+11
View File
@@ -412,6 +412,15 @@ class _EryaoAppState extends State<EryaoApp> {
});
}
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<EryaoApp> {
_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<EryaoApp> {
onDeleteHistorySession: _handleHistorySessionDeleted,
onLogout: _authBloc.logout,
onDeleteAccount: _deleteAccount,
onBalanceChanged: _handleBalanceChanged,
);
}
+3
View File
@@ -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';
}
@@ -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;
}
@@ -169,7 +169,7 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
final palette = Theme.of(context).extension<AppColorPalette>()!;
final l10n = AppLocalizations.of(context)!;
return PopScope<void>(
canPop: false,
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
@@ -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;
@@ -65,6 +68,7 @@ class HomeScreen extends StatefulWidget {
final Future<void> Function(String threadId) onDeleteHistorySession;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final void Function(int newBalance) onBalanceChanged;
@override
State<HomeScreen> createState() => _HomeScreenState();
@@ -132,6 +136,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
_ProfileTab(
account: widget.account,
userId: widget.userId,
settings: widget.profileSettings,
coinBalance: widget.coinBalance,
inviteRepository: _inviteRepository,
@@ -142,6 +147,7 @@ class _HomeScreenState extends State<HomeScreen> {
onUploadAvatar: widget.onUploadAvatar,
onLogout: widget.onLogout,
onDeleteAccount: widget.onDeleteAccount,
onBalanceChanged: widget.onBalanceChanged,
),
],
),
@@ -561,6 +567,7 @@ class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
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;
@@ -585,11 +594,13 @@ class _ProfileTab extends StatelessWidget {
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
final Future<void> 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,15 +715,24 @@ 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);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_hasCheckedInitialScroll) {
_hasCheckedInitialScroll = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScrollState();
});
}
}
@override
void dispose() {
@@ -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;
}
@@ -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<VerifyTransactionResponse> verifyTransaction(
VerifyTransactionRequest request,
) async {
final json = await _apiClient.postJson(
'/api/v1/payments/apple/transactions/verify',
data: request.toJson(),
);
return VerifyTransactionResponse.fromJson(json);
}
}
@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}
@@ -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<List<PurchaseDetails>>? _subscription;
Map<String, ProductDetails> _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<List<PurchaseDetails>> purchaseUpdated;
purchaseUpdated = _inAppPurchase.purchaseStream;
_subscription = purchaseUpdated.listen(_onPurchaseUpdated);
}
Future<void> loadStoreKitProducts(List<PackageInfo> 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 = <String, ProductDetails>{};
for (final detail in response.productDetails) {
products[detail.id] = detail;
}
_storeKitProducts = products;
}
ProductDetails? getStoreKitProduct(String appStoreProductId) {
return _storeKitProducts[appStoreProductId];
}
Future<bool> 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<PurchaseDetails> purchases) {
for (final purchase in purchases) {
_handlePurchase(purchase);
}
}
Future<void> _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<void> _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();
}
}
@@ -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<String, dynamic> json) {
return PackageInfo(
productCode: _parseProductCode(json['productCode'] as String),
appStoreProductId: json['appStoreProductId'] as String,
type: json['type'] == 'starter'
? PackageType.starter
: PackageType.regular,
@@ -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<CoinCenterScreen> createState() => _CoinCenterScreenState();
@@ -25,6 +39,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
final Logger _logger = getLogger('features.settings.coin_center_screen');
List<PackageInfo>? _packages;
bool _isLoading = true;
AppleIapService? _iapService;
@override
void initState() {
@@ -39,12 +54,26 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
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<CoinCenterScreen> {
}
}
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<void> _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<CoinCenterScreen> {
),
const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsCoinRechargeSection),
..._buildPackageCards(l10n),
..._buildPackageCards(l10n, colors),
],
),
);
}
List<Widget> _buildPackageCards(AppLocalizations l10n) {
List<Widget> _buildPackageCards(AppLocalizations l10n, ColorScheme colors) {
if (_isLoading) {
return [
const Padding(
@@ -140,22 +223,66 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
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<void> _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;
@@ -34,19 +34,11 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return PopScope<ProfileSettingsV1>(
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(_settings),
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
title: Text(l10n.settingsGeneralTitle),
@@ -145,7 +137,6 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
),
],
),
),
);
}
@@ -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),
),
),
],
@@ -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<void>(
builder: (_) => LegalDocumentScreen(
title: legalDocumentTitle(l10n, documents[i]),
assetPath: legalDocumentAssetPath(locale, documents[i]),
),
),
),
),
],
),
],
),
);
}
}
@@ -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;
@@ -44,6 +49,7 @@ class SettingsScreen extends StatefulWidget {
final Future<void> Function() onDeleteAccount;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
final void Function(int newBalance) onBalanceChanged;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@@ -119,35 +125,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<void>(
MaterialPageRoute<void>(
builder: (_) => FeedbackScreen(apiClient: widget.apiClient),
),
),
),
],
),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.person_rounded,
title: l10n.settingsAccountAndDataTitle,
@@ -159,6 +147,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
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<SettingsScreen> {
Future<void> _openCoinCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => CoinCenterScreen(balance: widget.coinBalance),
builder: (_) => CoinCenterScreen(
balance: widget.coinBalance,
userId: widget.userId,
onBalanceChanged: widget.onBalanceChanged,
),
),
);
}
@@ -229,9 +248,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _openLegalCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
void _openLegalDocument(LegalDocumentType type) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => LegalDocumentScreen(
title: legalDocumentTitle(l10n, type),
assetPath: legalDocumentAssetPath(locale, type),
),
),
);
}
@@ -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,6 +489,14 @@ class CoinPackageCard extends StatelessWidget {
],
),
const SizedBox(height: AppSpacing.lg),
if (!isAvailable && unavailableMessage != null)
Text(
unavailableMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.error,
),
)
else
Row(
children: [
Text(
@@ -493,19 +507,22 @@ class CoinPackageCard extends StatelessWidget {
),
const Spacer(),
FilledButton(
onPressed: () {
Toast.show(
context,
l10n.settingsPurchasePending,
type: ToastType.info,
);
},
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: Text(l10n.settingsPurchaseButton),
child: isPurchasing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.onPrimary,
),
)
: Text(l10n.settingsPurchaseButton),
),
],
),
+7 -1
View File
@@ -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"
}
+36
View File
@@ -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
+20
View File
@@ -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';
}
+36
View File
@@ -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 正在處理中,請稍候';
}
+7 -1
View File
@@ -505,5 +505,11 @@
"feedbackContentRequired": "请输入反馈内容",
"feedbackContentTooLong": "反馈内容不能超过500字",
"feedbackTooManyImages": "最多只能上传3张图片",
"feedbackImageTooLarge": "图片大小不能超过5MB"
"feedbackImageTooLarge": "图片大小不能超过5MB",
"paymentSuccess": "购买成功",
"paymentVerifyFailed": "购买验证失败,请稍后重试",
"paymentProductNotFound": "商品暂时不可用",
"paymentStarterPackIneligible": "新手包每位用户仅限购买一次",
"paymentProductUnavailable": "商品暂时不可用",
"paymentPending": "Apple 正在处理中,请稍候"
}
+7 -1
View File
@@ -407,5 +407,11 @@
"feedbackContentRequired": "請輸入回饋內容",
"feedbackContentTooLong": "回饋內容不能超過500字",
"feedbackTooManyImages": "最多只能上傳3張圖片",
"feedbackImageTooLarge": "圖片大小不能超過5MB"
"feedbackImageTooLarge": "圖片大小不能超過5MB",
"paymentSuccess": "購買成功",
"paymentVerifyFailed": "購買驗證失敗,請稍後重試",
"paymentProductNotFound": "商品暫時不可用",
"paymentStarterPackIneligible": "新手包每位用戶僅限購買一次",
"paymentProductUnavailable": "商品暫時不可用",
"paymentPending": "Apple 正在處理中,請稍候"
}
+3
View File
@@ -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:
@@ -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);
});
});
}
@@ -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")
@@ -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)"
)
@@ -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
+13
View File
@@ -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
)
@@ -12,7 +12,7 @@ agents:
enabled_tools: []
- agent_type: worker
llm_model_code: deepseek-chat
llm_model_code: deepseek-v4-flash
status: active
config:
temperature: 0.7
@@ -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
+2
View File
@@ -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",
@@ -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)
+2 -2
View File
@@ -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(
+25 -8
View File
@@ -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"),
+30 -20
View File
@@ -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
+3 -1
View File
@@ -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):
View File
+201
View File
@@ -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
+21
View File
@@ -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,
)
+85
View File
@@ -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
+45
View File
@@ -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)
+47
View File
@@ -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="")
+479
View File
@@ -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,
)
+1
View File
@@ -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,
+3
View File
@@ -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)
+8
View File
@@ -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,
+2
View File
@@ -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)
@@ -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")
+68
View File
@@ -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
@@ -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"
+15
View File
@@ -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 |
@@ -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