From 78e537fa7307d650be27f2f27b8ecf409663ae54 Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 16:19:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20=E7=94=A8=E6=88=B7-=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=88=86=E9=85=8D=20+=20=E6=9D=83=E9=99=90=E7=A0=81?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users: GET/PUT /users/:id/roles(事务化替换 + 过滤无效角色 + 清权限缓存) - auth: GET /auth/permissions 返回 {super, permissions} - permissions/seed: 新增 user:assign-role 权限码与资源 Co-Authored-By: Claude Opus 4.8 --- prisma/seed.ts | 1 + src/auth/auth.controller.ts | 8 ++++++++ src/common/permissions.ts | 1 + src/users/dto.ts | 9 ++++++++- src/users/users.controller.ts | 17 +++++++++++++++-- src/users/users.service.ts | 34 +++++++++++++++++++++++++++++++++- 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 0eaf9eb..9d81434 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -38,6 +38,7 @@ const APIS: { code: string; name: string; method: string; path: string; parent: { code: Permissions.user.update, name: '更新用户', method: 'PATCH', path: '/api/v1/users/:id', parent: 'user' }, { code: Permissions.user.delete, name: '删除用户', method: 'DELETE', path: '/api/v1/users/:id', parent: 'user' }, { code: Permissions.user.resetPwd, name: '重置密码', method: 'PATCH', path: '/api/v1/users/:id/password', parent: 'user' }, + { code: Permissions.user.assignRole, name: '分配角色', method: 'PUT', path: '/api/v1/users/:id/roles', parent: 'user' }, { code: Permissions.role.list, name: '角色列表', method: 'GET', path: '/api/v1/roles', parent: 'role' }, { code: Permissions.role.create, name: '创建角色', method: 'POST', path: '/api/v1/roles', parent: 'role' }, { code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' }, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 255fe4f..f0f5804 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -34,4 +34,12 @@ export class AuthController { if (!ctx) throw new UnauthorizedException('未登录'); return this.auth.getMenus(ctx.tenantCode, ctx.userId); } + + /** 当前用户权限码(含超管标志),供前端按钮/路由级权限控制(仅需登录)。 */ + @Get('permissions') + permissions() { + const ctx = getRequestContext(); + if (!ctx) throw new UnauthorizedException('未登录'); + return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId); + } } diff --git a/src/common/permissions.ts b/src/common/permissions.ts index 1540cd1..a0680f9 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -15,6 +15,7 @@ export const Permissions = { update: 'user:update', delete: 'user:delete', resetPwd: 'user:reset-pwd', + assignRole: 'user:assign-role', }, role: { list: 'role:list', diff --git a/src/users/dto.ts b/src/users/dto.ts index faa00fc..7025f8b 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { PageQuery } from '../common/page.dto.js'; import { UserStatus } from '../common/enums.js'; @@ -79,6 +79,13 @@ export class ResetPasswordDto { password!: string; } +export class AssignRolesDto { + @ApiProperty({ description: '角色 code 列表', type: [String] }) + @IsArray() + @IsString({ each: true }) + roleCodes!: string[]; +} + export class ListUserQuery extends PageQuery { @ApiPropertyOptional({ description: '用户名(模糊)' }) @IsOptional() diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 635896d..d4dac8a 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; -import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; +import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; import { UsersService } from './users.service.js'; @Controller('users') @@ -41,6 +41,19 @@ export class UsersController { return this.service.resetPassword(id, dto); } + @Get(':id/roles') + @RequirePermissions(Permissions.user.list) + roles(@Param('id') id: string) { + return this.service.assignedRoles(id); + } + + @Put(':id/roles') + @RequirePermissions(Permissions.user.assignRole) + @AuditLog('user', 'assign-role') + assignRoles(@Param('id') id: string, @Body() dto: AssignRolesDto) { + return this.service.assignRoles(id, dto); + } + @Delete(':id') @RequirePermissions(Permissions.user.delete) @AuditLog('user', 'delete') diff --git a/src/users/users.service.ts b/src/users/users.service.ts index f265e37..f7c3c12 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -5,7 +5,7 @@ import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../common/redis/redis.service.js'; import { getRequestContext } from '../common/context/request-context.js'; import { permCacheKey, userStateCacheKey } from '../common/constants.js'; -import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; +import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; @Injectable() export class UsersService extends BaseCrudService { @@ -91,6 +91,38 @@ export class UsersService extends BaseCrudService { await this.redis.del(userStateCacheKey(ctx.tenantCode, userId)); } + /** 用户已分配的角色 code 列表。 */ + async assignedRoles(userId: string): Promise { + const ctx = getRequestContext()!; + const rows = await this.prisma.client.userRole.findMany({ + where: { tenantCode: ctx.tenantCode, userId }, + select: { roleCode: true }, + }); + return rows.map((r) => r.roleCode); + } + + /** 分配角色:事务化替换,过滤不存在的角色,并清该用户权限缓存。 */ + async assignRoles(userId: string, dto: AssignRolesDto): Promise { + const ctx = getRequestContext()!; + const roles = dto.roleCodes.length + ? await this.prisma.client.role.findMany({ + where: { tenantCode: ctx.tenantCode, code: { in: dto.roleCodes }, deletedAt: null }, + select: { code: true }, + }) + : []; + const validCodes = roles.map((r) => r.code); + await this.prisma.client.$transaction(async (tx) => { + await tx.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, userId } }); + if (validCodes.length > 0) { + await tx.userRole.createMany({ + data: validCodes.map((roleCode) => ({ tenantCode: ctx.tenantCode, userId, roleCode })), + }); + } + }); + await this.redis.del(permCacheKey(ctx.tenantCode, userId)); + return null; + } + /** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */ private omitPasswordHash(row: T): Omit { const { passwordHash: _passwordHash, ...rest } = row;