nest.js使用腾讯云上传文件

0 阅读5分钟

腾讯云 COS 文件上传功能

一、功能概述

文件上传模块支持两种存储方式,通过配置项 app.file.isLocal 切换:

  • 本地存储:文件写入服务器本地磁盘
  • 腾讯云 COS 存储:通过 cos-nodejs-sdk-v5 SDK 将文件上传至腾讯云对象存储

此外还支持大文件分片上传、切片合并、上传结果查询、COS 直传授权签名获取等能力。

二、模块结构

文件上传模块位于 src/module/upload/,目录结构如下:

src/module/upload/
├── upload.module.ts          # 模块定义(@Global)
├── upload.controller.ts      # 控制器,定义上传相关 HTTP 接口
├── upload.service.ts         # 核心服务,封装本地与 COS 上传逻辑
├── dto/
│   └── index.ts              # 请求 DTO(FileUploadDto、ChunkFileDto、ChunkMergeFileDto 等)
└── entities/
    └── upload.entity.ts      # 文件上传记录实体 sys_upload

依赖(见 package.json):

  • cos-nodejs-sdk-v5:腾讯云 COS Node.js SDK,版本 ^2.15.4
  • iconv-lite:处理文件名编码(binary → utf8)
  • mime-types:根据 mimetype 推断扩展名
  • @nestjs/platform-express 的 FileInterceptor:处理 multipart/form-data 文件流

三、配置说明

1. COS 配置(src/config/dev.yml)

# 腾讯云cos配置
cos:
  secretId: '你的 SecretId'
  secretKey: '你的 SecretKey'
  bucket: '你的 Bucket'
  region: 'ap-nanjing'
  domain: ''        # COS 访问域名(拼接最终可访问 URL)
  location: ''      # 上传到 COS 的目标目录

2. 文件相关配置(src/config/dev.yml)

app:
  file:
    isLocal: true             # true=本地上传,false=COS 上传
    location: '../upload'     # 本地存储目录
    domain: 'http://localhost:3004'  # 文件服务访问域名
    serveRoot: '/profile'     # 文件虚拟路径前缀
    maxSize: 10               # 文件大小上限(MB)

四、数据库表 sys_upload

实体定义 upload.entity.ts,用于记录每次上传任务:

字段类型说明
upload_idvarchar(主键)上传任务 ID(UUID 生成)
sizeint文件大小(字节)
file_namevarchar文件路径(COS Key 或本地相对路径)
new_file_namevarchar重命名后的文件名(带时间戳)
urlvarchar文件最终访问 URL
extvarchar文件扩展名
statusvarchar上传状态(0=成功,其他=上传中;仅大文件 COS 上传使用)

继承自 BaseEntity,含通用字段(如 createTime、updateTime)。

五、接口列表

控制器 upload.controller.ts,统一前缀 /common/upload,需携带 Authorization 头。

方法路径说明入参
POST/common/upload单文件上传(本地或 COS)file(form-data)
GET/common/upload/chunk/uploadId获取分片上传任务 ID无
POST/common/upload/chunk文件切片上传file + ChunkFileDto
POST/common/upload/chunk/merge合并切片ChunkMergeFileDto
GET/common/upload/chunk/result查询分片上传结果uploadId
GET/common/upload/cos/authorization获取 COS 直传签名key
GET/common/upload/local获取本地文件上传地址key
GET/common/upload/local/:key读取本地 /upload 下的文件key

DTO 定义(dto/index.ts)

// 普通上传
export class FileUploadDto { file: any }
​
// 分片上传
export class ChunkFileDto {
  index: number;        // 当前分片序号
  totalChunks: number;  // 总分片数
  uploadId: string;     // 任务 ID
  fileName: string;    // 原始文件名
}
​
// 合并分片
export class ChunkMergeFileDto {
  uploadId: string;
  fileName: string;
}

六、核心实现解析

