摘要:「删除文章时顺手把它专属的图片也删掉,别在媒体库留垃圾」——需求一句话,主流程十行代码。但删数据类功能能不能上线,取决于边界清单,而不是主流程。本文给出六条边界(含一条我原实现里缺失、会导致误删的守卫),讲清「引用检查」为什么必须按文件 basename 查、以及如何用纯函数把删除规则在跑之前全测一遍。
适合阅读:写过或将要写「连带删除 / 批量清理 / 数据迁移」的开发者。文末附零依赖交付代码与线上真实 E2E(23 项断言)。
一、需求一句话,风险一大堆
主流程确实很短:文章被永久删除前,找出挂在它名下的附件,删掉。
add_action('before_delete_post', function ($post_id) {
foreach (get_attachments_of($post_id) as $att_id) {
wp_delete_attachment($att_id, true);
}
});
但这段代码上线后可能发生四种事故,而且都是静默的:别的文章的图突然裂了、想恢复文章时图已经没了、服务器递归到栈溢出、删了个 id 是 “05” 的东西。下面六条,每一条我都给出「现象 → 后果 → 写法」。
二、六条边界清单
边界 1:媒体库是共享的 —— 被引用就不能删(最关键)
这是我在自己项目里查出的一条真实缺陷。之前一版实现只做了两件事:取特色图 id、取 post_parent 指向本文的附件,然后删。看起来天经地义 —— 附件挂在我名下,不就是我的吗?
不是。WordPress 的媒体库天生支持复用:同一张图可以挂在文章 A 名下(post_parent = A),同时被文章 B、C 的正文引用,甚至被当作某篇文章的特色图。删 A 时如果直接删掉它,B、C 立刻变坏图,而且不会有任何报错 —— 你只会在某天收到读者反馈「图片挂了」。
所以删除前必须回答一个问题:这个附件除了本文之外,还有谁在用?只要发现别处在用,就跳过它(宁可留下一点垃圾,也不要制造坏图)。
// 决策:被引用的跳过
if ($strict && !empty($in_use[$id])) {
$skip[] = array('id' => $id, 'why' => 'in_use');
continue;
}
$del[] = $id;
这条守卫是本文交付代码相对旧实现的核心增量。实际效果我在线上真跑过:创建一个专属附件 + 一个被别文设成特色图的附件 + 一个被别文正文引用的附件,然后删掉宿主文章 —— 专属的被清掉,另外两个原封不动。
边界 2:递归 —— 删附件本身也会触发同一个钩子
before_delete_post 对所有 post type 生效,包括 attachment。所以「删文章 → 删附件 → 又触发钩子 → 又去找该附件的附件…」会形成递归,在真正的生产数据上有可能一路跑下去。必须显式挡掉:
if ('attachment' === get_post_type($post_id)) {
return; // 附件自身被删时不处理,避免递归
}
边界 3:只在「永久删除」时动手,回收站不能碰
WordPress 删除文章有两步:移入回收站(trash)与永久删除(force delete)。钩子 before_delete_post 只在前者之外的第二步触发 —— 这正是我们要的语义:站长把文章丢进回收站时,多半还在犹豫;这时候把图删了,他点「恢复」也回不来。
实现上不需要额外判断,只要选对这个钩子即可(用 wp_trash_post / trashed_post 之类钩子就会踩坑)。这条我在线上专门验证过:回收站阶段附件不动,永久删除被调起后记录与文件一起消失。
边界 4:修订与自动草稿一律跳过
修订(revision)与自动草稿(autosave)也是 post,也会走删除流程。它们没有自己的附件,但触发了钩子就会白跑查询;更麻烦的是某些表单流程里修订被删除的频率很高,等于每次都在做无用的全表扫描。
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
边界 5:in_array 必须严格比较
去重时写 in_array($id, $list) 是常见写法,但它是松散比较:'05' == 5 成立、'1abc' == 1 在旧版本 PHP 里也成立。id 从数组 / 表单 / JSON 里混进来时可能是字符串,松散比较会让本该保留的元素被判为「重复」而丢掉。一律写第三个参数:
if (!in_array($id, $out, true)) { // ← 第三个参数 true 不可省
$out[] = $id;
}
边界 6:删了什么必须留痕
删除类操作最怕「事后说不清」。日志里至少要记:删的是哪篇文章、删了哪些附件 id、哪些被跳过了以及跳过原因、是否连文件一起删。出问题时这行日志是唯一能解释「图为什么没了 / 为什么还在」的证据。
三、架构决定:把「决策」抽成纯函数
这是这套代码里我最想推荐的一个做法。删除类功能最难的不是写,而是验证规则对不对 —— 你不可能在生产库上「试删一下看看」。所以把逻辑拆成三层:
| 层 | 函数 | 输入 / 输出 | 可测性 |
|---|---|---|---|
| ① 候选收集 | zdw_da_collect() | 特色图 id + 子附件 id 列表 + 模式 → 去重后的 id 列表 | 纯函数,可单测 |
| ② 引用检查 | zdw_da_referrers() | 附件 id 列表 → 每个 id 是否仍被引用(查库) | 需要 WP,用真实数据测 |
| ③ 删除决策 | zdw_da_plan() | 候选 + 引用情况 + 选项 → {删哪些, 跳过哪些及原因} | 纯函数,可单测 |
这样一来,「规则」这一层(①②③ 的边界与组合)可以在跑之前全部测完,而且不依赖 WordPress;剩下要真机验证的只有「引用查得准不准」和「删得动不动」——这两件事用一次带断言的真实环境测试就能覆盖。
我这次的 23 项断言就是按这个分层来的:17 项纯函数(候选去重、模式选择、非法 id、被引用跳过、strict 开关、dry-run 报告、空输入)+ 6 项线上 E2E。其中有一项断言直接暴露了业务价值:被他文用作特色图的附件必须存活。
四、引用怎么查才不误判
「是否被引用」听起来是一次查询,实际上要覆盖三类引用,且每一类都有细节:
| 引用类型 | 怎么查 | 🧨 细节 |
|---|---|---|
| 正文里贴了这张图 | 查 wp_posts.post_content 里是否出现该附件 | 用文件 basename 而不是完整 URL —— 换域名 / 换 http→https 后 URL 变了,但文件名不会变;同时再查一次富文本的 wp-image-<id> 类名 |
| 被他文设为特色图 | 反查 wp_postmeta:meta_key = '_thumbnail_id' AND meta_value = 附件id | 不要用 get_post_thumbnail_id() 逐个文章去问(N+1 查询),直接反查(一次一条 SQL) |
| 站点配置里引用了它 | 查 wp_options 里是否出现该附件 | 最容易误判的一条:选项里出现一个裸数字太常见了(任何含该数字的配置都会命中)。必须再用文件 basename 复核一次 —— 宁可漏判,也不要因为误判把该删的留下… 不,这里反过来:误判为「被引用」只是留下垃圾,误判为「没被引用」会删掉在用的图。所以这里宁可偏保守 |
注意最后一条里的取舍方向:「假阳性(误判为被引用)」的代价是留下一个没用的附件;「假阴性(误判为没引用)」的代价是删掉一张还在用的图。两者代价不对称,所以默认策略必须是保守 —— 拿不准就留。
五、dry-run:开启前先看影响面
这类模块默认必须是关闭的,而且最好提供一个「只看不删」的探测模式,让站长在开启之前先知道「按现在的规则,如果删这篇文章,会删掉哪些附件」。
$plan = zdw_da_preview($post_id);
echo '本来会删: ' . implode(',', $plan['would_delete']);
echo '跳过: ' . json_encode($plan['skip']); // 每条带 why:in_use / invalid_id
这比写十页文档有用:规则到底会删什么,看一遍真实结果最清楚。
六、实测(三层,23 项断言)
| 层 | 规模 | 覆盖 | 结果 |
|---|---|---|---|
| ① 纯函数单测(PHP CLI,不需要 WP) | 17 项 | 候选收集 8 项(模式/去重/字符串数字混入/非法 id)+ 决策 9 项(被引用跳过 / strict 开关 / dry-run 报告 / 空输入) | 17/17 ✅ |
| ② 线上 E2E(真实站点、真实文件) | 6 项 | 专属附件被清 / 被他文用作特色图存活 / 被他文正文引用存活 / 回收站不触发 / 永久删除才清 / 测试件无残留 | 6/6 ✅ |
| ③ 测试件自清理 | — | 跑完把文章/附件/文件全部删除并断言零残留 | 无残留 ✅ |
E2E 的结果值得单独抄一遍,因为它直接证明了那道守卫是有效的:
| 断言 | 场景 | 结果 |
|---|---|---|
| S1 | 文章专属附件(只挂在该文下、无人引用) | 记录 + 服务器文件都被删除 ✅ |
| S2 | 被他文设为特色图的附件 | 存活(记录与文件都在)✅ |
| S3 | 被他文正文引用的附件 | 存活(记录与文件都在)✅ |
| S4 | 仅移入回收站 | 不触发清理 ✅ |
| S5 | 永久删除 | 才清理(记录 + 文件)✅ |
| S6 | 清理测试件 | 零残留 ✅ |
七、踩坑记录
| # | 坑 | 现象 / 后果 | 解法 |
|---|---|---|---|
| 1 | 只看 post_parent 就删 | 别文的特色图/正文图被删 → 静默坏图 | 删前扫三类引用,被引用即跳过(本文核心增量) |
| 2 | 未挡 attachment 自身 | before_delete_post 递归 | 开头判 get_post_type() === 'attachment' 直接 return |
| 3 | 用 trashed_post 之类的钩子 | 文章刚进回收站附件就没了,恢复文章也回不来图 | 用 before_delete_post(仅永久删除触发) |
| 4 | 正文引用按完整 URL 匹配 | 换域名 / 换协议后全部匹配失败 → 误删 | 按文件 basename 匹配(与域名无关) |
| 5 | in_array 不写第三参 | 字符串与数字混入时误判重复 / 丢元素 | 一律 in_array($id, $list, true) |
| 6 | 在 options 里查裸数字判断引用 | 几乎必然误判为「被引用」→ 该删的全留着 | 用附件文件 basename 复核,避免把无关配置当引用 |
附:测试方法
删除类功能的测试有一个特殊要求:必须能在测试完后把现场恢复干净,否则测试本身就在制造垃圾。我的做法是「自带 fixtures + 自带清理 + 断言零残留」:
① 纯函数层:collect / plan 不碰库不碰文件 → 桩函数 + PHP CLI 直接测
(重点覆盖:非法 id、字符串数字混入、被引用跳过、strict 开关、dry-run)
② 线上 E2E:脚本自带 fixture 生命周期
a. 造文章 A/B/C/D + 附件(写真实文件 + wp_insert_attachment + 生成元数据)
b. 建立引用关系(B 的特色图、C 的正文 img)
c. 按场景触发(trash / force delete)并断言
d. 关掉模块(add_filter 返回 false)后清理全部测试件
e. 断言「文章=0 附件=0 文件=0」
🧨 关键:断言要同时看「记录」和「服务器文件」——
只删记录不删文件(或反之)都是 bug,只查一个会漏。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界 (本文)
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统









暂无评论内容