← 返回文章列表
开发笔记

在 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、到期时间为准
服务器通知 V2Apple 向你的 HTTPS URL 发送 POST,正文为 JSON,核心字段为 signedPayload(嵌套 JWS);必须先验签再执行业务
App Store Server API使用 App Store Connect API密钥(.p8)Issuer IDKey ID 签发 JWT,调用 REST 接口查询交易、订阅状态等;服务端真相源
沙盒(Sandbox)开发/测试环境:使用 沙盒 Apple ID 购买,通知与 API 有对应 sandbox 行为与端点;上线前必须在此验证完整链路。

前置条件

  • Apple Developer Program 付费账号,App 已在 App Store Connect 创建
  • Flutter 工程已配置 iOSBundle ID 与 Connect 一致,具备签名与真机调试能力(订阅联调建议真机)
  • 已阅读 in_app_purchaseStoreKit 基础
  • Supabase 项目,能部署 Edge Function、配置 Secrets
  • 可在 App Store Connect 创建 App 内购买密钥 / API 密钥(.p8) 并安全保存(入库)

第一步:在 App Store Connect 创建订阅与优惠

  1. 登录 App Store Connect → 你的 App → 订阅
  2. 先创建 订阅群组(若尚无),再在群组内 创建订阅,填写 产品 ID(即客户端查询用的标识)。
  3. 配置 订阅时长、价格、本地化等。
  4. 配置 推介促销优惠(如 免费试用):设置时长、适用地区与资格规则;注意审核与文案要求。
  5. 将订阅与 App 版本协议/税务/银行业务 等未完成项处理到可测试状态。

免费试用 的开始与结束、是否转入正式扣费,以 Apple 返回的交易信息 + 服务器通知 + Server API 为准。


第二步:Flutter 客户端集成(in_app_purchase

  1. pubspec.yaml 添加 in_app_purchase,并按插件文档启用 iOS 侧 StoreKit 能力。
  2. 初始化 InAppPurchase.instance,监听 purchaseStream
    • pending:展示处理中
    • purchased / restored:取出 **PurchaseDetails**中的验证数据(如 serverVerificationData /本地收据或 StoreKit2 相关字段,以你使用的 iOS 版本与插件暴露为准),发送到 Edge Function 校验后再调用 completePurchase(若适用)
    • error:提示用户
  3. 使用 queryProductDetails 传入 Connect 中的 订阅 productId
  4. 使用插件提供的购买入口发起购买;若使用 StoreKit 2 能力,优先遵循插件对 JWS 交易 的封装方式。
  5. 用户关联:把 Supabase user.id(或稳定业务 ID)在服务端与 originalTransactionId / appAccountToken(若你在客户端设置)建立映射,便于换机与恢复购买后的对账。

客户端要点:不要在未经验证的情况下解锁高价值权益收据 / JWS 必须由服务端验证或向 Apple 查询确认


第三步:App Store Server API 与密钥(给 Edge Function 用)

  1. 在 App Store Connect → 用户和访问密钥App 内购买:创建 App 内购买密钥,下载 .p8(仅一次),记录 Issuer IDKey ID
  2. Edge Function 使用 ES256 JWT(官方文档规定的 claim:issiatexpaudbid 等)调用 App Store Server API
  3. .p8 内容、ISSUER_IDKEY_IDBundle ID 存为 Supabase Secrets(例如 APP_STORE_PRIVATE_KEYAPP_STORE_ISSUER_IDAPP_STORE_KEY_IDAPP_STORE_BUNDLE_ID)。
  4. 典型用途:根据 transactionId / originalTransactionId 调用 获取交易信息订阅状态 等接口,把返回 JSON 映射为你的 subscriptions 表字段(到期时间、是否自动续订、产品 ID、优惠类型等)。

生产与沙盒:API 请求需使用文档规定的 生产沙盒 主机名;对沙盒用户与沙盒通知应走沙盒端点,避免混用导致误判。


第四步:启用 App Store 服务器通知 V2

  1. 在 App Store Connect → 你的 App → App信息(或「服务器通知」相关页面,以当前控制台为准)。
  2. 填写 生产服务器 URLhttps://<project-ref>.supabase.co/functions/v1/app-store-server-notifications(示例路径,与你在 Supabase 中的函数名一致即可)。
  3. 填写 沙盒服务器 URL(可与生产相同函数,由载荷中的 environment 区分;也可分函数便于日志)。
  4. 选择 版本2通知(若控制台提供版本选择)。
  5. 保存后,Apple 会在订阅生命周期变化时向该 URL 发送 HTTPS POST

POST 正文与验签(Edge Function)

  • 请求体为 JSON,包含 signedPayload:这是一个 JWS,载荷内有 notificationTypesubtype(可选)、notificationUUIDdata(其中常含嵌套的签名交易信息)等。
  • 必须按 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 拉取当前订阅状态,与本地库合并。


第五步:沙盒环境与测试账号

  1. 在 App Store Connect → 用户和访问沙盒测试员,添加 沙盒 Apple ID(不要使用真实主账号)。
  2. 在 iOS 设备 设置 → App Store → 沙盒账户 登录该账号(系统版本不同入口可能略有差异)。
  3. Debug / TestFlight 构建在真机发起购买:交易走 Sandbox,服务器通知的 environmentSandbox
  4. Edge Function 调用 App Store Server API 时使用 沙盒 base URL 查询对应交易。
  5. 上线前:在沙盒验证 试用 → 转正 → 续费失败/宽限 → 用户关闭自动续订 → 到期 → 升级/降级 等路径。

第六步:宽限期、取消、试用与换档(产品层建议)

场景客户端服务端(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 推送(可通过 允许列表 IPmTLS、或额外共享密钥头——若 Apple 支持你的方案;V2 以 JWS 验签为主)。
  • 限流与日志:记录 notificationUUID、处理耗时与下游 API 错误,便于与 Apple 支持对账。
  • 数据库表建议包含:user_idoriginalTransactionIdlatest_transaction_idproduct_idexpires_dateauto_renew_statusenvironment(Sandbox/Production)等。

上线前检查清单

  • 沙盒路径跑通:购买、恢复、试用、续订、关自动续订、到期
  • 服务器通知验签失败时有告警,验签通过前不更新权益
  • 生产与沙盒 API 主机通知 URL 配置正确
  • .p8 仅存在于 Secrets,轮换密钥流程已记录
  • 幂等与乱序通知不会导致错误开通或重复扣权益

常见问题

现象建议
收不到通知查 Connect 中 URL、TLS 证书链、函数是否 2xx;Apple 会重试
验签失败是否用了完整 JWS 与 Apple 当前文档的证书/JWKS 流程;载荷是否被中间层改写
沙盒购买在生产 API 查不到主机名与环境必须匹配 Sandbox
用户已付费但库无记录客户端是否调用了服务端;是否未完成 completePurchase 导致流卡住;映射 originalTransactionId 是否丢失

参考链接