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
@@ -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
---