154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|