Go + Vue 管理后台接入 Cloudflare R2:把上传、AI 生成和外部素材统一保存

  1. 1. 问题背景:本地文件能保存,但还不够可管理
  2. 2. 先划分两个边界:对象和素材
    1. 2.1. storage 只负责对象存储
    2. 2.2. assets 负责素材元数据和权限
  3. 3. 用 AWS S3 SDK 对接 Cloudflare R2
  4. 4. 为什么管理员配置要保存到数据库
  5. 5. 数据库迁移:用一张表统一管理素材
  6. 6. 计划书上传如何接入 R2
  7. 7. AI 结果也走同一套素材入口
  8. 8. 下载为什么默认使用预签名 URL
  9. 9. 管理后台提供哪些能力
  10. 10. 安全和边界处理
  11. 11. 验证方式
  12. 12. 常见误区
    1. 12.1. 把 Access Key ID 和 Secret Access Key 填反
    2. 12.2. 只配置环境变量,却忘记启用开关
    3. 12.3. 把 Bucket 直接设为公开
    4. 12.4. 以为旧本地文件会自动迁移
  13. 13. 可复用检查清单

一个支持用户上传文件和 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
2
3
4
5
6
7
8
9
10
11
12
13
用户上传 / AI 生成 / 外部获取
|
v
storage.Service.Put
|
Cloudflare R2
或本地回退目录
|
v
storage_assets 元数据
|
v
Owner 管理、权限校验、下载链接

先划分两个边界:对象和素材

实现中没有让计划书处理器、AI Worker 和管理员页面各自维护一套 S3 逻辑,而是拆成两个职责清晰的包。

storage 只负责对象存储

server/storage/storage.go 提供统一的对象操作:

  • Put:写入对象;
  • Open:读取本地对象或 R2 对象;
  • Delete:删除对象;
  • URL:生成公开 URL 或预签名 URL;
  • Test:执行 Bucket HeadBucket 检查。

它并不关心这个对象是计划书还是 AI 结果。只要调用方提供对象 key、大小、MIME 类型和内容,就可以使用同一套接口。

assets 负责素材元数据和权限

server/assets/assets.go 新增了 storage_assets 表和素材接口。每一条素材至少包含:

1
2
3
4
5
6
7
8
user_id       所属用户
plan_id 可选的关联计划书
source upload / ai_generated / fetched
name 展示名称
object_key R2 或本地对象 key
mime_type MIME 类型
size_bytes 文件大小
metadata JSON 字符串

这样,存储服务可以保持通用,而业务层仍然能够按用户、计划书和来源查询素材。

用 AWS S3 SDK 对接 Cloudflare R2

Cloudflare R2 提供 S3 兼容 API,因此 Go 服务使用 AWS SDK v2 的 S3 客户端。R2 配置模型如下,敏感字段只作为服务端默认值或加密后的数据库配置使用:

1
2
3
4
5
6
7
8
9
10
11
type R2Config struct {
Enabled bool
AccountID string
Endpoint string
Bucket string
AccessKeyID string
SecretAccessKey string
PublicURL string
Region string
ForcePathStyle bool
}

如果只设置 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
2
3
4
5
6
storage_enabled
storage_endpoint
storage_bucket
storage_public_url
storage_region
storage_force_path_style

两个凭据使用应用已有的 APP_ENCRYPTION_KEY 通过 AES-GCM 加密后保存:

1
2
storage_access_key_id
storage_secret_access_key

后台的 GET /api/v1/admin/settings/storage 只返回 has_credentialsconfiguredusing_r2 等状态,不返回任何密钥明文。更新时 Access Key 或 Secret 留空表示保持现有值,只有显式设置 clear_credentials=true 才会清除凭据。

生产环境需要显式设置一个 32 字节的 APP_ENCRYPTION_KEY。开发环境仍然可以使用项目内置的开发密钥,但不能把它用于生产数据。

数据库迁移:用一张表统一管理素材

PostgreSQL 和 MySQL 分别新增了 004_create_storage_assets.sql,表结构保持一致,迁移文件中的多个语句使用项目约定的 -- statement-breakpoint 分隔。

PostgreSQL 的核心字段如下:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE storage_assets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id BIGINT NULL REFERENCES business_plans(id) ON DELETE SET NULL,
source VARCHAR(24) NOT NULL DEFAULT 'upload',
name VARCHAR(255) NOT NULL,
object_key VARCHAR(512) NOT NULL UNIQUE,
mime_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

object_key 设置唯一约束,避免同一个对象被重复记录;user_idcreated_at 建立索引,满足用户素材列表和管理员时间倒序列表的查询需求。

计划书上传如何接入 R2

计划书接口仍然使用原来的 multipart/form-data,调用路径变成:

1
2
3
4
5
6
7
8
9
10
ParseMultipartForm
|
v
storage.Service.Put
|
v
INSERT business_plans
|
v
INSERT storage_assets(source=upload)

这里有一个重要的顺序:先写对象,再写数据库记录。如果数据库记录失败,服务会尝试删除刚写入的对象,减少孤儿文件。

对象 key 不直接使用用户原始文件名,而是增加用户、来源和时间前缀,并清理路径字符:

1
users/42/upload/1723100000000000000-business-plan.pdf

原始文件名仍然作为展示名称保存,但不会直接参与本地路径拼接,从而避免路径穿越和特殊字符造成的文件名问题。

AI 结果也走同一套素材入口

AI Worker 原本只把分析 JSON 写入 analysis_jobs.result。现在在分析任务成功后,还会调用:

1
2
3
4
5
6
7
8
9
10
11
assets.Save(
ctx,
userID,
&planID,
"ai_generated",
fmt.Sprintf("analysis-%d.json", jobID),
"application/json",
int64(len(payload)),
metadata,
bytes.NewReader(payload),
)

将来抓取外部资料时,可以使用完全相同的入口,把 source 改为 fetched。这样 AI 生成文件和外部获取文件不会再需要另一套上传、权限和删除逻辑。

下载为什么默认使用预签名 URL

R2 Bucket 默认不要求公开访问。storage.Service.URL 的策略是:

  1. 配置了 R2_PUBLIC_URL 时,返回 CDN 的公开 URL;
  2. 没有公开 URL 但启用了 R2 时,生成 15 分钟有效的预签名 URL;
  3. 使用本地回退存储时,返回应用自己的鉴权下载路由。

用户只能读取自己的素材,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
2
make check
make test

其中包含:

  • 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 连接和下载逻辑。

投喂小莫
给快要饿死的小莫投喂点零食吧~
投喂小莫
分享
分享提示信息