跳转到主要内容

Nest 使用笔记

第十九章:MinIO——对象存储、桶策略与预签名直传

打开 learnhub 的 MinioService,在真实代码里讲透 S3 兼容对象存储、桶自动建 + 匿名读策略、服务端代传和前端预签名直传两条上传通道,以及公开桶和签名 GET 各自的边界。每段代码都能在 learnhub 里指到对应文件。第六章讲过 multer 怎么接收,这一章讲它存哪里。

  • Nest
  • MinIO
  • 对象存储

第六章里 learnhub 的头像上传写到这一步就停了:

const url = await this.minio.putObject(objectName, file.buffer, file.size, file.mimetype);

MinioService 当时是个黑盒——文件到底存到哪里、URL 为什么浏览器直接打开就能看到、bucket 是谁建的、accessKey 怎么管,全是悬念。这一章打开那个黑盒。learnhub 用的是 MinIO——一个 S3 兼容的开源对象存储,本地能跑、上云换成阿里云 OSS / AWS S3 改个 endPoint 就行。和「用 diskStorage 把文件丢本地 uploads/」的玩具写法不同,这一章打开 learnhub 真正在生产里跑的 MinioService,讲清四件新手最容易跳过、上线最容易踩的事:桶的自动建 + 匿名读策略、两条上传通道(服务端代传 vs 前端预签名直传)的分工、对象名怎么取不会撞、公开读和签名 GET 各自的边界

先搞懂:对象存储是什么 / 为什么需要 / 企业级怎么用

对象存储是什么:一种专门存文件(图片、视频、PDF、附件)的存储服务。数据模型非常简单——桶(bucket)+ 对象(object)。桶是顶层命名空间(类似一个顶级文件夹,名字全局唯一),对象是桶里的一条文件,用 key(也就是对象名)寻址。读写的 API 是 HTTP:PUT 一个对象、GET 一个对象、DELETE 一个对象,每个对象最终都有一个形如 http://host/{bucket}/{key} 的 URL。MinIO 是这套 API 规范(S3)的开源实现——自己机器上跑一个 MinIO 服务,等于跑了一个迷你版阿里云 OSS。

为什么需要它:文件不存对象存储就两条路,各有硬伤。塞进 MySQL 的 BLOB 列——查询和备份都被拖慢,mysqldump 一个几十 GB 的库全是二进制,迟早崩。堆应用服务器本地磁盘——容器一重启文件就丢、扩容时新节点看不到旧节点的文件、单机磁盘根本撑不住量。对象存储一次解决:应用服务器无状态、文件独立扩容、按 URL 直接访问、天然支持多副本。判断:只要文件要长期存、按 URL 访问、量可能上 GB,就放对象存储;只有临时、用完即删的小缓存,本地磁盘才划算。

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):

  • 应用只持有一个存储客户端实例(单例),凭 accessKey / secretKey 连服务,密钥走环境变量、绝不进代码。
  • 桶在应用启动时自动创建(不存在就建),顺手设好访问策略——不需要人手进控制台点。
  • 两条上传通道并存:服务端代传给小文件用(前端 → Nest → MinIO);预签名 PUT 给大文件和海量并发用(前端直传 MinIO,流量不经过 Nest)。
  • 公开资源用桶级匿名读策略(头像、封面),私有资源用预签名 GET URL限时下载(合同、病历)。
  • 对象名按「用途 / 用户 / 时间戳」分层,绝不把用户传的原文件名直接当 key。

这一章你会做出什么

  • 用 Docker 起 MinIO,理解 9000(S3 API)和 9001(控制台)两个端口的分工。
  • 打开 learnhub 的 MinioModule + MinioService,看一个生产级存储封装做了哪几件事:全局模块 + DI token、onModuleInit 建桶、两条上传通道、两种 URL。
  • 厘清第六章留下的边界——multer 管「Nest 怎么接住文件」,MinIO 管「文件存哪里 + 直传怎么签」。
  • 把第六章头像上传的服务端代传、和 learnhub POST /upload/presigned 的前端直传两条路都跑一遍。

前置:learnhub 的 MinIO 容器在跑(cd learnhub/docker && docker compose up -d minio),第六章看过的 UploadController 还记得个大概。

第一步:用 Docker 起 MinIO——两个端口、一份健康检查

