[审计]后台「清理孤儿附件」覆盖率审计

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:42:49

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/清理孤儿附件覆盖率审计_2026-09-29.md。

后台「清理孤儿附件」覆盖率审计

  • 审计对象:Settings::cleanupOrphanAttachments()(app/Helpers/Settings.php:748)
  • 后台入口:app/Controllers/Admin/MaintenanceController.php:92(action=cleanup_attachments)
  • 视图:app/Views/admin/maintenance.php:86-103
  • 审计日期:2026-09-29
  • 审计方式:只读(真实函数以 dryRun=true 调用,未删除任何文件)

一、结论

不能点。 在本机当前数据上,dry-run 实测会删除 23 个文件 / 1297.6 KB,其中:

  • 12 个是「碎语时光」插件帖子里正在引用的配图(站长本人发的 2 条,status=0 正常)→ 前台裂图,不可恢复;
  • 6 个是云存储插件台账里 pending 对象 → 台账变悬空,永远同步不了;
  • 2 个是历史探针/测试遗留;
  • 只有 3 个是真孤儿。

根因不是「个别文件没扫到」,而是函数的「在用集合」只覆盖核心库的 8 个来源,完全不含插件库。


二、实测证据

2.1 口径验证

项目结果
真实函数 cleanupOrphanAttachments(true)deleted=23, freed=1297.6 KB(耗时 3240ms)
自建复刻探针(反射调私有 normUploadRel/markUsedUpload,逐行照抄主逻辑)23 个,文件名清单见下
一致性✅ 完全一致 → 复刻口径可信

2.2 在用集合的来源统计(真实函数内部)

来源条数
attachments.filename14
users.avatar285
blogs.cover_image16
topic_index.excerpt_images19
blogs.content1
blog_comments.content0
settings.value0
extern reply 正文10
extern topic 正文12
合计 $used 键(含派生缩略图)1023

2.3 23 个待删文件的归属(全库引用检索结果)

