云函数调用INVALID_APP_SIGN根因分析

## 1. 问题背景

by 于成季 ·
知与行 CloudBase

1. 问题背景

用户点击「订阅」后,连续多次出现 [INVALID_APP_SIGN] jwt must be provided 错误,所有云函数调用(apple-iap-verifycheck-subscription)均失败,订阅流程完全中断。用户感知为不断弹出错误提示,订阅无法完成。

日志中可见矛盾现象:getAccessTokenwaitForAuthToken 均显示成功获取到 token(长度 799,格式正常),但紧接着云函数调用仍然报 jwt 为空。

补充问题:订阅状态没有全局共享,各处独立维护本地 state,导致购买成功后设置页仍显示旧状态,VIP 弹窗和设置页显示不一致。

2. 问题列表

2.1 问题一:云函数调用的 token 前置检查缺失

根因:

cloudbaseClient.functions.invoke() 的实现中,只等待了 authReadyPromise(认证对象初始化完成),但 authReadyPromise 完成 ≠ SDK 内部 token 就绪。

// cloudbase.ts(修复前)
invoke: (name, options) => {
  return (async () => {
    await authReadyPromise         // ← 只等认证对象初始化
    const res = await cloudbase.callFunction({ name, data: body }) // ❌ token 可能为空
  })()
}

这是两层不同的状态:认证对象存在,不代表 SDK 内部的 jwt token 已写入 callFunction 的请求头中。

方案:

  • 短期方案:在 functions.invoke() 内部 cloudbase.callFunction() 之前调用 waitForAuthToken()
  • 长远方案:重构 SDK 封装层,将 token 就绪保证作为云函数调用的内置契约,消除对外部调用方 waitForAuthToken 的依赖。

采纳方案:长远方案。

2.2 问题二:waitForAuthToken 与 callFunction 之间存在时间窗口

根因:

waitForAuthToken() 通过轮询 auth.getAccessToken() 确认 token 就绪后,到 cloudbase.callFunction() 实际发请求之间存在时间窗口。在此窗口内,SDK 的 token 可能还未被正确写入 HTTP 请求的认证头中,导致 callFunction 以空 jwt 发请求。

日志证据:

[cloudbase] getAccessToken: {tokenLength: 799, tokenPrefix: "eyJhbGciOiJSUzI..."}  ✅ SDK 有 token
[cloudbase] waitForAuthToken: 成功获取 token, 尝试次数: 1                         ✅ waitForAuthToken 认为已就绪
[cloudbase] invoke apple-iap-verify failed: [INVALID_APP_SIGN] jwt must be provided  ❌ 但 callFunction 实际用的是空 jwt

auth.getAccessToken() 能拿到 token,但 callFunction 内部使用的 jwt 为空——说明 SDK 层的 token 状态和 HTTP 请求头中的 token 是两个独立维护的状态,存在 race condition。

方案:

  • 短期方案:在 functions.invoke() 内部调用 waitForAuthToken(),在 callFunction 前再增加一次等待,确保发请求前 token 一定就绪。
  • 长远方案:在 SDK 封装层统一 callFunction 的 token 获取逻辑,确保 SDK 层拿到的 token 同步到 HTTP 请求头,消除状态不一致。

采纳方案:长远方案(functions.invoke() 内部用 waitForAuthToken() 替代 authReadyPromise)。

2.3 问题三:INVALID_APP_SIGN 错误无重试容错

根因:

invokeFunctionSafe 的错误处理将 INVALID_APP_SIGN 当作普通错误处理,统一抛出给用户「请求失败,请稍后重试」。实际上该错误表示认证 token 未就绪,应自动重试而非直接展示失败。

方案:

  • 短期方案:在 invokeFunctionSafe 的错误处理中,对 INVALID_APP_SIGN 特殊判断,触发一次 waitForAuthToken() 后重试一次云函数调用。
  • 长远方案:在底层 SDK 封装层彻底消除 race condition,让云函数调用天然具备 token 就绪保证,无需重试逻辑。

