对象存储与直传上传
presigned 直传到 Cloudflare R2,开发环境使用本地 Provider。
App API 的上传流程集成了真实对象存储:客户端向服务端申请 presigned URL, 把文件字节直接上传到存储,然后确认。文件内容永远不经过 Next.js 进程, 生产文件保存在 Cloudflare R2——而不是应用服务器的磁盘上。
提供的能力
StorageProvider抽象与两个实现:R2(生产)和本地磁盘(仅开发)。POST /api/app/v1/uploads——校验类型/大小后,为服务端生成的对象 Key 签发直传 URL。POST /api/app/v1/uploads/complete——确认对象存在、执行大小上限,返回可用 URL。幂等。- 按用户划分的 Key 前缀,用于归属校验和删除账户时的清理。
重要文件
| 文件 | 作用 |
|---|---|
src/server/app/uploads/uploadsService.ts | 校验、Key 生成、归属、complete |
src/server/app/uploads/storage/StorageProvider.ts | Provider 接口 |
src/server/app/uploads/storage/r2StorageProvider.ts | Cloudflare R2(S3 兼容)Provider |
src/server/app/uploads/storage/localStorageProvider.ts | 仅开发环境的本地 Provider |
src/app/api/app/v1/uploads/local/route.ts | 仅开发环境的 presigned PUT 目标(生产返回 404) |
流程
申请 presigned URL
客户端发送 contentType、size 和可选的 filename。服务端按白名单校验
MIME 类型(jpeg/png/webp/gif,用于头像图片)和 5 MB 大小上限,然后在
uploads/<userId>/… 下生成不可预测的对象 Key——客户端永远无法指定 Key。
直接上传到存储
响应包含 uploadUrl、method 和客户端必须原样携带的 headers。R2 的
presigned PUT 会把 Content-Type 和 Content-Length 绑定进签名,上传
与声明不符的类型或更大的对象会在存储层直接失败。URL 5 分钟过期。
调用 complete 确认
携带 objectKey 调用 POST /uploads/complete。服务端校验 Key 属于当前用户
(前缀检查——他人的 Key 返回 404,绝不暗示是否存在)、读取对象元数据、若
超限则删除对象,最后返回公开 URL。只有确认过的对象才对业务可见。
选择 Provider
STORAGE_PROVIDER 决定实现——并且没有静默 fallback:
| 取值 | 行为 |
|---|---|
local(默认) | 通过仅开发环境的 /uploads/local 路由写入 public/ 下。生产环境拒绝启动并给出明确错误。 |
r2 | Cloudflare R2。缺少 R2_* 配置会在首次使用时抛出明确的配置错误。 |
R2 配置
STORAGE_PROVIDER=r2
R2_ACCOUNT_ID=…
R2_ACCESS_KEY_ID=…
R2_SECRET_ACCESS_KEY=…
R2_BUCKET=…
R2_PUBLIC_BASE_URL=https://cdn.example.comR2_PUBLIC_BASE_URL 是用于构建对象 URL 的公开基址(自定义域名或
r2.dev)。在 Cloudflare 控制台创建 Bucket、对其具有对象读写权限的 API
Token,以及公开的自定义域名。
孤儿对象清理交给 Bucket 生命周期规则。 已签发但从未确认的上传会留下
无引用的对象。请配置 R2 生命周期规则(例如:uploads/ 下创建数天后仍未被
应用确认的对象自动删除),而不是在应用里加清理任务。删除账户会按前缀清理
该用户的全部对象——见账户删除。
旧的 Web 路由 POST /api/storage/upload-image(见
文件上传与存储)仍写入本地磁盘,只适合开发阶段。
需要持久化上传时请使用本页的 App API 流程——它才是接入 R2 的那条链路。
