saas-starter 从 0 到 1 打通订阅暂停与恢复

发布时间:2026/9/11 1:36:52
saas-starter 从 0 到 1 打通订阅暂停与恢复 saas-starter 从 0 到 1 打通订阅暂停与恢复【免费下载链接】saas-starterGet started quickly with Next.js, Postgres, Stripe, and shadcn/ui.项目地址: https://gitcode.com/GitHub_Trending/sa/saas-starter用户预算被冻结、业务进入淡季不想退订、只想把服务停几个月之后再续上——订阅暂停/恢复就是替他们把走掉变成歇着。saas-starter 基于 Next.js、PostgresDrizzle ORM与 Stripe 搭好了骨架下面顺着数据流把这条链路从数据库到前端完整走一遍。先画清楚状态怎么流转整个状态机真正的分歧点只有一处active与paused双向可逆canceled才是终态。这决定了实现形态——暂停和恢复都不需要重走 Checkout 或支付流程订阅对象还在只是状态字段在变本地存储要能接住这几种值事件回流路径要能区分它们。各组件分工teams表lib/db/schema.ts本地状态的事实源subscriptionStatus等字段存这里Stripe Billing Portal用户暂停/恢复的唯一操作入口Webhook 路由app/api/stripe/webhook/route.ts验签并把订阅事件分派下去handleSubscriptionChange把 Stripe 侧状态翻译后写回数据库团队页ManageSubscription卡片展示当前状态 跳转 Portal 的按钮把状态存进 teams 表数据层的原则是单一事实源 批量写回任何事件带来的状态变化都从同一个写回入口进库避免出现状态已暂停、套餐名却还是旧值的半截数据。teams表早已预留了 Stripe 侧字段暂停不需要动表结构只是状态枚举多出一个取值// lib/db/schema.ts节选 export const teams pgTable(teams, { id: serial(id).primaryKey(), name: varchar(name, { length: 100 }).notNull(), stripeCustomerId: text(stripe_customer_id).unique(), stripeSubscriptionId: text(stripe_subscription_id).unique(), stripeProductId: text(stripe_product_id), planName: varchar(plan_name, { length: 50 }), // 可取值active / trialing / paused / canceled / unpaid subscriptionStatus: varchar(subscription_status, { length: 20 }), // ...省略 createdAt / updatedAt });写回函数一次更新四个字段加时间戳保证事务语义完整// lib/db/queries.ts节选 export async function updateTeamSubscription( teamId: number, subscriptionData: { stripeSubscriptionId: string | null; stripeProductId: string | null; planName: string | null; subscriptionStatus: string; } ) { await db .update(teams) .set({ ...subscriptionData, updatedAt: new Date() }) .where(eq(teams.id, teamId)); }这一步的坑在于现有分支只覆盖active/trialing和canceled/unpaid两类paused若落进取消分支stripeSubscriptionId会被置空——恢复订阅时就再也查不到这个 team 了。暂停没事、恢复坏了多半是这个原因。打开 Portal 的暂停开关接住 Webhook 回调集成层的取舍不自建订阅管理页用户操作全部发生在 Stripe Portal 里状态变更全部走 Webhook 回流应用侧只做两件事——打开开关、接住事件。先打开开关。Portal 的 configuration 在首次调用时自动创建默认配置只开了改价与取消暂停入口要显式声明 pause 分支才会出现// lib/payments/stripe.tsconfiguration 的 features 部分 features: { subscription_update: { enabled: true, default_allowed_updates: [price, quantity, promotion_code], // ...省略 proration / products }, subscription_pause: { enabled: true, // 打开暂停入口 mode: immediate // 或 at_period_end本期账单收完再停 }, subscription_cancel: { enabled: true, mode: at_period_end } }再接住事件。暂停与恢复都通过customer.subscription.updated送达暂停是对象上的字段变化不是独立事件类型所以路由的分派逻辑不用动扩展点在handleSubscriptionChange。关键是 paused 分支只改状态字段、不清订阅关联// lib/payments/stripe.tshandleSubscriptionChange 节选 if (status active || status trialing) { // ...原有逻辑补齐 plan、productId 等字段 } else if (status paused) { await updateTeamSubscription(team.id, { stripeSubscriptionId: subscriptionId, // 保留关联恢复时靠它找回 stripeProductId: plan?.product as string, planName: (plan?.product as Stripe.Product).name, subscriptionStatus: paused }); } else if (status canceled || status unpaid) { // ...原有逻辑清空关联字段 }事件入口先用constructEvent验签失败直接 400再按event.type分派——现有的updated/deleted两个 case 已能覆盖暂停与恢复路由本身无需改动。给仪表盘加上暂停态提示交互层只干两件事把当前状态讲清楚、把操作交给 Portal。团队页的ManageSubscription卡片用 SWR 拉取团队数据把状态文案的判断从两态扩成能覆盖paused// app/(dashboard)/dashboard/page.tsx节选 p classNametext-sm text-muted-foreground {teamData?.subscriptionStatus active Billed monthly} {teamData?.subscriptionStatus trialing Trial period} {teamData?.subscriptionStatus paused Paused, billing on hold} {/* 其余状态显示 No active subscription */} /p form action{customerPortalAction} Button typesubmit variantoutlineManage Subscription/Button /form按钮提交的是 Server Action内部生成 Portal session 后redirect到 session URL动作外层套着 team 校验——没有团队的访客根本到不了 Portal权限控制天然落在这里。注意界面里刻意没有暂停/恢复按钮入口统一在 Stripe本地 UI 只做状态回声这样两端永远以 Stripe 为准不会出现按钮点了、库里没变的漂移。跑通一次暂停再恢复以用户视角走一轮完整流程登录进入团队设置页卡片显示Billed monthly点Manage SubscriptionServer Action 生成 Portal session302 到 Stripe 页面选 Pause 并选立即或本期末Stripe 将订阅置为paused发出customer.subscription.updatedWebhook 验签通过分派进handleSubscriptionChange的 paused 分支updateTeamSubscription写回 PostgressubscriptionStatus变为paused浏览器回到/dashboardSWR 重新拉取/api/team卡片切换为暂停文案再进 Portal 选 Resume流程原路反转核对落地效果避开几个坑动手验证验签拒绝curl -X POST http://localhost:3000/api/stripe/webhook -d {}应返回 400 并打印验签失败伪造事件进不了库触发事件先跑stripe listen --forward-to localhost:3000/api/stripe/webhook再stripe trigger customer.subscription.updated确认响应为{received:true}数据库断言psql $POSTGRES_URL -c SELECT subscription_status, stripe_subscription_id FROM teams;应显示paused且订阅 ID 非空UI 检查点暂停返回后团队卡片出现暂停提示恢复后回到Billed monthly手工兜底在 Stripe Dashboard 直接暂停一个订阅刷新页面本地状态应同步证明写回不依赖特定 UI 路径几个容易漏的坑幂等与乱序Webhook 会重试、事件可能乱序到达写库要按subscriptionId幂等收到updated时间戳比库中更早的事件直接丢弃。暂停语义选at_period_end时暂停窗口内 Stripe 侧 status 仍是active真正生效时间看pause_collection文案和功能降级都应读这个字段别只看 status。功能降级暂停用户不能只改文案仪表盘要按subscriptionStatus限制相关功能如禁用邀请、报表只读全局 middleware 是天然判断点。状态回补Webhook 丢失或服务宕机时本地状态会停在旧值可在进 Portal 或登录时调stripe.subscriptions.retrieve对账一次兜底。权限边界订阅操作应仅限 team owner现有 team 校验只保证有团队写操作前还要补 role 判断。链路接通后暂停与恢复只是状态机上两个可逆的转换用户的流失理由从用不起了变成暂时不用了——留存就是在这道缝里挣回来的。【免费下载链接】saas-starterGet started quickly with Next.js, Postgres, Stripe, and shadcn/ui.项目地址: https://gitcode.com/GitHub_Trending/sa/saas-starter创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询