24.04-套餐与订阅

要点

  • 套餐(Plan)定义了租户可用的资源配额和功能范围
  • 订阅(Subscription)是套餐的运行时实例,包含开始时间、结束时间、状态
  • 订阅系统需要对接支付平台(如 Stripe),处理周期扣费、升降级、退款
  • 订阅状态变更通过 Webhook 异步通知,后端需要保证幂等性

内容

1. 套餐设计

1.1 套餐层级

一个典型的 AI SaaS 套餐体系分三层:

套餐价格Token 配额模型权限知识库数量团队成员数
Free$010,000 tokens/月GPT-3.51 个1 人
Pro$29/月500,000 tokens/月GPT-3.5 + GPT-410 个5 人
Enterprise$99/月无限全部模型无限无限

套餐定义应该作为配置常量,不要硬编码在业务逻辑中:

// src/constants/plans.ts
export interface PlanDefinition {
  id: string
  name: string
  price: number              // 月费,单位:美分
  tokenQuota: number         // 每月 token 配额
  maxKnowledgeBases: number  // 知识库数量上限
  maxTeamMembers: number     // 团队成员上限
  allowedModels: string[]    // 可用模型列表
  features: string[]         // 额外功能
}
 
export const PLANS: Record<string, PlanDefinition> = {
  free: {
    id: 'free',
    name: 'Free',
    price: 0,
    tokenQuota: 10_000,
    maxKnowledgeBases: 1,
    maxTeamMembers: 1,
    allowedModels: ['gpt-3.5-turbo'],
    features: ['basic-chat'],
  },
  pro: {
    id: 'pro',
    name: 'Pro',
    price: 2900,
    tokenQuota: 500_000,
    maxKnowledgeBases: 10,
    maxTeamMembers: 5,
    allowedModels: ['gpt-3.5-turbo', 'gpt-4'],
    features: ['basic-chat', 'rag', 'custom-prompt'],
  },
  enterprise: {
    id: 'enterprise',
    name: 'Enterprise',
    price: 9900,
    tokenQuota: -1,  // 无限
    maxKnowledgeBases: -1,
    maxTeamMembers: -1,
    allowedModels: ['gpt-3.5-turbo', 'gpt-4', 'claude-3', 'deepseek'],
    features: ['basic-chat', 'rag', 'custom-prompt', 'api-access', 'priority-support'],
  },
}
 
export function getPlan(planId: string): PlanDefinition {
  return PLANS[planId] || PLANS.free
}

2. 订阅数据模型

2.1 数据库 Schema

-- 订阅表
CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  plan_id TEXT NOT NULL,               -- free/pro/enterprise
  status TEXT NOT NULL,                -- active/past_due/canceled/trialing
  current_period_start INTEGER NOT NULL,
  current_period_end INTEGER,
  cancel_at_period_end INTEGER DEFAULT 0,
  stripe_subscription_id TEXT,         -- Stripe 订阅 ID
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  FOREIGN KEY (tenant_id) REFERENCES tenants(id)
)
 
-- 支付记录表
CREATE TABLE payments (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  subscription_id TEXT NOT NULL,
  amount INTEGER NOT NULL,             -- 金额,单位:美分
  currency TEXT NOT NULL DEFAULT 'usd',
  status TEXT NOT NULL,                -- pending/succeeded/failed/refunded
  stripe_payment_id TEXT,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
)

2.2 TypeScript 类型

// src/types/subscription.ts
export interface Subscription {
  id: string
  tenantId: string
  planId: string
  status: SubscriptionStatus
  currentPeriodStart: number
  currentPeriodEnd?: number
  cancelAtPeriodEnd: boolean
  stripeSubscriptionId?: string
  createdAt: number
  updatedAt: number
}
 
export type SubscriptionStatus =
  | 'active'       // 正常
  | 'past_due'     // 逾期
  | 'canceled'     // 已取消
  | 'trialing'     // 试用中
  | 'incomplete'   // 支付未完成
 
export interface Payment {
  id: string
  tenantId: string
  subscriptionId: string
  amount: number
  currency: string
  status: 'pending' | 'succeeded' | 'failed' | 'refunded'
  stripePaymentId?: string
  createdAt: number
}

3. 订阅创建流程

3.1 创建订阅

用户选择套餐后,后端创建订阅记录并跳转到支付平台:

