免费用户与付费用户区分

区分免费用户和付费用户,是 SaaS 商业化的第一步,也是最基础的一步。如果你的系统无法准确判断一个用户当前属于哪个层级,后续的功能限制、计费、升级引导全部无从谈起。

这篇文章聚焦工程实现层面:如何在数据库中标识用户类型、如何在前后端实施功能限制、如何让 Stripe 的订阅状态准确同步到本地系统、如何在合适的时机引导用户升级。

用户类型标识

用户类型标识的根基在数据库设计。一个常见的做法是在 users 表或关联的 subscriptions 表中维护用户的层级信息。

数据库字段设计

核心字段包括:

  • tier:用户层级,枚举值如 freeproteamenterprise
  • subscription_status:订阅状态,如 activepast_duecanceledtrialing
  • stripe_customer_id:Stripe 客户 ID,用于关联支付系统
  • stripe_subscription_id:Stripe 订阅 ID
  • current_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 中读取 tiersubscription_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 机制,或者在关键权限检查时回查数据库。

字段类型用途更新时机
tierENUM用户层级Webhook 触发、管理员手动调整
subscription_statusVARCHAR订阅状态Stripe Webhook 事件
current_period_endTIMESTAMPTZ计费周期截止每次续费成功时
trial_endTIMESTAMPTZ试用期截止注册时设置

功能限制策略

功能限制是区分用户类型的执行层。限制策略分为三层:前端展示层、后端校验层、数据层。

前端展示层

前端的核心原则是「隐藏,而非禁用」。对免费用户不可见的功能,直接不渲染对应的 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订阅变更(升降级、续费)更新 tiercurrent_period_end
customer.subscription.deleted订阅取消设置 tierfree,保留数据
invoice.payment_succeeded支付成功更新 current_period_end
invoice.payment_failed支付失败设置 subscription_statuspast_due
customer.subscription.trial_will_end试用期即将结束发送提醒邮件

同步流程

流程图画布 · 115%
Mermaid 流程图加载中...

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 缓存:将用户的 tierfeatures 集合缓存到 Redis,TTL 设为 5-15 分钟。Webhook 更新数据库后,主动删除对应的缓存键。
  • 本地内存缓存:在单实例部署时,可以用进程内 Map 做缓存,配合 TTL 自动过期。多实例部署时需要配合 Redis Pub/Sub 做跨实例的缓存失效通知。

缓存和数据库之间的一致性窗口是不可避免的。当 Stripe Webhook 更新了用户的 tier,到缓存过期之间的几分钟内,用户可能仍然看到旧的权限状态。在大多数场景下,这个窗口是可以接受的。如果业务要求更高的一致性,可以在 Webhook 处理完成后通过 WebSocket 通知前端刷新。

方案一致性延迟复杂度适用规模
每次查数据库强一致日活 < 1000
Redis 缓存 + 主动失效秒级日活 < 10 万
本地缓存 + Pub/Sub 广播秒级日活 > 10 万
JWT 内嵌 + 短过期分钟级最低对实时性要求不高的场景

升级引导

升级引导的核心不是「弹一个付费框」,而是在用户最需要的时候提供合理的升级路径。

付费墙设计

付费墙出现的时机决定了它的效果。三种常见的触发场景:

  1. 功能触达型:用户尝试使用高级功能时,展示付费墙。比如免费用户点击「导出高清视频」按钮。
  2. 用量耗尽型:用户的免费额度用完时,展示付费墙。比如 AI 生成次数归零。
  3. 价值感知型:用户正在享受免费功能带来的价值时,展示升级建议。比如用户第三次使用 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 升级付费的阻力——升级不是为了自己,而是为了让团队成员能继续工作。

实施检查清单

在上线用户类型区分功能之前,逐项检查以下要点:

  • 数据库中包含 tiersubscription_statusstripe_customer_idstripe_subscription_idcurrent_period_end 等核心字段
  • 前端功能入口根据用户层级控制展示,隐藏而非禁用
  • 后端每个受限 API 端点有独立的权限校验中间件
  • Stripe Webhook 签名验证逻辑已实现
  • Webhook 事件处理具有幂等性,使用 Event ID 去重
  • 订阅状态变更时主动清除用户权限缓存
  • 降级时保留用户数据,仅限制操作权限
  • 升级提示的触发时机经过产品确认,不在用户首次使用时弹出
  • 403 响应中包含 upgradeUrl 字段,前端可据此展示升级引导
  • 试用期结束前发送提醒邮件(通常提前 3 天和 1 天各一次)
  • 支付失败时发送通知邮件,给予宽限期而非立即降级
  • 前后端的层级定义和权限映射保持一致,避免前后端各维护一套规则
  • 日志系统记录所有 Webhook 事件和处理结果,方便排查同步问题
  • 有手动调整用户层级的管理后台功能,用于客服和特殊情况处理

参考资料