删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界

删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界-知集
删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
已售 2
阅读约 45 分钟13,357 字

摘要:「删除文章时顺手把它专属的图片也删掉,别在媒体库留垃圾」——需求一句话,主流程十行代码。但删数据类功能能不能上线,取决于边界清单,而不是主流程。本文给出六条边界(含一条我原实现里缺失、会导致误删的守卫),讲清「引用检查」为什么必须按文件 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 匹配(与域名无关)
5in_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 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容