从旧对象存储到 Cloudflare R2:博客下载专区升级实践
一、为什么要升级下载专区
博客原来的下载专区已经能够展示资源卡片并提供文件下载,但文件长期依赖单一的旧对象存储。随着资源数量和文件体积增加,这套方案逐渐出现几个问题:
- 存储容量和流量扩展不够灵活;
- 上传流程与博客发布流程耦合,维护成本较高;
- 历史文件很难一次性重新上传或搬迁;
- 如果直接替换全部链接,任何迁移遗漏都会让已有下载失效。
这次升级的目标不是重新做一个下载网站,而是在原有博客和发布台基础上更换文件基础设施:博客中的入口、页面布局和资源卡片保持不变,新上传的文件改为进入 Cloudflare R2,发布台继续负责资源管理,博客继续负责公开展示。
换句话说,用户看到的仍是原来的下载专区,变化主要发生在后台。
二、升级后的整体架构
升级后的数据流如下:
发布台选择文件或文件夹
↓
服务端生成短期有效的预签名上传地址
↓
浏览器将文件直接上传到 Cloudflare R2
↓
发布台更新 GitHub 仓库中的资源清单
↓
GitHub 触发 Vercel 重新构建博客
↓
博客在原下载专区展示资源
↓
访问者通过 R2 自定义文件域名下载这里最重要的设计决定是:文件内容和资源信息分开管理。
- 大文件保存在 R2;
- 标题、描述、分类、大小、上传时间和对象路径保存在 JSON 资源清单;
- 博客构建时读取资源清单并生成下载卡片;
- 资源清单只保存对象键和公开地址,不保存任何访问密钥。
这样既避免把大文件提交到 Git 仓库,也保留了资源变更的版本记录。
三、为什么使用浏览器直传 R2
一种常见做法是先把文件上传到应用服务器,再由服务器转存到对象存储。这个方案实现简单,但会让服务器同时承担上传流量、内存占用和执行时间压力,对大文件尤其不友好。
本次升级改为预签名 URL 直传:
- 已登录的发布台向服务端提交文件名、大小和类型;
- 服务端校验参数后,使用仅存在于服务端的 R2 凭据生成短期 PUT 地址;
- 浏览器直接把二进制文件上传到 R2;
- 上传成功后,发布台再提交标题、分类和对象键,更新资源清单。
服务端不再中转文件本体,因此能够显著降低部署平台的带宽和执行时间压力。预签名地址只有较短有效期,并且只允许写入指定对象,风险也比把长期密钥交给浏览器低得多。
文件夹上传则先在浏览器端打包为 ZIP,再复用同一套直传流程。这样下载者得到的是结构完整的压缩包,上传端也不需要为每个小文件分别维护记录。
四、资源模型如何兼容新旧存储
为了避免一次迁移导致历史下载中断,资源记录同时支持两个字段:
interface ResourceRecord {
title: string
description: string
fileName: string
size: number
objectKey?: string
publicUrl?: string
uploadedAt: string
}objectKey表示文件在 R2 中的对象路径;publicUrl可以保存已经存在的完整下载地址;- 新资源优先根据
objectKey生成 R2 下载地址; - 历史资源在尚未完成搬迁时,继续使用原来的
publicUrl。
下载地址统一通过一个配置函数生成:
const baseUrl = process.env.NEXT_PUBLIC_DOWNLOAD_BASE_URL
export function buildDownloadUrl(objectKey: string) {
const encodedKey = objectKey
.replace(/^\/+/, "")
.split("/")
.map(encodeURIComponent)
.join("/")
return `${baseUrl}/${encodedKey}`
}对每一段路径分别编码很重要。直接对完整路径调用 encodeURIComponent 会把斜杠也编码掉,导致对象存储无法正确识别层级。
此外还保留了一个环境变量开关,用于控制是否强制让已有资源改走 R2。只有确认历史对象已经全部搬迁并完成校验后,才开启全局切换。
五、发布台与博客如何同步
发布台除了上传文件,还需要把新资源同步给博客。这里使用 GitHub Contents API 更新资源清单:
- 读取仓库中的资源清单和当前文件版本;
- 将新资源插入列表;
- 提交更新后的 JSON 文件;
- GitHub 提交触发 Vercel 的生产部署;
- 新资源随博客构建自动出现在下载专区。
删除资源时顺序相反:先更新清单,再删除 R2 对象。实现时还应考虑失败补偿,避免出现“清单还在但文件已删除”的状态。
这套流程的好处是,发布者只在一个后台完成操作,不必分别登录对象存储、代码仓库和部署平台。
六、权限设计:只给完成任务所需的权限
对象存储和代码仓库都采用最小权限原则。
R2 凭据只允许对指定存储桶执行对象读写,不授予账户级管理权限;访问密钥只配置在部署平台的服务端环境变量中,绝不进入浏览器代码或 Git 仓库。
GitHub 使用细粒度访问令牌,并限制为:
- 只能访问博客资源清单所在的单个仓库;
- 仅开启仓库内容读写;
- 不授予用户资料、组织管理、工作流管理等无关权限。
发布台使用的关键环境变量包括:
R2_ACCOUNT_ID
R2_BUCKET
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_PUBLIC_BASE_URL
BLOG_GITHUB_TOKEN
BLOG_GITHUB_BRANCH
RESOURCE_MANIFEST_PATH这些变量应分别配置到生产和预览环境,并在修改后重新部署。客户端可见变量中只允许出现公开下载基地址,不能出现任何密钥。
七、CORS 配置中的关键细节
由于浏览器从发布台域名直接向 R2 发起 PUT 请求,R2 必须配置 CORS。这里不应使用完全开放的通配符,而应限制到实际发布台来源。
示意配置如下:
[
{
"AllowedOrigins": ["https://your-publisher.example"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]需要注意,CORS 中填写的是发布台的来源,而不是博客的公开访问地址,也不是 R2 的下载域名。下载是公开 GET,上传则必须来自受信任的后台页面,两者用途不同。
八、历史文件迁移为什么不能冒进
这次升级中,历史资源的源站不允许服务端直接拉取,原始压缩包在本地也没有可验证副本。如果强行把资源清单切换到新的 R2 地址,结果只会是链接指向尚不存在的对象。
因此最终采用渐进式迁移:
- 新上传资源全部进入 R2;
- 历史资源继续使用旧地址,保证现有下载不受影响;
- 找到原文件后,再上传到对应的 R2 对象路径;
- 对文件大小、哈希值和下载状态进行校验;
- 全部确认无误后,才更新历史清单或开启全局切换。
这是一次基础设施升级,不应该以牺牲已有功能为代价。双路径兼容虽然多了一层判断,却换来了更安全的迁移窗口。
九、上线前后的验证清单
本次升级分别在代码、部署和外部访问三个层面进行了验证。
代码层:
- ESLint 检查通过;
- TypeScript 类型检查通过;
- Next.js 生产构建通过;
- 资源清单缺失或格式错误时能够安全回退为空列表。
部署层:
- 博客和发布台均部署到原有项目;
- 原有域名继续指向对应生产部署;
- 服务端环境变量已在重新部署后生效;
- GitHub 推送能够正常触发博客构建。
访问层:
- 原下载专区返回正常;
- 发布台登录和资源管理页面正常;
- R2 自定义下载域名能够读取测试对象;
- 历史资源继续指向原地址;
- 新资源生成的公开地址指向 R2。
十、这次升级带来的结果
完成升级后,下载专区在用户视角几乎没有变化,但后台能力已经不同:
- 新文件不再占用应用服务器的上传带宽;
- 存储容量可以按对象存储方式扩展;
- 发布、展示和下载形成自动化链路;
- 资源信息仍由 Git 管理,可审计、可回滚;
- 历史文件拥有明确的兼容与迁移路径;
- 各项凭据都被限制在完成任务所需的最小范围。
这次实践最值得保留的经验,不是简单地“把链接换成 R2”,而是把上传、元数据、部署和下载分成边界清晰的模块,同时给旧数据留下安全退路。基础设施升级真正困难的部分,往往不是接入新服务,而是在不中断现有使用的情况下完成切换。