better-auth Stripe 插件演进全解析:订阅生命周期回调、组织计费修复与安全加固
【免费下载链接】better-authThe most comprehensive authentication framework项目地址: https://gitcode.com/GitHub_Trending/be/better-auth
导读
本文以 packages/stripe/CHANGELOG.md 为核心骨架,系统梳理@better-auth/stripe从 1.6.0 到 1.7.3 的关键演进:订阅生命周期回调的语义统一、组织订阅与按席位计费的正确性修复、Checkout 会话参数的保护边界,以及元数据合并的安全加固。读完本文,你将掌握该插件各版本的行为差异、底层实现原理与升级迁移要点,并能在实际项目中准确使用onSubscriptionCancel、getCheckoutSessionParams等核心 API。
一、版本脉络与演进主线
@better-auth/stripe是 Better Auth 官方维护的 Stripe 计费集成插件(安装命令npm install @better-auth/stripe),其 CHANGELOG 记录了从 1.6.0 到 1.7.3 的完整变更。纵观这些变更,可以提炼出四条清晰的主线:
| 演进主线 | 代表版本 | 核心变更 |
|---|---|---|
| 订阅生命周期回调语义统一 | 1.6.10 / 1.7.0 | 所有回调统一接收stripeSubscription(原始 Stripe 对象)与"更新后"的订阅行;onSubscriptionCancel的event参数改为必填 |
| 组织订阅正确性修复 | 1.6.17 / 1.6.21 / 1.6.24 | 组织订阅操作不再作用到错误的组织;删除组织时校验全部订阅;钩子上下文正确透传 |
| Checkout 与同步逻辑加固 | 1.6.10 / 1.6.17 | 插件内部管理的字段不再被用户覆盖;/subscription/success改为从 Checkout Session 同步;取消只删除目标订阅行 |
| 安全与健壮性 | 1.6.3 / 1.6.17 | 元数据合并防护原型污染;returnUrl校验trustedOrigins |
下文将沿着这四条主线,结合 packages/stripe/src 下的源码逐一展开。
二、订阅生命周期回调体系:语义统一是 1.6.x–1.7.x 的主旋律
订阅生命周期回调是插件的核心扩展点,定义在 packages/stripe/src/types.ts 的SubscriptionOptions中。当前完整的回调族包括:
onSubscriptionComplete:Checkout 完成后触发,携带event、stripeSubscription、subscription、plan;onSubscriptionCreated:customer.subscription.created事件触发(如从 Stripe Dashboard 直接创建订阅);onSubscriptionUpdate:每次订阅更新事件触发;onSubscriptionCancel:订阅进入"待取消"状态(cancel_at_period_end或计划中的cancel_at)时触发一次;onSubscriptionDeleted:订阅删除事件触发;onTrialStart/onTrialEnd/onTrialExpired:免费试用三个阶段的回调(定义在StripePlan.freeTrial内)。
2.1 1.6.10:回调参数形态统一
CHANGELOG 中 1.6.10 的三条变更直接塑造了今天回调的签名:
onSubscriptionUpdate新增stripeSubscription——回调现在能拿到"原始 Stripe 对象",用于读取本地订阅行未持久化的字段(如cancellation_details、latest_invoice等)。onSubscriptionCancel改为接收更新后的订阅行,而不再是更新前的快照。onSubscriptionDeleted、onTrialEnd、onTrialExpired同样改为接收更新后的订阅行,与其他生命周期回调保持一致。
从 packages/stripe/src/hooks.ts 的实现可以看到这套语义的落地:在onSubscriptionUpdated中,插件先用ctx.context.adapter.update将最新状态写入数据库,得到subscriptionUpdated后再触发onSubscriptionCancel(仅当"新进入待取消状态"时)、onSubscriptionUpdate、onTrialEnd/onTrialExpired(仅当状态从trialing迁移时)。这意味着你的回调读到的订阅行永远与数据库一致,无需自行回查。
2.2 1.7.0:onSubscriptionCancel的event参数变为必填(破坏性变更)
1.7.0 的 Minor Changes 明确要求:onSubscriptionCancel回调的event参数现在是必填的,与其他订阅生命周期回调保持一致。升级到 1.7.0 时,你需要:
subscription: { enabled: true, plans, onSubscriptionCancel: async ({ event, subscription, stripeSubscription }) => { // 1.7.0 之前 event 可能为 undefined,需要 !event 守卫; // 现在 event 必定存在,直接使用即可 await trackCancellation(event, subscription); }, },即:声明event为必选参数,并删除原先围绕它的undefined守卫代码。
2.3 Webhook 驱动:回调与数据库同步的底层链路
这些回调全部由 Stripe Webhook 驱动。在 packages/stripe/src/routes.ts 中,stripeWebhook端点将事件分发给 packages/stripe/src/hooks.ts 中的四个处理器:onCheckoutSessionCompleted、onSubscriptionCreated、onSubscriptionUpdated、onSubscriptionDeleted。
以onSubscriptionCreated为例(hooks.ts),其处理顺序为:解析事件对象 → 通过subscriptionMetadata查找本地订阅 ID → 若已存在则跳过(保证幂等)→ 通过stripeCustomerId反查 user 或 organization → 用resolvePlanItem匹配计划 → 写入本地subscription表 → 触发onSubscriptionCreated。其中resolvePlanItem(utils.ts)会同时匹配priceId、annualDiscountPriceId及 lookup key,这是插件能把 Stripe 订阅"翻译"成本地计划名的关键。
本地订阅表的字段定义在 packages/stripe/src/schema.ts:plan、referenceId、stripeCustomerId、stripeSubscriptionId、status(默认incomplete)、periodStart/periodEnd、trialStart/trialEnd、cancelAtPeriodEnd、cancelAt/canceledAt/endedAt、seats、billingInterval、stripeScheduleId。其中stripeScheduleId用于记录"计划变更排期"(scheduleAtPeriodEnd场景),Webhook 更新时会同步写入。
三、组织订阅与按席位计费:1.6.17 / 1.6.21 / 1.6.24 的正确性修复
组织订阅是插件最复杂的场景,CHANGELOG 中多条修复都集中于此。
3.1 1.6.21:订阅操作不再作用于错误的组织
1.6.21 修复了"取消、升级、恢复订阅以及计费门户可能作用于错误组织"的问题。其根源在于 packages/stripe/src/middleware.ts 的referenceMiddleware:当customerType === "organization"时,referenceId取请求体/查询参数中的显式值,否则回退到session.activeOrganizationId;随后必须通过authorizeReference校验该referenceId确实属于当前用户,未配置authorizeReference时直接抛出ORGANIZATION_SUBSCRIPTION_NOT_ENABLED/AUTHORIZE_REFERENCE_REQUIRED错误。
配套的修复体现在cancelSubscription、restoreSubscription、upgradeSubscription三个端点中:当请求携带subscriptionId时,插件会先按stripeSubscriptionId查出订阅行,并校验subscription.referenceId === referenceId,不匹配即视为未找到(routes.ts),从源头杜绝跨组织操作。
3.2 1.6.17:取消/恢复只作用于目标订阅,组织删除校验全量订阅
1.6.17 的修复条目非常密集,核心有三点:
- 取消订阅只删除目标行:此前取消操作会删除所有共享同一
referenceId的订阅行;现在遇到过期数据(resource_missing)时,仅删除subscription.id对应的那一行(routes.ts),不再误伤同一引用下的其他订阅。 - 恢复订阅精确瞄准:
restoreSubscription通过retrieveStripeSubscription读取stripeSubscriptionId指向的具体订阅,并据其cancel_at/cancel_at_period_end状态精确清理,而不是"客户的第一份活跃订阅"。 - 组织删除检查全量订阅:在 packages/stripe/src/index.ts 的
beforeDeleteStripeOrg中,插件用for await自动分页遍历该组织客户的全部订阅(limit: 100),只要存在非canceled/incomplete/incomplete_expired状态的订阅就抛出ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION,阻止删除。
3.3 1.6.24:组织删除钩子的上下文透传
1.6.24 修复了 organization 插件beforeDeleteOrganization/afterDeleteOrganization钩子签名不一致的问题:Stripe 插件的beforeDeleteOrganization包装层现在会把 endpoint context 作为第二个参数转发给用户提供的钩子,与文档及databaseHooks的既有模式对齐(index.ts)。如果你自定义过组织删除钩子并依赖 ctx 参数,此版本后行为才正确。
3.4 按席位计费(seat-based billing)
组织订阅还支持按席位计费:StripePlan.seatPriceId配合prorationBehavior(默认create_prorations)使用(types.ts)。成员变动时,插件通过afterAddMember、afterRemoveMember、afterAcceptInvitation三个组织钩子触发syncSeatsAfterMemberChange(index.ts):统计member表中的成员数,匹配seatPriceId,再调用client.subscriptions.update同步 quantity 并更新本地seats字段。注意:使用seatPriceId必须同时启用organization: { enabled: true },否则插件会在初始化时输出错误日志(index.ts)。
四、Checkout 会话参数:内部字段保护与免费试用修复(1.6.10)
getCheckoutSessionParams允许开发者为 Checkout Session 追加自定义参数(types.ts)。1.6.10 明确划定了它的权限边界:success_url、cancel_url、mode、customer、customer_email、client_reference_id、line_items这七个字段由插件内部管理,无法被覆盖。
在 routes.ts 中可以看到实现:插件先从params?.params中解构剥离这七个字段(_mode、_customer等),其余参数原样透传,再强制写入插件计算的值。这保证了 Webhook 对账(subscriptionMetadata中的referenceId/subscriptionId)和计费链路不被破坏。此外locale现在优先取请求体的值,其次才是getCheckoutSessionParams。
1.6.10 还修复了免费试用的两个隐藏缺陷:返回自定义subscription_data不再遮蔽计划的免费试用期(trial_period_days),也不会在customer.subscription.createdWebhook 触发时产生重复的本地订阅行。注意免费试用本身有"每个 reference 终身一次"的约束——插件通过检查该referenceId下所有历史订阅是否曾出现过trialStart/trialEnd或trialing状态来判断(routes.ts),防止用户通过切换计划反复薅试用。
五、安全加固:元数据合并防原型污染(1.6.3 / 1.7.0-beta.1)
1.6.3 与 1.7.0-beta.1 记录了同一项安全修复:插件此前通过defu深度合并ctx.body.metadata,当攻击者构造__proto__等键时存在原型污染风险。由于 Stripe metadata 本质是扁平的Record<string, string>,深度合并本无必要,因此修复后:
- 合并
ctx.body.metadata时忽略__proto__、constructor、prototype三个危险键,用户可控面不再依赖defu; - 仅保留开发者提供的
CustomerCreateParams深度合并场景使用经过补丁的defu范围(见 index.ts 中defu合并getCustomerCreateParams的部分)。
1.6.17 还同步加固了 URL 校验:/subscription/upgrade的returnUrl现在与/subscription/cancel、计费门户一致,通过originCheck中间件校验trustedOrigins(routes.ts),防止开放重定向。
六、客户创建与复用:1.6.17 的邮箱复用规则
当开启createCustomerOnSignUp时,插件在用户注册后自动创建 Stripe Customer(index.ts)。1.6.17 明确了"按邮箱复用客户"的边界条件:
- 仅当邮箱已验证时才按邮箱复用现有客户;未验证邮箱的注册会创建全新客户;
- 即使邮箱匹配,若该客户已通过 metadata(
customerMetadata中的userId)关联到其他用户,也不会复用,防止客户归属错乱。
复用逻辑优先使用 Stripe Search API(customers.search),失败时回退到customers.list分页遍历(搜索 API 在部分区域不可用)。客户查找与创建同时会调用onCustomerCreate回调,并同步写入用户的stripeCustomerId字段。
七、其他值得关注的变化
- 1.6.17:
/subscription/success同步来源变更——现在从 Checkout Session 同步订阅(读取checkoutSession.subscription再 retrieve),而不是"客户的第一份活跃订阅",避免在多订阅场景下同步错对象。 - 1.6.0:插件版本字段与年付价格修复——插件接口新增可选
version字段,所有内置插件对外暴露版本;同时修复了年度订阅在订阅列表中的priceId返回错误的问题。插件源码通过 packages/stripe/src/version.ts 的PACKAGE_VERSION注入该字段(index.ts)。 - 常规依赖同步:CHANGELOG 中大量条目为
better-auth与@better-auth/core的版本跟随(Patch Changes),升级插件时建议与核心包保持同版本基线,避免类型不匹配。
八、升级与迁移清单
- 升级到 1.7.0:将
onSubscriptionCancel的event参数改为必填,删除undefined守卫。 - 升级到 1.6.10+:确认
onSubscriptionUpdate/onSubscriptionCancel/onSubscriptionDeleted/onTrialEnd/onTrialExpired回调读取的是"更新后"的订阅行与原始stripeSubscription,如有基于旧快照的假设需调整。 - 升级到 1.6.21+:检查组织订阅操作是否依赖"错误组织"的旧行为;确认已配置
authorizeReference(组织订阅的硬性要求,见 middleware.ts)。 - 升级到 1.6.3+(安全相关):确认没有依赖
__proto__/constructor/prototype键作为业务 metadata 的代码。 - 升级到 1.6.17+:
getCheckoutSessionParams中若曾自定义success_url等七个保留字段,需改为在请求体的successUrl/cancelUrl或插件选项层配置。
结语
从 1.6.0 到 1.7.3,@better-auth/stripe的演进始终围绕三条原则:回调语义一致、组织/用户订阅边界清晰、插件内部状态不可被外部参数破坏。CHANGELOG 中的每一条修复都能在 packages/stripe/src 的源码(尤其 routes.ts、hooks.ts、middleware.ts)中找到对应实现,配合 packages/stripe/test 下的webhook.test.ts、subscription.test.ts、stripe-organization.test.ts、seat-based-billing.test.ts等测试,你可以完整验证这些行为。对正在接入或维护 Stripe 计费的团队而言,理解这些演进细节,能帮助你写出更健壮、更安全、可平滑升级的订阅业务代码。
【免费下载链接】better-authThe most comprehensive authentication framework项目地址: https://gitcode.com/GitHub_Trending/be/better-auth
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考