会员权限控制

不同会员看到不同的功能,是付费转化的关键入口。用户打开你的 AI 产品,免费版只能看到基础编辑器,Pro 版多出了批量生成、团队协作和 API 调用入口——这种「差一点就能用上」的体验,比任何营销文案都更能推动升级决策。

权限控制不只是「能不能点」的问题。它涉及会员等级定义、后端校验逻辑、前端展示策略、升级降级的数据流转、以及高频校验下的缓存方案。一个设计不当的权限系统,可能导致用户升级后还要重新登录才能看到新功能,或者降级后仍然能调用付费 API。这些细节直接影响用户信任和商业转化。

本文从会员等级设计出发,逐层拆解权限校验实现、变更处理、缓存策略,给出可落地的代码示例和决策参考。


会员等级设计

等级划分原则

SaaS 产品的会员等级通常分为三到四层。每一层的划分依据不是功能堆砌,而是用户在不同阶段的真实需求差异。

以 AI 漫剧平台为例,常见的等级划分如下:

等级典型用户核心权益限制条件
Free体验用户基础编辑器、3 次/天 AI 生成、社区模板单项目 10 页上限,带水印导出
Pro个人创作者无限 AI 生成、自定义角色、无水印导出、批量操作单项目 200 页,5 个协作成员
Team小型工作室Pro 全部权益 + 团队空间、权限管理、审核流程20 个协作成员,共享素材库
Enterprise企业客户Team 全部权益 + SSO、专属部署、SLA、自定义模型接入按合同定制

权益矩阵设计

权益矩阵是权限系统的「数据源」。每一个功能点、资源配额、操作权限,都需要在矩阵中有明确定义。

设计权益矩阵时需要注意三个原则:

边界清晰。每个权益项要么属于某个等级,要么不属于,避免出现「Pro 和 Team 都有但 Free 有部分」的模糊状态。如果某个功能需要细粒度控制,用数值参数(如生成次数、存储空间)而非布尔值来区分。

向上兼容。高等级必须包含低等级的全部权益,不存在某个等级独有的基础功能。如果某个功能只出现在中间等级,说明等级划分需要重新审视。

可度量。每个权益项都能用代码表达为一个判断条件。「优先客服」不是一个好的权益定义,「24 小时内工单响应 + 专属客服通道」才是。

下面是一个典型的权益矩阵示例:

权益项FreeProTeamEnterprise
AI 生成次数/天3无限无限无限
单项目页数上限10200500无限制
导出分辨率720p4K4K8K
协作成员数0520无限制
API 调用
自定义模型
水印
SSO

权限校验实现

权限校验需要在三个层面同时生效:后端 API 网关、业务逻辑层、前端界面层。三层各有职责,缺一不可。

后端中间件校验

中间件是第一道防线。所有 API 请求在进入业务逻辑之前,必须先通过权限校验。

// 权限中间件 - 基于会员等级校验
export function requirePlan(minimumPlan: PlanTier) {
  return async (ctx: Context, next: Next) => {
    const userId = ctx.state.userId
    const membership = await getMembership(ctx, userId)
 
    // 等级权重比较:Enterprise > Team > Pro > Free
    if (getPlanWeight(membership.plan) < getPlanWeight(minimumPlan)) {
      ctx.status = 403
      ctx.body = {
        code: 'PLAN_UPGRADE_REQUIRED',
        requiredPlan: minimumPlan,
        currentPlan: membership.plan,
        upgradeUrl: `/billing/upgrade?to=${minimumPlan}`
      }
      return
    }
 
    // 将会员信息注入上下文,后续业务逻辑可直接使用
    ctx.state.membership = membership
    await next()
  }
}
 
// 使用示例
router.post('/api/comics/generate-batch',
  requirePlan('pro'),
  comicController.batchGenerate
)

中间件校验的核心是「快速拒绝」。不需要查询完整权限列表,只需要比较等级权重。这种设计将校验逻辑与业务逻辑解耦,新增功能时只需在路由上标注所需等级。

装饰器模式

对于需要更细粒度控制的场景(如按功能点而非等级校验),装饰器模式更灵活:

// 功能级权限装饰器
function RequirePermission(permission: string) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const original = descriptor.value
 
    descriptor.value = async function (...args: any[]) {
      const ctx = args[0] // 约定第一个参数为 Context
      const permissions = await getUserPermissions(ctx, ctx.state.userId)
 
      if (!permissions.includes(permission)) {
        throw new PermissionDeniedError(permission)
      }
 
      return original.apply(this, args)
    }
 
    return descriptor
  }
}
 
