Files
ONE/backend/internal/admin/file_refs_test.go
T
Sakurasan 1b3eb870da 文件删除前查引用:被文章/项目/站主头像用着就先挡住
- store.FileReferences(key) 反查谁在用这个文件:文章(封面 + content_md +
  content_html)、项目封面、settings.owner_avatar_key。匹配的键是 files.key 本身
  而不是完整 URL——本地 /uploads/{key}、R2 {publicBase}/{key}、缩略图
  /uploads/thumb/{key} 三种形态都以 key 结尾,换存储端后正文里的老链接照样查得到。
  草稿也算:现在没发布,删了将来发出来就是裂的。
- 按需 LIKE 现扫,不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处,
  计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。
- 新端点 GET /api/admin/files/{id}/refs;DELETE 默认对在用文件返回 409(消息带引用数
  和前三个位置名),明确带 force=1 才真删。守卫放在动 blob 之前——存储端一删就没法回头。
- 后台删除弹层先把引用清单摊出来(文章《标题》(草稿)、站主头像),仍然要删才带 force。
  引用查询失败就按无引用走:真被引用时后端会 409,不会静默删掉在用文件。

顺手把另一个 agent 工具的本地草稿目录(.zcode/、.zcodeignore)加进 .gitignore。
2026-09-30 02:03:14 +08:00

148 lines
4.4 KiB
Go

package admin
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
// seed 放一个本地 blob + 一行 files,再按 body 建一篇引用它的短文(body 为空表示不引用)。
func seed(t *testing.T, a *API, key, body string) model.File {
t.Helper()
if err := a.Blobs.Put(t.Context(), key, strings.NewReader("pngbytes"), 8, "image/png"); err != nil {
t.Fatal(err)
}
f, err := a.Store.CreateFile(model.File{
Key: key, Name: filepath.Base(key), Mime: "image/png",
Size: 8, SHA256: strings.Repeat("f", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
if body != "" {
if _, err := a.Store.Create(model.PostInput{
Kind: model.KindShort, Slug: "s-" + key, ContentMd: body, Status: model.StatusPublished,
}); err != nil {
t.Fatal(err)
}
}
return f
}
func TestFileRefsEndpoint(t *testing.T) {
a, h := newTestAPI(t)
a.Blobs = storage.NewLocal(t.TempDir())
f := seed(t, a, "2026/09/aaa.png", `看图 ![](/uploads/2026/09/aaa.png)`)
// 顺手把站主头像也指到同一张图,refs 要把这一路也报出来
if err := a.Store.SetSetting("owner_avatar_key", f.Key); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
var v struct {
Key string `json:"key"`
Count int `json:"count"`
Items []model.FileRef `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
if v.Key != f.Key || v.Count != 2 || len(v.Items) != 2 {
t.Fatalf("refs 不对: %+v", v)
}
var kinds = map[string]bool{}
for _, x := range v.Items {
kinds[x.Kind] = true
}
if !kinds["post"] || !kinds["avatar"] {
t.Fatalf("缺引用类型: %+v", v.Items)
}
// 不存在的文件:404 而不是空列表
rec = doAs(t, h, http.MethodGet, "/api/admin/files/9999/refs", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
// 乱七八糟的子路径不收
rec = doAs(t, h, http.MethodGet, "/api/admin/files/1/nope", "")
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
// 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。
func TestFileDeleteBlockedByRefs(t *testing.T) {
a, h := newTestAPI(t)
dir := t.TempDir()
a.Blobs = storage.NewLocal(dir)
f := seed(t, a, "2026/09/bbb.png", `![](/uploads/2026/09/bbb.png)`)
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String())
}
var e struct {
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatal(err)
}
if !strings.Contains(e.Error, "1 处引用") {
t.Fatalf("错误消息没报引用数: %q", e.Error)
}
if _, err := os.Stat(filepath.Join(dir, "2026/09/bbb.png")); err != nil {
t.Fatalf("被挡住时不该动存储: %v", err)
}
if _, err := a.Store.GetFile(f.ID); err != nil {
t.Fatalf("被挡住时不该删行: %v", err)
}
}
// 明确带 force=1 才真删:行、blob 一起走。
func TestFileDeleteForceProceeds(t *testing.T) {
a, h := newTestAPI(t)
dir := t.TempDir()
a.Blobs = storage.NewLocal(dir)
f := seed(t, a, "2026/09/ccc.png", `![](/uploads/2026/09/ccc.png)`)
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
}
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("行应已删除, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "2026/09/ccc.png")); !os.IsNotExist(err) {
t.Fatalf("blob 应已删除, got %v", err)
}
}
// 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。
func TestFileDeleteUnreferenced(t *testing.T) {
a, h := newTestAPI(t)
a.Blobs = storage.NewLocal(t.TempDir())
f := seed(t, a, "2026/09/ddd.png", "")
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
}
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("行应已删除, got %v", err)
}
}