rwadurian/backend/services/snapshot-service/src/api/controllers/snapshot.controller.ts

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