采纳方案:长远方案(invokeFunctionSafe 增加 INVALID_APP_SIGN 重试兜底)。

2.4 问题四:订阅状态无全局共享,各处状态不一致

根因:

订阅状态在各处独立维护本地 state,无全局状态管理和失效机制。

现状梳理:

组件/位置 订阅状态存储方式 刷新时机
AccountSettingsPage 组件本地 subStatus state 弹窗打开时、handleBackFromVip 关闭时
VipSubscriptionModal 组件本地 isSubscribedcurrentPlan state refreshEntitlement() 在购买/恢复成功后调用
useSubscription hook 每个 hook 实例独立的 status state mount 时、refresh() 调用时

三个数据源完全独立,无任何共享:

  1. refreshEntitlement() 只更新 VipSubscriptionModal 内部 state,不通知任何其他订阅方
  2. AccountSettingsPageloadEntitlements() 只在自身弹窗打开时触发,不感知 VipSubscriptionModal 内的购买成功事件
  3. 无任何全局 invalidate 机制(对比:ai-points-invalidate.tsinvalidateAiPointsDisplay() 广播积分余额刷新)

典型不一致场景:

用户完成订阅购买 → refreshEntitlement 更新 VipSubscriptionModalisSubscribed = true → 用户关闭订阅弹窗返回设置页 → AccountSettingsPagesubStatus 仍是旧的(未刷新)→ 设置页仍显示「免费」

额外问题:refreshEntitlement 的重试逻辑与 AccountSettingsPage 独立

  • VipSubscriptionModalrefreshEntitlement 最多重试 10 次(每次间隔 400ms)
  • AccountSettingsPageloadEntitlements 只查 1 次
  • 两者的调用时机和重试次数不同,可能出现:VipSubscriptionModal 查到付费,AccountSettingsPage 查到免费(取决于谁的查询先完成/重试到)

方案:

  • 短期方案:在 VipSubscriptionModal 购买/恢复成功后,通过 EventEmitter 或全局回调通知 AccountSettingsPage 刷新。
  • 长远方案:引入 React Context 统一管理订阅状态,所有读取统一来源,所有写入触发全局 invalidate,类似已有的 ai-points-invalidate.ts 模式。

采纳方案:长远方案(订阅全局 Context + invalidate 机制)。

已实施的长远方案实现:

  1. 新增 src/lib/subscription-invalidate.ts:参照 ai-points-invalidate.ts 模式,提供 subscribeSubscriptionInvalidate() 监听和 invalidateSubscriptionDisplay() 广播。
  2. 新增 src/contexts/SubscriptionContext.tsx:全局订阅 Context,SubscriptionProvider 在 mount 时加载订阅状态,同时监听 invalidate 广播自动刷新。
  3. 改造 VipSubscriptionModal.tsx:移除本地 isSubscribed/currentPlan state,改为调用 notifySubscriptionChanged() 广播刷新。
  4. 改造 AccountSettingsPage.tsx:移除本地 subStatus/loadEntitlements,改为使用 useSubscriptionContext()
  5. app/_layout.tsxAuthProvider 内嵌套挂载 SubscriptionProvider,全 app 生效。
  6. 删除 src/hooks/useSubscription.ts:已被 Context 替代。

最终架构:

订阅状态变更(购买/恢复/取消)
    └→ invalidateSubscriptionDisplay()
           └→ SubscriptionContext 收到 invalidate
                  └→ refresh() → 全 app 订阅状态同步

之前:购买成功 → VipSubscriptionModal 局部 state 更新 → 设置页不知道 现在:购买成功 → 广播 invalidate → SubscriptionContext 自动刷新 → 全 app 一致

2.5 问题五:订阅续期时历史过期 transaction 导致连续多次 Alert

根因:

