feat: add admin backend modules — dashboard, audit-log, admin-users
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:
WangDL 2026-05-21 17:22:48 +08:00
parent 5a7c21dd60
commit b8a1fb0921
16 changed files with 753 additions and 0 deletions

42
CHANGELOG.md Normal file
View 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 路径

View File

@ -732,6 +732,7 @@ model AdminUser {
deletedAt DateTime? deletedAt DateTime?
sessions AdminSession[] sessions AdminSession[]
auditLogs AdminAuditLog[]
@@index([email]) @@index([email])
@@index([status]) @@index([status])
@ -766,6 +767,8 @@ model AdminAuditLog {
userAgent String? @db.VarChar(500) userAgent String? @db.VarChar(500)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
adminUser AdminUser @relation(fields: [adminUserId], references: [id])
@@index([adminUserId]) @@index([adminUserId])
@@index([action]) @@index([action])
@@index([createdAt]) @@index([createdAt])

View File

@ -13,6 +13,9 @@ import { LoggerModule } from './infrastructure/logger/logger.module';
import { SystemModule } from './modules/system/system.module'; import { SystemModule } from './modules/system/system.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { AdminAuthModule } from './modules/admin-auth/admin-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 { UsersModule } from './modules/users/users.module';
import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module'; import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module';
import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module'; import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module';
@ -82,6 +85,9 @@ import appleConfig from './config/apple.config';
SystemModule, SystemModule,
AuthModule, AuthModule,
AdminAuthModule, AdminAuthModule,
AdminDashboardModule,
AdminUsersModule,
AdminAuditLogModule,
UsersModule, UsersModule,
KnowledgeBaseModule, KnowledgeBaseModule,
KnowledgeItemsModule, KnowledgeItemsModule,

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

View 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 {}

View 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(),
};
}
}

View 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;
}

View 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();
}
}

View 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 {}

View 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;
}
}

View 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: '已删除管理员' };
}
}

View 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 {}

View 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,
});
}
}

View 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;
}

View 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;
}

View 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;
}