iOS订阅业务做久了,几乎每个开发者都会撞上同一堵墙:用户明明已经取消续订,甚至宽限期都结束了,App本地记录的订阅状态却还显示有效。反过来也有,用户刚续费成功,因为收据刷新延迟,本地状态没更新,会员权益被误收回,直接换来一星差评。这个问题的根源在于本地状态和App Store服务器端的真实订阅状态是两个数据源,它们之间没有任何自动同步机制,一切都要靠开发者自己设计校验和更新逻辑。本文就围绕收据校验、过期时间比对、本地状态更新这三件事,把完整的方案讲清楚。

一、为什么本地订阅状态会失真
先理解失真的成因,才能设计出正确的校验策略。最常见的几种情况包括:用户卸载重装App后本地数据库被清空;用户在系统设置里关闭了自动续订,App没有感知;订阅进入宽限期(grace period)或者账单重试期(billing retry),此时订阅既不算有效也不算彻底失效,处于灰色地带;用户申请了退款并且苹果批准了,但收据里的记录不会立即反映出来。
其中最容易被忽视的是宽限期和账单重试期的区别。宽限期是指苹果检测到扣款失败后,在一定天数内(可在App Store Connect配置,通常是16天以内)仍然保留用户的订阅权益,同时收据中的expiration_intent和grace_period_expires_date_ms字段会给出提示。账单重试期则更隐蔽,此时收据的is_in_billing_retry_period为true,用户可能暂时失去权益,但订阅随时会因为扣款成功而恢复。如果本地逻辑只简单判断过期时间,就会在这两个阶段做出错误的权益判断。
还有一个坑是时间源问题。本地缓存的过期时间必须和可信时间比较,直接用Date()获取设备时间是不安全的,用户改个系统日期就能白嫖会员。正确做法是用服务器时间或者SKAN无关的苹果服务器返回的时间戳做基准,收据校验接口返回的字段本身就是毫秒级时间戳,可以直接拿来用。
二、收据校验的实施方案与代码实现
校验的核心思路是:不要信任本地缓存的订阅状态超过必要的时间。推荐在三个时机触发校验:应用启动完成时、应用从后台回到前台时(超过一定时间间隔才触发,避免频繁切换App带来的性能浪费)、以及购买或恢复购买流程结束时。iOS 12以上建议直接使用App Store Server API v2,配合App Transaction和SubscriptionInfo接口,比传统的verifyReceipt接口返回的信息更完整、字段更规范。
下面是一段在Swift中封装校验流程的示例代码,重点展示如何解析订阅记录并判断状态:
struct SubscriptionStatus {
let productId: String
let expiresDateMs: Int64
let isInBillingRetry: Bool
let gracePeriodExpiresMs: Int64?
let isRefunded: Bool
}
func evaluateSubscription(status: SubscriptionStatus,
serverTimeMs: Int64) -> EntitlementState {
// 已退款,直接失效
if status.isRefunded {
return .revoked
}
// 账单重试期:查看是否处于宽限期
if status.isInBillingRetry {
if let graceMs = status.gracePeriodExpiresMs, graceMs > serverTimeMs {
return .gracePeriod // 宽限期内仍保留权益
}
return .billingRetry // 重试期且无宽限,权益暂停
}
// 正常判断过期时间
if status.expiresDateMs > serverTimeMs {
return .active
} else {
return .expired
}
}
这段代码的关键点在于把状态分成了五档:active、gracePeriod、billingRetry、expired、revoked,而不是简单二分为有效和无效。宽限期内的用户应该继续享受权益,同时App可以温和地提示更新支付方式;重试期的用户可以展示挽留页面;已退款的用户要立即收回权益。如果只做二分处理,产品层面就没有任何运营空间,用户体验也会很生硬。
另一个实践建议是校验一定要走自己的服务端中转,而不是让App直接请求苹果服务器。原因有三个:一是App Store Server API需要生成JWS签名的请求,签名密钥不该下发到客户端;二是服务端可以顺便用自己的可信时间做比对,规避设备时间被篡改的问题;三是服务端可以把校验结果落库,形成订阅状态变更历史,方便客服排查纠纷。
三、本地状态更新与定期校验的工程化设计
校验拿到结果之后,本地更新也有讲究。首先要保证更新的原子性:建议用一个状态机管理本地订阅状态,所有状态迁移只允许通过状态机进行,禁止业务代码直接改写字段。比如从active到expired的迁移必须附带校验时间戳,一旦迁移就同时清理本地与会员相关的缓存数据(如已下载的付费内容索引),避免出现权益已失效但内容仍可访问的窗口。
定期校验的频率建议按订阅剩余时长动态调整。对于刚订阅的用户,每天校验一次足够;临近过期的用户可以提高到每次进前台都校验;而长期有效的年费用户,一周校验一次即可。这样既保证状态新鲜度,又不给服务器带来无谓的压力。同时别忘了处理校验失败的情况:网络异常时不能直接把本地状态置为失效,应该保留上一次的有效状态并标记一个待校验标志,等下次网络恢复后再补一次校验。宁可短暂多给权益,也不要误伤付费用户,这是订阅类产品的原则性取舍。
最后提供一个状态比对的参考表格,方便在实现时对号入座:
| 收据校验结果 | 本地当前状态 | 应执行的动作 |
|---|---|---|
| active且未过期 | active | 无操作,刷新过期时间 |
| expired | active | 收回权益,记录失效时间 |
| gracePeriod | active | 保留权益,提示更新支付方式 |
| revoked(退款) | 任意 | 立即收回权益并清缓存 |
| 校验失败 | 任意 | 保留状态,标记待重试 |
把这套逻辑跑顺之后,订阅状态不一致的客诉基本可以消除。剩下的极少数边界情况,比如家庭共享导致的权益归属问题、跨设备的状态同步延迟,可以在这个框架基础上继续扩展通知监听(App Store Server Notifications V2)来做准实时补偿,形成本地校验加服务端推送的双保险体系。