跳转到主要内容

Nest 使用笔记

第十二章:RBAC 权限控制——三表模型、功能级与数据级权限、全局 Guard 链

在上一章登录的基础上加权限。打开 learnhub 的 RBAC 代码——role/permission 实体、user_role/role_permission 多对多、@RequirePermission 装饰器、全局 PermissionGuard、Seed 灌角色权限,再讲清功能级权限和数据级权限(assertAuthor)为什么是两道关,以及生产必备的权限缓存怎么补。

  • Nest
  • RBAC
  • 认证

上一章做了「登录才能用」——拿到 token 就是合法用户。但 learnhub 里学生能发帖、不能置顶;管理员能置顶、能改任何人的帖子;游客连发帖按钮都看不到。光靠登录与否分不出这些。这一章做 RBAC(基于角色的访问控制)——用户挂角色、角色挂权限,接口声明「需要哪个权限」,全局 Guard 拦截判断。

和「写个 Article 跑通 admin/user」的玩具版不同,这一章直接打开 learnhub 的 RBAC 代码,把五件最容易讲浅的事讲透:三表模型、功能级 vs 数据级权限、Guard 链顺序、Seed 灌权限、权限缓存。每段代码都指向 learnhub 里能打开看的真实文件。

先搞懂:RBAC 是什么 / 为什么需要 / 企业级怎么用

RBAC 是什么:Role-Based Access Control。三层结构——用户(user)/ 角色(role)/ 权限(permission),用户不直接拿权限,而是通过「admin」「student」这类角色间接拿。给某个接口标 post:pin,谁的角色里挂了 post:pin 谁能调。learnhub 的模型长这样:

user  ──多对多──  role  ──多对多──  permission
(张三)          (student)       (post:create)
(admin)         (post:pin / user:read ...)

两个多对多意味着五张表:userrolepermission 三个主表 + user_rolerole_permission 两个中间表。管理员新增一个权限时,RBAC 只改「admin」角色一次,所有 admin 用户自动生效——这是它比 ACL(直接给用户挂权限)强的地方。下面第一步就会看到 learnhub 的实体定义。

为什么需要它:最朴素的 ACL(直接给每个用户挂权限)在人一多就彻底失控——新来一个员工要一条条勾权限,离职要一条条删,权限项一改一批人跟着错。角色做中间层,先定权限项再分角色,加人只挂角色一次。判断标准:用户多、权限项多、按岗位分组 → RBAC;用户极少且权限高度个性化 → 直接 ACL 也够用。

企业级怎么用(下面每一条 learnhub 都真做了,不是空头支票):

  • 权限分两层——功能级(能不能调这个接口,靠 Controller 上的 @RequirePermission + 全局 Guard)和数据级(同一接口,能不能动这条具体记录,靠 Service 里的所有权校验)。两层都要,缺一层都会漏。第七步落地。
  • 权限配置走数据库而不是写死代码——运营/管理员能在后台动态增删角色权限。learnhub 的 RbacModule 注册了 Role/Permission 实体,后台接口留作练习。
  • 用全局 Guard 在统一入口拦截,别在业务里到处 if (user.role === 'admin')——散落的判断迟早漏。
  • 角色/权限初始化走 Seed 迁移而不是手写 SQL——可复现、可版本化、CI 和 Docker 都能跑。第六步落地。
  • 大流量场景要给权限加缓存——每次请求都多表 JOIN 查权限,QPS 一上去数据库会顶不住。第九步讲清,learnhub 自己没做,作为生产加固讲。

这一章你会做出什么

  • 打开 learnhub 的 role.entity.ts / permission.entity.ts,把 user/role/permission 三表 + 两中间表讲透。
  • 打开 PermissionGuard 的真代码,讲清 Reflector.getAllAndOverride 怎么读 metadata、Guard 链的顺序为什么是 JwtAuthGuard 在前 PermissionGuard 在后、没标 @RequirePermission 的接口默认怎么处理。
  • 打开 learnhub 的 Seed 迁移,看「admin 拥有全部权限、student 只有 post:create」是怎么在代码里灌进去的——而不是让你手动 INSERT。
  • 在 PostController 上看「功能级权限」和「数据级权限」怎么配合:create/pin 用 @RequirePermission、update/delete 用 assertAuthor
  • 给出权限缓存的完整生产加固方案——learnhub 自己没做,作为生产实践补上。

