楠渡余生楠渡余生
首页
笔记
作品集
留言板
关于
GitHub
CSDN
首页
笔记
作品集
留言板
关于
GitHub
CSDN
  • 前端开发

    • React Server Components(RSC)学习笔记

      • React Server Components(RSC)学习笔记
    • 全栈框架学习笔记

      • 全栈框架学习笔记
    • jQuery 学习笔记

      • jQuery 学习笔记
    • React 学习笔记

      • React 学习笔记
    • AJAX 学习笔记

      • AJAX 学习笔记
    • Axios 完整学习笔记

      • Axios 完整学习笔记
    • CSS 属性速查手册

      • CSS 属性速查手册
    • HTML5 与 CSS 综合学习笔记

      • HTML5 与 CSS 综合学习笔记
    • JavaScript 学习笔记

      • JavaScript 学习笔记
    • Promise 学习笔记

      • Promise 学习笔记
    • Tailwind CSS 完整笔记

      • Tailwind CSS 完整笔记
    • TypeScript 快速上手

      • TypeScript 快速上手
    • Vue3 学习笔记

      • Vue3 学习笔记
  • 元框架与全栈路由

    • Next.js App Router 最佳实践

      • Next.js App Router 最佳实践
    • 跨域与服务端组件数据预取

      • 跨域与服务端组件数据预取
  • 现代数据流与安全

    • Prisma Schema 全栈类型生成

      • Prisma Schema 全栈类型生成
    • Supabase RLS 行级安全策略

      • Supabase RLS 行级安全策略
  • 商业化与支付闭环

    • SaaS 订阅制用户表结构设计

      • SaaS 订阅制用户表结构设计
    • Stripe Webhook 接入避坑指南

      • Stripe Webhook 接入避坑指南
  • 零运维与边缘计算

    • Cloudflare 基础防护与 CDN

      • Cloudflare 基础防护与 CDN
    • Vercel 自动化部署与环境变量

      • Vercel 自动化部署与环境变量
  • AI 赋能与集成

    • Vercel AI SDK 流式输出实战

      • Vercel AI SDK 流式输出实战
  • 增长、监控与运营

    • Resend 事务性邮件模板

      • Resend 事务性邮件模板
    • Sentry 前端异常捕获与报警

      • Sentry 前端异常捕获与报警
  • Node.js 深入学习

    • MongoDB 常用命令速查表

      • MongoDB 常用命令速查表
    • Node.js + MongoDB 生产级最佳实践指南

      • Node.js + MongoDB 生产级最佳实践指南
    • Node.js Express 框架

      • Node.js Express 框架
    • Node.js HTTP 模块

      • Node.js HTTP 模块
    • Node.js NPM 包管理

      • Node.js NPM 包管理
    • Node.js 文件系统模块

      • Node.js 文件系统模块
    • Node.js 模块化设计

      • Node.js 模块化设计
  • 后端开发

    • Express 基本使用

      • Express 基本使用
    • Node.js 学习笔记

      • Node.js 学习笔记
    • SpringBoot 完整学习笔记

      • SpringBoot 完整学习笔记
  • 开发工具

    • Windows + WSL + Docker 踩坑与通关指南

      • Windows + WSL + Docker 踩坑与通关指南
    • GitHub 新手完全指南

      • GitHub 新手完全指南
    • 个人博客搭建指南

      • 个人博客搭建指南

SaaS 订阅制用户表结构设计

1. 订阅制模型的核心实体

一个 SaaS 订阅系统至少要有:

User:用户
Plan:套餐
Subscription:订阅状态
PaymentEvent:支付事件日志
Entitlement:权益或配额

不要只在 user 表上放一个 isVip。

原因:

  • 无法表达取消、过期、欠费、试用。
  • 无法追踪历史订阅。
  • 无法处理 Webhook 重试和补偿。
  • 无法支持不同套餐权益。

2. Prisma 数据模型

model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String?
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt
  subscriptions Subscription[]
  entitlements  Entitlement[]
}

model Plan {
  id            String         @id @default(cuid())
  code          String         @unique
  name          String
  priceCents    Int
  currency      String         @default("usd")
  interval      String
  stripePriceId String         @unique
  active        Boolean        @default(true)
  createdAt     DateTime       @default(now())
  subscriptions Subscription[]
}

model Subscription {
  id                   String   @id @default(cuid())
  userId               String
  planId               String
  provider             String   @default("stripe")
  providerCustomerId   String
  providerSubscriptionId String @unique
  status               String
  currentPeriodStart   DateTime?
  currentPeriodEnd     DateTime?
  cancelAtPeriodEnd    Boolean  @default(false)
  createdAt            DateTime @default(now())
  updatedAt            DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict)

  @@index([userId])
  @@index([planId])
  @@index([status])
  @@index([providerCustomerId])
}

model Entitlement {
  id        String   @id @default(cuid())
  userId    String
  key       String
  value     Int
  expiresAt DateTime?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, key])
  @@index([expiresAt])
}

model PaymentEvent {
  id          String   @id @default(cuid())
  provider    String
  eventId     String   @unique
  type        String
  payload     Json
  processedAt DateTime?
  createdAt   DateTime @default(now())

  @@index([type])
  @@index([processedAt])
}

3. 套餐和权益的关系

可以在代码中维护套餐权益,也可以建表。

中小项目可以先用代码配置:

// config/plans.ts
export type PlanCode = 'free' | 'pro' | 'team'

export type PlanConfig = {
  code: PlanCode
  name: string
  monthlyLimit: number
  seats: number
}

export const plans: Record<PlanCode, PlanConfig> = {
  free: { code: 'free', name: 'Free', monthlyLimit: 100, seats: 1 },
  pro: { code: 'pro', name: 'Pro', monthlyLimit: 5000, seats: 1 },
  team: { code: 'team', name: 'Team', monthlyLimit: 50000, seats: 10 },
}

