云函数调用INVALID_APP_SIGN根因分析
## 1. 问题背景
1. 问题背景
用户点击「订阅」后,连续多次出现 [INVALID_APP_SIGN] jwt must be provided 错误,所有云函数调用(apple-iap-verify、check-subscription)均失败,订阅流程完全中断。用户感知为不断弹出错误提示,订阅无法完成。
日志中可见矛盾现象:getAccessToken 和 waitForAuthToken 均显示成功获取到 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 |
组件本地 isSubscribed、currentPlan state |
refreshEntitlement() 在购买/恢复成功后调用 |
useSubscription hook |
每个 hook 实例独立的 status state |
mount 时、refresh() 调用时 |
三个数据源完全独立,无任何共享:
refreshEntitlement()只更新VipSubscriptionModal内部 state,不通知任何其他订阅方AccountSettingsPage的loadEntitlements()只在自身弹窗打开时触发,不感知VipSubscriptionModal内的购买成功事件- 无任何全局 invalidate 机制(对比:
ai-points-invalidate.ts有invalidateAiPointsDisplay()广播积分余额刷新)
典型不一致场景:
用户完成订阅购买 → refreshEntitlement 更新 VipSubscriptionModal 的 isSubscribed = true → 用户关闭订阅弹窗返回设置页 → AccountSettingsPage 的 subStatus 仍是旧的(未刷新)→ 设置页仍显示「免费」
额外问题:refreshEntitlement 的重试逻辑与 AccountSettingsPage 独立
VipSubscriptionModal的refreshEntitlement最多重试 10 次(每次间隔 400ms)AccountSettingsPage的loadEntitlements只查 1 次- 两者的调用时机和重试次数不同,可能出现:
VipSubscriptionModal查到付费,AccountSettingsPage查到免费(取决于谁的查询先完成/重试到)
方案:
- 短期方案:在
VipSubscriptionModal购买/恢复成功后,通过 EventEmitter 或全局回调通知AccountSettingsPage刷新。 - 长远方案:引入 React Context 统一管理订阅状态,所有读取统一来源,所有写入触发全局 invalidate,类似已有的
ai-points-invalidate.ts模式。
采纳方案:长远方案(订阅全局 Context + invalidate 机制)。
已实施的长远方案实现:
- 新增
src/lib/subscription-invalidate.ts:参照ai-points-invalidate.ts模式,提供subscribeSubscriptionInvalidate()监听和invalidateSubscriptionDisplay()广播。 - 新增
src/contexts/SubscriptionContext.tsx:全局订阅 Context,SubscriptionProvider在 mount 时加载订阅状态,同时监听 invalidate 广播自动刷新。 - 改造
VipSubscriptionModal.tsx:移除本地isSubscribed/currentPlanstate,改为调用notifySubscriptionChanged()广播刷新。 - 改造
AccountSettingsPage.tsx:移除本地subStatus/loadEntitlements,改为使用useSubscriptionContext()。 app/_layout.tsx:AuthProvider内嵌套挂载SubscriptionProvider,全 app 生效。- 删除
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 的完整链路:
- 用户订阅
¥68.8专业版,Apple 服务器触发 renewal - App 连接 Apple 时,StoreKit 推送 12 个 historical transactions(每个 renewal 周期各 1 个过期交易)
- 每个过期交易有不同的
originalTransactionId,processingRefSet 防重不生效 - 每个交易调用
verifyApplePurchaseAndSync,云函数返回entitled: false - 每个
entitled: false弹一次 Alert → 连续 12 次
额外问题:duplicate-purchase 错误无专门 handler
purchaseErrorListener 中没有 DUPLICATE_PURCHASE 分支,错误走到兜底 Alert。
方案:
- Buffer + defer 策略:
purchaseUpdatedListener将所有 historical transactions 存入pendingVerifyRefMap(key 为 productId),等待推送完成(200ms debounce)后,只取每 productId 第一条进行 verify;其余直接finishTransaction跳过 DUPLICATE_PURCHASEhandler:静默忽略,恢复购买时正常现象- Alert 精简:只有
anyEntitled=true(订阅成功)才弹「已开通会员」,只有非 duplicate 的真实错误才弹失败,历史过期 transaction 的entitled=false不弹任何 Alert
已实施修复(VipSubscriptionModal):
- 新增
pendingVerifyRef(Map)buffer +pendingProcessingRef防重:所有purchaseUpdatedListener推送的交易先存入 Map,200ms debounce 后只处理第一条 - 新增
processPendingPurchases():统一处理 buffer,只取anyEntitled或anyError时弹一次 Alert,历史过期 transaction 的entitled=false完全不弹 - 补充
DUPLICATE_PURCHASE/E_DUPLICATE_PURCHASEhandler:静默 return - Alert 文案改为:「订阅已到期,如需继续使用请重新订阅。」
3. 小结
INVALID_APP_SIGN 问题:authReadyPromise(认证对象初始化完成)和 token 就绪是两层不同状态,functions.invoke() 只等了前者就发请求,导致 race condition。长远修复:在 functions.invoke() 内部用 waitForAuthToken() 替代 authReadyPromise,同时 invokeFunctionSafe 增加 INVALID_APP_SIGN 重试兜底。
订阅状态不一致问题:订阅状态在 AccountSettingsPage、VipSubscriptionModal、useSubscription 中各维护一份独立本地 state,无全局状态管理和失效机制。长远修复:引入订阅全局 Context(SubscriptionContext)+ invalidate 广播机制,参照已有的 ai-points-invalidate.ts 模式,在订阅状态变更时广播 invalidate,使全 app 订阅状态同步。
连续多次 Alert 问题:沙盒订阅续期时 StoreKit 推送多个历史过期 transaction,processingRef 防重不生效,且 entitled=false 文案模糊。修复:hasShownExpiredAlertRef 防抖 + 明确文案 + DUPLICATE_PURCHASE handler。