检索范围:81 个 sqlite(核心 data/ + 全部 plugins//data/.sqlite)的所有文本列,

外加 data/extern/ 下 293 个正文文件。

文件归属 / 命中位置判定
2026-09/0c81ec08…pngsuiyu.sqlite :: sy_posts.images(post#2)❌ 误删
2026-09/172549d1…png + md_ + thumb_suiyu.sqlite :: sy_posts.images(post#1)❌ 误删
2026-09/571790d4…png + thumb_suiyu.sqlite :: sy_posts.images(post#2)❌ 误删
2026-09/5c95b985…pngsuiyu.sqlite :: sy_posts.images(post#1)❌ 误删
2026-09/7dc1734a…png + md_ + thumb_suiyu.sqlite :: sy_posts.images(post#1)❌ 误删
2026-09/7fa6ab1a…png + thumb_suiyu.sqlite :: sy_posts.images(post#1)❌ 误删
2026-09/612847b2…jpg + thumb_bitiful_storage :: bs_objects(status=pending)+ 废弃块 extern/2026/Q3/18/t0.bin⚠ 台账悬空
2026-09/8094ac02…jpg + thumb_bitiful_storage :: bs_objects(status=pending)⚠ 台账悬空
2026-09/dae0f9f1…jpg + thumb_bitiful_storage :: bs_objects(status=pending)⚠ 台账悬空
2026-09/d8f6847b…png(314 B)bitiful_storage :: bs_objects探针/测试遗留
2026-09/probe_7e3f557d.png(80 B)bitiful_storage :: bs_objects探针/测试遗留
2026-09/b34f776b…png + md_无任何引用✅ 真孤儿
2026-09/cb268df6…png无任何引用✅ 真孤儿

合计:12 + 6 + 2 + 3 = 23。

碎语插件状态常量:plugins/suiyu/Plugin.php:20 STATUS_NORMAL = 0 → post#1 / post#2 均为 status=0,

即正常可见,不是回收站里的帖。


三、三个根因

根因 1:插件数据源完全未纳入扫描(主因,已实测误删)

函数只扫 8 个核心来源(见 2.2),不扫任何插件库。

受影响的已知字段:

插件表.列当前状态
suiyu(碎语时光)sy_posts.images2 行有引用 → 已实测误删 12 个文件
points_mall(积分商城)pm_products.image0 行(功能支持上传商品图)
single_page(单页)single_pages.content4 行,当前 0 引用 uploads
announcements(公告)announcements.content3 行,当前 0 引用 uploads
props(道具)pp_items.icon1 行,非 uploads 路径

根因 2:云存储同步台账未纳入

plugins/bitiful_storage/data/bitiful_storage.sqlite :: bs_objects 里 3 个对象 status='pending'、

synced_at=''(尚未推送云端)。本地文件被删后,这些台账项永远无法完成同步。

补充:UrlRewriter 的白名单是「推送成功即追加」,所以 pending 对象在前台仍走本地地址——

即被删后若还有引用就是真裂图(本次这 3 张无活跃引用,但机制上不安全)。

根因 3:正文 URL 与磁盘 rel 口径错位(潜在,成片风险)

  • 函数用 $urlRe = '/assets/uploads/([^"\'\s?]+)/i' 从正文提取路径,提取结果是 URL 里写什么就是什么。
  • 新帖正文写的是带月目录形态 /assets/uploads/2026-09/xxx.png → 与磁盘 rel 一致 ✅。
  • 但历史块 data/extern/2026/Q3/18/t0.bin 里仍是旧形态 /assets/uploads/612847b2….jpg(无月目录)

→ 提取键 = 612847b2….jpg,磁盘 rel = 2026-09/612847b2….jpg → 对不上 → 误判孤儿。

  • 当前只波及 1 个「无 bucket 记录指向」的旧块文件;但**迁移(cli/migrate_uploads_by_month.php)

若再漏改外置正文,会成片误删**。


四、后台文案与实际行为不符

lang/zh.php:897:

'admin.mt_cleanup_safety_hint' => '仅删除数据库无引用的文件,不会误删正在使用的附件',

这里的「数据库」只指核心库,不含插件库,该安全承诺不成立。

lang/zh.php:894 的 admin.mt_cleanup_hint 同样只描述「数据库中已无对应记录」。

→ 站长「一直没敢点」的直觉是对的。改逻辑时这两条文案要一起改。


五、设计边界(知情项,非缺陷)

项说明
24 小时缓冲只保护 24h 内的文件;用户上传后超 24h 未发帖/未保存,即被判孤儿删除
只删文件不动 DB删除后数据库里会留下悬空引用(本次碎语就是活例),函数不做反向修补
seed_avatars/整目录显式排除 ✅
covers/未排除,靠 blogs.cover_image 引用覆盖(当前 16 文件 / 16 行,全覆盖 ✅)
无预览后台按钮无 dry-run 预览,POST 即真删;只有 CLI 能 dry-run
未扫描但当前为空的潜在雷users.signature(5 行非空、0 引用 uploads)、messages.content(0 行)、categories.icon(6 行,非 uploads)


六、修复方向(供决策,未动手)

#方案代价覆盖度
A新增「引用来源注册」钩子,插件声明自己的图片路径,核心清理前汇总需定接口 + 逐插件适配精确,但要插件配合
B核心兜底:扫描 plugins//data/.sqlite 全部文本列(与本次审计探针同法)与插件库结构耦合;插件表多时耗时上升(本次 81 库全扫约 3s)全自动,零插件改动
C给后台按钮加 dry-run 预览页(先列清单再确认删)仅提升可见性,不修漏点不解决误删,但让用户可控
D删除改为「移入回收目录」而非 unlink需加清理策略兜底可恢复

推荐组合:B(兜底全覆盖)+ C(可见性),长期再补 A。

另需同步:bs_objects 台账纳入、正文 URL 形态归一化、两条 i18n 文案改写。


七、复现 / 回归探针

保留在 .workbuddy-ai/tmp/(只读,已同步一份到 skill flinthub-orphan-cleanup-audit/scripts/):

脚本用途
cleanup_orphan_real.php调真实函数 dryRun=true 取数量
cleanup_orphan_dryrun.php复刻逻辑,输出逐文件清单
cleanup_orphan_audit.php反向扫 data/ 下全部 sqlite 的所有文本列
cleanup_orphan_audit2.php反向扫 plugins//data/.sqlite
cleanup_orphan_refcheck.php对候选文件做全库引用检索
cleanup_orphan_shape.php引用路径形态分布统计
cleanup_orphan_extern.php分片正文读取覆盖率

运行方式:

cd D:/phpstudy_pro/WWW/.workbuddy-ai/tmp
/d/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe cleanup_orphan_real.php

⚠️ cleanup_orphan_real.php 固定传 dryRun=true,不会删文件。

修改本函数后,必须让 cleanup_orphan_real.php 与 cleanup_orphan_dryrun.php 的数量一致才算口径没跑偏。


八、修复实施(2026-09-29,B + C 方案)

改动清单(6 个文件)

文件sha256(前 12 位)改动
app/Helpers/Settings.phpa7fe90d588be新增第 5 段「插件库兜底扫描」;判定孤儿加 basename 兜底;新增 &$report 引用参数
app/Controllers/Admin/MaintenanceController.php5a26a464d5aa新增 action=cleanup_preview(dry-run 预览),与真删分离
app/Views/admin/maintenance.php2bb55e5d177b按钮改「预览待清理文件」+ 加载态;新增预览表格 + 二次确认按钮
lang/zh.php46582f8479b7改写 2 键 + 新增 9 键
lang/zh_tw.phpe85081df4068同上
lang/en.phpac6b6448fc1e同上

三个根因的对应修法

  1. 插件库未纳入扫描 → 新增第 5 段:扫 plugins/*/data/**/*.sqlite 的全部文本列,

三种口径提取(① 整值即路径 ② assets/uploads/ 前缀 ③ 裸 YYYY-MM/xxx.ext)。

覆盖 sy_posts.images、pm_products.image、single_pages.content、announcements.content 等。

  1. 云存储台账未纳入 → 同上一并覆盖(bs_objects.rel 本身是路径列,被第 5 段标记为「在用」)。
  2. 正文 URL 形态错位 → 判定孤儿时增加 isset($used[basename($rel)]) 兜底:

旧形态 /assets/uploads/xxx.jpg 提取出的裸文件名,能与磁盘 2026-09/xxx.jpg 对上。

验收结果

项结果
真实函数 cleanupOrphanAttachments(true)3
复刻探针 cleanup_orphan_dryrun.php3 ✅ 数量一致
碎语 12 个图✅ 不再出现
bs_objects 6 个文件✅ 不再出现
剩余清单b34f776b….png / cb268df6….png / md_b34f776b….png —— 均为真孤儿
三语 i18n各 1355 键、集合零差异、键序一致 ✅
后台视图渲染23080 B,新元素齐全、无未替换 i18n key ✅

⚠️ 预期「3 个真孤儿 + 2 个探针遗留」,实测只剩 3 个 ——

probe_7e3f557d.png(80B) 与 d8f6847b….png(314B) 在 bs_objects 里有 pending 记录,

被 B 方案保守保留。若要清掉需单独处理(删文件 + 删那 2 条台账记录)。


九、⚠️ 部署注意事项(紧急)

触发点不止后台按钮

触发点位置频率
后台MaintenanceController::index()手动
CLI 常驻cli/worker2.php:2517 天节流
云函数 / crontabcron_trigger.php:2637 天节流

后两者共用节流键 orphan_attachments(文件 data/runtime/cron_last_orphan_attachments.ts)。

它们只是调用同一个函数,逻辑在 Settings 内,所以本次改动自动覆盖全部部署形态 ——

无需修改这两个文件。

⚠️ 但要尽快上线

本机节流文件显示上次执行 = 2026-09-18 22:40,7 天早已过去 → 下次 cron 一跑就会清理。

生产若同样过期,10-02 前后会自动删掉站长 09-27 发的碎语图。

上线前请核查生产服务器上 data/runtime/cron_last_orphan_attachments.ts 的时间戳,

并确认碎语图是否还在。

性能

改造后约 9.5s(原 3.2s)。当时归因为「I/O 总量,代码层优化不掉」—— 该结论已于 2026-09-30 被推翻,

详见第十节。真正的瓶颈是「每个全新 SQLite 连接的首个查询」与「全量扫 39 个插件库」,二者均可优化。

云函数 5min 预算内安全。

需上传的文件

app/Helpers/Settings.php、app/Controllers/Admin/MaintenanceController.php、

app/Views/admin/maintenance.php、lang/zh.php、lang/zh_tw.php、lang/en.php


十、性能优化(2026-09-30,方案 A:A1 extern 直扫 + A2 插件动态白名单)

10.1 瓶颈定位(推翻「I/O 总量优化不掉」)

微基准(.workbuddy-ai/tmp/cleanup_perf_micro.php,3 轮)给出反证:

动作单次耗时
同一连接重复 COUNT(*)0.8 ~ 1.1 ms
全新连接 + 首次查询143 ~ 315 ms(中位 ~150ms,波动大)
new PDO 但不查询3.6 ~ 6.2 ms(SQLite 惰性打开,没真开文件)
纯 file_get_contents 同文件1.2 ~ 4.0 ms

★ 同一个文件重开 32 次同样慢(实测 4579~10096ms)→ 成本与数据量、文件大小无关,

就是「每个全新 SQLite 连接的首个查询」(建 -shm)。32 个 bucket 库 = 32 次该成本。

段分解(cleanup_perf_compare.php,3 轮):

段耗时
现状:32 个 bucket 库(连接 + extern_path 查询 + 读 1654 条正文)3556 ~ 5339 ms
改为:直扫 data/extern(45 .txt + 92 .bin = 1669 正文块)221 ~ 329 ms
插件库段(39 库全扫)3506 ~ 4843 ms

插件库段内部:39 库中 11 个有 uploads 引用 / 28 个零引用;

有引用库合计 1256ms,零引用库合计 3733ms(plugin_whitelist_probe.php)。

10.2 A1:bucket 段改为直扫 extern

原做法「遍历 32 个 bucket 库取 reply/topic.extern_path → ExternStorage::read」→

改为「直接遍历 data/extern 的 .txt / .bin 正文文件」。

等价性证明(.workbuddy-ai/tmp/equiv2.php):

指标结果
直扫 extern1714 正文块 → 34 个去重引用
DB 逐条 read1654 条 → 19 个去重引用
DB 有、直扫没有(漏标记)0 ✅
DB 读出的正文未在直扫集合命中0 ✅
直扫有、DB 没有(多标记)15(安全侧:多标记只会少删)

原因:直扫覆盖 data/extern 下全部正文(含已删除帖的遗留正文),

DB 侧只覆盖「当前仍存在 topic/reply 行」的正文 → 前者必然是后者的超集。

.bin 格式 = 连续 [4字节大端长度][正文] 记录(与 ExternStorage::readFromBin 一致),

顺序自解析即可,无需 .idx;.txt 为单条正文。两者都必须 Content::decode() 后再匹配

(正文可能是 base64 编辑器提交态)。

10.3 A2:插件库动态白名单

  • 缓存文件:data/runtime/plugins_uploads_cache.json

```json

{

"updated_at": 1790737579,

"next_full_scan_at": 1793329579,

"no_uploads_libs": ["ai_assistant/data/meta/task_queue/queue_0.sqlite", "..."],

"plugin_sig": { "props": "d11ed632f7d8...", "..." : "..." },

"last_scan": { "scanned": 11, "skipped": 28, "total": 39 }

}

```

  • 粒度 = 库(不是插件):实测 ai_assistant 的 4 个库里只有 1 个有引用,

另外 3 个 task_queue 库恰是全场最慢的(185~257ms/个)。按插件粒度白名单收益 61%,按库粒度 75%。

  • 跳过前提严格为「真的没有」(三者同时满足):
  1. 该库上次全扫零引用;
  2. 该插件源码指纹未变(plugin_sig:插件目录下除 data/ 外全部文件的

相对路径:mtime:size 排序串的 sha1)—— 改代码即失效重扫,覆盖「插件后来加了 uploads」;

  1. 距上次全量重扫不足 30 天(next_full_scan_at)。
  • 打开失败的库既不进白名单也不标记(绝不能把「打不开」当「无引用」,否则永久跳过 → 误删)。
  • 缓存缺失 / 损坏 → 退化为全量重扫(安全侧)。
  • 后台「重建插件扫描白名单」按钮 → action=cleanup_rebuild_plugin_cache →

Settings::rebuildPluginUploadsCache()(forceFull=true)。

10.4 验收结果

项结果
cleanup_orphan_real.php(真实函数 dryRun)deleted=3
cleanup_orphan_dryrun.php(独立复刻,旧逻辑)deleted=3(清单完全相同)
3 个孤儿的全库字节级检索data/ + plugins/*/data/ 零命中 → 确为真孤儿
性能(同一台机、同一份数据)旧逻辑复刻 8632ms → 新逻辑首跑 5215ms / 白名单生效 1429~1768ms
后台 POST 两个分支cleanup_rebuild_plugin_cache ✅、cleanup_preview ✅(清单表头三列齐全)
uploads 文件数387 → 387(dry-run 未删任何文件)
三语 i18n各 1358 键、集合零差异、键序一致

白名单机制行为验证(cleanup_rebuild_cache_probe.php):

强制全量 scanned=39/skipped=0 ✅;缓存命中 skipped=28 ✅;缓存损坏 → 全量重扫 ✅;

缓存删除 → 全量重扫并重建 ✅;插件源码 mtime 改动 → 该插件指纹变化并被重扫 ✅。

10.5 ⚠️ 残余风险:extern 文件数依赖

新方案的成本 ≈ extern 下文件数 × ~0.8ms(本机 293 文件 = 240ms)。

ExternMerger 只在后台手动触发,不在 cron 里 → 未合并的 .txt 会持续累积。

  • 若生产 data/extern 的 .txt 数量巨大(Settings::getDbSize() 注释提到过「数十万 .txt,

全量遍历耗时数十秒」),直扫会变慢。

  • 但旧方案同样是 O(正文数):它逐条 ExternStorage::read,未合并的正文也要逐个读 .txt。

两者差异仅「是否多读已删除帖的遗留正文」。故新方案在任何情况下都不会明显劣于旧方案,

且在本机现状下快 16 倍。

  • 上线前建议核对生产 find data/extern -type f | wc -l;若很大,先在后台跑一次

「外部正文合并(ExternMerger)」再上线。

10.6 本次改动文件

app/Helpers/Settings.php(A1 + A2 全部逻辑)、

app/Controllers/Admin/MaintenanceController.php(新增 cleanup_rebuild_plugin_cache 分支)、

app/Views/admin/maintenance.php(新增重建按钮 + hint)、

lang/zh.php / lang/zh_tw.php / lang/en.php(新增 3 键)。

⚠️ data/runtime/plugins_uploads_cache.json 是运行时缓存,不要打进发布包

(data/ 一律不进发行包;生产首次运行会自动生成)。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 4 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入