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:
parent
513688a5e6
commit
78e537fa73
@ -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.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.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.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.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.create, name: '创建角色', method: 'POST', path: '/api/v1/roles', parent: 'role' },
|
||||||
{ code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' },
|
{ code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' },
|
||||||
|
|||||||
@ -34,4 +34,12 @@ export class AuthController {
|
|||||||
if (!ctx) throw new UnauthorizedException('未登录');
|
if (!ctx) throw new UnauthorizedException('未登录');
|
||||||
return this.auth.getMenus(ctx.tenantCode, ctx.userId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const Permissions = {
|
|||||||
update: 'user:update',
|
update: 'user:update',
|
||||||
delete: 'user:delete',
|
delete: 'user:delete',
|
||||||
resetPwd: 'user:reset-pwd',
|
resetPwd: 'user:reset-pwd',
|
||||||
|
assignRole: 'user:assign-role',
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
list: 'role:list',
|
list: 'role:list',
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 { PageQuery } from '../common/page.dto.js';
|
||||||
import { UserStatus } from '../common/enums.js';
|
import { UserStatus } from '../common/enums.js';
|
||||||
|
|
||||||
@ -79,6 +79,13 @@ export class ResetPasswordDto {
|
|||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AssignRolesDto {
|
||||||
|
@ApiProperty({ description: '角色 code 列表', type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
roleCodes!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class ListUserQuery extends PageQuery {
|
export class ListUserQuery extends PageQuery {
|
||||||
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -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 { Permissions } from '../common/permissions.js';
|
||||||
import { AuditLog, RequirePermissions } from '../auth/decorators.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';
|
import { UsersService } from './users.service.js';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@ -41,6 +41,19 @@ export class UsersController {
|
|||||||
return this.service.resetPassword(id, dto);
|
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')
|
@Delete(':id')
|
||||||
@RequirePermissions(Permissions.user.delete)
|
@RequirePermissions(Permissions.user.delete)
|
||||||
@AuditLog('user', 'delete')
|
@AuditLog('user', 'delete')
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { PrismaService } from '../prisma/prisma.service.js';
|
|||||||
import { RedisService } from '../common/redis/redis.service.js';
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.js';
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
import { permCacheKey, userStateCacheKey } from '../common/constants.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()
|
@Injectable()
|
||||||
export class UsersService extends BaseCrudService {
|
export class UsersService extends BaseCrudService {
|
||||||
@ -91,6 +91,38 @@ export class UsersService extends BaseCrudService {
|
|||||||
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
|
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,避免泄漏(登录内部校验直接查库,不受影响)。 */
|
/** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */
|
||||||
private omitPasswordHash<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
|
private omitPasswordHash<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
|
||||||
const { passwordHash: _passwordHash, ...rest } = row;
|
const { passwordHash: _passwordHash, ...rest } = row;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user