最快修复:别再信本地的 isPro 标记。让服务器做唯一真源,每次 App 启动和回到前台都拉一次 entitlement,而这份服务端状态由两路输入共同驱动——这两路都得接:StoreKit 2 的 Transaction.updates 监听(放在 App.init 里,不是某个页面里),以及 App Store Server Notifications V2(生产和沙盒两个 URL 都要配)。然后跑一个每日对账 job,对接近过期的用户在降级前先查一次 App Store Server API。下面是这五步的全部细节。
用户发邮件给客服:“我刚付了 9.99 美元 Pro,App Store 扣了款,但 App 里还显示我是免费用户。我试着再升级,提示我已经订阅了。” 或者反过来:“我三周前取消了 Pro,昨晚又被扣费,但 App 里正确显示我是 free。” 你 App 以为的用户状态和 Apple 购买系统以为的状态对不上。钱流向一边,权限流向另一边。
这类不一致几乎都源于把 Apple 的购买系统当成一次性事件(“他付了,把 isPro 设 true”),而不是一条连续的状态变更流:购买、续费、降级、退款、过期、扣费重试、grace 期。修法是一个在每个相关事件上重新评估的服务端权威状态机。
先对号入座
| 用户报告的现象 | 最可能的原因 | 跳转 |
|---|---|---|
| 已付款扣了费仍显示免费;再点升级提示”已订阅” | entitlement 从没写进服务端,或购买与拉取之间有竞态 | 原因 1、3、5 |
| 几周前已取消 / 过期,仍显示 Pro | 本地 isPro 永久缓存,没有过期复查 | 原因 1、2 |
| 续费失败,卡明明有效却当晚就被切了 | grace 期 / 扣费重试没认 | 原因 6 |
| 更新前好好的,更新后整批用户变免费 | 改名后的 product ID 没映射 | 原因 7 |
| 用户随机在 free/Pro 间跳,看不出规律 | webhook 挂了或返回非 200;用设备时钟判过期 | 原因 3、4 |
常见原因
按命中率排序。
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 服务到服务发送所有事件:SUBSCRIBED、DID_RENEW、DID_FAIL_TO_RENEW、EXPIRED、GRACE_PERIOD_EXPIRED、REFUND、REVOKE、DID_CHANGE_RENEWAL_STATUS、DID_CHANGE_RENEWAL_PREF 等等(截至 2026 年 6 月共约 20 种通知类型,很多还带 subtype,比如 INITIAL_BUY、RESUBSCRIBE、BILLING_RECOVERY)。你的端点挂了、返回任何非 HTTP 200、或没正确校验并解码签名的 JWS payload,服务器对订阅的视图就停在旧状态上,Apple 最终也会停止重试。
如何判断:在 App Store Connect 打开你的 App,进 General > App Information,下滑到 App Store Server Notifications,确认 Production 和 Sandbox 两个字段都配了 Version 2 URL(是分开的两个字段——少配 Sandbox URL 正是测试购买永远对不上账的经典原因)。用 curl 打自己的端点,确认返回 200。再到服务器日志里 grep 最近 24 小时收到的通知,一条都没有就说明是投递(而非解析)出了问题。
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 会跑一段扣费重试期(billing retry,最长 60 天);如果你在 App Store Connect 里开了 grace 期,还会再叠加一段扣费 grace 期。Grace 期长度由你在 App Store Connect 里选——截至 2026 年 6 月是 3、16 或 28 天(不是固定 16 天)。Grace 期内上一付费期技术上已过期,但 Apple 仍在重试卡,且 Apple 给这个订阅的状态码是 4(billing grace period),不是过期。你 App 严格判 expiresAt > now 就会在 grace 期内切掉这些用户,他们什么都没做错却被投诉。
如何判断:看代码读不读 grace 期日期。StoreKit 2 里是 RenewalInfo.gracePeriodExpirationDate;App Store Server API / 通知 payload 里是 gracePeriodExpiresDate。再看你有没有区分状态 3(扣费重试中,无权限)和状态 4(grace 期内,保留权限)。两个都不读就是没认 grace 期。
7. 不同发版的 product ID 映射不同
这一版你把 pro_monthly 改成 pro_monthly_v2。老订阅用户的 transaction productID = pro_monthly。新代码只认 pro_monthly_v2。他们一夜之间变 “free”。
如何判断:审你的 entitlement 映射代码。没列全历史 product ID,老订阅就挂。
需要收集的信息
- 用户在 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,进 General > App Information,下滑到 App Store Server Notifications,给 Production 和 Sandbox 两个字段都配 Version 2 URL。只配 Production,沙盒通知也会发到那里;只配 Sandbox,生产就什么都不发。两个都配。
服务器 handler:
// Express 示例
app.post("/apple/notifications", async (req, res) => {
const { signedPayload } = req.body;
// 先把 JWS 签名链一路校验到 Apple root CA,再解码。
const decoded = verifyAndDecodeJWS(signedPayload);
const { notificationType, subtype, data } = decoded;
switch (notificationType) {
case "SUBSCRIBED": // subtype: INITIAL_BUY 或 RESUBSCRIBE
case "DID_RENEW": // 含扣款失败后的恢复
await upsertEntitlement(data.originalTransactionId, "pro", data.expiresDate);
break;
case "DID_FAIL_TO_RENEW":
// subtype 为 GRACE_PERIOD 表示 grace 期内仍保留权限,否则无权限。
await markBillingIssue(data.originalTransactionId, subtype, data.gracePeriodExpiresDate);
break;
case "GRACE_PERIOD_EXPIRED": // grace 用完且恢复失败
case "EXPIRED":
case "REFUND":
case "REVOKE": // Family Sharing / 退款撤权
await revokeEntitlement(data.originalTransactionId);
break;
// ... 处理剩下约 20 种通知类型(DID_CHANGE_RENEWAL_STATUS 等)
}
res.status(200).send();
});
每个 handler 都要幂等——Apple 对任何非 200 都会重试,同一事件也可能到达不止一次。
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 对已知漏网者对账
对状态可疑的用户(客服投诉过的,或被下面 job 标记的),调 App Store Server API 的 Get All Subscription Statuses 接口,从 Apple 拿权威当前状态,不依赖你的 webhook 有没有触发过:
GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{transactionId}
Authorization: Bearer <用 App Store Connect API key 签出的 ES256 JWT>
截至 2026 年 6 月,path 里可以传该用户的任意 transactionId(不再必须是 originalTransactionId)。返回里每个订阅带一个数字 status:1 有效、2 过期、3 扣费重试中、4 grace 期、5 已撤销。把 1 和 4 当作有权限;2、3、5 当作无权限。沙盒环境改用 https://api.storekit-sandbox.itunes.apple.com。
排一个每日对账 job,对 expiresAt < now 的用户在降级前先向 Apple 查一次——这会兜住你 webhook 漏掉的每一个事件。
怎么确认已经修好
- 沙盒账号购买后,App 约 5 秒内反映 Pro。
- 取消的沙盒订阅在过期时刻一过就停止有权限。
- 退款的 transaction 立即在服务端撤权。
- 处于扣费 grace 期的用户在 grace 结束前仍看到 Pro(状态
4),结束后才失去。 - 对一个已知正常的用户查 App Store Server API 返回
status: 1,且和你库里的行一致。 - 过去 24 小时服务器日志里,每条收到的通知都返回了 200。
- 每日对账 job 跑起来不再发现成规模的过期未清 entitlement。
如果还是没修好
- 挑一个受影响用户,拿他任意一个
transactionId直接调 App Store Server API,把返回的status和expiresDate跟你库里的行 diff。差异会精确告诉你漏了哪个事件。 - 查 Apple System Status 有没有 App Store / 内购相关故障;沙盒通知投递比生产不可靠,还可能延迟。
- 确认 webhook 公网可达且能快速返回 200。剥掉 body 的反向代理、超时的慢 handler、对重复事件返回非 200,都会让 Apple 退避重试。
- 加一个 “Refresh Entitlement” 客服按钮,按需调 Get All Subscription Statuses——客服侧不动代码、不发版就能挽救个案。
预防建议
- Entitlement 当服务端持有、带显式过期时间的授予;永远不是设备 flag。
- 每个通知事件用
originalTransactionId+ correlation ID 记日志便于追溯。 - 每日对账 job 用 App Store Server API 查接近过期的用户。
- 加 “Subscription Debug” 屏(仅内部 / 客服可见)展示服务端状态、最近通知、Apple 当前视图。
- CI 集成测试覆盖完整生命周期:买 → 续 → 取消 → 过期 → 退款,用
.storekit配置文件。Xcode 的 StoreKit 测试能在本地触发一次续费失败和 grace 期,让你不等真实扣费就验证状态4这条路径。
常见问答
用户付款后 App 先显示免费几秒,然后自己纠正过来,是 bug 吗?
只要几秒内自己纠正就是正常的——这就是原因 5 里的竞态。通知或 Transaction.updates 事件还没到你服务器。把购买后的页面改成轮询几次 entitlement 接口(或先用本地已验证的 transaction 乐观放权),别在弹窗消失后 50ms 只拉一次。
客户端 entitlement 该缓存多久? 要短——最多 60 秒——而且每次回前台、每次展示付费页之前都刷新。服务器是真源,客户端缓存只是为了避免每次点击都走一次网络。
已经监听了 Transaction.updates,还需要 App Store Server Notifications 吗?
需要。Transaction.updates 只在某台登了该 Apple ID 的设备正在运行你 App 时才触发。对于当前不在 App 里的用户,退款、过期、扣费失败只能靠服务端通知得知。两路都接,再用 App Store Server API 对账。
为什么用户扣费失败后还能用好几天 Pro?
那是扣费 grace 期在按设计工作(状态 4)。长度是你在 App Store Connect 里选的——截至 2026 年 6 月为 3、16 或 28 天——这段时间 Apple 一直在重试卡。要在 GRACE_PERIOD_EXPIRED 时才断权限,而不是在原始的 expiresDate。
发版后一整批用户掉到免费,怎么回事?
几乎一定是原因 7:你改了产品名,新的 entitlement 映射表里没列旧 productID。把所有历史 product ID 都加进映射表,再跑对账 job 重新放权。
不花真钱能把这一整套测一遍吗?
能。用一个沙盒 Apple Account 加上 Xcode 的 .storekit 配置文件。沙盒续费是加速的(月订阅几分钟就续一次),Xcode 还能强制触发 grace 期、扣费重试和退款,让你把整个状态机跑一遍。
相关阅读
标签: #排查 #App Store #App 审核 #IAP / 内购 #订阅