// src/routes/subscription.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { generateId } from '../lib/utils'
import Stripe from 'stripe'
 
const app = new Hono()
 
// 创建订阅
app.post('/create', zValidator('json', z.object({
  planId: z.enum(['free', 'pro', 'enterprise']),
})), async (c) => {
  const tenantId = c.get('tenantId')
  const { planId } = c.req.valid('json')
 
  // 1. 检查是否已有活跃订阅
  const existing = await c.env.DB.prepare(`
    SELECT id FROM subscriptions
    WHERE tenant_id = ? AND status IN ('active', 'trialing')
  `).bind(tenantId).first()
 
  if (existing) {
    return c.json({ error: '已有活跃订阅' }, 409)
  }
 
  // 2. 免费套餐直接创建
  if (planId === 'free') {
    const subscriptionId = generateId()
    const now = Date.now()
 
    await c.env.DB.prepare(`
      INSERT INTO subscriptions (id, tenant_id, plan_id, status, current_period_start, created_at, updated_at)
      VALUES (?, ?, ?, 'active', ?, ?, ?)
    `).bind(subscriptionId, tenantId, planId, now, now, now).run()
 
    return c.json({ subscriptionId, status: 'active' })
  }
 
  // 3. 付费套餐创建 Stripe 订阅
  const stripe = new Stripe(c.env.STRIPE_SECRET_KEY)
 
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: await getTenantEmail(tenantId, c.env),
    line_items: [{
      price: getStripePriceId(planId),
      quantity: 1,
    }],
    success_url: `${process.env.FRONTEND_URL}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.FRONTEND_URL}/subscription/cancel`,
    metadata: {
      tenantId,
      planId,
    },
  })
 
  // 4. 创建 pending 状态的订阅记录
  const subscriptionId = generateId()
  const now = Date.now()
 
  await c.env.DB.prepare(`
    INSERT INTO subscriptions (id, tenant_id, plan_id, status, current_period_start, created_at, updated_at)
    VALUES (?, ?, ?, 'incomplete', ?, ?, ?)
  `).bind(subscriptionId, tenantId, planId, now, now, now).run()
 
  return c.json({
    subscriptionId,
    checkoutUrl: session.url,
    status: 'incomplete',
  })
})
 
function getStripePriceId(planId: string): string {
  const priceIds: Record<string, string> = {
    pro: 'price_pro_monthly',
    enterprise: 'price_enterprise_monthly',
  }
  return priceIds[planId]
}

4. Webhook 处理

4.1 Stripe Webhook

支付平台通过 Webhook 通知订阅状态变更。后端需要:

  1. 验证签名
  2. 处理事件
  3. 保证幂等性(同一事件多次通知只处理一次)
// src/routes/webhook.ts
import { Hono } from 'hono'
import Stripe from 'stripe'
 
const app = new Hono()
 
app.post('/stripe', async (c) => {
  const stripe = new Stripe(c.env.STRIPE_SECRET_KEY)
  const signature = c.req.header('Stripe-Signature')
  const body = await c.req.text()
 
  // 1. 验证签名
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature!,
      c.env.STRIPE_WEBHOOK_SECRET
    )
  } catch (err) {
    return c.json({ error: 'Invalid signature' }, 400)
  }
 
  // 2. 幂等性检查
  const existing = await c.env.DB.prepare(`
    SELECT id FROM webhook_events WHERE event_id = ?
  `).bind(event.id).first()
 
  if (existing) {
    return c.json({ received: true })
  }
 
  // 3. 处理事件
  try {
    await handleStripeEvent(event, c.env)
 
    // 4. 记录已处理
    await c.env.DB.prepare(`
      INSERT INTO webhook_events (id, event_id, event_type, processed_at)
      VALUES (?, ?, ?, ?)
    `).bind(generateId(), event.id, event.type, Date.now()).run()
  } catch (err) {
    console.error('Webhook processing failed:', err)
    return c.json({ error: 'Processing failed' }, 500)
  }
 
  return c.json({ received: true })
})
 
async function handleStripeEvent(event: Stripe.Event, env: Env) {
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, env)
      break
    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, env)
      break
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, env)
      break
    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object as Stripe.Invoice, env)
      break
  }
}

4.2 处理支付成功

