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