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?
|
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])
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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