一个支持用户上传文件和 AI 分析的应用,真正需要保存的通常不止一份原始文件:用户上传的计划书、AI 生成的结果、外部获取的资料,最终都应该有统一的对象存储位置、元数据、权限和下载方式。
本文以一个 Go net/http + Vue 3 + PostgreSQL/MySQL 应用的实际实现为例,记录如何在保留本地存储回退能力的前提下接入 Cloudflare R2,并把 R2 配置和素材管理放进 Owner 管理后台。
问题背景:本地文件能保存,但还不够可管理
原有上传逻辑直接把文件写入 UPLOAD_DIR,数据库中的 business_plans.object_key 只记录本地文件名。这种方式在单机开发阶段很直接,但部署到容器或多实例环境后会出现几个现实问题:
- 文件跟着容器本地磁盘走,实例扩缩容后不一定还能读到;
- 计划书之外的 AI 生成文件和外部素材没有统一记录模型;
- 管理员无法查看素材来源、大小和所属用户;
- 私有文件下载不能简单地把 Bucket 暴露为公开目录;
- R2 配置写死在环境变量里时,修改配置通常需要重新发布服务。
因此这次实现的目标不是把 os.WriteFile 换成一个 SDK 调用,而是补齐一条完整的数据链路:
1 | 用户上传 / AI 生成 / 外部获取 |
先划分两个边界:对象和素材
实现中没有让计划书处理器、AI Worker 和管理员页面各自维护一套 S3 逻辑,而是拆成两个职责清晰的包。
storage 只负责对象存储
server/storage/storage.go 提供统一的对象操作:
Put:写入对象;Open:读取本地对象或 R2 对象;Delete:删除对象;URL:生成公开 URL 或预签名 URL;Test:执行 BucketHeadBucket检查。
它并不关心这个对象是计划书还是 AI 结果。只要调用方提供对象 key、大小、MIME 类型和内容,就可以使用同一套接口。
assets 负责素材元数据和权限
server/assets/assets.go 新增了 storage_assets 表和素材接口。每一条素材至少包含:
1 | user_id 所属用户 |
这样,存储服务可以保持通用,而业务层仍然能够按用户、计划书和来源查询素材。
用 AWS S3 SDK 对接 Cloudflare R2
Cloudflare R2 提供 S3 兼容 API,因此 Go 服务使用 AWS SDK v2 的 S3 客户端。R2 配置模型如下,敏感字段只作为服务端默认值或加密后的数据库配置使用:
1 | type R2Config struct { |
如果只设置 R2_ACCOUNT_ID 而没有显式设置 Endpoint,服务会按下面的规则构造 S3 Endpoint:
1 | https://<account-id>.r2.cloudflarestorage.com |
Region 默认使用 auto。需要注意的是,R2 的 Access Key ID 和 Secret Access Key 是两个不同字段,不能把 Secret 当成 Access Key 填入后台。
为什么管理员配置要保存到数据库
环境变量仍然保留,适合容器启动时提供默认值;管理员页面保存的配置则写入已有的 app_settings 表,且数据库值优先于环境默认值。
非敏感配置直接保存:
1 | storage_enabled |
两个凭据使用应用已有的 APP_ENCRYPTION_KEY 通过 AES-GCM 加密后保存:
1 | storage_access_key_id |
后台的 GET /api/v1/admin/settings/storage 只返回 has_credentials、configured 和 using_r2 等状态,不返回任何密钥明文。更新时 Access Key 或 Secret 留空表示保持现有值,只有显式设置 clear_credentials=true 才会清除凭据。
生产环境需要显式设置一个 32 字节的 APP_ENCRYPTION_KEY。开发环境仍然可以使用项目内置的开发密钥,但不能把它用于生产数据。
数据库迁移:用一张表统一管理素材
PostgreSQL 和 MySQL 分别新增了 004_create_storage_assets.sql,表结构保持一致,迁移文件中的多个语句使用项目约定的 -- statement-breakpoint 分隔。
PostgreSQL 的核心字段如下:
1 | CREATE TABLE storage_assets ( |
object_key 设置唯一约束,避免同一个对象被重复记录;user_id 和 created_at 建立索引,满足用户素材列表和管理员时间倒序列表的查询需求。
计划书上传如何接入 R2
计划书接口仍然使用原来的 multipart/form-data,调用路径变成:
1 | ParseMultipartForm |
这里有一个重要的顺序:先写对象,再写数据库记录。如果数据库记录失败,服务会尝试删除刚写入的对象,减少孤儿文件。
对象 key 不直接使用用户原始文件名,而是增加用户、来源和时间前缀,并清理路径字符:
1 | users/42/upload/1723100000000000000-business-plan.pdf |
原始文件名仍然作为展示名称保存,但不会直接参与本地路径拼接,从而避免路径穿越和特殊字符造成的文件名问题。
AI 结果也走同一套素材入口
AI Worker 原本只把分析 JSON 写入 analysis_jobs.result。现在在分析任务成功后,还会调用:
1 | assets.Save( |
将来抓取外部资料时,可以使用完全相同的入口,把 source 改为 fetched。这样 AI 生成文件和外部获取文件不会再需要另一套上传、权限和删除逻辑。
下载为什么默认使用预签名 URL
R2 Bucket 默认不要求公开访问。storage.Service.URL 的策略是:
- 配置了
R2_PUBLIC_URL时,返回 CDN 的公开 URL; - 没有公开 URL 但启用了 R2 时,生成 15 分钟有效的预签名 URL;
- 使用本地回退存储时,返回应用自己的鉴权下载路由。
用户只能读取自己的素材,Owner 可以读取全部素材。管理员页面拿到的下载地址同样经过 Owner 权限检查后生成。这样既能让大文件不经过 Go API 中转,也不会为了方便下载而公开整个 Bucket。
管理后台提供哪些能力
新增 /admin/storage 页面,并加入管理员侧边栏。页面包含两部分:
- R2 配置表单:启用开关、Endpoint、Bucket、Region、Public URL、凭据和连接测试;
- 素材对象表格:按用户上传、AI 生成、外部获取筛选,查看用户 ID、MIME 类型、大小、创建时间,并执行下载和删除。
对应的 Owner API 是:
| 方法 | 路径 | 用途 |
|---|---|---|
GET |
/api/v1/admin/settings/storage |
获取脱敏后的 R2 状态 |
PATCH |
/api/v1/admin/settings/storage |
保存 R2 配置 |
POST |
/api/v1/admin/settings/storage/test |
测试 Bucket 连接 |
GET |
/api/v1/admin/assets |
分页查看所有素材 |
DELETE |
/api/v1/admin/assets/{id} |
删除对象和元数据 |
同时提供了 /admin/settings/r2 兼容别名,便于已经把供应商名称写进客户端的调用方迁移。
安全和边界处理
对象存储接入最容易遗漏的不是 SDK 初始化,而是权限和失败边界。这次实现额外处理了几类问题:
- R2 凭据不返回前端,数据库中使用 AES-GCM 加密;
- Owner API 通过用户上下文检查
role=owner; - 写入、更新和删除 API 要求
X-Requested-With: XMLHttpRequest,阻挡跨站 HTML 表单伪造; - multipart 请求在解析前使用
http.MaxBytesReader限制完整请求体,单文件最大 20 MiB; - 下载文件名会清理换行、引号和反斜杠,避免污染
Content-Disposition; - R2 关闭时可以使用本地回退,R2 已启用但配置不完整时则直接报错,不静默把数据写到错误位置;
- 默认使用私有预签名 URL,而不是要求 Bucket 公共读写。
验证方式
这次实现实际运行了仓库提供的检查命令:
1 | make check |
其中包含:
go vet ./...;go test -race ./...;- Vue
vue-tsc类型检查; - Vite 生产构建。
另外还用 docker compose config 检查了生产和开发 Compose 文件,确认新增的 R2 环境变量语法有效。
本地存储服务也增加了回归测试,验证对象写入、读取、删除和 R2 未完整配置时的失败行为。
常见误区
把 Access Key ID 和 Secret Access Key 填反
后台应该分别填写两个字段。Secret Access Key 通常比 Access Key ID 更长,填反后 SDK 初始化看似成功,但真正上传时会被 R2 拒绝。
只配置环境变量,却忘记启用开关
服务只有在 R2_ENABLED=true,并且 Endpoint、Bucket 和两个凭据都齐全时才会使用 R2。否则会继续本地回退,或者在显式启用但配置不完整时返回 storage_not_configured。
把 Bucket 直接设为公开
如果素材含有用户商业计划或 AI 生成的私有资料,优先保持 Bucket 私有,让服务生成短时预签名 URL。只有明确需要 CDN 公开访问的对象才配置 R2_PUBLIC_URL。
以为旧本地文件会自动迁移
本次改造保证新上传和新生成对象进入统一素材模型,但不会自动扫描旧 UPLOAD_DIR 并上传到 R2。已有本地数据如果需要迁移,应单独设计一次性迁移脚本,并先核对对象 key、数据库记录和删除策略。
可复用检查清单
-
APP_ENCRYPTION_KEY已配置为 32 字节随机值; - R2 Endpoint、Bucket、Region 来自同一个账户;
- Access Key ID 和 Secret Access Key 没有填反;
- Owner 后台保存配置后,先执行 Bucket 连接测试;
- 私有资料没有误配置公开 CDN URL;
- 上传接口限制了完整 multipart 请求体大小;
- 对象写入和数据库元数据写入失败时都有清理策略;
- AI 生成和外部获取的文件复用了素材服务,而不是新写一套存储逻辑;
-
go test -race ./...和前端生产构建都通过; - 旧本地文件迁移计划与新对象存储上线计划分开验证。
这次改造的核心不是“换一个文件 SDK”,而是把对象存储、素材元数据、管理员配置、权限和失败处理放进同一个可验证的业务边界里。等后续增加图片、音频或抓取任务时,只需要复用 assets.Service.Save,不需要再复制一套 R2 连接和下载逻辑。