开发笔记
在 Flutter 中接入 Apple应用内订阅:客户端、App Store 服务器通知与 Supabase Edge Function
本文用分步方式说明如何在 Flutter(iOS) 中实现 App Store 应用内购买 / 自动续期订阅,并配合 App Store Server Notifications V2(下称 服务器通知),由 Supabase Edge Function 接收 HTTPS 回调、校验载荷并同步业务权益。文中会涉及:套餐变更、宽限期(billing grace period)、取消自动续费、免费试用 / 推介优惠、沙盒(Sandbox) 等概念。
Apple 的 StoreKit、Server API 与通知格式会迭代,实施时请以 App Store Connect帮助、App Store Server API 与 服务器通知 V2 为准。
你将完成什么
- 在 App Store Connect 配置订阅群组、订阅产品、免费试用 / 推介优惠
- 在 Flutter 中集成 StoreKit 购买流程(常用插件:
in_app_purchase+ Apple 平台实现),并把可验证的交易信息交给服务端 - 在 App Store Connect 配置 服务器通知 V2 的生产与沙盒 URL,指向 Edge Function
- 在 Edge Function 中验证
signedPayload(JWS)、解析通知类型,并调用 App Store Server API 拉取权威订阅状态后写入数据库 - 理解 沙盒账号、生产环境 差异及联调顺序
核心概念(先对齐名词)
| 概念 | 说明 |
|---|---|
| 订阅群组(Subscription Group) | 同一群组内用户同一时间通常只持有一个有效订阅;升级/降级多在群组内切换不同档位(产品)。 |
| 自动续期订阅 | 按周期扣费;状态由 续费成功、账单问题、用户关闭续订 等事件共同决定。 |
| 免费试用 / 推介优惠 | 在 Connect 为订阅配置 推介促销优惠(如免费试用、Pay as you go 等类型以控制台为准);用户兑换后进入试用或优惠期,服务端要以交易与通知为准,不要只信客户端展示。 |
| 用户「取消」 | 多指在 Apple ID 订阅设置里关闭自动续费:当前已付费周期一般仍然有效,到期后不再续订;不要收到「续订状态变更」就立刻移除未到期的权益。 |
| 宽限期(Grace Period) | 因账单问题扣费失败时,若启用相关能力,用户可能在宽限期内仍可使用订阅;对应通知与 Server API 中的状态字段会体现(如 gracePeriodExpiresDate 等,以当前 API 文档为准)。 |
| 账单重试(Billing Retry) | 与宽限期相关的一组状态;最终以 Server API /通知解析结果 决定是否仍视为有效。 |
| 套餐 / 计划变更 | 群组内换档(升级/降级)会产生相应通知(如与「续订偏好变更」相关的类型);应以 Server API 查询的当前权益、产品 ID、到期时间为准。 |
| 服务器通知 V2 | Apple 向你的 HTTPS URL 发送 POST,正文为 JSON,核心字段为 signedPayload(嵌套 JWS);必须先验签再执行业务。 |
| App Store Server API | 使用 App Store Connect API密钥(.p8)、Issuer ID、Key ID 签发 JWT,调用 REST 接口查询交易、订阅状态等;服务端真相源。 |
| 沙盒(Sandbox) | 开发/测试环境:使用 沙盒 Apple ID 购买,通知与 API 有对应 sandbox 行为与端点;上线前必须在此验证完整链路。 |
前置条件
- Apple Developer Program 付费账号,App 已在 App Store Connect 创建
- Flutter 工程已配置 iOS,
Bundle ID与 Connect 一致,具备签名与真机调试能力(订阅联调建议真机) - 已阅读 in_app_purchase 与 StoreKit 基础
- Supabase 项目,能部署 Edge Function、配置 Secrets
- 可在 App Store Connect 创建 App 内购买密钥 / API 密钥(.p8) 并安全保存(勿入库)
第一步:在 App Store Connect 创建订阅与优惠
- 登录 App Store Connect → 你的 App → 订阅。
- 先创建 订阅群组(若尚无),再在群组内 创建订阅,填写 产品 ID(即客户端查询用的标识)。
- 配置 订阅时长、价格、本地化等。
- 配置 推介促销优惠(如 免费试用):设置时长、适用地区与资格规则;注意审核与文案要求。
- 将订阅与 App 版本、协议/税务/银行业务 等未完成项处理到可测试状态。
免费试用 的开始与结束、是否转入正式扣费,以 Apple 返回的交易信息 + 服务器通知 + Server API 为准。
第二步:Flutter 客户端集成(in_app_purchase)
- 在
pubspec.yaml添加in_app_purchase,并按插件文档启用 iOS 侧 StoreKit 能力。 - 初始化
InAppPurchase.instance,监听purchaseStream:pending:展示处理中purchased/restored:取出 **PurchaseDetails**中的验证数据(如serverVerificationData/本地收据或 StoreKit2 相关字段,以你使用的 iOS 版本与插件暴露为准),发送到 Edge Function 校验后再调用completePurchase(若适用)error:提示用户
- 使用
queryProductDetails传入 Connect 中的 订阅 productId。 - 使用插件提供的购买入口发起购买;若使用 StoreKit 2 能力,优先遵循插件对 JWS 交易 的封装方式。
- 用户关联:把 Supabase
user.id(或稳定业务 ID)在服务端与originalTransactionId/appAccountToken(若你在客户端设置)建立映射,便于换机与恢复购买后的对账。
客户端要点:不要在未经验证的情况下解锁高价值权益;收据 / JWS 必须由服务端验证或向 Apple 查询确认。
第三步:App Store Server API 与密钥(给 Edge Function 用)
- 在 App Store Connect → 用户和访问 → 密钥 → App 内购买:创建 App 内购买密钥,下载 .p8(仅一次),记录 Issuer ID、Key ID。
- Edge Function 使用 ES256 JWT(官方文档规定的 claim:
iss、iat、exp、aud、bid等)调用 App Store Server API。 - 将
.p8内容、ISSUER_ID、KEY_ID、Bundle ID 存为 Supabase Secrets(例如APP_STORE_PRIVATE_KEY、APP_STORE_ISSUER_ID、APP_STORE_KEY_ID、APP_STORE_BUNDLE_ID)。 - 典型用途:根据
transactionId/originalTransactionId调用 获取交易信息、订阅状态 等接口,把返回 JSON 映射为你的subscriptions表字段(到期时间、是否自动续订、产品 ID、优惠类型等)。
生产与沙盒:API 请求需使用文档规定的 生产 与 沙盒 主机名;对沙盒用户与沙盒通知应走沙盒端点,避免混用导致误判。
第四步:启用 App Store 服务器通知 V2
- 在 App Store Connect → 你的 App → App信息(或「服务器通知」相关页面,以当前控制台为准)。
- 填写 生产服务器 URL:
https://<project-ref>.supabase.co/functions/v1/app-store-server-notifications(示例路径,与你在 Supabase 中的函数名一致即可)。 - 填写 沙盒服务器 URL(可与生产相同函数,由载荷中的
environment区分;也可分函数便于日志)。 - 选择 版本2通知(若控制台提供版本选择)。
- 保存后,Apple 会在订阅生命周期变化时向该 URL 发送 HTTPS POST。
POST 正文与验签(Edge Function)
- 请求体为 JSON,包含
signedPayload:这是一个 JWS,载荷内有notificationType、subtype(可选)、notificationUUID、data(其中常含嵌套的签名交易信息)等。 - 必须按 Apple 文档验证 JWS:使用 Apple 提供的 证书链 / JWKS 思路,确认签名算法与声明无误后,再解析 JSON。
- 使用
notificationUUID(及业务上的originalTransactionId)做 幂等,防止重复通知导致重复改库。
常见 notificationType(枚举以官方最新列表为准,此处仅帮助理解):
- SUBSCRIBED / DID_RENEW:订阅开始或续费成功 → 刷新到期时间与产品档位。
- DID_CHANGE_RENEWAL_PREF:用户在同一订阅群组内更改续订偏好(常与升级/降级路径相关)→ 以 Server API 查询结果确认新产品与生效时间。
- DID_CHANGE_RENEWAL_STATUS:自动续订开启/关闭(用户可能已取消续订)→ 结合到期时间判断是否仍享有当前周期权益。
- EXPIRED:订阅已过期 → 通常应移除权益(仍注意是否有宽限、退款等子类型)。
- GRACE_PERIOD_EXPIRED:宽限期结束 → 按文档进入下一状态(常为过期或账单重试结束)。
- OFFER_REDEEMED:用户兑换优惠(含试用)→ 更新优惠与周期。
- REFUND / REVOKE:退款或撤销 → 立即收回相应权益。
原则:通知可能乱序或重复;每次处理都应调用 Server API 拉取当前订阅状态,与本地库合并。
第五步:沙盒环境与测试账号
- 在 App Store Connect → 用户和访问 → 沙盒 → 测试员,添加 沙盒 Apple ID(不要使用真实主账号)。
- 在 iOS 设备 设置 → App Store → 沙盒账户 登录该账号(系统版本不同入口可能略有差异)。
- 用 Debug / TestFlight 构建在真机发起购买:交易走 Sandbox,服务器通知的
environment为 Sandbox。 - Edge Function 调用 App Store Server API 时使用 沙盒 base URL 查询对应交易。
- 上线前:在沙盒验证 试用 → 转正 → 续费失败/宽限 → 用户关闭自动续订 → 到期 → 升级/降级 等路径。
第六步:宽限期、取消、试用与换档(产品层建议)
| 场景 | 客户端 | 服务端(Edge Function + Server API) |
|---|---|---|
| 免费试用 | 展示 App Store 结算页 | 根据交易/通知更新试用结束时间与下一扣费点;试用结束前勿误判为「未订阅」 |
| 宽限期 | 可提示更新付款方式 | 解析通知与 API 中的宽限相关字段;决定是否暂时保留权益 |
| 用户关闭自动续订 | 可引导至设置 → Apple ID → 订阅 | DID_CHANGE_RENEWAL_STATUS + 到期时间:周期内通常仍有效 |
| 升级 / 降级 | 在群组内选购新档位 | DID_CHANGE_RENEWAL_PREF 等 + Server API 确认新产品与价格生效规则 |
| 退款 | 以系统结果为准 | REFUND / REVOKE 类通知 → 立即降级或封禁对应权益 |
第七步:Supabase 部署与安全建议
# 示例:勿泄露真实密钥
supabase secrets set \
APP_STORE_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
APP_STORE_KEY_ID=XXXXXXXXXX \
APP_STORE_BUNDLE_ID=com.example.app \
APP_STORE_PRIVATE_KEY="$(cat AuthKey_XXXXXXXXXX.p8)"
- 函数仅接受 Apple 推送(可通过 允许列表 IP、mTLS、或额外共享密钥头——若 Apple 支持你的方案;V2 以 JWS 验签为主)。
- 限流与日志:记录
notificationUUID、处理耗时与下游 API 错误,便于与 Apple 支持对账。 - 数据库表建议包含:
user_id、originalTransactionId、latest_transaction_id、product_id、expires_date、auto_renew_status、environment(Sandbox/Production)等。
上线前检查清单
- 沙盒路径跑通:购买、恢复、试用、续订、关自动续订、到期
- 服务器通知验签失败时有告警,验签通过前不更新权益
- 生产与沙盒 API 主机、通知 URL 配置正确
-
.p8仅存在于 Secrets,轮换密钥流程已记录 - 幂等与乱序通知不会导致错误开通或重复扣权益
常见问题
| 现象 | 建议 |
|---|---|
| 收不到通知 | 查 Connect 中 URL、TLS 证书链、函数是否 2xx;Apple 会重试 |
| 验签失败 | 是否用了完整 JWS 与 Apple 当前文档的证书/JWKS 流程;载荷是否被中间层改写 |
| 沙盒购买在生产 API 查不到 | 主机名与环境必须匹配 Sandbox |
| 用户已付费但库无记录 | 客户端是否调用了服务端;是否未完成 completePurchase 导致流卡住;映射 originalTransactionId 是否丢失 |