MinIO 起来之后对外暴露两个端口:9000 是 S3 API(应用代码连这个,相当于阿里云 OSS 的 oss-cn-xxx.aliyuncs.com),9001 是 Web 控制台(人在浏览器里看桶、传文件、设策略用的)。learnhub 的 docker-compose.yml 长这样:

# learnhub/docker/docker-compose.yml
minio:
  image: minio/minio
  container_name: learnhub-minio
  restart: unless-stopped
  environment:
    MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-learnhub_minio}
    MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-learnhub_minio_secret}
  command: server /data --console-address ":9001"
  ports:
    - "9000:9000"   # S3 API(应用 SDK 连这个)
    - "9001:9001"   # 控制台(浏览器访问)
  volumes:
    - minio-data:/data
  healthcheck:
    test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"]
    interval: 10s
    timeout: 5s
    retries: 10

几个细节值得讲:

  • MINIO_ROOT_USER / MINIO_ROOT_PASSWORD 就是 MinIO 的管理员密钥——SDK 连接时用的 accessKey / secretKey 等于这两个值。learnhub 从 .envMINIO_ACCESS_KEY / MINIO_SECRET_KEY 注入,默认值 learnhub_minio / learnhub_minio_secret 只够本地玩,生产必须换强密钥、走环境变量。
  • command: server /data --console-address ":9001" 是 MinIO 容器的启动命令:把 /data 作为存储目录(挂到 minio-data volume 持久化),控制台监听 9001
  • healthcheck 打的是 MinIO 的内置健康端点 /minio/health/live。Docker 据此判断容器有没有真起来——docker compose up 时 MinIO 要花几秒初始化,没健康检查的话应用那边连上来就是 connection refused。
cd learnhub
docker compose -f docker/docker-compose.yml up -d minio
# 浏览器打开 http://localhost:9001,用 learnhub_minio / learnhub_minio_secret 登录

进控制台先认个脸:左侧 Buckets 菜单能看到所有桶(一开始是空的),手动建一个桶可以、但 learnhub 不靠手动——下面第三步讲应用启动时自动建。

第二步:全局 MinioModule + DI token——SDK 客户端怎么注入

MinIO 官方提供了 JS SDK(npm i minio),核心是 new Minio.Client({ endPoint, port, accessKey, secretKey, useSSL }) 产出的 client 对象,所有桶操作(putObjectpresignedPutObjectbucketExists…)都是这个 client 的方法。要在 Nest 里用,第一步是想清楚这个 client 怎么注入——直接 new 在 Service 里就是反面教材:参数硬编码、没法测试、换实例就得改业务代码。learnhub 的做法是自定义 DI token + useFactory

// learnhub/src/modules/upload/minio.constants.ts
export const MINIO_CLIENT = 'MINIO_CLIENT';
// learnhub/src/modules/upload/minio.module.ts
@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: MINIO_CLIENT,           // 用字符串 token 而不是 class
      inject: [ConfigService],
      useFactory: (config: ConfigService) =>
        new Client({
          endPoint: config.get<string>('minio.endpoint') || '127.0.0.1',
          port: config.get<number>('minio.port') || 9000,
          useSSL: false,
          accessKey: config.get<string>('minio.accessKey') || '',
          secretKey: config.get<string>('minio.secretKey') || '',
        }),
    },
    MinioService,
  ],
  exports: [MinioService],
})
export class MinioModule {}

逐点讲清几个设计决定:

  • 为什么用字符串 token MINIO_CLIENT 而不是直接 provide: ClientClientminio 这个包导出的一个 class,按 class token 注入也能跑,但 learnhub 选了字符串 token,原因是——minio 包用的是 export = Client(CommonJS 默认导出),TypeScript 在按 class 注入时偶尔会因为模块导出形状报类型不匹配;字符串 token 把「依赖标识」和「实现类」彻底解耦,Nest 注入只看 token 匹配,类型问题绕开了。第四章讲 provider 的时候讲过这种「非 class token」的用法。
  • useFactory 而不是 useValue / useClassClient 的构造参数要从 ConfigService 拿(ConfigService 自己是 provider,要等 IoC 容器起来才能注入),useValue 是静态值给不了,useClass 不能传构造参数——只有 useFactory 能「先等依赖就绪、再调工厂函数造实例」。工厂函数的参数列表就是 inject: [ConfigService] 声明的依赖,Nest 会按顺序注入。
  • @Global() 装饰整个模块:意味着 MinioService 对全应用可见,任何模块的 Service 想用存储,直接构造函数注入就行,不用在每个模块的 imports 里逐个加 MinioModule。第六章里 UploadModule 就没列 MinioModule,但 UploadController 里能直接 private readonly minio: MinioService——就是全局模块的功劳。全局模块适合「基础设施级」依赖(数据库连接、存储客户端、HTTP 客户端、日志),业务模块不要随便 @Global()
  • exports: [MinioService] 但不 export MINIO_CLIENT:外部只看到 MinioService 这个封装层,看不到底层的 Client——这样换存储后端(MinIO 换 OSS)时只改 MinioService 内部,业务代码完全无感。MINIO_CLIENT 是封装的内部实现细节,不该泄漏出去。