前置:上一章的 JWT 登录已经能用(拿到 token),learnhub 的 MySQL 在跑、迁移跑过(npm run migration:run)。

第一步:Role 和 Permission 实体——三表 + 两中间表

权限点形如 'post:create''post:pin''user:read'——动词:资源 的写法,比 'admin' 这种粗粒度角色名灵活得多(角色能随便加,权限点是有穷集合,写在代码里)。先看 learnhub 的 Permission 实体:

// learnhub/src/modules/rbac/entities/permission.entity.ts
@Entity('permission')
export class Permission {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50, unique: true, comment: '权限点:post:create 等' })
  name: string;

  @Column({ length: 100, nullable: true, comment: '描述' })
  desc?: string;

  // 权限 ↔ 角色(反向,Role 那边用 @JoinTable 指定 role_permission)
  @ManyToMany(() => Role, (r) => r.roles)
  roles: Role[];
}

nameunique 是因为权限点是全局标识,重复了语义就乱了。roles 是反向关联——这块的 owning side 在 Role 那边(@JoinTable 写在 Role 上),Permission 这边只声明「我能被角色反查到」,不负责建中间表。Role 实体:

// learnhub/src/modules/rbac/entities/role.entity.ts
@Entity('role')
export class Role {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 20, unique: true, comment: '角色标识:admin / student' })
  name: string;

  @Column({ length: 50, nullable: true, comment: '显示名' })
  label?: string;

  // 角色 ↔ 用户(反向,User 那边用 @JoinTable 指定 user_role)
  @ManyToMany(() => User, (u) => u.roles)
  users: User[];

  // 角色 ↔ 权限,中间表 role_permission
  @ManyToMany(() => Permission, (p) => p.roles)
  @JoinTable({ name: 'role_permission' })
  permissions: Permission[];
}

name 是机器读的标识(admin / student),label 是给人看的(「管理员」/「学生」)。注意两个多对多里,@JoinTable 只写一次——在 owning side。Role-Permission 这层 owning side 是 Role(@JoinTable({ name: 'role_permission' })),User-Role 那层 owning side 是 User。两边都写 @JoinTable 会建两张中间表,错了。User 那边的关联:

// learnhub/src/modules/user/entities/user.entity.ts
@ManyToMany(() => Role, (r) => r.users)
@JoinTable({ name: 'user_role' })
roles: Role[];

这一个 @JoinTable({ name: 'user_role' }) 触发 TypeORM 在迁移里建 user_role 中间表(主键是 userId + roleId,带 CASCADE 外键)。加上 role_permission,三主表 + 两中间表全部到位。多对多 owning side 细节留给第九章,这一章知道「中间表由 owning side 的 @JoinTable 决定」就够。

注意:RBAC 模型在数据库设计上是「重读轻写」——角色/权限的增删改非常少(一年改不了几次),但每次鉴权都要 JOIN 五张表里的三张(user → user_role → role → role_permission → permission)。这个不对称是后面第九步要给权限加缓存的根本原因。

第二步:把 Role / Permission 注册到模块

learnhub 把 RBAC 单独拆成一个模块,里面只注册两个实体:

// learnhub/src/modules/rbac/rbac.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Role, Permission])],
  exports: [TypeOrmModule],
})
export class RbacModule {}

forFeature([Role, Permission]) 干两件事:把两个实体登记进全局的 autoLoadEntities 清单(建表用),同时把 Repository<Role>Repository<Permission> 注册成可注入的 provider。exports: [TypeOrmModule] 是把这两个 Repository 再导出——别的模块(比如 UserService 要查角色绑默认学生身份)import RbacModule 后就能注入。

注意 RbacModule 本身没有 service、没有 controller——learnhub 不给「增删角色/权限」开 REST 接口,这种低频高危操作走数据库直改或后台工具,避免被普通管理员误操作。真实业务里如果要做 RBAC 管理后台,再在这个模块里加 RbacController(路由 /api/v1/rbac/...)和 RbacService,给那些接口加 @RequirePermission('rbac:manage') 兜底,并在改完后清掉相关用户的权限缓存(第九步细讲)。

