📦 NestJS 系列教程(十八):文件上传与对象存储架构(Multer + S3/OSS + 权限控制)
✨ 本篇目标
本篇你将学会:
-
使用 Multer 在 NestJS 中实现:
- 单文件上传
- 多文件上传
-
上传前的校验策略(类型 / 大小)
-
文件命名与目录规划(企业级建议)
-
对象存储(S3/OSS)两种常用架构:
- 后端直传对象存储(Server Upload)
- 前端直传(Pre-signed URL / STS)
-
下载访问权限控制(私有桶 + 临时 URL)
为了确保你复制就能跑,本章提供 本地存储版本(可直接运行) + 对象存储版本(模板代码) 两套实现。
🧱 一、目录结构(可运行最小闭环)
src/
├── app.module.ts
└── files/
├── files.module.ts
├── files.controller.ts
├── files.service.ts
└── dto/
└── upload.dto.ts
📦 二、安装依赖
NestJS 上传基于 Multer:
bash
npm i @nestjs/platform-express multer
如果你用的是 Fastify,那么需要额外适配(本系列默认 Express)。
🔧 三、FilesModule
src/files/files.module.ts
ts
import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
@Module({
controllers: [FilesController],
providers: [FilesService],
})
export class FilesModule {}
在 app.module.ts 注册:
ts
import { Module } from '@nestjs/common';
import { FilesModule } from './files/files.module';
@Module({
imports: [FilesModule],
})
export class AppModule {}
🧠 四、企业级文件命名与存储规划
建议遵循:
-
目录按业务划分:
avatars/、attachments/、posts/ -
文件名避免冲突:
时间戳 + 随机数 + 原始后缀 -
不要信任前端传来的文件名
-
上传后返回:
fileIdurlmimesize
我们先实现本地存储版本。
✅ 五、本地存储版本:单文件上传(可运行)
1)FilesService:生成文件名 + 返回访问 URL
src/files/files.service.ts
ts
import { Injectable } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs';
@Injectable()
export class FilesService {
private readonly uploadRoot = path.join(process.cwd(), 'uploads');
ensureUploadDir(subDir: string) {
const dir = path.join(this.uploadRoot, subDir);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
buildPublicPath(subDir: string, filename: string) {
// 这里返回一个"可访问路径",后面我们会在 main.ts 做静态托管
return `/static/${subDir}/${filename}`;
}
}
2)FilesController:单文件上传接口
src/files/files.controller.ts
ts
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
BadRequestException,
Query,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as path from 'path';
import { FilesService } from './files.service';
function safeExt(originalName: string) {
return path.extname(originalName).toLowerCase();
}
function randomName(ext: string) {
const r = Math.random().toString(16).slice(2);
return `${Date.now()}_${r}${ext}`;
}
@Controller('files')
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (req, file, cb) => {
// 支持按业务目录分类,如 ?dir=avatars
const dir = (req.query.dir as string) || 'misc';
cb(null, path.join(process.cwd(), 'uploads', dir));
},
filename: (req, file, cb) => {
const ext = safeExt(file.originalname);
cb(null, randomName(ext));
},
}),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
fileFilter: (req, file, cb) => {
// 简单示例:只允许图片
if (!file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('仅允许上传图片文件'), false);
}
cb(null, true);
},
}),
)
uploadSingle(
@UploadedFile() file: Express.Multer.File,
@Query('dir') dir?: string,
) {
if (!file) throw new BadRequestException('未上传文件');
const subDir = dir || 'misc';
const url = `/static/${subDir}/${file.filename}`;
return {
fileId: file.filename,
url,
mime: file.mimetype,
size: file.size,
};
}
}
注意:上面 destination 里直接用
uploads/dir,但是正常使用的情况下需要确保目录存在,此处为了简洁我先写成可读版本;大家平时使用的时候要更严谨,可以在destination里调用ensureUploadDir(下面我会给你一版更稳妥的写法)。
3)在 main.ts 托管静态文件(让上传结果可访问)
src/main.ts
ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import * as path from 'path';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 访问: /static/... => 映射到项目根目录 uploads
app.use('/static', express.static(path.join(process.cwd(), 'uploads')));
await app.listen(3000);
}
bootstrap();
4)测试
用 Postman / Thunder Client:
-
POST
http://localhost:3000/files/upload?dir=avatars -
form-data:
- key:
file - value:选择一张图片
- key:
返回:
json
{
"fileId": "1719999999_abcd.png",
"url": "/static/avatars/1719999999_abcd.png",
"mime": "image/png",
"size": 12345
}
浏览器访问:
http://localhost:3000/static/avatars/1719999999_abcd.png
✅ 六、更严谨:确保目录存在(建议大家用这版)
把 storage.destination 改成:
ts
destination: (req, file, cb) => {
const dir = (req.query.dir as string) || 'misc';
const fullDir = this.filesService.ensureUploadDir(dir);
cb(null, fullDir);
},
这需要你把拦截器配置从装饰器里抽出来(因为
this用不到)。企业级建议:写成multer.config.ts或在构造函数里创建 options。我后面第 22 章会给最终模板目录。
📚 七、多文件上传(一次传多张)
在 Controller 增加接口:
ts
import { FilesInterceptor } from '@nestjs/platform-express';
@Post('upload-many')
@UseInterceptors(
FilesInterceptor('files', 10, {
storage: diskStorage({
destination: (req, file, cb) => {
const dir = (req.query.dir as string) || 'misc';
cb(null, path.join(process.cwd(), 'uploads', dir));
},
filename: (req, file, cb) => {
const ext = safeExt(file.originalname);
cb(null, randomName(ext));
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
}),
)
uploadMany(@UploadedFiles() files: Express.Multer.File[], @Query('dir') dir?: string) {
const subDir = dir || 'misc';
return files.map((f) => ({
fileId: f.filename,
url: `/static/${subDir}/${f.filename}`,
mime: f.mimetype,
size: f.size,
}));
}
☁️ 八、对象存储架构(S3/OSS):两种主流模式
模式 A:后端直传对象存储(Server Upload)
流程:
- 前端上传到后端
- 后端把文件上传到 S3/OSS
- 后端返回最终文件 URL
优点:
- 权限控制简单
- 统一审计、统一校验
缺点:
- 后端带宽压力大(大文件不划算)
模式 B:前端直传(Pre-signed URL / STS)
流程(推荐):
- 前端请求后端:我要上传一个文件(告诉文件名/类型)
- 后端生成临时上传凭证(签名 URL 或 STS)
- 前端直接 PUT 到对象存储
- 上传完成后回调后端登记文件信息
优点:
- 后端压力最小
- 大文件上传性能极佳
缺点:
- 实现稍复杂
- 需要更好的权限策略
🔐 九、私有资源访问控制:临时下载 URL(生产常用)
如果你用 私有桶:
- 文件默认不可公开访问
- 后端生成 临时下载 URL(5分钟有效)
- 前端拿到 URL 后直接下载
你可以在 FilesController 里提供:
GET /files/presign-uploadGET /files/presign-download
这部分因为涉及具体云厂商(AWS S3 / 阿里 OSS / 腾讯 COS),所以需要根据具体情况具体调整,我会在第 22 章模板里给出抽象接口(StorageProvider)。
✅ 十、本章小结
本篇我们完成了:
- NestJS 使用 Multer 实现单文件、多文件上传
- 文件校验(类型、大小)
- 企业级文件命名与目录规划建议
- 静态托管访问(本地版可运行)
- 对象存储两种架构:后端直传 / 前端直传
- 私有资源访问控制的设计思路(临时 URL)