注意MinioService 里用的是 @Inject(MINIO_CLIENT) private readonly client: Client——@Inject(token) 显式告诉 Nest「我这个参数按这个 token 找」。当依赖标识是字符串 token 时,@Inject 不能省;省了 Nest 会按参数的 TS 类型 Client 去找 class token,找不到就报依赖解析失败。这是自定义 token 最常踩的坑。

第三步:onModuleInit 建桶 + 匿名读策略——一次启动就自洽

新手用对象存储,桶都是人手进控制台建的。learnhub 不靠人手——应用启动时自己检查桶在不在、不在就建、顺手把访问策略设好。这件事写在 MinioService.onModuleInit 里:

// learnhub/src/modules/upload/minio.service.ts
@Injectable()
export class MinioService implements OnModuleInit {
  private readonly logger = new Logger(MinioService.name);
  private readonly bucket: string;

  constructor(
    @Inject(MINIO_CLIENT) private readonly client: Client,
    private readonly config: ConfigService,
  ) {
    this.bucket = this.config.get<string>('minio.bucket') || 'learnhub-assets';
  }

  async onModuleInit(): Promise<void> {
    try {
      const exists = await this.client.bucketExists(this.bucket);
      if (!exists) {
        await this.client.makeBucket(this.bucket);
        this.logger.log(`bucket 已创建:${this.bucket}`);
      }
      // 设匿名读:头像等资源可直接通过 URL 访问,不用每次签名
      try {
        await this.client.setBucketPolicy(this.bucket, publicReadPolicy(this.bucket));
      } catch {
        /* 部分 minio 版本策略语法差异,失败忽略,可去控制台手动设 */
      }
    } catch (e) {
      this.logger.warn(`MinIO 不可用,上传功能将不可用:${(e as Error).message}`);
    }
  }
}

OnModuleInit 是 Nest 的生命周期钩子——onModuleInit 在依赖注入完成之后、HTTP 服务接请求之前调一次(第四章讲 IoC 时讲过这一族钩子)。learnhub 把「初始化存储」放这里非常合适:此时 MINIO_CLIENT 已经注入、ConfigService 也能读,而应用还没开始接流量。

桶策略是一段 S3 标准的 JSON,learnhub 自己拼了一份:

// learnhub/src/modules/upload/minio.service.ts
function publicReadPolicy(bucket: string): string {
  return JSON.stringify({
    Version: '2012-10-17',
    Statement: [
      {
        Effect: 'Allow',
        Principal: { AWS: ['*'] },
        Action: ['s3:GetObject'],
        Resource: [`arn:aws:s3:::${bucket}/*`],
      },
    ],
  });
}

这段策略意思是「任何人都能 s3:GetObject 这个桶里的任何对象」——也就是匿名 GET 直接通过,浏览器打开 http://host/bucket/key 就能下载。这正是为什么第六章头像上传返回的 URL 浏览器打开能直接看到图:桶设了匿名读,不需要签名。换成阿里云 OSS 的术语,这叫「公共读」桶。

onModuleInit 里有两层 try/catch,这是值得专门讲的生产设计:

  • 外层 catch 软失败:MinIO 不可用时(容器没起来、网络断了、密钥错),只 logger.warn不抛。意思是——learnhub 启动不依赖 MinIO 在线。用户登录、刷帖子、看评论这些功能不碰存储,MinIO 挂了也得能用;只有头像上传会调用时报错。这是把「存储」和「核心业务」做依赖解耦:核心流程不能因为一个旁路依赖挂了就启动失败。
  • 内层 catch 软失败:设 bucket policy 这一步也可能挂(不同 MinIO 版本策略语法有差异),失败了也忽略、打日志、继续。桶已经建好了,最坏情况是文件传上去但 URL 访问不到,去控制台手动设就行。

