文件存储
默认用 R2 保存上传,Worker 经 /uploaded 代理读取。改存储配置后,在业务 API 里调用统一上传函数即可。
Saavo 模板已经提供统一的文件上传和访问能力,头像、工单图片、通知配图以及 OAuth 客户端 Logo 等文件都通过同一套文件服务进行存储和读取。
开发自己的产品时,通常不需要直接操作 R2 或 KV。完成文件存储配置后,即可在需要上传文件的业务接口中调用 uploadFileOrThrow。大文件直传或给私有对象发短时下载地址时,再用 createR2TemporaryUploadUrl 和 createR2TemporaryDownloadUrl。
已有功能
初始化模板后,文件存储的以下功能可直接使用,流程如下:
| 功能 | 默认值 | 说明 |
|---|---|---|
| 默认后端 | R2 | 在 config/deploy.ts 的 upload.storage.default 配置 |
| 文件读取代理 | /uploaded | Worker 通过该路径读取对象并返回文件 |
| 大小上限 | 5 MiB | 配置路径为 upload.maxSize |
| 应用生成 R2 直连地址 | 关闭 | 默认通过 Worker 的 /uploaded 读取 |
| MIME 白名单 | 图片、常用文档、压缩包、音视频 | 不包含 HTML、JS、SVG |
| 临时上传链接 | 无现成 HTTP 路由 | createR2TemporaryUploadUrl,需 Access Key |
| 临时下载链接 | 无现成 HTTP 路由 | createR2TemporaryDownloadUrl,需 Access Key |
文件存储依赖于在 wrangler.jsonc 里声明的 MAIN_R2 和 MAIN_KV 运行时绑定。常规表单上传使用 Worker Binding,无需将 R2 的 Access Key 传入上传接口。
虽然项目中声明了 R2_ACCOUNT_ID、R2_ACCESS_KEY_ID 与 R2_SECRET_ACCESS_KEY,但 src/libs/storage 里的上传和 /uploaded 读取不会用到它们。头像、工单图等现成能力只依赖 Worker Binding。这三个凭证只在签发临时上传 / 下载链接时才需要。
先体验文件上传
修改配置之前,可以先用现成的功能确认存储可用:
- 登录后在账号中心上传头像
- 提交一张带图片的工单
- 浏览器打开返回的
/uploaded/...地址,确认能访问
![]()
本地开发时,R2 会通过 Wrangler 中的 MAIN_R2 Binding 提供给应用。遇到上传失败时,先确认绑定和开发服务器日志是否正常,再检查业务代码。
配置文件存储
文件上传相关配置集中在 config/deploy.ts 的 upload 配置中,可以根据实际存储方式和上传需求进行调整。
upload: {
maxSize: 5 * 1024 * 1024,
delivery: {
proxyPath: '/uploaded',
cache: {
public: 'public, max-age=31536000, immutable',
},
},
storage: {
default: 'r2',
kv: {
defaultTTL: false,
},
r2: {
publicBaseUrl: false,
},
},
}上面这些字段改 config/deploy.ts。Bucket、KV 的 ID 改 wrangler.jsonc,见下一节。
上线前需要处理的事项:
maxSize:这是uploadFileOrThrow的全局上限,默认 5 MiB。按产品允许的最大文件调整。工单、通知配图还有各自更小的限制,把这里调大不会自动放宽那些业务上限。proxyPath:没有特殊需求就保持/uploaded。Worker 会按这个值自动挂读取路由,不必再改src/pages/routes.tsx。一旦改了前缀,数据库和富文本里已经存下来的旧 URL 不会自动更新。publicBaseUrl:默认是false,表示应用只生成 Worker 地址。只有绑定并启用 R2 Custom Domain 后才填写 URL。它不会关闭 Cloudflare 上已有的r2.dev或自定义域。
storage.default 决定所有普通上传使用 KV 还是 R2,上传接口不能单独覆盖。默认值是 r2,现成的头像、工单图片、通知配图都会写入 R2。
选择 KV 后,可以通过 storage.kv.defaultTTL 设置默认 TTL,也可以在单次上传时通过 expiresIn 设置文件自己的有效期。R2 没有对象级自动删除:传入 expiresIn 后,Worker 会在到期后拒绝代理读取,但对象仍会留在 Bucket 里,需要另行安排清理任务。
准备存储服务
生产环境必须把 wrangler.jsonc 里的 MAIN_R2、MAIN_KV 换成你自己账户下的 Bucket 和 KV Namespace。仓库里预填的 ID 是模板示例,部署到你的账户时无效或会指到别人的资源。
默认文件上传写入 MAIN_R2。MAIN_KV 仍然要换成自己的,因为工单正文、日志等内容也用它;将 storage.default 改成 kv 后,普通上传的文件才会写入 MAIN_KV。
生产若要让应用返回 R2 公开地址:先绑定自定义域并确认 HTTPS 正常,再填写 storage.r2.publicBaseUrl。不需要生成公开地址时保持 false;若要让 Bucket 真正不再公开,还要在 Cloudflare R2 中关闭 r2.dev 并移除自定义域。
本地开发通过 Worker 的 MAIN_R2 Binding 上传即可,普通表单上传不需要 R2 Access Key。只有签发临时上传 / 下载链接时,才需要配置 R2_ACCOUNT_ID、R2_ACCESS_KEY_ID 和 R2_SECRET_ACCESS_KEY。
MAIN_R2 配错时,头像、工单图片、通知配图会上传失败或打不开。MAIN_KV 配错时,默认走 R2 的文件上传不受影响,但工单正文、日志等 KV 数据会出问题。
在业务中存取文件
业务上传写在已认证、已授权的 API 里,使用统一函数:
import { resolveFetchWorkerCtx } from '@/ctx';
import { uploadFileOrThrow, purgeFileOrThrow } from '@/libs/storage';
const ctx = resolveFetchWorkerCtx(c);
const uploaded = await uploadFileOrThrow(ctx, {
file,
path: `user/${userId}`,
name: 'photo.png',
});默认返回相对地址 /uploaded/{key}。业务需要绝对地址时可以传入 urlMode: 'absolute'。
永久保存的 R2 对象在配置了 publicBaseUrl 后,生产环境会返回该公开地址。临时对象必须经过 Worker 检查有效期,因此仍返回当前网站的 /uploaded/{key} 绝对地址。调用方不能自行开启 R2 公开访问。
删除对象:
await purgeFileOrThrow(ctx, {
store: uploaded.store,
key: uploaded.key,
});生成临时上传链接和下载链接
uploadFileOrThrow 会把文件先传到 Worker,再写入存储。文件较大、或对象不应经 /uploaded 公开读取时,可以签发短时有效的 R2 预签名 URL,让浏览器或调用方直接对 Bucket 读写。
这两个函数在 src/libs/utils/r2-presigned-url.ts:
createR2TemporaryUploadUrl:签发 PUT 上传链接createR2TemporaryDownloadUrl:签发 GET 下载链接
它们不是现成的 HTTP 路由,要在你自己的已认证 API 里调用,把结果返回给前端。链接指向 *.r2.cloudflarestorage.com,不走 /uploaded,也不走 publicBaseUrl。
签发需要 R2 S3 API 凭证,从 Worker 环境读取,不要返回给前端:
const credentials = {
accountId: workerCtx.env.R2_ACCOUNT_ID,
bucketName: workerCtx.env.R2_BUCKET_NAME,
accessKeyId: workerCtx.env.R2_ACCESS_KEY_ID,
secretAccessKey: workerCtx.env.R2_SECRET_ACCESS_KEY,
};R2_BUCKET_NAME 在 wrangler.jsonc 的 vars 里。另外三个写在 .env / Cloudflare Secret,字段名与 example.vars 一致。凭证缺失或格式不对时,签名函数会抛错。expiresInSeconds 必须是 1~604800 之间的整数,单位为秒,即最短 1 秒、最长 7 天。
签名函数不会套用 upload.maxSize 和 MIME 白名单。签发前要在自己的 API 里做完登录、授权、路径、类型和大小校验。key 不能以 / 开头,也不能包含 ..。
临时上传链接
PUT 链接会把文件大小、Content-Type 和 SHA-256 签进请求头,并带上 if-none-match: *,已有同名对象时不能覆盖。sha256 必须是 64 位小写十六进制。
import { createR2TemporaryUploadUrl } from '@/libs/utils/r2-presigned-url';
const upload = await createR2TemporaryUploadUrl({
...credentials,
key: `user/${userId}/photo.png`,
expiresInSeconds: 600,
contentType: 'image/png',
sizeBytes: fileSize,
sha256: fileSha256Hex,
});把 method、url、headers 返回给前端。客户端必须用返回的请求头原样 PUT,请求体就是文件本身:
await fetch(upload.url, {
method: upload.method,
headers: upload.headers,
body: file,
});浏览器直传还要在 R2 Bucket 上配置 CORS,允许你的站点 Origin 发送 PUT,并放行签名用到的请求头。服务端代为 PUT 则不需要 CORS。
临时下载链接
GET 链接只绑定对象 key 和过期时间,适合私有对象的短时下载。拿到链接的人在过期前都能读取,TTL 尽量短。
import { createR2TemporaryDownloadUrl } from '@/libs/utils/r2-presigned-url';
const download = await createR2TemporaryDownloadUrl({
...credentials,
key: `user/${userId}/photo.png`,
expiresInSeconds: 60,
});客户端对 download.url 发 GET 即可,不必再带额外请求头。
文件路径中禁止包含 ..。返回给前端的应当是文件代理路径、配置好的 R2 公开地址,或上述短时链接;不要直接暴露内部 Object Key。账户级的存储凭证也只能留在服务端,不能返回给前端或写入公开文档。
工单图片上传已经提供了完整的实现示例,可以直接参考现有流程。新增文件上传功能时,应继续复用现有的认证和权限控制,不要另外创建一个绕过这些检查的通用上传接口。
上线检查
存储会直接影响用户内容,上线前建议至少确认:
-
MAIN_R2/MAIN_KV已换成自己的 Cloudflare 资源。 - 不公开 R2 时,
storage.r2.publicBaseUrl保持false,Cloudflare 中的r2.dev和自定义域也已关闭。 - 头像或业务上传可以成功,并通过
/uploaded或配置好的 R2 公开地址访问。 - 超过
maxSize或使用 HTML / JS / SVG 的文件会被拒绝。 - 删除路径会真正清除对象。
- 上传接口要求登录,且只能写当前用户自己的路径。
- 若使用临时链接:三个 R2 Secret 已配置,签发接口已鉴权,返回给前端的只有
url和必要请求头。
建议用普通账号实际上传一次,不要只在本地 mock File 对象。
常见问题
接下来
根据接下来要开发的功能,可以继续阅读:
- 想为上传功能建立业务接口 → 从业务表到用户数据 API
- 想看工单如何存图片 → 工单与支持
- 想配置 Cloudflare 资源 → 创建 Cloudflare 资源
大多数产品走完本章后,只需要换成自己的 Bucket;确实需要公开直连 R2 时,再配置独立文件域名。
真正接收业务文件时,在受保护的 API 里调用 uploadFileOrThrow 即可。需要直传或私有下载时,再包一层临时链接签发接口。