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 从.env读MINIO_ACCESS_KEY/MINIO_SECRET_KEY注入,默认值learnhub_minio/learnhub_minio_secret只够本地玩,生产必须换强密钥、走环境变量。command: server /data --console-address ":9001"是 MinIO 容器的启动命令:把/data作为存储目录(挂到minio-datavolume 持久化),控制台监听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 对象,所有桶操作(putObject、presignedPutObject、bucketExists…)都是这个 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: Client:Client是minio这个包导出的一个 class,按 class token 注入也能跑,但 learnhub 选了字符串 token,原因是——minio包用的是export = Client(CommonJS 默认导出),TypeScript 在按 class 注入时偶尔会因为模块导出形状报类型不匹配;字符串 token 把「依赖标识」和「实现类」彻底解耦,Nest 注入只看 token 匹配,类型问题绕开了。第四章讲 provider 的时候讲过这种「非 class token」的用法。 useFactory而不是useValue/useClass:Client的构造参数要从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]但不 exportMINIO_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,再由 MinioService 把 buffer 写到 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-Signature、X-Amz-Credential、X-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;
}
整个交互的时序是这样:
思考:注意这张图里,Nest 从头到尾没看到那个文件——它只签了个 URL、把 URL 给前端,文件是前端直接 PUT 到 MinIO 的。那 Nest 怎么知道这次上传到底传没传成功、传完之后要不要在业务库里记一条?这是预签名直传最大的工程坑,落地时通常两条路:(1) commit 接口——前端传完 MinIO 拿到 200 后,再调一个 POST /upload/commit 把 objectName 告诉 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 是「永久」的(对象不被删就一直能访问),适合头像、文章封面、商品图这类所有人可见的资源。presignedGet是SDK 签出来的临时 URL——带X-Amz-Signature等参数,过期作废(默认 1 小时)。不依赖桶策略,私有桶也能用。适合合同、病历、用户私有附件这类只允许特定人在限定时间看的资源;前端拿到 URL 临阵用掉,过期就废,泄露窗口小。
publicUrl 里 encodeURI(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(受正则约束),把 originalname、prefix 这种用户可控字段全部排除在 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 presignedPut 的 expiresSeconds = 3600)。客户端拿 URL 之后必须在窗口内传完——大文件 + 慢网络的场景要把这个值调大(比如 expiresSeconds = 3600 * 6),否则传到一半 URL 过期、MinIO 直接 403。但也不能无脑开 7 天——URL 一旦泄露,泄露窗口就是有效期。1 小时是常见的平衡点,严肃场景会做更细的策略(比如分块上传每块单独签、用 createMultipartUpload 系列 API)。
这一章的成果
- 用 Docker 起了 MinIO,知道
9000是 S3 API、9001是控制台,MINIO_ROOT_USER/PASSWORD就是 SDK 的accessKey/secretKey。 - 在真实
MinioModule里吃透了自定义 DI token +useFactory模式:用字符串 token 解决 CommonJS 导出的类型问题、用工厂拿到ConfigService造 client、@Global()让基础设施级服务全局可见。 - 在真实
onModuleInit里看懂了桶自动建 + 匿名读策略 + 软失败:MinIO 不可用时只warn不抛、应用照常启动,把「存储」和「核心业务」解耦。 - 厘清了两条上传通道:服务端代传(
putObject,小文件、要校验、要回写库)和前端预签名直传(presignedPutObject,大文件、流量不过 Nest、但需要 commit/webhook 补业务可见性)。 - 学会了对象名设计:
category/userId/timestamp.ext的分层既防撞又防猜、@IsIn收紧 category 杜绝路径穿越、encodeURI处理中文。 - 分清了
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 内网域名,签名里的Hostheader 对不上。开发期让 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.com、accessKey/secretKey换 OSS 的、port删掉(OSS 不用端口字段)、useSSL: true。业务代码(putObject、presignedPut这些)一行不改——这就是 S3 API 兼容的核心红利。 MINIO_CLIENT注入报Nest can't resolve dependencies:@Inject(MINIO_CLIENT)的@Inject不能省,省了 Nest 按 classClient找 token、找不到。自定义字符串 token 必须配@Inject。
下一章讲 RabbitMQ——消息队列,用来流量削峰、应用解耦。learnhub 发帖后会发一条 post.published 事件给搜索服务,就是走 RabbitMQ;这一章里「预签名直传后业务库没回写」的痛点,也可以用 MQ + MinIO 事件通知补齐。