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

- 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

+67 -1
View File
@@ -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) {