第三步:@RequirePermission 装饰器——把权限点写进 metadata

装饰器干的事只有一件——把权限点数组塞到路由的 metadata 里,Guard 后面读出来用:

// learnhub/src/core/decorators/require-permission.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const PERMISSIONS_KEY = 'permissions';
export const RequirePermission = (...permissions: string[]) =>
  SetMetadata(PERMISSIONS_KEY, permissions);

SetMetadata(key, value) 是 Nest 提供的工具——把任意键值挂到控制器/方法上,运行时用 Reflector 取出来。PERMISSIONS_KEY 常量单独 export,是为了装饰器和 Guard 引用同一个 key 字符串,避免「装饰器写 'permissions'、Guard 读 'permission'」这种 typo 灾难。用法一眼就懂:

@RequirePermission('post:create')              // 单个
@RequirePermission('post:create', 'post:pin')  // 多个(Guard 里默认要求全部拥有)

同一套路,@Public 装饰器是把「白名单」标记写进 metadata:

// learnhub/src/core/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

两个装饰器都只是「打标签」,不做任何判断——判断全在 Guard 里。这种「装饰器写元数据、Guard/Interceptor 读元数据」的分工,是 Nest 框架式编程的基本姿势(第五章讲 AOP 五件套时细讲过)。

第四步:全局 PermissionGuard——这一章的核心

learnhub 的 PermissionGuard 完整代码(逐段拆开讲):

// learnhub/src/core/guards/permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly userService: UserService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const required = this.reflector.getAllAndOverride<string[] | undefined>(PERMISSIONS_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required || required.length === 0) return true;

    const req = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
    if (!req.user) throw new UnauthorizedException('未登录');

    const user = await this.userService.findById(req.user.userId);
    if (!user) throw new UnauthorizedException('用户不存在');

    const owned = new Set<string>();
    for (const role of user.roles ?? []) {
      for (const p of role.permissions ?? []) owned.add(p.name);
    }

    const ok = required.every((p) => owned.has(p));
    if (!ok) {
      throw new ForbiddenException(`权限不足,需要:${required.join(', ')}`);
    }
    return true;
  }
}

四个关键设计:

1. Reflector.getAllAndOverride(key, [handler, class]) 同时看方法级和类级 metadata。它按 [handler, class] 顺序找,先命中谁用谁——这意味着你可以在 Controller 类上贴 @RequirePermission('post:read') 当默认,某个方法再单独贴 @RequirePermission('post:write') 覆盖。learnhub 没用到这层,但机制是给出来的。如果用 Reflector.get 而不是 getAllAndOverride,就只能取一个层级,错过类级默认值。

2. 三档默认行为——看代码里的早返回顺序:

  • @Public → 直接放行(连查库都不查);
  • @RequirePermission → 直接放行(仅需登录,因为 JwtAuthGuard 已经验过 token);
  • @RequirePermission → 必须登录且权限齐全。

中间那档是新手最容易搞错的:以为「没贴装饰器就是公开」。错。没贴 @Public 也没贴 @RequirePermission 的接口,前面 JwtAuthGuard 仍然会要 token,所以是登录即可访问。想让接口公开必须显式贴 @Public

3. req.user 是 JwtAuthGuard 注入的——这个 Guard 依赖前一个 Guard 已经把 { userId, username } 挂到 request.user 上了。所以执行顺序必须 JwtAuthGuard 在前、PermissionGuard 在后(下一步细讲)。如果 req.user 是空的,说明认证没过,抛 401。

4. 多角色权限合并 + 全部命中for...for... 把用户所有角色的所有权限拍平进一个 Set 去重;然后 required.every(p => owned.has(p)) 要求「接口声明的权限点用户全都有」——是 AND 语义。要 OR 语义(有任意一个就放行)改成 .some(...) 即可,但 learnhub 选 AND,因为「需要 A 且 B」更安全,少一个就拦下。ForbiddenException 抛 403(不是 401)——401 是「没登录」、403 是「登录了但权限不够」,这个区分前端会按状态码决定是跳登录页还是提示「无权限」。