// 使用示例
class ComicController {
  @RequirePermission('comic:export:4k')
  async exportHighRes(ctx: Context) {
    // 业务逻辑
  }
 
  @RequirePermission('comic:batch:generate')
  async batchGenerate(ctx: Context) {
    // 业务逻辑
  }
}

装饰器模式的优势在于权限声明与业务方法绑定,代码可读性强。缺点是装饰器只能处理布尔判断,对于需要数值比较的场景(如「剩余生成次数 > 0」)需要额外处理。

前端权限控制

前端权限控制的目标不是安全(后端已经兜底),而是用户体验。用户不应该看到自己无法使用的按钮,然后点击后才被告知「权限不足」。

// 权限控制组件
function PermissionGate({
  permission,
  children,
  fallback
}: {
  permission: string
  children: React.ReactNode
  fallback?: React.ReactNode
}) {
  const { permissions } = useMembership()
 
  if (!permissions.includes(permission)) {
    return fallback ?? null
  }
 
  return <>{children}</>
}
 
// 使用示例
function EditorToolbar() {
  return (
    <div className="toolbar">
      <Button>保存</Button>
 
      <PermissionGate permission="comic:export:4k">
        <Button>高清导出</Button>
      </PermissionGate>
 
      <PermissionGate
        permission="comic:batch:generate"
        fallback={
          <Button variant="ghost" onClick={showUpgradeModal}>
            解锁批量生成
          </Button>
        }
      >
        <Button>批量生成</Button>
      </PermissionGate>
    </div>
  )
}

前端权限控制有一个容易忽略的细节:降级后的界面刷新。用户从 Pro 降级到 Free 后,如果前端缓存没有及时更新,用户仍然会看到 Pro 功能按钮。虽然后端会拒绝请求,但这种体验会让用户困惑。解决方案是在后端返回 403 时,前端统一拦截并刷新权限状态。


权限变更处理

会员等级不是静态的。升级、降级、过期、退款,每一种场景都需要正确处理权限变更,否则会出现数据不一致。

升级处理

升级是最简单的场景。用户支付成功后,支付平台(Stripe、Paddle)会发送 webhook 通知。后端接收到通知后,更新会员等级并清除权限缓存。

// Stripe webhook 处理
async function handleSubscriptionUpdated(event: Stripe.Event) {
  const subscription = event.data.object as Stripe.Subscription
 
  // 1. 映射 Stripe 价格 ID 到会员等级
  const newPlan = mapPriceIdToPlan(subscription.items.data[0].price.id)
 
  // 2. 更新数据库
  await db.membership.update({
    where: { userId: subscription.metadata.userId },
    data: {
      plan: newPlan,
      subscriptionId: subscription.id,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000)
    }
  })
 
  // 3. 清除权限缓存
  await invalidatePermissionCache(subscription.metadata.userId)
 
  // 4. 发送通知(可选)
  await notifyUser(subscription.metadata.userId, 'plan_upgraded', { newPlan })
}

升级后需要注意一个细节:当前周期的处理。如果用户从 Pro 升级到 Team,且 Pro 的计费周期还没结束,是否需要按比例退还差价?这取决于支付平台的配置和业务策略。Stripe 的 proration 功能可以自动处理差价计算。

降级处理

降级比升级复杂,因为涉及「什么时候生效」的问题。常见策略有两种:

立即生效:降级后立即切换到新等级。适用于免费等级降级(如取消试用)。用户可能已经创建的内容如果超出新等级限制,需要决定是保留还是标记为只读。

周期结束后生效:降级在当前计费周期结束后才生效。这是更常见的做法,用户已经支付了当前周期的费用,应该享有完整权益直到周期结束。

async function handlePlanDowngrade(userId: string, newPlan: PlanTier) {
  const membership = await getMembership(userId)
 
  // 策略:周期结束后生效
  const effectiveDate = membership.currentPeriodEnd
 
  // 1. 记录降级意向,不立即变更
  await db.membershipUpdate.create({
    data: {
      userId,
      type: 'DOWNGRADE_SCHEDULED',
      fromPlan: membership.plan,
      toPlan: newPlan,
      effectiveAt: effectiveDate
    }
  })
 
  // 2. 通知用户
  await notifyUser(userId, 'downgrade_scheduled', {
    newPlan,
    effectiveDate
  })
 
  // 3. 定时任务在 effectiveDate 执行实际降级
  // 见下方「过期处理」
}

过期与续费失败处理

订阅过期或续费失败时,需要有一个缓冲期。信用卡过期、银行处理延迟等情况很常见,不应该在到期当天就切断用户访问。

