From b8a1fb092114b4e180e989bdee7fe315099c5680 Mon Sep 17 00:00:00 2001 From: WangDL Date: Thu, 21 May 2026 17:22:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20admin=20backend=20modules=20?= =?UTF-8?q?=E2=80=94=20dashboard,=20audit-log,=20admin-users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 42 ++++ prisma/schema.prisma | 3 + src/app.module.ts | 6 + .../admin-audit-log.controller.ts | 30 +++ .../admin-audit-log/admin-audit-log.module.ts | 11 + .../admin-audit-log.service.ts | 101 ++++++++ .../dto/query-audit-logs.dto.ts | 40 ++++ .../admin-dashboard.controller.ts | 18 ++ .../admin-dashboard/admin-dashboard.module.ts | 11 + .../admin-dashboard.service.ts | 96 ++++++++ .../admin-users/admin-users.controller.ts | 81 +++++++ src/modules/admin-users/admin-users.module.ts | 11 + .../admin-users/admin-users.service.ts | 222 ++++++++++++++++++ .../admin-users/dto/create-admin-user.dto.ts | 24 ++ .../admin-users/dto/query-admin-users.dto.ts | 35 +++ .../admin-users/dto/update-admin-user.dto.ts | 22 ++ 16 files changed, 753 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 src/modules/admin-audit-log/admin-audit-log.controller.ts create mode 100644 src/modules/admin-audit-log/admin-audit-log.module.ts create mode 100644 src/modules/admin-audit-log/admin-audit-log.service.ts create mode 100644 src/modules/admin-audit-log/dto/query-audit-logs.dto.ts create mode 100644 src/modules/admin-dashboard/admin-dashboard.controller.ts create mode 100644 src/modules/admin-dashboard/admin-dashboard.module.ts create mode 100644 src/modules/admin-dashboard/admin-dashboard.service.ts create mode 100644 src/modules/admin-users/admin-users.controller.ts create mode 100644 src/modules/admin-users/admin-users.module.ts create mode 100644 src/modules/admin-users/admin-users.service.ts create mode 100644 src/modules/admin-users/dto/create-admin-user.dto.ts create mode 100644 src/modules/admin-users/dto/query-admin-users.dto.ts create mode 100644 src/modules/admin-users/dto/update-admin-user.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..07635ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## 2026-05-21 — Admin Starter 后台管理 + +### 新增模块 + +#### Admin 认证体系 +- `AdminAuthModule` — 管理员登录/登出/刷新令牌/获取当前用户 +- JWT 双 token 机制,admin 专用 secret,`type: "admin"` 字段区隔 C 端 +- bcryptjs 密码哈希(12 rounds) +- 账号锁定:5 次登录失败 → 锁定 15 分钟 +- 5 个管理员角色层级:SUPER_ADMIN > ADMIN > OPERATIONS > DEVELOPER > READONLY +- `AdminAuthGuard` / `AdminRolesGuard` / `@AdminRoles()` / `@AdminPublic()` 装饰器 +- 审计日志自动记录所有管理员操作 + +#### AdminDashboardModule +- `GET /admin-api/dashboard/stats` — 仪表盘聚合统计 + - 总用户数 / 今日新增 / 今日活跃 + - 知识库总数 / 今日新增 + - AI 调用总数 / 文件总数 / 存储总量 + - 近 30 天日活趋势、AI 调用趋势 + +#### AdminUsersModule +- `GET /admin-api/admin-users` — 管理员列表(分页 + 搜索 + 角色/状态筛选) +- `GET /admin-api/admin-users/:id` — 管理员详情 +- `POST /admin-api/admin-users` — 创建管理员(SUPER_ADMIN) +- `PUT /admin-api/admin-users/:id` — 更新角色/状态/名称(SUPER_ADMIN) +- `DELETE /admin-api/admin-users/:id` — 软删除管理员(SUPER_ADMIN) + +#### AdminAuditLogModule +- `GET /admin-api/audit-logs` — 审计日志列表(分页 + 按用户/操作/日期筛选) +- `GET /admin-api/audit-logs/:id` — 审计日志详情 +- 关联 AdminUser 返回操作者邮箱和名称 + +### Prisma Schema 变更 +- `AdminUser` 新增反向关系 `auditLogs AdminAuditLog[]` +- `AdminAuditLog` 新增关系字段 `adminUser AdminUser` + +### 路由隔离 +- C 端:`/api/*` — JwtAuthGuard +- Admin:`/admin-api/*` — AdminAuthGuard + AdminRolesGuard +- 全局 prefix `api` 排除 admin-api 路径 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7c7f936..50fb85a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -732,6 +732,7 @@ model AdminUser { deletedAt DateTime? sessions AdminSession[] + auditLogs AdminAuditLog[] @@index([email]) @@index([status]) @@ -766,6 +767,8 @@ model AdminAuditLog { userAgent String? @db.VarChar(500) createdAt DateTime @default(now()) + adminUser AdminUser @relation(fields: [adminUserId], references: [id]) + @@index([adminUserId]) @@index([action]) @@index([createdAt]) diff --git a/src/app.module.ts b/src/app.module.ts index 5680235..8aba25e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,9 @@ import { LoggerModule } from './infrastructure/logger/logger.module'; import { SystemModule } from './modules/system/system.module'; import { AuthModule } from './modules/auth/auth.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; +import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; +import { AdminUsersModule } from './modules/admin-users/admin-users.module'; +import { AdminAuditLogModule } from './modules/admin-audit-log/admin-audit-log.module'; import { UsersModule } from './modules/users/users.module'; import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module'; import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module'; @@ -82,6 +85,9 @@ import appleConfig from './config/apple.config'; SystemModule, AuthModule, AdminAuthModule, + AdminDashboardModule, + AdminUsersModule, + AdminAuditLogModule, UsersModule, KnowledgeBaseModule, KnowledgeItemsModule, diff --git a/src/modules/admin-audit-log/admin-audit-log.controller.ts b/src/modules/admin-audit-log/admin-audit-log.controller.ts new file mode 100644 index 0000000..60dfa1e --- /dev/null +++ b/src/modules/admin-audit-log/admin-audit-log.controller.ts @@ -0,0 +1,30 @@ +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { AdminAuditLogService } from './admin-audit-log.service'; +import { QueryAuditLogsDto } from './dto/query-audit-logs.dto'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import { AdminRole } from '../../common/types/admin-role.enum'; + +@ApiTags('admin-audit-log') +@Controller('admin-api/audit-logs') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +@AdminRoles(AdminRole.ADMIN) +export class AdminAuditLogController { + constructor(private readonly auditLogService: AdminAuditLogService) {} + + @Get() + @ApiBearerAuth() + @ApiOperation({ summary: '获取审计日志列表' }) + async list(@Query() query: QueryAuditLogsDto) { + return this.auditLogService.list(query); + } + + @Get(':id') + @ApiBearerAuth() + @ApiOperation({ summary: '获取审计日志详情' }) + async getById(@Param('id') id: string) { + return this.auditLogService.getById(id); + } +} diff --git a/src/modules/admin-audit-log/admin-audit-log.module.ts b/src/modules/admin-audit-log/admin-audit-log.module.ts new file mode 100644 index 0000000..1aef88d --- /dev/null +++ b/src/modules/admin-audit-log/admin-audit-log.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminAuditLogController } from './admin-audit-log.controller'; +import { AdminAuditLogService } from './admin-audit-log.service'; +import { AdminAuthModule } from '../admin-auth/admin-auth.module'; + +@Module({ + imports: [AdminAuthModule], + controllers: [AdminAuditLogController], + providers: [AdminAuditLogService], +}) +export class AdminAuditLogModule {} diff --git a/src/modules/admin-audit-log/admin-audit-log.service.ts b/src/modules/admin-audit-log/admin-audit-log.service.ts new file mode 100644 index 0000000..44b8ee0 --- /dev/null +++ b/src/modules/admin-audit-log/admin-audit-log.service.ts @@ -0,0 +1,101 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { QueryAuditLogsDto } from './dto/query-audit-logs.dto'; + +@Injectable() +export class AdminAuditLogService { + constructor(private readonly prisma: PrismaService) {} + + async list(query: QueryAuditLogsDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const where: any = {}; + + if (query.adminUserId) { + where.adminUserId = query.adminUserId; + } + if (query.action) { + where.action = query.action; + } + if (query.startDate || query.endDate) { + where.createdAt = {}; + if (query.startDate) { + where.createdAt.gte = new Date(query.startDate); + } + if (query.endDate) { + where.createdAt.lte = new Date(query.endDate + 'T23:59:59.999Z'); + } + } + + const [items, total] = await Promise.all([ + this.prisma.adminAuditLog.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + adminUser: { + select: { + email: true, + displayName: true, + }, + }, + }, + }), + this.prisma.adminAuditLog.count({ where }), + ]); + + return { + items: items.map((item) => ({ + id: item.id, + adminUserId: item.adminUserId, + adminUserEmail: item.adminUser.email, + adminUserDisplayName: item.adminUser.displayName, + action: item.action, + resourceType: item.resourceType, + resourceId: item.resourceId, + beforeJson: item.beforeJson, + afterJson: item.afterJson, + ip: item.ip, + userAgent: item.userAgent, + createdAt: item.createdAt.toISOString(), + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getById(id: string) { + const log = await this.prisma.adminAuditLog.findUnique({ + where: { id }, + include: { + adminUser: { + select: { + email: true, + displayName: true, + }, + }, + }, + }); + if (!log) throw new NotFoundException('审计日志不存在'); + + return { + id: log.id, + adminUserId: log.adminUserId, + adminUserEmail: log.adminUser.email, + adminUserDisplayName: log.adminUser.displayName, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceId, + beforeJson: log.beforeJson, + afterJson: log.afterJson, + ip: log.ip, + userAgent: log.userAgent, + createdAt: log.createdAt.toISOString(), + }; + } +} diff --git a/src/modules/admin-audit-log/dto/query-audit-logs.dto.ts b/src/modules/admin-audit-log/dto/query-audit-logs.dto.ts new file mode 100644 index 0000000..df32090 --- /dev/null +++ b/src/modules/admin-audit-log/dto/query-audit-logs.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryAuditLogsDto { + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + adminUserId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + action?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + endDate?: string; +} diff --git a/src/modules/admin-dashboard/admin-dashboard.controller.ts b/src/modules/admin-dashboard/admin-dashboard.controller.ts new file mode 100644 index 0000000..3baa0e2 --- /dev/null +++ b/src/modules/admin-dashboard/admin-dashboard.controller.ts @@ -0,0 +1,18 @@ +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AdminDashboardService } from './admin-dashboard.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; + +@ApiTags('admin-dashboard') +@Controller('admin-api/dashboard') +@UseGuards(AdminAuthGuard) +export class AdminDashboardController { + constructor(private readonly adminDashboardService: AdminDashboardService) {} + + @Get('stats') + @ApiBearerAuth() + @ApiOperation({ summary: '获取仪表盘统计数据' }) + async getStats() { + return this.adminDashboardService.getStats(); + } +} diff --git a/src/modules/admin-dashboard/admin-dashboard.module.ts b/src/modules/admin-dashboard/admin-dashboard.module.ts new file mode 100644 index 0000000..8b780cc --- /dev/null +++ b/src/modules/admin-dashboard/admin-dashboard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminDashboardController } from './admin-dashboard.controller'; +import { AdminDashboardService } from './admin-dashboard.service'; +import { AdminAuthModule } from '../admin-auth/admin-auth.module'; + +@Module({ + imports: [AdminAuthModule], + controllers: [AdminDashboardController], + providers: [AdminDashboardService], +}) +export class AdminDashboardModule {} diff --git a/src/modules/admin-dashboard/admin-dashboard.service.ts b/src/modules/admin-dashboard/admin-dashboard.service.ts new file mode 100644 index 0000000..79600c2 --- /dev/null +++ b/src/modules/admin-dashboard/admin-dashboard.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class AdminDashboardService { + constructor(private readonly prisma: PrismaService) {} + + async getStats() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const [ + totalUsers, + newUsersToday, + activeUsersToday, + totalKnowledgeBases, + newKbsToday, + totalAiCallsToday, + totalFiles, + storageAgg, + ] = await Promise.all([ + this.prisma.user.count({ where: { deletedAt: null } }), + this.prisma.user.count({ + where: { createdAt: { gte: today, lt: tomorrow }, deletedAt: null }, + }), + this.prisma.dailyLearningActivity.count({ + where: { activityDate: { gte: today, lt: tomorrow } }, + }), + this.prisma.knowledgeBase.count({ where: { deletedAt: null } }), + this.prisma.knowledgeBase.count({ + where: { createdAt: { gte: today, lt: tomorrow }, deletedAt: null }, + }), + this.prisma.aiUsageLog.count({ + where: { createdAt: { gte: today, lt: tomorrow } }, + }), + this.prisma.uploadedFile.count(), + this.prisma.uploadedFile.aggregate({ + _sum: { sizeBytes: true }, + }), + ]); + + const userTrend = await this.getUserTrend(30); + const aiCallTrend = await this.getAiCallTrend(30); + + return { + totalUsers, + newUsersToday, + activeUsersToday, + totalKnowledgeBases, + newKbsToday, + totalAiCallsToday, + totalFiles, + totalStorageBytes: Number(storageAgg._sum.sizeBytes ?? 0), + userTrend, + aiCallTrend, + }; + } + + private async getUserTrend(days: number) { + const values: { date: string; value: number }[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const start = new Date(d); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + + const count = await this.prisma.dailyLearningActivity.count({ + where: { activityDate: { gte: start, lt: end } }, + }); + values.push({ date: start.toISOString().split('T')[0], value: count }); + } + return values; + } + + private async getAiCallTrend(days: number) { + const values: { date: string; value: number }[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const start = new Date(d); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + + const count = await this.prisma.aiUsageLog.count({ + where: { createdAt: { gte: start, lt: end } }, + }); + values.push({ date: start.toISOString().split('T')[0], value: count }); + } + return values; + } +} diff --git a/src/modules/admin-users/admin-users.controller.ts b/src/modules/admin-users/admin-users.controller.ts new file mode 100644 index 0000000..27b73d8 --- /dev/null +++ b/src/modules/admin-users/admin-users.controller.ts @@ -0,0 +1,81 @@ +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Req, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AdminUsersService } from './admin-users.service'; +import { CreateAdminUserDto } from './dto/create-admin-user.dto'; +import { UpdateAdminUserDto } from './dto/update-admin-user.dto'; +import { QueryAdminUsersDto } from './dto/query-admin-users.dto'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import { AdminRole } from '../../common/types/admin-role.enum'; +import type { Request } from 'express'; + +@ApiTags('admin-users') +@Controller('admin-api/admin-users') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +export class AdminUsersController { + constructor(private readonly adminUsersService: AdminUsersService) {} + + @Get() + @AdminRoles(AdminRole.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: '获取管理员列表' }) + async list(@Query() query: QueryAdminUsersDto) { + return this.adminUsersService.list(query); + } + + @Get(':id') + @AdminRoles(AdminRole.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: '获取管理员详情' }) + async getById(@Param('id') id: string) { + return this.adminUsersService.getById(id); + } + + @Post() + @AdminRoles(AdminRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: '创建管理员' }) + async create(@Body() dto: CreateAdminUserDto, @Req() req: Request) { + const adminUser = (req as any).adminUser; + return this.adminUsersService.create(dto, adminUser.id, req.ip, req.headers['user-agent']); + } + + @Put(':id') + @AdminRoles(AdminRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: '更新管理员' }) + async update( + @Param('id') id: string, + @Body() dto: UpdateAdminUserDto, + @Req() req: Request, + ) { + const adminUser = (req as any).adminUser; + return this.adminUsersService.update(id, dto, adminUser.id, req.ip, req.headers['user-agent']); + } + + @Delete(':id') + @AdminRoles(AdminRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: '删除管理员' }) + async delete(@Param('id') id: string, @Req() req: Request) { + const adminUser = (req as any).adminUser; + await this.adminUsersService.delete(id, adminUser.id, req.ip, req.headers['user-agent']); + return { success: true, message: '已删除管理员' }; + } +} diff --git a/src/modules/admin-users/admin-users.module.ts b/src/modules/admin-users/admin-users.module.ts new file mode 100644 index 0000000..fd54d0d --- /dev/null +++ b/src/modules/admin-users/admin-users.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersService } from './admin-users.service'; +import { AdminAuthModule } from '../admin-auth/admin-auth.module'; + +@Module({ + imports: [AdminAuthModule], + controllers: [AdminUsersController], + providers: [AdminUsersService], +}) +export class AdminUsersModule {} diff --git a/src/modules/admin-users/admin-users.service.ts b/src/modules/admin-users/admin-users.service.ts new file mode 100644 index 0000000..234c2c6 --- /dev/null +++ b/src/modules/admin-users/admin-users.service.ts @@ -0,0 +1,222 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { PasswordService } from '../../common/utils/password.service'; +import { AdminAuditService } from '../admin-auth/admin-audit.service'; +import { CreateAdminUserDto } from './dto/create-admin-user.dto'; +import { UpdateAdminUserDto } from './dto/update-admin-user.dto'; +import { QueryAdminUsersDto } from './dto/query-admin-users.dto'; +import { AdminRole } from '../../common/types/admin-role.enum'; + +@Injectable() +export class AdminUsersService { + constructor( + private readonly prisma: PrismaService, + private readonly passwordService: PasswordService, + private readonly auditService: AdminAuditService, + ) {} + + async list(query: QueryAdminUsersDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const where: any = { deletedAt: null }; + + if (query.search) { + where.OR = [ + { email: { contains: query.search } }, + { displayName: { contains: query.search } }, + ]; + } + if (query.role) { + where.role = query.role; + } + if (query.status) { + where.status = query.status; + } + + const [items, total] = await Promise.all([ + this.prisma.adminUser.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + email: true, + displayName: true, + role: true, + status: true, + twoFactorEnabled: true, + lastLoginAt: true, + lastLoginIp: true, + createdAt: true, + }, + }), + this.prisma.adminUser.count({ where }), + ]); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getById(id: string) { + const admin = await this.prisma.adminUser.findFirst({ + where: { id, deletedAt: null }, + select: { + id: true, + email: true, + displayName: true, + role: true, + status: true, + twoFactorEnabled: true, + lastLoginAt: true, + lastLoginIp: true, + createdAt: true, + }, + }); + if (!admin) throw new NotFoundException('管理员不存在'); + return admin; + } + + async create( + dto: CreateAdminUserDto, + operatorId: string, + ip?: string, + userAgent?: string, + ) { + const existing = await this.prisma.adminUser.findUnique({ + where: { email: dto.email }, + }); + if (existing) throw new ConflictException('邮箱已被使用'); + + const passwordHash = await this.passwordService.hash(dto.password); + const admin = await this.prisma.adminUser.create({ + data: { + email: dto.email, + passwordHash, + displayName: dto.displayName, + role: dto.role, + }, + select: { + id: true, + email: true, + displayName: true, + role: true, + status: true, + twoFactorEnabled: true, + lastLoginAt: true, + lastLoginIp: true, + createdAt: true, + }, + }); + + await this.auditService.log({ + adminUserId: operatorId, + action: 'CREATE_ADMIN', + resourceType: 'AdminUser', + resourceId: admin.id, + afterJson: { email: dto.email, role: dto.role }, + ip, + userAgent, + }); + + return admin; + } + + async update( + id: string, + dto: UpdateAdminUserDto, + operatorId: string, + ip?: string, + userAgent?: string, + ) { + const admin = await this.prisma.adminUser.findFirst({ + where: { id, deletedAt: null }, + }); + if (!admin) throw new NotFoundException('管理员不存在'); + + if (dto.role && !Object.values(AdminRole).includes(dto.role as AdminRole)) { + throw new BadRequestException('无效的角色'); + } + if (dto.status && !['ACTIVE', 'DISABLED'].includes(dto.status)) { + throw new BadRequestException('无效的状态'); + } + + const beforeJson = { role: admin.role, status: admin.status }; + + const updated = await this.prisma.adminUser.update({ + where: { id }, + data: { + ...(dto.role && { role: dto.role }), + ...(dto.status && { status: dto.status }), + ...(dto.displayName && { displayName: dto.displayName }), + }, + select: { + id: true, + email: true, + displayName: true, + role: true, + status: true, + twoFactorEnabled: true, + lastLoginAt: true, + lastLoginIp: true, + createdAt: true, + }, + }); + + await this.auditService.log({ + adminUserId: operatorId, + action: 'UPDATE_ADMIN', + resourceType: 'AdminUser', + resourceId: id, + beforeJson, + afterJson: { role: updated.role, status: updated.status }, + ip, + userAgent, + }); + + return updated; + } + + async delete( + id: string, + operatorId: string, + ip?: string, + userAgent?: string, + ) { + const admin = await this.prisma.adminUser.findFirst({ + where: { id, deletedAt: null }, + }); + if (!admin) throw new NotFoundException('管理员不存在'); + + if (admin.role === 'SUPER_ADMIN') { + throw new BadRequestException('不可删除超级管理员'); + } + + await this.prisma.adminUser.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await this.auditService.log({ + adminUserId: operatorId, + action: 'DELETE_ADMIN', + resourceType: 'AdminUser', + resourceId: id, + beforeJson: { email: admin.email, role: admin.role }, + ip, + userAgent, + }); + } +} diff --git a/src/modules/admin-users/dto/create-admin-user.dto.ts b/src/modules/admin-users/dto/create-admin-user.dto.ts new file mode 100644 index 0000000..128a1c9 --- /dev/null +++ b/src/modules/admin-users/dto/create-admin-user.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, MaxLength, IsIn } from 'class-validator'; + +export class CreateAdminUserDto { + @ApiProperty({ example: 'admin@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'Admin@123' }) + @IsString() + @MinLength(8) + password: string; + + @ApiProperty({ example: '张管理' }) + @IsString() + @MinLength(1) + @MaxLength(100) + displayName: string; + + @ApiProperty({ example: 'ADMIN', enum: ['SUPER_ADMIN', 'ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY'] }) + @IsString() + @IsIn(['SUPER_ADMIN', 'ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY']) + role: string; +} diff --git a/src/modules/admin-users/dto/query-admin-users.dto.ts b/src/modules/admin-users/dto/query-admin-users.dto.ts new file mode 100644 index 0000000..000fc11 --- /dev/null +++ b/src/modules/admin-users/dto/query-admin-users.dto.ts @@ -0,0 +1,35 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryAdminUsersDto { + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: ['SUPER_ADMIN', 'ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY'] }) + @IsOptional() + @IsString() + role?: string; + + @ApiPropertyOptional({ enum: ['ACTIVE', 'DISABLED'] }) + @IsOptional() + @IsString() + status?: string; +} diff --git a/src/modules/admin-users/dto/update-admin-user.dto.ts b/src/modules/admin-users/dto/update-admin-user.dto.ts new file mode 100644 index 0000000..ff869d1 --- /dev/null +++ b/src/modules/admin-users/dto/update-admin-user.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsIn, MaxLength } from 'class-validator'; + +export class UpdateAdminUserDto { + @ApiPropertyOptional({ example: 'ADMIN', enum: ['SUPER_ADMIN', 'ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY'] }) + @IsOptional() + @IsString() + @IsIn(['SUPER_ADMIN', 'ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY']) + role?: string; + + @ApiPropertyOptional({ example: 'ACTIVE', enum: ['ACTIVE', 'DISABLED'] }) + @IsOptional() + @IsString() + @IsIn(['ACTIVE', 'DISABLED']) + status?: string; + + @ApiPropertyOptional({ example: '新名字' }) + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; +}