免费用户与付费用户区分
区分免费用户和付费用户,是 SaaS 商业化的第一步,也是最基础的一步。如果你的系统无法准确判断一个用户当前属于哪个层级,后续的功能限制、计费、升级引导全部无从谈起。
这篇文章聚焦工程实现层面:如何在数据库中标识用户类型、如何在前后端实施功能限制、如何让 Stripe 的订阅状态准确同步到本地系统、如何在合适的时机引导用户升级。
用户类型标识
用户类型标识的根基在数据库设计。一个常见的做法是在 users 表或关联的 subscriptions 表中维护用户的层级信息。
数据库字段设计
核心字段包括:
tier:用户层级,枚举值如free、pro、team、enterprisesubscription_status:订阅状态,如active、past_due、canceled、trialingstripe_customer_id:Stripe 客户 ID,用于关联支付系统stripe_subscription_id:Stripe 订阅 IDcurrent_period_end:当前计费周期结束时间trial_end:试用期结束时间
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
tier VARCHAR(20) DEFAULT 'free' NOT NULL,
subscription_status VARCHAR(20) DEFAULT NULL,
stripe_customer_id VARCHAR(100),
stripe_subscription_id VARCHAR(100),
current_period_end TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);tier 字段的设计需要注意一个问题:它是「事实状态」还是「订阅状态的映射」?如果每次判断用户层级都要去查 Stripe,系统的延迟和可用性都会受影响。更稳妥的做法是在本地维护 tier 作为当前事实状态,通过 Stripe Webhook 保持同步。
状态管理
在应用层,通常会在 Session 或 JWT Token 中携带用户层级信息。每次请求到达时,中间件从 Token 中读取 tier 和 subscription_status,注入到请求上下文中。
interface UserContext {
userId: string
tier: 'free' | 'pro' | 'team' | 'enterprise'
subscriptionStatus: 'active' | 'past_due' | 'canceled' | 'trialing' | null
features: Set<string>
}这里有一个容易踩的坑:JWT Token 的过期时间如果过长(比如 7 天),用户升级后 Token 中的 tier 不会立即更新。解决方案有两种——缩短 Token 有效期配合 Refresh Token 机制,或者在关键权限检查时回查数据库。
| 字段 | 类型 | 用途 | 更新时机 |
|---|---|---|---|
tier | ENUM | 用户层级 | Webhook 触发、管理员手动调整 |
subscription_status | VARCHAR | 订阅状态 | Stripe Webhook 事件 |
current_period_end | TIMESTAMPTZ | 计费周期截止 | 每次续费成功时 |
trial_end | TIMESTAMPTZ | 试用期截止 | 注册时设置 |
功能限制策略
功能限制是区分用户类型的执行层。限制策略分为三层:前端展示层、后端校验层、数据层。
前端展示层
前端的核心原则是「隐藏,而非禁用」。对免费用户不可见的功能,直接不渲染对应的 UI 元素,而不是渲染一个灰色按钮然后弹一个付费提示。灰色按钮增加认知负担,用户看到能用但不能用,体验上是一种挫败感。
但有一个例外:付费墙(Paywall)场景。当用户主动触达某个付费功能时——比如点击了一个高级按钮、访问了一个受限页面——此时展示升级提示是合理的。这种场景下,用户已经表达了意图,付费提示起到的是引导作用。
// 前端权限控制组件
function FeatureGate({ feature, children, fallback }: {
feature: string
children: React.ReactNode
fallback?: React.ReactNode
}) {
const { hasFeature } = usePermissions()
if (!hasFeature(feature)) {
return fallback ?? null
}
return <>{children}</>
}后端校验层
前端隐藏只是 UX 层面的保护,安全边界在后端。每一个涉及功能权限的 API 端点,都必须在服务端校验当前用户是否有权限执行该操作。
// 后端权限中间件
function requireFeature(feature: string) {
return async (ctx: Context, next: Next) => {
if (!ctx.user.features.has(feature)) {
ctx.status = 403
ctx.body = {
error: 'FORBIDDEN',
message: `This feature requires ${feature} access`,
upgradeUrl: `/upgrade?feature=${feature}`,
}
return
}
await next()
}
}后端校验返回 403 时,附带 upgradeUrl 字段,前端可以据此展示升级引导。这种设计把权限检查、错误响应、升级引导串成了一条完整的路径。
优雅降级
对于部分功能,可以选择不完全禁止,而是提供降级体验。比如 AI 生成功能,免费用户每天可以使用 5 次,付费用户无限制。超过限制后,不是直接拒绝,而是展示剩余次数和升级提示。
async function checkUsageLimit(userId: string, feature: string) {
const limits = FEATURE_LIMITS[feature]
const usage = await getUsageCount(userId, feature)
if (usage >= limits.free) {
return {
allowed: false,
remaining: 0,
upgradeUrl: `/upgrade?feature=${feature}`,
}
}
return {
allowed: true,
remaining: limits.free - usage,
}
}| 策略 | 适用场景 | 优点 | 风险 |
|---|---|---|---|
| 前端隐藏 | 免费用户完全不需要的功能 | 界面简洁,减少困惑 | 用户可能不知道有该功能 |
| 后端 403 | 所有受限操作 | 安全,不可绕过 | 需要前后端双重维护 |
| 优雅降级 | 有使用次数限制的功能 | 用户体验好,有升级动机 | 需要额外的用量计数系统 |
| 付费墙 | 用户主动触达付费功能 | 转化时机精准 | 设计不当会显得突兀 |
付费状态同步
Stripe 是你管理订阅的外部系统,但你不能每次判断用户权限时都去调 Stripe API。本地数据库必须维护一份订阅状态的副本,并且保持同步。
Webhook 事件处理
Stripe 通过 Webhook 通知你的服务端订阅状态的变化。需要监听的核心事件包括:
| 事件 | 触发时机 | 本地处理 |
|---|---|---|
customer.subscription.created | 新订阅创建 | 创建本地订阅记录,设置 tier |
customer.subscription.updated | 订阅变更(升降级、续费) | 更新 tier、current_period_end |
customer.subscription.deleted | 订阅取消 | 设置 tier 为 free,保留数据 |
invoice.payment_succeeded | 支付成功 | 更新 current_period_end |
invoice.payment_failed | 支付失败 | 设置 subscription_status 为 past_due |
customer.subscription.trial_will_end | 试用期即将结束 | 发送提醒邮件 |
同步流程
Webhook 处理有一个关键细节:幂等性。同一个事件可能因为网络重试被发送多次,你的处理逻辑必须保证多次执行的结果与一次执行一致。做法是在本地记录已处理的 Stripe Event ID,收到事件时先检查是否已处理过。
async function handleWebhook(event: Stripe.Event) {
// 幂等检查
const exists = await db.query(
'SELECT 1 FROM processed_events WHERE event_id = $1',
[event.id]
)
if (exists) return
// 业务处理
await processEvent(event)
// 记录已处理
await db.query(
'INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())',
[event.id]
)
}缓存策略
用户权限信息会被频繁读取,每次请求都查数据库不现实。常见的缓存方案:
- Redis 缓存:将用户的
tier、features集合缓存到 Redis,TTL 设为 5-15 分钟。Webhook 更新数据库后,主动删除对应的缓存键。 - 本地内存缓存:在单实例部署时,可以用进程内 Map 做缓存,配合 TTL 自动过期。多实例部署时需要配合 Redis Pub/Sub 做跨实例的缓存失效通知。
缓存和数据库之间的一致性窗口是不可避免的。当 Stripe Webhook 更新了用户的 tier,到缓存过期之间的几分钟内,用户可能仍然看到旧的权限状态。在大多数场景下,这个窗口是可以接受的。如果业务要求更高的一致性,可以在 Webhook 处理完成后通过 WebSocket 通知前端刷新。
| 方案 | 一致性 | 延迟 | 复杂度 | 适用规模 |
|---|---|---|---|---|
| 每次查数据库 | 强一致 | 高 | 低 | 日活 < 1000 |
| Redis 缓存 + 主动失效 | 秒级 | 低 | 中 | 日活 < 10 万 |
| 本地缓存 + Pub/Sub 广播 | 秒级 | 低 | 高 | 日活 > 10 万 |
| JWT 内嵌 + 短过期 | 分钟级 | 最低 | 低 | 对实时性要求不高的场景 |
升级引导
升级引导的核心不是「弹一个付费框」,而是在用户最需要的时候提供合理的升级路径。
付费墙设计
付费墙出现的时机决定了它的效果。三种常见的触发场景:
- 功能触达型:用户尝试使用高级功能时,展示付费墙。比如免费用户点击「导出高清视频」按钮。
- 用量耗尽型:用户的免费额度用完时,展示付费墙。比如 AI 生成次数归零。
- 价值感知型:用户正在享受免费功能带来的价值时,展示升级建议。比如用户第三次使用 AI 生成后,提示升级可以获得更多功能。
升级提示的时机选择
时机选择的原则是「用户已经感受到价值,但遇到了限制」。过早提示,用户还没理解产品的价值;过晚提示,用户可能已经流失。
// 升级提示触发条件
const UPGRADE_TRIGGERS = {
// 功能触达
featureGate: (feature: string) => ({
type: 'feature_gate',
feature,
message: `Upgrade to unlock ${feature}`,
}),
// 用量接近上限
usageThreshold: (feature: string, remaining: number) => {
if (remaining <= 2) {
return {
type: 'usage_warning',
feature,
remaining,
message: `You have ${remaining} ${feature} left this period`,
}
}
return null
},
// 价值感知(连续使用 N 天后)
valueAwareness: (consecutiveDays: number) => {
if (consecutiveDays >= 3) {
return {
type: 'value_suggestion',
days: consecutiveDays,
message: `You've been using us for ${consecutiveDays} days. See what Pro offers.`,
}
}
return null
},
}| 引导方式 | 触发条件 | 转化率 | 用户体验影响 |
|---|---|---|---|
| 功能付费墙 | 用户触达受限功能 | 高 | 中,可能打断操作 |
| 用量提醒 | 剩余次数 ≤ 2 | 中高 | 低,属于信息提示 |
| 设置页入口 | 用户主动查看账户 | 中 | 低,用户主动行为 |
| 邮件提醒 | 试用期即将结束 / 用量即将耗尽 | 中 | 低,非侵入式 |
| 应用内横幅 | 价值感知触发 | 低 | 低,可关闭 |
降级处理
用户取消订阅后,系统需要妥善处理降级。这个过程涉及权限回收、数据处理、用户沟通三个方面。
权限回收
订阅取消后,用户的 tier 从付费层级降回 free。需要处理的逻辑:
- 立即或延迟到计费周期结束后生效。Stripe 默认在
current_period_end时才真正取消。在这个时间点之前,用户仍然有权使用付费功能。 - 周期结束后,清除所有付费功能的访问权限。
- 如果有正在进行的付费功能操作(比如正在导出高清视频),允许当前操作完成。
数据保留
降级时最容易引起争议的环节。用户在付费期间创建的数据——比如用 Pro 功能生成的高级报告——降级后如何处理?
合理的做法是保留数据,但限制操作。用户可以查看自己之前创建的所有内容,但不能编辑或使用需要 Pro 功能的操作。直接删除用户数据会严重损害信任,绝大多数用户不会接受。
async function handleDowngrade(userId: string) {
// 降级为 free
await db.users.update(userId, { tier: 'free', subscription_status: 'canceled' })
// 保留用户创建的所有数据,不删除
// 但对需要付费功能的数据设置只读标记
await db.contents.updateMany(
{ userId, requiresPro: true },
{ readOnly: true }
)
// 清除缓存
await cache.del(`user:${userId}:permissions`)
// 发送降级通知邮件
await sendEmail(userId, 'subscription_downgraded')
}用户沟通
降级发生时,发送一封清晰的邮件说明变化内容。包括:哪些功能不再可用、已有数据如何处理、如果重新订阅数据是否会恢复。透明的沟通减少用户的焦虑感,也为未来重新订阅留下可能。
案例分析
案例一:Notion 的层级限制
Notion 的用户层级区分策略在产品中有几个值得参考的设计。免费用户可以创建无限页面和 Block,但文件上传有 5MB 限制,版本历史只保留 7 天。这些限制足够让个人用户日常使用,但团队协作场景下会成为明确的升级动机。
在技术实现上,Notion 的前端根据用户的 plan 信息控制功能入口的展示。当免费用户访问「Page History」时,会看到 7 天限制的说明和升级按钮。后端在 API 层对版本历史的查询做了时间范围校验,即使前端绕过限制,后端也会返回 7 天内的数据。
案例二:Figma 的 Seat 模型
Figma 采用的是 Seat-Based 定价,团队中每个成员占用一个 Seat。免费用户可以在 Figma 中查看和评论文件,但编辑功能限制在 3 个文件和 2 个 Design 页面以内。
Figma 的处理方式是在文件级别做权限判断。当免费用户打开第 4 个 Design 文件时,系统展示升级提示。这里有一个细节:Figma 不会阻止用户打开文件查看,只限制编辑操作。这让免费用户仍然可以参与协作流程,不会因为功能限制而被完全排除在团队之外。这种策略降低了团队 Lead 升级付费的阻力——升级不是为了自己,而是为了让团队成员能继续工作。
实施检查清单
在上线用户类型区分功能之前,逐项检查以下要点:
- 数据库中包含
tier、subscription_status、stripe_customer_id、stripe_subscription_id、current_period_end等核心字段 - 前端功能入口根据用户层级控制展示,隐藏而非禁用
- 后端每个受限 API 端点有独立的权限校验中间件
- Stripe Webhook 签名验证逻辑已实现
- Webhook 事件处理具有幂等性,使用 Event ID 去重
- 订阅状态变更时主动清除用户权限缓存
- 降级时保留用户数据,仅限制操作权限
- 升级提示的触发时机经过产品确认,不在用户首次使用时弹出
- 403 响应中包含
upgradeUrl字段,前端可据此展示升级引导 - 试用期结束前发送提醒邮件(通常提前 3 天和 1 天各一次)
- 支付失败时发送通知邮件,给予宽限期而非立即降级
- 前后端的层级定义和权限映射保持一致,避免前后端各维护一套规则
- 日志系统记录所有 Webhook 事件和处理结果,方便排查同步问题
- 有手动调整用户层级的管理后台功能,用于客服和特殊情况处理
参考资料
- Stripe: Using Webhooks with Subscriptions
- Stripe: How Subscriptions Work
- Stripe: Freemium Pricing Strategy Explained
- a16z: The Three Most Common Challenges with Freemium
- Stack Overflow: Updating Subscription Status Inside the Database by Using Stripe Webhooks
- Indie Hackers: Free vs Paid Features – Finding That Sweet Spot
- Stripe API Reference: The Subscription Object
- Recurly: What Is Freemium? A Guide for Subscription Businesses