文件删除前查引用:被文章/项目/站主头像用着就先挡住
- 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:
1 parent
f1e639e0ba
commit
1b3eb870da
8 files changed
+427
-4
No files matched your search
@@ -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: "正文配图 ",
|
||||
Status: model.StatusPublished,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Create(model.PostInput{
|
||||
Kind: model.KindShort, Slug: "local",
|
||||
ContentMd: "本地形态  和缩略图 ",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user