文件删除前查引用:被文章/项目/站主头像用着就先挡住

- 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。
This commit is contained in:
Sakurasan committed 2026-09-30 02:03:14 +08:00
1 parent f1e639e0ba
commit 1b3eb870da
8 files changed
+427 -4

No files matched your search

+109
View File
@@ -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)
}
}
+62
View File
@@ -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)