feat(rbac): 用户-角色分配 + 权限码下发接口

- users: GET/PUT /users/:id/roles(事务化替换 + 过滤无效角色 + 清权限缓存)
- auth: GET /auth/permissions 返回 {super, permissions}
- permissions/seed: 新增 user:assign-role 权限码与资源

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 16:19:11 +08:00
parent 513688a5e6
commit 78e537fa73
6 changed files with 66 additions and 4 deletions

View File

@ -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' },

View File

@ -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);
}
}

View File

@ -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',

View File

@ -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()

View File

@ -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')

View File

@ -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<string[]> {
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<null> {
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<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
const { passwordHash: _passwordHash, ...rest } = row;