注意ForbiddenException 的 message 把需要的权限点列出来(权限不足,需要:post:pin)——开发期方便排查,生产里可能要脱敏(不要把接口的权限模型细节暴露给调用方)。req.user 由 JwtAuthGuard 用 token payload 注入,只包含 userIdusername,不包含权限——权限必须现去库里查。这是为什么这个 Guard 要注入 UserService、而且每次请求都查一次库。第九步讲怎么用缓存省掉这个查询。

第五步:全局 Guard 链——JwtAuthGuard 在前,PermissionGuard 在后

两个 Guard 都在 app.module.ts 里用 APP_GUARD 注册成全局:

// learnhub/src/app.module.ts
providers: [
  // 全局异常过滤器:统一错误结构【节22】
  { provide: APP_FILTER, useClass: HttpExceptionFilter },
  // 全局拦截器:统一返回结构 + 请求日志【节19】
  { provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  // ...
  // 阶段2:全局 Guard。顺序即执行顺序:先认证后鉴权
  { provide: APP_GUARD, useClass: JwtAuthGuard },
  { provide: APP_GUARD, useClass: PermissionGuard },
],

APP_GUARD 是个特殊 token——Nest 把所有用 APP_GUARD 注册的 Guard 按声明顺序串成一条链,请求进来按顺序过,任何一个返回 false 或抛异常都立刻拦下。这条链的顺序就是声明顺序:JwtAuthGuard 在前、PermissionGuard 在后。

顺序为什么不能反:PermissionGuard 依赖 req.user,而 req.user 是 JwtAuthGuard 解析 token 后注入的。如果反过来,PermissionGuard 先跑、req.user 还是空的,要么误判所有人「未登录」、要么直接放行(取决于代码怎么写)——都不对。认证(你是谁)永远在鉴权(你能干啥)之前。JwtAuthGuard 的代码上一章细讲过,这里看它和 PermissionGuard 怎么配合:

// learnhub/src/core/guards/jwt-auth.guard.ts
async canActivate(context: ExecutionContext): Promise<boolean> {
  // 1. @Public 白名单
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);
  if (isPublic) return true;

  // 2. 取 token
  const req = this.getRequest(context);
  const auth = req.headers['authorization'] || '';
  if (!auth.startsWith('Bearer ')) {
    throw new UnauthorizedException('缺少登录凭证');
  }
  const token = auth.slice('Bearer '.length).trim();

  // 3. 验签
  let payload: RequestUser;
  try {
    payload = await this.jwtService.verifyAsync(token);
  } catch {
    throw new UnauthorizedException('登录凭证无效或已过期');
  }

  // 4. 挂到 request.user,后续 PermissionGuard / @CurrentUser 都用它
  req.user = { userId: payload.userId, username: payload.username };
  return true;
}

两件事配套:

  • @Public 是两个 Guard 共享的白名单——两边都用 Reflector 读 IS_PUBLIC_KEY。所以贴一次 @Public() 就能让接口绕过整条 Guard 链,不用在每个 Guard 里分别声明白名单。注册、登录、refresh、列表、详情这些接口都贴了 @Public
  • JwtAuthGuard 只往 req.user{ userId, username },不放角色权限。权限在 PermissionGuard 里现查。这是个权衡:把权限塞进 JWT 能省一次查库,但权限一旦改了,老 token 在过期前还带着旧权限,存在权限延迟失效问题;现查保证实时,代价是每请求一次 JOIN。learnhub 选实时,把缓存留作生产加固(第九步)。

注意(容易踩的坑):忘了给 register/login 贴 @Public,新用户永远注册不了——因为没有 token 就过不了 JwtAuthGuard,连注册接口都进不去。learnhub 的 AuthController 里 register、login、refresh、github 全部贴了 @Public。新加公开接口时一定先想「这个接口需要登录吗」,需要就别贴,不需要就显式贴 @Public——不要依赖「没贴 @RequirePermission 就是公开」,那是错的,没贴 @RequirePermission 只是「不需要特定权限」,仍要登录。

