记一次数据权限改造,看 NestJS 装饰器与守卫的组合应用

在 NestJS 项目里,装饰器和守卫是非常常见的能力。

装饰器负责"声明意图",守卫负责"执行判断"。这次涉及的是后台管理的数据权限改造,正好是一个比较典型的实践场景:我们希望不同角色访问客户、订单、购物车等数据时,只能看到自己负责或自己部门范围内的数据。

如果把数据权限判断直接写在每个 Service 里,一是会生成很多重复代码,二是手写的查询条件散落在代码各处,难以维护。也不应该落到Service中再处理:

ts 复制代码
where: {
  status: 1,
  followId: currentUser.id,
}

当业务规则变成"跟进人或销售任一匹配即可"时,又会继续扩散成更多手写条件。

为了解决这个问题,可以把权限规则前置到 Controller,通过装饰器声明,再由 Guard 统一生成查询条件。

1. 用装饰器声明接口需要的数据权限

首先定义一个装饰器:

ts 复制代码
export const DataScopeAny = (fields: string[]) =>
  SetMetadata(DATA_SCOPE_KEY, {
    fields,
    mode: DataScopeMatchMode.ANY,
  });

它的含义是:当前接口的数据权限需要匹配多个字段中的任意一个。

比如订单接口:

ts 复制代码
@Get('list')
@DataScopeAny(['followId', 'salesId'])
async getOrderList(
  @Query() query: SelectOrderListDto,
  @DataScopeWhere() dataScopeWhere: DataScopeWhereType,
) {
  return this.orderService.getOrderList(query, dataScopeWhere);
}

业务含义:

当前用户只要是订单的跟进人,或者是订单所属销售,就可以访问这条订单数据。

这样 Controller 不需要关心具体 SQL,也不需要关心用户是全部权限、部门权限还是本人权限,只负责声明当前接口的数据归属字段。

2. Guard 读取元数据并生成查询条件

装饰器只是把规则写入元数据,真正执行逻辑的是 Guard。

ts 复制代码
const metadata = this.reflector.get<DataScopeMetadata>(
  DATA_SCOPE_KEY,
  context.getHandler(),
);

Guard 通过 Reflector 读取接口上的数据权限配置,然后结合 当前登录用户的信息,生成查询条件。再 Guard 中判读用户所拥有的权限,并拼接成后续使用到的查询条件。

这里有一个关键点:【为什么分写了两个装饰】。其实之前的处理中,已经存在一个 DataScope 装饰器,但是:

  • DataScope 返回的是对象类型。TypeORM 解析的时候会转成 AND 条件,不满足现在的需求。
  • DataScopeAny 返回数组,利用 TypeORM 的数组 where 表达 OR 条件。

例如:

ts 复制代码
@DataScopeAny(['followId', 'salesId'])

对于本人权限用户,会生成:

ts 复制代码
[
  { followId: userId },
  { salesId: userId },
]

在 TypeORM 中,这会被解释为:

sql 复制代码
WHERE followId = :userId OR salesId = :userId

2. 用参数装饰器把权限条件传给业务层

除了方法装饰器,还需要一个参数装饰器从请求上下文中取出 Guard 生成的权限条件:

ts 复制代码
export const DataScopeWhere = createParamDecorator(
  (_data, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.dataScopeWhere;
  },
);

这样可以将最终需要的数据权限条件传到业务方法中:

ts 复制代码
async list(
  @Query() params: ListDto,
  @DataScopeWhere() dataScopeWhere: DataScopeWhereType,
) {
  return this.clientService.getList(params, dataScopeWhere);
}

这比在每个接口里手动读取 request.user 更干净,也让 Controller 的职责更单一。

4. Service 只负责合并业务条件和权限条件

业务层不再关心权限规则从哪里来,只需要把基础业务条件和数据权限条件合并:

ts 复制代码
where: mergeDataScopeWhere(
  { id: body.id },
  dataScopeWhere,
)

合并工具的作用也很明确:

ts 复制代码
export function mergeDataScopeWhere(baseWhere, dataScopeWhere) {
  if (Array.isArray(dataScopeWhere)) {
    return dataScopeWhere.map(item => ({ ...baseWhere, ...item }));
  }

  return { ...baseWhere, ...dataScopeWhere };
}

当权限条件是 OR 模式时:

ts 复制代码
mergeDataScopeWhere(
  { status: 1 },
  [{ followId: 1 }, { salesId: 1 }],
)

会得到:

ts 复制代码
[
  { status: 1, followId: 1 },
  { status: 1, salesId: 1 },
]

对应 SQL 语义就是:

sql 复制代码
(status = 1 AND followId = 1)
OR
(status = 1 AND salesId = 1)

这正好符合业务预期:业务条件始终生效,权限字段之间按 OR 匹配。

5. 这次实践带来的收益

这次改造的核心不是"写了一个装饰器",而是把数据权限拆成了三个清晰层次:

  • Controller:声明当前接口按哪些字段做数据权限。
  • Guard:根据用户角色和数据范围生成权限条件。
  • Service:合并权限条件和业务条件,执行查询或更新。

这样做之后,权限逻辑不会散落在各个模块里。后续新增接口时,只要加上类似下面的声明即可:

ts 复制代码
@DataScopeAny(['follow', 'salesId'])

同时,@Permissions() 这类接口权限装饰器也可以和数据权限装饰器组合使用:

ts 复制代码
@DataScopeAny(['followId', 'salesId'])
@Permissions('功能权限')

一个控制"能不能访问接口",一个控制"能访问哪些数据",职责边界比较清楚。

总结

装饰器用来表达声明式规则,Guard 则承载认证、鉴权、数据权限这类横切逻辑。

总结一下定义和使用的规范:

用装饰器声明规则,用守卫解释规则,再把结果交给业务层使用。

一是延续了 NestJS 的框架风格,也避免了权限判断和业务代码强耦合。对于后台管理系统中常见的角色权限、部门权限、本人数据权限等场景,这是一种比较清晰、可维护的实现方式。

相关推荐
摇滚侠1 小时前
SpringBoot 官网 阅读笔记 启用生产就绪功能 端点
spring boot·笔记·后端
我命由我123452 小时前
匈牙利命名法
java·服务器·后端·学习·java-ee·kotlin·学习方法
苏三说技术2 小时前
一线大厂的Git规范
后端
神奇小汤圆2 小时前
阿里面试官问我:“Redis 的 String 底层是怎么设计的?”,我画完 SDS,他点了点头……
后端
Cicada1283 小时前
ccvt:一个用 Rust 写的中国地图坐标系互转命令行工具
开发语言·后端·rust
明月_清风3 小时前
🤗 Hugging Face 模型上传完全指南:从本地到 Hub 的 4 种姿势
前端·后端·ai编程
神奇小汤圆4 小时前
疯了!Fastjson 又爆出严重安全漏洞,Spring Boot 项目赶紧自查
后端
自进化Agent智能体4 小时前
Hermes CLI 界面详解——命令行交互全攻略
后端