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

24 KiB
Raw Blame History

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_packstarter_packpopular_packpremium_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 SandboxProduction
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 receivedverifiedgrantedfailedrefundedrefunded_insufficientrevoked
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 允许 purchaserefund
  • biz_type 允许 payment
  • purchase 必须 direction = 1
  • refund 必须 direction = -1
  • purchase/refund 必须 biz_type = 'payment'biz_id 不为空。
  • purchase/refundbiz_id 保存 apple_iap_transactions.id
  • purchaseevent_id 建议为 payment.apple_iap:{transaction_id}
  • refundevent_id 建议为 refund.apple_iap:{transaction_id}

points_ledger.metadata.ext 建议保存:

{
  "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,额外保存:

{
  "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_hashuser_email_snapshotfirst_user_id_snapshotgrant_event_id
  • 该操作必须与积分入账在同一个数据库事务中完成。

6. 后端接口设计

6.1 新增接口

POST /api/v1/payments/apple/transactions/verify

请求:

{
  "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

{
  "status": "granted",
  "productCode": "starter_pack",
  "transactionId": "1000000123456789",
  "creditsAdded": 100,
  "newBalance": 180,
  "ledgerEventId": "payment.apple_iap:1000000123456789"
}

重复提交同一已发放交易时仍返回 200:

{
  "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. 前端把 signedTransactionInfotransactionIdproductCodeappStoreProductId 发送到后端。
  3. 后端从 JWT 获取 user_id 和邮箱。
  4. 后端校验 productCode 是否启用,并映射到期望的 Apple Product ID。
  5. 后端验证 Apple JWS 签名、证书链、bundle id、environment、transaction id、product id、购买时间、撤销状态。
  6. 后端按 transaction_idapple_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.balancelifetime_earned
  15. 后端写入 points_ledgerpurchase 流水。
  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_ledgerrefund 流水,amount = balance(扣到 0 为止)。
      • 事务提交后触发告警,等待人工处理。
    • 如果 balance >= credits
      • 扣减 user_points.balancelifetime_earned
      • 写入 points_ledgerrefund 流水。
      • 更新 apple_iap_transactions.status = 'refunded'
      • 事务提交。
  5. points_ledgerrefund 记录:
    • 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 typeREFUNDREVOKE 等。
  • 按上述流程处理退款/撤销。

余额不足时的处理策略:

  • 不允许用户余额变为负数。
  • 扣到 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 前端模块建议

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. 状态为 errorcanceled:展示失败/取消,不调用后端入账。
  11. 状态为 purchased 且含可验证数据:调用后端验证接口。消耗型商品不依赖 restore 作为补发入口,补发应通过未完成交易重新提交实现。
  12. 后端返回 granted/already_granted 后,前端调用 completePurchase
  13. 前端刷新 GET /api/v1/points/balanceGET /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 中套餐示例仍使用旧字段 priceUsdbadgenew_user_pack_099_60,但当前后端实际返回 priceproductCode 等字段,应同步修正。
  • 该文档仍描述 points_ledger.biz_id -> sessions.id,但当前模型和迁移已取消该外键,应同步修正为快照引用。

11. 安全要求

  • 后端永远从 JWT 获取用户身份,客户端不得传 userId
  • 后端不信任客户端传入的积分、价格、币种。
  • 后端必须验证 Apple signed transaction JWS。
  • 后端必须验证 bundleIdproductIdtransactionIdenvironmentrevocationDate
  • 后端必须按 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. 扩展 PointsChangeTypePointsBizTypepoints_ledger check constraints。

Phase 2:后端支付服务

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