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('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 { // 安全检查:防止路径遍历攻击 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 = { '.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) } } }