第六步:Seed 迁移——角色、权限、admin 账号写进代码

旧课程让你「手动 INSERT 几条权限点」——那是错的。生产里手写 SQL 灌权限会出三件事:本地能跑线上跑不了(环境间不一致)、CI/Docker 启动时没权限可用、权限项改了没法版本化追踪。learnhub 把种子数据写成迁移文件,和建表迁移一起跑:

// learnhub/src/migrations/1784362383604-Seed.ts
export class Seed1784362383604 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 角色
    await queryRunner.query(
      `INSERT INTO \`role\` (id, name, label) VALUES (1,'admin','管理员'),(2,'student','学生')`,
    );
    // 权限
    await queryRunner.query(
      `INSERT INTO \`permission\` (id, name, \`desc\`) VALUES
        (1,'post:create','发帖'),
        (2,'post:pin','置顶帖子'),
        (3,'user:read','查看用户'),
        (4,'user:update','修改用户')`,
    );
    // 角色-权限:admin 拥有全部;student 仅 post:create
    await queryRunner.query(
      `INSERT INTO \`role_permission\` (\`roleId\`, \`permissionId\`) VALUES
        (1,1),(1,2),(1,3),(1,4),
        (2,1)`,
    );
    // 管理员用户:密码 admin123456 + salt(learnhub_salt),MD5
    await queryRunner.query(
      `INSERT INTO \`user\` (id, username, password, email) VALUES
        (1,'admin', MD5(CONCAT('admin123456','learnhub_salt')), 'admin@learnhub.local')`,
    );
    await queryRunner.query(`INSERT INTO \`user_role\` (\`userId\`, \`roleId\`) VALUES (1,1)`);
    // ...测试标签和帖子省略
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    // 反向 DELETE,按外键依赖倒序删
  }
}

几件事值得讲:

  • 种子是迁移——和建表迁移(Init1784362383603)同一个 migration:run 命令一起执行,先建表再灌数据。迁移文件名前缀是时间戳,TypeORM 按时间戳排序执行。所以建表 → 灌种子 → 后续加表(AddChat)这个顺序是文件名时间戳保证的。
  • admin 拥有全部权限(4 个)、student 只有 post:create——这是 learnhub 业务的真实设计:学生能发帖但不能置顶,管理员能置顶、能管用户。新员工来挂 admin 角色,立即拥有全部权限;新用户注册挂 student(UserService.register 里默认绑 student)。
  • 密码用 MD5(CONCAT('admin123456', 'learnhub_salt'))——和 UserService.hashPassword 同一套算法(MD5 + salt)。salt 从 .envPASSWORD_SALT 拿,默认 learnhub_salt。这样种子里的密码哈希和注册生成的哈希一致,登录能验通。生产里 MD5 不够安全(应换 bcrypt),learnhub 教学版用 MD5。
  • down 反向 DELETE——回滚迁移时按外键依赖倒序删,保证能干净回滚到空库。

为什么用 Seed 而不是手动 SQL:可复现(每个开发者本地、CI、生产跑同样的种子)、可版本化(权限模型变化 git 能 diff)、可重放(migration:revertmigration:run 能恢复初始状态)。容器启动时第八章的 docker-entrypoint.shRUN_MIGRATIONS_ON_BOOT=true 时自动跑迁移,新容器起来就有 admin 账号可用。手写 SQL 做不到这些——你忘了在哪台机器上灌过、灌的什么版本,迟早出事。

第七步:Controller 贴 @RequirePermission、Service 做 assertAuthor——功能级 vs 数据级

这是 RBAC 最容易讲浅的一节。learnhub 的 PostController 把两层权限用得清清楚楚:

// learnhub/src/modules/post/post.controller.ts
@ApiTags('帖子')
@ApiBearerAuth()
@UseInterceptors(ClassSerializerInterceptor)
@Controller({ path: 'posts', version: '1' })
export class PostController {
  constructor(private readonly postService: PostService) {}

