import { Controller, Get, Post, Delete, Param, Body, Query, Res, Req, NotFoundException, BadRequestException, ConflictException, Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import { CreateSnapshotDto } from '../dto/create-snapshot.dto'; import { toSnapshotResponse } from '../dto/snapshot.response'; import { SnapshotOrchestratorService } from '@/application/services/snapshot-orchestrator.service'; import { SnapshotRepository } from '@/infrastructure/persistence/repositories/snapshot.repository'; import { StorageType } from '@/domain/enums'; @ApiTags('快照备份') @Controller('snapshots') export class SnapshotController { private readonly logger = new Logger(SnapshotController.name); constructor( private readonly orchestrator: SnapshotOrchestratorService, private readonly repo: SnapshotRepository, ) {} @Get('targets') @ApiOperation({ summary: '获取可用备份目标列表' }) getAvailableTargets() { return { targets: this.orchestrator.getAvailableTargets(), isRunning: this.orchestrator.isRunning(), }; } @Post() @ApiOperation({ summary: '创建备份任务' }) @ApiResponse({ status: 201, description: '任务已创建,异步执行' }) @ApiResponse({ status: 409, description: '已有任务在执行' }) async createSnapshot(@Body() dto: CreateSnapshotDto) { if (this.orchestrator.isRunning()) { throw new ConflictException('已有备份任务正在执行,请等待完成'); } const taskId = await this.orchestrator.startSnapshot(dto); return { taskId, message: '备份任务已启动' }; } @Get() @ApiOperation({ summary: '备份历史列表' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) async listSnapshots( @Query('page') page?: string, @Query('limit') limit?: string, ) { const p = Math.max(1, parseInt(page || '1', 10) || 1); const l = Math.min(100, Math.max(1, parseInt(limit || '20', 10) || 20)); const result = await this.repo.findAll(p, l); return { tasks: result.tasks.map(toSnapshotResponse), total: result.total, page: result.page, limit: result.limit, }; } @Get(':id') @ApiOperation({ summary: '备份任务详情' }) async getSnapshot(@Param('id') id: string) { const task = await this.repo.findById(id); if (!task) throw new NotFoundException('备份任务不存在'); return toSnapshotResponse(task); } @Delete(':id') @ApiOperation({ summary: '删除备份' }) async deleteSnapshot(@Param('id') id: string) { await this.orchestrator.deleteSnapshot(id); return { message: '备份已删除' }; } @Get(':id/download/:target') @ApiOperation({ summary: '下载备份文件 (仅 LOCAL 模式)' }) @ApiResponse({ status: 200, description: '文件内容' }) @ApiResponse({ status: 206, description: '部分内容 (断点续传)' }) async downloadFile( @Param('id') id: string, @Param('target') target: string, @Req() req: Request, @Res() res: Response, ) { const task = await this.repo.findById(id); if (!task) throw new NotFoundException('备份任务不存在'); if (task.storageType !== StorageType.LOCAL) { throw new BadRequestException('仅本地存储模式支持下载'); } const detail = task.details.find((d) => d.target === target); if (!detail || !detail.fileName) { throw new NotFoundException('备份文件不存在'); } const tempDir = process.env.SNAPSHOT_TEMP_DIR || './data/snapshots'; const filePath = path.join(tempDir, id, detail.fileName); if (!fs.existsSync(filePath)) { throw new NotFoundException('备份文件已过期或被删除'); } const stat = fs.statSync(filePath); const fileSize = stat.size; const range = req.headers.range; res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Content-Type', 'application/gzip'); res.setHeader( 'Content-Disposition', `attachment; filename="${encodeURIComponent(detail.fileName)}"`, ); if (range) { const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; if (start >= fileSize || end >= fileSize || start > end) { res.status(416); res.setHeader('Content-Range', `bytes */${fileSize}`); res.end(); return; } const chunkSize = end - start + 1; res.status(206); res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`); res.setHeader('Content-Length', chunkSize); fs.createReadStream(filePath, { start, end }).pipe(res); } else { res.setHeader('Content-Length', fileSize); fs.createReadStream(filePath).pipe(res); } } }