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

107 lines
3.4 KiB
TypeScript

import {
Controller,
Get,
Param,
Res,
Req,
NotFoundException,
Logger,
} from '@nestjs/common'
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'
import { Request, Response } from 'express'
import { ConfigService } from '@nestjs/config'
import * as fs from 'fs'
import * as path from 'path'
@ApiTags('Downloads')
@Controller('downloads')
export class DownloadController {
private readonly logger = new Logger(DownloadController.name)
private readonly uploadDir: string
constructor(private readonly configService: ConfigService) {
this.uploadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads'
}
@Get(':filename')
@ApiOperation({ summary: '下载文件 (支持断点续传)' })
@ApiParam({ name: 'filename', description: '文件名' })
@ApiResponse({ status: 200, description: '文件内容' })
@ApiResponse({ status: 206, description: '部分内容 (断点续传)' })
@ApiResponse({ status: 404, description: '文件不存在' })
@ApiResponse({ status: 416, description: 'Range 不满足' })
async downloadFile(
@Param('filename') filename: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
// 安全检查:防止路径遍历攻击
const sanitizedFilename = path.basename(filename)
const filePath = path.join(this.uploadDir, sanitizedFilename)
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
throw new NotFoundException('文件不存在')
}
const stat = fs.statSync(filePath)
const fileSize = stat.size
const range = req.headers.range
// 设置通用响应头
const ext = path.extname(filename).toLowerCase()
const mimeTypes: Record<string, string> = {
'.apk': 'application/vnd.android.package-archive',
'.ipa': 'application/octet-stream',
}
const contentType = mimeTypes[ext] || 'application/octet-stream'
// 设置缓存和下载头
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Content-Type', contentType)
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(sanitizedFilename)}"`,
)
res.setHeader('Cache-Control', 'public, max-age=86400') // 缓存1天
res.setHeader('ETag', `"${stat.mtime.getTime()}-${fileSize}"`)
res.setHeader('Last-Modified', stat.mtime.toUTCString())
if (range) {
// 断点续传请求
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
// 验证 Range 有效性
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416)
res.setHeader('Content-Range', `bytes */${fileSize}`)
res.end()
return
}
const chunkSize = end - start + 1
this.logger.log(
`Range request: ${filename}, bytes ${start}-${end}/${fileSize}`,
)
res.status(206)
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
res.setHeader('Content-Length', chunkSize)
const stream = fs.createReadStream(filePath, { start, end })
stream.pipe(res)
} else {
// 完整文件请求
this.logger.log(`Full download: ${filename}, size: ${fileSize}`)
res.setHeader('Content-Length', fileSize)
const stream = fs.createReadStream(filePath)
stream.pipe(res)
}
}
}