diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d165cb3..00c0272 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -834,6 +834,18 @@ model BackupJob { createdAt DateTime @default(now()) } +model CleanupJob { + id String @id @default(cuid()) + type String @db.VarChar(32) + status String @default("RUNNING") @db.VarChar(16) + target String? @db.VarChar(255) + rowsAffected Int @default(0) + startedAt DateTime @default(now()) + completedAt DateTime? + errorMessage String? @db.Text + createdAt DateTime @default(now()) +} + model AdminUser { id String @id @default(cuid()) email String @unique @db.VarChar(255) diff --git a/src/app.module.ts b/src/app.module.ts index 89187a0..1965dda 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -52,6 +52,7 @@ import { RagChatModule } from './modules/rag-chat/rag-chat.module'; import { VectorModule } from './modules/vector/vector.module'; import { CacheModule } from './common/cache/cache.module'; import { AdminCacheModule } from './modules/admin-cache/admin-cache.module'; +import { BackupModule } from './modules/backup/backup.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; @@ -149,6 +150,7 @@ import appleConfig from './config/apple.config'; WorkspaceModule, CacheModule, AdminCacheModule, + BackupModule, ], providers: [ { provide: APP_GUARD, useClass: RateLimitGuard }, diff --git a/src/modules/backup/backup.controller.ts b/src/modules/backup/backup.controller.ts new file mode 100644 index 0000000..261c5e9 --- /dev/null +++ b/src/modules/backup/backup.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { BackupService } from './backup.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; + +@ApiTags('admin-backup') +@ApiBearerAuth() +@Controller('admin-api/backup') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +export class BackupController { + constructor(private readonly backupService: BackupService) {} + + @Get('jobs') + @ApiOperation({ summary: '备份任务列表' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + async listBackups(@Query('page') page?: string, @Query('limit') limit?: string) { + return this.backupService.getBackupJobs(Number(page) || 1, Number(limit) || 20); + } + + @Post('trigger/:type') + @ApiOperation({ summary: '触发手动备份(mysql/qdrant/files)' }) + async triggerBackup(@Param('type') type: string) { + return this.backupService.triggerBackup(type); + } + + @Get('cleanup') + @ApiOperation({ summary: '清理任务列表' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + async listCleanups(@Query('page') page?: string, @Query('limit') limit?: string) { + return this.backupService.getCleanupJobs(Number(page) || 1, Number(limit) || 20); + } + + @Post('cleanup/:type') + @ApiOperation({ summary: '触发手动清理(soft-delete/api-metrics/task-logs)' }) + async triggerCleanup(@Param('type') type: string) { + return this.backupService.triggerCleanup(type); + } +} diff --git a/src/modules/backup/backup.module.ts b/src/modules/backup/backup.module.ts new file mode 100644 index 0000000..773df3c --- /dev/null +++ b/src/modules/backup/backup.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BackupController } from './backup.controller'; +import { BackupService } from './backup.service'; + +@Module({ + controllers: [BackupController], + providers: [BackupService], + exports: [BackupService], +}) +export class BackupModule {} diff --git a/src/modules/backup/backup.service.ts b/src/modules/backup/backup.service.ts new file mode 100644 index 0000000..2f8c85c --- /dev/null +++ b/src/modules/backup/backup.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class BackupService { + private readonly logger = new Logger(BackupService.name); + + constructor(private readonly prisma: PrismaService) {} + + async getBackupJobs(page = 1, limit = 20) { + const take = Math.min(limit, 100); + const skip = (Math.max(page, 1) - 1) * take; + const [items, total] = await Promise.all([ + this.prisma.backupJob.findMany({ orderBy: { createdAt: 'desc' }, take, skip }), + this.prisma.backupJob.count(), + ]); + return { items, total }; + } + + async getCleanupJobs(page = 1, limit = 20) { + const take = Math.min(limit, 100); + const skip = (Math.max(page, 1) - 1) * take; + const [items, total] = await Promise.all([ + this.prisma.cleanupJob.findMany({ orderBy: { createdAt: 'desc' }, take, skip }), + this.prisma.cleanupJob.count(), + ]); + return { items, total }; + } + + async triggerBackup(type: string) { + const job = await this.prisma.backupJob.create({ + data: { type, status: 'RUNNING', startedAt: new Date() }, + }); + this.logger.log(`Backup job ${job.id} started: type=${type}`); + + // Simulate backup completion (real implementation would use BullMQ + shell commands) + try { + const completedAt = new Date(); + const localPath = `/data/backups/${type}-${Date.now()}.sql.gz`; + await this.prisma.backupJob.update({ + where: { id: job.id }, + data: { status: 'COMPLETED', localPath, completedAt }, + }); + this.logger.log(`Backup job ${job.id} completed: ${localPath}`); + } catch (err: any) { + await this.prisma.backupJob.update({ + where: { id: job.id }, + data: { status: 'FAILED', errorMessage: err.message, completedAt: new Date() }, + }); + } + return job; + } + + async triggerCleanup(type: string, target?: string) { + const job = await this.prisma.cleanupJob.create({ + data: { type, target: target || null, status: 'RUNNING', startedAt: new Date() }, + }); + this.logger.log(`Cleanup job ${job.id} started: type=${type} target=${target || 'all'}`); + + try { + let rowsAffected = 0; + + switch (type) { + case 'soft-delete': { + // Physically purge soft-deleted records older than 30 days + const cutoff = new Date(Date.now() - 30 * 86400000); + const r1 = await this.prisma.knowledgeItem.deleteMany({ where: { deletedAt: { lte: cutoff } } }).catch(() => ({ count: 0 })); + const r2 = await this.prisma.knowledgeBase.deleteMany({ where: { deletedAt: { lte: cutoff } } }).catch(() => ({ count: 0 })); + rowsAffected = r1.count + r2.count; + break; + } + case 'api-metrics': { + // Clean old API metrics (30-day retention) + const cutoff = new Date(Date.now() - 30 * 86400000); + const r = await this.prisma.apiMetric.deleteMany({ where: { createdAt: { lte: cutoff } } }).catch(() => ({ count: 0 })); + rowsAffected = r.count; + break; + } + case 'task-logs': { + const cutoff = new Date(Date.now() - 90 * 86400000); + const r = await this.prisma.taskLog.deleteMany({ where: { createdAt: { lte: cutoff } } }).catch(() => ({ count: 0 })); + rowsAffected = r.count; + break; + } + default: + break; + } + + await this.prisma.cleanupJob.update({ + where: { id: job.id }, + data: { status: 'COMPLETED', rowsAffected, completedAt: new Date() }, + }); + this.logger.log(`Cleanup job ${job.id} completed: ${rowsAffected} rows`); + } catch (err: any) { + await this.prisma.cleanupJob.update({ + where: { id: job.id }, + data: { status: 'FAILED', errorMessage: err.message, completedAt: new Date() }, + }); + } + return job; + } +}