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

- 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

+24 -2
View File
@@ -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 } })
+4 -1
View File
@@ -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 拿不到上传进度,编辑器的进度提示走这里