feat: add admin backend modules — dashboard, audit-log, admin-users
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 10s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 10s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5a7c21dd60
commit
b8a1fb0921
42
CHANGELOG.md
Normal file
42
CHANGELOG.md
Normal file
@ -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 路径
|
||||
@ -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])
|
||||
|
||||
@ -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,
|
||||
|
||||
30
src/modules/admin-audit-log/admin-audit-log.controller.ts
Normal file
30
src/modules/admin-audit-log/admin-audit-log.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
11
src/modules/admin-audit-log/admin-audit-log.module.ts
Normal file
11
src/modules/admin-audit-log/admin-audit-log.module.ts
Normal file
@ -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 {}
|
||||
101
src/modules/admin-audit-log/admin-audit-log.service.ts
Normal file
101
src/modules/admin-audit-log/admin-audit-log.service.ts
Normal file
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
40
src/modules/admin-audit-log/dto/query-audit-logs.dto.ts
Normal file
40
src/modules/admin-audit-log/dto/query-audit-logs.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
18
src/modules/admin-dashboard/admin-dashboard.controller.ts
Normal file
18
src/modules/admin-dashboard/admin-dashboard.controller.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
11
src/modules/admin-dashboard/admin-dashboard.module.ts
Normal file
11
src/modules/admin-dashboard/admin-dashboard.module.ts
Normal file
@ -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 {}
|
||||
96
src/modules/admin-dashboard/admin-dashboard.service.ts
Normal file
96
src/modules/admin-dashboard/admin-dashboard.service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
81
src/modules/admin-users/admin-users.controller.ts
Normal file
81
src/modules/admin-users/admin-users.controller.ts
Normal file
@ -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: '已删除管理员' };
|
||||
}
|
||||
}
|
||||
11
src/modules/admin-users/admin-users.module.ts
Normal file
11
src/modules/admin-users/admin-users.module.ts
Normal file
@ -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 {}
|
||||
222
src/modules/admin-users/admin-users.service.ts
Normal file
222
src/modules/admin-users/admin-users.service.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/modules/admin-users/dto/create-admin-user.dto.ts
Normal file
24
src/modules/admin-users/dto/create-admin-user.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
35
src/modules/admin-users/dto/query-admin-users.dto.ts
Normal file
35
src/modules/admin-users/dto/query-admin-users.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
22
src/modules/admin-users/dto/update-admin-user.dto.ts
Normal file
22
src/modules/admin-users/dto/update-admin-user.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user