购买后订阅 / entitlement 不一致:7 类原因 + 服务端权威修复

用户付了款但 App 里还是免费档;或者取消 / 退款后仍是付费。

用户发邮件给客服:“我刚付了 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。只在某个不是 initapplicationDidFinishLaunching 的地方调用过,就在漏事件。

3. App Store Server Notifications V2 没接或 webhook 坏

Apple 服务到服务发送所有事件(DID_RENEWDID_FAIL_TO_RENEWEXPIREDREFUNDDID_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。

如果还是没修好

  1. 挑一个受影响用户,拿 originalTransactionId 直接调 App Store Server API,和你库里数据 diff。差异告诉你漏了哪个事件。
  2. 查 Apple System Status 有没有通知投递问题;沙盒通知比生产不可靠。
  3. 确认 webhook 公网可达(只对 Apple IP),不要藏在 VPN 或防火墙后。
  4. 加一个 “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 / 内购 #订阅