事故复盘:Stripe 与 Google/Apple IAP 并存时的 `plans.plan` 一致性修复)
Readest 跨计费提供方订阅权益互覆Plan Clobber事故复盘Stripe 与 Google/Apple IAP 并存时的plans.plan一致性修复【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest导读本文复盘 Readest 生产环境中的一次真实事故用户的plans.plan由 Stripe webhook 与 Google Play / Apple App Store 两套内购IAP处理器各自独立写入而每一方都只查询自己的提供方导致“后触发的一方说了算”——用户在 Stripe 上取消订阅会把仍在 Google Play 付费的 Pro 用户直接降级为 free并触发 500 MB 免费额度下的存储锁死。文章从事故现场、根因分析、修复方案新增resolveUserPlan统一裁决入口到测试与遗留问题完整还原该缺陷在 Readest 支付体系中的成因与解决路径读完你将掌握“多提供方并存时订阅权益的收敛写模型”这一实战范式。一、事故现场plans.plan被谁覆写Readest 的订阅权益落在plans表的单列plan上。围绕它存在多个写入方而每个写入方只感知自己的计费提供方写入方触发场景只查询的数据源stripe/server.ts 的createOrUpdateSubscriptionStripe 订阅创建/更新stripe.subscriptions.list({ customer })stripe/webhook/route.ts 的取消处理customer.subscription.deleted同一 Stripe customer 的订阅iap/google/server.ts 的createOrUpdateSubscriptionGoogle Play 内购事件google_iap_subscriptions表iap/apple/server.ts 的createOrUpdateSubscriptionApple App Store 内购事件apple_iap_subscriptions表问题在于没有一方检查“其他提供方是否仍有权”。于是“哪个提供方的事件最后落地plan就以它为准”。文档以已确认的生产事故记录2026-09-16 在处理一张同时被 Stripe 与 Google Play 扣费的用户工单时暴露当日修复并合入 PR #6228说明这不是理论推演而是实际发生的故障。当前仓库代码中修复后的取消路径可见于 webhook/route.tsconst plan await resolveUserPlan(subscriptionData.user_id, { stripeCustomerId: subscriptionData.stripe_customer_id, }); await supabase .from(plans) .update({ plan, status: plan free ? cancelled : active, }) .eq(id, subscriptionData.user_id);注意status不再是写死的cancelled只有plan真降到free时才标cancelled否则仍保留active——这正是对“取消一处、他处仍付费”场景的直接回应。二、事故还原取消 Stripe 订阅导致 Google Play 用户被降级文档记录的事故链路如下用户在迁移计费通道从 Stripe 银行卡迁移到 Google Play后同时持有两个订阅Stripe Pro 与 Google Play Pro。用户取消 Stripe 端的订阅冗余订阅Play 端仍在正常扣费。取消事件落入handleSubscriptionCancelled处理逻辑。修复前的实现只向 Stripe 查询该 customer 的订阅列表得到“已无有效订阅”后将plans行写为planfree, statuscancelled。降级在同一秒内生效尽管google_iap_subscriptions中仍有一条状态为active的 Google Play Pro。后果free 额度为 500 MB而该用户已存约 9.5 GB 书籍立即触发“insufficient storage”存储空间不足错误用户被锁在库外。由此可以提炼出本缺陷的两条方向性影响文档称之为 blast radius即爆炸半径Play → Stripe 方向用户在 Play 取消或到期时Stripe 仍在扣费也会被降级且立即发生。Stripe → Play 方向用户在 Stripe 取消时Play 仍在有效期但降级同样立即发生反过来若先到期的是 Play则问题在 Play 的到期日才显现。也就是任何一个方向的“旧提供方订阅终结事件”都会抹掉“新提供方仍有效的权益”。三、根因单列多写入方 单提供方视角3.1 Stripe 侧getHighestActivePlan只看 Stripestripe/server.ts 的getHighestActivePlan是 Stripe 侧的唯一裁决函数const { data: subscriptions } await stripe.subscriptions.list({ customer: customerId, status: all, limit: 100, }); const activeSubscriptions subscriptions.filter((sub) [active, trialing].includes(sub.status), );它在同一个 Stripe customer 内部做了正确的“多订阅取最高档”例如 Plus 升级 Pro 后、旧 Plus 尚未取消的重叠期但对 Google/Apple 毫无感知。修复前取消路径直接以它的返回值为最终plan于是“Stripe 全部取消”就被等价成了“用户没有订阅”。3.2 IAP 侧createOrUpdateSubscription无 Stripe 意识Google 侧修复前的写法文档标注为 iap/google/server.ts 的写入逻辑可概括为plan: isEntitledStatus(status) ? plan : free即“本次事件是否仍然 entitled就写什么档位”。一个真实的 PlayEXPIREDRTDN实时开发者通知会直接流入该逻辑把plan改成free完全不知道用户可能还有一张在跑的 Stripe 订阅。3.3 为什么已有的“降级防护”挡不住iap/google/notifications.ts 中确实存在一道防护当 Play API重新验证失败时只有终态/宽限类事件REVOKED、EXPIRED、ON_HOLD、PAUSED、IN_GRACE_PERIOD才允许降级其余情况抛出异常交给 Pub/Sub 重试const isDowngradeEvent [ SubscriptionNotificationType.REVOKED, SubscriptionNotificationType.EXPIRED, SubscriptionNotificationType.ON_HOLD, SubscriptionNotificationType.PAUSED, SubscriptionNotificationType.IN_GRACE_PERIOD, ].includes(notificationType); if (!isDowngradeEvent) { throw new Error( Google re-verification failed for notification type ${notificationType}: ${verificationResult.error}, ); }但这道闸只约束“重新验证失败”这一种分支。当 Play 的EXPIRED通知能被正常验证时事件会直接走processPurchaseData → createOrUpdateSubscription的常规路径此时该防护完全不介入——降级照常发生。这正是文档强调“does NOT help”的原因它抑制的是验证失败的降级而不是“真实验证成功但用户在别处仍付费”的降级。四、修复方案resolveUserPlan统一收敛入口修复的核心是新增 entitlements.ts暴露唯一裁决函数resolveUserPlan并在全部四个plans.plan写入方接入4.1 计划档位排序const PLAN_RANK: RecordUserPlan, number { free: 0, purchase: 0, plus: 1, pro: 2, }; export const higherPlan (a: UserPlan, b: UserPlan): UserPlan PLAN_RANK[b] PLAN_RANK[a] ? b : a;purchase一次性买断与free同级plus pro。higherPlan是收敛比较器无论各提供方返回什么档位最终都取其最大值。4.2 读取所有提供方的当前权益const IAP_SUBSCRIPTION_TABLES [google_iap_subscriptions, apple_iap_subscriptions] as const; const ENTITLED_STORED_IAP_STATUSES [active]; export const getHighestActiveIapPlan async (userId: string): PromiseUserPlan { const supabase createSupabaseAdminClient(); const results await Promise.all( IAP_SUBSCRIPTION_TABLES.map((table) supabase .from(table) .select(product_id, status) .eq(user_id, userId) .in(status, ENTITLED_STORED_IAP_STATUSES), ), ); // ...对每行 higherPlan(mapProductIdToUserPlan(row.product_id, true)) };IAP 侧不再实时调 Play/App Store API而是读仓库内已持久化的google_iap_subscriptions/apple_iap_subscriptions行——因为每个 IAP handler 在写plans之前都会先 upsert 自己的行所以“读到即当前”。Stripe 侧则由getHighestActiveStripePlan惰性加载 stripe/server.ts 的getHighestActivePlan动态import避免 IAP handler 在模块加载时拖入 Stripe SDK同时规避两个模块互相引用的循环依赖。4.3 顶层裁决函数export const resolveUserPlan async ( userId: string, options: { stripeCustomerId?: string; // 已知的 Stripe customer跳过 customers 查询 entitledPlan?: UserPlan; // 调用方从事件中已知的 entitlement } {}, ): PromiseUserPlan { const [stripePlan, iapPlan] await Promise.all([ getHighestActiveStripePlan(userId, options.stripeCustomerId), getHighestActiveIapPlan(userId), ]); return higherPlan(higherPlan(stripePlan, iapPlan), options.entitledPlan ?? free); };语义Stripe 最高档、双 IAP 表最高档、事件自带 entitlement三者取最大。于是无论哪一个提供方的事件先落地、后落地plans.plan都收敛到“全渠道最高权益”彻底消除“后写者胜出”的竞态。接入点验证Stripe 订阅创建/更新stripe/server.ts 以resolveUserPlan(userId, { stripeCustomerId: customerId })写plansStripe 取消webhook/route.ts见第一节代码Google Playiap/google/server.tsAppleiap/apple/server.ts。五、关键 GOTCHA账单宽限期被存储为expired修复中第一个被既有测试逮住的坑IAP 表在 upsert 时只保留两态词汇status: purchase.status active ? active : expired,见 iap/google/server.ts 与 iap/apple/server.ts。后果一个处于账单宽限期grace period的订阅用户明明仍有权访问却以expired落库。因此修复后的 IAP handler 不能“读回自己刚写的行”来判断本次 entitlement——行里是expired会把正在处理的提供方误判为无权益。解决方式是 handler 把事件中已知的 entitlement显式传入const plan await resolveUserPlan(userId, { entitledPlan: isEntitledStatus(purchase.status) ? mapProductIdToUserPlan(purchase.productId, true) : free, });这正是resolveUserPlan第三个输入来源存在的意义。文档同时指出代码库中没有其他位置读取该status列因此未来把存储词汇表扩宽例如引入in_grace_period是可行的当时未做是因为该表 schema 不在本仓库内无法排除数据库端存在 CHECK 约束。六、二次陷阱读取失败绝不能当作“无订阅”在修复本身内部CodeRabbit 又逮回了一个同族 bug提交 fb3ec39fccustomers查找把返回的error丢弃了而 supabase-js 客户端并未配置throwOnError一次失败的读取会静默返回空结果最终落入free——又把付费用户降级了。当前 entitlements.ts 中的正确处理是const { data, error } await supabase .from(customers) .select(stripe_customer_id) .eq(user_id, userId) .maybeSingle(); if (error) throw error;两个要点maybeSingle()而非single()single()会把“查无此客户”的空结果当作错误上报从而无法区分“该用户从未有过 Stripe customer”与“查询真的失败了”maybeSingle()对空结果返回null行错误则如实返回。任何error立即throw让上游 webhook 失败并交给提供方重试而不是把失败误判成free落库。同理getHighestActiveIapPlan中对两张 IAP 表的读取也遵循if (error) throw errorentitlements.ts。由此沉淀出的仓库级规则值得单列凡是“空结果会导致 entitlement 降级”的读取必须 throw绝不返回 empty。七、测试验证跨提供方权益测试套件本次修复配套的测试位于 cross-provider-entitlement.test.ts它用 mock 的 Stripe SDK 与 Supabase 客户端同时注入三路状态逐一验证测试场景期望结果Stripe 无订阅Google Play Pro 仍activeresolveUserPlan返回proGoogle Play 已expiredStripe Pro 仍active返回proApp Store 权益是唯一存活方返回pro各提供方档位不一致Stripe plus Play pro取最高档pro存储行为expired、但事件携带entitledPlan: plus返回plus宽限期场景所有提供方均无权益返回freecustomers查询返回errorreject抛错而非降级用户从未有过 Stripe customer返回pro且不调用subscriptions.list并额外覆盖两个 handler 级回归“取消最后一张 Stripe 订阅但 Play 仍活跃时最终写入plan为pro”精确复刻生产事故以及“所有提供方都消失时才落free”。这套测试同时锁死了 Google 侧“Play 到期但 Stripe 仍活跃”的反向场景。八、遗留问题与运维规范8.1 仍未处理的存量数据文档明确标注STILL OPEN2026-09-16 修复上线之前就已迁移提供方的用户可能已处于“付费中但planfree”的状态。排查方案是一次只读扫描——把plans.plan与 Stripe 订阅、google_iap_subscriptions、apple_iap_subscriptions三路权益源逐一比对以量化受损规模该扫描在记录事故当日尚未执行。8.2 事故修复的正确时序对单个用户的修复是plans.update({plan:pro,status:active})但必须在降级 webhook落地之后执行且要先轮询确认降级已发生再修复——否则后到的 webhook 会把修复覆盖回free。这是“修复动作与异步事件流竞速”时的标准处理姿势。8.3 善意存储授权必须走合成支付行文档强调给用户的善意存储额度goodwill storage grant必须是合成payments行provider: readest storage_gb: N status: completed随后重跑updateUserStorage重算——绝不直接写plans.storage_purchased_bytes因为该字段由 storage.ts 的updateUserStorage派生并覆盖直接写会被下一次重算抹掉。此外注意连锁反应任何 0 GB的授权都会触发shouldGrantGraceCustomizationstorage.ts 中graceEnabled !alreadyEntitled totalStorageGB 0在宽容期内永久解锁 Full Customization。相关设计细节可继续阅读 storage-customization-entitlement-split。九、同类问题的家族关系与启发plans.plan覆写属于“多写入方共享一行、各自只见局部真相”的经典并发问题与仓库中记录的另外两起事故同族group-metadata-row-lww-clobber-5911-5912元数据行的 LWW后写胜出覆写google-rtdn-worker-verify-downgrade-incidentGoogle RTDN 验证与降级相关事故。它们在工程上给出的一致教训是当多个异步事件源都要写同一个派生状态时任何“只基于单一数据源计算最终值”的写入都是隐患。正确的做法要么是收敛到单一裁决函数Readest 选择的方式要么引入版本/向量时钟做冲突合并而裁决函数内部又必须对所有“空结果即降级”的读取保持零容忍fail-closed。附涉及的核心文件索引修复主体entitlements.tsPLAN_RANK/higherPlan/getHighestActiveIapPlan/getHighestActiveStripePlan/resolveUserPlanStripe 侧写入方stripe/server.tsgetHighestActivePlan、createOrUpdateSubscriptionStripe 取消路径stripe/webhook/route.tsGoogle IAP 写入方iap/google/server.tsApple IAP 写入方iap/apple/server.tsRTDN 降级防护iap/google/notifications.ts存储与自定义权益派生storage.ts回归测试cross-provider-entitlement.test.ts事故记忆原文cross-provider-plan-clobber-stripe-google.md【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考