export function getPlanConfig(code: string): PlanConfig | null {
  return code in plans ? plans[code as PlanCode] : null
}

4. 服务端查询当前订阅

// lib/billing.ts
import { prisma } from '@/lib/prisma'

export type CurrentSubscription = {
  status: string
  planCode: string
  currentPeriodEnd: Date | null
  cancelAtPeriodEnd: boolean
}

const activeStatuses = new Set(['active', 'trialing'])

export async function getCurrentSubscription(userId: string): Promise<CurrentSubscription | null> {
  if (!userId) return null

  try {
    const subscription = await prisma.subscription.findFirst({
      where: {
        userId,
        status: { in: Array.from(activeStatuses) },
      },
      orderBy: { updatedAt: 'desc' },
      include: { plan: true },
    })

    if (!subscription) return null

    return {
      status: subscription.status,
      planCode: subscription.plan.code,
      currentPeriodEnd: subscription.currentPeriodEnd,
      cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
    }
  } catch (error) {
    console.error('[getCurrentSubscription]', error)
    return null
  }
}

5. 前端展示订阅状态

// app/billing/BillingStatus.tsx
'use client'

import { useEffect, useState } from 'react'

type BillingResponse = {
  subscription: {
    status: string
    planCode: string
    currentPeriodEnd: string | null
    cancelAtPeriodEnd: boolean
  } | null
}

export function BillingStatus() {
  const [data, setData] = useState<BillingResponse | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const controller = new AbortController()

    async function load() {
      try {
        const res = await fetch('/api/billing/current', { signal: controller.signal })
        const json = await res.json().catch(() => null) as BillingResponse | { error?: string } | null

        if (!res.ok) throw new Error((json as { error?: string } | null)?.error || '获取订阅失败')
        setData(json as BillingResponse)
      } catch (error) {
        if (error instanceof DOMException && error.name === 'AbortError') return
        console.error('[BillingStatus]', error)
        setError(error instanceof Error ? error.message : '加载失败')
      } finally {
        setLoading(false)
      }
    }

    load()
    return () => controller.abort()
  }, [])

  if (loading) return <p>加载订阅状态中...</p>
  if (error) return <p role="alert">{error}</p>
  if (!data?.subscription) return <p>当前为免费套餐</p>

  return (
    <section>
      <p>套餐:{data.subscription.planCode}</p>
      <p>状态:{data.subscription.status}</p>
      {data.subscription.currentPeriodEnd && <p>到期时间:{data.subscription.currentPeriodEnd}</p>}
      {data.subscription.cancelAtPeriodEnd && <p>将在周期结束后取消</p>}
    </section>
  )
}
// app/api/billing/current/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentSubscription } from '@/lib/billing'

function getUserId(request: NextRequest): string | null {
  const userId = request.cookies.get('user_id')?.value
  return userId && userId.length < 100 ? userId : null
}

export async function GET(request: NextRequest) {
  const userId = getUserId(request)
  if (!userId) return NextResponse.json({ error: '未登录' }, { status: 401 })

  try {
    const subscription = await getCurrentSubscription(userId)

    return NextResponse.json({
      subscription: subscription && {
        ...subscription,
        currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
      },
    })
  } catch (error) {
    console.error('[GET /api/billing/current]', error)
    return NextResponse.json({ error: '服务暂时不可用' }, { status: 500 })
  }
}

6. 权益判断

// lib/entitlements.ts
import { prisma } from '@/lib/prisma'

export async function hasEntitlement(userId: string, key: string): Promise<boolean> {
  if (!userId || !key) return false

  try {
    const entitlement = await prisma.entitlement.findUnique({
      where: { userId_key: { userId, key } },
    })

    if (!entitlement) return false
    if (entitlement.expiresAt && entitlement.expiresAt.getTime() < Date.now()) return false

    return entitlement.value > 0
  } catch (error) {
    console.error('[hasEntitlement]', error)
    return false
  }
}

7. 订阅状态不要只靠前端控制

前端隐藏按钮只是体验优化,不是安全。

后端接口必须检查权益:

// app/api/export/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { hasEntitlement } from '@/lib/entitlements'

export async function POST(request: NextRequest) {
  const userId = request.cookies.get('user_id')?.value

  if (!userId) {
    return NextResponse.json({ error: '未登录' }, { status: 401 })
  }

  try {
    const allowed = await hasEntitlement(userId, 'export')

    if (!allowed) {
      return NextResponse.json({ error: '当前套餐不支持导出' }, { status: 403 })
    }

    return NextResponse.json({ ok: true, url: '/exports/demo.csv' })
  } catch (error) {
    console.error('[POST /api/export]', error)
    return NextResponse.json({ error: '导出失败' }, { status: 500 })
  }
}

8. 真实业务坑点

8.1 用 isVip 代替订阅表

短期快,长期很难维护取消、退款、欠费、套餐升级。

8.2 只处理支付成功,不处理取消和失败

至少要处理:

  • checkout.session.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_failed

8.3 用户权益直接和支付页面同步

支付成功页不可信。用户可以直接访问 success URL。

真正发放权益必须基于 Webhook。

8.4 没有事件日志

Webhook 失败后无法追踪,也无法补偿。

9. 生产建议

  1. Plan 表描述套餐。
  2. Subscription 表描述订阅状态。
  3. Entitlement 表描述实际可用权益。
  4. PaymentEvent 表记录所有支付事件。
  5. 权益发放只相信 Webhook,不相信前端跳转。
  6. 所有付费能力后端都要二次校验。
最后更新: 2026/6/13 21:40
贡献者: 52nnnn, Claude Opus 4.7