本文由 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.filename | 14 |
users.avatar | 285 |
blogs.cover_image | 16 |
topic_index.excerpt_images | 19 |
blogs.content | 1 |
blog_comments.content | 0 |
settings.value | 0 |
extern reply 正文 | 10 |
extern topic 正文 | 12 |
合计 $used 键(含派生缩略图) | 1023 |
2.3 23 个待删文件的归属(全库引用检索结果)
检索范围:81 个 sqlite(核心 data/ + 全部 plugins//data/.sqlite)的所有文本列,
外加 data/extern/ 下 293 个正文文件。
| 文件 | 归属 / 命中位置 | 判定 |
|---|---|---|
2026-09/0c81ec08…png | suiyu.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…png | suiyu.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.images | 2 行有引用 → 已实测误删 12 个文件 |
points_mall(积分商城) | pm_products.image | 0 行(功能支持上传商品图) |
single_page(单页) | single_pages.content | 4 行,当前 0 引用 uploads |
announcements(公告) | announcements.content | 3 行,当前 0 引用 uploads |
props(道具) | pp_items.icon | 1 行,非 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.php | a7fe90d588be | 新增第 5 段「插件库兜底扫描」;判定孤儿加 basename 兜底;新增 &$report 引用参数 |
app/Controllers/Admin/MaintenanceController.php | 5a26a464d5aa | 新增 action=cleanup_preview(dry-run 预览),与真删分离 |
app/Views/admin/maintenance.php | 2bb55e5d177b | 按钮改「预览待清理文件」+ 加载态;新增预览表格 + 二次确认按钮 |
lang/zh.php | 46582f8479b7 | 改写 2 键 + 新增 9 键 |
lang/zh_tw.php | e85081df4068 | 同上 |
lang/en.php | ac6b6448fc1e | 同上 |
三个根因的对应修法
- 插件库未纳入扫描 → 新增第 5 段:扫
plugins/*/data/**/*.sqlite的全部文本列,
三种口径提取(① 整值即路径 ② assets/uploads/ 前缀 ③ 裸 YYYY-MM/xxx.ext)。
覆盖 sy_posts.images、pm_products.image、single_pages.content、announcements.content 等。
- 云存储台账未纳入 → 同上一并覆盖(
bs_objects.rel本身是路径列,被第 5 段标记为「在用」)。 - 正文 URL 形态错位 → 判定孤儿时增加
isset($used[basename($rel)])兜底:
旧形态 /assets/uploads/xxx.jpg 提取出的裸文件名,能与磁盘 2026-09/xxx.jpg 对上。
验收结果
| 项 | 结果 |
|---|---|
真实函数 cleanupOrphanAttachments(true) | 3 |
复刻探针 cleanup_orphan_dryrun.php | 3 ✅ 数量一致 |
| 碎语 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:251 | 7 天节流 |
| 云函数 / crontab | cron_trigger.php:263 | 7 天节流 |
后两者共用节流键 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):
| 指标 | 结果 |
|---|---|
| 直扫 extern | 1714 正文块 → 34 个去重引用 |
DB 逐条 read | 1654 条 → 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%。
- 跳过前提严格为「真的没有」(三者同时满足):
- 该库上次全扫零引用;
- 该插件源码指纹未变(
plugin_sig:插件目录下除data/外全部文件的
相对路径:mtime:size 排序串的 sha1)—— 改代码即失效重扫,覆盖「插件后来加了 uploads」;
- 距上次全量重扫不足 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/ 一律不进发行包;生产首次运行会自动生成)。