典型的做法是设置 3-7 天的宽限期。宽限期内用户仍可使用付费功能,但会收到续费提醒。宽限期结束后仍未续费,自动降级到 Free。

// 定时任务:每日检查过期会员
async function processExpiredMemberships() {
  const expired = await db.membership.findMany({
    where: {
      currentPeriodEnd: { lt: new Date() },
      status: 'ACTIVE',
      gracePeriodEnd: { gt: new Date() } // 仍在宽限期内
    }
  })
 
  for (const membership of expired) {
    // 发送最后提醒
    await notifyUser(membership.userId, 'membership_expiring_soon', {
      daysLeft: Math.ceil(
        (membership.gracePeriodEnd.getTime() - Date.now()) / 86400000
      )
    })
  }
 
  // 宽限期已结束,执行降级
  const pastGrace = await db.membership.findMany({
    where: {
      currentPeriodEnd: { lt: new Date() },
      gracePeriodEnd: { lt: new Date() },
      status: 'ACTIVE'
    }
  })
 
  for (const membership of pastGrace) {
    await downgradeToFree(membership.userId)
  }
}

数据处理策略

降级或过期后,用户已经创建的数据如何处理?常见的策略有三种:

策略适用场景实现复杂度用户体验
保留全部数据,高级功能变只读文档、设计稿等内容型产品好,用户不会丢失数据
保留数据但限制访问存储空间有限制的产品一般,用户看不到自己的内容
删除超出限额的数据存储成本高或合规要求差,需要谨慎处理

推荐第一种策略:保留全部数据,但将付费功能标记为只读。用户升级后可以立即恢复使用。这种方式对用户最友好,也最容易实现。


权限缓存策略

权限校验是高频操作。每次 API 请求都需要检查权限,如果每次都查询数据库,会成为性能瓶颈。缓存是必要的,但缓存一致性是挑战。

缓存方案设计

常见的缓存策略有三种,各有适用场景:

策略实现方式一致性性能适用场景
无缓存每次查数据库强一致内部系统、低频操作
本地内存缓存应用进程内缓存弱一致(需广播失效)最好单实例部署、容忍短暂不一致
Redis 分布式缓存Redis Hash/Set 存储中等(需主动失效)多实例部署、高频校验

对于大多数 SaaS 产品,Redis 分布式缓存是平衡性能和一致性的最佳选择。

Redis 缓存实现

// 权限缓存结构
// Key: `permission:{userId}`
// Value: Hash { plan: 'pro', permissions: ['comic:export:4k', ...], exp: timestamp }
 
async function getUserPermissions(ctx: Context, userId: string): Promise<string[]> {
  const cacheKey = `permission:${userId}`
 
  // 1. 尝试从缓存读取
  const cached = await redis.hgetall(cacheKey)
  if (cached && cached.exp && Number(cached.exp) > Date.now()) {
    return JSON.parse(cached.permissions)
  }
 
  // 2. 缓存未命中或已过期,查询数据库
  const membership = await db.membership.findUnique({
    where: { userId }
  })
  const permissions = await buildPermissionsFromPlan(membership.plan)
 
  // 3. 写入缓存,TTL 10 分钟
  await redis.hset(cacheKey, {
    plan: membership.plan,
    permissions: JSON.stringify(permissions),
    exp: String(Date.now() + 10 * 60 * 1000)
  })
  await redis.expire(cacheKey, 10 * 60) // 兜底过期
 
  return permissions
}
 
// 权限变更时主动清除缓存
async function invalidatePermissionCache(userId: string) {
  await redis.del(`permission:${userId}`)
}

缓存一致性保障

缓存最大的问题是「什么时候失效」。权限变更时必须主动清除缓存,否则用户升级后仍需等待缓存过期才能看到新功能。

几个关键场景的缓存失效策略:

升级/降级:在 webhook 处理流程中主动清除缓存,见前文代码示例。

管理员手动调整:管理后台修改用户权限后,同步清除缓存。

定时任务兜底:即使主动失效失败,缓存最多 10 分钟后自动过期。这个延迟对用户来说通常可以接受。

多实例部署:如果使用本地内存缓存,需要通过 Redis Pub/Sub 或消息队列广播失效事件。这是选择 Redis 分布式缓存的优势所在——所有实例共享同一份缓存,无需广播。

缓存预热与降级

高并发场景下,大量缓存同时过期会导致「缓存雪崩」,数据库压力骤增。解决方案是 TTL 加随机抖动:

// TTL 加随机抖动,避免同时过期
const baseTTL = 10 * 60 // 10 分钟
const jitter = Math.floor(Math.random() * 2 * 60) // 0-2 分钟随机
await redis.expire(cacheKey, baseTTL + jitter)

