NestJS 系列教程(十八):文件上传与对象存储架构(Multer + S3/OSS + 访问控制)

📦 NestJS 系列教程(十八):文件上传与对象存储架构(Multer + S3/OSS + 权限控制)

✨ 本篇目标

本篇你将学会:

  • 使用 Multer 在 NestJS 中实现:

    • 单文件上传
    • 多文件上传
  • 上传前的校验策略(类型 / 大小)

  • 文件命名与目录规划(企业级建议)

  • 对象存储(S3/OSS)两种常用架构:

    1. 后端直传对象存储(Server Upload)
    2. 前端直传(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/

  • 文件名避免冲突:时间戳 + 随机数 + 原始后缀

  • 不要信任前端传来的文件名

  • 上传后返回:

    • fileId
    • url
    • mime
    • size

我们先实现本地存储版本。


✅ 五、本地存储版本:单文件上传(可运行)

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:选择一张图片

返回:

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)

流程:

  1. 前端上传到后端
  2. 后端把文件上传到 S3/OSS
  3. 后端返回最终文件 URL

优点:

  • 权限控制简单
  • 统一审计、统一校验

缺点:

  • 后端带宽压力大(大文件不划算)

模式 B:前端直传(Pre-signed URL / STS)

流程(推荐):

  1. 前端请求后端:我要上传一个文件(告诉文件名/类型)
  2. 后端生成临时上传凭证(签名 URL 或 STS)
  3. 前端直接 PUT 到对象存储
  4. 上传完成后回调后端登记文件信息

优点:

  • 后端压力最小
  • 大文件上传性能极佳

缺点:

  • 实现稍复杂
  • 需要更好的权限策略

🔐 九、私有资源访问控制:临时下载 URL(生产常用)

如果你用 私有桶

  • 文件默认不可公开访问
  • 后端生成 临时下载 URL(5分钟有效)
  • 前端拿到 URL 后直接下载

你可以在 FilesController 里提供:

  • GET /files/presign-upload
  • GET /files/presign-download

这部分因为涉及具体云厂商(AWS S3 / 阿里 OSS / 腾讯 COS),所以需要根据具体情况具体调整,我会在第 22 章模板里给出抽象接口(StorageProvider)。


✅ 十、本章小结

本篇我们完成了:

  • NestJS 使用 Multer 实现单文件、多文件上传
  • 文件校验(类型、大小)
  • 企业级文件命名与目录规划建议
  • 静态托管访问(本地版可运行)
  • 对象存储两种架构:后端直传 / 前端直传
  • 私有资源访问控制的设计思路(临时 URL)
相关推荐
Ruihong1 小时前
放弃 Vue3 传统 <script>!我的 VuReact 编译器做了一次清醒取舍
前端·vue.js
2501_948114241 小时前
从 Claude Code 源码泄露看 2026 年 Agent 架构演进与工程化实践
大数据·人工智能·架构
weixin_456164831 小时前
vue3 父组件向子组件传参
前端
Beginner x_u1 小时前
前端八股整理|CSS|高频小题 01
前端·css·八股
蜡台2 小时前
IDEA LiveTemplates Vue ElementUI
前端·vue.js·elementui·idea·livetemplates
E-cology2 小时前
【泛微低代码开发平台e-builder】使用HTML组件实现页面中部分区域自定义开发
前端·低代码·泛微·e-builder
用户9751470751362 小时前
如何使用Promise.any()处理多个异步操作?
前端
yuki_uix2 小时前
只渲染「必要的部分」:从 DepartmentTree 和 VirtualList 看前端的两种裁剪哲学
前端·面试
苏瞳儿2 小时前
前端/后端-配置跨域
前端·javascript·node.js·vue