用户发邮件给客服:“我刚付了 9.99 美元 Pro,App Store 扣了款,但 App 里还显示我是免费用户。我试着再升级,提示我已经订阅了。” 或更糟,另一个用户报告 “我三周前取消了 Pro,昨晚又被扣费,但 App 里正确显示我是 free。” 你 App 以为的用户状态和 Apple 购买系统以为的状态对不上。钱流向一边,权限流向另一边。
订阅 entitlement 不一致几乎都是因为把 Apple 的购买系统当成了一次性事件(“他付了,把 isPro 设 true”)而不是一条连续的状态变更流(购买 / 续费 / 降级 / 退款 / 过期 / grace 期)。修法是一个在每个相关事件上重新评估的服务端权威状态机。
常见原因
按命中率排序。
1. 本地 “isPro” 标记永久缓存
购买成功后 UserDefaults.set(true, forKey: "isPro") 然后再不复查。用户取消,三周后订阅过期,标记还是 true。他们继续用 Pro 直到重装。
如何判断:搜代码里所有映射到订阅状态的本地 boolean。读不重新对 StoreKit 或服务器复核就是这个 bug。
2. Transaction.updates 监听没跑起来
StoreKit 2 里 Transaction.updates 在 App 前台或重启时投递续费 / 过期 / 退款事件。App 启动时不订阅就漏事件。
如何判断:搜 for await result in Transaction.updates。只在某个不是 init 或 applicationDidFinishLaunching 的地方调用过,就在漏事件。
3. App Store Server Notifications V2 没接或 webhook 坏
Apple 服务到服务发送所有事件(DID_RENEW、DID_FAIL_TO_RENEW、EXPIRED、REFUND、DID_CHANGE_RENEWAL_STATUS)。你的端点挂了、返回 500、或没正确处理 JWS payload,服务器对订阅的视图就停在过期上。
如何判断:查 App Store Connect → App Information → App Store Server Notifications,生产和沙盒 URL 都配。用 curl 测一遍。看服务器日志最近收到的事件。
4. 过期判定用了设备时钟
你在设备上算 isExpired = expiresAt < Date()。设备时钟错(时差、手动改、NTP 坏)就过早过期或永不过期。
如何判断:搜代码里 Date() 或 new Date() 用于过期比较的地方。没用服务器返回的当前时间就有这个 bug。
5. 购买完成和 entitlement fetch 的竞态
购买弹窗带成功消失。你 App 50ms 后调 fetch entitlement。你服务器还没收到 Apple 通知,返回 “未订阅”。App 显示 free,用户懵了。
如何判断:在购买完成 + entitlement fetch 周围加详细日志。购买成功后几秒内 entitlement fetch 返回 “未订阅” 就是这个竞态。
6. Grace 期和扣费重试没处理
Apple 给 16 天 grace 期处理续费失败。Grace 期内用户付的上一期已过期,但 Apple 在重试他卡。你 App 严格判 expiresAt > now 就在 grace 期内切了他,他来投诉。
如何判断:看代码读不读 gracePeriodExpiresDate(StoreKit 2: RenewalInfo.gracePeriodExpirationDate)。不读就没认 grace 期。
7. 不同发版的 product ID 映射不同
这一版你把 pro_monthly 改成 pro_monthly_v2。老订阅用户的 transaction productID = pro_monthly。新代码只认 pro_monthly_v2。他们一夜之间变 “free”。
如何判断:审你的 entitlement 映射代码。没列全历史 product ID,老订阅就挂。
动手前先确认
- 决定在 client 端(StoreKit listener)、server 端(通知 handler)还是两边都修——多数 case 都要双边。
- 受影响用户的
originalTransactionID记下来,便于跨系统追踪事件。 - 改之前备份当前 entitlement 表,万一脚本写错能回滚。
- 在沙盒里能稳定复现再开始动 client / server 代码。
需要收集的信息
- 用户在 App Store Connect → Sales → Transactions 里的
originalTransactionID。 - 服务器对该用户订阅收到的全部事件日志。
- Client 日志:所有 entitlement check + 状态变更。
- 问题发生时用户的设备时钟(拿得到的话)。
- 当前支持的产品 ID 集合 + entitlement 映射表。
最短修复路径
Step 1:让 entitlement 服务端权威
停用本地 boolean 作真源。每个相关时机(App 启动、前台、entitlement 相关页面)从服务器拉当前 entitlement。最多缓存 60 秒。
@MainActor
class EntitlementManager: ObservableObject {
@Published var isPro = false
func refresh() async {
let result = try await server.fetchEntitlement(userID: currentUser.id)
isPro = result.tier == .pro
}
}
// scene 里:
.task { await entitlement.refresh() }
.onChange(of: scenePhase) { newPhase in
if newPhase == .active { Task { await entitlement.refresh() } }
}
Step 2:启动时订阅 Transaction.updates
@main
struct AcmeApp: App {
init() {
Task {
for await result in Transaction.updates {
guard case .verified(let txn) = result else { continue }
await syncToServer(transaction: txn)
await txn.finish()
}
}
}
}
loop 要比任何单页生命周期长。放在 App init 或单例里。
Step 3:实现 App Store Server Notifications V2
App Store Connect → App Information → App Store Server Notifications,配生产和沙盒两个 V2 URL。
服务器 handler:
// Express 示例
app.post("/apple/notifications", async (req, res) => {
const { signedPayload } = req.body;
const decoded = decodeJWS(signedPayload); // 用 Apple root cert 校验
const { notificationType, data } = decoded;
switch (notificationType) {
case "SUBSCRIBED":
case "DID_RENEW":
await upsertEntitlement(data.originalTransactionId, "pro", data.expiresDate);
break;
case "EXPIRED":
case "REFUND":
await revokeEntitlement(data.originalTransactionId);
break;
case "DID_FAIL_TO_RENEW":
await markGracePeriod(data.originalTransactionId, data.gracePeriodExpiresDate);
break;
// ... 处理全部 15+ 通知类型
}
res.status(200).send();
});
每个 handler 都要幂等——Apple 会重试。
Step 4:尊重 grace 期
let entitlement: String?
if let gracePeriodEnd = renewalInfo.gracePeriodExpirationDate,
gracePeriodEnd > Date() {
entitlement = "pro" // grace 期内仍有权限
} else if let expires = transaction.expirationDate, expires > Date() {
entitlement = "pro"
} else {
entitlement = nil
}
服务端同样逻辑。
Step 5:映射所有历史 product ID
服务端映射表:
const ENTITLEMENT_MAP = {
"com.acme.pro_monthly": "pro",
"com.acme.pro_monthly_v2": "pro",
"com.acme.pro_monthly_v3_2026": "pro",
"com.acme.pro_yearly": "pro",
"com.acme.lifetime": "pro_forever",
};
function entitlementFor(productID) {
return ENTITLEMENT_MAP[productID] ?? null;
}
老订阅在改产品名后仍有权限。
Step 6:用 App Store Server API 对已知漏网者对账
对状态可疑的用户(比如客服投诉过的),调 App Store Server API /inApps/v1/subscriptions/{transactionId} 从 Apple 拿权威当前状态,不依赖你 webhook 有没有触发。
排一个每日对账 job,对 expiresAt < now 的用户在降级前先向 Apple 查一次。
怎么确认已经修好
- 沙盒账号买 → App 5 秒内反映 Pro。
- 取消的沙盒订阅在过期时刻后停止有权限。
- 退款的 transaction 立即在服务端撤权。
- Grace 期内用户在 grace 结束前仍看到 Pro。
- 过去 24 小时服务器日志显示每种通知类型都被成功处理。
- 每日对账 job 不再发现大量过期未清的 entitlement。
如果还是没修好
- 挑一个受影响用户,拿
originalTransactionId直接调 App Store Server API,和你库里数据 diff。差异告诉你漏了哪个事件。 - 查 Apple System Status 有没有通知投递问题;沙盒通知比生产不可靠。
- 确认 webhook 公网可达(只对 Apple IP),不要藏在 VPN 或防火墙后。
- 加一个 “Refresh Entitlement” 客服按钮,按需调 App Store Server API——客服侧能不动代码挽救个案。
预防建议
- Entitlement 当服务端持有、带显式过期时间的授予;永远不是设备 flag。
- 每个通知事件用
originalTransactionId+ correlation ID 记日志便于追溯。 - 每日对账 job 用 App Store Server API 查接近过期的用户。
- 加 “Subscription Debug” 屏(仅内部 / 客服可见)展示服务端状态、最近通知、Apple 当前视图。
- CI 集成测试覆盖完整生命周期:买 → 续 → 取消 → 过期 → 退款,用
.storekit配置文件。
相关阅读
标签: #排查 #App Store #App 审核 #IAP / 内购 #订阅