  @Post()
  @RequirePermission('post:create')    // 功能级:能发帖吗?
  create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser): Promise<PostEntity> {
    return this.postService.create(dto, user.userId);
  }

  @Public()
  @Get()
  list(@Query() q: QueryPostDto): Promise<PageResult<PostEntity>> {
    return this.postService.findMany(q);
  }

  @Public()
  @Get(':id')
  detail(@Param('id') id: number): Promise<PostEntity> {
    return this.postService.findOne(id);
  }

  @Patch(':id')                          // 没有 @RequirePermission
  update(@Param('id') id: number, @Body() dto: UpdatePostDto, @CurrentUser() user: RequestUser): Promise<PostEntity> {
    return this.postService.update(id, dto, user.userId);
  }

  @Delete(':id')                         // 没有 @RequirePermission
  async remove(@Param('id') id: number, @CurrentUser() user: RequestUser): Promise<void> {
    await this.postService.remove(id, user.userId);
  }

  @Patch(':id/pin')
  @RequirePermission('post:pin')        // 功能级:能置顶吗?
  async pin(@Param('id') id: number, @Body() body: { pinned: boolean }): Promise<void> {
    await this.postService.pin(id, body.pinned);
  }
}

注意三种用法并存:

  • @Public() —— 列表、详情,游客可看;
  • @RequirePermission('post:create') / ('post:pin') —— 发帖、置顶,需要特定权限点;
  • 啥都不贴 —— update、delete,仅需登录(JwtAuthGuard 验身份),但能不能动这条帖子由 Service 里的所有权校验决定。

update/delete 为什么不挂 @RequirePermission?因为「能不能改这条帖子」不是角色问题,而是数据问题——作者本人能改自己的,管理员能改任何人的,别的学生不能改。这种判断得看具体帖子的 authorId,Controller 层拿不到,必须进 Service:

// learnhub/src/modules/post/post.service.ts
async update(id: number, dto: UpdatePostDto, userId: number): Promise<Post> {
  const post = await this.assertAuthor(id, userId);     // 先校验所有权
  Object.assign(post, { title: dto.title ?? post.title, content: dto.content ?? post.content });
  if (dto.tagIds) post.tags = await this.tagRepo.findBy({ id: In(dto.tagIds) });
  const saved = await this.postRepo.save(post);
  this.indexPost(saved);
  return saved;
}

async remove(id: number, userId: number): Promise<void> {
  await this.assertAuthor(id, userId);
  await this.postRepo.delete(id);
  this.searchService.removePost(id).catch(() => undefined);
}

/** 数据级权限:本人或 admin 才能操作 */
private async assertAuthor(id: number, userId: number): Promise<Post> {
  const post = await this.postRepo.findOne({ where: { id }, relations: { author: true } });
  if (!post) throw new NotFoundException('帖子不存在');
  const isAdmin = await this.isAdmin(userId);
  if (post.author.id !== userId && !isAdmin) {
    throw new ForbiddenException('只能操作自己的帖子');
  }
  return post;
}

private async isAdmin(userId: number): Promise<boolean> {
  const user = await this.userService.findById(userId);
  return (user?.roles ?? []).some((r) => r.name === 'admin');
}

assertAuthor 第八章已经讲过(事务、N+1 那章),这里重新看它一眼——它是数据级权限:判断「这条 post 的 author 是不是当前用户」。这不是 Guard 能干的活,Guard 看不到 id 参数对应的帖子;这必须在 Service 里、查到具体记录之后判断。

思考:为什么 create 用功能级、update/delete 用数据级?因为「能不能发帖」是关于「你这个人有没有这个能力」——学生身份就有,跟具体帖子无关,是角色问题;而「能不能改这条帖子」是关于「这条帖子是不是你的」——得看具体记录的 authorId,是数据问题。两件事性质不同,所以走两套机制。pin 又是另一回事:「置顶」是平台级操作,不是改自己的帖子,只有 admin 能干,所以回到功能级 @RequirePermission('post:pin')

注意(铁律):功能级权限和数据级权限是两道关,不是二选一。一个接口可能两层都要——比如「管理员能置顶任何人的帖子」,功能级过 post:pin、数据级因为 admin 直接放行。漏掉任一层都会出问题:只做功能级、漏掉 assertAuthor,学生能改别人的帖子;只做数据级、漏掉 post:pin,学生能置顶别人的帖子。新接口上线前过一遍清单:公开?登录即可?需要哪个权限点?要不要校验所有权?