async function handleCheckoutCompleted(
  session: Stripe.Checkout.Session,
  env: Env
) {
  const tenantId = session.metadata!.tenantId
  const planId = session.metadata!.planId
 
  // 获取 Stripe 订阅
  const stripe = new Stripe(env.STRIPE_SECRET_KEY)
  const stripeSubscription = await stripe.subscriptions.retrieve(
    session.subscription as string
  )
 
  // 更新订阅状态
  await env.DB.prepare(`
    UPDATE subscriptions
    SET status = 'active',
        stripe_subscription_id = ?,
        current_period_start = ?,
        current_period_end = ?,
        updated_at = ?
    WHERE tenant_id = ? AND plan_id = ?
  `).bind(
    stripeSubscription.id,
    stripeSubscription.current_period_start * 1000,
    stripeSubscription.current_period_end * 1000,
    Date.now(),
    tenantId,
    planId
  ).run()
 
  // 更新租户套餐
  await env.DB.prepare(`
    UPDATE tenants SET plan = ?, updated_at = ? WHERE id = ?
  `).bind(planId, Date.now(), tenantId).run()
 
  // 清除配置缓存
  await env.TENANT_CONFIG.delete(`config:${tenantId}`)
}

4.3 处理订阅取消

async function handleSubscriptionDeleted(
  subscription: Stripe.Subscription,
  env: Env
) {
  // 查找本地订阅
  const sub = await env.DB.prepare(`
    SELECT * FROM subscriptions WHERE stripe_subscription_id = ?
  `).bind(subscription.id).first()
 
  if (!sub) return
 
  // 更新状态为 canceled
  await env.DB.prepare(`
    UPDATE subscriptions
    SET status = 'canceled', updated_at = ?
    WHERE id = ?
  `).bind(Date.now(), sub.id).run()
 
  // 租户降级为 free
  await env.DB.prepare(`
    UPDATE tenants SET plan = 'free', updated_at = ? WHERE id = ?
  `).bind(Date.now(), sub.tenant_id).run()
 
  // 清除配置缓存
  await env.TENANT_CONFIG.delete(`config:${sub.tenant_id}`)
}

5. 订阅升降级

5.1 升级套餐

app.post('/upgrade', zValidator('json', z.object({
  newPlanId: z.enum(['pro', 'enterprise']),
})), async (c) => {
  const tenantId = c.get('tenantId')
  const { newPlanId } = c.req.valid('json')
 
  // 1. 获取当前订阅
  const subscription = await c.env.DB.prepare(`
    SELECT * FROM subscriptions WHERE tenant_id = ? AND status = 'active'
  `).bind(tenantId).first()
 
  if (!subscription?.stripe_subscription_id) {
    return c.json({ error: '没有活跃订阅' }, 400)
  }
 
  // 2. 调用 Stripe 升级
  const stripe = new Stripe(c.env.STRIPE_SECRET_KEY)
  const stripeSub = await stripe.subscriptions.retrieve(
    subscription.stripe_subscription_id
  )
 
  await stripe.subscriptions.update(stripeSub.id, {
    items: [{
      id: stripeSub.items.data[0].id,
      price: getStripePriceId(newPlanId),
    }],
    proration_behavior: 'create_prorations',  // 按比例计费
  })
 
  // 3. 更新本地记录
  await c.env.DB.prepare(`
    UPDATE subscriptions SET plan_id = ?, updated_at = ? WHERE id = ?
  `).bind(newPlanId, Date.now(), subscription.id).run()
 
  await c.env.DB.prepare(`
    UPDATE tenants SET plan = ?, updated_at = ? WHERE id = ?
  `).bind(newPlanId, Date.now(), tenantId).run()
 
  // 4. 清除缓存
  await c.env.TENANT_CONFIG.delete(`config:${tenantId}`)
 
  return c.json({ success: true, newPlanId })
})

5.2 取消订阅

app.post('/cancel', async (c) => {
  const tenantId = c.get('tenantId')
 
  const subscription = await c.env.DB.prepare(`
    SELECT * FROM subscriptions WHERE tenant_id = ? AND status = 'active'
  `).bind(tenantId).first()
 
  if (!subscription?.stripe_subscription_id) {
    return c.json({ error: '没有活跃订阅' }, 400)
  }
 
  // 在周期结束时取消(不立即取消)
  const stripe = new Stripe(c.env.STRIPE_SECRET_KEY)
  await stripe.subscriptions.update(
    subscription.stripe_subscription_id,
    { cancel_at_period_end: true }
  )
 
  // 更新本地记录
  await c.env.DB.prepare(`
    UPDATE subscriptions SET cancel_at_period_end = 1, updated_at = ?
    WHERE id = ?
  `).bind(Date.now(), subscription.id).run()
 
  return c.json({
    success: true,
    cancelAt: new Date(subscription.current_period_end * 1000),
  })
})

