# 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` 数据一致。 - 网络中断/重复提交不会导致漏发或重复发放。 - 退款/撤销时正确扣回积分,余额不足时正确记录并告警。 - 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。