从旧对象存储到 Cloudflare R2:博客下载专区升级实践

12 min read313 字项目
分享:

一、为什么要升级下载专区

博客原来的下载专区已经能够展示资源卡片并提供文件下载,但文件长期依赖单一的旧对象存储。随着资源数量和文件体积增加,这套方案逐渐出现几个问题:

  • 存储容量和流量扩展不够灵活;
  • 上传流程与博客发布流程耦合,维护成本较高;
  • 历史文件很难一次性重新上传或搬迁;
  • 如果直接替换全部链接,任何迁移遗漏都会让已有下载失效。

这次升级的目标不是重新做一个下载网站,而是在原有博客和发布台基础上更换文件基础设施:博客中的入口、页面布局和资源卡片保持不变,新上传的文件改为进入 Cloudflare R2,发布台继续负责资源管理,博客继续负责公开展示。

换句话说,用户看到的仍是原来的下载专区,变化主要发生在后台。

二、升级后的整体架构

升级后的数据流如下:

发布台选择文件或文件夹
        ↓
服务端生成短期有效的预签名上传地址
        ↓
浏览器将文件直接上传到 Cloudflare R2
        ↓
发布台更新 GitHub 仓库中的资源清单
        ↓
GitHub 触发 Vercel 重新构建博客
        ↓
博客在原下载专区展示资源
        ↓
访问者通过 R2 自定义文件域名下载

这里最重要的设计决定是:文件内容和资源信息分开管理。

  • 大文件保存在 R2;
  • 标题、描述、分类、大小、上传时间和对象路径保存在 JSON 资源清单;
  • 博客构建时读取资源清单并生成下载卡片;
  • 资源清单只保存对象键和公开地址,不保存任何访问密钥。

这样既避免把大文件提交到 Git 仓库,也保留了资源变更的版本记录。

三、为什么使用浏览器直传 R2

一种常见做法是先把文件上传到应用服务器,再由服务器转存到对象存储。这个方案实现简单,但会让服务器同时承担上传流量、内存占用和执行时间压力,对大文件尤其不友好。

本次升级改为预签名 URL 直传:

  1. 已登录的发布台向服务端提交文件名、大小和类型;
  2. 服务端校验参数后,使用仅存在于服务端的 R2 凭据生成短期 PUT 地址;
  3. 浏览器直接把二进制文件上传到 R2;
  4. 上传成功后,发布台再提交标题、分类和对象键,更新资源清单。

服务端不再中转文件本体,因此能够显著降低部署平台的带宽和执行时间压力。预签名地址只有较短有效期,并且只允许写入指定对象,风险也比把长期密钥交给浏览器低得多。

文件夹上传则先在浏览器端打包为 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 更新资源清单:

  1. 读取仓库中的资源清单和当前文件版本;
  2. 将新资源插入列表;
  3. 提交更新后的 JSON 文件;
  4. GitHub 提交触发 Vercel 的生产部署;
  5. 新资源随博客构建自动出现在下载专区。

删除资源时顺序相反:先更新清单,再删除 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”,而是把上传、元数据、部署和下载分成边界清晰的模块,同时给旧数据留下安全退路。基础设施升级真正困难的部分,往往不是接入新服务,而是在不中断现有使用的情况下完成切换。