chore(task): archive 04-27-feat-ios-apple-pay
This commit is contained in:
@@ -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`, `starter_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
|
||||
starter_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.starter_pack
|
||||
credits: 100
|
||||
type: regular
|
||||
popular_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.popular_pack
|
||||
credits: 210
|
||||
type: regular
|
||||
premium_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.premium_pack
|
||||
credits: 415
|
||||
type: regular
|
||||
```
|
||||
|
||||
### 2.4 测试
|
||||
|
||||
**单元测试**:
|
||||
- `backend/tests/unit/payments/test_apple_verifier.py`
|
||||
- `backend/tests/unit/payments/test_payment_service.py`
|
||||
|
||||
**集成测试**:
|
||||
- `backend/tests/integration/payments/test_verify_flow.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: iOS / Flutter 接入 ✅ 已完成
|
||||
|
||||
### 3.1 依赖添加 ✅
|
||||
|
||||
- [x] `apps/pubspec.yaml` — `in_app_purchase: ^3.2.3` + `in_app_purchase_storekit: ^0.4.8` + `crypto: ^3.0.7`
|
||||
|
||||
### 3.2 后端配合变更 ✅
|
||||
|
||||
- [x] `backend/src/v1/points/schemas.py` — `PackageInfo` 新增 `appStoreProductId` 字段
|
||||
- [x] `backend/src/v1/points/service.py` — `PackageInfoResult` 新增 `app_store_product_id`,`get_available_packages` 从 mapping.yaml 加载映射
|
||||
- [x] `backend/src/v1/points/router.py` — 响应中包含 `appStoreProductId`
|
||||
|
||||
### 3.3 前端目录结构 ✅
|
||||
|
||||
```
|
||||
apps/lib/features/payments/
|
||||
├── data/
|
||||
│ ├── apis/
|
||||
│ │ └── apple_payment_api.dart # 后端 verify 接口
|
||||
│ ├── models/
|
||||
│ │ └── apple_purchase_models.dart # VerifyTransactionRequest/Response
|
||||
│ └── services/
|
||||
│ └── apple_iap_service.dart # StoreKit 集成服务
|
||||
```
|
||||
|
||||
### 3.4 核心实现 ✅
|
||||
|
||||
- [x] `apple_purchase_models.dart` — VerifyTransactionRequest(含 appAccountToken)/ VerifyTransactionResponse(含 newBalance/ledgerEventId)
|
||||
- [x] `apple_payment_api.dart` — POST /api/v1/payments/apple/transactions/verify
|
||||
- [x] `apple_iap_service.dart` — AppleIapService (ChangeNotifier):
|
||||
- 初始化 purchaseStream 监听
|
||||
- queryProductDetails 查询 StoreKit 商品
|
||||
- `buyConsumable` 传递 `applicationUserName`(appAccountToken = userId MD5 hash)
|
||||
- 购买成功 → 发送 JWS + appAccountToken 到后端验证 → completePurchase
|
||||
- 可重试错误(5xx/网络)不 complete,下次启动自动重试
|
||||
- 不可重试错误(4xx)complete 并暴露 ApiProblem 供 UI 映射 l10n
|
||||
- 暴露 `lastApiProblem` 供错误码映射
|
||||
- [x] `package_info.dart` — 新增 `appStoreProductId` 字段
|
||||
- [x] `settings_section_widgets.dart` — CoinPackageCard 新增 `onPurchase`/`isPurchasing`/`isAvailable`/`unavailableMessage`
|
||||
- [x] `coin_center_screen.dart` — 集成 AppleIapService:
|
||||
- 接收 `userId`/`onBalanceChanged` 参数
|
||||
- StoreKit 价格覆盖后端参考价格
|
||||
- 商品不可用时禁用卡片并显示提示
|
||||
- pending 状态显示 "Apple 正在处理中"
|
||||
- 购买成功后调用 `_refreshBalance` 并回调 `onBalanceChanged`
|
||||
- 使用 `mapApiProblemToMessage` 映射错误码到 l10n
|
||||
|
||||
### 3.5 调用链更新 ✅
|
||||
|
||||
- [x] `app.dart` — HomeScreen 传递 `userId` + `onBalanceChanged`
|
||||
- [x] `home_screen.dart` — `_ProfileTab` 传递 `userId` + `onBalanceChanged`
|
||||
- [x] `settings_screen.dart` — 传递 `userId` + `onBalanceChanged` 给 CoinCenterScreen
|
||||
|
||||
### 3.6 错误处理与本地化 ✅
|
||||
|
||||
- [x] `api_problem_mapper.dart` — 6 个支付错误码映射
|
||||
- [x] 3 个 ARB 文件新增 7 个 l10n key:
|
||||
- `paymentSuccess` / `paymentVerifyFailed` / `paymentProductNotFound` / `paymentStarterPackIneligible`
|
||||
- `paymentProductUnavailable` / `paymentPending`
|
||||
- [x] `flutter gen-l10n` 生成通过
|
||||
|
||||
### 3.7 前端测试 ✅
|
||||
|
||||
- [x] `apps/test/features/payments/data/models/apple_purchase_models_test.dart`
|
||||
- VerifyTransactionRequest.toJson 含/不含 appAccountToken
|
||||
- VerifyTransactionResponse 解析 granted / already_granted
|
||||
- [x] 4 个测试全部通过
|
||||
|
||||
### 3.8 验证 ✅
|
||||
|
||||
- [x] `flutter analyze` 0 issues
|
||||
- [x] 后端 basedpyright 0 errors
|
||||
- [x] 后端 16 个单元测试全部通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 联调与发布准备
|
||||
|
||||
### 4.1 App Store Connect 配置
|
||||
|
||||
- [x] 创建 4 个消耗型 IAP 商品(Product ID 已确认与映射表一致)
|
||||
- [x] Product ID 与映射表一致
|
||||
- `com.meeyao.qianwen.new_user_pack` — 新手包
|
||||
- `com.meeyao.qianwen.starter_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`,前端保留交易 |
|
||||
@@ -0,0 +1,12 @@
|
||||
{"role": "system", "content": "You are checking the iOS Apple Pay implementation. Verify all acceptance criteria are met."}
|
||||
{"role": "user", "content": "Run checks for the iOS Apple Pay integration feature."}
|
||||
{"role": "assistant", "content": "Running verification checks..."}
|
||||
{"role": "assistant", "action": "check", "name": "Backend module structure", "command": "ls -la backend/src/v1/payments/", "expected": "Should have __init__.py, schemas.py, service.py, repository.py, router.py, dependencies.py"}
|
||||
{"role": "assistant", "action": "check", "name": "Router registration", "command": "grep -r 'payments_router' backend/src/v1/router.py", "expected": "payments_router should be registered in main v1 router"}
|
||||
{"role": "assistant", "action": "check", "name": "Error codes documentation", "command": "grep -E 'PAYMENT_|PAYMENT_RECEIPT_|PAYMENT_ALREADY_|PAYMENT_PRODUCT_' docs/protocols/common/http-error-codes.md", "expected": "Should have payment-related error codes documented"}
|
||||
{"role": "assistant", "action": "check", "name": "Points integration", "command": "grep -r 'PointsChangeType.PURCHASE' backend/src/", "expected": "Should have PURCHASE change type for points ledger"}
|
||||
{"role": "assistant", "action": "check", "name": "iOS StoreKit module", "command": "ls -la apps/lib/features/payments/", "expected": "Should have payments module with data/ and presentation/ subdirs"}
|
||||
{"role": "assistant", "action": "check", "name": "StoreKit integration", "command": "grep -r 'StoreKit\\|InAppPurchase' apps/lib/features/payments/", "expected": "Should use StoreKit2 for IAP"}
|
||||
{"role": "assistant", "action": "check", "name": "Type checking", "command": "cd backend && uv run basedpyright src/v1/payments/", "expected": "No type errors"}
|
||||
{"role": "assistant", "action": "check", "name": "Lint check", "command": "cd backend && uv run ruff check src/v1/payments/", "expected": "No lint errors"}
|
||||
{"role": "assistant", "action": "check", "name": "Unit tests", "command": "cd backend && uv run pytest tests/unit/test_apple_payment.py -v", "expected": "All tests pass"}
|
||||
@@ -0,0 +1,12 @@
|
||||
{"role": "system", "content": "You are implementing iOS Apple Pay integration. Follow the task.json requirements. Reference existing code patterns from v1/points/ and features/points/."}
|
||||
{"role": "user", "content": "Start implementing the iOS Apple Pay integration feature."}
|
||||
{"role": "assistant", "content": "I'll implement this feature in phases:\n\n**Phase 1: Backend - Apple Receipt Validation**\n1. Create `v1/payments/` module with Apple IAP endpoints\n2. Implement receipt validation with Apple's servers\n3. Integrate with existing PointsService for crediting\n4. Add purchase tracking\n\n**Phase 2: iOS - StoreKit2 Integration**\n1. Create `features/payments/` module in Flutter\n2. Implement StoreKit2 product fetching\n3. Implement purchase flow\n4. Connect to backend validation endpoint\n\n**Phase 3: Error Handling & Testing**\n1. Add error codes to http-error-codes.md\n2. Handle edge cases (network, cancelled, pending)\n3. Add integration tests\n\nLet me start with Phase 1."}
|
||||
{"role": "assistant", "action": "create_file", "path": "backend/src/v1/payments/__init__.py", "content": "from v1.payments.router import router as payments_router\n\n__all__ = [\"payments_router\"]"}
|
||||
{"role": "assistant", "action": "create_file", "path": "backend/src/v1/payments/schemas.py", "content": "from __future__ import annotations\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass ApplePurchaseRequest(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n product_id: str = Field(min_length=1, max_length=100)\n transaction_id: str = Field(min_length=1, max_length=200)\n receipt_data: str = Field(min_length=1)\n\n\nclass ApplePurchaseResponse(BaseModel):\n success: bool\n credits_added: int\n new_balance: int\n transaction_id: str"}
|
||||
{"role": "assistant", "action": "create_file", "path": "backend/src/v1/payments/service.py", "content": "from __future__ import annotations\n\nimport hashlib\nfrom dataclasses import dataclass\nfrom decimal import Decimal\nfrom typing import TYPE_CHECKING\nfrom uuid import UUID\n\nfrom core.config.packages import get_packages_config_for_region
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from schemas.enums import PointsChangeType, PointsBizType
|
||||
from schemas.domain.points import ApplyPointsChangeCommand, PurchaseLedgerMetadata
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
if TYPE_CHECKING:\n pass\n\n\n@dataclass(frozen=True)\nclass AppleReceiptValidationResult:\n valid: bool\n product_id: str\n transaction_id: str\n purchase_date_ms: int\n quantity: int = 1\n\n\nclass ApplePaymentService:\n def __init__(self, repository: PointsRepository) -> None:\n self._repository = repository\n\n async def validate_and_process_purchase(\n self,\n *,\n user_id: UUID,\n user_email: str,\n product_id: str,\n transaction_id: str,\n receipt_data: str,\n country: str = \"US\",\n ) -> ApplePurchaseResponse:\n \"\"\"Validate Apple receipt and credit points.\"\"\"\n # Step 1: Validate receipt with Apple\n validation = await self._validate_receipt_with_apple(\n receipt_data=receipt_data,\n product_id=product_id,\n transaction_id=transaction_id,\n )\n \n if not validation.valid:\n raise ApiProblemError(\n status_code=400,\n detail=problem_payload(\n code=\"PAYMENT_RECEIPT_INVALID\",\n detail=\"Apple receipt validation failed\",\n ),\n )\n\n # Step 2: Check for duplicate transaction\n event_id = f\"apple.iap:{validation.transaction_id}\"\n if await self._repository.has_ledger_event(\n user_id=user_id, event_id=event_id\n ):\n raise ApiProblemError(\n status_code=409,\n detail=problem_payload(\n code=\"PAYMENT_ALREADY_PROCESSED\",\n detail=\"Transaction already processed\",\n ),\n )\n\n # Step 3: Get credits for product\n pkg_config = get_packages_config_for_region(country)\n credits = self._get_credits_for_product(\n packages=pkg_config.packages,\n product_id=product_id,\n )\n \n if credits <= 0:\n raise ApiProblemError(\n status_code=400,\n detail=problem_payload(\n code=\"PAYMENT_PRODUCT_NOT_FOUND\",\n detail=\"Product not found in catalog\",\n ),\n )\n\n # Step 4: Credit points\n account = await self._repository.get_or_create_user_points_for_update(\n user_id=user_id\n )\n account.balance = int(account.balance) + credits\n account.lifetime_earned = int(account.lifetime_earned) + credits\n account.version = int(account.version) + 1\n\n # Step 5: Record ledger\n metadata = PurchaseLedgerMetadata(\n platform=\"apple\",\n product_id=product_id,\n transaction_id=validation.transaction_id,\n )\n command = ApplyPointsChangeCommand(\n user_id=user_id,\n change_type=PointsChangeType.PURCHASE,\n biz_type=PointsBizType.PAYMENT,\n event_id=event_id,\n amount=credits,\n direction=1,\n operator_id=None,\n metadata=metadata,\n )\n await self._repository.append_ledger(\n command=command,\n balance_after=int(account.balance),\n )\n\n return ApplePurchaseResponse(\n success=True,\n credits_added=credits,\n new_balance=int(account.balance),\n transaction_id=validation.transaction_id,\n )\n\n async def _validate_receipt_with_apple(\n self,\n *,\n receipt_data: str,\n product_id: str,\n transaction_id: str,\n ) -> AppleReceiptValidationResult:\n \"\"\"Validate receipt with Apple's servers.\"\"\"\n # TODO: Implement actual Apple server verification\n # For now, return mock validation for development\n # Production: Call https://buy.itunes.apple.com/verifyReceipt\n # Sandbox: Call https://sandbox.itunes.apple.com/verifyReceipt\n return AppleReceiptValidationResult(\n valid=True,\n product_id=product_id,\n transaction_id=transaction_id,\n purchase_date_ms=0,\n )\n\n def _get_credits_for_product(\n self,\n packages: list,\n product_id: str,\n ) -> int:\n \"\"\"Get credits amount for a product ID.\"\"\"\n for pkg in packages:\n if pkg.product_code == product_id and pkg.enabled:\n return pkg.credits\n return 0"}
|
||||
@@ -0,0 +1,549 @@
|
||||
# PRD:iOS Apple 内购接入积分购买
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前应用已经有积分账户、积分流水、注册赠送积分和积分套餐展示能力。用户可以在积分中心看到套餐,但还不能通过 iOS 完成真实购买。本任务目标是在 iOS 端接入 Apple In-App Purchase(IAP),购买成功后由后端验证 Apple 签名交易并给用户发放积分。
|
||||
|
||||
本 PRD 同时明确支付数据如何落库、哪些现有表可以复用、哪些表必须新增,以及 iOS 到后端的完整闭环。
|
||||
|
||||
## 2. 当前事实
|
||||
|
||||
### 2.1 后端现状
|
||||
|
||||
- 积分账户表:`user_points`
|
||||
- 积分业务流水:`points_ledger`
|
||||
- 成本/审计流水:`points_audit_ledger`
|
||||
- 注册奖励与新手包资格表:`register_bonus_claims`
|
||||
- 积分套餐配置:`backend/src/core/config/static/packages/*.yaml`
|
||||
- 当前套餐:`new_user_pack`、`starter_pack`、`popular_pack`、`premium_pack`
|
||||
- 当前套餐接口:`GET /api/v1/points/packages`
|
||||
- 当前积分余额接口:`GET /api/v1/points/balance`
|
||||
|
||||
### 2.2 前端现状
|
||||
|
||||
- Flutter 应用已有积分中心:`apps/lib/features/settings/presentation/screens/coin_center_screen.dart`
|
||||
- 当前积分中心只加载后端套餐并展示卡片,没有真实购买动作。
|
||||
- `apps/pubspec.yaml` 还没有 IAP 相关依赖。
|
||||
|
||||
### 2.3 关键约束
|
||||
|
||||
- Apple 官方文档已标记旧 `verifyReceipt` 端点为 deprecated。新实现不应以 `receipt_data + verifyReceipt` 作为主方案。
|
||||
- StoreKit2 的交易以 Apple 签名的 JWS 形式表达。后端应验证交易签名与交易内容,而不是信任客户端传来的商品、金额或积分数。
|
||||
- 当前 `points_ledger` 的约束只允许 `change_type in ('register', 'consume', 'grant', 'adjust')`,`biz_type` 只允许 `chat`。因此它不能直接合法记录“购买入账”。
|
||||
|
||||
## 3. 需求范围
|
||||
|
||||
### 3.1 必须实现
|
||||
|
||||
- iOS 积分中心展示 App Store Connect 中可购买的消耗型 IAP 商品。
|
||||
- 用户点击套餐后通过 Apple 支付完成购买。
|
||||
- 后端验证 Apple StoreKit2 signed transaction JWS。
|
||||
- 后端保证 Apple transaction 只发放一次积分。
|
||||
- 后端把积分入账到 `user_points`。
|
||||
- 后端把积分入账记录写入 `points_ledger`。
|
||||
- 新手包购买后更新 `register_bonus_claims.has_purchased_starter_pack = true`。
|
||||
- 购买完成后前端刷新积分余额和套餐列表。
|
||||
- 支持购买失败、取消、pending、后端验证失败、网络中断后的恢复处理。
|
||||
|
||||
### 3.2 本期不做
|
||||
|
||||
- Android / Google Play Billing。
|
||||
- 订阅型商品。
|
||||
- 非消耗型商品。
|
||||
- 后台运营发放、优惠券、折扣码。
|
||||
- Web 支付。
|
||||
|
||||
## 4. 产品定义
|
||||
|
||||
### 4.1 商品类型
|
||||
|
||||
所有积分包均为 Apple 消耗型商品(consumable)。
|
||||
|
||||
| 后端 `product_code` | App Store Product ID | 类型 | 积分 | 备注 |
|
||||
|---|---|---|---:|---|
|
||||
| `new_user_pack` | `com.meeyao.qianwen.new_user_pack` | starter | 60 | 每个邮箱身份只允许购买一次 |
|
||||
| `starter_pack` | `com.meeyao.qianwen.starter_pack` | regular | 100 | 可重复购买 |
|
||||
| `popular_pack` | `com.meeyao.qianwen.popular_pack` | regular | 210 | 可重复购买 |
|
||||
| `premium_pack` | `com.meeyao.qianwen.premium_pack` | regular | 415 | 可重复购买 |
|
||||
|
||||
价格以 App Store Connect 为准。后端 YAML 中的价格仅用于非商店展示参考;iOS 支付页最终展示应以 StoreKit 返回的本地化价格为准。
|
||||
|
||||
### 4.2 新手包资格
|
||||
|
||||
- 后端继续使用 `register_bonus_claims.has_purchased_starter_pack` 作为新手包资格的源头。
|
||||
- `GET /api/v1/points/packages` 中,如果该字段为 `true`,不返回 `new_user_pack`。
|
||||
- 后端支付校验时必须再次检查资格,不能只依赖前端是否展示。
|
||||
- 如果用户删除账号后用同一邮箱重新注册,`register_bonus_claims` 的邮箱哈希仍应阻止再次购买新手包。
|
||||
|
||||
## 5. 数据保存方案
|
||||
|
||||
### 5.1 结论
|
||||
|
||||
不能只复用现有数据库表。应采用“复用积分账户和资格表 + 新增 Apple 支付交易表 + 最小扩展积分流水约束”的方案。
|
||||
|
||||
复用现有表:
|
||||
|
||||
- `user_points`:继续保存余额、累计获得、累计消耗。
|
||||
- `register_bonus_claims`:继续保存新手包是否已购买。
|
||||
- `points_ledger`:继续作为用户积分变动的权威流水,但需要扩展枚举和约束来支持购买入账。
|
||||
|
||||
必须新增表:
|
||||
|
||||
- `apple_iap_transactions`:保存 Apple 交易校验、幂等、发放状态、退款/撤销状态和原始签名快照。
|
||||
|
||||
### 5.2 为什么必须新增支付交易表
|
||||
|
||||
`points_ledger` 只回答“用户积分为什么变了”,不适合作为支付交易系统的唯一事实表,原因如下:
|
||||
|
||||
- 支付交易有独立生命周期:`received -> verified -> granted -> failed -> refunded/revoked`。
|
||||
- 支付幂等需要按 Apple `transactionId` 全局去重,而 `points_ledger` 当前唯一约束是 `(user_id, event_id)`。
|
||||
- 支付需要保存 Apple 交易环境、原始交易 ID、签名 JWS、购买时间、撤销时间、bundle id、appAccountToken 等审计字段。
|
||||
- 支付成功但积分入账失败时,需要能补偿重试;单靠 `points_ledger` 无法表达“已验证未发放”。
|
||||
- 后续退款、客服核查、对账、App Store Server Notifications V2 都需要独立交易表承载。
|
||||
|
||||
### 5.3 `apple_iap_transactions` 表设计
|
||||
|
||||
建议新增 Alembic 迁移创建表:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | UUID PK | 内部交易记录 ID |
|
||||
| `user_id` | UUID not null | 当前购买归属用户,来自后端 JWT,不接受客户端传入 |
|
||||
| `product_code` | varchar not null | 后端套餐码,例如 `starter_pack` |
|
||||
| `app_store_product_id` | varchar not null | Apple 商品 ID |
|
||||
| `transaction_id` | varchar not null unique | Apple 交易 ID,核心幂等键 |
|
||||
| `original_transaction_id` | varchar null | Apple 原始交易 ID |
|
||||
| `web_order_line_item_id` | varchar null | Apple 可选订单行 ID |
|
||||
| `environment` | varchar not null | `Sandbox` 或 `Production` |
|
||||
| `bundle_id` | varchar not null | 必须等于当前 iOS bundle id |
|
||||
| `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`、`refunded_insufficient`、`revoked` |
|
||||
| `credits` | bigint not null | 本次应发积分,由后端套餐配置决定 |
|
||||
| `currency` | varchar null | Apple 或后端记录的币种 |
|
||||
| `price_milliunits` | bigint null | 如 Apple JWS 提供则保存,单位按 Apple 字段定义 |
|
||||
| `ledger_event_id` | varchar null unique | 对应 `points_ledger.event_id` |
|
||||
| `signed_transaction_info` | text not null | 客户端提交或服务器查询到的 Apple signed transaction JWS |
|
||||
| `apple_payload` | jsonb not null default `{}` | 验签后的交易 payload 快照 |
|
||||
| `failure_code` | varchar null | 验证或入账失败原因码 |
|
||||
| `created_at` / `updated_at` | timestamptz | 时间戳 |
|
||||
|
||||
约束与索引:
|
||||
|
||||
- `transaction_id` 全局唯一。
|
||||
- `(user_id, created_at desc)` 索引用于用户购买记录查询。
|
||||
- `(status, updated_at)` 索引用于补偿任务扫描。
|
||||
- `status` 使用 check constraint 限定合法值。
|
||||
- `environment` 使用 check constraint 限定 `Sandbox` / `Production`。
|
||||
|
||||
### 5.4 `points_ledger` 最小扩展
|
||||
|
||||
新增积分变更类型:
|
||||
|
||||
- `PointsChangeType.PURCHASE = 'purchase'`
|
||||
- `PointsChangeType.REFUND = 'refund'`
|
||||
|
||||
新增业务类型:
|
||||
|
||||
- `PointsBizType.PAYMENT = 'payment'`
|
||||
|
||||
约束调整:
|
||||
|
||||
- `change_type` 允许 `purchase`、`refund`。
|
||||
- `biz_type` 允许 `payment`。
|
||||
- `purchase` 必须 `direction = 1`。
|
||||
- `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` 建议保存:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "apple_iap",
|
||||
"platform": "ios",
|
||||
"product_code": "starter_pack",
|
||||
"app_store_product_id": "com.meeyao.qianwen.starter_pack",
|
||||
"transaction_id": "1000000123456789",
|
||||
"original_transaction_id": "1000000123456789",
|
||||
"environment": "Production",
|
||||
"apple_iap_transaction_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
对于 `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` 为准。
|
||||
|
||||
如果后续希望所有积分变动都统一进入 `points_audit_ledger`,再单独扩展它的 `change_type/biz_type` 约束。不要在本期为了“统一”扩大实现范围。
|
||||
|
||||
### 5.6 `register_bonus_claims` 使用方式
|
||||
|
||||
新手包入账成功后:
|
||||
|
||||
- 按当前用户邮箱计算 `email_hash`。
|
||||
- 如果对应 claim 已存在,设置 `has_purchased_starter_pack = true`。
|
||||
- 如果不存在但用户购买的是新手包,后端应创建 claim,并写入 `email_hash`、`user_email_snapshot`、`first_user_id_snapshot`、`grant_event_id`。
|
||||
- 该操作必须与积分入账在同一个数据库事务中完成。
|
||||
|
||||
## 6. 后端接口设计
|
||||
|
||||
### 6.1 新增接口
|
||||
|
||||
`POST /api/v1/payments/apple/transactions/verify`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "starter_pack",
|
||||
"appStoreProductId": "com.meeyao.qianwen.starter_pack",
|
||||
"transactionId": "1000000123456789",
|
||||
"signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs...",
|
||||
"appAccountToken": "7c4c7a82-2f6f-4e70-b57a-8b0a7f2e9b72"
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `productCode` 必须是后端套餐配置中的合法值。
|
||||
- `appStoreProductId` 必须与后端映射表匹配。
|
||||
- `transactionId` 必须与验签后的 Apple payload 中的 `transactionId` 一致。
|
||||
- `signedTransactionInfo` 必须是 Apple 签名 JWS。
|
||||
- `appAccountToken` 如果存在,必须与后端为当前用户生成/约定的 token 一致。
|
||||
|
||||
响应 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "granted",
|
||||
"productCode": "starter_pack",
|
||||
"transactionId": "1000000123456789",
|
||||
"creditsAdded": 100,
|
||||
"newBalance": 180,
|
||||
"ledgerEventId": "payment.apple_iap:1000000123456789"
|
||||
}
|
||||
```
|
||||
|
||||
重复提交同一已发放交易时仍返回 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "already_granted",
|
||||
"productCode": "starter_pack",
|
||||
"transactionId": "1000000123456789",
|
||||
"creditsAdded": 0,
|
||||
"newBalance": 180,
|
||||
"ledgerEventId": "payment.apple_iap:1000000123456789"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 可选接口
|
||||
|
||||
`GET /api/v1/payments/apple/transactions/{transactionId}`
|
||||
|
||||
用途:前端在网络中断或 App 重启后查询交易是否已经入账。可延后实现;如果本期不做,前端应通过重新提交 `signedTransactionInfo` 实现幂等恢复。
|
||||
|
||||
### 6.3 错误码
|
||||
|
||||
新增到 `docs/protocols/common/http-error-codes.md`:
|
||||
|
||||
| code | status | 说明 | 前端处理 |
|
||||
|---|---:|---|---|
|
||||
| `PAYMENT_PRODUCT_NOT_FOUND` | 404 | `productCode` 不存在或未启用 | 刷新套餐并提示商品不可用 |
|
||||
| `PAYMENT_PRODUCT_MISMATCH` | 422 | 客户端商品 ID 与后端/Apple 验证结果不一致 | 阻断入账并提示重试 |
|
||||
| `PAYMENT_TRANSACTION_INVALID` | 422 | Apple signed transaction 无效、验签失败或 payload 不合法 | 提示购买验证失败 |
|
||||
| `PAYMENT_TRANSACTION_REVOKED` | 409 | 交易已撤销或退款,不允许入账 | 提示购买不可用 |
|
||||
| `PAYMENT_TRANSACTION_CONFLICT` | 409 | 交易已由另一个用户或冲突状态处理 | 提示联系客服或刷新余额 |
|
||||
| `PAYMENT_STARTER_PACK_INELIGIBLE` | 409 | 当前邮箱身份已购买过新手包 | 刷新套餐并隐藏新手包 |
|
||||
| `PAYMENT_APPLE_UNAVAILABLE` | 503 | Apple Server API 或证书获取不可用 | 提示稍后重试,前端不要 finish/complete 交易 |
|
||||
| `PAYMENT_GRANT_FAILED` | 500 | 验证成功但入账事务失败 | 提示稍后重试,保留交易用于补偿 |
|
||||
|
||||
注:如果重复提交同一用户已完成发放的交易,应返回 200 `already_granted`,不应返回错误。
|
||||
|
||||
## 7. 后端处理流程
|
||||
|
||||
### 7.1 主流程
|
||||
|
||||
1. 前端收到 StoreKit 购买成功事件。
|
||||
2. 前端把 `signedTransactionInfo`、`transactionId`、`productCode`、`appStoreProductId` 发送到后端。
|
||||
3. 后端从 JWT 获取 `user_id` 和邮箱。
|
||||
4. 后端校验 `productCode` 是否启用,并映射到期望的 Apple Product ID。
|
||||
5. 后端验证 Apple JWS 签名、证书链、bundle id、environment、transaction id、product id、购买时间、撤销状态。
|
||||
6. 后端按 `transaction_id` 查 `apple_iap_transactions`。
|
||||
7. 如果交易已经由当前用户成功发放,返回 `already_granted`。
|
||||
8. 如果交易已绑定其他用户或处于冲突状态,返回结构化错误。
|
||||
9. 后端开启数据库事务。
|
||||
10. 后端插入或锁定 `apple_iap_transactions` 记录。
|
||||
11. 后端锁定 `user_points` 行。
|
||||
12. 后端按套餐配置计算积分,不信任客户端传入的积分数。
|
||||
13. 如果是新手包,锁定/创建 `register_bonus_claims` 并检查 `has_purchased_starter_pack`。
|
||||
14. 后端增加 `user_points.balance` 和 `lifetime_earned`。
|
||||
15. 后端写入 `points_ledger` 的 `purchase` 流水。
|
||||
16. 后端更新 `apple_iap_transactions.status = 'granted'`、写入 `ledger_event_id`。
|
||||
17. 如果是新手包,设置 `has_purchased_starter_pack = true`。
|
||||
18. 事务提交。
|
||||
19. 后端返回新增积分和新余额。
|
||||
20. 前端收到成功响应后调用 `completePurchase` / finish transaction。
|
||||
21. 前端刷新余额和套餐列表。
|
||||
|
||||
### 7.2 幂等策略
|
||||
|
||||
- Apple `transactionId` 是支付发放的全局幂等键。
|
||||
- `apple_iap_transactions.transaction_id` 必须唯一。
|
||||
- `points_ledger.event_id = payment.apple_iap:{transaction_id}`。
|
||||
- 同一用户重复提交已完成交易返回 200 `already_granted`。
|
||||
- 不同用户提交同一 transaction 必须拒绝,避免串单。
|
||||
|
||||
### 7.3 补偿策略
|
||||
|
||||
- 如果 Apple 已扣款、客户端未收到后端成功响应,前端下次启动或交易流恢复时重新提交同一交易。
|
||||
- 后端接口必须可重复调用。
|
||||
- 如果后端已插入 `apple_iap_transactions` 但未完成 `points_ledger`,后台补偿任务可扫描 `status='verified'` 或 `status='failed'` 且具备有效 Apple payload 的记录重试入账。
|
||||
- 前端只有在后端确认 `granted/already_granted` 后才完成 StoreKit 交易,避免交易被提前 finish 后丢失发放机会。
|
||||
|
||||
### 7.4 退款与撤销
|
||||
|
||||
当收到 Apple 退款/撤销通知时,必须扣回已发放积分,避免积分被白嫖。
|
||||
|
||||
**处理流程:**
|
||||
|
||||
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 接入设计
|
||||
|
||||
### 8.1 技术选择
|
||||
|
||||
优先使用 Flutter 官方 `in_app_purchase` 插件及 iOS StoreKit 实现。若当前插件版本无法稳定提供 StoreKit2 `Transaction.signedData`,则增加最小 iOS platform channel 获取 `signedTransactionInfo`,不要把旧 `verifyReceipt` 作为主验证方案。
|
||||
|
||||
建议依赖:
|
||||
|
||||
- `in_app_purchase`
|
||||
- 如需 iOS 细节能力,再引入官方 StoreKit 平台包。
|
||||
|
||||
### 8.2 前端模块建议
|
||||
|
||||
```text
|
||||
apps/lib/features/payments/
|
||||
├── data/
|
||||
│ ├── apis/
|
||||
│ │ └── apple_payment_api.dart
|
||||
│ ├── models/
|
||||
│ │ └── apple_purchase_models.dart
|
||||
│ └── services/
|
||||
│ └── apple_iap_service.dart
|
||||
└── presentation/
|
||||
└── bloc/
|
||||
└── payment_bloc.dart
|
||||
```
|
||||
|
||||
如果实现时发现只服务积分中心,`PaymentBloc` 可以先保持轻量,不要过度抽象。
|
||||
|
||||
### 8.3 前端购买流程
|
||||
|
||||
1. 积分中心加载后端套餐。
|
||||
2. 前端把后端 `productCode` 映射为 App Store Product ID。
|
||||
3. 前端调用 StoreKit 查询商品详情。
|
||||
4. UI 用 StoreKit 返回的本地化价格覆盖后端展示价格。
|
||||
5. 用户点击购买。
|
||||
6. 前端构造购买参数,带上当前用户绑定用的 `appAccountToken`。
|
||||
7. 前端调用 consumable purchase。
|
||||
8. 前端监听 purchase stream。
|
||||
9. 状态为 `pending`:展示处理中,不调用后端入账。
|
||||
10. 状态为 `error` 或 `canceled`:展示失败/取消,不调用后端入账。
|
||||
11. 状态为 `purchased` 且含可验证数据:调用后端验证接口。消耗型商品不依赖 restore 作为补发入口,补发应通过未完成交易重新提交实现。
|
||||
12. 后端返回 `granted/already_granted` 后,前端调用 `completePurchase`。
|
||||
13. 前端刷新 `GET /api/v1/points/balance` 和 `GET /api/v1/points/packages`。
|
||||
|
||||
### 8.4 UI 状态
|
||||
|
||||
- 套餐加载中:使用项目统一加载组件。
|
||||
- 商品不可用:禁用对应卡片并提示稍后再试。
|
||||
- 购买中:当前卡片按钮进入 loading,避免重复点击。
|
||||
- pending:显示 Apple 正在处理,不发放积分。
|
||||
- 成功:刷新余额;正常成功路径可不打扰,必要时显示轻量成功反馈。
|
||||
- 失败:按后端 RFC7807 `code` 映射本地化错误。
|
||||
|
||||
## 9. App Store Connect 配置
|
||||
|
||||
上线前必须完成:
|
||||
|
||||
1. iOS App ID 开启 In-App Purchase capability。
|
||||
2. Xcode Runner target 开启 In-App Purchase capability。
|
||||
3. App Store Connect 创建 4 个 consumable 商品。
|
||||
4. Product ID 与本 PRD 表格完全一致。
|
||||
5. 配置价格、展示名称、描述和审核截图。
|
||||
6. 配置沙盒测试账号。
|
||||
7. 后端配置 Apple 验签所需 bundle id、Apple 根证书/JWS 证书链校验策略;如果需要主动调用 App Store Server API,再配置 issuer/key id/private key。
|
||||
8. 确认后端区分 Sandbox / Production,但业务逻辑保持一致。
|
||||
|
||||
## 10. 协议与文档更新要求
|
||||
|
||||
实现前必须先更新协议文档:
|
||||
|
||||
- 新增支付协议文档:`docs/protocols/payments/apple-iap-protocol.md`
|
||||
- 更新错误码文档:`docs/protocols/common/http-error-codes.md`
|
||||
- 更新积分数据协议:`docs/protocols/common/user-points-chat-data-protocol.md`
|
||||
|
||||
需要修正的现有协议不一致点:
|
||||
|
||||
- `docs/protocols/common/user-points-chat-data-protocol.md` 中套餐示例仍使用旧字段 `priceUsd`、`badge`、`new_user_pack_099_60`,但当前后端实际返回 `price`、`productCode` 等字段,应同步修正。
|
||||
- 该文档仍描述 `points_ledger.biz_id -> sessions.id`,但当前模型和迁移已取消该外键,应同步修正为快照引用。
|
||||
|
||||
## 11. 安全要求
|
||||
|
||||
- 后端永远从 JWT 获取用户身份,客户端不得传 `userId`。
|
||||
- 后端不信任客户端传入的积分、价格、币种。
|
||||
- 后端必须验证 Apple signed transaction JWS。
|
||||
- 后端必须验证 `bundleId`、`productId`、`transactionId`、`environment`、`revocationDate`。
|
||||
- 后端必须按 `transactionId` 全局幂等。
|
||||
- 后端日志禁止打印完整 JWS、用户邮箱、访问令牌或 Apple 私钥。
|
||||
- Apple 私钥只能通过 `core.config.settings` 读取配置,不允许散落 `os.getenv`。
|
||||
- 支付入账必须在数据库事务中完成。
|
||||
|
||||
## 12. 测试策略
|
||||
|
||||
### 12.1 后端单元测试
|
||||
|
||||
- 验签成功后创建 `apple_iap_transactions`。
|
||||
- 商品不存在返回 `PAYMENT_PRODUCT_NOT_FOUND`。
|
||||
- product id 不匹配返回 `PAYMENT_PRODUCT_MISMATCH`。
|
||||
- revoked transaction 不发放积分。
|
||||
- 同一 transaction 重复提交只发放一次。
|
||||
- 同一 transaction 被不同用户提交时拒绝。
|
||||
- 新手包第一次购买成功,第二次返回 `PAYMENT_STARTER_PACK_INELIGIBLE`。
|
||||
- 入账后 `user_points.balance/lifetime_earned` 正确增加。
|
||||
- 入账后 `points_ledger` 写入 `purchase/payment`。
|
||||
- 退款时余额充足:正确扣减积分,写入 `refund` 流水,状态变为 `refunded`。
|
||||
- 退款时余额不足:扣到 0,状态变为 `refunded_insufficient`,触发告警路径。
|
||||
- 重复退款通知:幂等处理,不重复扣减。
|
||||
|
||||
### 12.2 后端集成测试
|
||||
|
||||
- 使用伪造的 Apple verifier 依赖注入,避免测试依赖真实 Apple 网络。
|
||||
- 覆盖事务回滚:支付记录、积分账户、流水、新手包标记必须一致提交或一致回滚。
|
||||
- 覆盖并发重复提交:只有一个请求成功入账。
|
||||
|
||||
### 12.3 前端测试
|
||||
|
||||
- 套餐接口与 StoreKit 商品映射正确。
|
||||
- 购买成功后调用后端验证并刷新余额。
|
||||
- 后端返回 `already_granted` 时仍完成 StoreKit 交易并刷新余额。
|
||||
- pending/error/canceled 不调用入账接口。
|
||||
- 后端错误码正确映射本地化提示。
|
||||
|
||||
### 12.4 手工测试
|
||||
|
||||
- Xcode StoreKit Configuration 本地测试。
|
||||
- App Store Connect Sandbox 账号测试。
|
||||
- 网络中断后重启 App,未完成交易可重新提交并发放。
|
||||
- TestFlight 环境测试。
|
||||
- 新手包隐藏与重复购买阻断。
|
||||
|
||||
## 13. 实施阶段
|
||||
|
||||
### Phase 1:协议与数据库
|
||||
|
||||
1. 更新协议文档和错误码。
|
||||
2. 新增 `apple_iap_transactions` 模型与 Alembic 迁移。
|
||||
3. 扩展 `PointsChangeType`、`PointsBizType` 与 `points_ledger` check constraints。
|
||||
|
||||
### Phase 2:后端支付服务
|
||||
|
||||
1. 更新协议文档和错误码。
|
||||
2. 新增 `apple_iap_transactions` 模型与 Alembic 迁移。
|
||||
3. 扩展 `PointsChangeType`、`PointsBizType` 与 `points_ledger` check constraints。
|
||||
4. 新增 `v1/payments/` 模块。
|
||||
5. 实现 Apple transaction verifier 抽象。
|
||||
6. 实现支付校验、幂等和积分入账服务。
|
||||
7. 实现退款/撤销处理服务(扣回积分)。
|
||||
8. 接入 `v1/router.py`。
|
||||
9. 添加单元测试和集成测试。
|
||||
|
||||
### Phase 3:iOS / Flutter
|
||||
|
||||
1. 添加 IAP 依赖。
|
||||
2. 实现商品查询和购买流监听。
|
||||
3. 实现后端验证 API client。
|
||||
4. 接入 `CoinCenterScreen`。
|
||||
5. 添加前端测试。
|
||||
|
||||
### Phase 4:联调与发布准备
|
||||
|
||||
1. 配置 App Store Connect 商品。
|
||||
2. 配置后端 Apple 验签参数。
|
||||
3. 配置 App Store Server Notifications V2 URL。
|
||||
4. Sandbox 和 TestFlight 测试。
|
||||
5. 测试退款/撤销流程(使用 Sandbox 退款功能)。
|
||||
6. 检查日志、错误码、补偿路径。
|
||||
|
||||
## 14. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 客户端支付成功但后端未入账 | 后端幂等接口 + 前端不提前 completePurchase + 重启后重新提交 |
|
||||
| 重复发放积分 | `apple_iap_transactions.transaction_id` 唯一 + `points_ledger.event_id` 幂等 |
|
||||
| 商品配置不一致 | 后端维护 `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. 成功标准
|
||||
|
||||
- iOS Sandbox 可完成 4 个积分包购买。
|
||||
- 每笔 Apple transaction 最多发放一次积分。
|
||||
- 新手包同一邮箱身份只能发放一次。
|
||||
- 支付入账后 `user_points`、`points_ledger`、`apple_iap_transactions` 数据一致。
|
||||
- 网络中断/重复提交不会导致漏发或重复发放。
|
||||
- 退款/撤销时正确扣回积分,余额不足时正确记录并告警。
|
||||
- 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"title": "iOS Apple Pay Integration for Points Purchase",
|
||||
"slug": "feat-ios-apple-pay",
|
||||
"branch": "worktree/feat-ios-apple-pay",
|
||||
"status": "completed",
|
||||
"priority": "high",
|
||||
"dev_type": "fullstack",
|
||||
"created_at": "2026-04-27",
|
||||
"updated_at": "2026-04-27",
|
||||
"assignee": null,
|
||||
"labels": [
|
||||
"ios",
|
||||
"payment",
|
||||
"apple-iap",
|
||||
"points",
|
||||
"backend"
|
||||
],
|
||||
"description": "Integrate Apple In-App Purchase (IAP) for iOS points purchase flow, connecting frontend StoreKit2 with backend receipt validation.",
|
||||
"acceptance_criteria": [
|
||||
"iOS app can display available packages via StoreKit2",
|
||||
"User can purchase points through Apple IAP",
|
||||
"Backend validates Apple receipt and credits points",
|
||||
"Purchase history is recorded in points_ledger",
|
||||
"Error handling for failed/cancelled purchases",
|
||||
"Starter pack purchase tracked in register_bonus_claims"
|
||||
],
|
||||
"dependencies": [],
|
||||
"blocks": [],
|
||||
"related_docs": [
|
||||
"docs/protocols/common/http-error-codes.md",
|
||||
"backend/src/v1/points/service.py",
|
||||
"apps/lib/features/points/data/models/package_info.dart"
|
||||
],
|
||||
"completedAt": "2026-04-28"
|
||||
}
|
||||
Reference in New Issue
Block a user