1. COS 实例初始化([upload.service.ts)

在 UploadService 构造函数中通过 ConfigService 读取 cos.secretId、cos.secretKey,并设置并发与分片参数:

this.cos = new COS({
  SecretId: this.config.get('cos.secretId') || '',
  SecretKey: this.config.get('cos.secretKey') || '',
  FileParallelLimit: 3,      // 文件上传并发数
  ChunkParallelLimit: 8,     // 单文件分片并发数
  ChunkSize: 1024 * 1024 * 8 // 分片大小 8MB
});

2. 单文件上传([upload.service.ts)

流程:

  1. 校验文件大小不超过 app.file.maxSize(默认 10MB)
  2. 根据 app.file.isLocal 路由到本地或 COS 上传
  3. 生成 uploadId 并将上传记录写入 sys_upload
async singleFileUpload(file: any) {
  const fileSize = (file.size / 1024 / 1024).toFixed(2);
  if (fileSize > this.config.get('app.file.maxSize')) {
    return ResultData.fail(500, `文件大小不能超过${this.config.get('app.file.maxSize')}MB`);
  }
  let res;
  if (this.isLocal) {
    res = await this.saveFileLocal(file);
  } else {
    const targetDir = this.config.get('cos.location');
    res = await this.saveFileCos(targetDir, file);
  }
  const uploadId = GenerateUUID();
  await this.sysUploadEntityRep.save({ uploadId, ...res, ext: path.extname(res.newFileName), size: file.size });
  return res;
}

3. COS 普通上传(upload.service.ts)

saveFileCos 负责:文件名解码 → 生成带时间戳的新文件名 → 调用 uploadCos → 拼接 cos.domain 得到访问 URL。

uploadCos 调用 SDK 的 putObject,并先通过 headObject 判断对象是否已存在(幂等):

async uploadCos(targetFile: string, buffer: COS.UploadBody) {
  const { statusCode } = await this.cosHeadObject(targetFile);
  if (statusCode !== 200) {
    const data = await this.cos.putObject({
      Bucket: this.config.get('cos.bucket') || '',
      Region: this.config.get('cos.region') || '',
      Key: targetFile,
      Body: buffer,
    });
    return path.dirname(data?.Location || targetFile);
  }
  return targetFile;
}

4. COS 大文件分片上传(upload.service.ts)

使用 cos.uploadFile,SliceSize 设为 5MB,超过阈值自动走分块上传;通过 onProgress 监听进度,完成时(percent === 1)更新 sys_upload.status = '0',最后删除本地临时文件。

async uploadLargeFileCos(sourceFile: string, targetFile: string) {
  const { statusCode } = await this.cosHeadObject(targetFile);
  if (statusCode !== 200) {
    await this.cos.uploadFile({
      Bucket: this.config.get('cos.bucket') || '',
      Region: this.config.get('cos.region') || '',
      Key: targetFile,
      FilePath: sourceFile,
      SliceSize: 1024 * 1024 * 5,
      onProgress: (progressData) => {
        if (progressData.percent === 1) {
          this.sysUploadEntityRep.update({ fileName: targetFile }, { status: '0' });
        }
      },
    });
  }
  fs.unlinkSync(sourceFile);
  return targetFile;
}

5. COS 资源存在性检查(upload.service.ts)

async cosHeadObject(targetFile: string) {
  try {
    return await this.cos.headObject({
      Bucket: this.config.get('cos.bucket') || '',
      Region: this.config.get('cos.region') || '',
      Key: targetFile,
    });
  } catch (error) {
    return error;  // 不存在时 SDK 抛错,这里吞掉并返回
  }
}

6. COS 直传签名(upload.service.ts)

前端直传 COS 场景下,后端通过 COS.getAuthorization 生成临时签名(有效期 60 秒):

async getAuthorization(Key: string) {
  const authorization = COS.getAuthorization({
    SecretId: this.config.get('cos.secretId') || '',
    SecretKey: this.config.get('cos.secretKey') || '',
    Method: 'post',
    Key: Key,
    Expires: 60,
  });
  return ResultData.ok({ sign: authorization });
}

7. 文件名生成规则(upload.service.ts)

在原文件名主体后追加 _${Date.now()} 时间戳,避免覆盖:

getNewFileName(originalname: string): string {
  if (!originalname) return originalname;
  const arr = originalname.split('.');
  arr[0] = `${arr[0]}_${new Date().getTime()}`;
  return arr.join('.');
}

七、大文件分片上传完整流程

前端分片上传结合本地缓存 + COS 大文件上传,整体时序如下:

  1. 获取任务 ID:GET /common/upload/chunk/uploadId → 返回 uploadId(UUID)

  2. 切片上传(本地暂存) :POST /common/upload/chunk

    • 每个分片写入本地 upload/thunk/{uploadId}/{uploadId}{fileName}@{index}
    • 通过 checkChunkFile 可查询某分片是否已上传(支持断点续传)
  3. 合并切片:POST /common/upload/chunk/merge

    • 按分片序号排序后流式拼接为完整文件
    • 若 isLocal=false,调用 uploadLargeFileCos 异步上传到 COS,并写入 sys_upload(status='0' 初始为上传中)
    • 合并完成后删除临时分片目录
  4. 查询上传结果:GET /common/upload/chunk/result?uploadId=xxx

    • 返回 status、fileName、newFileName、url
    • status === '0' 表示 COS 上传成功

八、本地与 COS 上传对比

维度本地上传COS 上传
开关app.file.isLocal: trueapp.file.isLocal: false
存储位置app.file.location 目录cos.bucket / cos.region
访问域名app.file.domain + app.file.serveRootcos.domain + Key
普通上传saveFileLocal(fs.writeFileSync)saveFileCos → uploadCos(putObject)
大文件本地合并即可uploadLargeFileCos(uploadFile 分块)
URL 拼接domain + serveRoot + relativeFilePathcos.domain + targetFile

九、模块注册

upload.module.ts 使用 @Global() 装饰,全局导出 UploadService,其他模块可直接注入使用:

@Global()
@Module({
  imports: [TypeOrmModule.forFeature([SysUploadEntity])],
  controllers: [UploadController],
  providers: [UploadService],
  exports: [UploadService],
})
export class UploadModule {}

十、使用示例

1. 切换为 COS 上传

修改 dev.yml:

app:
  file:
    isLocal: false        # 切换到 COS
​
cos:
  secretId: '你的 SecretId'
  secretKey: '你的 SecretKey'
  bucket: '你的 Bucket'
  region: 'ap-nanjing'
  domain: 'https://your-cos-domain.cos.ap-nanjing.myqcloud.com'
  location: 'upload'     # COS 上的目标目录前缀

2. 调用单文件上传接口

curl -X POST http://localhost:3004/common/upload \
  -H "Authorization: Bearer <token>" \
  -F "file=@/path/to/file.png"

返回示例:

{
  "code": 200,
  "data": {
    "fileName": "upload/file_1692834567890.png",
    "newFileName": "file_1692834567890.png",
    "url": "https://your-cos-domain.cos.ap-nanjing.myqcloud.com/upload/file_1692834567890.png"
  }
}

3. 前端直传 COS(获取签名)

curl "http://localhost:3004/common/upload/cos/authorization?key=upload/test.png" \
  -H "Authorization: Bearer <token>"

返回包含 sign 字段,前端可用该签名直接 POST 文件到 COS。

十一、注意事项

  1. 大文件异步上传:chunkMergeFile 中 uploadLargeFileCos 为异步调用(未 await),合并接口返回时 COS 可能仍在传输,需通过 getChunkUploadResult 轮询最终状态。
  2. headObject 异常处理:cosHeadObject 捕获异常后直接返回 error 对象,调用方通过判断 statusCode !== 200 决定是否上传,逻辑可工作但依赖异常控制流,建议后续优化为显式返回布尔值。
  3. 分片合并目录:合并完成后会删除整个 thunk/{uploadId} 临时目录,断点续传需保证所有分片在上传阶段已落盘。
  4. 凭证安全:生产环境务必将 secretId / secretKey 从配置文件中迁移至环境变量或密钥管理服务。
  5. 白名单:perm.router.whitelist 中已包含 { path: '/upload', method: 'POST' },但实际接口路径为 /common/upload,需确认权限中间件匹配规则(前缀拼接)是否生效。