diff --git a/.gitignore b/.gitignore index bf8601e..110188d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ data/ # 开发进程日志 / pid .run/ +# 其他 agent 工具的本地草稿(计划、忽略清单),不进仓库 +.zcode/ +.zcodeignore + # 二进制 one-server backend/one-server diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 3dcfd1e..696921a 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -894,13 +894,27 @@ func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error) } func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { - id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/")) + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/") + idPart, sub := rest, "" + if i := strings.Index(rest, "/"); i >= 0 { + idPart, sub = rest[:i], rest[i+1:] + } + // /files/{id} 与 /files/{id}/refs 两种形态,别的一律不收 + if sub != "" && (sub != "refs" || r.Method != http.MethodGet) { + httpx.BadRequest(w, "bad file path") + return + } + id, err := parseInt(idPart) if err != nil { httpx.BadRequest(w, "bad file id") return } switch r.Method { case http.MethodGet: + if sub == "refs" { + a.fileRefs(w, r, id) + return + } f, err := a.Store.GetFile(id) if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) @@ -922,6 +936,15 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } + // 正文/封面/项目/站主头像里还在用就先挡住,除非明确带 force=1。 + // 检查放在删对象之前:一旦 blob 删了就没法回头。 + if refs, err := a.Store.FileReferences(f.Key); err != nil { + httpx.ServerError(w, err) + return + } else if len(refs) > 0 && httpx.QueryString(r, "force") != "1" { + httpx.Error(w, http.StatusConflict, fileInUseMessage(refs)) + return + } // 先删对象存储再删行:存储端失败时行保留,可以重试 if err := a.Blobs.Delete(r.Context(), f.Key); err != nil { httpx.ServerError(w, err) @@ -940,6 +963,49 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { } } +// fileRefs 报告这个文件被谁引用,供前端在删除弹层里列出来。 +func (a *API) fileRefs(w http.ResponseWriter, r *http.Request, id int64) { + f, err := a.Store.GetFile(id) + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + if err != nil { + httpx.ServerError(w, err) + return + } + refs, err := a.Store.FileReferences(f.Key) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"key": f.Key, "count": len(refs), "items": refs}) +} + +// fileInUseMessage 是挡住删除时回给调用方的一句话,只列前三个引用。 +func fileInUseMessage(refs []model.FileRef) string { + names := make([]string, 0, 3) + for _, x := range refs { + if x.Kind == "avatar" { + names = append(names, "站主头像") + continue + } + t := x.Title + if t == "" { + t = x.Slug + } + names = append(names, "《"+t+"》") + if len(names) == 3 { + break + } + } + label := strings.Join(names, "、") + if len(refs) > len(names) { + label += " 等" + } + return fmt.Sprintf("该文件正被 %d 处引用:%s,删除后这些位置会失效(确认要删带 force=1)", len(refs), label) +} + // ---------- comments(评论审核与管理) ---------- func (a *API) adminComments(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/admin/file_refs_test.go b/backend/internal/admin/file_refs_test.go new file mode 100644 index 0000000..56a5008 --- /dev/null +++ b/backend/internal/admin/file_refs_test.go @@ -0,0 +1,147 @@ +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) + } +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 1459305..4442356 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -147,6 +147,16 @@ type FilePage struct { Size int `json:"size"` } +// FileRef 是「谁在用这个文件」的一处记录,供删除前的引用检查。 +// Kind 取 post / project / avatar;avatar 没有可跳转的行,ID 为 0。 +type FileRef struct { + Kind string `json:"kind"` + ID int64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Status string `json:"status"` +} + // CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。 type CommentPage struct { Items []Comment `json:"items"` diff --git a/backend/internal/store/file_refs_test.go b/backend/internal/store/file_refs_test.go new file mode 100644 index 0000000..a309d86 --- /dev/null +++ b/backend/internal/store/file_refs_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "testing" + + "oneblog/internal/model" +) + +func countKind(refs []model.FileRef, kind string) int { + n := 0 + for _, r := range refs { + if r.Kind == kind { + n++ + } + } + return n +} + +// 同一个 key 在正文里的三种形态都要认出来:R2 直链、本地 /uploads、缩略图路径。 +func TestFileReferencesMatchesKeyNotURL(t *testing.T) { + s := openTestStore(t) + const key = "2026/09/deadbeefcafe.png" + + if _, err := s.Create(model.PostInput{ + Kind: model.KindLong, Title: "直链引用", Slug: "r2", + CoverURL: "https://cdn.example.com/" + key, + ContentMd: "正文配图 ![图](https://cdn.example.com/" + key + ")", + Status: model.StatusPublished, + }); err != nil { + t.Fatal(err) + } + if _, err := s.Create(model.PostInput{ + Kind: model.KindShort, Slug: "local", + ContentMd: "本地形态 ![](/uploads/" + key + ") 和缩略图 ![](/uploads/thumb/" + key + "?w=480)", + Status: model.StatusDraft, + }); err != nil { + t.Fatal(err) + } + + refs, err := s.FileReferences(key) + if err != nil { + t.Fatal(err) + } + // 两篇文章各算一处:同一行里多次命中不重复计 + if len(refs) != 2 { + t.Fatalf("want 2 refs, got %+v", refs) + } + if countKind(refs, "post") != 2 { + t.Fatalf("两条都该是 post: %+v", refs) + } + // 草稿也要报出来(发布后就会图裂) + if refs[1].Status != model.StatusDraft { + t.Fatalf("草稿状态没带出来: %+v", refs[1]) + } +} + +func TestFileReferencesProjectsAndAvatar(t *testing.T) { + s := openTestStore(t) + const key = "2026/09/111122223333.webp" + + if _, err := s.CreateProject(model.ProjectInput{ + Title: "ONE", Slug: "one", CoverURL: "/uploads/" + key, Status: model.StatusPublished, + }); err != nil { + t.Fatal(err) + } + if err := s.SetSetting("owner_avatar_key", key); err != nil { + t.Fatal(err) + } + + refs, err := s.FileReferences(key) + if err != nil { + t.Fatal(err) + } + if countKind(refs, "project") != 1 { + t.Fatalf("项目封面没查到: %+v", refs) + } + if countKind(refs, "avatar") != 1 { + t.Fatalf("站主头像没查到: %+v", refs) + } +} + +// 没被引用、以及 key 为空时都不能误报——空 pattern 会 LIKE '%%' 命中全库。 +func TestFileReferencesNoFalsePositive(t *testing.T) { + s := openTestStore(t) + if _, err := s.Create(model.PostInput{ + Kind: model.KindLong, Title: "无关", Slug: "x", + ContentMd: "正文里没有这张图", CoverURL: "/uploads/2026/09/aaaa.png", + Status: model.StatusPublished, + }); err != nil { + t.Fatal(err) + } + + // 同内容不同月上传出的 key 只是月份段不同,不该互相命中 + refs, err := s.FileReferences("2026/10/aaaa.png") + if err != nil { + t.Fatal(err) + } + if len(refs) != 0 { + t.Fatalf("换月的 key 误报: %+v", refs) + } + + empty, err := s.FileReferences("") + if err != nil { + t.Fatal(err) + } + if len(empty) != 0 { + t.Fatalf("空 key 不该匹配任何东西,得到 %+v", empty) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 918d7b7..4c1c918 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1650,6 +1650,68 @@ func (s *Store) GetFileByKey(key string) (model.File, error) { return f, nil } +// FileReferences 反查谁在用这个文件:文章(封面或正文)、项目封面、站主头像。 +// 匹配的键是 files.key 本身而不是完整 URL——本地存成 /uploads/{key}、R2 存成 +// {publicBase}/{key}、缩略图是 /uploads/thumb/{key},三种形态都以 key 结尾, +// 换存储端后正文里的老链接也照样能查到。 +// 用 LIKE 现扫而不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处, +// 计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。 +func (s *Store) FileReferences(key string) ([]model.FileRef, error) { + if key == "" { + return []model.FileRef{}, nil + } + like := "%" + key + "%" + out := []model.FileRef{} + + rows, err := s.db.Query(s.db.Q(`SELECT id,title,slug,status FROM posts + WHERE cover_url LIKE ? OR content_md LIKE ? OR content_html LIKE ? + ORDER BY published_at DESC LIMIT 100`), like, like, like) + if err != nil { + return nil, err + } + for rows.Next() { + var r model.FileRef + r.Kind = "post" + if err := rows.Scan(&r.ID, &r.Title, &r.Slug, &r.Status); err != nil { + rows.Close() + return nil, err + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + + proj, err := s.db.Query(s.db.Q(`SELECT id,title,slug,status FROM projects WHERE cover_url LIKE ? LIMIT 100`), like) + if err != nil { + return nil, err + } + for proj.Next() { + var r model.FileRef + r.Kind = "project" + if err := proj.Scan(&r.ID, &r.Title, &r.Slug, &r.Status); err != nil { + proj.Close() + return nil, err + } + out = append(out, r) + } + if err := proj.Err(); err != nil { + proj.Close() + return nil, err + } + proj.Close() + + // 头像存的就是 key(不是 URL),等值比较 + if st, err := s.GetSettings(); err != nil { + return nil, err + } else if st.AuthorAvatarKey == key { + out = append(out, model.FileRef{Kind: "avatar"}) + } + return out, nil +} + // DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。 func (s *Store) DeleteFile(id int64) (model.File, error) { f, err := s.GetFile(id) diff --git a/frontend/src/admin/FilesView.vue b/frontend/src/admin/FilesView.vue index 57b1069..77a8f1b 100644 --- a/frontend/src/admin/FilesView.vue +++ b/frontend/src/admin/FilesView.vue @@ -89,10 +89,32 @@ async function copyUrl(f) { } } +// 引用位置的展示文案:文章/项目带标题,头像没有标题可指。 +// 草稿也列出来——现在没发布,删了图将来发出来就是裂的。 +function refLabel(x) { + if (x.kind === 'avatar') return '站主头像' + const head = x.kind === 'project' ? '项目' : '文章' + const name = x.title || x.slug || '未命名' + return `${head}《${name}》${x.status === 'draft' ? '(草稿)' : ''}` +} + async function remove(f) { - if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return + // 删除前查一次引用:后端默认会挡住被引用的文件(409),这里先把清单摊出来, + // 用户看完仍然要删才带 force=1 过去。 + let refs = [] try { - await adminApi.deleteFile(f.id) + refs = (await adminApi.fileRefs(f.id)).items || [] + } catch (_) { + // 查询失败就按无引用走:真被引用时后端会返回 409,不会静默删掉在用文件 + } + let tip = `删除「${f.name}」?存储里的文件会一并删除。` + if (refs.length) { + const list = refs.slice(0, 3).map(refLabel).join('、') + tip = `删除「${f.name}」?\n它正被 ${refs.length} 处引用:${list}${refs.length > 3 ? ' 等' : ''}。\n删掉这些地方会图裂。` + } + if (!window.confirm(tip)) return + try { + await adminApi.deleteFile(f.id, refs.length > 0) toastOk(`已删除「${f.name}」`) // 当前页删空时退一页,其余直接重载 if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } }) diff --git a/frontend/src/api.js b/frontend/src/api.js index 8f6447a..040a736 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -104,7 +104,10 @@ export const adminApi = { request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }), // ---------- 文件上传 ---------- files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)), - deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }), + // 删除前查引用(被哪些文章/项目/站主头像用着);force 用于明知有引用仍要删 + fileRefs: (id) => request('/api/admin/files/' + id + '/refs'), + deleteFile: (id, force = false) => + request('/api/admin/files/' + id + (force ? '?force=1' : ''), { method: 'DELETE' }), // 上传走 FormData:request() 是 JSON helper,这里单独 fetch。 // 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。 // 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里