背景
WordPress 站点在长期运行过程中,wp-content/uploads 目录会不断积累大量媒体文件(图片、PDF、视频等)。这些文件的主要来源包括:
- 文章/页面内容:编辑器插入的图片、附件下载、相册等;
- 主题/插件自动生成:缩略图裁剪、缓存、日志、占位图等;
- 直接上传未使用:后台或 FTP 上传后从未被任何内容引用;
- 内容删除后的残留:文章或产品已删除,但关联的媒体文件仍留在磁盘上。
随着时间推移,未被引用的"垃圾文件"会带来以下问题:
- 浪费存储空间:尤其是图片密集的站点(电商、新闻、图库),未用文件可能占用数 GB 到数十 GB;
- 备份体积膨胀:全量备份中包含大量无用文件,拖慢备份/恢复速度,增加存储成本;
- 迁移/部署效率低:站点迁移或搭建测试环境时,大量无用文件被一并拷贝;
- 安全隐患:残留的敏感文件(如发票截图、内部文档)可能被公开访问。
然而,手动清理风险极高:
- 仅凭文件名或目录结构无法判断文件是否被引用;
- 缩略图与原始图片之间存在关联关系,误删会导致页面破图;
- 媒体库有登记记录 ≠ 文件正在被使用,反之亦然。
因此,需要一套自动化的、数据驱动的、可审计的清理方案——通过扫描文件系统、分析数据库引用关系,精准识别"无实际引用"的附件,并提供安全、可逆的删除操作。这就是本指南的目的。
一、适用范围与目标
- 扫描
wp-content/uploads下全部文件,建立文件索引; - 排除插件自用目录(不参与计算,仅列出并打标签);
- 从全站数据(除附件表自身登记外)与附件特殊引用状态两个来源,给文件打"引用标记";
- 原图与缩略图关联传播标记(同生共死);
- 提供 Web 页面:展示清单(文件名、大小、修改时间等)、图片预览、批量下载、勾选删除;
- 所有产出物(脚本、结果、日志、页面)放在独立目录(如站点根下的
doc/)。
二、前置条件
| 项目 | 要求 |
|---|---|
| 站点环境 | 完整的 WordPress 文件(含 wp-content),uploads 目录可读 |
| 数据库 | SQL 备份已导入本机 MySQL(或可直连线上库只读);需知道 host/port/库名/账号 |
| PHP | CLI 8.x(脚本)+ 内置服务器(预览页面);ZipArchive 扩展(批量下载) |
| 表结构 | 标准 WP 前缀 wp_(会自动适配,含 WooCommerce / WPML / Gravity Forms / Elementor 等插件表亦可) |
三、核心模型:标记引用,未标记即无用
只做一件事:给每个文件打"引用标记"。三步完成:
第 1 步:扫描文件,建立索引
- 遍历
uploads下全部文件,建立索引(相对路径、文件名、大小、修改时间、扩展名、是否图片); - 插件/系统自用目录(cache、wc-logs、cartflows-logs、wpcom、astra-sites、flags、woocommerce_uploads、wpforms、gravity_forms 等)与根目录 WooCommerce 占位图:不参与引用计算,打
plugin标签单独列出(可由用户自行决定处理)。
第 2 步:全站引用标记(两个来源)
来源 A · 全站内容引用:附件表自身存在登记数据(登记 ≠ 引用),必须排除,否则会自我标记。因此搜索除附件表外的其余所有表的文本字段,出现该文件路径/文件名 → 打上"引用"标记。
来源 B · 附件 ID 指名引用:附件被"指名引用"(数据库中以附件 post_id 指向该附件)。核心原则:凡出现指向附件 ID 的位置都算来源 B,新插件/新引用方式按此原则补充即可,不必改动其他逻辑。严格分为两大类:
类 1 · 附件表中的特殊引用(附件体系内的标准引用状态,通过明确的 meta 键 / option 名存储附件 ID):
| 引用方式 | 存储位置 | 说明 |
|---|---|---|
| 特色图 | _thumbnail_id |
文章/页面/产品设定的特色图片 |
| 产品/变体画廊 | _product_image_gallery / _variation_image_gallery |
WooCommerce 产品与变体图片画廊 |
| 站点 Logo | options site_logo / custom_logo |
网站 logo |
| 主题背景/页头图 | options background_image / header_image |
主题自定义背景/页头 |
| 菜单图标/附件 | _menu_item_icon / _menu_item_attachment_id |
菜单项图标或附件 |
| 本地头像 | 用户设置 | 本地化头像设置 |
| 自定义图片字段 | ACF 等插件 meta | 值为附件 ID 的图片字段 |
类 2 · 附件表外所有表的附件 ID 引用(文本指名,范围与来源 A 一致——除附件登记数据外的所有表文本字段):
- 文本中出现附件 ID:Gutenberg 块
{"id":123}、短代码/、链接?attachment_id=123、构建器媒体 ID 等 → 对应附件标记引用。
命中任意一条 → 打上"引用"标记。
第 3 步:原图 ↔ 缩略图关联传播
通过 _wp_attachment_metadata(PHP 序列化,需 unserialize)的 sizes 建立附件组(原图 + 其全部尺寸图/缩略图):
- 组内任一文件被来源 A(全站内容)标记 → 整组全部标记为引用;
- 组内任一文件被来源 B(附件特殊引用状态)标记 → 整组全部标记为引用。
原图与缩略图同生共死:只要原图或任一缩略图被标记引用,关联的全部文件都保留;全部未被标记,则关联的全部文件都是无用文件。
结论:三态
| 状态 | 判定 | 处理 |
|---|---|---|
| referenced | 被标记引用(来源 A / 来源 B,经第 3 步传播) | 保留,只读 |
| unused | 未被标记引用 | 无用,可删除(是否在媒体库有登记记录不影响结论,登记信息仅作证据展示) |
| plugin | 插件/系统自用目录或占位图 | 不参与判定,单独列出 |
四、计算逻辑(对应上节的实现步骤)
第 1 步:扫描 uploads,建立文件索引
- 递归遍历全部文件,记录 rel / name / size / mtime / ext / is_image;
- 位于插件/系统自用目录或为占位图 → 直接标
plugin,不再参与后续计算。
第 2 步:收集"全站内容"(来源 A 的检索底稿)
扫描范围:排除 wp_posts 中 post_type='attachment' 的行(附件自身的登记记录),以及 _wp_attached_file / _wp_attachment_metadata 两个登记类 meta;其余全部表的所有文本列都必须纳入扫描——图片引用可能出现在任何一张表的任何一个文本字段中。主要来源:
- 文章表:非附件行的 post_content / post_excerpt / post_title
- postmeta:全部 meta_value,排除
_wp_attached_file/_wp_attachment_metadata两个登记类 meta(否则登记路径会被误判为引用) - options:全量 option_value(小工具 widget_*、主题设置 theme_mods_*、site_logo 等)
- WPML 翻译表(icl_translate)、评论表(comments)
- 其余全部表:
SHOW TABLES+SHOW COLUMNS自动发现文本类列(varchar/char/text/longtext/json)并全量取值(覆盖 termmeta、terms 菜单/分类、Gravity Forms 表单设置与提交、WPML 字符串、用户、插件自有表等)——这是"图片不在文章表中"的核心兜底
第 3 步:收集"附件 ID 指名引用"(来源 B)
核心原则:凡是数据库中以附件 post_id 指名引用的位置,都是来源 B,按两大类收集:
类 1 · 附件表中的特殊引用(结构化键,明确 meta 键 / option 名):
- postmeta:
_thumbnail_id(特色图)、_product_image_gallery/_variation_image_gallery(产品/变体画廊)、_menu_item_icon/_menu_item_attachment_id(菜单)等,meta_value 是附件 post_id → 对应附件标记引用(证据注明引用者文章 ID); - options:
site_logo/custom_logo/background_image/header_image等,值为附件 ID 时 → 对应附件标记引用(值为 URL 的走第 4 步路径检索); - 其他值为附件 ID 的图片字段(ACF 等)同理。
类 2 · 附件表外所有表的附件 ID 引用(文本指名,与第 2 步同范围):
- 全站文本中出现附件 ID(Gutenberg 块
{"id":123}、短代码/
、链接?attachment_id=123、构建器媒体 ID 等)→ 对应附件标记引用。
第 4 步:从全站内容提取引用路径(来源 A)
基于第 2 步收集的"全站内容"(除附件登记数据外的所有表),提取所有引用路径:
- 正则匹配
uploads/<路径>(兼容wp-content/uploads/与uploads/两种前缀、URL 编码urldecode、去除查询参数/锚点); - 得到路径集合;再取其 basename 集合(兜底:换域名 / 相对路径引用)。
第 5 步:标记与传播
- 插件/特殊目录 →
plugin; - 命中来源 A(路径精确 / basename / 全文包含)或来源 B(附件特殊引用状态)→
referenced; - 附件组传播:原图与其全部尺寸图/缩略图视为一组,组内任一
referenced→ 整组referenced(即:先判原图,尺寸图跟随原图;原图未标记 → 整组unused); - 其余 →
unused(evidence 注明是否有媒体库登记 post_id,供核对,不影响结论)。
五、脚本实现说明
5.1 目录结构(产出物集中在单独的自定义目录)
| 文件 | 类型 | 作用 |
|---|---|---|
db_config.php |
配置 | 数据库连接参数(scan / 页面共用,建议 chmod 600);返回关联数组:host / port / dbname / user / password / prefix |
scan.php |
PHP CLI 脚本 | 连接 MySQL + 扫描 uploads + 标记引用 → 生成 useless_files.json |
media_clean.php |
PHP Web 单文件 | 展示清单 / 预览 / 下载 / 删除(含安全校验) |
useless_files.json |
数据 | 全量文件索引 + 引用标记结果 + 引用证据(扫描产物) |
delete_log.jsonl |
日志 | 每次删除/移入回收站的操作记录 |
trash/ |
目录 | 回收站(删除的文件先移到这里,不直接删) |
5.2 scan.php 关键实现点
- 数据库读取:
PDO直连;wp_posts(附件行 guid + 非附件行内容)、wp_postmeta(含登记类与特殊引用 meta)、wp_options(全量)、icl_translate、comments、其余表全文本列; - 附件组构建:
_wp_attached_file/ guid 找原图,_wp_attachment_metadata.sizes把尺寸图归属到原图; - 来源 A:拼接全站内容 → 提取
uploads/路径集合与 basename 集合; - 来源 B:
_thumbnail_id、_product_image_gallery、site_logo等指向的附件 ID 集合; - 标记 + 传播:插件目录 → plugin;来源 A/B 命中 → referenced;尺寸图随原图 → 整组同状态;未标记 → unused;
- 输出 JSON:含 meta(扫描时间、总数、三态计数)与 files(rel/name/size/mtime/ext/is_image/status/evidence);evidence 为字符串数组(来源 + 命中内容,如
["内容引用: 文章 ID 12", "特色图: 文章 ID 8"]),供页面展示与人工复核。
5.3 media_clean.php 功能(Web 页面)
- 列表区:分 Tab ——「无用文件」(默认,未标记引用的文件) /「插件/特殊目录」/「被引用」;列:勾选框、缩略预览、文件名、相对路径、大小、修改时间、判定依据/引用证据;
- 筛选:关键字搜索、扩展名筛选;
- 预览:图片在表格内
<img>缩略预览,点击放大; - 下载:勾选后批量打包下载(ZipArchive 生成临时 zip),或单文件直下 readfile;
- 删除:勾选 → 二次确认弹窗 → 默认"移入
trash/<相对路径>"(保留相对结构便于还原),可切换直接删除;每步写delete_log.jsonl。
六、安全与注意事项
- 先备份再删:全程在备份环境执行;回收站保留 7 天双保险。
- 判定只漏判、不误判:宁可把"疑似无用"标为证据较弱供人工确认,也不自动全删。
- 插件目录不参与判定:cache、wc-logs、cartflows-logs、wpcom、astra-sites、flags、woocommerce_uploads、wpforms、gravity_forms 等插件自用目录单独列出、默认不勾选。
- 性能:判定数据一次性读入内存;全库全文约 10MB,数百文件 × 全文/集合检索 1~2s;页面读 JSON 无数据库开销。
- 删除权限:备份文件可能为 root 属主,页面删除失败时提示先
chown -R www:www wp-content/uploads(宝塔默认 www 用户)或手动执行终端命令删除。 - 域名差异:guid 中域名可能与环境不一致,比对一律用"uploads 之后的路径"而非完整 URL。
- 凭据安全:数据库密码记录于
db_config.php,建议 doc 目录在 nginx 中禁止访问、chmod 600;任务结束后修改该账号密码。
附录:清理附件表中的"幽灵记录"(文件已删,记录残留)
本工具只删除实际文件,不删除数据库记录。文件清理完成后,附件表(wp_posts 中 post_type='attachment' 及其 postmeta)里仍会残留已删文件的登记记录,媒体库中显示为破图。如需彻底清理,按以下步骤操作。
1. 前置条件(必须)
- 文件清理已全部完成(本附录不参与清理过程,任何文件操作都必须在此之前结束);
- 已备份数据库(
mysqldump); - 已确认这些记录对应的文件确实已从 uploads 删除(先跑完文件清理并核对);
- 只处理"文件不存在"的记录,不影响任何仍存在的文件。
2. 找出"文件不存在但记录存在"的附件
SQL 无法直接访问文件系统,用一个小脚本比对(复用 db_config.php 连接,改前缀后即可通用):
<?php
// cleanup_orphan_records.php — 找出已删文件的附件记录(CLI 运行:先只打印,核对后再执行删除)
$CFG = require __DIR__ . '/db_config.php';
$UPLOADS = dirname(__DIR__) . '/wp-content/uploads';
$pdo = new PDO(
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $CFG['host'], $CFG['port'], $CFG['dbname']),
$CFG['user'], $CFG['password']
);
$P = rtrim($CFG['prefix'], '_') . '_';
// 取所有附件登记的原图路径,检查文件是否仍存在
$rows = $pdo->query("SELECT pm.post_id, pm.meta_value FROM {$P}postmeta pm WHERE pm.meta_key='_wp_attached_file'")->fetchAll(PDO::FETCH_NUM);
$missing = [];
foreach ($rows as [$postId, $rel]) {
if ($rel === '') continue;
if (!file_exists($UPLOADS . '/' . ltrim($rel, '/'))) {
$missing[] = (int)$postId;
}
}
echo '文件已不存在的附件记录: ' . count($missing) . " 条\n";
echo 'post_id 列表: ' . implode(',', $missing) . "\n";
// ⚠ 人工核对无误后,再取消下面两行注释执行删除:
// $ids = implode(',', $missing);
// $pdo->exec("DELETE FROM {$P}postmeta WHERE post_id IN ({$ids}); DELETE FROM {$P}posts WHERE ID IN ({$ids}) AND post_type='attachment';");
3. 核对与执行
- 先运行脚本只打印(删除被注释),人工核对 post_id 清单;
- 确认无误后,取消删除注释再运行一次;
- 也可把打印的 post_id 列表手动带入 SQL 执行(将
{PREFIX}替换为实际表前缀,如wp_)。
4. 执行前必查的"反向引用"
删除记录前,确认这些附件没有被反向引用,否则应先处理引用(例如先恢复文件):
-- 被设为特色图 / 产品画廊的附件记录,不应删除
SELECT meta_key, meta_value
FROM {PREFIX}postmeta
WHERE meta_key IN ('_thumbnail_id', '_product_image_gallery')
AND meta_value IN (待删除的 post_id 列表);
- 有结果 → 该附件仍被使用,属于文件误删,应先从回收站恢复文件,再重新走引用判定;
- 无结果 → 可以执行删除。
5. 收尾
- 删除记录后刷新媒体库,确认不再显示破图;
- 保留本脚本与操作日志(可与
delete_log.jsonl一并归档); - 若站点使用 WPML 等翻译插件,可顺带核对
{PREFIX}icl_translations中对应元素是否一并清理。







![[ARM飞牛]网心云OES/OES PLUS刷飞牛OS教程](https://www.wifilu.com/wp-content/uploads/2026/01/20260104235946959306.webp)



