Files
eryao/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/prd.md
T

550 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PRDiOS Apple 内购接入积分购买
## 1. 背景与目标
当前应用已经有积分账户、积分流水、注册赠送积分和积分套餐展示能力。用户可以在积分中心看到套餐,但还不能通过 iOS 完成真实购买。本任务目标是在 iOS 端接入 Apple In-App PurchaseIAP),购买成功后由后端验证 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 3iOS / 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` 数据一致。
- 网络中断/重复提交不会导致漏发或重复发放。
- 退款/撤销时正确扣回积分,余额不足时正确记录并告警。
- 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。