思考:为什么 onModuleInit 不重试建桶?因为这里的设计是「尽力而为、首次启动一次性初始化」——MinIO 起来晚了几秒、这次没建成,下次重启应用会再试一次(bucketExists 还是 false 就再 makeBucket)。生产环境如果 MinIO 长期不可用,问题不应该靠应用层重试掩盖——应该有监控系统告警(learnhub 配合 Docker healthcheck + 外部监控)。在这里加个无限重试循环反而会让应用启动卡死、把「依赖挂了」这件事藏起来。失败可见、降级可控比「自动恢复」更值得追求。

第四步:两条上传通道——putObject 服务端代传 vs 预签名直传

这是和「写个玩具上传」差距最大的一节。learnhub 的 MinioService 同时提供两条上传通道,对应两类完全不同的场景。

服务端代传:putObject

第六章的头像上传走的就是这条——前端把文件 multipart 提交给 Nest,Nest 用 multer 读进 buffer,再由 MinioServicebuffer 写到 MinIO:

// learnhub/src/modules/upload/minio.service.ts
async putObject(
  objectName: string,
  buffer: Buffer,
  size: number,
  contentType: string,
): Promise<string> {
  await this.client.putObject(this.bucket, objectName, buffer, size, {
    'Content-Type': contentType,
  });
  return this.publicUrl(objectName);
}

client.putObject(bucket, key, buffer, size, metaData) 是 MinIO SDK 的方法,把一段 Buffer 一次性 PUT 到指定桶和对象名。metaData 里塞 Content-Type 是为了浏览器下载时知道按什么类型渲染(图片直接显示、PDF 走预览)。返回的 publicUrl 是拼出来的公开访问 URL(下面第五步细讲)。

这条通道的好处是简单可控——文件全程经过 Nest,能做大小校验、类型校验、扫病毒、生成缩略图、回写业务库(learnhub 头像上传完直接 userService.updateProfile 把 URL 写进 user 表)。代价也明显:整个文件进应用内存(第六章讲过 memoryStorage 的 OOM 风险),流量两次经过 Node(用户上传一次、Node 再传给 MinIO 一次)。头像这种 2MB 内的小文件没问题,一旦到几百 MB 的视频或海量并发上传,Node 直接被打爆。

预签名直传:presignedPutObject

大文件的正解是预签名 PUT。learnhub 的 MinioService 同样提供这个能力:

// learnhub/src/modules/upload/minio.service.ts
async presignedPut(objectName: string, expiresSeconds = 3600): Promise<string> {
  return this.client.presignedPutObject(this.bucket, objectName, expiresSeconds);
}

presignedPutObject(bucket, key, expires) 返回一个 URL——这个 URL 里带签名(query string 里有 X-Amz-SignatureX-Amz-CredentialX-Amz-Date 一堆参数),任何人拿到这个 URL 在过期时间之前 PUT 一个文件 body 上去,MinIO 都会接受、写到 bucket/key不需要 accessKey。前端只要拿到这个 URL,fetch(url, { method: 'PUT', body: file }) 直接传到 MinIO,完全不经过 Nest。

learnhub 暴露的接口长这样:

