会员权限控制
不同会员看到不同的功能,是付费转化的关键入口。用户打开你的 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 小时内工单响应 + 专属客服通道」才是。
下面是一个典型的权益矩阵示例:
| 权益项 | Free | Pro | Team | Enterprise |
|---|---|---|---|---|
| AI 生成次数/天 | 3 | 无限 | 无限 | 无限 |
| 单项目页数上限 | 10 | 200 | 500 | 无限制 |
| 导出分辨率 | 720p | 4K | 4K | 8K |
| 协作成员数 | 0 | 5 | 20 | 无限制 |
| 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、审计日志)会立即失效。这种处理方式平衡了用户体验和数据安全。
会员权限校验流程
以下流程图展示了从用户发起请求到权限校验完成的完整链路:
检查清单
在上线会员权限系统之前,逐项确认以下要点:
- 权益矩阵已定义,每个等级之间的差异明确可量化
- 后端 API 网关层有统一的权限中间件,不依赖业务代码自行校验
- 前端权限控制与后端权限数据同步,降级后界面能及时刷新
- 升级处理流程包含缓存清除,用户升级后无需重新登录
- 降级策略已确定(立即生效 vs 周期结束后生效),且有定时任务兜底
- 过期/续费失败有宽限期处理,不会因支付延迟直接切断服务
- 降级后用户数据保留策略已明确,不会意外删除用户内容
- Redis 缓存有 TTL 兜底,避免永久缓存导致一致性问题
- 缓存失效策略覆盖所有权限变更场景(升级、降级、管理员调整、退款)
- 缓存雪崩防护已实现(TTL 随机抖动、降级方案)
- 权限校验失败的响应格式统一,包含升级引导信息
- 权限变更有审计日志,支持排查用户投诉
- 压力测试已验证缓存命中率,数据库不会成为瓶颈
- 国际化场景下权限文案已翻译,升级引导适配多语言
参考资料
-
Feature Gating & 8 Ways to Implement It — Stigg 对功能门控策略的系统梳理,涵盖 8 种实现方式和各自的适用场景。
-
Subscription-based Access Control (SBAC): A Smarter Approach for SaaS — 提出 SBAC 概念,将订阅状态与访问控制直接绑定,比传统 RBAC 更适合 SaaS 场景。
-
How to Store User Permissions in Redis — OneUptime 的实战分享,详细介绍如何在 Redis 中存储和查询用户权限。
-
Caching Patterns – Database Caching Strategies Using Redis — AWS 白皮书,覆盖 Cache-Aside、Write-Through 等缓存模式的选型依据。
-
Shortcomings of Plan Identifiers, Authorization & Feature Flags — 分析传统方案(计划标识符、权限系统、Feature Flag)在订阅制产品中的不足。
-
权限系统设计详解 — JavaGuide 对 RBAC、ABAC 等权限模型的中文系统性介绍,适合理解底层概念。
-
7 Best Practices to Implement Feature Flags at Scale — GrowthBook 关于规模化 Feature Flag 的最佳实践,涉及订阅等级作为目标属性的设计。