6. 订阅状态检查

6.1 配额检查中间件

// src/middleware/quota.ts
import { Context, Next } from 'hono'
import { getPlan } from '../constants/plans'
 
export async function checkQuotaMiddleware(c: Context, next: Next) {
  const tenantId = c.get('tenantId')
 
  // 1. 获取订阅
  const subscription = await c.env.DB.prepare(`
    SELECT * FROM subscriptions
    WHERE tenant_id = ? AND status = 'active'
  `).bind(tenantId).first()
 
  if (!subscription) {
    return c.json({ error: '没有活跃订阅' }, 402)
  }
 
  // 2. 获取套餐定义
  const plan = getPlan(subscription.plan_id)
 
  // 3. 检查是否过期
  if (subscription.current_period_end &&
      subscription.current_period_end < Date.now()) {
    return c.json({ error: '订阅已过期' }, 402)
  }
 
  // 4. 检查 token 配额
  if (plan.tokenQuota > 0) {
    const usage = await c.env.DB.prepare(`
      SELECT COALESCE(SUM(tokens_used), 0) as total
      FROM quota_usage
      WHERE tenant_id = ? AND created_at > ?
    `).bind(tenantId, subscription.current_period_start).first()
 
    if (usage.total >= plan.tokenQuota) {
      return c.json({
        error: 'Token 配额已用完',
        quota: { used: usage.total, limit: plan.tokenQuota },
      }, 429)
    }
  }
 
  // 5. 存储订阅信息到上下文
  c.set('subscription', subscription)
  c.set('plan', plan)
 
  await next()
}

7. 获取订阅信息

app.get('/info', async (c) => {
  const tenantId = c.get('tenantId')
 
  const subscription = await c.env.DB.prepare(`
    SELECT * FROM subscriptions
    WHERE tenant_id = ? AND status IN ('active', 'trialing', 'past_due')
  `).bind(tenantId).first()
 
  if (!subscription) {
    return c.json({ error: '没有订阅' }, 404)
  }
 
  const plan = getPlan(subscription.plan_id)
 
  // 获取当前用量
  const usage = await c.env.DB.prepare(`
    SELECT COALESCE(SUM(tokens_used), 0) as tokensUsed
    FROM quota_usage
    WHERE tenant_id = ? AND created_at > ?
  `).bind(tenantId, subscription.current_period_start).first()
 
  return c.json({
    subscription: {
      id: subscription.id,
      planId: subscription.plan_id,
      status: subscription.status,
      currentPeriodEnd: subscription.current_period_end,
      cancelAtPeriodEnd: subscription.cancel_at_period_end === 1,
    },
    plan: {
      name: plan.name,
      tokenQuota: plan.tokenQuota,
      maxKnowledgeBases: plan.maxKnowledgeBases,
      maxTeamMembers: plan.maxTeamMembers,
      allowedModels: plan.allowedModels,
    },
    usage: {
      tokensUsed: usage.tokensUsed,
      tokensLimit: plan.tokenQuota,
      percentage: plan.tokenQuota > 0
        ? Math.round((usage.tokensUsed / plan.tokenQuota) * 100)
        : 0,
    },
  })
})

8. 小结

套餐与订阅系统的关键点:

  1. 套餐定义:作为配置常量,定义每个套餐的配额、功能、价格
  2. 订阅模型:订阅是套餐的运行时实例,包含状态、周期、支付方式
  3. 支付集成:对接 Stripe,处理 Checkout、订阅、发票
  4. Webhook 处理:异步接收支付平台通知,保证幂等性
  5. 升降级:调用支付平台 API,处理按比例计费
  6. 配额检查:中间件统一拦截,检查订阅状态和用量

一句话带走:

订阅系统的核心是「套餐定义 + 订阅实例 + 支付平台」三者配合。套餐是配置,订阅是状态,支付平台是事实来源。Webhook 保证两端数据一致,配额检查中间件统一拦截。