第八步:跑起来验证

迁移跑过后,库里已经有:admin 角色(4 个权限)、student 角色(1 个权限)、admin 用户(admin/admin123456)。

cd learnhub
npm run start:dev

用 admin 登录拿 token:

curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

注册一个学生账号(@Public,无需 token):

curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123","email":"alice@x.com"}'

学生登录拿 token,然后试三个场景:

# 1. 学生发帖 → 通过(student 有 post:create)
curl -X POST http://localhost:3000/api/v1/posts \
  -H "Authorization: Bearer <学生 token>" \
  -H "Content-Type: application/json" \
  -d '{"title":"我的第一条帖子","content":"..."}'

# 2. 学生置顶 → 403(student 没有 post:pin,PermissionGuard 拦)
curl -X PATCH http://localhost:3000/api/v1/posts/1/pin \
  -H "Authorization: Bearer <学生 token>" \
  -H "Content-Type: application/json" \
  -d '{"pinned":true}'
# {"message":"权限不足,需要:post:pin","statusCode":403}

# 3. 学生改别人的帖子 → 403(assertAuthor 拦)
curl -X PATCH http://localhost:3000/api/v1/posts/1 \
  -H "Authorization: Bearer <学生 token>" \
  -H "Content-Type: application/json" \
  -d '{"title":"被改了"}'
# {"message":"只能操作自己的帖子","statusCode":403}

换 admin token 再试:置顶通过(有 post:pin)、改任何人的帖子通过(assertAuthorisAdmin 放行)。三个错误 message 不一样——「权限不足,需要:post:pin」是功能级 PermissionGuard 抛的、「只能操作自己的帖子」是数据级 assertAuthor 抛的、登录过期则是 JwtAuthGuard 抛的 401。看 message 就知道是哪一层拦的,这是分层权限设计的额外好处。

第九步:生产加固——权限缓存(learnhub 没做,你需要补)

最后一步讲清 learnhub 的一个生产短板:每次受保护请求都查一次 user → roles → permissions 的多表 JOIN。看 PermissionGuard 里这行:

// learnhub/src/core/guards/permission.guard.ts
const user = await this.userService.findById(req.user.userId);

UserService.findById 的实现是 relations: { roles: { permissions: true } }——一次 JOIN 三张表(user、role、permission 通过两个中间表连起来)。QPS 一高,这个 JOIN 就是数据库的瓶颈。learnhub 自己在 PermissionGuard 注释里也写了「性能优化(阶段5 再做):把用户权限缓存到 Redis,避免每次请求查库」。这是真实项目里迟早要补的,下面给出完整方案,不改 learnhub,作为生产加固理解

思路:用户登录后角色权限极少变(管理员改角色才会变),可以缓存。每次鉴权先查 Redis、未命中再查库并回填。写入时机——登录成功时把当前用户的权限点集合序列化进 Redis:

// 生产加固示意(非 learnhub 现有代码)
const u = await this.userService.findById(userId);
const owned = new Set<string>();
for (const r of u.roles ?? []) for (const p of r.permissions ?? []) owned.add(p.name);
// TTL 1 小时,和 access token 寿命匹配;token 过期重新登录时再写一次
await this.redis.set(`user:${userId}:perms`, JSON.stringify([...owned]), 'EX', 3600);

PermissionGuard 改造——先查 Redis,未命中查库并回填:

// 生产加固示意(非 learnhub 现有代码)
async canActivate(context: ExecutionContext): Promise<boolean> {
  // ...前面 @Public、required 判断不变
  const userId = req.user.userId;
  const cacheKey = `user:${userId}:perms`;

  let owned: string[] | null = await this.redis
    .get(cacheKey)
    .then((s) => (s ? JSON.parse(s) : null));
  if (!owned) {
    const user = await this.userService.findById(userId);
    owned = (user.roles ?? []).flatMap((r) => r.permissions ?? []).map((p) => p.name);
    await this.redis.set(cacheKey, JSON.stringify([...new Set(owned)]), 'EX', 3600);
  }

  const ok = required.every((p) => owned!.includes(p));
  if (!ok) throw new ForbiddenException(`权限不足,需要:${required.join(', ')}`);
  return true;
}