如果 Redis 不可用,需要有降级方案。可以回退到直接查询数据库,或者使用本地内存缓存作为二级缓存。


案例参考

案例 1:Notion 的权限与协作模型

Notion 的权限系统分为两层:工作空间级别的角色(Owner / Admin / Member / Guest)和页面级别的权限(可编辑 / 可评论 / 只读)。这种分层设计允许细粒度控制,同时保持简单。

对于会员等级,Notion 采用功能门控(Feature Gating)策略:Free 用户可以创建无限页面,但文件上传限制 5MB、协作成员限制 10 人;Plus 用户放开到无限成员和更大文件;Business 增加 SAML SSO 和高级权限管理。

Notion 的做法值得借鉴的是:免费版不限制核心功能的使用,只在协作和存储上设限。这让用户充分体验产品价值,升级时不会觉得「之前用不上」,而是「现在需要更多」。

案例 2:Midjourney 的订阅等级设计

Midjourney 的会员等级分为 Basic($10/月)、Standard($30/月)、Pro($60/月)和 Mega($120/月)。其权限设计的核心不是功能门控,而是用量门控(Usage Gating)。

所有等级都能使用相同的功能,区别在于每月 GPU 时间:Basic 约 200 张图,Standard 15 小时快速生成,Pro 30 小时,Mega 60 小时。这种设计的优势是用户不需要「学习」不同等级的功能差异,只需根据用量需求选择。

Midjourney 的处理方式对 AI 产品有直接参考价值:AI 生成本身是资源密集型操作,用量门控比功能门控更符合成本结构。但缺点是高等级用户没有额外的功能激励,升级动力主要来自用量需求而非功能需求。

案例 3:Linear 的权限与数据隔离

Linear 采用 RBAC + 数据隔离的混合模式。Free 计划的工作空间数据对所有成员可见,Enterprise 计划支持基于团队的数据隔离和自定义角色。

Linear 的权限变更采用「周期结束后生效」策略。降级后当前周期内仍可使用付费功能,周期结束后数据不会删除,但高级权限(如 SSO、审计日志)会立即失效。这种处理方式平衡了用户体验和数据安全。


会员权限校验流程

以下流程图展示了从用户发起请求到权限校验完成的完整链路:

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

检查清单

在上线会员权限系统之前,逐项确认以下要点:

  • 权益矩阵已定义,每个等级之间的差异明确可量化
  • 后端 API 网关层有统一的权限中间件,不依赖业务代码自行校验
  • 前端权限控制与后端权限数据同步,降级后界面能及时刷新
  • 升级处理流程包含缓存清除,用户升级后无需重新登录
  • 降级策略已确定(立即生效 vs 周期结束后生效),且有定时任务兜底
  • 过期/续费失败有宽限期处理,不会因支付延迟直接切断服务
  • 降级后用户数据保留策略已明确,不会意外删除用户内容
  • Redis 缓存有 TTL 兜底,避免永久缓存导致一致性问题
  • 缓存失效策略覆盖所有权限变更场景(升级、降级、管理员调整、退款)
  • 缓存雪崩防护已实现(TTL 随机抖动、降级方案)
  • 权限校验失败的响应格式统一,包含升级引导信息
  • 权限变更有审计日志,支持排查用户投诉
  • 压力测试已验证缓存命中率,数据库不会成为瓶颈
  • 国际化场景下权限文案已翻译,升级引导适配多语言

参考资料

  1. Feature Gating & 8 Ways to Implement It — Stigg 对功能门控策略的系统梳理,涵盖 8 种实现方式和各自的适用场景。

  2. Subscription-based Access Control (SBAC): A Smarter Approach for SaaS — 提出 SBAC 概念,将订阅状态与访问控制直接绑定,比传统 RBAC 更适合 SaaS 场景。

  3. How to Store User Permissions in Redis — OneUptime 的实战分享,详细介绍如何在 Redis 中存储和查询用户权限。

  4. Caching Patterns – Database Caching Strategies Using Redis — AWS 白皮书,覆盖 Cache-Aside、Write-Through 等缓存模式的选型依据。

  5. Shortcomings of Plan Identifiers, Authorization & Feature Flags — 分析传统方案(计划标识符、权限系统、Feature Flag)在订阅制产品中的不足。

  6. 权限系统设计详解 — JavaGuide 对 RBAC、ABAC 等权限模型的中文系统性介绍,适合理解底层概念。

  7. 7 Best Practices to Implement Feature Flags at Scale — GrowthBook 关于规模化 Feature Flag 的最佳实践,涉及订阅等级作为目标属性的设计。