文件上传: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 全绿;前端构建通过
This commit is contained in:
Sakurasan
2026-09-27 16:26:11 +08:00
parent bc1b0ccce3
commit 34f0faedcc
18 changed files with 1191 additions and 15 deletions
+117
View File
@@ -86,6 +86,16 @@ func (s *Store) migrate() error {
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS files (
id %s,
key TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
store TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL
)`, ai),
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -114,6 +124,8 @@ func (s *Store) migrate() error {
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
{"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`},
{"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -1391,3 +1403,108 @@ func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error
*dst = out
return nil
}
// ---------- files ----------
func scanFile(sc interface{ Scan(...any) error }) (model.File, error) {
var f model.File
err := sc.Scan(&f.ID, &f.Key, &f.Name, &f.Mime, &f.Size, &f.SHA256, &f.Store, &f.CreatedAt)
return f, err
}
func (s *Store) CreateFile(f model.File) (model.File, error) {
f.CreatedAt = now()
q := s.db.Q(`INSERT INTO files (key,name,mime,size,sha256,store,created_at) VALUES (?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt).Scan(&id)
if err != nil {
return model.File{}, err
}
} else {
res, err := s.db.Exec(q, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt)
if err != nil {
return model.File{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.File{}, err
}
}
f.ID = id
return f, nil
}
// ListFiles 按创建时间倒序分页,q 模糊匹配原始文件名 / key。
func (s *Store) ListFiles(page, size int, q string) (model.FilePage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
if query := strings.TrimSpace(q); query != "" {
where = ` WHERE name LIKE ? OR key LIKE ?`
like := "%" + query + "%"
args = append(args, like, like)
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM files`+where), args...).Scan(&total); err != nil {
return model.FilePage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files`+
where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.FilePage{}, err
}
defer rows.Close()
out := []model.File{}
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return model.FilePage{}, err
}
out = append(out, f)
}
return model.FilePage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
func (s *Store) GetFile(id int64) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
func (s *Store) GetFileByKey(key string) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE key = ?`), key))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
// DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。
func (s *Store) DeleteFile(id int64) (model.File, error) {
f, err := s.GetFile(id)
if err != nil {
return model.File{}, err
}
res, err := s.db.Exec(s.db.Q(`DELETE FROM files WHERE id = ?`), id)
if err != nil {
return model.File{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return model.File{}, ErrNotFound
}
return f, nil
}