失效时机(最难的一环):管理员给用户加/减角色、给角色加/减权限时,必须主动清掉相关用户的缓存,否则权限延迟生效——提拔一个用户为管理员,他得重新登录或等 TTL 过期才生效,这是安全风险。最稳的做法是「角色变更时批量删该角色下所有用户的缓存」+「权限变更时删所有缓存」。learnhub 现在 没有 RBAC 管理接口,所以不存在这个写入路径;真要做后台时,缓存的失效要在 RbacService 里实现,且改完角色立刻 DEL user:${uid}:perms

思考:缓存换来了性能(少一次 JOIN),代价是「权限变更延迟生效」。这个权衡取决于业务——如果业务允许「管理员改角色后 1 小时(TTL)内才生效」,直接靠 TTL 失效最简单;如果必须立即生效(比如封禁用户),就要主动清缓存,甚至把「被封禁」这种高频状态从 RBAC 里独立出来不走缓存(learnhub 的 User.isFrozen 字段就是这个意思,每次登录直接查库,不缓存)。下一章讲 Redis 缓存会把「MySQL 是真相源、Redis 是副本」这条一致性原则系统讲透。

这一章的成果

  1. 定义了 user/role/permission 三主表 + user_role/role_permission 两中间表,理解 @JoinTable 只写在 owning side。
  2. 写了 @RequirePermission('xxx') 装饰器(SetMetadata)+ @Public 装饰器,配套 Reflector.getAllAndOverride 在 PermissionGuard 里读出来。
  3. 注册了全局 Guard 链——JwtAuthGuard 在前(注入 req.user)、PermissionGuard 在后(读 req.user 查权限),理解顺序为什么不能反、@Public 是两个 Guard 共享的白名单、没贴装饰器的接口默认「仅需登录」。
  4. 用 Seed 迁移灌角色权限和 admin 账号,理解为什么种子数据要走迁移而不是手写 SQL。
  5. 在 PostController 上看到功能级(@RequirePermission)和数据级(assertAuthor)两层权限怎么配合——create/pin 用功能级、update/delete 用数据级,两层都要。
  6. 看懂权限缓存为什么是生产必备,掌握「登录时写、Guard 读、变更时失效」的完整方案,以及它带来的延迟生效权衡。

常见问题

  • 新加的接口谁都能调? 检查三件事:贴了 @Public → 公开;没贴 @Public 也没贴 @RequirePermission → 登录即可;贴了 @RequirePermission('xxx') → 需要该权限点。默认是「登录即可」,不是公开。
  • ForbiddenException (403) 和 UnauthorizedException (401) 怎么选? 没登录/登录失效用 401;登录了但权限不够用 403。PermissionGuard 两处都抛:req.user 空抛 401(虽然理论上 JwtAuthGuard 已经拦了)、权限不匹配抛 403。前端按状态码分流:401 跳登录页、403 提示「无权限」。
  • Reflector.getAllAndOverride vs get 前者按 [handler, class] 顺序找,handler 覆盖 class,能在 controller 上写默认、方法上覆盖;后者只取一个层级。RBAC 用前者更灵活。
  • 每次请求查权限太慢? 加 Redis 缓存(第九步)。短期方案:把权限塞进 JWT payload,代价是权限改了要等 token 过期才生效,且 token 变大。
  • 管理员改了角色权限,用户权限没变? 因为权限要么走缓存(要主动失效)、要么走 JWT payload(要 token 过期才刷新)。learnhub 现在每请求查库不存在这个问题;加缓存后必须在 RbacService 里清缓存。
  • Guard 顺序怎么调试? 在两个 Guard 的 canActivateconsole.log 看执行顺序。APP_GUARD 注册顺序就是执行顺序;想临时跳过某个 Guard 注释掉那行就行。

下一章讲 Redis 缓存——把这一章提到的权限缓存落地,再讲验证码、阅读量、排行榜这些高频读写场景怎么用 Redis 减压,以及「MySQL 是真相源、Redis 是副本」这条缓存一致性原则。