App Store 订阅续期时,StoreKit 通过 purchaseUpdatedListener 会推送该 originalTransactionId 下所有历史交易(包括已过期的 renewal 历史)。代码中有 processingRef(Set)防重,但防重 key 是 originalTransactionId,而续期时每个 renewal 周期的过期交易有不同的 originalTransactionId,导致 12 个历史过期交易全部通过防重检查,逐个验证后弹 Alert。

云函数日志证据(RequestId: 8566950c):

[getSubscriptionStatusFromAppleApi] data 数组长度: 0
[main] Apple API 查询失败,从 JWS payload 备用获取信息
[main] expiresDateMs (UTC时间戳): 1776325497000
[main] 当前时间 (UTC): 2026-04-16T07:56:27.124Z
[main] isActive: false (过期时间戳 > 当前时间戳)
[main] 写入数据: { "status":"expired", ... }

Apple API 返回 data: [](空数组),说明服务器认为该 originalTransactionId 不在 status=1(活跃)或 status=4(宽限期)状态,只能从 JWS payload 取过期时间,最终 isActive = false

12 次 Alert 的完整链路:

  1. 用户订阅 ¥68.8 专业版,Apple 服务器触发 renewal
  2. App 连接 Apple 时,StoreKit 推送 12 个 historical transactions(每个 renewal 周期各 1 个过期交易)
  3. 每个过期交易有不同的 originalTransactionIdprocessingRef Set 防重不生效
  4. 每个交易调用 verifyApplePurchaseAndSync,云函数返回 entitled: false
  5. 每个 entitled: false 弹一次 Alert → 连续 12 次

额外问题:duplicate-purchase 错误无专门 handler

purchaseErrorListener 中没有 DUPLICATE_PURCHASE 分支,错误走到兜底 Alert。

方案:

  • Buffer + defer 策略:purchaseUpdatedListener 将所有 historical transactions 存入 pendingVerifyRef Map(key 为 productId),等待推送完成(200ms debounce)后,只取每 productId 第一条进行 verify;其余直接 finishTransaction 跳过
  • DUPLICATE_PURCHASE handler:静默忽略,恢复购买时正常现象
  • Alert 精简:只有 anyEntitled=true(订阅成功)才弹「已开通会员」,只有非 duplicate 的真实错误才弹失败,历史过期 transaction 的 entitled=false 不弹任何 Alert

已实施修复(VipSubscriptionModal):

  1. 新增 pendingVerifyRef(Map)buffer + pendingProcessingRef 防重:所有 purchaseUpdatedListener 推送的交易先存入 Map,200ms debounce 后只处理第一条
  2. 新增 processPendingPurchases():统一处理 buffer,只取 anyEntitledanyError 时弹一次 Alert,历史过期 transaction 的 entitled=false 完全不弹
  3. 补充 DUPLICATE_PURCHASE / E_DUPLICATE_PURCHASE handler:静默 return
  4. Alert 文案改为:「订阅已到期,如需继续使用请重新订阅。」

3. 小结

INVALID_APP_SIGN 问题authReadyPromise(认证对象初始化完成)和 token 就绪是两层不同状态,functions.invoke() 只等了前者就发请求,导致 race condition。长远修复:在 functions.invoke() 内部用 waitForAuthToken() 替代 authReadyPromise,同时 invokeFunctionSafe 增加 INVALID_APP_SIGN 重试兜底。

订阅状态不一致问题:订阅状态在 AccountSettingsPageVipSubscriptionModaluseSubscription 中各维护一份独立本地 state,无全局状态管理和失效机制。长远修复:引入订阅全局 Context(SubscriptionContext)+ invalidate 广播机制,参照已有的 ai-points-invalidate.ts 模式,在订阅状态变更时广播 invalidate,使全 app 订阅状态同步。

连续多次 Alert 问题:沙盒订阅续期时 StoreKit 推送多个历史过期 transaction,processingRef 防重不生效,且 entitled=false 文案模糊。修复:hasShownExpiredAlertRef 防抖 + 明确文案 + DUPLICATE_PURCHASE handler。

← 所有文章