// learnhub/src/modules/upload/upload.controller.ts
@Post('presigned')
@ApiOperation({ summary: '申请预签名 PUT URL(前端直传)' })
async presigned(
  @Body() dto: PresignedUploadDto,
  @CurrentUser() user: RequestUser,
): Promise<{ objectName: string; presignedUrl: string; publicUrl: string }> {
  const ext = (dto.filename.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase();
  const objectName = `${dto.category}/${user.userId}/${Date.now()}${ext}`;
  const [presignedUrl] = await Promise.all([this.minio.presignedPut(objectName)]);
  return { objectName, presignedUrl, publicUrl: this.minio.publicUrl(objectName) };
}

请求体是一个 DTO:

// learnhub/src/modules/upload/dto/presigned-upload.dto.ts
export class PresignedUploadDto {
  @ApiProperty({ example: 'avatar', description: '用途分类,决定 object 前缀' })
  @IsString()
  @IsIn(['avatar', 'attachment', 'image'])
  category: 'avatar' | 'attachment' | 'image';

  @ApiProperty({ example: 'photo.png', description: '原始文件名(用于拼 object 名/扩展名)' })
  @IsString()
  filename: string;

  @ApiPropertyOptional({ description: '自定义 object 名前缀(可选)' })
  @IsOptional()
  @IsString()
  prefix?: string;
}

整个交互的时序是这样:

sequenceDiagram autonumber participant F as 前端 participant N as Nest 服务 participant M as MinIO F->>N: POST /upload/presigned(category、filename) N->>N: 拼 objectName、签 URL N-->>F: { objectName, presignedUrl, publicUrl } F->>M: PUT presignedUrl(body=file,不带 accessKey) M-->>F: 200 OK Note over F,M: 文件流量完全不经过 Nest

思考:注意这张图里,Nest 从头到尾没看到那个文件——它只签了个 URL、把 URL 给前端,文件是前端直接 PUT 到 MinIO 的。那 Nest 怎么知道这次上传到底传没传成功、传完之后要不要在业务库里记一条?这是预签名直传最大的工程坑,落地时通常两条路:(1) commit 接口——前端传完 MinIO 拿到 200 后,再调一个 POST /upload/commitobjectName 告诉 Nest,Nest 用 client.statObject 确认对象真的在 MinIO 里、再回写业务库;(2) MinIO 事件通知——MinIO 配置 bucket notification,对象创建时发 webhook 给 Nest,Nest 收到通知再处理。learnhub 目前没实现 commit 接口(这是留给后续迭代的口子)——预签名 URL 一旦发出去,业务库就再没跟踪。生产里如果上传完要回写库(比如头像换完要更新 user.avatar),要么前端补一个 commit 调用,要么干脆走服务端代传那条路。预签名直传省了带宽,但「上传完成」这件事需要你自己设计通知机制——这是它和服务端代传最大的体验差。

两条通道怎么选

不是「哪个更好」,是「各管哪段场景」:

维度服务端代传(putObject)预签名直传(presignedPut)
文件大小小文件(< 几 MB)合适大文件(视频、数据集)首选
经过 Nest 内存是(OOM 风险)否(Node 只签 URL)
经过 Nest 流量是(两跳)否(直打 MinIO)
业务可观测完整(拿到 buffer 啥都能做)弱(Nest 不见文件,需要 commit/webhook 补)
类型校验、扫毒当场能做做不了,或要事后异步处理
客户端复杂度简单(一个 multipart)复杂(先申请 URL、再 PUT、再 commit)
凭证暴露accessKey 在服务端,安全同样安全(URL 带签名、不带 accessKey)

learnhub 现在头像走代传(小文件、要回写 user 表、要做类型校验),/upload/presigned 给将来上大文件留口子——这是典型的「按场景分通道」,不要试图用一套方案覆盖所有情况。

第五步:公开读 vs 预签名 GET——两种 URL 各自的边界

对象传上去之后要能读出来。MinioService 提供两种 URL,对应两类访问场景:

// learnhub/src/modules/upload/minio.service.ts
/** 限时 GET URL(私有资源下载) */
async presignedGet(objectName: string, expiresSeconds = 3600): Promise<string> {
  return this.client.presignedGetObject(this.bucket, objectName, expiresSeconds);
}

/** 公开读 URL(bucket 已设匿名读时可直接访问) */
publicUrl(objectName: string): string {
  const host = this.config.get<string>('minio.endpoint');
  const port = this.config.get<number>('minio.port');
  return `http://${host}:${port}/${this.bucket}/${encodeURI(objectName)}`;
}

两个本质区别:

  • publicUrl纯字符串拼接——没有签名、不带 token,就是 http://host/bucket/key。能直接访问完全靠桶的匿名读策略(第三步设的那个 publicReadPolicy)。策略一关、URL 立刻失效。这种 URL 是「永久」的(对象不被删就一直能访问),适合头像、文章封面、商品图这类所有人可见的资源。
  • presignedGetSDK 签出来的临时 URL——带 X-Amz-Signature 等参数,过期作废(默认 1 小时)。不依赖桶策略,私有桶也能用。适合合同、病历、用户私有附件这类只允许特定人在限定时间看的资源;前端拿到 URL 临阵用掉,过期就废,泄露窗口小。

publicUrlencodeURI(objectName) 是为了处理对象名里的中文或空格——对象名带中文直接拼到 URL 里浏览器会拒绝,先编码成 %E4%B8%AD%E6%96%87 这种 percent-encoding 就稳了。

注意:匿名读桶(publicReadPolicy 那套)= 桶里任何对象的 URL 只要猜出来就能下。learnhub 的桶是匿名读的,因为头像本来就是公开的。但这意味着对象名一旦泄露,文件就被任意人下载——所以私有文档(合同、报表、用户上传的隐私文件)绝对不能和头像放同一个匿名桶。生产里通常是「公开桶放静态资源 + 私有桶放隐私数据」两个桶并存,按业务分类路由。如果一定要用一个桶,就关掉匿名读、所有 URL 都走 presignedGet 签名——但代价是每次访问都要后端签一次,CDN 缓存也难做。

对象名怎么取不会撞、不会被人猜

对象名(key)是开发时最容易随手写错的地方。反面教材:

// 反面教材:原文件名直接当 key(不要学)
const objectName = file.originalname;       // 用户传 a.png,下个用户也传 a.png 就覆盖了
const objectName = `uploads/${user.username}/${file.originalname}`;  // username 带中文/斜杠就崩

三个问题:重名覆盖(同文件名就撞)、可枚举(猜到 avatar/admin.png 就能扒管理员头像)、用户输入当 key 有路径穿越风险(originalname 塞个 ../../../etc/passwd 的话,虽然对象存储一般会规范化、但写法本身是错的)。learnhub 的写法是「用途 + 用户 id + 时间戳」:

// learnhub/src/modules/upload/upload.controller.ts
const ext = (file.originalname.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase();
const objectName = `avatar/${user.userId}/${Date.now()}${ext}`;
// 结果形如:avatar/3/1753000000000.png

几个关键决定:

  • category 在 DTO 里用 @IsIn(['avatar', 'attachment', 'image']) 收紧到枚举——前端只能传这三个之一,不能传任意字符串。这是把「对象名前缀」从用户输入里剥出来,杜绝 ../../xxx 这种 key 注入。
  • user.userId 是数字、从 JWT 解出来的、可信。按用户分目录的好处是 MinIO 控制台一眼看全用户头像、要按用户批量删也好做。
  • Date.now() 毫秒时间戳当文件名主体——同一用户同一毫秒传两个文件的几率低到可以忽略,不会撞、不会覆盖。要更严格可以换成 uuid
  • ext 用正则从 originalname 抠扩展名(不直接用整个 originalname)、强制小写——既保留了 a.PNG.png 这种归一化,又避免文件名里的特殊字符污染 key。

注意:对象名里任何来自用户输入的部分都要 sanitize。learnhub 只保留了 ext(受正则约束),把 originalnameprefix 这种用户可控字段全部排除在 key 之外——这是路径穿越(path traversal)防御的标准做法。如果你真的需要把用户名拼进 key(比如按用户名归档),必须先用正则或 sanitize-filename 这类库洗掉 /\.. 这些字符。

第六步:跑起来——两条路都走一遍

cd learnhub
docker compose -f docker/docker-compose.yml up -d minio   # 起对象存储
npm run start:dev                                          # 起 Nest

启动日志里你应该能看到这行(第一次启动时 bucket 不存在、被自动创建):

[MinioService] LOG bucket 已创建:learnhub-assets

第二次启动就不再打了——因为 bucketExists 返回 true,跳过 makeBucket。这就是第三步 onModuleInit 的效果。

先登录拿 token(第十一章细讲登录,这里先用):

curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

走服务端代传

这就是第六章那条路,一条命令:

curl -X POST http://localhost:3000/api/v1/upload/avatar \
  -H "Authorization: Bearer eyJ..." \
  -F "file=@avatar.jpg"
# {"url":"http://minio:9000/learnhub-assets/avatar/1/1753000000000.jpg"}

浏览器打开 url 直接看到图——桶是匿名读的、对象名带时间戳不会撞、user 表里 avatar 字段被回写成这个 URL。Nest 整条链路里拿到了文件 buffer、过 fileFilter、过 putObject、回写库——一切都在掌控之中。

走预签名直传

三步:

# 1. 申请预签名 PUT URL
curl -X POST http://localhost:3000/api/v1/upload/presigned \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"category":"image","filename":"photo.png"}'
# {"objectName":"image/1/1753000000000.png",
#  "presignedUrl":"http://minio:9000/learnhub-assets/image/1/...png?X-Amz-Algorithm=...&X-Amz-Signature=...",
#  "publicUrl":"http://minio:9000/learnhub-assets/image/1/1753000000000.png"}

# 2. 用预签名 URL 直接 PUT 到 MinIO(不带 accessKey、不经过 Nest)
curl -X PUT "<presignedUrl>" --data-binary @photo.png
# 200 OK

# 3. 用 publicUrl 访问
# 浏览器打开 http://minio:9000/learnhub-assets/image/1/1753000000000.png

注意第二步是 PUT、body 直接是文件二进制、不是 multipart——这是 S3 预签名协议硬性要求。前端用 axios.put(url, file)fetch(url, { method: 'PUT', body: file }) 即可,文件流直接进 MinIO,Nest 完全不经手。第四步那张时序图就是这件事。

注意:预签名 URL 默认 1 小时过期(learnhub presignedPutexpiresSeconds = 3600)。客户端拿 URL 之后必须在窗口内传完——大文件 + 慢网络的场景要把这个值调大(比如 expiresSeconds = 3600 * 6),否则传到一半 URL 过期、MinIO 直接 403。但也不能无脑开 7 天——URL 一旦泄露,泄露窗口就是有效期。1 小时是常见的平衡点,严肃场景会做更细的策略(比如分块上传每块单独签、用 createMultipartUpload 系列 API)。

这一章的成果

  1. 用 Docker 起了 MinIO,知道 9000 是 S3 API、9001 是控制台,MINIO_ROOT_USER/PASSWORD 就是 SDK 的 accessKey/secretKey
  2. 在真实 MinioModule 里吃透了自定义 DI token + useFactory 模式:用字符串 token 解决 CommonJS 导出的类型问题、用工厂拿到 ConfigService 造 client、@Global() 让基础设施级服务全局可见。
  3. 在真实 onModuleInit 里看懂了桶自动建 + 匿名读策略 + 软失败:MinIO 不可用时只 warn 不抛、应用照常启动,把「存储」和「核心业务」解耦。
  4. 厘清了两条上传通道:服务端代传(putObject,小文件、要校验、要回写库)和前端预签名直传(presignedPutObject,大文件、流量不过 Nest、但需要 commit/webhook 补业务可见性)。
  5. 学会了对象名设计category/userId/timestamp.ext 的分层既防撞又防猜、@IsIn 收紧 category 杜绝路径穿越、encodeURI 处理中文。
  6. 分清了 publicUrl(永久、靠桶策略、公开资源)和 presignedGet(限时、签名、私有资源)的边界,知道匿名桶的代价是「对象名一泄露就能下」。

常见问题

  • onModuleInit 没建桶、bucketExists 报错:MinIO 容器还没起来,logger.warn 会打 MinIO 不可用docker compose ps minio 看 status 是不是 healthy,等几秒重启 Nest 就好。
  • presignedPutObject 返回的 URL 浏览器打开是 403:预签名 URL 是给 PUT 用的,浏览器直接 GET 会签不匹配。要 GET 请用 presignedGetObject 签一个 GET URL,或对匿名桶用 publicUrl
  • 预签名 URL 一调就 SignatureDoesNotMatch:99% 是 SDK 用的 endPoint 和 MinIO 实际暴露的 host 不一致——比如 SDK 配的 127.0.0.1、客户端访问时走的是 minio 这个 Docker 内网域名,签名里的 Host header 对不上。开发期让 SDK 和客户端用同一个 host(都用 localhost:9000 或都用 minio:9000)。
  • 大文件传到一半 403:URL 过期了。expiresSeconds 调大,或上 S3 分块上传(createMultipartUpload / uploadPart / completeMultipartUpload,每块单独签)。
  • 公开桶怎么换私有setBucketPolicy 设成空字符串、或上控制台把 anonymous policy 删掉。之后所有 publicUrl 立刻 403,要访问改走 presignedGet
  • 换阿里云 OSS 改哪些endPoint 改成 oss-cn-xxx.aliyuncs.comaccessKey/secretKey 换 OSS 的、port 删掉(OSS 不用端口字段)、useSSL: true。业务代码(putObjectpresignedPut 这些)一行不改——这就是 S3 API 兼容的核心红利。
  • MINIO_CLIENT 注入报 Nest can't resolve dependencies@Inject(MINIO_CLIENT)@Inject 不能省,省了 Nest 按 class Client 找 token、找不到。自定义字符串 token 必须配 @Inject

下一章讲 RabbitMQ——消息队列,用来流量削峰、应用解耦。learnhub 发帖后会发一条 post.published 事件给搜索服务,就是走 RabbitMQ;这一章里「预签名直传后业务库没回写」的痛点,也可以用 MQ + MinIO 事件通知补齐。