# 把主题素材放到对象存储(任意 S3 兼容) 这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、 阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4, 差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** —— 主题生成器里「素材托管」就是这两选一。 现状:主题素材在 `static/images/themes//`,通过 `static/fs.go` 的 `//go:embed images/themes` 随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**, 而且一张 1920×1080 的场景图会让二进制大几百 KB。 主题素材只有一处引用:**主题 CSS 里的 `url(...)`**(`--tb-page-image` / `--tb-page-pattern` / `--tb-deco`)。分享卡是 Go 里用 `imaging` 画出来的,不读主题素材。所以只要解决「CSS 里的地址指向哪」, 就把整套素材搬走了。 下面三种做法按「改动量从 0 到有代码」排列,选一个即可,不必叠加。 --- ## A. 生成器直接写绝对地址 + 就近上传(已支持,推荐) `dev/theme-builder` 左侧「**素材托管**」有两个选项:本地(随二进制)/ S3 兼容存储。 选 S3 后填: ``` Endpoint https://<账号>.r2.cloudflarestorage.com ← MinIO / B2 / OSS / COS 填各自的 Region auto ← R2 用 auto;MinIO 常为 us-east-1 桶名 my-theme-assets 路径前缀 themes 公开前缀 https://cdn.example.com/themes ← 留空 = 按 Endpoint 推导 Access Key / Secret Key ``` 然后: 1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致) → 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `` 翻成人话。 2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>//.webp`, 带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。 3. **检测远端** —— `ListObjectsV2` 列出 `<前缀>//` 下所有对象,按对象键名跟本地比: 一致 / 旧图(同名但大小不同)/ 多余(当前主题已不引用)/ 缺失。 可以「补齐差异」(只传缺的与旧图)、「删除多余」,也可以单行覆盖或删除。 上传经本机 `serve.py` 代理(`/api/s3/test`、`/api/s3/upload`),浏览器不直连对象存储: **不需要给桶配 CORS**,密钥只在当前标签页的 sessionStorage 与内存里, 不进草稿、不进工程 json、不进导出 zip。 不想用面板也行,导出的 zip 里图片照旧在 `static/images/themes//*.webp`, 用任何 S3 客户端推上去即可: ```bash aws s3 sync static/images/themes/shiroko/ s3://my-bucket/themes/shiroko/ \ --endpoint-url https://<你的 Endpoint> --cache-control "public, max-age=31536000, immutable" sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \ static/css/themes/shiroko.css # 已有主题不想重导时,等价于「填前缀再导一次」 ``` - 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中); - 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换); - 仓库里可以删掉 `static/images/themes//` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。 --- ## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改) `conf/app.ini`: ```ini [theme] asset_base = https://cdn.example.com/themes ; 留空 = 本地静态目录 ``` 中间件必须在 `flamego.Static` **之前**注册(`internal/route/route.go` 里 `f.Use(flamego.Static(...))` 前面): ```go // 主题素材转发到对象存储:命中前缀就 301 出去,其余请求原样放过。 // 必须挂在 static 中间件之前——Static 命中本地文件就直接返回了,轮不到这里。 func themeAssetRedirect() flamego.Handler { const prefix = "/static/images/themes/" return func(ctx flamego.Context) { base := theme.AssetBase() // 从 conf 读,空串 = 不接管 p := ctx.Request().URL.Path if base == "" || !strings.HasPrefix(p, prefix) { return } w := ctx.ResponseWriter() w.Header().Set("Cache-Control", "public, max-age=86400") http.Redirect(w, ctx.Request(), base+strings.TrimPrefix(p, prefix), http.StatusMovedPermanently) } } ``` - 优点:已有主题、生成器导出的相对地址**都能用**,切 CDN 只改配置; - 缺点:冷缓存时每张图多一跳;**301 会被浏览器长期缓存**,以后想切回本地, 老访客可能还在访问 CDN(介意的话用 `302`,或一开始就用方案 A); - 本地开发记得把 `asset_base` 留空,否则断网时素材全 404。 --- ## C. 边缘 / 反代改写(零代码) 素材路径有稳定前缀,最适合在边缘处理: - **Cloudflare**:Origin Rule / Transform Rule,把 `/static/images/themes/*` 的 Host 改成 R2 自定义域(如 `cdn.example.com`),或 Redirect Rule 直接 301 过去; - **nginx**:`location /static/images/themes/ { proxy_pass https://cdn.example.com/themes/; }`, 或 `rewrite` 成 CDN 地址后 `return 301`。 - 优点:应用完全不知道素材在哪,不占应用带宽; - 缺点:绑定部署环境,本地跑没有等效层(要考虑 fallback)。 --- ## 三种方案共同要处理的细节 1. **缓存**:对象存储上建议 `Cache-Control: public, max-age=31536000, immutable`(文件名带版本时)。 换图**不要覆盖同名对象**——要么换 `id`(生成器里改文件名),要么给 URL 加 `?v=`; Cloudflare 默认把 query string 计入缓存键,所以 `?v=` 是有效的。 2. **CORS 不用配**:`background-image` 不触发 CORS,匿名 GET 即可。 3. **主题 CSS 本身不用上 CDN**:一个主题只有几 KB,而且随 `StaticVersion` 走缓存键, 留在 `static/css/themes/` 更省事。 4. **深色模式没有素材**(深色块 `--tb-deco` 恒为 `none`),别只传浅色图就以为漏传了。 5. **分享卡不受影响**:`route/question/share.go` 用 `imaging` 直接画,不加载主题图片。 6. 迁完之后 `go build` 会小一圈,但**主题 css 仍是编译期 embed**:改 CSS 还是要重编译部署。 --- ## 建议 - 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。 - 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。 - 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。 - 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。