24.04-套餐与订阅
要点
- 套餐(Plan)定义了租户可用的资源配额和功能范围
- 订阅(Subscription)是套餐的运行时实例,包含开始时间、结束时间、状态
- 订阅系统需要对接支付平台(如 Stripe),处理周期扣费、升降级、退款
- 订阅状态变更通过 Webhook 异步通知,后端需要保证幂等性
内容
1. 套餐设计
1.1 套餐层级
一个典型的 AI SaaS 套餐体系分三层:
| 套餐 | 价格 | Token 配额 | 模型权限 | 知识库数量 | 团队成员数 |
|---|---|---|---|---|---|
| Free | $0 | 10,000 tokens/月 | GPT-3.5 | 1 个 | 1 人 |
| Pro | $29/月 | 500,000 tokens/月 | GPT-3.5 + GPT-4 | 10 个 | 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 通知订阅状态变更。后端需要:
- 验证签名
- 处理事件
- 保证幂等性(同一事件多次通知只处理一次)
// 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. 小结
套餐与订阅系统的关键点:
- 套餐定义:作为配置常量,定义每个套餐的配额、功能、价格
- 订阅模型:订阅是套餐的运行时实例,包含状态、周期、支付方式
- 支付集成:对接 Stripe,处理 Checkout、订阅、发票
- Webhook 处理:异步接收支付平台通知,保证幂等性
- 升降级:调用支付平台 API,处理按比例计费
- 配额检查:中间件统一拦截,检查订阅状态和用量
一句话带走:
订阅系统的核心是「套餐定义 + 订阅实例 + 支付平台」三者配合。套餐是配置,订阅是状态,支付平台是事实来源。Webhook 保证两端数据一致,配额检查中间件统一拦截。