腾讯云 COS 文件上传功能
一、功能概述
文件上传模块支持两种存储方式,通过配置项 app.file.isLocal 切换:
- 本地存储:文件写入服务器本地磁盘
- 腾讯云 COS 存储:通过
cos-nodejs-sdk-v5SDK 将文件上传至腾讯云对象存储
此外还支持大文件分片上传、切片合并、上传结果查询、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.4iconv-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_id | varchar(主键) | 上传任务 ID(UUID 生成) |
size | int | 文件大小(字节) |
file_name | varchar | 文件路径(COS Key 或本地相对路径) |
new_file_name | varchar | 重命名后的文件名(带时间戳) |
url | varchar | 文件最终访问 URL |
ext | varchar | 文件扩展名 |
status | varchar | 上传状态(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)
流程:
- 校验文件大小不超过
app.file.maxSize(默认 10MB) - 根据
app.file.isLocal路由到本地或 COS 上传 - 生成
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 大文件上传,整体时序如下:
-
获取任务 ID:
GET /common/upload/chunk/uploadId→ 返回uploadId(UUID) -
切片上传(本地暂存) :
POST /common/upload/chunk- 每个分片写入本地
upload/thunk/{uploadId}/{uploadId}{fileName}@{index} - 通过
checkChunkFile可查询某分片是否已上传(支持断点续传)
- 每个分片写入本地
-
合并切片:
POST /common/upload/chunk/merge- 按分片序号排序后流式拼接为完整文件
- 若
isLocal=false,调用uploadLargeFileCos异步上传到 COS,并写入sys_upload(status='0'初始为上传中) - 合并完成后删除临时分片目录
-
查询上传结果:
GET /common/upload/chunk/result?uploadId=xxx- 返回
status、fileName、newFileName、url status === '0'表示 COS 上传成功
- 返回
八、本地与 COS 上传对比
| 维度 | 本地上传 | COS 上传 |
|---|---|---|
| 开关 | app.file.isLocal: true | app.file.isLocal: false |
| 存储位置 | app.file.location 目录 | cos.bucket / cos.region |
| 访问域名 | app.file.domain + app.file.serveRoot | cos.domain + Key |
| 普通上传 | saveFileLocal(fs.writeFileSync) | saveFileCos → uploadCos(putObject) |
| 大文件 | 本地合并即可 | uploadLargeFileCos(uploadFile 分块) |
| URL 拼接 | domain + serveRoot + relativeFilePath | cos.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。
十一、注意事项
- 大文件异步上传:
chunkMergeFile中uploadLargeFileCos为异步调用(未await),合并接口返回时 COS 可能仍在传输,需通过getChunkUploadResult轮询最终状态。 headObject异常处理:cosHeadObject捕获异常后直接返回error对象,调用方通过判断statusCode !== 200决定是否上传,逻辑可工作但依赖异常控制流,建议后续优化为显式返回布尔值。- 分片合并目录:合并完成后会删除整个
thunk/{uploadId}临时目录,断点续传需保证所有分片在上传阶段已落盘。 - 凭证安全:生产环境务必将
secretId/secretKey从配置文件中迁移至环境变量或密钥管理服务。 - 白名单:perm.router.whitelist 中已包含
{ path: '/upload', method: 'POST' },但实际接口路径为/common/upload,需确认权限中间件匹配规则(前缀拼接)是否生效。