Files
ONE/backend/internal/storage/storage.go
T
Sakurasan 34f0faedcc 文件上传:R2 对象存储 + 后台文件管理 + 编辑器/封面联动
存储抽象(internal/storage,新包)
- BlobStore 接口(Put / Open / Delete)+ 两个实现:R2(aws-sdk-go-v2 走
  R2 的 S3 兼容 API,path-style、region auto)与本地磁盘(DataDir/uploads,
  零配置兜底)。选择:Endpoint / S3Api / Bucket / AccessKey / SecretAccessKey
  齐全 → R2,缺任一项回落本地并在启动日志提示缺失的字段名(只报名字不报值)
- 端点 URL 里的路径段不交给 SDK:path-style 下它会被折进对象 key,
  导致「数据库 key」和「实际对象 key」对不上(直链 404,实测复现)。
  EndpointKeyPrefix 提取路径段给上传 handler 拼进 key,StripEndpointPath
  只取 scheme://host 给 SDK——数据库 / 存储端 / 直链三方一致

数据与 API
- files 表:id / key(唯一) / name / mime / size / sha256 / store(r2|local) /
  created_at;URL 不入库,按「store 来源 + PublicBase」响应时解析,
  切存储端不破坏存量链接
- POST /api/admin/files:multipart 多文件,单文件 ≤50MB(MaxBytesReader 64MB);
  类型白名单 = 图片(jpg/png/webp/gif/avif)+ 附件(pdf/zip/txt),
  扩展名 + http.DetectContentType 双重校验(实测拦截随机字节改名 .png),
  SVG 拒绝(同源脚本);内容 sha256 做 key(2026/09/{哈希前12位}{扩展名}),
  同内容重复上传自动去重复用
- GET /api/admin/files(分页 + 文件名搜索)、DELETE /{id}(先删对象再删行,
  存储端失败保留行可重试)
- 公开路由 GET /uploads/{key}(main.go 挂载):按 key 查行、存储层流式返回,
  Cache-Control immutable + ETag 304;R2 + PublicBase 时 302 直链(后端不出流量)
- URL 解析:FileURL(store, key, PublicBase)——R2 且配了公开域名走直链,
  否则 /uploads/ 流式

后台文件管理页(FilesView,「工作台 → 文件」)
- 点击 / 拖拽多选上传(uploadFiles 走 FormData 裸 fetch,401 广播与
  request() 一致);缩略图卡片网格(图片出图、其他出类型占位);
  复制链接(clipboard,非 https 回落 prompt)/ 打开 / 删除(确认提示);
  分页、loading/empty 沿用既有模式

编辑器联动(EditorView)
- 封面:URL 输入框旁「上传」按钮,选图自动填 cover_url
- wysiwyg:Crepe ImageBlock 官方 onUpload 钩子——粘贴 / 拖拽 / 插图
  全部走上传,返回 URL 后由 Crepe 插节点
- Markdown 模式:插图弹层加「上传」按钮 + 编辑器粘贴 / 拖拽图片,
  上传后在原光标处插入 ![](url)(异步上传先记光标位,逐张追踪偏移)

.env.example
- 模板入库(无敏感值):Endpoint = 公开访问域名(直链)、S3Api = 上传端点、
  Bucket / AccessKey / SecretAccessKey

验证
- 本地兜底全流程:上传 201、同内容去重复用、随机字节改名 .png 被
  内容嗅探拒绝、.svg 拒绝、公开路由 immutable 缓存头 + 内容一致、
  删除后存储与公开路由双清 404
- R2 真实链路(站主 .env):上传 store=r2、直链 200(过程中定位并修复
  S3Api 路径段折进 key 导致的直链 404,见 EndpointKeyPrefix)
- 后端 go build/test/vet 全绿;前端构建通过
2026-09-27 16:26:11 +08:00

55 lines
2.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package storage 是文件上传的存储抽象。R2(S3 兼容 API)是第一个实现,
// 本地磁盘是零配置兜底;以后接其他存储端 = 再写一个 BlobStore 实现,
// 从 FromEnv 的选择逻辑里加一个分支,调用方(admin API / 公开路由)不变。
//
// URL 不在存储层决定:文件行里记 store 来源,读取时按「来源 + PublicBase」
// 解析直链或后端代理路由,因此切换存储端不破坏存量链接。
package storage
import (
"context"
"io"
"net/url"
"strings"
)
// BlobStore 是对象存储的最小面。size 只是提示(S3 PutObject 需要预知长度,
// 用临时文件或内存缓冲满足),实现方不得依赖它做校验——类型与大小上限
// 在上传 handler 里统一把关。
type BlobStore interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, key string) error
}
// FileURL 解析文件的公开访问地址:R2 上的文件且配了公开域名(PublicBase,
// 尾部不带 /)走 CDN 直链;其余(本地兜底、或没配公开域名)走后端的
// /uploads/key 流式路由。调用方传入文件行里的 store 来源。
func FileURL(storeName, key, publicBase string) string {
if storeName == "r2" && publicBase != "" {
return publicBase + "/" + key
}
return "/uploads/" + key
}
// EndpointKeyPrefix 提取端点 URL 里的路径段(去首尾斜杠)作为上传 key 的
// 前缀:S3Api 写成 https://host/oss 时,对象 key 会带上 oss/ 前缀,
// 数据库 / 直链 / 存储端三方都用这个前缀对齐。没有路径段返回空串。
func EndpointKeyPrefix(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return ""
}
return strings.Trim(u.Path, "/")
}
// StripEndpointPath 去掉端点 URL 的路径,只留 scheme://host[:port]:
// SDK 的 BaseEndpoint 用它(路径段已经折进 key 前缀,见 EndpointKeyPrefix)。
func StripEndpointPath(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return endpoint
}
return u.Scheme + "://" + u.Host
}