# 保哥笔记 — 织梦CMS教程 > 本分片含 35 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:织梦CMS教程 **生成**:2026-06-04 23:09:29 CST --- ## DedeCMS怎么用宝塔做百度自动推送?baiduapi.php脚本加定时任务 - URL:https://zhangwenbao.com/bt-baidu-api-dedecms-url.html - 分类:织梦CMS教程 - 发布:2021-09-16 | 更新:2026-06-01 - 摘要:手把手讲清DedeCMS的百度普通收录API推送:从archives与arctype三表join取当日链接、占位符替换、curl POST到百度接口的标准写法,再叠加宝塔每天定时任务、success与remain字段监控、错误码对照,以及和sitemap、IndexNow的协同,是套能复用三年的稳定方案。 - 关键词:宝塔面板,百度推送,计划任务,百度api,百度提交 > **TLDR**:摘要:手把手讲清DedeCMS的百度普通收录API推送。本文给能直接用的baiduapi.php完整脚本——从archives与arctype三表join取当日链接、占位符替换、curl POST到百度接口,再叠加宝塔每天定时任务、success与remain字段监控、错误码排错,讲和sitemap与indexnow的协同、避免推送脚本被外人触发的安全加固,是套能复用三年的方案。 > 摘要:手把手讲清DedeCMS的百度普通收录API推送。本文给能直接用的baiduapi.php完整脚本——从archives与arctype三表join取当日链接、占位符替换、curl POST到百度接口,再叠加宝塔每天定时任务、success与remain字段监控、错误码排错,讲和sitemap与indexnow的协同、避免推送脚本被外人触发的安全加固,是套能复用三年的方案。 保哥这两年帮人做织梦 DedeCMS 站点的 SEO 收尾工作,常被问到一个看似很基础但落地很麻烦的问题:怎么让百度第一时间抓到 DedeCMS 当天发布的新文章?答案不止是把 sitemap (https://zhangwenbao.com/discuz-portal-sitemap.html).xml 挂到搜索资源平台那么简单,真正能压缩百度收录时间的做法是把"普通收录 API 推送"接到 DedeCMS 的发布流程里,让每一篇文章一发布就被推送出去。本文把整条链路从原理、脚本、宝塔计划任务、监控、错误码到日常排错全部摊开来讲,是我自己在二十多个 DedeCMS 站点上反复跑过、稳定使用三年以上的方案。 ## 为什么 DedeCMS 站点必须自己写推送脚本 百度对 DedeCMS 这类相对老旧的 CMS 没有任何官方插件,搜索资源平台里 WordPress 的"百度搜索推送管理"只能装在 WP 上,DedeCMS 用户只能选这三条路: - 纯手工提交:每天打开站长后台,把当天链接粘到普通收录的"手动提交"框里。低于 50 篇还能忍,超过这个量就完全不现实。 - sitemap.xml 自动提交:把 sitemap 地址挂到普通收录的"sitemap"模块。优点是不用脚本,缺点是百度对 sitemap 的抓取节奏完全由它自己控制,热门站可能 30 分钟一次,冷门站可能 24 小时一次,对刚发布的内容并不及时。 - API 推送:拿到 token 之后,自己用 curl 把 URL POST 到 http://data.zz.baidu.com/urls。响应是即时的,被推送的链接会进入百度的优先抓取队列,平均抓取时间从 sitemap 的小时级压到分钟级。 三种方式可以叠加用,并不冲突。但要把"发布即推送"做出来,只有第三种能做到。下面整篇都是围绕第三条展开。 ## 普通收录 API 的配额规则与备案差异 百度搜索资源平台对 API 推送有明确的每日配额。配额值不是固定的 10 万,它跟两个变量挂钩: - 站点权重等级:搜索资源平台后台"站点信息"里能看到一个隐藏字段叫"接口推送配额"。新站普遍是 5000~10000 条/日,做了半年以上、有自然流量进来的站点能到 20000~50000,权重 5+ 的优质站点上限是 100000。 - 是否完成 ICP 备案:未备案站的配额会被压到正常值的 30%~50%,并且部分推送会直接被丢弃。这是在 2023 年百度内部一次反垃圾调整后开始严格执行的,做国内站不要心存侥幸。 实测数据:保哥手上一个 DedeCMS 旅游站,备案前每日配额显示 8000,实际能成功推送的不到 5000;备案下来之后第二周配额自动跳到 20000,全量都能推成功。所以"备案值不值"在这个语境下答案是肯定的。 ## DedeCMS 数据库里要查的三张表 DedeCMS 把一篇文章拆在三张表里存,要拿到完整的发布 URL 必须 join: - dede_archives:主表,存 id、typeid、title、pubdate、senddate 等通用字段。 - dede_arctype:栏目表,存 id、typename、typedir、ispart 等。typedir 是关键,它决定 URL 路径前缀,可能含 {cmspath} 占位符。 - dede_addonarticle:附属表,存正文 body、写法 redirecturl、自定义字段等,对推送来说一般用不到,但做"只推送有正文的文章"时需要它。 三张表里 typedir 的写法多半是 {cmspath}/news、{cmspath}/product/zhuangbei。{cmspath} 默认是空字符串(DedeCMS 系统参数 cfg_cmspath),但如果站点装在二级目录下例如 /cms,这个值就会是 /cms。脚本里必须把 {cmspath} 替换成实际值再拼接 URL,否则推过去的全是带占位符的非法链接。 ## 能直接用的 baiduapi.php 完整脚本 我现在线上跑的版本经过几轮迭代,比网上流传的最简版多了三块:错误日志、推送结果落地、已推送去重。完整代码: = {$dayBegin} AND a.pubdate <= {$dayEnd}"; $dsql->Execute('push', $query); $urls = []; while ($row = $dsql->GetArray('push')) { $dir = str_replace('{cmspath}', $cmsPath, $row['typedir']); $url = $siteUrl . $dir . '/' . $row['id'] . '.html'; $urls[] = $url; } // 去重:读取已推送清单 $pushed = file_exists($pushedDb) ? array_filter(array_map('trim', file($pushedDb))) : []; $todo = array_values(array_diff($urls, $pushed)); if (empty($todo)) { file_put_contents($logFile, date('Y-m-d H:i:s') . " 无新链接需要推送\n", FILE_APPEND); exit('no new url'); } // 调用百度推送接口 $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $apiUrl, CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_POSTFIELDS => implode("\n", $todo), CURLOPT_HTTPHEADER => ['Content-Type: text/plain'], ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); // 落地日志与已推送清单 $logLine = date('Y-m-d H:i:s') . " count=" . count($todo) . " http={$httpCode}" . " resp=" . trim($response) . ($curlErr ? " err={$curlErr}" : '') . "\n"; file_put_contents($logFile, $logLine, FILE_APPEND); if ($httpCode == 200) { $result = json_decode($response, true); if (!empty($result['success'])) { file_put_contents($pushedDb, implode("\n", $todo) . "\n", FILE_APPEND); echo 'OK pushed ' . $result['success'] . " urls\n"; } else { echo "FAIL " . $response . "\n"; } } else { echo "HTTP {$httpCode} error\n"; } ## 逐段拆解:脚本里每个细节都不是装饰 这段代码看起来很长,但每一行都对应一个曾经踩过的坑。 ## 用 DedeCMS 自己的 dsql 而不是新建 PDO 很多教程让你用 mysqli 或 PDO 直接连 DedeCMS 数据库,这在调试时方便,但在正式环境有两个问题:一是密码要硬编码到 baidu_push.php,泄露风险高;二是 DedeCMS 的 GBK/UTF-8 站点编码差异需要手动处理。直接 require include/common.inc.php 复用 DedeCMS 的 $dsql 全局对象,编码、字符集、连接池都自动解决。 ## 时间戳取当日范围而不是相对秒数 原始版本里很多人写 pubdate > time() - 86400,意思是"过去 24 小时"。这个写法的问题是:如果计划任务定在 23:50 跑,就会把昨天 23:50 之后到今天 23:50 之前的内容推一遍;但如果某天计划任务延迟了 30 分钟,就会漏掉前一天 23:50~00:20 的发布。用 mktime(0,0,0) 到 mktime(23,59,59) 锁住"自然日",跑得早跑得晚都是同一批数据。 ## arcrank = 0 过滤未审核稿 DedeCMS 的 arcrank 字段:0 是开放浏览,-1 是待审核,>0 是会员等级阅读权限。如果你的站点开了"投稿要审核",没加 arcrank = 0 这个过滤条件,就会把待审稿的草稿链接也推给百度,返回的是 404,相当于亲自给自己制造死链 (https://zhangwenbao.com/batch-detection-of-site-dead-links.html)。 ## typedir 必须做 {cmspath} 替换 这是被忽略最多的一行。线上看到过一个站,所有推送链接长这样:https://example.com{cmspath}/news/123.html。百度照样收,但收的全是 404。最后才发现该站装在 /site 二级目录里,cfg_cmspath 是 /site,但脚本作者没替换占位符,整月配额白白浪费。 ## 已推送去重 这是脚本对外不太显眼但很重要的一块。baidu_pushed.txt 文件按行存所有已推送 URL。每次跑都先做 array_diff,避免相同链接当天反复推送。百度文档没有明说重复推送会扣配额,但实测上:同一 URL 一周内推第二次以上的,会被记进 not_same_site 或干脆静默丢弃。该文件长期累积,每两个月可以人工清空一次,或者改成只保留最近 60 天。 ## 日志落地到 data/ 目录 用 data/ 而不是网站根目录是因为 DedeCMS 官方默认 nginx 规则里 data/ 不允许 PHP 执行,但允许写入和读取。日志放这里既能写又不会被外网当作脚本访问。 ## 宝塔计划任务的细致配置 宝塔的"任务类型"四选一里,这种场景应该用"访问 URL",因为它走的是宝塔自己的 curl,不依赖外部 cron 配置 PATH。具体步骤: - 登录宝塔面板,左侧"计划任务"。 - 点"添加任务"。任务类型选 访问URL。 - 任务名称填一个有辨识度的:DedeCMS 百度推送 - zhangwenbao.com。 - 执行周期:每天 23:50。理由后面有详述。 - URL 地址填 https://www.zhangwenbao.com/baidu_push.php?key=xxxx。key 是你脚本里加的访问 token,避免被外网随便触发。 - 勾上"日志保留"。宝塔每次执行的输出会被记录,方便事后核对。 为什么是 23:50?我在三类时间点都跑过对比:早上 6 点跑、下午 14 点跑、晚上 23:50 跑。前两个时间点都会漏掉之后发布的文章,第二天才推;23:50 跑能把当天 0~23:50 发布的全部捕捉到,剩下 23:50~00:00 这十分钟的发布留给次日的当天范围(因为 mktime 锁的是自然日)。如果你站点发文集中在白天,可以再多挂一个早晨 8:00 的任务做兜底。 ## 推送结果监控:怎么知道推送真的生效 百度 API 的响应 JSON 长这样: { "remain": 4985, "success": 12, "not_same_site": [], "not_valid": [] } 四个字段的实战含义: - remain:当天剩余配额。建议在脚本里把它打印到日志,连续几天 remain 接近 0 就说明配额吃满,需要去后台看看是不是申请提配额。 - success:本次推送成功条数。这个数字应该等于你 POST 上去的 URL 数。如果小于,说明有部分链接被丢弃。 - not_same_site:URL 域名跟 token 绑定的域名不一致。最常见原因是带 www 和不带 www 的差异、http 和 https 混用。 - not_valid:URL 格式非法。常见于 typedir 没替换 {cmspath}、或者站点 URL 里有空格、中文。 除了 API 返回,还要去搜索资源平台后台看"普通收录—资源提交—API提交"的趋势曲线,正常情况是每天一个柱子,柱子高度对应当天推送量。如果连续一周柱子高度等于配额上限的 90%+,就该考虑申请提额或缩减提交范围。 ## 常见错误码与排错 API 返回非 200 时按下面的对应表排查: - 400 site error:URL 里 site 参数和 token 不匹配,或 site 后没有跟域名。检查脚本里 parse_url 出来的 host 是不是真域名(不要把 IP 当 host)。 - 401 token is not valid:token 错了,或者域名换了 token 没换。重新到搜索资源平台领新 token。 - 403 over quota:今天配额吃满。明天再推。 - 404 site not exists:搜索资源平台里这个站还没添加,或被删了。先在后台把站点加上。 - 413 over body size:单次 POST 超过 5MB。把 todo 数组按 1000 一批切片再循环 push。 - 500 internal error:百度自己的故障,过一小时再试。 实战中 90% 的失败都是 400 和 401 这两类,根因都是 token 或 site 配错。剩下 10% 是配额。脚本里把 httpCode 和 response 都写日志,排错时直接 grep 错误码即可。 ## 进阶:和 sitemap、indexnow 一起用 很多人以为 API 推送上了 sitemap 就可以撤了。错。三种方式各管一段: - API 推送覆盖最近发布的内容,时效性强但每天有上限。 - sitemap.xml覆盖全站存量,百度按自己节奏抓,能修复 API 漏掉的旧文章。 - indexnow是必应、Yandex 系协议(百度 2024 年开始有限支持),可以补 API 之外的搜索引擎。 正确做法是三种全开。API 每天跑,sitemap 一次性挂上不再动,indexnow 在内容发布钩子里同步推。这套组合拳下来,新文章被百度收录的中位时间可以压到 10 分钟以内,而单靠 sitemap 通常要 4~24 小时。 ## 安全加固:避免推送脚本被外人触发 baidu_push.php 一旦放到根目录,外人就能通过 URL 直接访问。这有两个风险:被恶意刷接口拖垮服务器、或者被刷成"接口访问异常"导致百度封 token。两条防护写到脚本最顶部: // 1. 简单 key 校验 $expectedKey = 'p7Kb9vZmTw'; if (empty($_GET['key']) || $_GET['key'] !== $expectedKey) { header('HTTP/1.1 403 Forbidden'); exit('forbidden'); } // 2. IP 白名单(只允许本机或宝塔所在服务器调用) $allowed = ['127.0.0.1', '::1', '你的宝塔出网IP']; if (!in_array($_SERVER['REMOTE_ADDR'], $allowed, true)) { header('HTTP/1.1 403 Forbidden'); exit('not allowed ip'); } 计划任务里 URL 必须带正确的 key 才能跑,外网误触都被 403 拦掉。如果你担心 key 在 URL 里被记进访问日志泄露,改成 POST 请求或者加 nginx 层 deny 都可以。 ## 额外细节:DedeCMS 列表页与详情页 URL 一致性 有些站点把 DedeCMS 的"伪静态 (https://zhangwenbao.com/discuz-portal-list-rewrite.html)"开了一半:列表页用伪静态、详情页还是 plus/view.php?aid=123。这种情况下 typedir 拼出来的 URL 是伪静态形式,但实际访问是动态形式,百度收的是 plus/view.php?aid=123,跟你推送的不一致,最后表现为收录数远低于推送数。 解决办法:在 DedeCMS 后台"系统参数 - 性能选项"里把"是否使用伪静态"打开,并在 nginx 加上 dede 的伪静态规则。所有详情页都走 .html 之后,推送和实际页面就对得上了。 ## 常见问题解答 ## API 推送和主动推送有什么区别? 百度官方在 2022 年统一了术语,现在文档里只有"普通收录"和"快速收录"。普通收录 API 推送就是本文讲的免费版本,每天有配额;快速收录 API 推送是给已经在搜索资源平台获得"快速收录"权限的优质站点用的,时效性更强(分钟级)但权限要单独申请,并不是所有站点都能开。本文脚本走的是普通收录,DedeCMS 站点直接拿来用。如果你的站点拿到了快速收录权限,把 apiUrl 换成 http://data.zz.baidu.com/urls?site=xxx&token=xxx&type=daily 即可。 ## 推送脚本会不会和 DedeCMS 自身的 SEO 插件冲突? 不会。DedeCMS 自带的"百度推送"是在文章保存的 hook 里同步调一次 curl,本文方案是计划任务定时调一次。两者数据来源都是 dede_archives 表,重复推送被脚本里的 baidu_pushed.txt 去重过滤掉,只是日志条目会多一些。如果你已经装了 DedeCMS 的官方推送插件,可以保留,本脚本作为兜底兜住"插件偶发失败"的场景。 ## 为什么我的 success 总是 0,但 remain 又在减? 这个组合的根因几乎都是"URL 域名带不带 www 与 token 绑定的域名不一致"。百度后台领 token 时填的是 www.zhangwenbao.com,脚本里 siteUrl 写的是 https://zhangwenbao.com,推过去之后被认为不是同站,进 not_same_site 数组,但配额已经扣了。改成完全一致的形式即可。如果你的站点既走 www 又走非 www,要在百度后台为两个域名分别申请 token 并分别推送。 ## 每天推送上限是多少?没备案的站点会被惩罚吗? 普通收录的配额上限官方说是 10 万条/日,但实际是按站点权重等级动态分配,新站初始配额一般 5000~10000。未备案站点配额会被压到正常值的 30%~50%,且部分推送被丢弃。建议尽快完成 ICP 备案,备案下来后第二周左右配额会自动调高,对收录速度有肉眼可见的提升。 ## 计划任务说执行成功,但日志里看不到推送记录怎么办? 第一步检查脚本是否有写入权限。data/baidu_push.log 这个文件如果不存在且 data 目录权限不允许 PHP 写入,file_put_contents 会静默失败。手动 touch data/baidu_push.log && chmod 666 data/baidu_push.log。第二步检查宝塔计划任务的"日志保留"选项是不是开了,开了之后宝塔后台能看到任务每次的标准输出。第三步确认 URL 带的 key 参数和脚本里 $expectedKey 一致,否则脚本第一行就 exit 了,根本走不到推送。 ## 能不能改成实时推送,文章一发布就推? 可以。在 dede/archives_do.php 或 dede/article_add.php 里找到保存成功后的 hook 位置,加一段 curl 调用本文的 push 接口。但要注意:实时推送会让发布操作多一次外部 HTTP 请求,遇到百度服务慢的时候编辑器会卡几秒。建议异步触发:发布时只把 URL 写入一个待推送队列文件,由独立的计划任务每 5 分钟跑一次扫队列推送,编辑体验和推送时效可以兼得。 ## 脚本能用在 DedeCMS V5.6、V5.7、V5.8 之间吗? 能。DedeCMS 三个主流版本里 dede_archives 和 dede_arctype 的字段命名一直保持兼容,arcrank、typedir、id、typeid、pubdate 五个字段在所有版本都存在。cfg_cmspath 在 V5.6 之前叫 cmspath,如果你跑在很老的版本里要改一下变量名。我自己跑过 V5.7.108 和 V5.8.1,脚本无需改动。 ## 写在最后 把这套脚本接到你 DedeCMS 站点之后,第一周可以每天看一次日志,确认 success 数与发布数对得上、remain 配额是否合理。第二周开始基本就能放着不管。重点是任何 SEO 优化最终的判断标准只有一个:百度站长后台的"索引量"曲线是不是在涨。如果按本文做完一个月索引量没动,那不是推送的问题,要回到内容质量、站内结构、外链建设 (https://zhangwenbao.com/google-seo-manual-backlink-advanced-strategies-guide.html)上找原因,光靠推送 API 推不出排名。 保哥这套思路目前在 zhangwenbao.com 之外还有十几个 DedeCMS 站在用,最长的一个跑了三年没出过故障。脚本本身很短,但里面每一行的取舍都是一次踩坑总结。读到这里你已经具备从零搭建 DedeCMS 百度自动推送的全部背景知识,剩下的就是把代码贴到自己站点跑通。遇到具体错误码不知道怎么处理时,回来翻第十节的对照表基本都能定位。 ## 权威参考资料 ## 织梦DedeCMS上传FILEID:3报错完整修复指南 - URL:https://zhangwenbao.com/dedecms-upload-fileid.html - 分类:织梦CMS教程 - 发布:2021-09-16 | 更新:2026-06-02 - 摘要:DedeCMS批量上传图片提示FILEID:3但图其实传成功了,是响应被警告或BOM污染导致SWF前端解析失败。本文讲swfupload.php的修复改动、php.ini与.htaccess的预防配置、Flash退役后用WebUploader或HTML5原生File API的替代路径,以及session干扰排查。 - 关键词:织梦上传图片,DedeCMS问题排查,PHP输出缓冲 > **TLDR**:摘要:DedeCMS批量传图提示FILEID 3但图其实传成功了,是后端响应被警告或BOM污染导致SWF前端解析失败。本文讲修复的核心是清空输出缓冲、更彻底的在文件开头关警告输出,再深排BOM问题、给Flash退役后用WebUploader或HTML5 File API的现代化路径、php.ini与.htaccess的预防配置和session干扰排查。 > 摘要:DedeCMS (https://zhangwenbao.com/dedecms-commonly-used-batch-sql-statements.html)批量传图提示FILEID 3但图其实传成功了,是后端响应被警告或BOM污染导致SWF前端解析失败。本文讲修复的核心是清空输出缓冲、更彻底的在文件开头关警告输出,再深排BOM问题、给Flash退役后用WebUploader或HTML5 File API的现代化路径、php.ini与.htaccess的预防配置和session干扰排查。 保哥这两年接的织梦DedeCMS维护单子里,出现频率最高的报错之一就是后台批量上传图片时弹出的红色FILEID:3 ERROR。表象看起来很吓人,文章保存之后图片其实已经躺在服务器目录里了,只是前端的SWF上传组件没收到正确的成功回执,导致编辑器误以为上传失败。这篇文章我把这个错误的来龙去脉、修复步骤、以及背后涉及到的PHP输出缓冲机制,从头到尾讲清楚。 ## 错误现象与第一次定位 第一次遇到这个错误,是在帮一个老客户迁移织梦站点之后。客户反馈:在文章编辑器里点“批量上传”,选好图片,进度条跑完,右侧出现红色的ERROR,提示FILEID:3。但是奇怪的是,保存文章之后图片是能正常显示的,相当于“上传成功了,前端却告诉你失败”。 这种“服务端成功 + 前端报错”的组合,几乎必然指向响应内容被污染:服务端在返回给SWF的内容里,混进了不该有的字符(比如BOM、空格、warning、notice),导致SWF解析的时候fileid被当成了非预期的值。织梦上传报错里的数字(FILEID:1、FILEID:2、FILEID:3)正是SWF内部的状态码,3一般对应“响应不符合预期”。 那个客户的环境里,PHP版本从5.4升级到了5.6,error_reporting默认级别变高了,再加上模板里有未定义索引的警告输出,组合起来就把响应体打脏了。 ## 根因解析:SWF前端怎么解析后端响应 要修得彻底,得理解SWF(Flash)前端的解析逻辑。dedeCMS的swfupload.php在响应体里只输出三段格式之一: - 预览图片二进制流,前置Content-Type: image/jpeg头 - 纯文本FILEID:数字,告诉SWF这是哪个上传任务的状态 - 纯文本错误码ERROR:错误描述 SWF前端拿到响应后,先看Content-Type判断走哪条解析分支。如果是image/jpeg就解析为预览图,如果是text/html就尝试匹配/^FILEID:(\d+)/正则,匹配不到就走错误分支并把整个响应体作为错误描述弹出来。 问题就出在“匹配不到”这一步。SWF的正则要求响应体第一个字符就是F,前面不能有任何字节。一旦响应体第一个字节是空格、换行、UTF-8 BOM(EF BB BF)、HTML标签<、PHP警告里的
,正则就匹配失败,FILEID就变成undefined,前端用undefined拼接就成了“FILEID:NaN”或者奇怪的状态码。Flash player内部把这种undefined状态码归一化为3,所以你看到的就是FILEID:3。 ## 修复方案的核心:清空输出缓冲 根因明确之后,修复思路就有了:在向SWF输出真正的响应之前,强制清空PHP的输出缓冲区,把任何可能混进去的警告文本、HTML片段、空白字符全部丢掉,确保返回给SWF的字节流是干净的。 PHP提供了ob_end_clean()这个函数,作用是关闭最内层的输出缓冲并丢弃其内容。我们要做的就是在dede/swfupload.php文件中所有“输出最终响应”的地方前面,都加一句ob_end_clean()。 ## 第一处:FILEID输出之前 用编辑器打开/dede/swfupload.php,搜索这一行: echo "FILEID:" . $_SESSION['fileid']; 在它上面新增一行: ob_end_clean(); echo "FILEID:" . $_SESSION['fileid']; 这样无论之前有什么输出,全部被丢弃,SWF拿到的就是纯粹的FILEID:数字字符串,可以正确解析。 ## 第二处:图片二进制流输出之前 继续在同一个文件里搜索: header('Content-type: image/jpeg'); header('Content-Length: ' . strlen($_SESSION['file_info'][$id])); 这两行header()是用来回传图片二进制数据给前端预览的。同样,在它们上面插入一行: ob_end_clean(); header('Content-type: image/jpeg'); header('Content-Length: ' . strlen($_SESSION['file_info'][$id])); echo $_SESSION['file_info'][$id]; 注意一定要在header()之前清缓冲,因为header()函数有一个潜规则:在任何输出(包括空格、换行、警告)之后调用header()会触发“Cannot modify header information - headers already sent”致命错误,这种情况下连图片预览都返回不出来。 ## 第三处:错误响应之前 如果你的swfupload.php里有echo "ERROR:..."这种错误返回路径,同样在它前面加一行ob_end_clean()。错误响应被污染会让前端弹出比FILEID:3更难定位的报错,加这一行是廉价的保险。 ## 更彻底的方案:在文件开头关闭警告输出 清缓冲是治标。如果你的服务器经常有PHP警告产生,每次都要靠ob_end_clean()兜底也累。一劳永逸的做法是在swfupload.php开头屏蔽所有非致命错误的输出。 在文件最顶部(配合FormData、XMLHttpRequest最简洁。代码量小、无依赖、性能好。 我现在新接的DedeCMS站点都用方案三,把后端的swfupload.php改造一下,让它接收multipart/form-data格式的POST,前端用FormData直接POST文件,整个流程比SWF简单一个量级,且彻底避免FILEID:3类的字节污染问题。 ## 预防性配置:php.ini与.htaccess的关键参数 除了改代码,服务器配置层面也有几处影响FILEID:3类错误的关键参数。我自己的标准配置: php.ini里: upload_max_filesize = 20M post_max_size = 25M max_execution_time = 300 memory_limit = 256M display_errors = Off log_errors = On error_log = /var/log/php_errors.log default_charset = "UTF-8" output_buffering = 4096 zlib.output_compression = Off 这里output_buffering = 4096很关键——开启了PHP默认的输出缓冲,ob_end_clean()才有缓冲区可以清。zlib.output_compression = Off是因为某些版本的SWFUpload不能正确处理gzip压缩响应,关闭后避免兼容性问题。 .htaccess里: php_flag display_errors Off php_value error_reporting 0 Header unset Content-Encoding Header unset Vary 对swfupload.php这个特殊文件单独关闭压缩和Vary头,进一步降低响应被中间层污染的概率。 ## 跨服务器迁移的预检清单 FILEID:3很多时候是迁移触发的。我做迁移项目时有一份预检清单,过一遍能避开90%的迁移后上传问题: - 原服务器和目标服务器的PHP版本是否相同(5.4到5.6/7.x跨大版本一定会有警告变化) - error_reporting和display_errors设置是否一致 - 所有PHP文件是否UTF-8无BOM - data/、uploads/、dede/目录的写权限是否正确(一般755 + 所有者www用户) - 是否启用了output_buffering - 是否在Nginx或Apache配置里开了gzip输出压缩(开了的话swfupload.php要单独排除) - 客户端是否是支持Flash的浏览器(如果不是,需要切到HTML5替代方案) 这7条逐项检查,迁移后第一次进后台上传图片时99%会一次成功。 ## Session机制的潜在干扰 FILEID:3还有一类隐性原因跟SESSION有关。SWFUpload组件需要在每次请求时携带session id(一般通过URL参数或cookie),但Flash player在某些情况下不会自动带cookie。如果你的swfupload.php开头的session_start()拿不到正确的session id,$_SESSION['fileid']会是空,输出就成了FILEID:(冒号后什么都没有),前端解析失败也会归类为FILEID:3。 排查方法是在swfupload.php里加一行临时调试代码: file_put_contents('/tmp/swf_debug.log', date('Y-m-d H:i:s') . " sid=" . session_id() . " fileid=" . ($_SESSION['fileid'] ?? 'NONE') . "\n", FILE_APPEND); 触发一次上传,去看/tmp/swf_debug.log。如果sid每次都不一样、且fileid=NONE,那就是session没传过来。修复方法是在swfupload.php开头手动接受URL里的session id: if (!empty($_GET['PHPSESSID'])) { session_id($_GET['PHPSESSID']); } session_start(); 同时前端调用SWFUpload的时候要把session id传过来:upload_url: 'swfupload.php?PHPSESSID=' + sessionID。 ## 真实案例:一次跨域FILEID:3的排查全过程 2024年我接过一个比较棘手的案例,分享一下完整排查流程,能给类似环境的同行参考。 客户站点本身在国内主机,但他们做了一个反向代理 (https://zhangwenbao.com/apache-proxy.html)用海外CDN加速,上传图片是直接传到主站,下载图片走CDN。客户反馈是“上传一会儿成功一会儿FILEID:3,没规律”。 我先在浏览器F12打开Network面板做几次上传复现,发现成功的请求响应体是FILEID:1234,失败的响应体开头是
Notice: Undefined variable: cfg_cookie_encode。这条警告很关键——cfg_cookie_encode是DedeCMS的全局配置变量,应该在common.inc.php引导时定义。出现undefined意味着引导链断了。 继续追,我登录服务器看swfupload.php的include链。文件第一行是require_once 'config.php',config.php里又require了common.inc.php。把include路径打印出来后发现——common.inc.php有时候被include成功,有时候失败。 失败时的错误日志是open_basedir restriction in effect。客户的服务器开了PHP的open_basedir限制,但这个限制路径在某次安全加固时被改窄了,正好把common.inc.php所在的路径排除在外了。安全加固那次是凌晨执行的,没人测试过DedeCMS后台。 修复方法是把open_basedir路径加上DedeCMS的根目录。改完后所有上传都恢复正常,FILEID:3彻底消失。 这个案例的启示是:FILEID:3不一定是swfupload.php本身的问题,可能是它依赖的任何一个上游组件出了问题,导致响应体被警告污染。排查时一定要看响应体的完整内容,而不是只看错误码。 ## 常见问题解答 ## 修改swfupload.php之后还是报FILEID:3怎么办? 按这个顺序排查:第一,检查文件保存时是不是又加了BOM(用head -c 3 swfupload.php | xxd看头三个字节是不是EF BB BF);第二,检查PHP error_log里有没有警告,把所有警告对应的代码修掉或屏蔽;第三,看Nginx/Apache的访问日志,确认请求确实到达了swfupload.php而不是被某个rewrite规则拦截了;第四,检查output_buffering是不是被改成Off了——某些定制的php.ini会关掉它,ob_end_clean()就形同虚设。 ## 能用ob_clean()代替ob_end_clean()吗? 不能完全替代。ob_clean()只清空当前缓冲区内容,但不关闭缓冲;ob_end_clean()清空内容并关闭最内层缓冲。在swfupload.php这种“需要确保后续输出不再经过缓冲”的场景,必须用ob_end_clean()。如果你用ob_clean(),可能清完之后又被缓冲累积污染。 ## FILEID:1和FILEID:2分别代表什么? FILEID:1表示“上传文件大小超过upload_max_filesize限制”,FILEID:2表示“上传过程被中断或超时”。这两个错误是真实的功能性错误,不是响应污染——遇到FILEID:1要把upload_max_filesize和post_max_size调大;遇到FILEID:2检查max_execution_time和网络稳定性。只有FILEID:3是“响应被污染”这类隐性问题。 ## 用Nginx做反向代理后SWF上传还能正常吗? 能,但要注意几个配置。Nginx的client_max_body_size必须比PHP的upload_max_filesize大,否则文件根本传不到PHP;fastcgi_buffer_size和fastcgi_buffers要调大,避免大响应被截断;fastcgi_read_timeout调到120s以上避免大文件上传超时。这几个不调好,SWF上传时会出现各种诡异错误。 ## Flash已死,直接卸了所有跟SWFUpload相关的代码可以吗? 可以,但要先确认替代方案落地。直接卸掉SWFUpload不部署HTML5替代方案,整个DedeCMS后台的图片上传功能就完全瘫痪了。建议步骤是:第一步部署HTML5上传组件并测试通过;第二步并行运行两套上传1-2周;第三步确认HTML5方案稳定后再下线SWFUpload相关代码。 ## 修改swfupload.php会被DedeCMS的版本升级覆盖吗? 会。DedeCMS官方升级包会覆盖dede/目录下的所有核心文件。如果你做了上面那些修改,每次升级都要重新打补丁。我自己的做法是把所有自定义修改记录在一个patch/swfupload.diff文件里,升级后用patch -p0 < patch/swfupload.diff一键应用。如果有自动化部署脚本,把这一步加到部署流程末尾。 ## 如果只是开发环境出现FILEID:3,生产环境正常,要修吗? 建议修。开发环境出现说明你的代码里有警告输出,只是恰好被某些线上环境的display_errors=Off掩盖。一旦运维误改了线上配置(比如临时打开display_errors调试别的问题),生产环境就会立刻暴雷。健康的做法是把警告本身修掉或显式屏蔽,不要依赖运维侧配置兜底。 ## 这套修复方法能用在DedeBIZ或DedeCMSV6上吗? 能。DedeBIZ和DedeCMSV6基本继承了原版的SWFUpload架构,swfupload.php路径和核心逻辑几乎一致,ob_end_clean()兜底、关闭警告输出、去BOM这三步在两个分支上都直接生效。但DedeBIZ在2024年版本之后默认改用了UploadHelper(HTML5方案),新装的DedeBIZ站点可能根本没有swfupload.php文件——这种情况下FILEID:3不会出现,遇到上传问题要往HTML5上传组件方向排查。 ## DedeCMS手机端图片溢出修复:3种实战方案 - URL:https://zhangwenbao.com/dedecms-mobile-picture-adaptation.html - 分类:织梦CMS教程 - 发布:2021-02-10 | 更新:2026-06-02 - 摘要:织梦DedeCMS的文章正文img标签里硬编码width和height导致手机端图片溢出,纯CSS解决不了。本文从HTML源头讲清楚根因,给出extend.func.php注册MobileImgFix函数的可回滚方案、数据库批量改写脚本、阿里云OSS与七牛云的图片处理参数适配,以及移动端LCP优化要点。 - 关键词:图片自适应,织梦手机端,响应式设计,Web性能优化 > **TLDR**:摘要:织梦文章正文的img标签里硬编码了width和height,手机端图片溢出,纯CSS解决不了。本文从HTML源头讲清根因,给三种方案——用extend.func.php注册MobileImgFix函数在输出阶段动态剥尺寸的可回滚法、批量改写数据库存量、纯前端JS应急,再讲阿里云OSS与七牛云的图片处理参数适配和移动端LCP优化。 > 摘要:织梦 (https://zhangwenbao.com/dedecms-batch-modify-article-naming-rules.html)文章正文的img标签里硬编码了width和height,手机端图片溢出,纯CSS解决不了。本文从HTML源头讲清根因,给三种方案——用extend.func.php注册MobileImgFix函数在输出阶段动态剥尺寸的可回滚法、批量改写数据库存量、纯前端JS应急,再讲阿里云OSS与七牛云的图片处理参数适配和移动端LCP优化。 我从2013年开始接触织梦DedeCMS,那会儿一边给本地中小企业做企业站,一边折腾自己的博客,前前后后给大概四十多个DedeCMS站点做过手机端适配。今天这篇我把处理“文章正文图片在手机端撑破屏幕”这个老大难问题的完整方案写下来,包含我踩过的坑、最终落地的代码、以及为什么这么写。 这篇文章面向的是用DedeCMS 5.7 SP2、UTF8或GBK版本搭建的站点,文章页通过{dede:field.body/}标签输出正文的场景。如果你的内容是在2018年以前用旧版CKEditor或FCKEditor录入的,几乎一定能在文章里搜到带硬编码width/height的img标签——这正是这篇文章要解决的核心。 ## 问题到底出在哪里:从HTML源头说起 先复现现象。我自己有个2014年上线的客户站,2018年之前几百篇老文章都是用旧版CKEditor录入的。作者插入图片时习惯直接拖入,编辑器会自动把图片真实像素宽高写进img标签,长这样: 示例 或者更糟糕——把宽高直接写进了内联style: 这两种写法在PC端没问题,正文容器一般有800px以上的可用宽度。但是切到手机端模板,正文容器宽度可能只有360px,浏览器看到width="800"这个硬编码值,就会按800像素渲染,结果就是图片把整个页面横向撑开,用户必须左右拖动才能看完,体验非常糟糕。 Google的Mobile-Friendly Test会直接判定这种页面Mobile Unfriendly,“点击元素彼此过近”“内容超出视口宽度”两条警告会同时触发。我那个站点在2019年上半年就因为这个问题,移动端关键词排名掉了一大截,后来才意识到原因。修复后两个月,移动端流量回升大约35%。 ## 为什么纯CSS方案搞不定这个问题 很多教程会说“在手机端样式表里加img { max-width: 100%; height: auto; }就完事了”。这话只对一半。 这条CSS规则确实可以让图片宽度不超过容器,但它解决不了HTML里硬编码的width和height属性带来的问题。具体来说: - 如果img同时有width="800" height="533"属性和max-width:100%; height:auto样式,浏览器会按CSS缩放宽度到容器宽度,这部分没问题。 - 但height属性会被当作原始尺寸约束,浏览器在等比缩放时,老版本Safari和某些国产浏览器会出现高度计算错误,图片被压扁或者拉长。 - 内联style里的width:800px优先级高于外部CSS的max-width:100%,会直接覆盖掉移动端样式规则。 - 带onload属性触发的JS缩放(旧版CKEditor的图片插件会写这种代码)会跟CSS冲突,部分浏览器表现为闪烁后还原成原尺寸。 我当年就吃过这个亏,加完CSS在Chrome模拟器里看着挺好,结果用户拿真机一访问,部分图片还是溢出。最后排查半天,发现问题就是内联style没被覆盖。 所以正确的做法是:从HTML源头把硬编码尺寸属性全部去掉,再配合CSS的max-width:100%,才能保证万无一失。 ## 方案一:用PHP在输出阶段动态剥离尺寸属性(推荐) 这是我现在用的标准方案。它的好处是不动数据库里的存量内容 (https://zhangwenbao.com/revise-old-content-for-aeo-ai-search-optimization.html),只在内容输出到页面时实时清洗。如果以后想回滚,把PHP逻辑去掉即可,原始内容毫发无伤。 具体实现:在DedeCMS的核心模板文件article_default.htm(或你正在用的文章模板)里,找到{dede:field.body/}这一行。它的作用是输出文章正文。我们要把它替换成一段PHP代码,对正文做正则清洗后再输出。 但DedeCMS模板里直接写PHP是行不通的——模板引擎会把它当成纯文本输出。需要先在/include/extend.func.php里定义一个自定义函数,再在模板里通过function调用。 打开/include/extend.func.php,在文件末尾追加: ]*?)\s+width\s*=\s*["\']?\d+(?:px)?["\']?/i', '$1', $body); $body = preg_replace('/(]*?)\s+height\s*=\s*["\']?\d+(?:px)?["\']?/i', '$1', $body); // 第二步:清理内联 style 里的 width 和 height 像素声明 $body = preg_replace_callback( '/]*?)style\s*=\s*["\']([^"\']*)["\']/i', function ($m) { $cleaned = preg_replace('/(?:^|;)\s*(width|height)\s*:\s*\d+(?:px|%|em|rem)?\s*(?=;|$)/i', '', $m[2]); $cleaned = trim($cleaned, ' ;'); if ($cleaned === '') { return ']*?)\s+onload\s*=\s*["\'][^"\']*["\']/i', '$1', $body); return $body; } ?> 保存文件后,回到模板。把原来的: {dede:field.body/} 替换成: {dede:field name='body' function='MobileImgFix(@me)'/} 这个语法是DedeCMS模板引擎支持的“函数过滤”用法,@me会被替换成body字段的当前值。整段流程下来,文章页输出时所有img标签里的硬编码尺寸属性都会被实时清洗。 清洗完成后,再在主题的全局CSS里加一行: .article-body img { max-width: 100%; height: auto; display: block; margin: 12px auto; } 这样图片在任意屏幕宽度下都会等比缩放,不会撑破容器,也避免了老Safari的高度错算。 ## 方案二:批量改写数据库里的存量数据 方案一不动存量数据,但有一个副作用:每次页面渲染都要跑一次正则,对页面缓存命中率有微弱影响(开了静态化可以忽略)。如果你的站点对性能极致敏感、且不打算回滚,可以选择方案二,直接把数据库里的存量内容改写一遍。 这个动作风险比方案一高,必须先备份。备份命令: mysqldump -u 用户名 -p 数据库名 dede_addonarticle > addonarticle_backup_$(date +%Y%m%d).sql 然后写一个PHP脚本,遍历dede_addonarticle表的body字段,套用上面MobileImgFix函数的逻辑后写回。脚本核心: require_once 'include/common.inc.php'; $dsql->SetQuery("SELECT aid,body FROM `#@__addonarticle`"); $dsql->Execute(); $count = 0; while ($row = $dsql->GetArray()) { $cleaned = MobileImgFix($row['body']); if ($cleaned !== $row['body']) { $cleaned = addslashes($cleaned); $dsql->ExecNoneQuery("UPDATE `#@__addonarticle` SET body='$cleaned' WHERE aid={$row['aid']}"); $count++; } } echo "已清洗 $count 条文章"; 跑完后随便挑10篇文章打开看一眼源码,确认img标签里没有width/height属性即可。 这个方案的不可逆性需要警惕:一旦数据库里的内容被改写,原始尺寸属性就丢了,不能再恢复。所以备份SQL文件至少要保留3个月以上。 ## 方案三:纯前端JS兜底(应急用) 如果你暂时不能改主题模板,也不愿意动数据库,还有一种纯前端兜底方案。在footer.htm底部加这段JS: 这个方案的缺点很明显:在JS执行之前,浏览器已经按硬编码尺寸渲染了一遍,用户会看到一瞬间的“图片溢出再缩回”抖动。Google的Page Speed Insights也会扣分(CLS指标受影响)。所以这只是过渡方案,最终还是要切换到方案一或方案二。 ## 不能忽略的细节:阿里云OSS和七牛云的图片处理 如果你的DedeCMS站点把图片放到了阿里云OSS、七牛云、又拍云这类对象存储,并且用了它们的图片处理功能,事情会更复杂一些。 这些云存储一般支持URL参数化的图片处理,比如阿里云OSS的?x-oss-process=image/resize,w_800会让OSS服务端返回宽800px的缩图。如果你之前是按PC尺寸预设了URL参数,手机端拿到的还是800px大图,浪费流量。 处理思路是:在MobileImgFix函数里加一段动态根据视口宽度调整URL参数的逻辑。但视口宽度只有客户端知道,所以这段逻辑要写在前端而不是后端。具体可以搭配srcset属性: $body = preg_replace_callback( '/]*?>/i', function ($m) { $base = $m[1]; $sep = (strpos($base, '?') !== false) ? '&' : '?'; $small = $base . $sep . 'x-oss-process=image/resize,w_400'; $medium = $base . $sep . 'x-oss-process=image/resize,w_800'; $large = $base . $sep . 'x-oss-process=image/resize,w_1200'; return sprintf( '', $medium, $small, $medium, $large ); }, $body ); 加完之后浏览器会根据视口和DPR自动选最合适的图片版本,移动端流量能省掉一半以上。 ## 七牛云的特殊坑:URL裁剪参数与CDN缓存 七牛云的图片处理参数是?imageView2/2/w/800这种格式,处理逻辑跟阿里云OSS不一样。如果你的站点早期用的是七牛旧版API(?imageView/2/w/800,不带imageView2),切换到新参数时要在七牛后台手动刷新一遍CDN缓存,否则会出现“同一张图URL变了但CDN还在返回旧版本”的奇怪现象。 我去年在一个客户的迁移项目里栽过这个跟头。改完模板上线,开发环境一切正常,结果生产环境的部分图片返回的还是旧URL的版本(CDN边缘节点没刷),以为代码写错了,排查了一晚上才反应过来。从那之后,凡是涉及七牛云URL变更的,我都在发布脚本最后加一行qrefresh命令把CDN刷干净。 ## 移动端图片懒加载与LCP优化 解决了图片溢出之后,下一步要考虑的是首屏图片的Largest Contentful Paint(LCP)指标。Google Core Web Vitals (https://zhangwenbao.com/core-web-vitals-ai-search-industry-benchmark.html)对LCP的阈值是2.5秒,超过这个值会扣分。 对DedeCMS来说,常见做法是给所有非首屏图片加loading="lazy",让浏览器在用户滚动到附近时才发请求。但要注意:首屏图片绝对不能加loading="lazy",否则LCP指标会变得更糟,因为lazy load会引入额外的判断延迟。 我的处理方式是在MobileImgFix里只给“文章正文里的非第一张图”加lazy标记。判断方法是用一个静态变量在循环里跟踪是否是第一张img: function MobileImgFix($body) { static $isFirstInArticle = true; $body = preg_replace_callback('/]*?)>/i', function ($m) use (&$isFirstInArticle) { $tag = ''; }, $body); return $body; } 这样首屏图给浏览器fetchpriority="high"提示让它优先抓取,之后所有图都lazy load,LCP指标能压在1.5秒以内。 ## 常见问题解答 ## 方案一的MobileImgFix函数会不会影响PC端的图片显示? 不会。剥离width/height属性后,PC端的图片宽度会被CSS的max-width:100%限制在文章容器宽度(一般800px左右)以内,超过的会等比缩放,不超过的按原始尺寸显示。如果你希望PC端的图片显示比容器宽(撑出版心),可以把CSS改成.article-body img { max-width: 1024px; height: auto; },但要注意PC端最大宽度不要超过对象存储返回的最大版本,否则会糊。 ## 用了{dede:field name='body' function='MobileImgFix(@me)'/}之后,DedeCMS的SEO插件还会正常工作吗? 会,因为function调用是在模板渲染阶段处理的,DedeCMS的SEO相关字段(seotitle、keywords、description)都是从主表读取,跟body字段无关。但要注意:如果你装了某些会扫描正文做关键词提取的插件,那些插件读取的可能是数据库原始内容(包含尺寸属性),不影响功能但会导致提取结果中有HTML残留。 ## 为什么有时候清洗完img标签,图片还是会变形? 三个常见原因:第一,CSS里有更具体的选择器(比如.article-body table img)覆盖了通用规则,要把所有相关选择器都加上max-width:100%; height:auto;第二,浏览器缓存了清洗前的页面,强制刷新(Ctrl+F5)就好;第三,图片本身在对象存储侧被裁剪了非等比的版本,源图就是变形的,这种要回到对象存储后台检查。 ## 方案二批量改写数据库时,能不能用LIKE做条件过滤减少更新量? 可以,能显著降低UPDATE量。在SELECT阶段加WHERE body LIKE '%width=%' OR body LIKE '%height=%' OR body LIKE '%style=%',只对真正包含尺寸属性的文章做处理。我自己跑过的一个站点1.2万篇文章,加这个过滤之后只命中了4300多篇,UPDATE耗时从15分钟降到5分钟以内。 ## 开了PageCache插件之后,方案一的清洗会被缓存吗? 会被缓存,这是好事。PageCache一般是把渲染后的HTML缓存到磁盘或内存,下一次同一URL请求直接返回缓存,MobileImgFix只在缓存未命中时跑。所以即便正则有性能开销,对生产环境的实际影响几乎可以忽略。但要注意PageCache和移动端模板的配合——如果是PC和移动同URL不同模板,要确保PageCache的key包含设备类型,否则会出现“PC用户拿到了移动端缓存”的串读。 ## 用srcset之后,DedeCMS的{dede:field.body/}调用方式还兼容旧主题吗? 兼容。srcset是HTML5标准属性,不支持srcset的老浏览器(IE11之前)会fallback到src属性指向的图片。也就是说,加了srcset对现代浏览器是优化,对老浏览器是无影响。但前提是src属性要保留并指向一个尺寸适中的版本(800px左右),不能光给srcset。 ## 这套方案在DedeCMS的二次开发分支(如DedeBIZ、DedeCMSV6)上还能用吗? 能,模板语法和函数过滤机制在DedeBIZ和DedeCMSV6里都保留了。但要注意:DedeCMSV6改了部分核心常量名,extend.func.php路径可能从/include/extend.func.php改到/system/extend.func.php,定位时先find . -name extend.func.php确认。模板里的函数调用语法是一致的。 ## 权威参考资料 ## DedeCMS文章页图片去宽高做响应式:arc.archives改造实战 - URL:https://zhangwenbao.com/dedecms-removes-height-width.html - 分类:织梦CMS教程 - 发布:2021-02-10 | 更新:2026-06-01 - 摘要:DedeCMS内容页图片自带的width和height属性在移动端会撑破布局。本文给出arc.archives.class.php里非贪婪preg_replace的安全版本,对比贪婪版吞文章的踩坑、DOMDocument替代方案,再讲MySQL批量清洗历史数据和srcset、WebP现代图片管线升级。 - 关键词:DedeCMS手机站,DedeCMS批量处理,织梦图片,技术SEO,DedeCMS > **TLDR**:摘要:DedeCMS内容页图片自带的width和height属性在移动端会撑破布局。本文给出改arc.archives.class.php的非贪婪preg_replace核心代码、对比更稳健但更慢的DOMDocument替代,再加CSS响应式样式兜底,讲老文章还变形怎么办、MySQL批量清洗历史数据、从去宽高升级到完整srcset的现代图片管线,附改完后的验收清单。 > 摘要:DedeCMS内容页图片自带的width和height属性在移动端会撑破布局。本文给出改arc.archives.class.php的非贪婪preg_replace核心代码、对比更稳健但更慢的DOMDocument替代,再加CSS响应式样式兜底,讲老文章还变形怎么办、MySQL批量清洗历史数据、从去宽高升级到完整srcset的现代图片管线,附改完后的验收清单。 织梦 DedeCMS 后台编辑器(FCK / CKEditor 简化版)在用户上传图片时会自动给 标签塞进 width 与 height 两个 HTML 属性,外加一段 style="width:xxx;height:xxx" 的内联样式。这套设计在 2008 年的 PC-only 时代是合理的——固定尺寸能让浏览器渲染时预留空间避免布局抖动。但在 2024 年的移动优先环境里,这种写法直接和响应式 CSS 冲突:HTML 的 width 属性优先级比 CSS 的 max-width 高,导致图片硬撑出屏幕外形成横向滚动条。本文按"原因 → 改源码 → CSS 兜底 → 历史文章修复 → 现代 srcset 升级 → 性能与 CLS 平衡"六个层面把这个问题完整解决,并补上 DOMDocument 替代正则、CDN 缓存清理、PHP 7-8 兼容、备份恢复等运维细节。 ## 为什么织梦会自动加 width 和 height ## FCKEditor 的历史背景 织梦 DedeCMS 5.6 / 5.7 时代默认使用 FCKEditor(后改名 CKEditor)作为富文本编辑器。FCKEditor 在 2007 年左右设计,那时候网页布局还是固定像素栅格、Flash 是主流交互方式、移动端 Web 几乎不存在。编辑器在插入图片时会读本地文件的真实像素尺寸,把它写入 width 和 height 两个 HTML 属性,再加一段 style 内联样式——这是当时的"良好实践",能让浏览器预先知道图片占多少空间提前计算布局。 ## 移动优先时代的冲突点 问题在 2014 年 Google 推 mobile-first indexing 后开始严重: - 响应式被破坏:现代主题用 max-width: 100%; height: auto 让图片自适应,但 HTML 的 width 属性优先级比 CSS 高,固定 800px 的图片会撑破 375px 的手机屏幕。 - 横向滚动条出现:图片溢出导致整个文章页可以左右滚动,用户体验崩塌。 - CLS 指标错位:HTML 固定尺寸 + CSS 等比缩放冲突时,浏览器对最终高度的计算可能滞后,触发 Layout Shift。 - 编辑器误差被放大:很多客户上传的是设计师切好的高清大图,宽度动辄 1920px,硬塞到手机屏上必然炸。 ## 处理原则 正确思路:HTML 上不留固定尺寸,让 CSS 完全控制布局。这要做两件事: - 在 PHP 层面把现有内容里的 width / height 属性和 style 内联样式去掉。 - 在 CSS 层面给图片加响应式样式作为兜底。 ## 修改 arc.archives.class.php 的核心代码 ## 定位修改点 打开 /include/arc.archives.class.php(DedeCMS 5.7 路径),用编辑器的全文搜索找到这段代码: // 设置全局环境变量 $this->Fields['typename'] = $this->TypeLink->TypeInfos['typename']; @SetSysEnv($this->Fields['typeid'], $this->Fields['typename'], $this->Fields['id'], $this->Fields['title'], 'archives'); 这两行通常出现在 GetArc 或 ParAddTable 方法里,作用是把当前文章的元数据写入全局环境。我们要在它后面紧接着插入正文 HTML 改造代码,让所有调用此类的位置都自动应用改造。 ## 三行 preg_replace 完整版 // 去掉正文里 img 标签的 width / height 属性和 style 宽度 $this->Fields['body'] = preg_replace('/style="width\s*:\s*[^"]*"/i', '', $this->Fields['body']); $this->Fields['body'] = preg_replace('/(]*?)\s+width=(["\']?)[0-9]+\2/i', '$1', $this->Fields['body']); $this->Fields['body'] = preg_replace('/(]*?)\s+height=(["\']?)[0-9]+\2/i', '$1', $this->Fields['body']); 三行各自的作用: - 第一行:去掉 style="width:600px" 这种内联样式(精确匹配,不依赖回溯避免贪婪问题)。 - 第二行:去掉 这种 HTML 属性。 - 第三行:去掉 这种 HTML 属性。 ## 正则贪婪匹配的坑 网上很多老教程给的版本第一行写的是 preg_replace('/style="width:(.*)"/', ...)——用了贪婪 (.*)。这个写法在下面这种正文里会出问题:

这是一段话

另一段

贪婪正则会从第一个 style="width: 一路吃到最后一个 ",把中间所有 HTML 全删掉。整篇文章被吞。修复方案有两个: - 把 (.*) 改成 (.*?) 加问号变成非贪婪。 - 更稳妥的写法是用字符类 [^"]* 明确"不包含双引号的任意字符"——这样根本不需要回溯,性能也更好。 我现在线上用的是字符类版本,零回溯零踩坑。 ## 修改后必须重新生成静态页 这是新手最容易忽略的一步。织梦默认把每篇文章生成静态 HTML 文件存在 /a/ 目录下,老文章的静态页是在你修改代码之前生成的,里面已经写死了 width/height 属性,新代码不会主动重生成它们。修完代码必须: - 登录后台 → 生成 → 更新文档 HTML → 选择全部栏目 → 开始更新。 - 文章数量大(比如几千篇)时这一步会跑很久,建议在低流量时段执行。 - 大批量更新可能触发 PHP 脚本超时,调高 max_execution_time 或者分栏目分批跑。 ## 用 DOMDocument 替代正则:更稳健但更慢 ## DOMDocument 改写方案 如果服务器是 PHP 7.0 以上且你对正则不放心,可以用 DOMDocument 解析正文: function stripImgSize($html) { if (empty($html)) return $html; $dom = new DOMDocument(); @$dom->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); foreach ($dom->getElementsByTagName('img') as $img) { $img->removeAttribute('width'); $img->removeAttribute('height'); $style = $img->getAttribute('style'); if ($style) { $style = preg_replace('/(width|height)\s*:\s*[^;]+;?/i', '', $style); if (trim($style) === '') { $img->removeAttribute('style'); } else { $img->setAttribute('style', trim($style)); } } } return $dom->saveHTML(); } $this->Fields['body'] = stripImgSize($this->Fields['body']); ## DOMDocument 的优劣 对比项 | 正则版本 | DOMDocument 版本 | 性能 | 快(线性扫描) | 慢 2-3 倍(建树) | 正确性 | 边界条件多 | 解析器级别可靠 | HTML5 支持 | 无 | libxml2 支持但不完整 | 嵌套属性 | 容易出错 | 正确处理 | HTML 实体 | 不解码 | 自动解码 | 自闭合标签 | 需要小心 | 正确识别 | 编码问题 | 需手动处理 | 需要 xml encoding 声明 | ## 适用场景 - 启用了静态页缓存的站点:DOMDocument 多 200ms 也无所谓,命中缓存后 0ms,选稳定性。 - 裸跑没静态化的高流量站点:建议正则版本,性能优先。 - 正文里经常出现奇怪的 HTML(嵌套属性、未闭合标签):DOMDocument 健壮性更高。 - 开发者团队对正则不熟悉:DOMDocument 维护起来心智负担小。 ## CSS 兜底:响应式样式必不可少 ## 主题 CSS 加响应式规则 光去掉 HTML 属性还不够,CSS 必须配合: .article-content img, .entry-content img, .post-content img { max-width: 100%; height: auto; display: block; margin: 1em auto; border-radius: 4px; } @media (max-width: 768px) { .article-content img { margin: 0.6em auto; } } ## 关键属性解释 - max-width: 100%:图片不超过容器宽度。 - height: auto:宽度变化时自动等比缩放高度,避免变形。 - display: block:图片单独成块,配合 margin auto 居中。 - margin: auto:水平居中。比 text-align: center 在父元素上更可靠(text-align 只对 inline 元素生效,display block 后失效)。 - border-radius: 4px:轻微圆角,视觉上更温和。 ## aspect-ratio 进阶用法 2021 年 CSS 新增 aspect-ratio 属性,可以让浏览器在图片加载前就预留正确高度,消除 CLS: .article-content img { aspect-ratio: attr(width) / attr(height); max-width: 100%; height: auto; } 但 attr() 在 aspect-ratio 上的支持还不普及,更稳妥的写法是 PHP 渲染时根据图片真实尺寸生成内联 CSS 变量: ... 这样既能保持响应式,又能预留布局空间——既不固定像素也不丢失尺寸信息。 ## 新文章正常但老文章还是变形怎么办 ## 原因:静态页已生成 这是改完代码后最常见的问题。织梦默认把动态文章生成静态 HTML 文件存在 /a/<栏目>/.html,老文章的静态页在修改代码之前就已经生成了,里面写死了 width/height 属性。代码改完后不主动重生成,老页面不会自动应用新逻辑。 ## 三种修复方法 方法 1:后台手动批量更新 登录后台 → 生成 → 更新文档 HTML → 选择全部栏目 → 开始更新。 文章多的话会跑很久(几千篇大概 30-60 分钟)。建议设置 PHP max_execution_time = 0(仅命令行有效)或者分栏目跑。 方法 2:直接在数据库跑 SQL 替换 MySQL 8 以上支持 REGEXP_REPLACE: UPDATE dede_addonarticle SET body = REGEXP_REPLACE(body, ' width=("[0-9]+"|[0-9]+)', '') WHERE body REGEXP ' width='; UPDATE dede_addonarticle SET body = REGEXP_REPLACE(body, ' height=("[0-9]+"|[0-9]+)', '') WHERE body REGEXP ' height='; UPDATE dede_addonarticle SET body = REGEXP_REPLACE(body, 'style="width\\s*:\\s*[^"]*"', '') WHERE body LIKE '%style="width%'; MySQL 5.7 没有 REGEXP_REPLACE,要写 PHP 脚本读出来 preg_replace 再写回去。务必先 mysqldump 备份。 方法 3:CDN 缓存清理 如果用了 CDN(Cloudflare、阿里云 CDN、腾讯云 CDN 等),改完源站要清 CDN 缓存: - Cloudflare:Caching → Purge Everything 或 Custom Purge 指定路径。 - 阿里云 CDN:刷新缓存 → URL 刷新 / 目录刷新。 - 腾讯云 CDN:缓存配置 → 缓存刷新。 ## 三种方法的组合 实操时通常三种结合用:先 SQL 把数据库里的属性清掉,再后台批量更新静态页,最后清 CDN 缓存。三步走完才算彻底搞定,否则总有一层缓存还在持有旧版本。 ## 现代化升级:从去 width/height 到完整 srcset ## 响应式图片的现代写法 2024 年的最佳实践不只是去掉固定尺寸,而是用 srcset + sizes 给浏览器多个分辨率选择: ... 这样浏览器会根据视口宽度和 DPR 自动选最合适的图片,节省流量、保持清晰。 ## WebP / AVIF 格式 用 给现代浏览器优先 WebP,老浏览器回退 JPEG: ... WebP 比 JPEG 平均小 25-30%,AVIF 比 JPEG 小 50%。同样视觉质量下大幅减少加载时间。 ## 织梦集成现代图片管线的难点 织梦原生不支持 srcset 和 picture。要集成现代化方案: - 修改后台上传脚本,生成多种分辨率的副本。 - 修改 arc.archives.class.php,在输出正文时把 重写为 ...。 - 或者用 CDN 的图片处理服务(Cloudflare Image Resizing、阿里云 OSS 图片处理等)做边缘转换,源站只存一张原图。 ## 常见问题解答 ## 去掉 width 和 height 属性后会不会让 CLS 变差 会有一定影响但实测下来不严重。CLS 主要看图片加载完成前后的布局变化。max-width: 100% 配 height: auto,再加服务端给图片包一层 aspect-ratio 容器或用 loading="lazy",可以把视觉跳动控制在可接受范围。如果对 Core Web Vitals (https://zhangwenbao.com/mobile-seo-mistakes-2026.html) 极度敏感,可以让 PHP 渲染时根据真实图片尺寸生成 aspect-ratio CSS 变量,这样既能保持响应式又能预留布局空间。 ## 原版只删 style 不删 width/height 属性的代码到底能不能用 能用但只解决了一半问题。织梦不同版本的 FCK/CKEditor 在插入图片时不一定都加 style 内联样式,有些版本只加 HTML 属性。所以单行替换在部分站点上看似没效果。完整三行版本才能覆盖所有情况不会漏。建议直接用三行版本一劳永逸。 ## 改完代码生成静态页后部分图片连图都没有了 大概率是正则贪婪匹配吃掉了图片的 src。检查代码是不是用了 (.*) 而不是 (.*?),或者按上文给的精确字符类版本重写。务必在测试站验证完再推到生产。如果已经在生产炸了,从备份恢复 dede_addonarticle 表即可。 ## 能不能完全交给前端 JS 去掉 width/height 不动 PHP 技术上可以,写一段 jQuery:document ready 后 $('.article-content img').removeAttr('width').removeAttr('height')。但不推荐,原因有两个:第一 JS 执行前那一瞬间用户已经看到了变形的图片体验不好;第二搜索引擎抓取的是原始 HTML 里面还是带固定尺寸,对移动友好度评分有影响。从根上在 PHP 输出阶段处理才是正解。 ## 修改 include 目录下的核心文件升级会被覆盖怎么办 织梦官方很久没主版本更新,升级风险其实不大。但如果要做安全补丁升级,建议把 stripImgSize 这种自定义函数封装到一个独立文件里(如 /include/custom_helpers.inc.php),在 arc.archives.class.php 里 require_once 这个文件然后调用函数。这样下次升级 arc.archives.class.php 时只要重新加一行 require 和一行调用即可,不会丢失改动。 ## 动态页面(不生成静态)需要这样改吗 需要而且更直接。动态页面每次访问都跑 arc.archives.class.php 的 GetArc,所以改完源码立刻全站生效不需要批量更新。性能开销在每次请求多 0.5-2ms(取决于正文长度)。如果用静态页缓存或反向代理 (https://zhangwenbao.com/apache-proxy.html)缓存,开销可以忽略。 ## 编辑器里能不能直接禁止上传时加宽高 可以但需要改编辑器配置。织梦 5.7 的 FCKEditor 在 /include/FCKeditor/fckeditor.js 或 /include/ckeditor/config.js 里有 image plugin 配置,找到生成 img 标签的代码删除 width / height 属性赋值即可。但这会让"对话框里的尺寸预览"功能失效——用户看不到图片有多大。权衡是否值得,看用户群体技术水平。 ## SEO 角度图片必须有 alt 属性吗 必须有且必须填实质内容。alt 属性是搜索引擎理解图片内容的唯一文本信号,缺失或填空字符串会失去图片搜索的所有流量入口。FCK 编辑器默认有 alt 输入框但默认不填。建议改造编辑器或写后端 hook 强制 alt 不能为空(实在不知道写什么用文章标题 (https://zhangwenbao.com/how-to-write-catchy-article-titles.html)兜底)。 ## PHP 7.4 升级到 PHP 8 后 stripImgSize 还能用吗 能用但要注意两点:(1) PHP 8 把 libxml_disable_entity_loader 标记为 deprecated 但 DOMDocument 默认安全;(2) preg_replace 的回调写法 preg_replace_callback 在 PHP 8 行为不变。本文给的所有正则代码在 PHP 5.4 到 PHP 8.3 上都验证过可用。如果你站点要升 PHP 版本,先用 PHPUnit 写几条针对性的测试覆盖各种 img 标签变体,升级后跑一遍即可。 ## 总结:从去宽高到现代图片管线的演进路径 "织梦 DedeCMS 去掉 img 宽高"这个看起来一行 preg_replace 的小事,背后是 16 年间 Web 图片处理的演进史: - 2008 PC-only 时代:固定宽高是 best practice。 - 2014 移动优先:固定宽高变成 anti-pattern,需要响应式 CSS 兜底。 - 2018 Core Web Vitals:完全去掉宽高又会让 CLS 变差,需要 aspect-ratio。 - 2020 srcset 普及:单图变多图,浏览器自动选择最佳分辨率。 - 2024 AVIF/WebP:格式升级,文件减半视觉无损。 如果你只是接手一个老织梦站做应急修复,本文方案 A(三行 preg_replace + CSS max-width 100%)足够用。如果是要长期维护甚至做 SEO 优化,建议直接做到第 4-5 步——这才是 2024 年企业站该有的样子。 ## 改完后的验收清单 把这 8 项过一遍才算彻底搞定: - 桌面端测试:随机抽 5 篇文章在 Chrome、Edge、Firefox 上看一遍,图片不变形、不溢出。 - 移动端测试:用 iPhone Safari、Android Chrome、微信内置浏览器各看 3 篇,没有横向滚动条。 - F12 检查 HTML:在文章页 F12 → Elements 面板,搜索 **TLDR**:摘要:织梦DedeCMS默认缩略图按目标矩形强行缩放铺满,非目标比例的图就被拉伸变形。本文剖析image.helper.php里ResizeImg函数的内部数学和三种缩放模式,给出经典裁剪改造代码、批量重生成脚本、居中裁剪会裁掉人脸的局限、WebP与AVIF输出,再讲避免上传同步阻塞、缓存策略和强制比例上传的备选。 > 摘要:织梦DedeCMS (https://zhangwenbao.com/dedecms-commonly-used-batch-sql-statements.html)默认缩略图按目标矩形强行缩放铺满,非目标比例的图就被拉伸变形。本文剖析image.helper.php里ResizeImg函数的内部数学和三种缩放模式,给出经典裁剪改造代码、批量重生成脚本、居中裁剪会裁掉人脸的局限、WebP与AVIF输出,再讲避免上传同步阻塞、缓存策略和强制比例上传的备选。 织梦 DedeCMS 默认的缩略图算法是"按宽高比例缩放铺满矩形"——用户上传任意比例的图片,DedeCMS 会强行把它拉伸或压缩到目标缩略图尺寸(比如 240×160),结果就是非目标比例的图片在列表页显示时变形拉伸。文章首图是横版照片但缩略图是 1:1 正方形?人物脸被压扁;横版风景图被压成竖版?水波纹变成竖纹。 这个问题从 DedeCMS V5.6 一路延续到 V5.7 SP2 没改过,社区里"织梦 58"分享了一段改 image.helper.php 的代码——把强行拉伸改成"按比例缩放后裁剪溢出部分"。但这段代码网传版本几个细节没讲透:什么时候应该选裁剪 vs 留白、人脸 / 主体被裁掉怎么办、性能开销有多大、能不能用 CDN 替代。这一篇全部讲清。 ## DedeCMS 缩略图算法的三种模式 从图像处理角度看,把任意比例图片转成固定矩形缩略图有三种主流策略: 模式 | 英文术语 | 处理方式 | 视觉效果 | 1. 拉伸填充 | stretch / scale | 不保比例,直接缩放到目标尺寸 | 变形(DedeCMS 默认) | 2. 比例缩放 + 留白 | fit / contain | 保比例缩放到能放进目标矩形,剩余区域留白 | 不变形但有白边 | 3. 比例缩放 + 裁剪 | cover / crop | 保比例缩放到能完全覆盖目标矩形,溢出区域裁掉 | 不变形不留白,但可能裁掉重要部分 | DedeCMS 默认走模式 1(最差视觉效果)。织梦 58 的修改是切到模式 3(cover/crop)。模式 2 在文章列表场景不太适用(白边丑),但在产品图等需要"完整展示"的场景反而更好。 ## image.helper.php 关键代码逐行解析 原版 image.helper.php 中处理缩略图的逻辑(变量名简化): $srcW = ImageSX($im); // 源图宽 $srcH = ImageSY($im); // 源图高 // 比目标小直接返回原图 if ($srcW <= $toW && $srcH <= $toH) return TRUE; $toWH = $toW / $toH; // 目标矩形宽高比 $srcWH = $srcW / $srcH; // 源图宽高比 // 判断该按宽缩放还是按高缩放 if ($toWH <= $srcWH) { // 目标更窄/源图更宽 → 按宽缩放 $ftoW = $toW; $ftoH = $ftoW * ($srcH / $srcW); // 高自动按比例 } else { // 目标更宽/源图更窄 → 按高缩放 $ftoH = $toH; $ftoW = $ftoH * ($srcW / $srcH); // 宽自动按比例 } 这段代码的输出 $ftoW × $ftoH 是"保比例缩放后的尺寸"——它会被送到 ImageCopyResized() 拉伸到 $toW × $toH。这就是变形的根源:算了一个保比例的目标尺寸但又不保比例地输出。 ## 修改后的代码逻辑 改成裁剪模式后的代码: $srcW = ImageSX($im); $srcH = ImageSY($im); if ($srcW <= $toW && $srcH <= $toH) return TRUE; $toWH = $toW / $toH; $srcWH = $srcW / $srcH; // 关键:直接把目标尺寸设为最终输出 $ftoH = $toH; $ftoW = $toW; if ($toWH <= $srcWH) { // 目标矩形比源图更瘦 → 源图水平方向溢出,要左右裁 $src_Y = 0; // 源图取整高 $src_X = ($srcW - $srcH * $toWH) / 2; // 源图水平居中起点 $srcW = $srcH * $toWH; // 源图取的宽度(缩成与目标比例一致) } else { // 目标矩形比源图更胖 → 源图垂直方向溢出,要上下裁 $src_X = 0; $src_Y = ($srcH - $srcW / $toWH) / 2; $srcH = $srcW / $toWH; } 核心思路:不动目标尺寸,调整源图取样区域,让源图取出的那一块比例正好等于目标比例。再用 imagecopyresampled() 把这块抠出来缩到目标矩形——保比例 + 不变形 + 不留白。 $src_X / $src_Y 都用 (差额)/2 实现"居中裁剪"——这是裁剪策略的默认行为。后面会讲"智能裁剪"如何替代居中。 ## 不同 DedeCMS 版本的文件路径 版本 | image.helper.php 位置 | 主要差异 | DedeCMS V5.6 | /include/helpers/image.helper.php | 函数名 ResizeImg | DedeCMS V5.7 | /include/helpers/image.helper.php | 函数名 ResizeImg / cn_resize | DedeCMS V5.7 SP1/SP2 | /include/helpers/image.helper.php | 同上 + 部分加 GD2 fallback | DedeBIZ(社区分叉) | 同上路径 | 已合并裁剪模式作为可选项 | V5.6 / V5.7 / SP1/SP2 改造逻辑通用,路径不变。DedeBIZ 已经把这个改造做成了内置选项,可以在后台直接切换缩略图模式,不用改文件。 ## imagecopyresized 与 imagecopyresampled 的差异 DedeCMS 的缩略图函数在不同版本里用过两个 GD 函数: - imagecopyresized():最近邻插值,速度快,但缩略图明显锯齿、文字模糊。 - imagecopyresampled():双线性插值,速度慢约 4-5 倍,但缩略图边缘平滑、清晰。 V5.6 早期版本用 imagecopyresized()——所以即便修了变形问题,缩略图清晰度仍然不行。如果你的项目对图片质量敏感,把所有 ImageCopyResized 改成 imagecopyresampled: grep -rn "ImageCopyResized" /www/wwwroot/yoursite.com/include/ # 找到所有调用,逐一改成 imagecopyresampled 性能开销实测:100 张 800×600 原图生成 240×160 缩略图,imagecopyresized 总耗时 1.2 秒,imagecopyresampled 6.5 秒。对每次上传时同步生成的场景,多 5 秒可接受;对批量重生成几千张图的场景,要走异步队列。 ## 规模化重生成已有缩略图 修改 image.helper.php 后只影响新上传的图片,老缩略图还是变形的。要批量重生成: // 自定义 PHP 脚本:批量重生成 DedeCMS 缩略图 // 放到网站根目录跑:php regenerate_thumbs.php require_once 'include/common.inc.php'; $dsql = new DedeSqlite(); // 或 new DedeSql() $result = $dsql->Query("SELECT aid, litpic FROM dede_archives WHERE litpic != ''"); $count = 0; while ($row = $result->fetch_assoc()) { $litpic_path = DEDEROOT . $row['litpic']; if (!file_exists($litpic_path)) continue; // 找到对应的原图(一般 litpic 是缩略图,原图在 dede_addonarticle.body 里) $body = $dsql->GetOne("SELECT body FROM dede_addonarticle WHERE aid={$row['aid']}"); preg_match('/]+src=[\'"]([^\'"]+)[\'"]/', $body, $m); if (empty($m[1])) continue; $original = DEDEROOT . $m[1]; if (!file_exists($original)) continue; // 用新算法重生成 require_once 'include/helpers/image.helper.php'; ResizeImg($original, $litpic_path, 240, 160); $count++; if ($count % 100 == 0) echo "处理 $count 张...\n"; } echo "完成,共 $count 张"; 实战提示: - 分批跑,一次跑 1000-2000 张就 sleep 几秒释放内存,避免 PHP-FPM 超时。 - 跑前备份原图,新算法可能产生不可预期的裁剪结果(人脸断手等)。 - 用命令行跑,不要从浏览器——浏览器超时会中断。 - 大图站建议异步队列,把待处理 aid 列表放 Redis,多 worker 并行。 ## 居中裁剪的局限:人脸 / 主体被裁掉 裁剪模式的最大问题:默认居中裁,但视觉重心不一定在中央。人物全身照的脸通常在上 1/3,居中裁就把脸切了;横版照片的主体可能在左侧三分之一,居中裁切走了主体。 ## 智能裁剪:人脸优先 用 OpenCV 的 Haar Cascade 做人脸检测,找到人脸区域作为裁剪锚点: # 简化的 Python 版本(DedeCMS 调外部脚本) import cv2 def smart_crop(img_path, target_w, target_h, output_path): img = cv2.imread(img_path) h, w = img.shape[:2] # 加载人脸检测器 cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') faces = cascade.detectMultiScale(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), 1.3, 5) if len(faces) > 0: # 取最大人脸的中心作为裁剪锚点 x, y, fw, fh = max(faces, key=lambda f: f[2]*f[3]) cx, cy = x + fw // 2, y + fh // 2 else: # 没人脸 → 居中 cx, cy = w // 2, h // 2 # 计算裁剪框 target_ratio = target_w / target_h src_ratio = w / h if target_ratio < src_ratio: # 按高填满,左右裁 crop_h = h crop_w = int(h * target_ratio) x0 = max(0, min(w - crop_w, cx - crop_w // 2)) y0 = 0 else: crop_w = w crop_h = int(w / target_ratio) x0 = 0 y0 = max(0, min(h - crop_h, cy - crop_h // 2)) cropped = img[y0:y0+crop_h, x0:x0+crop_w] resized = cv2.resize(cropped, (target_w, target_h), interpolation=cv2.INTER_AREA) cv2.imwrite(output_path, resized) 在 PHP 主体的 DedeCMS 里调用这个 Python 脚本:shell_exec("python3 smart_crop.py /path/to/img.jpg 240 160 /path/to/thumb.jpg")。性能开销大(人脸检测每张图 50-300ms),适合"重要图片"通道,不适合每张图。 ## AI 模型驱动的智能裁剪 2024 年起几个图片处理服务(Cloudflare Images / Imgix / Cloudinary)都内置了 AI 智能裁剪,能识别"主体在哪"并以主体为锚点裁剪。它们的算法比 Haar Cascade 强得多,能识别人脸、动物、文字、商品等多种主体。 对企业站,用 CDN 替代本地缩略图生成已经是更优解: 服务 | 智能裁剪 | WebP/AVIF | 定价(10K 图/月) | Cloudflare Images | ✓ | 自动 | 免费 5K → $5/月起 | 阿里云 OSS + 图片处理 | ✓ | 自动 | 按流量约 ¥10-30/月 | 腾讯云 CI | ✓ | 自动 | 按流量约 ¥10-30/月 | Imgix | ✓ | 自动 | $10/月起 | Cloudinary | ✓ | 自动 | 免费 25K → $99/月起 | 用法是把图片 URL 从 /uploads/abc.jpg 改成 https://imgix.example.com/abc.jpg?w=240&h=160&fit=crop&crop=faces——参数化即时生成,不用预先批量处理。 ## WebP / AVIF 现代格式输出 2026 年的图片优化里 WebP / AVIF 已经是标配——同等画质比 JPEG 体积小 25-40%。DedeCMS 默认只输出 JPG / PNG,要改成同时输出 WebP: // 在 image.helper.php 函数末尾,原 imagejpeg() 之后追加 $webp_path = preg_replace('/\.(jpg|jpeg|png)$/i', '.webp', $output_path); imagewebp($im_target, $webp_path, 80); // 同时输出 AVIF(需要 GD 编译时启用 AVIF 支持,PHP 8.1+ 默认有) if (function_exists('imageavif')) { $avif_path = preg_replace('/\.(jpg|jpeg|png)$/i', '.avif', $output_path); imageavif($im_target, $avif_path, 60); } 前端用 标签同时声明三种格式,浏览器自动选支持的: ... 这套组合在 Chrome / Edge / Firefox / Safari 上全平台覆盖,AVIF 优先(最小体积)、WebP 次之、JPG 兜底。 ## 性能优化:避免每次上传都同步阻塞 DedeCMS 默认在文章发布时同步生成缩略图——上传 10 张图的文章发布要等 5-10 秒。改为异步: ## 用 PHP-FPM 的 fastcgi_finish_request 在文章保存逻辑后立刻断 HTTP 连接,缩略图后台跑: // 在 article_add.php 保存逻辑末尾 echo '保存成功'; if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } // 后续生成缩略图(用户已看到成功页) generate_thumbnails($article_id); ## 用 Redis 队列 对大量并发上传场景: // 入队(保存文章时) $redis->lPush('thumb_queue', json_encode([ 'aid' => $aid, 'time' => time(), ])); // 后台 worker 出队(独立进程) while (true) { $task = $redis->brPop('thumb_queue', 30); if (!$task) continue; $data = json_decode($task[1], true); generate_thumbnails($data['aid']); } worker 用 supervisord 守护。这种架构能扛 1000+ 并发上传不阻塞。 ## 缓存策略 缩略图一旦生成不会再变(除非内容方手动重生成),所以 HTTP Cache-Control 应设非常长: location ~* /uploads/.*\.(jpg|jpeg|png|webp|avif)$ { expires 1y; add_header Cache-Control "public, immutable"; # 配合内容哈希文件名,永远不用刷新缓存 } 如果文件名带内容哈希(abc123def_240x160.jpg),可以加 immutable——浏览器会信任这个文件永远不变,连 If-Modified-Since 都不发,性能极好。 ## 备选方案:让用户上传时强制比例 所有这些算法层面的修改都不如从源头规范用户上传。在编辑器里加一个比例裁剪 UI(Cropper.js / Croppie),让用户上传后强制按指定比例(比如 3:2)裁剪: // 在 DedeCMS 编辑器里集成 Cropper.js
这种方式让用户在前端就裁好比例,DedeCMS 缩略图函数只需"等比例缩小",不用裁剪不会变形。一劳永逸。 ## DedeCMS 在 2026 年的现实 这一节给运维或决策者参考。DedeCMS 官方核心团队 2019 年起停止维护,社区分叉版(DedeBIZ)在缓慢更新。如果你的项目还在 DedeCMS 上: - 建议升级到 DedeBIZ V6.x,已合并智能裁剪等大量改进; - 新建项目不建议选 DedeCMS——选 WordPress + WooCommerce (https://zhangwenbao.com/woocommerce-points-rewards-loyalty-membership-plugin-operations.html)、或自研。 - 缩略图改造做完之后,配合 CDN(七牛 / 阿里云)做远程图片处理,可以 95% 替换本地缩略图算法的工作量。 ## 常见问题解答 ## 修改 image.helper.php 后老的缩略图还是变形的,怎么办? image.helper.php 改动只影响"新上传"的图片。老缩略图要批量重生成——用文中第四节的脚本扫 dede_archives 表,对每篇文章的 litpic 重跑 ResizeImg()。重生成期间网站不要做大量上传,避免新旧逻辑混淆。 ## 智能裁剪需要装什么扩展? OpenCV 方案要装 Python + opencv-python(pip install opencv-python);PHP 直接调外部 Python 脚本通过 shell_exec。要纯 PHP 方案,可以用 imagick(PHP 扩展)的 cropThumbnailImage(),自带"按目标比例从中心裁剪",但没有人脸识别。最强的方案是用 CDN 的 AI 智能裁剪 API。 ## imagecopyresampled 比 imagecopyresized 慢 5 倍,CPU 顶得住吗? 取决于上传频率。普通中小博客每天 10-30 张图,slow 5x 都是几毫秒到一秒级,毫无压力。日 1000+ 张图的高频站点(电商 / UGC 内容)建议异步队列 + 多 worker 并行,每个 worker 跑 imagecopyresampled 不影响用户体感。 ## DedeCMS 升级会覆盖我的修改吗? 会。/include/helpers/image.helper.php 是核心文件,升级时被覆盖。两种方案:① 升级前 git diff 备份这个文件,升级后手动 merge 自定义改动;② 不改原文件,新建 image_custom.helper.php,在原文件中 require 自定义版本(但这要 patch 一行原文件,本质还是改原文件);③ 切到 DedeBIZ 分叉版,缩略图模式可以在后台直接切换,不用改文件。 ## WebP 输出后老 PHP 不支持怎么办? imagewebp() 在 PHP 5.4+ 默认就有(前提 GD 编译启用 WebP 支持)。如果调用报"undefined function imagewebp",说明 GD 编译时没开 WebP——重新编译 PHP 加 --with-webp,或装预编译版本(如 Remi 仓库)。Imageavif() 要 PHP 8.1+。 ## 裁剪后人脸 / 主体被裁掉的概率有多高? 居中裁剪策略下,人物全身照(人脸在上 1/3)裁竖版缩略图时大概率裁掉头;产品图(主体居中)通常没问题;风景图(主体偶尔在三分点)有 30% 概率裁掉重要部分。智能裁剪 + 人脸检测能把这个概率降到 5% 以下。 ## 用 CDN 智能裁剪和本地裁剪对 SEO 有差别吗? 无差别。Google 抓取的图片实际是浏览器渲染出来的样子,不管来自本地还是 CDN,只要图片质量一致、加载速度一致即可。CDN 智能裁剪反而对 Core Web Vitals (https://zhangwenbao.com/core-web-vitals-ai-search-industry-benchmark.html) 的 LCP 有正向贡献(CDN 节点更近、更快)。 ## 缩略图裁切后的尺寸还是不一致怎么办? 检查 image.helper.php 函数被调用时传入的 $toW / $toH。DedeCMS 不同版本里"封面图缩略图"和"列表页缩略图"用不同函数调用,可能传不同尺寸。在后台 → 系统 → 系统设置 → 缩略图,统一所有尺寸 settings。 ## 批量重生成卡死了怎么办? 多半是某张图损坏(半下载 / 错误格式)让 imagecreatefromjpeg 返回 false。脚本里加 if (!$im) continue; 跳过这张继续。也可在脚本前加 set_time_limit(0)、ini_set('memory_limit', '512M') 避免单图过大爆内存。 ## 智能裁剪如果识别错了怎么办? OpenCV 的 Haar Cascade 准确率约 70-85%,错误识别(把背景斑点识成人脸)会让裁剪偏离。生产环境建议:① 给运营用的批量重生成不开启智能裁剪(避免不可预测);② 给最终用户在前端显示的图允许用户手动调整裁剪锚点;③ 用更现代的 ML 模型(YOLO / MTCNN)准确率更高但 PHP 调用复杂。 ## 权威参考资料 ## 织梦怎么取文章第一张图?三种方案与正文抓取实战 - URL:https://zhangwenbao.com/dedecms-method-first-picture-of-the-article.html - 分类:织梦CMS教程 - 发布:2020-11-20 | 更新:2026-06-01 - 摘要:织梦调用文章第一张图,老方法删末尾几位字符的写法早就不兼容新格式。本文给出三层兜底函数:基于缩略图标识做兼容jpg、png、webp等多格式的替换、litpic缺失时从正文preg_match抓首图、最后兜底占位图,再附两种调用语法差异和批量回写脚本。 - 关键词:织梦图片,织梦调用,DedeCMS函数,litpic字段,织梦模板 > **TLDR**:摘要:织梦调用文章第一张图,老方法删末尾几位字符的写法早就不兼容新格式。本文给三层兜底函数——先基于缩略图标识做兼容jpg和png和webp等多格式的替换、litpic缺失时从正文preg_match直接抓首图、最后兜底占位图,再讲织梦标签两种调用形式的区别、为什么没图的排查、性能优化和SEO视角下的首图策略。 > 摘要:织梦调用文章第一张图,老方法删末尾几位字符的写法早就不兼容新格式。本文给三层兜底函数——先基于缩略图标识做兼容jpg和png和webp等多格式的替换、litpic缺失时从正文preg_match直接抓首图、最后兜底占位图,再讲织梦标签两种调用形式的区别、为什么没图的排查、性能优化和SEO视角下的首图策略。 保哥从 2010 年前后开始接触织梦 DedeCMS,那个时候做企业站、行业门户、地方资讯站,DedeCMS 几乎是标配。十几年下来,用织梦搭过的站点不下两百个,光是"调用文章第一张图"这一个看似不起眼的需求,就在不同站点上反复折腾过几十次。这篇文章把保哥踩过的坑、改过的代码、最终落地的方案完整记录下来,希望能帮到还在用织梦的朋友。 ## 为什么需要单独写一个"取第一张图"的函数 织梦 DedeCMS 的列表模板里,调用缩略图通常用 [field:litpic/] 或 {dede:field name='litpic'/}。但是实际项目里,很多场景下编辑根本不会主动上传缩略图,他们习惯把图片直接贴在正文里就完事。这就导致列表页大量缺图,封面只能显示一个默认占位图,整体观感非常差。 保哥早期的解决思路有三种: - 思路一:让编辑必须上传缩略图,否则不允许发布。但这条规则在团队大、外包多的场景里几乎执行不下去。 - 思路二:用计划任务定时扫描数据库,把正文里第一张图自动写入 litpic 字段。这种做法对服务器有压力,而且修改图片后不会同步更新。 - 思路三:在模板渲染层做一个 fallback——如果 litpic 为空就从 body 里正则取第一张图。这是最稳妥的方案,本文重点讲这种方式的几个变体。 搞清楚需求后会发现,织梦自带的缩略图机制其实有一个隐藏规则:上传原图后,系统会在文件名末尾追加 -lp 这样的标识符(不同版本略有差异,老版本是直接补 7 位字符再加扩展名),缩略图和原图存放在同一目录。理解这个规则是后面所有方法的基础。 ## 流传最广的旧方法:删除末尾 7 位字符 网上随便一搜"织梦调用第一张图",跳出来的几乎都是下面这段代码。保哥最早也是从某个论坛抄来的,用了大概两年才发现它的问题。 打开 /include/common.func.php,在文件末尾追加: [field:litpic function="firstimg('@me')"/] {dede:field name='litpic' function="firstimg(@me)"/} 这段代码为什么不靠谱?保哥实际跑过几个站后总结出三个致命问题: - 写死 .jpg 扩展名:如果原图是 png、gif、webp,调用出来全部是 404。 - 依赖文件名长度:织梦不同版本生成的缩略图后缀不一样,盲目截掉 7 位会破坏文件名。 - 没有空值保护:如果 litpic 字段为空(编辑根本没上传图),函数会返回 .jpg 这个无效字符串。 如果你的站点 100% 用 jpg,并且确认织梦版本生成的缩略图后缀就是 7 位字符,这段代码确实能工作。但放在 2026 年的环境下,几乎没有任何站点能满足这两个前提——WebP/AVIF 格式逐渐成主流,老方法注定淘汰。 ## 改良方法:基于 -lp 标识符的字符串替换 这是保哥目前推荐的写法,兼容多种图片格式,逻辑也更清晰。同样是在 /include/common.func.php 里追加函数: 标签。 保哥在客户站点上线的最终版函数是这样的: 0) { global $dsql; $row = $dsql->GetOne("SELECT body FROM `#@__addonarticle` WHERE aid=$arcid"); if ($row && !empty($row['body'])) { preg_match('/]+src=["\']?([^"\'>\s]+)/i', $row['body'], $match); if (!empty($match[1])) { return $match[1]; } } } // 第三步:兜底返回默认占位图 return '/static/images/default-cover.jpg'; } 模板里调用时把 aid 传进去: [field:litpic function="firstimg('@me', ~aid~)"/] 这样三层逻辑就齐了:缩略图存在时直接还原原图,缺失时回正文抓,正文也没图就走兜底。保哥在好几个内容站上跑了三年多,这套逻辑一直没出过问题。 性能担心吗?说实话,列表页每篇文章多查一次 addonarticle 表确实有开销。如果列表页一次出 20 条,最坏情况就是 20 次额外查询。在 5 万条文章规模以下、并且加了 OPCache 的环境里,多出来的延迟基本感知不到。如果数据量更大,建议把这个 fallback 写成一次性脚本,把抓出来的第一张图回写到 litpic 字段,从根上避免运行时查询。 ## 织梦标签的两种调用形式区别 用织梦多年,新手最常问的就是:什么时候用 [field:.../],什么时候用 {dede:field name='...'/}?保哥这里一次说清楚。 - [field:xxx/] 形式:只能写在 list 类标签内部,例如 {dede:list}...{/dede:list}、{dede:arclist}...{/dede:arclist} 之间。它是循环上下文里的字段占位符。 - {dede:field name='xxx'/} 形式:用在文章详情页、单页模板的根作用域里,直接渲染当前文档的字段值。 上面写的 firstimg 函数两种调用方式都演示了,记得 list 内部用方括号、详情页用花括号,混着写会直接渲染失败。 另外说一个细节:@me 在两种语法里都表示"当前字段的值"。所以 function="firstimg('@me')" 实际就是把 litpic 当前值传进 firstimg 函数,等价于 PHP 里写 firstimg($litpic)。 ## 常见踩坑场景与排查思路 保哥这些年帮人排错,发现"调用第一张图失败"的问题集中在以下几个点。如果你照着上面写完不出图,按这个清单查一遍基本都能定位。 - 函数没生效:确认 common.func.php 里函数加在 ?> 之前,并且文件保存成 UTF-8 无 BOM。BOM 会让函数注册失败。 - 静态化没更新:织梦默认生成静态 HTML,模板改了之后必须在后台"更新主页 HTML""更新栏目 HTML""更新文档 HTML"一遍,否则前台还是旧内容。 - 图片路径带域名:如果 litpic 存的是绝对 URL(带 http://),str_replace 不影响域名部分,但要小心和图片防盗链 (https://zhangwenbao.com/using-htaccess-to-set-up-wordpress-anti-stealing-link.html)规则冲突。 - 图片附件已删除:缩略图存在但原图被清理过,会导致还原后的路径 404。可以在函数里加 file_exists 判断,找不到就走兜底图。 - 正则贪婪问题:从 body 抓 img 时如果 src 用了双引号嵌套,正则要小心写。保哥推荐的写法已经处理了单双引号和无引号三种情况。 - OPCache 残留:修改完 common.func.php 后如果开启了 OPCache,旧字节码可能还在缓存里。service php-fpm reload 一下保险。 - 编辑器自动加 alt 属性:百度编辑器某些版本会在 src 之前插入 alt 属性,正则的 src= 锚点无法匹配。需要把正则的 src 前置位放宽。 ## 性能优化建议 当文章数突破 10 万 + 之后,firstimg 函数里查 addonarticle 表的 fallback 会成为列表页瓶颈。保哥推荐三种优化路径: 路径一:批量回写 litpic 字段。写一个一次性脚本,把所有 litpic 为空的文章扫一遍,从 body 抓第一张图回写到 litpic。脚本伪代码: $rows = $dsql->GetAll("SELECT a.aid, b.body FROM dede_archives a LEFT JOIN dede_addonarticle b ON a.id=b.aid WHERE a.litpic='' OR a.litpic IS NULL"); foreach ($rows as $r) { if (preg_match('/]+src=["\']?([^"\'>\s]+)/i', $r['body'], $m)) { $dsql->ExecuteNoneQuery("UPDATE dede_archives SET litpic='" . $m[1] . "' WHERE id=" . $r['aid']); } } 跑一次后所有历史文章都补上了 litpic,模板里只用方法二即可,不再需要 fallback 查询。 路径二:发布钩子。在 article_add.php、article_edit.php 的入库函数后面加一段,发布时如果 litpic 为空就自动从 body 抓。这样新文章不会再产生空 litpic,只需要处理一次历史数据。 路径三:Memcached/Redis 缓存。把 firstimg 的结果按 aid 缓存 1 小时,前 1 小时之内的相同 aid 直接返回缓存,避免重复查询。这种适合大流量站点,配合 OPCache 几乎零延迟。 ## 与其他 CMS 的对照参考 "取文章第一张图"这个需求几乎所有 CMS 都遇到过,对照参考能加深理解: - WordPress:get_the_post_thumbnail_url() 取 featured image,缺失时用 preg_match('/post_content) 回退到正文。 - Typecho:通过 $archive->content 过滤插件实现,或者写一个 helper 函数 get_first_image($content)。 - Joomla:插件 plg_content_jcomments 之类的扩展自带"自动取首图"功能。 - 帝国 CMS:通过自定义字段 + 系统模型钩子,发布时自动抓正文首图填充缩略图字段。 - Discuz:门户文章的 picflag 字段判断有无配图,配合 parsehtml 函数从主题正文抓图。 所有 CMS 的实现思路殊途同归——"先用专属缩略图字段,回退到正文图,最后兜底默认图",本文的三层逻辑可以直接迁到任何 CMS 上。 ## 九-A、SEO 视角下的首图策略 "取第一张图"看似只是一个视觉补丁,从 SEO 角度其实牵连出更多需要思考的问题。保哥过去几年帮客户做织梦站 SEO 时积累了一些经验: 列表页首图与 Open Graph。社交平台(微博、微信公众号、Twitter、Facebook)抓取链接预览时读 。如果你的详情页 og:image 用的是 firstimg 函数取出的图,列表页和社媒预览的封面就能一致,提升品牌识别度。具体写法:在 article_article.htm 模板的 head 区域加 。 结构化数据 (https://zhangwenbao.com/shopify-schema-seo-guide.html) Article 类型的 image 字段。Google 的 Article schema 推荐 image 字段填一张高质量主图(建议 1200×630 或更大)。如果 firstimg 取出的图分辨率不够,搜索结果摘要的图位会被 Google 自动忽略。建议在站点上传规范里强制要求文章首图最低 1200×630,并通过模板编辑器侧加一道前端校验。 图片 SEO 的 alt 文本。从 body 抓出来的 img 标签往往没有 alt 属性,但作为列表页缩略图渲染时也不需要 alt。真正影响 SEO 的是详情页正文里的 alt,这个跟 firstimg 函数没关系,需要在编辑发文时养成填写 alt 的习惯,或者在 article_add.php 入库时自动生成 alt(用文章标题 (https://zhangwenbao.com/how-to-write-catchy-article-titles.html)作为 alt 文本是常见兜底)。 WebP/AVIF 支持的 SEO 影响。Google 在 2026 年对页面性能的权重比以往更高,启用 WebP 通常能让 LCP 下降 30%-40%。建议站点全面切换到 WebP 输出,可以在 nginx 层用 map 指令做 Accept 头协商,自动给支持 WebP 的浏览器返回 .webp 文件,老浏览器返回 .jpg。这种透明协商方案不需要改 firstimg 函数。 ## 调试技巧:怎么定位"为什么没图" 列表页有些卡片显示了图,有些卡片显示不了图,这是织梦 firstimg 部署后最常见的"中段问题"。保哥的排查流程是这样的: - 用 Chrome DevTools 看 Network 面板。404 的图片会以红色标记,点开看请求 URL,对比下数据库里实际存的 litpic 路径,找出函数处理后的 URL 错在哪。 - 临时关闭 firstimg 包装。把 function="firstimg('@me')" 改成不带 function,让模板直接输出原始 litpic 字段。如果不带 function 时图正常显示但带上后 404,说明问题出在 firstimg 函数本身。 - 逐条 echo 调试。在 firstimg 函数里加 echo "[$str_pic]
";,把进函数前后的值都打出来,肉眼对比哪条记录的输入异常。 - 查 dede_archives.litpic 字段。SELECT id, title, litpic FROM dede_archives WHERE litpic IS NOT NULL AND litpic != '' ORDER BY id DESC LIMIT 50; 看看最近 50 条文章的 litpic 实际存了什么——很多时候问题是数据脏,比如某条记录把 alt 文本误存到了 litpic 字段。 - 对比缩略图与原图实际文件。SSH 到 uploads 目录里,ls -la 202401/ 看是否真的存在原图和缩略图两个版本。如果原图被早期清理任务删掉了,函数还原出的路径自然 404。 调试踩过几次后会发现,"为什么没图"通常 80% 是数据问题、15% 是函数逻辑、5% 是模板调用方式。先排数据再排代码,效率最高。 如果你管的是大型站点(>1 万篇文章),强烈建议把这套排查流程做成一个后台脚本:每天定时扫描 dede_archives 里 litpic 异常的记录(空值、不存在的文件、404 链接),生成一份运维报告邮件给编辑组。这样问题暴露得早,反应也快,不会等到读者反馈"封面图缺失"才发现。保哥过去帮一家本地媒体站搭这套自动巡检后,"列表页缺图"工单数量从每月十几条降到几乎为零。这种边角细节做扎实了,整个站点的视觉品质会有肉眼可见的提升。 另一个值得一提的运维实践是缩略图统一规格。织梦默认缩略图比例与编辑上传的原图比例一致,但列表卡片是固定比例(比如 16:9),不一致会导致卡片大小参差。建议在 article_add.php 入库时对 litpic 做一次 ImageMagick 中心裁剪,统一裁成 16:9 或 4:3,这样列表页视觉规整度直线上升,且不影响详情页的原图展示。这一步可以在 firstimg 函数之外独立完成,是属于上传流程的优化范畴,但跟首图策略相辅相成。 最后一点是性能监控数据要定期复盘。每月把 list 页的平均 PHP 处理时间、SQL 执行次数对比上月,如果发现回退到正文抓图的比例上升、SQL 数明显增加,说明 litpic 字段的覆盖率在下降,需要回头看是不是发文流程出了问题,或者老文章批量回写脚本要再跑一次。把指标握在手里,问题在变成事故前就能被识别——这才是十几年织梦运维沉淀下来的真正经验。 ## 常见问题解答 Q1:站点有上千篇文章已经发布,能不能一次性把第一张图回写到 litpic 字段? 可以,写一个迁移脚本即可。思路是遍历 dede_archives 表里 litpic 为空的记录,到 dede_addonarticle 表对应行抓 body,正则取出第一张 img 的 src,更新回 litpic。在凌晨流量低的时候跑一次,几千条文章十几分钟就完事,跑完之后模板里就不需要 fallback 函数了。 Q2:织梦升级到最新版本后,缩略图标识符还是 -lp 吗? 不一定。DedeCMS 5.7 SP2、5.8、还有各种二开版本(WeCenter、织梦增强版)的标识符都不完全相同。保哥的建议是:每次升级或换版本部署后,都手动验证一下数据库里 litpic 字段的命名规则,再调整 str_replace 的参数。不要假设规则不变。 Q3:用上面的方法调出来的图片在手机端显示变形怎么办? 这个不是函数本身的问题,是织梦自动给 img 标签加了 width/height 内联样式,固定了尺寸。需要修改 arc.archives.class.php,用正则把 width/height 属性剥掉。这个话题保哥另外写过一篇专门的文章,可以搜"织梦去掉图片长宽属性"找到。 Q4:除了正则,还有更优雅的方式从 body 里抓图吗? 如果服务器 PHP 版本 7.0+,推荐用 DOMDocument 解析。代码稍长一点,但比正则更稳健,能正确处理嵌套属性、自闭合标签和 HTML 实体编码。性能上 DOMDocument 对小段 HTML 解析非常快,列表页用完全没问题。 Q5:如果文章里第一张图是水印图、二维码这种小图怎么办? 可以在 firstimg 里加一道筛选逻辑——抓出所有 img,按 src 路径过滤掉包含 watermark、qrcode、icon 等关键字的小图,取第一张"真实内容图"。或者按图片尺寸判断(需要在 server-side 用 getimagesize 读取,性能开销较大)。多数情况下"取第一张"就够用,水印图问题占比 < 5%。 Q6:函数能不能放在主题模板里而不是 common.func.php? 不能直接放在 .htm 模板里——织梦模板是预编译的,不支持任意 PHP。但可以放在 /include/extend.func.php 这个扩展函数文件里,效果跟 common.func.php 一样。区别在于 extend.func.php 不会被升级覆盖,更适合自定义函数集中存放。 Q7:能不能让 firstimg 兼容 WebP/AVIF 等新格式? 本文方法二(基于 -lp 替换)天然兼容所有图片格式,因为它不关心扩展名。如果你的站点用 CDN 自动转 WebP,只需要确认原图存的是 jpg/png 路径即可,CDN 会在响应层自动协商格式。 ## 写在最后 织梦 DedeCMS 在 2026 年看确实是"上个时代"的产品,但保哥手里还有不少老站跑在它上面,迁移成本太高没法立刻替换。这种工具型函数虽然简单,但日积月累攒下来就是经验沉淀。把每一个小坑都记录清楚,下次再遇到同类问题就不用从零摸索。 如果你的项目正在用织梦,希望这篇能帮你省下几个小时调试时间。有更好的写法或者新版本的兼容问题,欢迎在评论区告诉保哥,会持续更新这篇文章。下一篇打算写"织梦发布钩子全集",把入库时的自动化补全(litpic 字段、关键词、自动摘要)一次性讲清楚。 ## 权威参考资料 ## DedeCMS TDK调用完整写法:4场景+SEO优化 - URL:https://zhangwenbao.com/dedecms-title-keyword-description-tag.html - 分类:织梦CMS教程 - 发布:2020-07-22 | 更新:2026-05-16 - 摘要:本文梳理DedeCMS模板里TDK标签的正确写法:百度、Google、必应三大引擎的title与description截断字符数对照、seotitle为空时的三元回退、翻页第N页的rel prev next配套、tag页与搜索页的特殊处理、PC与移动的alternate与canonical互指和小程序TDK独立配置。 - 关键词:织梦调用,织梦标签,DedeCMS SEO,TDK,schema.org > **TLDR**:摘要:TDK是织梦SEO的命脉。本文梳理模板里TDK标签的正确写法——首页、栏目页、频道list页、文章页各自的标准调用,再讲特殊页面的TDK处理、seotitle为空时的三元回退、避免TDK重复的几个技巧,以及OG与Twitter Card与微博卡片这些兄弟标签,让每类页面的标题和描述都唯一不撞车。 > 摘要:TDK是织梦SEO的命脉。本文梳理模板里TDK标签的正确写法——首页、栏目页、频道list页、文章页各自的标准调用,再讲特殊页面的TDK处理、seotitle为空时的三元回退、避免TDK重复的几个技巧,以及OG与Twitter Card与微博卡片这些兄弟标签,让每类页面的标题和描述都唯一不撞车。 这几年我经手的织梦项目里,差不多每三个站就有一个是SEO出问题来找我返工的。问题集中在一个非常基础但又特别容易被忽略的位置——TDK (https://zhangwenbao.com/wordpress-categories-seo-add-custom-titles-keywords-descriptions.html)调用,也就是title、keywords、description这三个标签的写法。新手们经常以为织梦默认模板里的写法已经够好了,结果一上线发现首页、栏目页、文章页全都重复,关键词命中惨不忍睹。 今天我就把这块整个串起来讲一次,把首页、栏目页、频道页、文章页四种典型场景的标签调用方式全部给你写清楚,再加上我自己常用的几个SEO优化经验,希望能帮到那些刚接手织梦项目的兄弟们。 ## 为什么TDK是织梦SEO的命脉 做过SEO的都知道,title和description是搜索引擎判断一个页面主题的最直接信号。keywords虽然在国内主流搜索引擎的权重已经下降不少,但在某些垂直引擎和站内搜索里依然有效,所以我一直坚持把这三个标签都规范化处理。 我曾经接过一个本地装修公司的站,模板是别的团队做的,所有页面 title 都直接调用 {dede:global.cfg_webname/},结果整个站不管哪个页面,标题都是公司名。最后表现就是几百个页面在搜索引擎里只剩首页有排名,其它页面互相竞争把权重分散光了。 这种问题排查起来其实就是检查一遍每个模板的TDK标签,把对应场景的写法换成正确的版本就好了。但前提是你得知道每种页面应该用哪个调用标签,这也是这篇笔记要解决的核心问题。 ## 主流搜索引擎对 TDK 三个字段的实际渲染长度 动手写之前先记住几个关键的字符数约束——超出后会被搜索引擎截断或忽略: 字段 | 百度 | Google | 必应 | 建议保险值 | title 中文 | 30~32 字 | 14~16 个全角字 | 30 字左右 | ≤30 字 | title 英文 | — | 50~60 字符 | 50 字符 | ≤55 字符 | description 中文 | 78 字(移动)/120 字(PC) | 120~160 字符 | 156 字符 | 78~120 字 | keywords | 权重低,3~5 个 | 不读 | 权重低 | 3~5 个 | 所以同一个 title 在百度移动端、PC 端、Google 中的截断行为可能差异很大。我现在写 title 都按"前 14 字必须包含核心查询词"的硬规矩来,确保所有引擎都能读到核心词。 ## 首页的TDK调用标准写法 首页的TDK调用最简单,因为它取的就是后台"系统-系统基本参数"里设置的全局值。 {dede:global.cfg_webname/} 这种写法直接读取后台配置,省心,但有一个小问题:很多站长会把 cfg_webname 直接当成首页 title,导致首页 title 只有公司名,缺少业务关键词。 我个人推荐的做法是,在后台再单独建一个变量,比如 cfg_indextitle,专门存放首页 title 的完整写法,例如"某某装修公司-成都老房翻新-厨卫改造-XX装饰"。然后模板里这样调用: {dede:global.cfg_indextitle/} 这样既保留了在后台直接编辑的便利性,又避免了首页title过于单薄。要新增自定义变量很简单,进入"系统-系统基本参数-添加新变量",类型选"文本",变量名填 cfg_indextitle,值填你想要的title即可。 ## 栏目页的TDK调用标准写法 栏目页是很多站长容易翻车的地方。织梦的栏目页 title 默认调用 {dede:field.seotitle/},这个值对应的是你在"栏目管理"里编辑栏目时设置的"SEO标题"。 {dede:field.seotitle/} 这里有几个坑必须提前讲清楚。第一个坑:如果你在栏目设置里没填"SEO标题",那么 seotitle 会是空的,最终输出的 title 就是空标签,这对 SEO 是灾难性的。 我推荐做一个兼容写法,没填 seotitle 的时候自动 fallback 到栏目名加站点名。织梦的 function 属性里写三元运算符可以实现这个 fallback。 第二个坑:栏目页 description 默认会读 HTML 转纯文本,如果你的栏目描述里有图片或者长段标签,转出来的文本会很乱。我自己的做法是写描述的时候只写纯文字,不超过 150 个字,并且把核心关键词放在前 80 个字以内。 ## 批量检查全站栏目空 SEO 标题的 SQL SELECT id, typename, typedir FROM dede_arctype WHERE (seotitle IS NULL OR seotitle = '') AND ishidden = 0; 这条 SQL 在 SQL 命令行工具里跑一次,能瞬间列出所有缺 SEO 标题的栏目。然后再用一条 UPDATE 批量回填: UPDATE dede_arctype SET seotitle = CONCAT(typename, '_全部资讯') WHERE (seotitle IS NULL OR seotitle = ''); 当然这是兜底用的,理想情况是逐栏目人工写 SEO 标题,但兜底方案能让站点马上脱离"空 title"的灾难状态。 ## 频道页(list)的TDK调用写法 频道页一般指的是 list 模板列表页面,对应栏目下的内容列表。织梦默认的写法和栏目页其实有点区别: {dede:field.title/}_{dede:global.cfg_webname/} 这里 field.title 取的是栏目名,加上站点名做一个组合,整体长度比较友好,搜索结果里展示也比较干净。 但我经常会再做一层增强,把页码加进去。说实话直接在 dede 标签里写翻页有点 hack,更简单的做法是用一个全局判断。如果你的列表页有翻页,第二页之后建议在 title 里加上"第N页",避免分页的多个 URL 全都用同一个 title 互相竞争权重。织梦原生支持得不太好,更优雅的方式是用 PHP 直接拼: 1 ? '_第' . $pageNo . '页' : ''; ?> <?php echo $typename; ?><?php echo $pageSuffix; ?>_<?php echo $cfg_webname; ?> 注意要在合适的位置 include 织梦的全局变量,具体怎么 include 取决于你模板的入口文件。 ## 分页 canonical 与 rel=prev/next 的配套 翻页页面除了 title 加"第 N 页",还要在 head 里加 canonical 和 prev/next 配合,告诉搜索引擎多页之间的关系: 1 ? $baseUrl . '-' . ($pageNo - 1) . '.html' : ''; $nextUrl = $pageNo < $totalPages ? $baseUrl . '-' . ($pageNo + 1) . '.html' : ''; ?> ' /> "; ?> "; ?> Google 在 2019 年宣布不再使用 rel=prev/next 作为索引信号,但 Bing 和百度依然会读,所以保留它是有意义的——并且对辅助工具(screen reader、爬虫框架)也友好。 ## 文章页的TDK调用写法 文章页是织梦SEO流量的主战场,也是TDK必须最讲究的地方。默认写法是: {dede:field.title/}_{dede:global.cfg_webname/} 这个写法基本够用,但我每次接项目都会做几个小升级。 第一,title 里增加栏目层级。比如一篇文章原来标题是"老房水电改造注意事项",加上栏目和站名后变成"老房水电改造注意事项_老房翻新_某某装饰",对长尾关键词 (https://zhangwenbao.com/infinite-tail-seo-beyond-keywords.html)的覆盖更好。 {dede:field.title/}_{dede:field.typename/}_{dede:global.cfg_webname/} 第二,description 默认从文章内容里截取,但有些文章开头是图片或者一句客套话,截出来的描述完全没有信息量。我建议在发文章时手动填写 description,并且在模板里加一层 fallback:如果作者没填,就从正文中截取前 200 字作为兜底。 第三,keywords 字段织梦支持自动从分词系统提取,但提取出来的词经常没什么意义。我个人推荐手动填写 3-5 个核心词,并且每个词之间用半角逗号分隔。 ## 给文章页加上 schema.org Article 结构化数据 规范的文章页除了 TDK 三件套,还应该加上 schema.org/Article 的 JSON-LD,让搜索引擎能识别作者、发布时间、封面、面包屑 (https://zhangwenbao.com/google-mobile-breadcrumbs-removed-seo.html): 这一段我会一并放进 article.htm 的 head 里。Google Search Console 的"丰富搜索结果测试"工具能验证它是否被正确识别。 ## 特殊页面的TDK处理 除了上面四种主流页面,还有一些特殊页面需要单独处理。比如 tag 标签页、搜索结果页、专题页。 Tag 标签页的写法可以这样: {dede:tag.tag/}_相关文章_{dede:global.cfg_webname/} 搜索结果页一般会被加 noindex,但如果你想做长尾关键词覆盖,也可以让它参与索引: {dede:global.keyword/}的搜索结果_{dede:global.cfg_webname/} 专题页要看你具体用哪种实现方式,如果是 spec 模板,调用和栏目页类似;如果是用 SQL 自定义页面,建议手动写死 TDK 或者从专题字段里取。 ## TDK 的兄弟标签:OG / Twitter Card / 微博卡片 移动互联网时代,文章被分享到微信、微博、Twitter 时的卡片渲染同样重要。这套元数据也是 TDK 的扩展,加上之后社交分享的点击率明显提升: 实测加完 OG 后,文章在微信内部被分享出去的卡片会有大图缩略和完整 description,比裸链接展示效果好得多——某客户站文章被微信转发的 CTR 翻倍。 ## 避免TDK重复的几个技巧 SEO 上最忌讳的就是大量页面 TDK 重复。我总结的几个原则给你参考: 第一,title 必须页面唯一。能加栏目名就加栏目名,能加页码就加页码。第二,description 长度控制在 80-160 字之间,开头出现核心关键词。第三,keywords 不要堆砌,3-5 个就够。第四,列表页第二页起一定要加"第 N 页"避免分页重复。第五,每次大改 TDK 之后用站长工具批量抓取一遍,看看是否还有重复。 # 用 wget 拉一遍站点结构后,再用脚本提取所有页面的 title wget -r -l 3 -nd -A.html https://example.com grep -h -o '.*' *.html | sort | uniq -c | sort -rn | head -50 这套命令我每次上线前都会跑一遍,看看哪些 title 出现频次高,再去针对性修改模板。 ## 用 Python + lxml 写一个更精细的全站 TDK 抽样工具 import requests, csv from urllib.parse import urljoin from lxml import html sitemap = requests.get('https://example.com/sitemap.xml').text urls = [u.split('')[0] for u in sitemap.split('')[1:]] with open('tdk_audit.csv', 'w', encoding='utf-8', newline='') as f: w = csv.writer(f) w.writerow(['url','title','title_len','desc_len','kw_count']) for u in urls: try: r = requests.get(u, timeout=8) tree = html.fromstring(r.content) title = (tree.xpath('//title/text()') or [''])[0] desc = (tree.xpath('//meta[@name=\"description\"]/@content') or [''])[0] kws = (tree.xpath('//meta[@name=\"keywords\"]/@content') or [''])[0] w.writerow([u, title, len(title), len(desc), len([k for k in kws.split(',') if k.strip()])]) except Exception as e: w.writerow([u, '', 0, 0, 0]) print('done') 跑完用 Excel 透视表看一下"标题=A 的行数 > 1"就能找出所有重复 title。这个脚本对几千页的站点跑一遍大约 20-40 分钟。 ## 保哥的实战经验和建议 做了这么多年的织梦SEO,我的体会是:模板里的 TDK 是最基础也是最容易拉开差距的地方。同一套站,TDK 写得好和写得差,半年后流量可能差 5 倍以上。 所以我建议每一个接手织梦项目的同行,第一件事就是把所有页面的 TDK 标签全部检查一遍,确保每种页面都用对了调用方式,确保没有空 title、没有重复 description、没有关键词堆砌。这件事看似枯燥,但收益是长期的。 ## TDK 改造之后的监控指标 上线后的两周到一个月内,盯紧三个指标看效果: - 百度站长平台-索引量趋势:TDK 优化后通常 7-14 天会看到索引量阶梯式上升。 - Google Search Console-效果报告:关键词曝光数、平均点击率(CTR)。优化前后 CTR 提升 30-80% 是常见结果。 - 5118 或站长之家的关键词排名:长尾词排名变化最直观,特别是栏目页 + 翻页加上"第 N 页"后的长尾词覆盖。 ## 常见问题解答 ## 栏目页设置了SEO标题但前台还是显示空白怎么办? 先检查一下后台"栏目管理"里这个栏目的 SEO 标题是不是真的填了,再确认前台模板里调用的是 field.seotitle 而不是 field.title。如果两边都没问题,再看看是不是没生成栏目静态页。织梦改了模板后必须重新生成才能生效,这个坑新手踩得最多。如果是动态访问也能复现,那就检查 list.htm 头部是否被某个 include 文件覆盖掉了 title 标签。 ## description调用为什么会带HTML标签? 那就是没加 function='html2text(@me)'。这个函数会把字段里的 HTML 转成纯文本,避免 description 里出现 p 标签、br 标签这种垃圾内容。建议所有调用 description 的地方都加上这个函数,无一例外。另外 html2text 会把链接文字也保留,但去掉 a 标签包装,所以最终输出是干净的纯文本。 ## keywords标签真的还有用吗? 国内主流搜索引擎对 keywords 的权重已经非常低,但站内搜索、tag 关联、相关文章推荐等功能依然会读取这个字段,所以我建议保留并且认真填写。每篇文章 3-5 个相关词,对站内推荐和站内搜索的体验都有帮助。Google 早在 2009 年就明确声明不读 meta keywords (https://zhangwenbao.com/google-seo-meta-keywords.html),但百度对长尾分词依然有微弱的辅助作用。 ## 织梦默认的description截取很乱,有什么好的优化方式? 最稳的方式是发文章时手动填写 description,这样不会出现奇怪截断。如果文章太多没法每篇都填,可以在模板层加一层 fallback,先判断 description 字段是否为空,为空就用 cn_substr 截取正文前 200 字作为兜底。这种写法在织梦里需要稍微 hack 一下,但效果非常好。更彻底的做法是写一个 cron 脚本每天扫一遍 dede_archives,把空 description 的字段用文章正文前 200 字回填一次。 ## 同一篇文章不同 URL(PC 与移动)TDK 怎么处理? 建议 PC 和移动版用完全相同的 TDK,避免被搜索引擎认为是重复内容。如果你的移动版在 /m/ 子域,PC 页 head 加 alternate 指向 /m/ 版本、/m/ 页 head 加 canonical 指回 /a/ 原页,告诉搜索引擎这是同一内容的两种呈现。这样不会因为 TDK 重复触发降权,反而能集中两个版本的权重。 ## title 用下划线分隔还是用空格、竖线分隔哪个更好? 实测三种都行:下划线、半角空格、竖线。我个人偏好下划线和竖线混用:层级用下划线(核心词_细分_品牌),断点用竖线。比如"老房水电改造_装修攻略 | 某某装饰"。这种分隔在百度 SERP 里能让用户一眼看出重点。Google 中文站对中文下划线分隔的解析也是完美的。 ## 用了百度小程序,TDK 还要单独配置吗? 是的。百度小程序有自己的 page meta 配置,不读 HTML 里的 TDK。具体在 app.json 和每个 page.json 里配置 navigationBarTitleText,并且小程序的 description 走的是百度智能小程序后台-基本信息-长描述字段。这是和 PC/移动 H5 完全独立的一套,不能复用织梦模板里的 TDK。 ## 批量给历史文章补 description 的 SQL 脚本? 给一个稳妥的版本:UPDATE dede_archives a JOIN dede_addonarticle b ON a.id=b.aid SET a.description=LEFT(REGEXP_REPLACE(b.body, '<[^>]+>', ''), 200) WHERE (a.description IS NULL OR a.description = '') AND b.body IS NOT NULL. 这条会把正文 HTML 标签剥掉、截前 200 字回填到 description。但 REGEXP_REPLACE 需要 MySQL 8.0+,5.7 上可以用织梦自带的 html2text PHP 函数写脚本批跑。 ## 权威参考资料 ## DedeCMS提示信息错误怎么改?源码+模板修改47步实战指南 - URL:https://zhangwenbao.com/dedecms-prompt-message.html - 分类:织梦CMS教程 - 发布:2019-12-26 | 更新:2026-06-02 - 摘要:深入梳理DedeCMS 5.7 SP2提示信息文案的所有源码位置:ShowMsg/ShowMsgFinish/ShowMsgWait三个函数差异、include与templets与member三套模板同步改写、倒计时进度条JS实现、品牌logo嵌入、多语言i18n方案以及升级时不丢改动的Git工作流。 - 关键词:织梦提示信息,ShowMsg,message.htm,DedeCMS品牌定制 > **TLDR**:摘要:想改DedeCMS那行提示信息文案,得先定位它在源码里的位置。本文讲清ShowMsg与ShowMsgFinish与ShowMsgWait三个函数的差异,给出改common.func.php和更稳妥的改message.htm模板两种做法,提醒别忘了容易漏的plus/message.htm,再讲怎么在不破坏升级路径的前提下改动、回归测试和ShowMsg的XSS防御。 > 摘要:想改DedeCMS那行提示信息文案,得先定位它在源码里的位置。本文讲清ShowMsg与ShowMsgFinish与ShowMsgWait三个函数的差异,给出改common.func.php和更稳妥的改message.htm模板两种做法,提醒别忘了容易漏的plus/message.htm,再讲怎么在不破坏升级路径的前提下改动、回归测试和ShowMsg的XSS防御。 做了这么多年的织梦仿站和企业站维护,我手上经手过的DEDECMS项目少说也有上百个。每次客户拿到一个新做的站,最先发现的小毛病,几乎不是栏目分类有问题,也不是模板样式没对齐,而是登录提交一条评论、留言失败、或者投票之后跳出来的那行字——"DEDECMS提示信息!"。 这行文字本身没什么大问题,但它对一个想要做品牌的网站来说,确实显得不够自家。客户经常会问我:保哥,这几个字能不能改成我们公司的名字?要不就改成网站的logo文字?说实话,这种需求从我做织梦的第一年就开始遇到,到现在依然不少。今天我就把多年来积累的修改方式,连同我自己踩过的坑,统一整理成一篇笔记,方便后来者直接照搬上手。 ## 为什么这行提示文字值得花时间改 很多新手会觉得,提示信息只是用户在某些动作之后才能看到的小弹窗,谁会在意呢?我以前也这么想过,直到有一次给一个本地的家居企业客户做完站,他们的运营人员在上线第二周就找到我,说:保哥,我们老板看到那个跳转页弹的还是DEDECMS,问我们花的钱去哪了。 从那次以后我就开始重视这件事。一个网站的细节体验,往往就藏在这种角落里。从SEO的角度看,搜索引擎抓取站点的时候不会专门解析这行字,但它会影响品牌词 (https://zhangwenbao.com/branded-vs-nonbranded-keyword-traffic-structure-strategy.html)在页面里的覆盖率。从用户体验的角度看,提示信息出现的瞬间,用户的注意力是集中的,这时候出现你的品牌名而不是CMS名字,效果远比页脚一行版权信息好得多。 所以我现在每次仿站交付前,都会把这件事放进上线前检查清单里。改一次只要几分钟,但一旦不改,可能客户半年都念叨这事。 ## 织梦提示信息背后的源码定位 要改一段提示文字,先得搞清楚它到底从哪里来。织梦的提示文字主要由两部分构成,一部分写在PHP文件里,一部分写在模板里。 PHP端的逻辑核心在 include/common.func.php 这个文件中,这里有一个名为 ShowMsg 的函数,它负责接收提示标题、跳转链接、跳转时间这些参数,然后输出一段HTML。模板端则在 templets/system/message.htm 或者 templets/plus/message.htm 中,这两个文件是真正负责把提示界面渲染出来的载体。 搞清楚这层关系,后面要改什么、改在哪里,思路就会清晰很多。我在带新人的时候,总是会强调一句话:织梦的所有奇怪现象都能通过 grep 一下源码找到答案,这套系统就是个没怎么加密的 PHP 程序,没有什么神秘的魔法。 ## DedeCMS 全站包含"DEDECMS提示信息"的所有文件清单 我在客户站上跑过完整 grep,发现远不止 common.func.php 和 message.htm 两个文件,下面是 5.7 SP2 GBK 版的完整命中清单: 路径 | 命中次数 | 说明 | include/common.func.php | 2 | ShowMsg 函数 + showMsg 兼容函数 | templets/system/message.htm | 2 | 系统提示模板 | templets/plus/message.htm | 2 | 前台插件类提示模板 | member/templets/message.htm | 2 | 会员中心提示模板 | plus/list_old.php | 1 | 旧版列表回退提示 | plus/feedback.php | 1 | 评论提交反馈提示 | plus/vote.php | 1 | 投票结果提示 | plus/recommend.php | 1 | 推荐文章提示 | plus/digg_ajax.php | 1 | 顶踩 Ajax 反馈 | plus/diy.php | 1 | 自定义表单 (https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html)提交结果 | 如果你只改前两个,会员中心和插件页面依然显示老文案。这就是为什么很多教程教完客户改 common.func.php 后客户回头说"还有几个页面没改",原因就在这。 ## 最直接的方式:修改common.func.php 大多数教程里讲的都是这种方式,因为它最容易理解,也最容易上手。打开 include/common.func.php,搜索关键字 DEDECMS,你会发现两处出现"DEDECMS提示信息"这几个字。 // 修改前 $htmlhead = '\r\n\r\nDEDECMS提示信息\r\n'; // 后面还有一段类似的输出 echo '
DEDECMS提示信息!
'; 你只要把这两处的"DEDECMS提示信息"替换成你想要的文字,例如"保哥笔记 友情提示"或者"某某公司 系统提示",保存上传覆盖,刷新一下页面,提示文字就更新了。 // 修改后示例 $htmlhead = '\r\n\r\n保哥笔记 友情提示\r\n'; echo '
保哥笔记 友情提示!
'; 这种方式有一个潜在的坑:织梦本身的升级补丁经常会覆盖 common.func.php。如果你后续打了官方补丁,可能会把你之前改过的文字又恢复回去。我个人的做法是把改动写进一份 patch 笔记里,每次升级完先 diff 一下,再把自己的改动重新打回去。 ## ShowMsg、ShowMsgFinish、ShowMsgWait 三个函数的差异 织梦的提示函数其实是一族而不是单个函数: - ShowMsg($msg, $gourl, $onlymsg=0, $limittime=0):标准提示函数,输出 message.htm 渲染的页面,自动跳转 - ShowMsgFinish($title, $msg, $gourl):完成态提示,常用于发布文章成功后 - ShowMsgWait($title, $msg, $url, $sec):等待态提示,倒计时跳转 三者都在 common.func.php 里定义,且都包含 "DEDECMS提示信息" 字面。建议三处都改成同一个品牌文案,保持一致性。 ## 更稳妥的方式:直接修改message.htm模板 说实话,我现在已经很少改 common.func.php 了,更倾向于改模板文件,因为模板文件被升级覆盖的概率小很多。 打开 templets/system/message.htm,里面是一个标准的HTML文档结构。把里面的两个"DEDECMS提示信息"改成你的品牌名,再顺手把样式也调一下,让它和主站风格一致。比如把 body 的字体调成你网站常用的字体,把 .title 的颜色改成主色调,这样提示页就不再是裸奔的样子了。 保哥笔记 友情提示
保哥笔记保哥笔记
友情提示
{dede:global.msg/}
{dede:global.foot/}
保存覆盖后,所有触发提示的动作都会用这个新模板渲染,效果立竿见影。我顺便加了 viewport meta 和移动端 media query,让提示页在手机上也是友好的。 ## 给提示页加上倒计时进度条 原版的跳转倒计时只是一行小字,体验不好。我后来习惯加一条进度条:
5 秒后自动跳转, 立即跳转
## 容易被忽略的plus/message.htm 很多人改完 templets/system/message.htm 之后觉得万事大吉,结果切到会员中心或者评论页,依然看到"DEDECMS提示信息"。这是因为 plus 目录下还有一个独立的提示模板。 我自己的习惯是改一份就同步另一份,而且把两个文件的样式做成完全一样的。这样无论提示信息从哪里来,用户看到的视觉效果都是统一的。否则用户在前台一种风格、在会员中心又看到另一种风格,整个站点的质感立刻就掉了一截。 如果你装了第三方插件,有些插件还会自带自己的提示模板,比如某些会员系统、问答系统。这时候要做的就是 grep 一下整个项目里所有的 message.htm,把它们都过一遍。 # 在项目根目录下查找所有 message 模板 grep -rl 'DEDECMS提示信息' ./templets ./plus ./member # 一行命令一次性替换所有 grep -rl 'DEDECMS提示信息' ./templets ./plus ./member | xargs sed -i 's/DEDECMS提示信息/保哥笔记 友情提示/g' 这条命令我每次接手一个新站都会跑一次,少则几个文件,多则十几个,全部统一成同一份品牌文案,整体感会强很多。注意 sed -i 在 macOS 上要写成 sed -i ''(带空字符串),Linux 上不用。 ## 用 PowerShell 在 Windows Server 上批量替换 如果客户用的是 Windows Server + IIS 部署,没有 sed 可用,PowerShell 一行能搞定: Get-ChildItem -Path 'D:\wwwroot\client' -Recurse -Include '*.htm','*.php' | ForEach-Object { $c = [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::GetEncoding('GBK')) if ($c -match 'DEDECMS提示信息') { $c2 = $c -replace 'DEDECMS提示信息','保哥笔记 友情提示' [System.IO.File]::WriteAllText($_.FullName, $c2, [System.Text.Encoding]::GetEncoding('GBK')) Write-Host "Patched: $($_.FullName)" } } 注意织梦 GBK 站点要用 GetEncoding('GBK') 读写,UTF-8 站点改成 UTF8。直接 Get-Content -Raw 会按系统默认编码读,混编情况下容易乱码。 ## 如何在不破坏升级路径的前提下做改动 做织梦项目的人最怕的就是改一次就再也不敢升级了。我自己摸索出来的一套规范是这样的: 第一,所有源码层面的改动,都要写进 notes/patch.md,包括文件路径、修改前的关键字、修改后的内容、修改时间。第二,所有模板层面的改动,都要复制一份原始文件加 .bak 后缀,方便随时回滚。第三,每次升级补丁前先做一次完整压缩备份,文件名带上日期。 # 备份 include 目录下的关键文件 cp include/common.func.php include/common.func.php.bak.20260507 # 备份模板 cp templets/system/message.htm templets/system/message.htm.bak.20260507 这样的好习惯,一旦养成会受益很多年。我手里有几个站从 2014 年一路用到今天,正是靠着这套笔记体系才能稳定维护下来。 ## 用 Git 替代 .bak 备份是更好的方案 更专业的做法是给整个织梦站点初始化一个 Git 仓库,所有改动用 commit 记录: cd /www/wwwroot/yoursite git init git add . git commit -m 'initial dedecms 5.7 SP2 baseline' # 改完之后 git add include/common.func.php templets/system/message.htm git commit -m 'rebrand: DEDECMS提示信息 → 保哥笔记 友情提示' # 升级补丁前先切到一个 patch 分支 git checkout -b official-patch-2026-05 # 应用官方 patch # 然后 merge 回 main,conflict 解决一次即可 Git 的好处是 diff 一目了然、回滚极快、可以追溯何时为什么改的。.gitignore 里把 data/、uploads/ 这两个动态目录排除即可。 ## 修改完别忘了回归测试 改完之后不要急着关 SSH,建议把所有可能触发提示的动作都点一遍。我自己用的清单大概是这些: 第一类是会员动作,包括登录失败、注册成功、密码错误、邮箱未验证。第二类是内容互动,包括发表评论、评论审核中、收藏成功、点赞成功。第三类是后台动作,包括登录提示、权限不足、数据保存成功。第四类是支付或订单,如果你的站点用了商城插件,这块也要测。 每点一次都看看弹出来的提示页面,标题、正文、跳转倒计时是不是都符合预期。如果有遗漏的"DEDECMS提示信息",就再回去 grep 一遍源码,找到对应的位置补上。 ## 用 Puppeteer 自动化回归测试 如果客户站规模较大、提示页类型多,可以写一个 Node + Puppeteer 脚本自动遍历所有提示场景: const puppeteer = require('puppeteer'); const scenarios = [ { name: '评论提交', url: 'https://example.com/plus/feedback.php?aid=1', method: 'POST', body: { msg: 'test' } }, { name: '投票', url: 'https://example.com/plus/vote.php?dopost=send' }, { name: '会员注册', url: 'https://example.com/member/reg_new.php', method: 'POST' }, // ... ]; (async () => { const browser = await puppeteer.launch(); for (const s of scenarios) { const page = await browser.newPage(); await page.goto(s.url); const title = await page.title(); if (title.includes('DEDECMS')) console.error('FAIL:', s.name, title); else console.log('OK:', s.name, title); await page.close(); } await browser.close(); })(); 这个脚本可以放到 CI 里,每次升级补丁后自动跑一遍,第一时间发现哪些提示页又被还原了。 ## ShowMsg 的 XSS 防御注意事项 改完文字之后顺便把安全性也看一眼。原版 ShowMsg 函数把 $msg 直接拼到 HTML 里输出,如果某个调用方传入了用户可控数据(比如评论内容),就会触发存储型 XSS。改写 message.htm 时建议加一道防御: // 在 message.htm 里把
{dede:global.msg/}
// 改成(如果你确定 msg 永远是文本不含 HTML)
{dede:global.msg function='htmlspecialchars(@me, ENT_QUOTES, "UTF-8")'/}
这样即使有插件不小心把用户输入直接传给 ShowMsg,也会被 htmlspecialchars 拦截一道。注意 dede 标签的 function 参数里如果包含双引号要用 HTML 实体写。 ## 保哥的几条经验总结 做了这么多年织梦,我对这种小细节的修改已经形成了固定流程。第一,先改模板,能不动 PHP 就不动。第二,所有改动都做记录,方便升级。第三,前后台、插件、模板都要扫一遍。第四,改完之后必须做一遍完整的点击测试。 表面看起来这只是一个文字替换的小活,但它背后体现的是一个站长对网站细节的态度。我经常跟同行说一句话:织梦不是一个完美的 CMS,但它给了我们足够多的自由度,让我们可以把每一个角落都改成自己想要的样子,问题在于你愿不愿意花这个时间。 ## 常见问题解答 ## 修改完common.func.php上传后还是显示原来的字,是缓存问题吗? 大概率是浏览器缓存或者织梦自身的页面缓存。先在浏览器里 Ctrl+F5 强制刷新,如果还不行就进后台"系统-系统基本参数"后随便保存一次,再到"生成-更新主页 HTML"里重新生成一次。如果还是没生效,回去检查文件是否真的上传成功,特别注意 FTP 工具有时候会因为编码问题导致 PHP 文件没真正覆盖。GBK 站点用 UTF-8 编辑器保存 PHP 文件会让中文变成乱码,这是新人最容易踩的坑。 ## 我改了message.htm但有些提示页还是老样子,怎么办? 那基本就是 plus/message.htm 或者 member/templets/message.htm 没改,或者站点里装了第三方插件自带模板。建议在项目根目录运行一次 grep -rl 'DEDECMS提示信息' . 命令,把所有包含旧文字的文件都列出来,逐一处理。改完之后再运行一次同样的 grep,确认没有漏网之鱼。 ## 织梦升级会把我的改动覆盖回去吗? 会。所以我才反复强调要做笔记和备份。我的建议是把所有改动整理成一份 patch 文件,每次升级完用 diff 比对一下,再把改动重新打回去。如果改动比较多,可以写一个 shell 脚本一键应用,效率会高很多。最稳的方式是用 Git,每次官方 patch 在独立分支应用,然后 merge 回主分支,冲突自动定位。 ## 能不能不改源码,只通过模板把提示文字盖掉? 大部分场景是可以的,因为提示页面的标题和正文最终都是由模板控制的。但 common.func.php 里有些直接 echo 出来的字符串依然会绕过模板,建议两边都过一遍最稳妥。如果你是仿站交付,模板和源码我都会帮客户改,确保上线后看不到任何 CMS 默认文案。具体来说,ShowMsg 里的 htmlhead 部分是绕过模板直接 echo 的,只能改源码;模板里的部分才走 message.htm 渲染。 ## 移动端访问提示页布局错乱怎么办? 原版 message.htm 没加 viewport meta、CSS 也是固定宽度,手机上显示会很丑。本文第四节的样式模板已经加了 viewport 和 media query,把固定 600px 宽度改成 max-width + 自适应 margin,手机上看完全正常。如果你想更进一步,可以把样式抽到单独的 CSS 文件,方便后续整体改皮肤。 ## 多语言站点怎么处理提示文字? 织梦本身不支持原生 i18n。变通办法是根据 $_SERVER['HTTP_ACCEPT_LANGUAGE'] 在 ShowMsg 函数顶部判断语种,预先选择不同的 message.htm 模板。或者更彻底一点:把 message.htm 里的提示文字全部抽到一份 lang.php 文件里,提示函数读 $LANG[$key] 输出。改造工作量稍大,但一次到位。 ## 客户希望提示页带上品牌 logo 和导航栏,怎么做? 把 message.htm 改写成 layout 嵌套结构,复用主站的 header.htm 和 footer.htm 即可。具体写法是把 {dede:include filename='head.htm'/} 和 {dede:include filename='foot.htm'/} 放在 main 区块外面,提示内容放中间。这样提示页和正文页视觉完全统一,用户体验会好得多。但要注意 head.htm 里如果有依赖 cookie 或 session 的逻辑,提示页可能拿不到,先在测试环境试一下。 ## 提示页跳转倒计时能不能动态控制? 能。ShowMsg 函数的第四个参数 $limittime 就是跳转秒数。在调用 ShowMsg 的地方传入不同值即可。比如成功类提示用 2 秒(快速跳走),失败类提示用 5 秒(让用户有时间读完原因)。如果想做"用户主动确认才跳转",把模板里的 setInterval 改成给"立即跳转"按钮加 onclick 即可,去掉自动跳转。 ## 织梦DedeCMS交付前怎么清空数据?一组SQL连ID一起归零 - URL:https://zhangwenbao.com/dedecms-batch-clear-data-and-zero-sql-command.html - 分类:织梦CMS教程 - 发布:2019-11-10 | 更新:2026-06-02 - 摘要:完整整理DedeCMS批量清空SQL命令实战指南,覆盖dede_archives、addonarticle、arctiny三张文章表、关键词、标签、评论留言、附件管理共8类数据,含AUTO_INCREMENT归零步骤、5个生产场景SOP与4种回滚方案。 - 关键词:织梦SQL,DedeCMS批量处理,DedeCMS,DedeCMS数据维护,DedeCMS SQL工具 > **TLDR**:摘要:织梦站交付前必须把测试数据清干净,而且要用SQL命令而非后台逐条删。本文给出数据库表结构速查,逐类清空栏目、文章三张表、关键词与搜索词、标签系统、评论与留言、上传目录的图片附件共八类数据,含AUTO_INCREMENT归零步骤、五个生产场景的完整SOP和四种回滚方案。 > 摘要:织梦站交付前必须把测试数据清干净,而且要用SQL命令而非后台逐条删。本文给出数据库表结构速查,逐类清空栏目、文章三张表、关键词与搜索词、标签系统、评论与留言、上传目录的图片附件共八类数据,含AUTO_INCREMENT归零步骤、五个生产场景的完整SOP和四种回滚方案。 ## 写在前面:交付前为什么必须清干净数据 保哥做企业站点交付,最常遇到的一个收尾环节就是:测试期间填充了一堆假数据、栏目和文章被反复增删,到了正式上线那一刻,客户希望整个后台是干净的,文章ID从1开始、栏目编号也从1开始。如果只是从后台一条条删,效率低不说,自增ID依然不会归零,看起来非常不专业。 这篇文章把保哥多年实操织梦DedeCMS(包括早期的5.7 SP2和后来的若干修复版、UTF8/GBK双编码版、DedeBIZ商业版)总结的批量清空SQL命令整理一遍,并把每条语句背后的原理、风险点、踩坑经验全部写清楚,方便后来者照单全收。所有命令在保哥本地的5.7 SP2 UTF8、5.7 SP2 GBK、DedeBIZ 6.2三套环境上实测过,差异点会单独标出。 ## 5个真实需要清空数据的场景 不是所有"清空数据"都是交付,下面这5个场景在保哥客户中各占一定比例: - 测试转正式交付(占60%)——测试期间灌了一两千条假数据,上线前要全部清掉、ID归零 - 站点内容重做(占15%)——SEO策略大改,旧文章对应的关键词布局 (https://zhangwenbao.com/full-platform-seo-keyword-deployment-7-platforms-guide.html)已经不适用,整体推倒重来 - 合并多个测试站点(占10%)——把3个测试站合成1个生产站,要先把目标站清空再导入 - 灾备演练(占10%)——验证备份能否完整恢复,先清空再从备份还原 - 站点改版前的快照(占5%)——保留旧站快照后清空当前数据,做大改 ## 3种常见的错误清理方式 错误做法 | 问题表现 | 正确做法 | 仅用后台"批量删除文档" | 翻页慢,ID不归零,容易超时 | 用SQL命令行+ALTER AUTO_INCREMENT | 仅Delete from dede_archives | 正文还在addonarticle表里占用空间 | 三张文章表一起清 | 清完不清uploads物理文件 | 磁盘空间没释放,附件管理乱 | SQL+FTP双清,配合附件检测 | ## 为什么要用SQL命令而不是后台逐条删除 刚接触织梦的朋友可能会问,后台不是有"批量删除文档"、"批量删除栏目"吗,为什么还要绕一圈写SQL?保哥早期也是这么干的,结果发现几个非常麻烦的问题。 ## 问题一:后台批量删除受单页条数限制 织梦后台的批量删除默认每页20到100条,如果你测试阶段灌了几千条文章,光是翻页删除就要花掉一两个小时。而且中途网络一断就要重来,PHP超时也会让操作中断。SQL命令行一条Delete from语句几秒钟就完事。 ## 问题二:自增ID不会归零 这是最关键的一点:后台删除并不会重置自增ID。比如你删了500篇文章,再发一篇新文章它的aid会从501开始而不是1。对于追求URL美观、对aid有规划的SEO项目来说这是不可接受的。比如有些站点的URL规则是/news/{aid}.html,aid直接出现在URL里,从1开始还是从501开始对用户感知和SEO评分都有影响。 ## 问题三:数据分布在多张表,后台删除易留孤立 织梦的文章数据不是单表存储,它至少分布在dede_archives、dede_addonarticle、dede_arctiny三张表中(如果开了图集、软件下载模型还会更多)。后台删除偶尔会出现孤立数据,导致后台列表正常但前台模板渲染报错。这些孤立数据用SELECT很难发现,往往要等到客户报障才知道。 ## 保哥的标准做法 所以保哥的标准做法是:直接进入织梦后台的SQL命令行工具(路径在"系统 -> 系统设置 -> SQL命令行工具"),勾选"多行命令",把下面分类整理好的语句复制粘贴执行,几秒钟就能完成所有清理。如果后台SQL命令行工具被禁用(少数加固版本会禁),用phpMyAdmin或Navicat直连数据库执行也是一样的。 ## DedeCMS数据库表结构速查 清空之前先把表结构搞清楚。DedeCMS的核心数据分布在以下8类表中,每一类都有自己的清空策略。 类别 | 表名 | 用途 | 是否必清 | 文章主表 | dede_archives | 所有文档的主信息(标题、栏目、时间) | 必清 | 文章附加表 | dede_addonarticle | 普通文章的正文body字段 | 必清 | 文档索引 | dede_arctiny | 极简文档信息,URL路由用 | 必清 | 图集附加表 | dede_addonimages | 图集模型的图片列表 | 用图集才清 | 软件附加表 | dede_addonsoft | 软件下载模型的下载链接 | 用软件模型才清 | 商品附加表 | dede_addonshop | 商品模型的价格库存 | 用商品模型才清 | 栏目表 | dede_arctype | 所有栏目(含嵌套关系) | 看是否重置栏目 | 关键词表 | dede_keywords / dede_search_keywords | 文档关键词与搜索关键词 | 建议清 | 标签表 | dede_tagindex / dede_taglist | 标签本身和文章标签关系 | 建议清 | 评论表 | dede_feedback | 所有评论记录 | 测试评论应清 | 留言表 | dede_guestbook | 所有留言记录 | 测试留言应清 | 这张表很重要,是后面所有清空操作的依据。建议清空前先按你站点实际启用的模型对照一遍,缺什么补什么。 ## 清空所有栏目(dede_arctype) 栏目表是织梦内容结构的骨架,所有文章都挂在栏目下,一旦栏目被清掉,文章列表也会出现"未指定栏目"的提示。所以如果你只是想重置文章而保留栏目,不要执行这一段。 Delete from dede_arctype; ALTER TABLE dede_arctype AUTO_INCREMENT = 1; 第一行把dede_arctype表里的所有记录干掉,第二行把这张表的自增ID重置为1,下次新建第一个栏目它的id就是1。这里保哥特别提醒一句:织梦的栏目支持嵌套,子栏目通过reid字段指向父栏目id,所以一旦清空就是连根拔起,不存在"只删某一个分类下所有子栏目"这种半截操作。 ## 条件式删除:只删某个父栏目下的子栏目 如果只想删除某一个父栏目下的全部子栏目,可以用条件式删除: -- 只删 reid 等于 5 的子栏目 Delete from dede_arctype WHERE reid = 5; -- 同时删除该父栏目下所有文章(先查后删,避免误删) SELECT id, title FROM dede_archives WHERE typeid = 5; Delete from dede_archives WHERE typeid = 5; Delete from dede_addonarticle WHERE typeid = 5; Delete from dede_arctiny WHERE typeid = 5; 这条语句会把reid等于5的所有子栏目删掉,但不影响id等于5本身和其他不相关栏目。如果要连父栏目也删,加一条Delete from dede_arctype WHERE id = 5。 ## 清空所有文章(三张表必须一起处理) 这是保哥踩坑最多的地方。织梦的"一篇文章"其实是三张表配合写入的: - dede_archives:文档主表,存标题、栏目、发布时间、点击数等核心字段 - dede_addonarticle:附加表,存正文body字段(普通文章模型) - dede_arctiny:极简索引表,存最简文档信息,主要给URL路由用 ## 三张表的依赖关系 如果只删了dede_archives而忘了dede_addonarticle,会出现"文章已删除但正文仍在数据库占用空间"的状况,长期累计会让数据库膨胀;如果只删了dede_archives而忘了dede_arctiny,前台访问旧URL会出现路由异常。所以一定要三张表一起来: Delete from dede_addonarticle; ALTER TABLE dede_addonarticle AUTO_INCREMENT = 1; Delete from dede_arctiny; ALTER TABLE dede_arctiny AUTO_INCREMENT = 1; Delete from dede_archives; ALTER TABLE dede_archives AUTO_INCREMENT = 1; 顺序上保哥习惯是先删附加表、再删极简索引、最后删主表。原因是从依赖关系上讲,dede_addonarticle和dede_arctiny是依赖dede_archives.aid存在的,先删依赖方再删被依赖方逻辑上更干净。虽然在MySQL层面上由于织梦没有用外键约束,顺序对结果没有强制影响,但养成好习惯能在切换到有外键的系统时少踩坑。 ## 含图集和软件模型的扩展清理 如果你的站点开了图集、软件下载等其他文档模型,还要补上对应的附加表: -- 图集模型 Delete from dede_addonimages; ALTER TABLE dede_addonimages AUTO_INCREMENT = 1; -- 软件下载模型 Delete from dede_addonsoft; ALTER TABLE dede_addonsoft AUTO_INCREMENT = 1; -- 商品模型(DedeBIZ 商业版默认包含) Delete from dede_addonshop; ALTER TABLE dede_addonshop AUTO_INCREMENT = 1; -- 专题模型 Delete from dede_addonspec; ALTER TABLE dede_addonspec AUTO_INCREMENT = 1; 没用到的模型可以跳过。如果不确定开了哪些模型,先执行SHOW TABLES LIKE 'dede_addon%';列出所有附加表,挨个清即可。 ## 清空关键词与搜索词 织梦有两套关键词机制,新手很容易混淆,保哥这里掰开讲清楚。 ## 文档关键词(dede_keywords) 第一套是文档关键词,存在dede_keywords表里,用于"关键词维护"功能。早期可以做关键词替换内链 (https://zhangwenbao.com/significantlink-relatedlink-schema-internal-linking.html)(在文章正文里自动给某些词加链接),但因为容易被搜索引擎判定为黑帽 (https://zhangwenbao.com/black-hat-seo-agency-collateral-penalty-trust-graph.html)内链,新版本DedeCMS已经默认关闭这个功能。 Delete from dede_keywords; ALTER TABLE dede_keywords AUTO_INCREMENT = 1; ## 搜索关键词(dede_search_keywords) 第二套是搜索关键词,存在dede_search_keywords表里,用于记录用户在前台搜索框中输入过的词,便于后台统计热门搜索。 Delete from dede_search_keywords; ALTER TABLE dede_search_keywords AUTO_INCREMENT = 1; 两张表互不干扰,都建议在交付前清空,避免把测试阶段乱搜的"测试"、"123"之类的词留给客户看到。客户进后台第一眼看到热门搜索是"测试1"、"asdf"会非常不专业。 ## 清空标签系统(dede_tagindex 与 dede_taglist) 标签也是两张表配合的: - dede_tagindex:存标签本身,包括tag名、点击数、is_hot标记等 - dede_taglist:存"这个标签下有哪些文章"的对应关系 两张表必须一起清,否则会出现"标签已经不存在但文章列表里还显示该标签"的诡异情况: Delete from dede_tagindex; ALTER TABLE dede_tagindex AUTO_INCREMENT = 1; Delete from dede_taglist; ALTER TABLE dede_taglist AUTO_INCREMENT = 1; 清完之后,原先在文章发布页填过的TAG也会一并消失,下次发新文章时重新填即可。 ## 只清没文章的孤立标签 如果只想清"已经没有任何文章引用"的孤立标签(保留还在使用的标签): -- 先查哪些标签没文章在用 SELECT t.id, t.tag FROM dede_tagindex t LEFT JOIN dede_taglist l ON t.id = l.tid WHERE l.tid IS NULL; -- 删掉这些孤立标签 DELETE t FROM dede_tagindex t LEFT JOIN dede_taglist l ON t.id = l.tid WHERE l.tid IS NULL; 这种用法在已上线运行一段时间的站点上更实用——不需要全清,只清掉历史遗留的"测试"、"123"这类无意义标签。 ## 清空评论与留言 测试期间客户或开发自己发的评论留言也要清掉。这部分比较简单: -- 评论 Delete from dede_feedback; ALTER TABLE dede_feedback AUTO_INCREMENT = 1; -- 留言板 Delete from dede_guestbook; ALTER TABLE dede_guestbook AUTO_INCREMENT = 1; 如果用了第三方评论插件(畅言、Disqus等),还要去对应插件的后台单独清。 ## 清空上传目录中的图片与附件 SQL语句只能处理数据库,处理不了文件系统。织梦后台上传的图片、附件物理文件存放在站点根目录的uploads文件夹下,以年月命名子目录。 ## uploads的标准目录结构 uploads/ allimg/ # 文章插图、缩略图 191110/ # 2019年11月10日上传的文件 191111/ 200115/ userup/ # 会员上传文件 soft/ # 软件下载模型上传的安装包 media/ # 媒体模型上传的音视频 flink/ # 友情链接的logo ## 清理方式一:FTP图形化删除 用FTP工具(FileZilla、WinSCP、Cyberduck)连接服务器,进入uploads目录,把所有日期格式的子文件夹选中删除。注意uploads本身不要删,它是织梦默认的上传根目录,删掉之后再上传图片会报路径错误。 ## 清理方式二:SSH命令行删除 如果你有SSH权限,进入站点根目录后执行: cd /www/wwwroot/site/uploads/allimg rm -rf 19* 20* 21* cd ../userup rm -rf 19* 20* 21* cd ../soft rm -rf 19* 20* 21* 这条命令会删掉以19、20、21开头的所有子目录(覆盖2019到2199年的可能命名),干净利落。删完之后建议再看一眼uploads/media、uploads/flink这几个常用子目录,按需清理。 ## 同步清理附件管理表 执行完文件清理后,记得回到织梦后台"核心 -> 附件管理"里再点一下"不存在文件检测",把数据库里残留的附件记录也一并清理。或者直接清表: Delete from dede_uploads; ALTER TABLE dede_uploads AUTO_INCREMENT = 1; dede_uploads表记录所有上传过的文件路径,物理文件删了但表记录还在,附件管理界面就会显示一堆"文件不存在"的项目,影响美观也影响后续审计。 ## 5个生产场景的完整SOP ## 场景一:测试转正式交付(最常见) 完整步骤: - 做完整数据库备份(必须) - 关闭前台访问 - 清三张文章表(archives+addonarticle+arctiny) - 清关键词与搜索词 - 清标签 - 清评论与留言 - 清uploads物理文件 + 附件管理表 - 更新主页、栏目、文档HTML - 开放前台访问 - 客户验收:发一篇测试文章,确认aid是1 ## 场景二:保留栏目结构只清文章 客户希望栏目分类保留(已经按SEO规划好了),但所有文章重做。这种场景跳过dede_arctype的清空,只清文章相关表。注意清完后栏目的"文章数"统计不会自动归零,要手动跑一遍后台的"文档重新审核"。 ## 场景三:合并多个测试站 把3个测试站合成1个生产站。先清空目标站全部数据,然后从源站SELECT出文章导入目标站。注意typeid(栏目ID)的映射,源站和目标站栏目ID可能不一致,需要先建一张映射表,再用INSERT INTO target ... SELECT ... FROM source ...语法批量导入。 ## 场景四:灾备演练 先备份,再清空,再从备份恢复,最后对比记录数是否一致。这种场景必须用独立的测试数据库实例,不能在生产库上做。恢复后检查关键SQL:SELECT COUNT(*) FROM dede_archives、SELECT MAX(aid) FROM dede_archives这些指标和备份前的快照对比。 ## 场景五:站点改版前的快照 先备份当前数据库到本地(mysqldump --single-transaction),命名为backup_before_redesign_yyyymmdd.sql。然后清空当前数据准备灌入新数据。这种场景的备份必须保留至少6个月,应对客户突然要找旧文章的需求。 ## 4种回滚方案 ## 方案一:mysqldump整库还原 清空前如果做了mysqldump全库备份,回滚就是一行命令: mysql -u root -p database_name < backup_before_clear.sql 整库覆盖式恢复,1分钟以内完成。要求:备份必须是清空前的完整dump,中间没有其他写入。 ## 方案二:binlog点对点恢复 如果MySQL开了binlog(log_bin=ON),可以从binlog里找到Delete语句之前的时间点恢复: mysqlbinlog --stop-datetime="2026-05-12 10:30:00" \ /var/log/mysql/mysql-bin.000123 | mysql -u root -p database_name 精度可以到秒级。要求:开了binlog,并且binlog没有被清理掉。 ## 方案三:单表逐表恢复 只想恢复某一张表(比如不小心多删了文章但不想恢复栏目),用mysqldump导出单表然后mysql导入: mysqldump -u root -p database_name dede_archives > archives.sql mysql -u root -p database_name < archives.sql ## 方案四:Innodb表空间文件恢复 最极端的情况——没备份、没binlog,只有物理文件。Innodb表的.ibd文件包含完整数据,可以用innodb_force_recovery模式启动MySQL尝试读取。这种方式成功率不高(30到50%),仅作为最后手段,不要依赖。 ## 常见问题解答 ## 执行SQL后提示Table doesn't exist是怎么回事? 大多数情况是因为你的织梦版本表前缀不是dede。早期版本默认前缀就是dede,但安装时可以自定义。打开站点根目录的data文件夹下的common.inc.php,找到cfg_dbprefix这一行,看到的就是你站点的真实前缀,把上面所有SQL中的dede替换成你的实际前缀即可。如果是DedeBIZ商业版,前缀可能默认是dedebiz_,要相应修改。还有一种情况:你的MySQL用户没有目标库的权限,执行SHOW TABLES都看不到表,这种要找DBA加权限。 ## 执行后ID没有归零仍然从原来最大值往后加怎么办? 说明你只跑了Delete from却忘了跑ALTER TABLE加AUTO_INCREMENT等于1。Delete只删除数据行,自增计数器仍然记着上一次的最大值,必须显式重置。如果重置后仍然不归零请确认表是InnoDB引擎且MySQL版本足够新;早期MyISAM版本的织梦数据库一般没有这个问题。另外注意:如果Delete之后没有commit就执行ALTER(少数事务模式下),重置可能没生效,重新执行一次ALTER即可。 ## 清空数据后能否撤销恢复? 不能直接撤销。Delete语句一旦提交事务就完成了,没有Ctrl+Z。唯一的恢复方式是从你之前做的数据库备份里还原,或者从MySQL的binlog点对点恢复,要求开了binlog并且没被清掉。所以保哥反复强调:执行任何批量删除前,先做备份、先做备份、先做备份。备份的最低标准是mysqldump整库dump出sql文件并下载到本地一份,加上服务器留一份,两个独立位置降低单点风险。 ## 如果只想清理某一段时间内的测试文章而不是全部清空,怎么写SQL? 这个比较常见,保哥也经常这么干。可以加一个时间条件,比如Delete from dede_archives WHERE pubdate大于等于UNIX_TIMESTAMP两千零2019年十一月一日。但要注意单删dede_archives不够,对应的dede_addonarticle和dede_arctiny也要按aid一起删。可以先把要删的aid查出来用SELECT id FROM dede_archives WHERE pubdate大于等于某时间,然后用DELETE FROM dede_addonarticle WHERE aid IN 括号里aid列表的形式逐表删除比较保险。也可以用JOIN方式一次性删,但JOIN加DELETE的语法在不同MySQL版本表现略有差异,保哥推荐用IN语法更稳。 ## 清空之后前台访问报500错误是怎么回事? 大概率是模板里的arclist标签设置了rownum但数据库里行数不够。比如模板写了rownum等于10但数据库里没有文章,arclist标签可能报undefined index。解决:去模板里把arclist改为容错写法,或者在模板顶部加判断"如果没有文章则显示暂无内容"。还有一种可能:清空后没更新缓存,访问的还是旧缓存里的引用关系,导致模板渲染时找不到对应数据。后台"生成 -> 更新主页"和"更新栏目"跑一遍通常能解决。 ## DedeCMS 5.7和DedeBIZ的清空命令一样吗? 核心表是一样的,dede_archives三件套、dede_arctype等,但DedeBIZ商业版多了几张商业相关的表,比如dede_addonshop商品、dede_addonproduct产品、dede_addonorder订单等。如果是DedeBIZ站点要清空所有数据,要把这些额外表也加上。执行SHOW TABLES LIKE dede_addon开头看到的所有附加表挨个检查是否要清。 ## 清空之后再发布文章ID依然不是1可能是哪个环节没做到? 三个最可能的原因:第一你的SQL命令行工具实际执行失败但没报错,少数情况下织梦后台SQL工具静默吞掉错误,用phpMyAdmin重新执行ALTER TABLE确认;第二你的表是从备份还原的,备份里包含了AUTO_INCREMENT值,还原后需要再次ALTER重置;第三你用的是分库分表方案,如MyCAT、ShardingSphere,不同分片有不同的自增起点,这种情况需要联系DBA重置全局序列。前两个最常见,第三个比较罕见。 ## 清空时MySQL报deadlock错误怎么办? 说明有其他连接正在写这些表,前台访问、后台编辑都可能触发。解决方法:第一在系统设置里把站点改为关闭状态,让前台返回维护页面;第二SHOW PROCESSLIST看哪些连接在跑,KILL掉非必要连接;第三在SQL命令行工具里加SET autocommit等于0然后START TRANSACTION,最后COMMIT。第四种是分批删除,比如DELETE FROM dede_archives WHERE id between 1 and 1000一次只删1000条,循环执行避免长事务锁表。 ## 写在最后 织梦DedeCMS虽然这几年因为安全更新和版权问题逐渐淡出主流视野,但保哥手上仍有不少老客户的存量站点跑在DedeCMS上,运维交接时这套清空SQL几乎每个月都要用一次。把它整理成上面的标准化流程,既是给自己的备忘,也希望能帮到那些接手老站点、需要在交付前还原一个干净后台的同行。 所有命令都在保哥本地的5.7 SP2 UTF8、5.7 SP2 GBK、DedeBIZ三套环境上反复验证过,按步骤执行不会出现意外。如果你的版本是某些第三方修改版(例如带有DedeBIZ商业插件或某些定制版),表结构可能略有差异,记得先在测试环境跑一遍SHOW TABLES LIKE确认所有表名再上正式服务器。备份永远是底线——任何清空操作前的5分钟备份,能避免事后5小时甚至5天的恢复工作。 ## 权威参考资料 ## DedeCMS自定义表单列表UI重构指南:现代化样式、搜索筛选、批量导出与LayUI集成 - URL:https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html - 分类:织梦CMS教程 - 发布:2019-09-29 | 更新:2026-06-02 - 摘要:DedeCMS自定义表单的后台列表页又老又难用。本文给出系统化重构:换模板做卡片网格布局、状态色块徽章,功能上加全选、表头排序、关键词搜索、状态与日期筛选、防乱码的Excel导出,再补无障碍ARIA、暗黑模式、移动端列隐藏和LayUI集成路径。 - 关键词:织梦自定义表单,DedeCMS模板,DedeCMS自定义表单,diy_list,后台UI > **TLDR**:摘要:DedeCMS自定义表单的后台列表页又老又难用。本文给系统化重构——列出原版问题,给整体设计方案、替换dede/templets/diy_list.htm的完整代码和对应的PHP后端改造,再讲接入LayUI或Bootstrap、移动端适配、无障碍与键盘操作、性能优化和常见故障,让后台列表从老气表格变成好用的现代管理页。 > 摘要:DedeCMS (https://zhangwenbao.com/dedecms-commonly-used-batch-sql-statements.html)自定义表单的后台列表页又老又难用。本文给系统化重构——列出原版问题,给整体设计方案、替换dede/templets/diy_list.htm的完整代码和对应的PHP后端改造,再讲接入LayUI或Bootstrap、移动端适配、无障碍与键盘操作、性能优化和常见故障,让后台列表从老气表格变成好用的现代管理页。 DedeCMS 自带的“自定义表单 (https://zhangwenbao.com/dedecms-custom-form-settings-required-items.html)”(diy)模块功能上够用,但后台的列表页样式停留在 2010 年的视觉——白底灰线、文字居中、按钮挤在右下角,连复选框都没有全选。运营每天要在这个界面里审核留言、清理垃圾投稿,看半小时眼睛就花。本文给出一套完整的 dede/templets/diy_list.htm 重构方案:现代化 UI、批量操作、过滤搜索、字段排序、移动端适配、Excel 导出、与现代后台 UI 框架(LayUI、Bootstrap、Element UI)的集成路径。重构后的列表页能让运营效率提升至少 3 倍。 ## 原版列表页的问题清单 ## 视觉层面 - 表格用 1990 年代风格的边框(border 1px solid #ccc),现代浏览器看着扁平且粗糙。 - 字体默认走 SimSun(宋体),对设计师眼里是“档案管理系统”气质,不是产品体验。 - 列宽不可调,长字段(评论内容、留言)会被截断或撑爆布局。 - 分页按钮散在表格底部,没有突出当前页。 ## 功能层面 - 没有全选 / 取消全选按钮,逐行勾选 50 条数据点 100 次鼠标。 - 批量操作只有“审核”“删除”两选项,没有“拒绝”“标记重要”“导出 Excel”。 - 搜索过滤功能缺失,只能翻页找数据。 - 不支持点击表头排序(按时间、按 ID、按用户)。 - 没有响应式设计,手机端打开后表格横向滚动。 ## 交互层面 - 编辑按钮一行一个,没有快速预览 hover 效果。 - 状态字段是文字“已审核 / 未审核”,没有视觉色块区分。 - 无操作日志,谁在什么时候审核了哪条数据完全不知道。 ## 整体设计方案 ## 视觉规范 采用现代后台常见的“卡片 + 网格”视觉风格: - 主色:#009688(青绿,与原文方案一致,是 LayUI 默认主色,运营熟悉)。 - 辅色:#1e9fff(蓝,链接色),#f56c6c(红,删除/警告),#67c23a(绿,已审核状态)。 - 字体:'Microsoft YaHei','PingFang SC','Helvetica Neue',Arial,sans-serif。 - 圆角:4px(按钮/卡片)、8px(弹窗)。 - 阴影:box-shadow: 0 2px 8px rgba(0,0,0,0.08)。 ## 布局方案 +----------------------------------------------------+ | 自定义表单列表 / 反馈表 [前台预览] [新建] | +----------------------------------------------------+ | [搜索框] [状态筛选 ▾] [日期范围] [刷新] [导出] | +----------------------------------------------------+ | [全选] ID 字段1 字段2 状态 操作 | | [☑] 1 ... ... ● [编辑] [审核] [删除] | | [☑] 2 ... ... ○ [编辑] [审核] [删除] | +----------------------------------------------------+ | 共 N 条 [上一页] 1 2 3 [下一页] | +----------------------------------------------------+ | [批量审核] [批量删除] [批量导出] [批量拒绝] | +----------------------------------------------------+ ## 完整代码:替换 dede/templets/diy_list.htm <?php echo $diy->name;?> - 自定义表单
name;?> — 表单列表
自定义表单管理 / 内容列表
重置
$fielddata) { echo ''; } ?> {dede:datalist} 已审核' : '未审核'; echo ''; echo ''; echo ''; foreach ($fieldlist as $field => $fielddata) { if ($fielddata[1] == 'img') { $value = '[图片附件]'; } elseif ($fielddata[1] == 'addon') { $value = '[附件]'; } else { if (!in_array($fielddata[1], $allowhtml)) { $value = htmlspecialchars($fields[$field]); if (mb_strlen($value, 'UTF-8') > 50) { $value = '' . mb_substr($value, 0, 50, 'UTF-8') . '...'; } } else { $value = $fields[$field]; } } echo ''; } echo ''; echo ''; echo ''; } else { echo ''; } ?> {/dede:datalist}
ID ' . htmlspecialchars($fielddata[0]) . '状态 操作
' . $fields['id'] . '' . $value . '' . $statusBadge . ''; echo '编辑 '; if ($fields['ifcheck'] != 1) { echo '审核 '; } echo '删除'; echo '
暂无数据
## 对应的 PHP 后端改造 ## diy_list.php 加搜索过滤逻辑 原版的 diy_list.php 不处理 keyword、status、date_from/to 这些参数。需要在文件里把 SQL 查询条件扩展: $where = "WHERE diyid='{$diyid}'"; if (!empty($_GET['keyword'])) { $kw = addslashes($_GET['keyword']); // 简化:在第一个 text 字段搜索 $where .= " AND ("; $first = true; foreach ($fieldlist as $field => $fielddata) { if (in_array($fielddata[1], ['text', 'textdata', 'multitext', 'htmltext'])) { if (!$first) $where .= " OR "; $where .= "`{$field}` LIKE '%{$kw}%'"; $first = false; } } $where .= ")"; } if (isset($_GET['status']) && $_GET['status'] !== '') { $st = intval($_GET['status']); $where .= " AND ifcheck={$st}"; } if (!empty($_GET['date_from'])) { $df = strtotime($_GET['date_from']); if ($df) $where .= " AND dtime >= {$df}"; } if (!empty($_GET['date_to'])) { $dt = strtotime($_GET['date_to']) + 86400; if ($dt) $where .= " AND dtime < {$dt}"; } // 排序 $orderby = isset($_GET['orderby']) ? preg_replace('/[^a-z0-9_]/i', '', $_GET['orderby']) : 'id'; $order = (isset($_GET['order']) && $_GET['order'] === 'asc') ? 'ASC' : 'DESC'; $ordersql = "ORDER BY `{$orderby}` {$order}"; ## 批量导出 Excel 的实现 在 diy_list.php 加 export 分支: if ($_POST['action'] === 'export' && !empty($_POST['id'])) { $ids = array_map('intval', $_POST['id']); $ids_in = implode(',', $ids); header('Content-Type: application/vnd.ms-excel; charset=UTF-8'); header('Content-Disposition: attachment; filename=diy_export_' . date('Ymd_His') . '.xls'); header('Cache-Control: max-age=0'); echo "\xEF\xBB\xBF"; // UTF-8 BOM 让 Excel 正确识别中文 $rows = $dsql->getAll("SELECT * FROM `dede_diyform{$diyid}` WHERE id IN ({$ids_in})"); if (!empty($rows)) { // 表头 echo implode("\t", array_keys($rows[0])) . "\n"; // 数据 foreach ($rows as $row) { echo implode("\t", array_map(function($v) { return str_replace(["\t", "\n", "\r"], ' ', $v); }, $row)) . "\n"; } } exit; } ## 更进一步:接入 LayUI 或 Bootstrap 本文方案是“纯 CSS 重构”,没有引入框架。如果你愿意承担额外 JS/CSS 大小(一般 200KB 左右),用 LayUI 能换来更好的弹窗、tooltip、消息提示: 把表格替换为 layui.table: layui.use('table', function() { var table = layui.table; table.render({ elem: '#diy-table', data: , cols: [[ { type: 'checkbox' }, { field: 'id', title: 'ID', width: 70, sort: true }, // ... 其它列 { fixed: 'right', title: '操作', toolbar: '#action-bar' } ]], page: true, limit: 20 }); }); ## 移动端适配 本文 CSS 已经包含 @media (max-width: 768px) 适配。补充几条让移动端体验更好: @media (max-width: 768px) { /* 隐藏非关键列 */ .diy-table .col-id { display: none; } .diy-table th:nth-child(n+5):not(.col-status):not(.col-actions), .diy-table td:nth-child(n+5):not(.col-status):not(.col-actions) { display: none; } /* 操作按钮纵向排列 */ .col-actions { display: flex; flex-direction: column; gap: 4px; } } ## 无障碍与键盘操作 给所有 button、input 加 aria-label,让屏幕阅读器能正确朗读: 删除 键盘操作:用 tabindex 让 Tab 键能依次跳到所有表单元素,让运营能纯键盘操作。 ## 性能优化 ## 大数据量下的虚拟滚动 当表单数据超过 1000 条,HTML 表格渲染会卡。两种应对: - 分页:每页 20-50 条,绝对够用。本文方案默认走分页。 - 虚拟滚动:只渲染可视区域的行,需要引入 layui.table 或 vue-virtual-scroller,复杂度上升。 ## SQL 查询索引 diyform 表的 ifcheck、dtime 字段如果没建索引,状态筛选与日期范围会全表扫描: ALTER TABLE dede_diyform1 ADD INDEX idx_ifcheck (ifcheck); ALTER TABLE dede_diyform1 ADD INDEX idx_dtime (dtime); ## 常见故障 ## 故障 1:替换模板后空白页 多数是 PHP 语法错误。检查模板里的 PHP 代码段,特别是 echo $diy-> 与 $fields[ 这些位置的引号匹配。打开 PHP 错误显示:在 diy_list.php 顶部加 error_reporting(E_ALL); ini_set('display_errors',1); 临时排查。 ## 故障 2:CSS 样式不生效 浏览器缓存或者 base.css 的样式覆盖了你的内联 style。在你的 style 块里给关键样式加 !important 强制优先。 ## 故障 3:搜索后翻页丢失搜索条件 原版 diy_pagelist 不传 keyword 等参数。需要修改 dede:pagelist 标签输出,或者在 footer 自己拼分页 URL: $baseurl = $_SERVER['PHP_SELF'] . '?' . http_build_query(array_merge($_GET, ['pageno' => '%d'])); ## 故障 4:批量导出 Excel 中文乱码 Excel 默认按 GBK 解析 .xls 文件。修复:在 echo 之前 echo "\xEF\xBB\xBF" 加 UTF-8 BOM;或者改用 PhpSpreadsheet 库生成真正的 .xlsx 文件。 ## 故障 5:移动端横向滚动 说明 @media 没生效。检查 viewport meta:。有些 DedeCMS 后台模板没设 viewport,移动端会按桌面宽度渲染。 ## 故障 6:全选后提交报错“id 不能为空” selectAll 函数把所有 checkbox 都选了,包括“批量动作”单选按钮。改成只选 .row-check 类的:document.querySelectorAll('.row-check').forEach(box => box.checked = true)。 ## 常见问题解答 ## 替换模板后能否回滚? 能。改前先备份原版 diy_list.htm(cp diy_list.htm diy_list.htm.bak)。出问题 mv 回去即可。 ## UI 框架选 LayUI 还是 Element UI? LayUI 是国内出品,文档中文友好,与 PHP 后台结合最少冲突。Element UI 是 Vue 生态,需要前后端分离,与 DedeCMS 的传统 PHP 模板架构不匹配。建议 DedeCMS 用 LayUI。 ## 能否只改 CSS 不改 PHP 逻辑? 能。本文方案分两层:CSS 重构(纯样式)与功能扩展(搜索、排序、导出)。如果你只想视觉升级不动业务逻辑,只复制 CSS + HTML 结构那部分。 ## 大量自定义表单怎么统一管理样式? 把 CSS 抽成独立文件 dede/templets/css/diy_modern.css,所有 diy_list.htm、diy_main.htm、diy_view.htm 都引用同一个 CSS。改样式时只改一处。 ## DedeCMS 5.7 SP2 与 SP1 的 diy_list.htm 有差异吗? 有但不大。SP2 添加了 multitext 字段类型支持,本文模板的 fielddata[1] 判断已经兼容。SP1 没有 multitext 类型不会触发分支。 ## 能否做暗黑模式(dark mode)? 能。在 CSS 末尾加: @media (prefers-color-scheme: dark) { body { background: #1e1e1e; color: #e0e0e0; } .diy-container { background: #2d2d2d; } .diy-table thead th { background: #3a3a3a; color: #999; } .diy-table tbody td { color: #ccc; border-color: #3a3a3a; } .diy-table tbody tr:hover { background: #383838; } } ## 表单字段是富文本怎么显示? 本文方案用 htmlspecialchars 转义所有非 htmltext 字段,富文本(htmltext 类型)会原样输出 HTML。如果担心 XSS,再加一层 strip_tags 限制允许的标签:strip_tags($value, '


')。 ## 有没有现成的更专业的 DedeCMS 后台美化模板? 有。GitHub 上有 dedecms-admin-layui、dedecms-bootstrap 这类开源主题,整套替换 dede/templets/ 目录。但接管整个后台改动较大,本文方案是单页面美化更可控。 ## 新版 DedeBIZ 是否还需要这个美化? DedeBIZ(DedeCMS 的商业延续版)后台 UI 已经现代化,不需要本文方案。本文专门针对 DedeCMS 5.7 原版。 ## 美化后能否影响后台登录与权限? 不影响。本文只改 dede/templets/diy_list.htm 这一个模板文件,与 dede/login.php、权限校验逻辑完全独立。 ## DedeCMS自定义表单必填项校验加固版:变量变量安全风险、HTML5校验、蜂蜜罐与reCAPTCHA协同 - URL:https://zhangwenbao.com/dedecms-custom-form-settings-required-items-2.html - 分类:织梦CMS教程 - 发布:2019-09-29 | 更新:2026-06-02 - 摘要:DedeCMS自定义表单默认不校验必填,社区流传的修法依赖PHP可变变量,反而埋下能覆盖数据库密码的高危坑。本文给出加固版服务端校验:字段名白名单、邮箱手机号格式校验、蜂蜜罐反爬、同IP频率限制和reCAPTCHA v3集成。 - 关键词:织梦自定义表单,DedeCMS表单,plus/diy.php,HTML5表单,蜂蜜罐 > **TLDR**:摘要:DedeCMS自定义表单默认不校验必填,社区流传的修法依赖PHP可变变量,反而埋下能覆盖数据库密码的高危坑。本文先讲清这个隐患,给出加固版服务端校验的完整代码、HTML5的现代前端校验、服务端与前端协同设计,再讲与reCAPTCHA和蜂蜜罐的协同、提交成功后的跳转、跨版本兼容和表单提交日志的留存。 > 摘要:DedeCMS自定义表单默认不校验必填,社区流传的修法依赖PHP可变变量,反而埋下能覆盖数据库密码的高危坑。本文先讲清这个隐患,给出加固版服务端校验的完整代码、HTML5的现代前端校验、服务端与前端协同设计,再讲与reCAPTCHA和蜂蜜罐的协同、提交成功后的跳转、跨版本兼容和表单提交日志的留存。 DedeCMS 的"自定义表单 (https://zhangwenbao.com/dedecms-empties-the-custom-form-content.html)"功能让站长能在前台收集用户提交(咨询、报名、留言等),后台 → 核心 → 自定义表单管理可以可视化建表。但默认表单没有必填项校验——用户提交空白表单也能成功,结果后台收一堆空数据。社区流传的修补方法是改 /plus/diy.php 加服务端校验,或前端 jQuery 校验。两种方法 2026 年都有需要重新审视的地方:服务端的写法有 变量变量($$field)安全风险、前端的 jQuery 在现代浏览器已是冗余依赖。 这一篇把 DedeCMS 自定义表单必填项校验讲透:原社区代码的安全隐患、加固版的服务端校验、纯前端的现代浏览器原生校验(HTML5 required + JS)、二者协同设计、与 reCAPTCHA / 蜂蜜罐反爬虫的协同、提交后跳转优化、跨版本(V5.7 SP1/SP2 + DedeBIZ)兼容、迁移现代 CMS 的等价做法。 ## 社区代码的安全隐患:变量变量 $$field 原社区流传的服务端校验代码核心: $requireds = explode(',', $required); foreach($requireds as $field) { if($$field == '') { // ← 变量变量 showMsg('带*号的为必填内容', '-1'); exit(); } } $$field 是 PHP 的变量变量语法——如果 $field = "name",那么 $$field 等价于 $name。这个特性配合 DedeCMS 的全局变量注入机制(register_globals=on 或 DedeCMS 自家的全局注入),允许通过 $_POST['name'] 自动变成 $name。但这同时是个安全坑: - 开 register_globals 的 PHP 5.x 老站:攻击者可以伪造任意变量名,覆盖应用内部变量。但 PHP 5.4 起 register_globals 已被移除,新站点无此风险。 - DedeCMS 全局注入:DedeCMS 自带 foreach($_POST as $k => $v) ${$k} = $v; 这种危险代码(在 common.inc.php 里)。攻击者构造 POST: required=cfg_db_pwd&cfg_db_pwd=hacked 能覆盖数据库密码全局变量。 - 变量名校验缺失:原代码没验证 $field 是不是合法的字段名——攻击者可以传 required=xxx;DROP TABLE 这种值,foreach 进去虽不直接 SQL 注入,但破坏程序流。 正确的写法不要用变量变量,直接读 $_POST: if (!empty($required)) { $requireds = is_array($required) ? $required : explode(',', $required); foreach ($requireds as $field) { // 严格校验字段名(仅字母数字下划线) if (!preg_match('/^[a-zA-Z0-9_]+$/', $field)) continue; $val = isset($_POST[$field]) ? trim($_POST[$field]) : ''; if ($val === '') { showMsg('带*号的为必填内容,请正确填写:' . htmlspecialchars($field), '-1'); exit(); } } } 这个写法相比社区版本:① 不用变量变量,避免依赖 DedeCMS 的全局注入;② 字段名加白名单正则,防止注入;③ 错误提示告知具体哪个字段没填,对用户更友好。 ## 加固服务端校验:完整代码 放在 /plus/diy.php 找到 $dede_fields = empty($dede_fields) ? '' : trim($dede_fields); 行,下面加: // === 自定义表单必填项与基础校验 === // 1. 必填项检查 if (!empty($required)) { $requireds = is_array($required) ? $required : explode(',', $required); foreach ($requireds as $field) { if (!preg_match('/^[a-zA-Z0-9_]+$/', $field)) continue; $val = isset($_POST[$field]) ? trim($_POST[$field]) : ''; if ($val === '') { showMsg('带 * 号的为必填内容,请正确填写', '-1'); exit(); } } } // 2. 邮箱字段格式校验 if (isset($_POST['email']) && $_POST['email'] !== '') { if (!filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) { showMsg('邮箱格式不正确', '-1'); exit(); } } // 3. 手机号简单校验(中国大陆) if (isset($_POST['mobile']) && $_POST['mobile'] !== '') { if (!preg_match('/^1[3-9]\d{9}$/', $_POST['mobile'])) { showMsg('手机号格式不正确', '-1'); exit(); } } // 4. 蜂蜜罐反爬虫(hidden 字段,正常用户不填,爬虫填了就拦) if (isset($_POST['url']) && $_POST['url'] !== '') { // 静默拦截,不告诉爬虫具体原因 showMsg('提交失败', '/'); exit(); } // 5. 提交频率限制(同 IP 30 秒内只能提交 1 次) $cacheKey = 'diy_submit_' . md5($_SERVER['REMOTE_ADDR']); $cacheFile = DEDEDATA . '/cache/' . $cacheKey; if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) { showMsg('提交太频繁,请 30 秒后重试', '-1'); exit(); } file_put_contents($cacheFile, time()); 这套加固版做了 5 件事:① 必填校验;② 邮箱格式校验;③ 手机号校验;④ 蜂蜜罐反爬;⑤ 提交频率限制。比社区版本只做必填的安全防御提升一个量级。 ## 纯前端校验:现代浏览器 + HTML5 2026 年的浏览器已经原生支持表单校验,不需要 jQuery。直接用 HTML5 required 属性:

HTML5 表单校验的优势: - 无 JS 依赖——浏览器原生支持,不用引 jQuery; - 多语言友好——浏览器按用户语言显示错误提示(中文用户看到中文); - 可访问性好——屏幕阅读器能读 required 标签,无障碍 (https://zhangwenbao.com/website-accessibility-seo-optimization-guide.html)体验更佳; - 禁用 JS 也能提交——服务端兜底,防 JS 关闭场景。 ## 自定义错误提示 HTML5 默认错误提示是浏览器自带的("请填写此字段" 等),可以自定义: oninvalid 设错误文案,oninput 在用户重新输入时清空错误。这两个事件让自定义提示与原生校验机制结合。 ## 服务端 + 前端协同设计 正确的做法是两端都校验: 场景 | 前端 | 服务端 | 用户体验 | 实时反馈,不需要等服务器 | 提交后才知道错 | 禁用 JS / 直接 POST | 失效 | 仍能挡住 | 性能 | 减少无效请求到服务器 | 无差别 | 安全 | 不可信(用户能改 DOM 绕过) | 可信,最后防线 | 结论:前端用 HTML5 增强用户体验,服务端校验是不能省的最终防线。任何只做前端不做服务端校验的表单都不安全。 ## 与 reCAPTCHA / 蜂蜜罐的协同 ## 蜂蜜罐(Honeypot) 原理:在表单里加一个对正常用户隐藏的字段(CSS position: absolute; left: -9999px),机器人爬虫填表时会无脑填所有字段,正常用户因为看不到所以不填。服务端检查这个字段: - 空 = 正常用户,放行; - 非空 = 爬虫,静默拦截(不告诉它具体哪个字段有问题,让它继续浪费配额)。 蜂蜜罐对 80%+ 的低端爬虫有效,且对正常用户零打扰——不需要点验证码。 ## Google reCAPTCHA v3 对剩余的高端爬虫(能识别蜂蜜罐的),加 reCAPTCHA v3。它在背后给每个用户打分(0-1,1 是真人,0 是爬虫),不需要用户点击: // 服务端校验 $token = $_POST['recaptcha_token'] ?? ''; $secret = 'YOUR_SECRET_KEY'; $resp = file_get_contents("https://www.google.com/recaptcha/api/siteverify?secret={$secret}&response={$token}"); $data = json_decode($resp, true); if (!$data['success'] || $data['score'] < 0.5) { showMsg('请稍后重试', '-1'); exit(); } 注意:reCAPTCHA 需要 Google 服务可访问——国内用户可能因网络问题加载失败,建议用国产替代(极验、网易易盾、阿里云人机验证)。 ## 提交成功后的跳转优化 原代码用 showMsg() 显示提示后跳转,体验不佳。优化: // 提交成功后跳转到独立感谢页(更专业) header('Location: /thank-you.html?from=' . urlencode($_POST['name'] ?? '')); exit(); 感谢页可以放:① 提交成功提示;② 客户经理联系方式;③ 引导用户去看其他相关产品/文章;④ 触发 Google Analytics 转化事件(电商漏斗追踪)。 ## GA / 百度统计的转化追踪 在感谢页 thank-you.html 里加: 这样 Google Analytics 能跟踪每次表单提交的转化漏斗,结合广告投放的 CPA / ROAS (https://zhangwenbao.com/roas-roi-advertising-guide.html) 计算。 ## 跨版本兼容(V5.7 / SP1 / SP2 / DedeBIZ) /plus/diy.php 在 DedeCMS 各版本里逻辑大致一致,但行号略有差异: 版本 | $dede_fields trim 行 | 建议插入位置 | V5.7 | ~ 第 38 行 | 第 39 行后 | V5.7 SP1 | ~ 第 40 行 | 第 41 行后 | V5.7 SP2 | ~ 第 42 行 | 第 43 行后 | DedeBIZ V6 | 已合并必填校验 | 无需手改 | 升级 DedeCMS 时这个改动会被覆盖——记得升级前备份 /plus/diy.php。 ## 与防垃圾留言的纵深防御 必填校验只挡空表单,挡不了"机器人填假数据"。完整的防垃圾留言要做: - 必填项 + 格式校验(本文) - 蜂蜜罐(隐藏字段) - 提交频率限制(同 IP 30 秒) - reCAPTCHA / 极验(人机判断) - 关键词黑名单(拦"代开发票"、"刷流量"等垃圾文本) - fail2ban(动态拉黑高频提交 IP) - WAF(Cloudflare / 阿里云) 每多一层就少一个数量级的垃圾留言。中小站点至少做前 4 层。 ## 迁移到现代 CMS 的等价做法 DedeCMS 自定义表单 | WordPress | Hexo / 静态站 | plus/diy.php | Contact Form 7 / Gravity Forms / WPForms | Formspree / Netlify Forms / Vercel Edge | 必填校验 | 插件原生支持 | HTML5 + 服务端 webhook | 反垃圾 | Akismet | Formspree honeypot 内置 | 邮件通知 | 插件原生 | Formspree 邮件转发 | WordPress 上这些功能都是装个插件即可,不用改源码。Hexo 等静态站点用第三方 SaaS 服务(Formspree 免费 50 次/月)。 ## 表单提交日志的留存 除了写入 dede_diyforms 数据库表,建议同时记录到日志: // 记录提交日志(追加到 logs/form_submit.log) $logEntry = sprintf( "[%s] [%s] %s\n", date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'], json_encode([ 'name' => $_POST['name'] ?? '', 'email' => $_POST['email'] ?? '', 'mobile' => $_POST['mobile'] ?? '', 'message' => mb_substr($_POST['message'] ?? '', 0, 200, 'UTF-8'), 'ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 100), ], JSON_UNESCAPED_UNICODE) ); file_put_contents(DEDEDATA . '/logs/form_submit.log', $logEntry, FILE_APPEND | LOCK_EX); 日志的好处:① 数据库被攻击时还有副本;② 配 fail2ban 直接读日志识别垃圾 IP;③ 后期数据分析(哪些时段提交多、什么 UA 提交多)。 ## 常见问题解答 ## 改完 plus/diy.php 没生效? ① 确认文件保存了;② DedeCMS 后台 → 系统 → 系统设置 → 更新缓存(虽然 plus 不走模板缓存,但保险起见全清);③ 浏览器开 F12 看提交时是否真请求到 plus/diy.php(路径对不对);④ 看 PHP error_log 有没有报错。 ## required 隐藏字段的字段名要按什么命名? 按你后台自定义表单建立时的"数据字段名"。比如建表单时"姓名"对应数据字段名 name,"邮箱"对应 email,那 required 就写 name,email。字段名严格区分大小写。 ## 表单提交后页面跳转到 plus/diy.php 但页面空白? 多半是 PHP 报错且 display_errors=Off。先打开 display_errors 看报错——大概率是某个 require 路径错或 include 失败。生产环境调试完务必关回 display_errors,避免暴露路径信息给攻击者。 ## 能不能给不同表单设不同的必填项规则? 能。在 /plus/diy.php 里读 $_POST['diyid'](自定义表单 ID),按 ID 走不同规则:if ($diyid == 1) { ... } elseif ($diyid == 2) { ... }。或者把规则配置写到数据库的扩展字段,让管理员后台编辑。 ## 用 reCAPTCHA 后 SEO 受影响吗? 不直接影响。Google 不会因为页面用了自家 reCAPTCHA 给 SEO 加分(也不扣分)。但 reCAPTCHA 加载约 200KB JS,对 LCP 有约 50-100ms 影响。生产建议仅在表单页加 reCAPTCHA,不要全站加。 ## 蜂蜜罐字段叫什么名字最有效? 选爬虫"看到一定会填"的字段名:url、website、company、fax 等。爬虫的填表逻辑是按字段名匹配自带字典,常见字段名几乎必填。但要避免和真实表单里的同名字段冲突。 ## 提交频率限制用 IP 还是 cookie? 都用更稳。IP 限制对 NAT 后的多用户共享 IP 不友好(误伤);cookie 限制可被清除绕过。生产建议组合:① IP 限频较宽(30 秒 1 次);② cookie 配合标识"已提交过"(24 小时内不能重复);③ 用 Redis 存计数器,性能更好。 ## 能拦截使用 cURL 等工具直接提交吗? 用 cURL 等工具能绕过前端校验直接 POST。要拦:① 服务端必填校验(绕不过);② 蜂蜜罐 + reCAPTCHA + IP 限频组合;③ 检查 User-Agent 是否包含 cURL/Wget/Python 等爬虫特征字符串。但攻击者改 UA 容易,最终防御还是看服务端的逻辑校验。 ## 表单提交日志要不要清理? 要。logs/form_submit.log 长期不清会越来越大,建议 logrotate:每天滚动 + 保留 30 天 + 超期归档到云存储(OSS / S3)。日志体量大时(每天几万次提交)走结构化日志(JSON Lines)+ 上 ELK 或 Loki,比 grep 文本快得多。 ## 能在表单页加防止 F12 调试的代码吗? 不建议。这种"反调试"对正常开发者是骚扰,对攻击者完全无效(任何反调试都能在浏览器层面绕过)。要保护表单不被滥用,靠服务端校验 + 速率限制 + WAF + Honeypot 组合,不要靠"防 F12"这种心理防御。 ## 权威参考资料 ## DedeCMS自定义表单一键清空与ID重置实战 - URL:https://zhangwenbao.com/dedecms-empties-form-data.html - 分类:织梦CMS教程 - 发布:2019-09-29 | 更新:2026-06-02 - 摘要:DedeCMS自定义表单测试数据要清空、ID跳号要重置怎么办?本文区分TRUNCATE与DELETE在DDL/DML层面的本质差别,详解InnoDB自增策略在5.7/8.0的差异,并给出多表批量清空存储过程、附件文件清理、binlog恢复等实战要点。 - 关键词:织梦自定义表单,DedeCMS数据维护,MySQL优化 > **TLDR**:摘要:DedeCMS自定义表单的测试数据要清空、ID跳号要重置,得先分清TRUNCATE与DELETE在DDL和DML层面的本质差别。本文讲清InnoDB自增策略在5.7与8.0的内部行为、操作前必做的三件事、正式执行的完整SQL流程,再给DELETE后单独重置自增ID、多表批量清空脚本、附件文件清理、未读消息与统计计数的善后,附binlog恢复。 > 摘要:DedeCMS自定义表单 (https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html)的测试数据要清空、ID跳号要重置,得先分清TRUNCATE与DELETE在DDL和DML层面的本质差别。本文讲清InnoDB自增策略在5.7与8.0的内部行为、操作前必做的三件事、正式执行的完整SQL流程,再给DELETE后单独重置自增ID、多表批量清空脚本、附件文件清理、未读消息与统计计数的善后,附binlog恢复。 我做织梦站点的运维已经有些年头了,期间帮客户清理过的自定义表单 (https://zhangwenbao.com/dedecms-custom-form-verification-mobile.html)不下几十次。每次遇到测试数据混在生产数据里、或是表单ID跳号严重不好看的情况,客户总希望能把数据一键清空,并且让新提交的记录从ID=1重新开始。这篇文章就把我多年攒下来的实战经验整理出来,从原理到操作再到风险规避,把织梦DEDECMS自定义表单数据清空这件事讲透。 ## 为什么需要清空DEDECMS自定义表单数据 我接手的不少织梦项目,都会遇到一个尴尬场景:开发阶段为了测试表单提交流程,往往会反复提交几十上百条样例数据。等到正式上线那一天,后台的dede_diyform1表里塞满了“测试1”“test”“张三 13800138000”这类垃圾数据。如果不清掉,新数据混在里面,统计、导出、推送都会受影响。 更让人头疼的是ID跳号问题。织梦默认的自定义表单使用了InnoDB的自增主键,一旦你普通DELETE删除测试数据,下一条新提交的记录ID不会从1开始,而是接着之前的最大值往上加。客户拿到Excel一看,第一条数据ID是137,怎么解释都解释不通。这就是我为什么坚持用TRUNCATE TABLE而不是DELETE FROM的核心原因。 还有一种情况比较隐蔽:站点被采集脚本盯上后,攻击者会通过表单批量灌水,几个小时就能塞进几万条无效记录。这种时候清空表单不仅是清洁工作,更是应急止损。我去年给一家做装修资讯的客户处理过类似事故,TRUNCATE一执行,5万多条灌水数据0.3秒就清干净了。 ## TRUNCATE与DELETE的本质差别 很多新手会问我:“保哥,DELETE FROM不也能删完所有数据吗?”从结果上看好像差不多,但底层机制差得太远了。 -- 方式一:DELETE 逐行删除,写入 binlog,自增不重置 DELETE FROM `dede_diyform1`; -- 方式二:TRUNCATE 直接重建表结构,自增归零,速度极快 TRUNCATE TABLE `dede_diyform1`; DELETE是DML语句,会逐行扫描并写入事务日志,删除几万条数据可能要跑几十秒甚至更久,而且每一行都会触发可能存在的触发器。TRUNCATE是DDL语句,MySQL会直接drop掉原表然后按照原始结构重建一张空表,所以速度跟表里有0行还是5万行几乎没关系,都在毫秒级。 更关键的是自增ID。TRUNCATE会把自增计数器重置为1,这恰好是我们想要的效果。如果你只是想保留表结构、清掉数据并让ID从1重新开始,TRUNCATE是唯一干净利落的方案。 不过TRUNCATE也有一些限制要注意:它无法回滚(在MySQL默认配置下不能用事务包裹后再撤销),也不能带WHERE条件。所以执行前一定要再三确认表名、确认是不是真的要清空全部数据。 ## InnoDB自增策略的内部行为 很多人对DELETE之后自增不重置的现象感到困惑。这背后是InnoDB存储引擎的设计哲学:AUTO_INCREMENT是一个独立的计数器,不和具体行绑定。每次INSERT时计数器自增然后给新行赋值,DELETE只是删除行,计数器不动。 具体到MySQL的内部实现,InnoDB的AUTO_INCREMENT在5.7和8.0版本上行为不一样: - MySQL 5.7:自增计数器存在内存里,MySQL重启会回到“当前表里最大ID + 1”的位置。这意味着如果你DELETE FROM diyform1清空数据后SHOW CREATE TABLE显示AUTO_INCREMENT=137,重启MySQL后再看会变成AUTO_INCREMENT=1——但这是不可控的,绝对不能依赖。 - MySQL 8.0:自增计数器持久化到redo log,重启不会重置。这意味着DELETE之后即使重启MySQL,计数器仍然停在原来的位置。 所以无论哪个版本,要确定性地把自增重置为1,都必须用TRUNCATE或者显式执行ALTER TABLE diyform1 AUTO_INCREMENT=1。我自己更推荐TRUNCATE,因为它一次完成“清数据 + 重置自增”两件事,没有竞态窗口。 ## 操作前必做的三件事 我在执行任何TRUNCATE之前,习惯性会做三件事,这些年从来没出过事故,全靠这套流程顶着。 ## 第一件事:定位diyid 织梦的自定义表单不是只有一张表,而是按你创建表单的顺序生成dede_diyform1、dede_diyform2、dede_diyform3这样的多张表。每张表对应一个diyid。如果你直接照着别人教程里的dede_diyform1操作,结果可能把别人的留言表清空了。正确做法是: SELECT diyid, name, edithtml FROM dede_diyforms; 这条SQL会列出所有表单和对应的diyid。比如你看到“在线咨询”对应的diyid是2,那要清的就是dede_diyform2,不是1。这条命令花5秒,能避免一辈子的事故。 ## 第二件事:备份目标表 哪怕你确信里面全是垃圾数据,也要养成备份的习惯。命令很简单: mysqldump -u 用户名 -p 数据库名 dede_diyform2 > diyform2_backup_$(date +%Y%m%d_%H%M).sql 备份文件保留在服务器上至少30天。我经历过一次客户后悔——他在执行TRUNCATE 24小时后突然想起里面有一条三个月前的真实询盘记录被一起清掉了,幸亏当时备份还在,从SQL里捞出来恢复成功。如果当时没备份,那条业务损失就找不回来了。 ## 第三件事:确认是否有外键依赖 织梦默认设计的dede_diyform*表是孤立的,没有外键约束。但有些站长会自己加,比如把表单数据和会员表关联,建立userid字段的外键。这种情况下直接TRUNCATE会报错:“Cannot truncate a table referenced in a foreign key constraint”。 排查方法: SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME = 'dede_diyform2'; 如果返回非空,说明有其他表引用了你要清的表,要先临时禁用外键检查再操作: SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE dede_diyform2; SET FOREIGN_KEY_CHECKS = 1; ## 正式执行:完整SQL流程 三件事都做完之后,开始正式执行。我用一个完整的SQL块包起来: -- 第一步:再次确认要清的表 SELECT COUNT(*) AS rows_before FROM dede_diyform2; -- 第二步:禁用外键检查(如果有外键依赖的话) SET FOREIGN_KEY_CHECKS = 0; -- 第三步:执行 TRUNCATE TRUNCATE TABLE dede_diyform2; -- 第四步:恢复外键检查 SET FOREIGN_KEY_CHECKS = 1; -- 第五步:验证清空成功 SELECT COUNT(*) AS rows_after FROM dede_diyform2; SHOW CREATE TABLE dede_diyform2 \G 第五步的SHOW CREATE TABLE是最关键的验证——返回结果里应该看到AUTO_INCREMENT=1。如果不是1,说明TRUNCATE没成功(可能是表上有未释放的锁),需要排查原因。 执行完之后立刻在前台提交一条测试表单,看新记录的ID是不是1。是1就说明清空和重置都成功了。 ## 除了TRUNCATE还有哪些场景需要DELETE 有些场景TRUNCATE不能用,必须用DELETE。归纳几种典型的: 场景一:只清掉某段时间的数据,保留其他。比如想清掉2023年之前的旧记录,保留2024年起的新数据,这种带条件的删除TRUNCATE做不到,只能用DELETE加WHERE: DELETE FROM dede_diyform2 WHERE addtime < UNIX_TIMESTAMP('2024-01-01'); 注意织梦默认的时间字段是Unix时间戳(INT类型),所以条件要用UNIX_TIMESTAMP()包一下。 场景二:清掉特定IP的灌水数据。有时候攻击者用同一个IP灌水,可以精准清理: DELETE FROM dede_diyform2 WHERE ip = '1.2.3.4'; 场景三:清掉空内容或符合某种pattern的垃圾。比如所有name字段为空或长度小于2的记录: DELETE FROM dede_diyform2 WHERE name IS NULL OR LENGTH(name) < 2; 这些DELETE场景下,自增ID不会重置,但通常这也不是问题——因为是部分删除而不是清空,留下的数据还在用原来的ID编号。 ## DELETE之后单独重置自增ID 如果你已经用DELETE清空了表,事后又想把自增重置为1,可以单独执行: ALTER TABLE dede_diyform2 AUTO_INCREMENT = 1; 这条命令在表为空时直接生效;如果表不为空,MySQL会忽略小于当前最大ID的值——也就是说,如果表里还有ID=137的记录,ALTER TABLE ... AUTO_INCREMENT=1会被静默忽略,下一条新记录的ID还是138。 所以这条命令的正确使用顺序必须是:先DELETE FROM dede_diyform2;再ALTER TABLE dede_diyform2 AUTO_INCREMENT=1;,反过来不行。 ## 批量清空多张表的脚本 如果一个站点有10个表单(diyform1到diyform10)都要清空,一条一条手敲容易漏。我用一段动态SQL批量处理: DELIMITER // CREATE PROCEDURE clear_all_diyforms() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE tname VARCHAR(64); DECLARE cur CURSOR FOR SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'dede_diyform%'; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; SET FOREIGN_KEY_CHECKS = 0; OPEN cur; read_loop: LOOP FETCH cur INTO tname; IF done THEN LEAVE read_loop; END IF; SET @sql = CONCAT('TRUNCATE TABLE `', tname, '`'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END LOOP; CLOSE cur; SET FOREIGN_KEY_CHECKS = 1; END // DELIMITER ; CALL clear_all_diyforms(); DROP PROCEDURE clear_all_diyforms; 这段存储过程会自动找到当前数据库里所有以dede_diyform开头的表并TRUNCATE。执行完毕后用DROP PROCEDURE清理掉,保持数据库干净。 第一次跑这个脚本前,把所有dede_diyform*表都用mysqldump备份下来。这个脚本是“核武器级”的清理,做错了恢复成本很高。 ## 常被忽略的“未读消息”与统计计数 清空表单数据时容易忽略一件事:DedeCMS的后台首页和"消息中心"模块会显示“未读自定义表单提交”的数量徽标,这个数字不是从dede_diyform*表实时算出来的,而是缓存在dede_member_msg或dede_sysconfig表里。TRUNCATE了表单本身但没清这些计数缓存,后台徽标会一直显示“您有N条未读”,点进去发现是空列表。 解决方法是同步清掉相关计数: -- 清表单提交相关的消息记录 DELETE FROM dede_member_msg WHERE msgtype = 'diyform'; -- 重置 sysconfig 里相关计数 UPDATE dede_sysconfig SET value = 0 WHERE varname LIKE '%diyform%count%'; -- 清掉后台缓存 DELETE FROM dede_admin_message WHERE message LIKE '%表单%'; 这几条语句在不同DedeCMS版本上字段名可能略有差异,执行前先DESC dede_member_msg;看看实际字段。 ## 导出表单数据后再清空的标准流程 有些表单数据虽然过时但有商业价值,比如三年前的客户咨询信息,业务方希望先导出存档再清空。我推荐的标准流程: 第一步:用mysqldump导出为可读SQL备份。命令前面已经讲过,注意加--complete-insert --skip-extended-insert参数让导出文件每行一条INSERT,便于人工查看和部分恢复。 第二步:用SELECT INTO OUTFILE导出CSV供业务方查看。 SELECT * FROM dede_diyform2 INTO OUTFILE '/tmp/diyform2_archive.csv' FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\n'; 这条命令需要MySQL的FILE权限,secure_file_priv变量必须配置成允许写入的目录。导出的CSV用Excel打开做去重、过滤、分类,完成业务交接后再回去执行TRUNCATE。 第三步:保留两份独立备份至少90天。一份放在本地服务器,一份压缩后传到OSS或对象存储做异地备份。我的客户里有过一次教训:本地服务器硬盘故障导致备份SQL丢失,而清空操作已经做了三天了——幸好我额外传了一份到OSS,否则那批价值数十万的销售线索就找不回来了。 ## 清空之后的善后工作 TRUNCATE只清了表,但织梦的一些缓存可能还指向旧数据。要做几件事保证后台显示和前端表现都正确。 第一件:清掉DedeCMS的内置缓存。登录后台 → 系统 → 数据库管理 → 优化数据库表,或者直接删data/cache/目录下的所有缓存文件。 第二件:刷新前台静态页。如果你的站点开启了静态生成,表单清空后相关的统计数字(如"已收到XX条留言")需要重新生成静态页才能更新。 第三件:通知第三方推送服务。如果你接了消息推送(短信、邮件、钉钉、企微)触发表单提交通知,验证清空后的新提交是否能正确触发推送。 这三件事做完,整个清空流程才算完整收尾。 ## 常见问题解答 ## TRUNCATE之后能用binlog恢复数据吗? 能,但前提是MySQL开启了binlog并且binlog里包含了TRUNCATE之前的INSERT记录。具体步骤:先mysqlbinlog --start-datetime='清空前时间' --stop-datetime='清空时间' /var/log/mysql/mysql-bin.000001 > recovery.sql导出原始SQL,然后从recovery.sql里筛出和目标表相关的INSERT语句,重新执行就能恢复。但这种恢复只针对insert操作,update/delete历史不一定能完整还原。 ## 表里有上百万条数据TRUNCATE会不会很慢? 不会。TRUNCATE是DDL操作,不论表里有多少行,执行时间都在毫秒级——它本质上是drop表再重建一张同结构的空表,跟原表数据量无关。我自己测过的最大表是2300万行,TRUNCATE耗时不到1秒。但要注意:TRUNCATE会持有元数据锁,期间所有访问该表的查询会阻塞,所以高并发场景下要选择业务低峰期执行。 ## 清空diyform之后,附件文件还在/uploads目录里怎么办? 需要单独清理。织梦的自定义表单如果有上传字段,文件会被保存到/uploads/diyform/目录下,TRUNCATE只清掉数据库记录,物理文件还在。建议在TRUNCATE之前先SELECT出所有附件路径备份,然后再用rm命令清掉对应文件。或者更简单粗暴:直接rm -rf /uploads/diyform/*清掉整个目录(前提是确认目录里全是要清的内容)。 ## 多张表单中的一张被错误TRUNCATE了怎么紧急恢复? 三个步骤:第一,立刻停止所有可能往这张表写入的服务(关掉前台表单接收页面),避免新数据覆盖恢复路径;第二,从最新的mysqldump备份恢复,命令是mysql -u root -p 数据库名 < diyform2_backup_xxx.sql;第三,如果备份没有最新数据但开了binlog,把备份时间到TRUNCATE时间之间的binlog重放回去:mysqlbinlog --start-datetime='备份时间' --stop-datetime='TRUNCATE时间' /var/log/mysql/mysql-bin.* | mysql -u root -p 数据库名。 ## 清空表单之后能不能从前台模板里彻底隐藏旧的统计数字? 可以。织梦模板里如果用了{dede:sql}标签查询表单总数,TRUNCATE之后该标签会自动返回0或空。但如果模板里写死了一个数字(比如"我们已收到3万条留言"),那是硬编码,得手动改模板文件。检查方法:在主题目录搜grep -r "diyform" templets/找出所有引用。 ## TRUNCATE需要什么权限?普通用户能执行吗? 需要DROP权限。MySQL默认的application user一般只授予了SELECT/INSERT/UPDATE/DELETE,没有DROP权限——这种情况下执行TRUNCATE会报“Access denied”。要么用root账户登录,要么临时给application user授予DROP权限:GRANT DROP ON 数据库.* TO 'user'@'host';,操作完再REVOKE回收。 ## TRUNCATE之后alter自增能不能从一个非1的值开始? 能。ALTER TABLE dede_diyform2 AUTO_INCREMENT=10000会让下一条新记录从ID 10000开始。这个用法在“业务上希望ID看起来比较大”(比如订单号、客户编号)的场景比较有用。但要注意起始值不能小于当前表里实际的最大ID(如果表非空),否则会被MySQL静默忽略。 ## 权威参考资料 ## 织梦DedeCMS表单防垃圾提交:手机号、IP校验加双层防重复 - URL:https://zhangwenbao.com/dedecms-custom-form-verification-mobile.html - 分类:织梦CMS教程 - 发布:2019-09-29 | 更新:2026-06-01 - 摘要:DedeCMS自定义表单要防垃圾提交,得把验证做全。本文覆盖preg_match手机号正则的锚点陷阱、GetIP结合CF头的可信IP方案、HTTP_REFERER兼容兜底、Cookie加数据库双层防重复、SQL注入与XSS防御、蜜罐与Turnstile反垃圾配方,并对比Discuz、WordPress同类机制。 - 关键词:织梦自定义表单,DedeCMS安全,表单验证,防垃圾 > **TLDR**:摘要:DedeCMS自定义表单要防垃圾提交,得把验证做全,验证顺序也很关键。本文先讲四个核心字段的设计,给出手机号正则校验、自动注入IP与time与domain、Cookie弱校验加后端数据库强校验的双层防重复,再补SQL注入与XSS防御、CAPTCHA与蜜罐反垃圾的进阶手段,附真实站点的反垃圾数据和与其他CMS的对比。 > 摘要:DedeCMS自定义表单要防垃圾提交,得把验证做全,验证顺序也很关键。本文先讲四个核心字段的设计,给出手机号正则校验、自动注入IP与time与domain、Cookie弱校验加后端数据库强校验的双层防重复,再补SQL注入与XSS防御、CAPTCHA与蜜罐反垃圾的进阶手段,附真实站点的反垃圾数据和与其他CMS的对比。 织梦DedeCMS的自定义表单 (https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html)功能基础够用但功能孤零零——你建了个"售前咨询"表单,访客把名字和电话填了提交了,你拿到一条记录就只有名字和电话。但生产环境里你需要更多:手机号必须是合法的,得知道访客IP方便后续黑名单,得记录提交时间和来源URL(哪个落地页转化的),还得防止同一个人疯狂刷10次浪费销售时间。这篇文章给一套完整可落地的解决方案,覆盖手机号正则校验、IP抓取、时间记录、来源URL、Cookie防重复提交,并给出我在3个客户站上踩过的坑和加强版本。 ## 表单字段设计:4个核心字段缺一不可 第一步是在DedeCMS后台"核心 → 自定义表单"里添加4个字段: 字段名 | 类型 | 用途 | 是否前台填写 | telephone | varchar(20) | 访客手机号,校验格式 | 是 | ip | varchar(50) | 记录访客IP(v4或v6) | 否,后端自动写 | domain | varchar(255) | 来源URL,定位落地页 | 否,后端自动写 | time | varchar(20) | 提交时间(人类可读格式) | 否,后端自动写 | 注意ip字段宽度建议给50字符。IPv4最长15字符,但越来越多家庭宽带分配IPv6(最长39字符),加上未来可能补充ASN信息或地理位置,给宽点不费空间。time字段我推荐用varchar而不是datetime——便于后期统一格式化展示,且不被MySQL时区设置影响。 ## 核心思路:验证顺序为什么至关重要 新手最常见的错误是把Cookie防重复检查放在手机号校验之前。后果是访客随手输错手机号点提交,浏览器Cookie被设置上"已提交"标记,他纠错重新输入正确号码再提交时被拒绝——只能让访客手动清浏览器Cookie,体验糟糕,转化率直接腰斩。 正确的执行顺序: - 先校验手机号格式合法性(合法才有必要继续)。 - 校验非空字段、必填项。 - 注入字段值(IP、time、domain)。 - 查询数据库是否已存在同手机号或同IP的提交(强校验)。 - 检查Cookie防重复标记(弱校验,作为前置拦截层)。 - 写入数据库。 - 设置Cookie标记成功提交。 这个顺序保证了"只有合法且未重复的提交才会被Cookie标记",避免了用户因为输错而被永久拦截。 ## 手机号正则校验 找到DedeCMS自定义表单提交处理文件plus/diy.php,在表单数据接收后、写库前加入: // 验证手机号 if ( ! preg_match( '/^1[3-9]\d{9}$/', $telephone ) ) { echo ""; exit(); } 这条正则^1[3-9]\d{9}$是中国大陆11位手机号的标准模式:以1开头,第2位是3到9,后面9位是任意数字。注意几个细节: - 第2位用[3-9]不要用[3456789]。两者等价但前者更简洁,可读性高。早期教程里写[3456789]是因为有人怕区间表示法兼容性问题,2026年早不存在。 - 不要漏掉锚点^和$。否则输入"abc13912345678def"也能通过校验,因为里面包含合法手机号子串。我亲自检查过3个DedeCMS站发现都有这个漏洞,导致垃圾数据混入。 - 港澳台、海外号码需要单独处理。如果你的业务有外贸需求,正则要扩展到/^(?:\+?86)?1[3-9]\d{9}$|^(?:\+?852|853|886)\d{8,10}$|^\+\d{6,15}$/这种形式。 - 提示语本身用英文双引号外、中文单引号内,避免引号嵌套报错。 建议同时在前端用JavaScript做一次同样的正则校验,让用户在输入时就得到反馈,不必等到提交后才知道格式错。前后端双重校验是表单设计的基本规范——前端体验、后端兜底。 ## 自动注入IP、time、domain 在diy.php处理字段循环里加入: // 自动写入访客IP if ( $fieldinfo[0] == 'ip' ) { ${$fieldinfo[0]} = GetIP(); } // 自动写入提交时间(年月日时分秒) if ( $fieldinfo[0] == 'time' ) { ${$fieldinfo[0]} = date( 'Y/m/d H:i:s' ); } // 自动写入来源URL if ( $fieldinfo[0] == 'domain' ) { ${$fieldinfo[0]} = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : ''; } ## GetIP()函数的内部细节 DedeCMS自带的GetIP()函数(在include/common.func.php)默认逻辑会按下面顺序取IP: - HTTP_X_FORWARDED_FOR(CDN或代理转发链最左端) - HTTP_CLIENT_IP(更老的代理头) - REMOTE_ADDR(直连IP) 这个顺序在没有CDN的环境下没问题,但如果你站点跑在Cloudflare、阿里云CDN、宝塔反代后,需要小心:X-Forwarded-For头是HTTP头,可以被客户端伪造。攻击者随便发一个X-Forwarded-For: 8.8.8.8就能让你的GetIP()记录假IP。如果你只是用IP记录访客来源不做严格安全决策,影响不大;如果用IP做黑名单或限频,必须在Web服务器层做校验:只信任来自CDN白名单IP段的XFF头,其他直接忽略走REMOTE_ADDR。Nginx里通过set_real_ip_from+real_ip_header配置即可。 对于Cloudflare用户,更准确的做法是直接读$_SERVER['HTTP_CF_CONNECTING_IP']——这是Cloudflare独有的非公开header,无法被伪造。改造后的GetIPSafe函数: function GetIPSafe() { if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) { return $_SERVER['HTTP_CF_CONNECTING_IP']; } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } ## 时间格式选择的考量 date('Y/m/d H:i:s')输出"2026/05/10 19:34:21"。我建议改成更标准的'Y-m-d H:i:s'(用横杠分隔),后期导出Excel更容易被识别为日期类型。如果你做跨时区业务,记录UTC时间避免时区混乱:gmdate('Y-m-d H:i:s')。同时建议补一列timestamp(int)方便后端排序与计算时间差。 ## HTTP_REFERER的兼容性陷阱 HTTP_REFERER头不是必填的,浏览器在以下情况下不会发送: - 从HTTPS页面跳转到HTTP页面(隐私保护)。 - 用户开了浏览器隐私模式或装了广告/隐私拦截插件。 - HTML里设置了。 - 访客直接在地址栏输入URL或从书签打开。 所以domain字段经常会出现空值,写库前要做isset()判空,否则会触发PHP Notice。如果你需要更可靠的来源追踪,建议在前端用JavaScript往hidden字段写document.referrer,再加个URL参数?utm_source=xxx做兜底,比单靠HTTP_REFERER稳。 ## Cookie防重复提交(弱校验层) 原版代码: // 检测游客是否已提交过表单 if ( isset( $_COOKIE['VOTE_MEMBER_IP'] ) ) { if ( $_COOKIE['VOTE_MEMBER_IP'] == $_SERVER['REMOTE_ADDR'] ) { echo ""; exit(); } else { setcookie( 'VOTE_MEMBER_IP', $_SERVER['REMOTE_ADDR'], time() * $row['spec'] * 3600, '/' ); } } else { setcookie( 'VOTE_MEMBER_IP', $_SERVER['REMOTE_ADDR'], time() * $row['spec'] * 3600, '/' ); } 这段代码有几个隐患需要修正: 问题1:过期时间表达式严重错误。time() * $row['spec'] * 3600会把当前时间戳(约17亿)乘以一个数,远超int范围导致Cookie时间溢出。正确写法是time() + $row['spec'] * 3600(加号不是乘号)——当前时间往后推$spec小时。 问题2:Cookie作为唯一拦截层不够用。客户端可以删除Cookie或换浏览器绕过。Cookie应该作为第一层"软拦截"提升体验(让正常用户感知到"已提交过"),核心防重复必须靠后端数据库查询。 问题3:Cookie名易被识别。VOTE_MEMBER_IP这种通用名容易被反爬虫脚本识别和清除,建议改成项目相关的随机字符串如biaoge_form_token_a8f3。 修正后的代码: $cookie_key = 'biaoge_form_token_a8f3'; $cookie_ttl = ( isset( $row['spec'] ) ? (int) $row['spec'] : 24 ) * 3600; if ( isset( $_COOKIE[ $cookie_key ] ) && $_COOKIE[ $cookie_key ] == md5( GetIPSafe() . $telephone ) ) { echo ""; exit(); } setcookie( $cookie_key, md5( GetIPSafe() . $telephone ), time() + $cookie_ttl, '/', '', isset( $_SERVER['HTTPS'] ), true ); 改进点:用md5(IP+手机号)作为Cookie值,不暴露原始IP;setcookie加上HttpOnly(最后一个true)和Secure标志,防XSS窃取;统一用GetIPSafe替代原来的REMOTE_ADDR。 ## 后端数据库强校验(必须有的兜底层) Cookie防重复对清Cookie或换浏览器的"硬刷"无效,所以必须在写库前查一次数据库: $dsql = $GLOBALS['dsql']; $telephone_safe = addslashes( $telephone ); $ip_safe = addslashes( GetIPSafe() ); $check_sql = "SELECT id FROM #@__diyform WHERE telephone='{$telephone_safe}' AND time > '" . date( 'Y/m/d H:i:s', strtotime( '-24 hours' ) ) . "' LIMIT 1"; $row_dup = $dsql->GetOne( $check_sql ); if ( ! empty( $row_dup ) ) { echo ""; exit(); } 这段查询过去24小时内同手机号的提交记录。能阻止:换浏览器、清Cookie、用代理换IP的反复提交。但还有2点要补: - 给telephone字段加索引:ALTER TABLE dede_diyform ADD INDEX idx_telephone (telephone);,否则记录变多后查询会慢。 - 对IP做并发限频:单IP在5分钟内提交超过3次直接拉黑,应对脚本攻击。可以用SELECT COUNT(*) ... WHERE ip='..' AND time > ...的形式实现。 ## SQL注入与XSS防御 DedeCMS 5.7默认开启了FilterAll对GET/POST全局过滤,但自定义表单的处理逻辑老旧,建议显式做防御: - 所有写入数据库的字段都过addslashes()或$dsql->EscapeString(),避免SQL注入 (https://zhangwenbao.com/dedecms-message-board-edit-inc-php-injection-vulnerability-repair-method.html)。永远不要直接拼接用户输入到SQL。 - 所有展示到前端的字段用htmlspecialchars($value, ENT_QUOTES, 'UTF-8')转义,防XSS。后台审核员看到的留言列表如果没转义,访客提交一段   ## DedeCMS自定义表单防重复提交:5种实战方案 - URL:https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html - 分类:织梦CMS教程 - 发布:2018-11-23 | 更新:2026-05-16 - 摘要:DedeCMS表单怎么防重复提交?本文给出基于session绑定IP的核心代码、30分钟冷却窗口实现、PHP 5.6到7以上的token生成兼容方案、Nginx与Cloudflare反代的真实IP透传配置、织梦升级覆盖的auto_prepend_file兜底,以及集群部署的Redis session切换。 - 关键词:织梦自定义表单,网站安全,DedeCMS,织梦,PHP开发 > **TLDR**:摘要:DedeCMS默认表单有安全空白,本文给五种防重复提交方案叠加用。从session绑IP的基础拦截,到30分钟时间窗冷却、CSRF token防脚本伪造、前端按钮置灰防双击、thank-you页禁缓存,再讲反向代理与CDN场景下的真实IP处理、auto_prepend_file兜底应对织梦升级覆盖、集群部署的Redis session切换,附性能压测和排错清单。 > 摘要:DedeCMS默认表单有安全空白,本文给五种防重复提交方案叠加用。从session绑IP的基础拦截,到30分钟时间窗冷却、CSRF token防脚本伪造、前端按钮置灰防双击、thank-you页禁缓存,再讲反向代理与CDN场景下的真实IP处理、auto_prepend_file兜底应对织梦升级覆盖、集群部署的Redis session切换,附性能压测和排错清单。 这是我维护一批老客户企业站时反复用到的一套实战方案。织梦DedeCMS至今还在大量企业官网跑着,自定义表单 (https://zhangwenbao.com/dedecms-empties-the-custom-form-content.html)是询盘、报名、留言这类核心组件,但出厂的/plus/diy.php没有任何防重复提交逻辑——用户多点几次、网络抖一下、误回退页面再 F5,后台就会出现一连串完全相同的脏数据。这篇文章把基于session+IP的5层加固方案整理成可落地代码,每一层都标注修改位置、踩坑点、性能影响、与运营动作的协同,让你直接抄回去用,并附上对集群部署、Cloudflare CDN、织梦升级覆盖等真实场景的处理方法。所有代码片段中的尖括号都用HTML实体替代以避免被strip_tags吃掉。 ## 问题根因:织梦默认表单的安全空白 打开/plus/diy.php这个处理自定义表单提交的核心脚本,会发现它的逻辑非常简陋——拿到POST的字段,按模型配置写库,跳转成功页。整个过程没有任何"重复请求"判断:没有CSRF token、没有IP限速、没有提交频率控制、没有nonce机制。这种"信任前端"的设计带来 4 类典型故障: 第一类,弱网下用户连点。中国大陆移动网络在地铁、电梯、隧道等场景常见 3-5 秒延迟,用户点了提交后界面没反应,下意识又点一次,浏览器把同一份表单数据再发一次,后台就会写入两条记录。这种"无感的重复提交"是真实用户最常见的造脏数据来源。 第二类,浏览器回退键 F5。提交成功跳转到 thank-you 页面后,用户回退到原表单页再按 F5,浏览器会弹"是否重新提交表单"对话框,用户随意点确定就会再次提交。如果 thank-you 页面没禁用缓存,回退甚至不会触发对话框,直接重新 POST。 第三类,CDN 缓存与回源不一致。某些客户的网管在配置宝塔、阿里云 CDN 时不小心把表单页路径也加了缓存,用户回退页面命中缓存版本,再次提交也会出现完全相同的订单。 第四类,低级爬虫脚本。看到 /plus/diy.php 这个公开 POST 入口的爬虫极多——一晚上几百条垃圾数据是常态,包括各种英文乱码、广告链接、SQL 注入测试、XSS 探针。 ## 方案 1:session+IP 基础拦截(核心方案) ## 修改位置 打开 /plus/diy.php,定位到大约第 58 行附近的字段处理段(不同织梦版本可能在第 50-70 行之间,搜$dede_fields定位)。 ## 核心代码 if(!empty($dede_fields)) { session_start(); $cur_ip = GetIP(); if ($cur_ip == $_SESSION['form_ip']) { showmsg('您已经提交过了,无需重复提交!', '/', 0, 10000); exit; } else { $_SESSION['form_ip'] = $cur_ip; } $fieldarr = explode(';', $dede_fields); // 后续保持原样 } GetIP()是织梦自带的工具函数,封装在include/common.func.php里,会按顺序检查HTTP_X_FORWARDED_FOR、HTTP_CLIENT_IP、REMOTE_ADDR三个 header。showmsg是织梦自带的提示函数,第三个参数 0 表示不自动跳转,第四个参数 10000 表示提示展示 10 秒。 ## 为什么用 session+IP 而不是单 IP 这是新人最常问的问题。直接拿 IP 做对比也能拦重复,但缺点明显:IP 是公网共享资源——一个写字楼几百号人共用一个 NAT 出口 IP 是常态、移动 4G/5G 用户在同一基站下共用一批出口 IP、企业园区往往用统一公网。如果纯 IP 拦截,第一个提交后所有同 IP 的真实用户都会被错杀。 session 的作用是绑定到具体浏览器会话(依赖 Cookie 里的 PHPSESSID)。同一用户、同一浏览器才能命中前一次写入的$_SESSION['form_ip']。换浏览器、换设备、清 Cookie,session 重新开始,不会被错杀。 IP 在这里的作用是"二级保险"——如果用户开了 N 个标签页同时提交(共享 session),IP 比对能确认是同一物理位置发出的。 ## 方案 2:时间窗口冷却(基础方案的进阶) 方案 1 的"同 session 永久不能再提交"过于激进——用户可能 2 小时后想重新询盘新内容。给 session 加时间戳,控制冷却窗口能解决这个问题。 session_start(); $cur_ip = GetIP(); $now = time(); $last = isset($_SESSION['form_last_time']) ? (int)$_SESSION['form_last_time'] : 0; if ($cur_ip == $_SESSION['form_ip'] && ($now - $last) < 1800) { showmsg('您刚刚提交过,请稍后再试。', '/', 0, 10000); exit; } $_SESSION['form_ip'] = $cur_ip; $_SESSION['form_last_time'] = $now; 这里 1800 是 30 分钟(秒数)。冷却窗口的选择按业务调整:纯展示官网用 1800(30 分钟);询盘价值高的 B2B 站点用 7200(2 小时);招商加盟站点甚至可以用 86400(24 小时);活动报名页用 60-120(1-2 分钟,仅挡连点不挡正常重提)。 ## 方案 3:CSRF token 防止脚本伪造(与 1 叠加) 方案 1 与 2 都假设请求来自浏览器(有 session),如果攻击者直接用 curl 或 Python 脚本 POST 到/plus/diy.php,没有 session、永远不会被拦。CSRF token 是防御这一类攻击的有效手段。 ## 表单页面输出 token
## diy.php 入口校验 session_start(); if (empty($_POST['_token']) || $_POST['_token'] !== $_SESSION['form_token']) { showmsg('表单已过期,请刷新页面后重新提交。', '/', 0, 10000); exit; } unset($_SESSION['form_token']); token 用一次就销毁。远程脚本拿不到 token 就 POST 不进来。random_bytes(16)生成 16 字节高熵随机数,配合bin2hex转十六进制后是 32 位字符串,碰撞概率忽略不计(2 的 128 次方分之一)。 ## 注意:织梦旧版 PHP 不支持 random_bytes random_bytes是 PHP 7+ 的函数。如果你的服务器还在 PHP 5.6(很多老织梦站点),用openssl_random_pseudo_bytes替代:$token = bin2hex(openssl_random_pseudo_bytes(16));。再老的环境(不支持 openssl)退回到md5(mt_rand() . microtime() . session_id()),安全强度略低但够防 CSRF。 ## 方案 4:前端按钮置灰+防双击(用户体验层) 服务端的 3 层防御之外,前端做一道用户体验层防护——提交按钮点击后立即置灰+提示"正在提交",避免用户连点。这一步对真实用户场景下的"无感重复"效果最直接。
5 秒超时是必要的——如果服务端响应失败(500 错误、连接断开),按钮永远卡在"正在提交"会让用户无路可走。5 秒后自动恢复,给用户重试机会,同时大多数正常请求 3-4 秒能完成,5 秒已经覆盖。 ## 方案 5:thank-you 页面禁用缓存 提交成功后跳转到独立的 thank-you 页面,避免用户在原页面按 F5 触发再次 POST。在 thank-you 页面 HTML 头部加: 同时在服务端响应头加: header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); 双重禁用缓存确保用户回退时浏览器不会从本地缓存恢复 POST 后的结果。某些激进的浏览器(移动端 UC、QQ 浏览器)默认会缓存所有 GET 页面,没有禁用缓存的 thank-you 页面会被错误恢复。 ## 反向代理与 CDN 场景的真实 IP 处理 这一块是改完 diy.php 之后最容易踩的坑。我见过最严重的案例是一个加盟商站点改完防重复提交后,所有用户从第二天起完全无法提交——后台日志显示$cur_ip永远是127.0.0.1,因为 Nginx 没有透传真实 IP。 ## Nginx 反向代理配置 在 location 块里加: location ~ \.php$ { proxy_pass http://127.0.0.1:9000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } 这样织梦GetIP()能正确从HTTP_X_FORWARDED_FOR读到真实 IP。 ## Cloudflare CDN 场景 Cloudflare 把客户端真实 IP 放在CF-Connecting-IP这个 header 里,织梦原生GetIP不识别。需要自己包一层: function GetRealIp() { if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { return $_SERVER['HTTP_CF_CONNECTING_IP']; } if (!empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { return $_SERVER['HTTP_TRUE_CLIENT_IP']; } return GetIP(); } 然后把方案代码里的GetIP()全部替换为GetRealIp()。HTTP_TRUE_CLIENT_IP是 Cloudflare Enterprise 计划的标准 header,普通付费版仍然用CF-Connecting-IP。 ## 阿里云、腾讯云 CDN 阿里云 CDN 用X-Forwarded-For透传,符合 RFC 标准;腾讯云 CDN 同样用X-Forwarded-For。两者都不需要额外处理,但要注意 X-Forwarded-For 是逗号分隔的列表(多次代理会追加),织梦GetIP()已经处理了这种情况。 ## 性能影响与压测数据 很多人担心加 session 会拖慢响应。我在一个真实生产环境(4 核 8G 阿里云 ECS、PHP 7.4、Nginx 1.20、MySQL 5.7)做过压测: 未改造前:/plus/diy.php平均响应 85ms,QPS 约 240。 改造后(方案 1+2+3+5 全启用):平均响应 92ms,QPS 约 220。 性能损失约 8%-9%,主要来自 session 文件读写。如果使用 Redis 替代文件 session(推荐生产环境),性能损失降到 3% 以内。 对绝大多数企业站(QPS < 50),这种性能损失完全感知不到。访问量大的场景(QPS > 200)建议直接上 Redis session。 ## 常见报错与排查清单 ## 错误 1:headers already sent session_start()必须在任何输出之前调用。如果文件顶部 BOM 没去干净(用 UTF-8 with BOM 保存的 PHP 文件会输出 3 字节 BOM),或前面有echo、空行混入,session 启动失败。织梦的入口文件common.inc.php已经做了ob_start(),多数情况下不会触发,但如果你在自定义模板里手动 echo 过东西要警惕。修复方法:用 Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 把所有相关 PHP 文件转成"UTF-8 无 BOM"格式重新保存。 ## 错误 2:session 文件目录不可写 生产服务器上 PHP 的session.save_path默认指向/tmp或/var/lib/php/sessions。如果运维清理脚本太狠把目录权限改掉(chmod 700但 web 用户不在 owner),session 写不进去就会失效。$_SESSION['form_ip']永远拿不到上次的值,重复提交又能进来。排查方法:phpinfo() 看session.save_path,确认目录存在且 web 用户(通常是 www、nginx、apache)可写。 ## 错误 3:负载均衡场景 session 不一致 多台 web 机器+负载均衡时,文件 session 默认存在各机器本地,用户被分到 A 机器存 session、再次访问被分到 B 机器读不到 session,第二次提交不被拦下。修复方法两种:第一种把session.save_handler切到 Redis(推荐);第二种用 sticky session(Nginx 的 ip_hash 模块或 lvs 的会话保持),保证同一用户始终落到同一台机器。 ## 错误 4:CSRF token 跨页面共享失败 如果用户同时打开多个表单页(多个 token 写到同一 session),后写的 token 覆盖前面的,先打开的页面提交时 token 不匹配。修复方法:每个表单生成独立 token 存到 session 数组,$_SESSION['form_tokens'][$page_id] = $token,校验时查对应页面 ID 的 token。 ## 织梦升级覆盖的应对 织梦官方升级包通常会替换/plus目录下的核心脚本,你的修改会被覆盖。建议两种应对: 第一种是把改动整理成 patch 文件备份。每次改完用diff -u 原 diy.php 改后 diy.php > diy_patch.txt生成补丁,升级后用patch /plus/diy.php diy_patch.txt恢复。简单但需要每次升级都手动操作。 第二种是用auto_prepend_file在 PHP 启动前自动加载防重复逻辑。在php.ini或.htaccess里:php_value auto_prepend_file "/path/to/prevent_duplicate.php"。然后在prevent_duplicate.php里检测当前请求的脚本名是不是diy.php等敏感入口,是的话执行拦截逻辑。这种方式不动织梦源码,升级后自动生效。但织梦的钩子机制相对原始,多数项目还是直接改+备份。 ## 运营层面的去重补救 技术修改之外,运营也要建立去重机制。我会在每个客户后台加一段 SQL 视图: SELECT DATE(addtime) AS day, email, phone, COUNT(*) AS submits FROM dede_diyform_xxx WHERE addtime > UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) GROUP BY day, email, phone HAVING submits > 1; 每天凌晨跑一次,把 24 小时内同邮箱或同手机号出现 2 次以上的记录标黄,运营人员人工复核。配合上述服务端拦截,基本上能让脏数据降到忽略不计。 ## 从 DedeCMS 视角看为什么不直接换 CMS 有些读者会问"织梦都被官方放弃多年了,为什么不直接迁移到 WordPress 或 Typecho?"。现实里很多老客户企业站存在以下迁移阻力: 第一,存量内容 (https://zhangwenbao.com/revise-old-content-for-aeo-ai-search-optimization.html)多。一个跑了 10 年的企业站可能有 8000+ 篇产品页、5000+ 张图片、2000+ 个 SEO 收录链接。迁移要保 URL 兼容、保 SEO 权重,工作量比"重做一个新站"还大。 第二,定制功能深。10 年里客户改过的模板、加过的功能、改过的字段都跑在织梦的逻辑里,迁到 WordPress 需要重新开发。 第三,运维已习惯。客户的内容编辑团队已经熟悉织梦后台 5-10 年,重新培训的隐性成本高。 所以"在老织梦上做安全加固"在很多场景下是比"迁移到新 CMS"更现实的选择。这套防重复提交方案是这种"现实选择"的一部分——不是技术理想最优解,但能让一个 2010 年代的 CMS 在 2026 年继续安全运行。 ## 常见问题解答 ## 改完diy.php之后织梦升级会不会被覆盖? 会。织梦官方升级包通常会替换 /plus 目录下的核心脚本。升级前一定要把改动备份成 patch,升级后再回贴。或者用 auto_prepend_file 把拦截逻辑放在外部文件里自动加载,不动织梦源码。但织梦的钩子机制相对原始,多数项目还是直接改加备份。建议每次升级前用 git 或者简单的备份脚本把所有自定义修改的文件打包,避免漏掉某些文件的改动。 ## 用IP拦截会不会误伤公司内网用户? 会有一定概率。对内网集中提交场景比较多的客户,建议把单纯 IP 拦截改成 IP 加 UserAgent 加表单 hash 三段式比对,提升 session 区分度。或者干脆把冷却窗口从 30 分钟缩短到 60 秒,只挡连点。如果客户的内网用户经常需要批量提交(如展会现场多人填表),建议在前端加一个"我不是同一个人"的复选框,勾选后绕过 IP 限制——把判断权交给用户而不是机器。 ## session在多台服务器集群下能用吗? 默认 session 存在文件里,多机房会失效。集群方案要么把 session.save_handler 切到 Redis(推荐),要么用 sticky session 保证同一用户始终落到同一台机器。织梦本身没有集群部署的官方支持,多机方案需要自己评估。Redis session 配置:php.ini 里 session.save_handler = redis、session.save_path = "tcp://your-redis-host:6379",重启 PHP-FPM 即可。Redis 同时被多台 web 机器读写不会有数据一致性问题,是织梦集群的标准方案。 ## 除了改diy.php,还有哪些文件需要类似处理? 织梦还有 /plus/feedback.php(评论提交)、/plus/guestbook.php(留言板)、/plus/carbuyaction.php(购物提交)等几个公开提交入口,逻辑大同小异。可以把上面的 IP 加 session 拦截抽成一个公共函数 prevent_duplicate_submit() 放到 include/extend.func.php,在每个入口顶部调用一次,统一维护。如果你站点开了搜索 search.php,搜索操作通常不需要防重复(搜索本身是幂等的),但要防爬虫高频抓取,可加频率限制(同 IP 5 秒内最多 10 次)。 ## 这套方案能不能完全防住爬虫脚本? 不能完全防住但能挡住 90% 的低级脚本。CSRF token 能挡住所有不解析 HTML 直接 POST 的脚本(占爬虫的大头);但高级爬虫会先 GET 表单页解析 token 再 POST,这种情况要叠加验证码(Google reCAPTCHA、阿里云验证码、腾讯云验证码任选)才能挡住。完全防住所有爬虫不现实,只要防御成本超过攻击者收益,对方就会去找下家。验证码的代价是降低真实用户的转化率(5%-15% 用户因看到验证码而放弃提交),是否启用要看业务对脏数据的容忍度与对转化率的敏感度。 ## 提交后跳转thank-you页面能不能加用户提交的具体内容回显? 可以,但要小心 XSS。用户提交的内容里可能含 script 标签、img onerror 等攻击代码。如果直接拼接到 thank-you 页面 HTML 里渲染会触发 XSS。处理方法是用 PHP 的 htmlspecialchars 函数转义所有用户输入再输出:echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');。织梦自带的 dede_htmlspecialchars 函数也可以。这是 OWASP Top 10 里 A03 注入类攻击的常见入口,写表单功能时务必养成转义习惯。 ## 有没有现成的织梦防重复提交插件? 织梦插件市场(dedecms.com 的扩展模块)有几个号称防重复提交的插件,但绝大多数已经多年没维护,部分插件还包含后门或恶意代码(2019-2021 年出现过多起)。建议不要装第三方插件,直接按本文方案修改源码更安全可控。如果团队没有 PHP 修改能力,可以付费请专业开发者做一次性修改,预算 200-500 元能搞定,比装来源不明的插件安全。 ## 权威参考资料 ## DedeCMS自定义表单数据怎么清空并重置自增ID - URL:https://zhangwenbao.com/dedecms-empties-the-custom-form-content.html - 分类:织梦CMS教程 - 发布:2018-11-22 | 更新:2026-06-02 - 摘要:DedeCMS的diyform表单想批量清空,细节不少。本文覆盖SQL命令行多语句模式、用information_schema批量生成TRUNCATE、外键约束错误的FOREIGN_KEY_CHECKS切换、TRUNCATE与DELETE的性能对比、mysqldump备份与binlog恢复,附五条健康检查清单。 - 关键词:织梦自定义表单,TRUNCATE TABLE,AUTO_INCREMENT,DedeCMS SQL工具 > **TLDR**:摘要:DedeCMS的diyform表单想批量清空,删了记录ID却不会归零。本文讲清原因,给出通过后台SQL命令行清空表单的完整步骤、一次清空多张表的批量写法、清空前必做的三件事、TRUNCATE与DELETE与DROP的区别,再讲ID没归零或后台报错怎么办、从备份恢复误清数据,附批量TRUNCATE后的健康检查清单。 > 摘要:DedeCMS的diyform表单想批量清空,删了记录ID却不会归零。本文讲清原因,给出通过后台SQL命令行清空表单的完整步骤、一次清空多张表的批量写法、清空前必做的三件事、TRUNCATE与DELETE与DROP的区别,再讲ID没归零或后台报错怎么办、从备份恢复误清数据,附批量TRUNCATE后的健康检查清单。 保哥在做织梦DEDECMS项目时经常遇到一个让人头大的需求:客户在测试阶段往自定义表单 (https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html)里灌了几百上千条假数据,等正式上线那天,希望表单数据从零开始,而且新提交的记录ID要从1重新计数。如果只是用后台的删除按钮一条条勾选,不仅效率低,而且即使把所有记录都删掉,下次新提交的数据ID也不会从1开始,会接着原来的最大ID继续往后排,看上去就像数据不连续。这一篇文章保哥把多年来在织梦自定义表单 (https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html)清理上的踩坑经验整理出来,从原理、操作步骤、风险防范到常见疑问,带你彻底搞懂如何安全地清空自定义表单。 ## 为什么删除记录之后ID不会归零 要理解这个问题,得先从MySQL的自增机制说起。织梦的自定义表单在数据库里实际上对应一张以 dede_diyform 为前缀的表,比如 dede_diyform1、dede_diyform2,表的主键 aid 通常是 INT 类型并设置了 AUTO_INCREMENT。MySQL在记录被DELETE删除时,会保留自增计数器的当前值,下一条插入仍然会用上次的最大值加一。 保哥早期不懂这个原理,曾经以为后台"清空所有数据"按钮可以让ID归零,结果上线后第一条客户提交的留言ID居然是837。这种细节看上去不影响业务,但当你需要把表单ID拼接到URL或者订单号里时,就会非常尴尬。所以正确的做法不是DELETE,而是TRUNCATE TABLE,这是MySQL官方推荐的"清空表 + 重置自增"的标准动作。 ## AUTO_INCREMENT 计数器在 InnoDB 与 MyISAM 上的区别 这一点很多教程都会模糊带过,但实战中经常踩坑: 引擎 | 计数器存在哪 | 重启后是否丢失 | TRUNCATE 后行为 | MyISAM | frm 文件元数据 | 不丢,持久化 | 归零到 1 | InnoDB(MySQL 5.7-) | 内存里维护,启动时 SELECT MAX 重算 | 会,重启后被 MAX(id)+1 覆盖 | 归零到 1 | InnoDB(MySQL 8.0+) | redo log 持久化 | 不丢 | 归零到 1 | MySQL 5.7 InnoDB 上有个反直觉的现象:如果你 DELETE 了表里 ID=100 的最大记录,然后重启 MySQL,新插入的 ID 会变成 100 而不是 101——因为重启时 InnoDB 用 SELECT MAX(id)+1 重算。MySQL 8.0 已经修复了这个行为。织梦绝大部分站点都跑 5.7,所以 DELETE 后的 ID 行为依赖于是否近期重启过 MySQL,更要养成用 TRUNCATE 的习惯。 ## 通过后台SQL命令行工具清空表单的完整步骤 织梦自带一个非常实用的SQL命令行工具,登录后台之后路径是"系统 - 系统设置 - SQL命令行工具"。保哥推荐优先用这个工具,因为它会强制使用织梦预设的数据库连接,避免你手动连数据库时连错环境(生产/测试搞混过的同行应该都懂这种痛)。 第一步,先确认你要清空的自定义表单到底是 diyform 几号。进入"核心 - 频道模型 - 自定义表单管理",列表里每一行最右边的"ID"就是 diyid。比如保哥这边的"在线留言"表 diyid 是 1,那么对应的物理表名就是 dede_diyform1。 第二步,打开 SQL 命令行工具,把下面的语句粘贴进去: TRUNCATE TABLE `dede_diyform1`; 注意三个细节:表名要用反引号包起来;语句末尾分号必须有;前缀 dede_ 不一定固定,如果你安装时改过表前缀,就要换成自己的。点击"确定"执行,后台返回"执行成功"说明已经搞定。 第三步,回到自定义表单的前台测试页面,随便提交一条新数据,再去后台看看记录的 aid,应该就是 1 了。 ## 用 SHOW CREATE TABLE 验证清空前后的 AUTO_INCREMENT 值 验证 TRUNCATE 是否真的把 AUTO_INCREMENT 归零,最直接的方法是 SHOW CREATE TABLE: SHOW CREATE TABLE `dede_diyform1`\G -- 输出末尾会有 AUTO_INCREMENT=837(清空前) -- TRUNCATE 后再跑一次,AUTO_INCREMENT 字段会消失或显示 AUTO_INCREMENT=1 有些客户站后台显示"清空成功"但实际没归零,原因往往是某些二开插件在 dede_diyform1 上加了 BEFORE INSERT 触发器,从外部计数器读 ID 写回——这种情况就要找到具体触发器把它禁用或修改: SHOW TRIGGERS LIKE 'dede_diyform1'\G ## 一次清空多张自定义表单的批量写法 如果你的站点同时有十几张自定义表单都需要清空(保哥之前接过一个二手车站,有报价、预约、贷款、试驾四张表),逐条执行TRUNCATE太啰嗦,可以写成多语句: TRUNCATE TABLE `dede_diyform1`; TRUNCATE TABLE `dede_diyform2`; TRUNCATE TABLE `dede_diyform3`; TRUNCATE TABLE `dede_diyform4`; 织梦后台的SQL命令行工具默认支持多语句模式,把上面这一段整体粘贴进去就能一次性执行。如果你想更省事,可以用 INFORMATION_SCHEMA 自动生成所有 diyform 表的清空语句: SELECT CONCAT('TRUNCATE TABLE `', table_name, '`;') AS sql_text FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'dede_diyform%' AND table_name <> 'dede_diyforms'; 保哥习惯先用这条语句把要执行的命令查出来,肉眼确认没有清空错的表(比如 dede_diyforms 是字段定义表,绝对不能 TRUNCATE!),再把结果整体复制到 SQL 工具里执行。这一步是保哥从一次惨痛事故里总结出来的:当年我曾经误用 LIKE 'dede_diyform%' 把 dede_diyforms 也清空了,结果整套自定义表单结构全部丢失,恢复花了一个晚上。注意上面的 SQL 我特意加了 table_name 不等于 dede_diyforms 的过滤,这是从那次事故之后我固定的写法。 ## 带白名单的"安全清空脚本" 给客户的运维脚本里,我会再加一层显式白名单,避免新人误删: -- 仅允许下列表名被 TRUNCATE,超出列表则不生成 SQL SET @whitelist = 'dede_diyform1,dede_diyform2,dede_diyform3,dede_diyform4'; SELECT CONCAT('TRUNCATE TABLE `', table_name, '`;') AS sql_text FROM information_schema.tables WHERE table_schema = DATABASE() AND FIND_IN_SET(table_name, @whitelist) > 0; 把 @whitelist 当成保险栓,任何不在显式列表里的表都不会出现在生成的 SQL 中。在客户量大、运维交接频繁的场景里,这一层保护值千金。 ## 清空表单前必须做的三件事 保哥反复强调一句话:DEDECMS的SQL命令行工具是危险品,下手之前必须做足准备。 第一件事是备份数据库。最稳妥的做法是直接登录服务器执行 mysqldump: mysqldump -uroot -p --single-transaction --quick --routines --triggers --events \ --databases dedecms_db \ > /backup/dedecms_$(date +%Y%m%d_%H%M%S).sql 各参数含义: - --single-transaction:在一个 REPEATABLE READ 事务里读所有数据,不锁表,适合 InnoDB - --quick:逐行流式输出,避免大表把内存撑爆 - --routines:导出存储过程和函数 - --triggers:导出触发器(默认是开的,但显式写更清晰) - --events:导出事件调度器 如果没有SSH权限,也可以在织梦后台"系统 - 数据库备份/还原"里点一次完整备份,把生成的文件下载到本地。备份的目的不是恢复整库,而是给自己留一个反悔的余地——保哥见过太多"执行TRUNCATE之后,客户突然问 ID=237 那条客户名片去哪了"的尴尬场面。 第二件事是确认 diyid 和真实表名一致。织梦后台显示的"ID"是 diyid,对应物理表 dede_diyformN,但如果你的站经历过历史数据迁移,这个对应关系不一定可靠。保哥的做法是先到 phpMyAdmin 里 SELECT * FROM dede_diyform1 LIMIT 5; 看看里面的字段是不是你想清空的那张表的字段,再决定下一步。 第三件事是关闭前台表单提交入口或者贴一个临时维护页。否则你 TRUNCATE 完,几秒钟后又有用户提交了,aid 会变成 1,但你心里以为它是清空之后的第一条,过几天对账就会发现数据少了。最简洁的关停方法是在 Nginx 里给 /plus/diy.php 加临时 deny: location = /plus/diy.php { return 503 'Maintenance in progress'; } TRUNCATE 完测试通过后再把这段 location 删掉、reload Nginx。 ## TRUNCATE、DELETE、DROP三者的区别 这三个SQL关键字在中文资料里经常被混用,但行为完全不一样,保哥用一段日常比喻帮你一次记住。 -- DELETE:逐条删,可回滚,自增不重置 DELETE FROM `dede_diyform1` WHERE 1=1; -- TRUNCATE:清空表数据,不可回滚,自增归零 TRUNCATE TABLE `dede_diyform1`; -- DROP:连表结构一起删,必须重新建表 DROP TABLE `dede_diyform1`; DELETE 像是把书柜里的书一本本搬出来,书柜结构和编号还在;TRUNCATE 像是把整个书柜清空再重新贴"1号格"标签;DROP 则是把书柜本身砸了。在织梦自定义表单的场景里,99%的需求都是 TRUNCATE:表结构保留、ID 重置、效率高。 ## 三者的性能对比与实测耗时 操作 | 1 万行耗时 | 100 万行耗时 | binlog 体积 | 是否锁表 | DELETE FROM | 1.8 秒 | 156 秒 | 每行一条 row event,巨大 | 行锁,可能升级 | TRUNCATE TABLE | 0.04 秒 | 0.06 秒 | 一条 DDL,几十字节 | 瞬时元数据锁 | DROP + CREATE | 0.12 秒 | 0.15 秒 | 两条 DDL | 瞬时元数据锁 | TRUNCATE 在大表上几乎是常数时间——本质是 DROP 数据文件再 CREATE 新文件,跟数据量无关。DELETE 在 100 万行上要 156 秒,期间 binlog 还会膨胀几个 GB,主从复制延迟会被拉爆,绝对不要在生产用 DELETE 清表。 顺便一提,TRUNCATE 在 InnoDB 引擎下是 DDL 级别操作,不会触发 ON DELETE 触发器,也不会写入 binlog 的 row 模式记录(取决于你的 MySQL 配置),所以如果你做了主从复制或基于触发器的审计日志,记得先评估一下这两个副作用。 ## 清空之后ID没归零或后台报错怎么办 保哥总结了几种常见的故障场景。 第一种:执行完TRUNCATE但新数据 aid 还是从老的最大值开始。这通常是因为你执行的是 DELETE 而不是 TRUNCATE,或者你站点开启了一种叫"ID池"的自定义插件,会从插件表里读 ID。检查一下 dede_sys_module 和插件目录有没有相关代码。 第二种:执行 TRUNCATE 时报 Cannot truncate a table referenced in a foreign key constraint。这说明你的 diyform 表被另一张表用外键引用了。织梦默认是没有外键的,但有些二开版本会加。解决方案是先临时关掉外键检查: SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE `dede_diyform1`; SET FOREIGN_KEY_CHECKS = 1; 第三种:清空之后前台表单提交报错"字段不存在"。这往往是因为表单结构在 dede_diyforms(注意带 s)里的定义和实际表的字段不一致,跟 TRUNCATE 没关系,需要进"自定义表单管理 - 编辑"把字段重新保存一次,让织梦重新生成表结构 SQL。 第四种:阿里云 RDS / 腾讯云 CDB 上 TRUNCATE 报权限错误。云厂商的"高权限"账号默认没给 DROP / TRUNCATE 权限,必须在控制台单独申请。临时方案是用 DELETE + ALTER TABLE AUTO_INCREMENT=1 组合替代,性能差但能跑通。 第五种:执行后织梦后台显示"数据库连接已断开"。这是因为 TRUNCATE 是 DDL,会刷新表的元数据 cache,部分织梦版本对元数据变更的处理不够鲁棒。退出后台重新登录即可,数据本身没问题。 ## 从备份恢复误清空数据的实战 万一你跳过了备份直接 TRUNCATE 出了事,还有两条挽救路径: ## 路径 A:从 mysqldump 备份恢复单表 # 从全库备份里抽取单表的 INSERT 数据 sed -n '/-- Dumping data for table `dede_diyform1`/,/-- Dumping/p' \ /backup/dedecms_20260511_023000.sql > /tmp/restore_diyform1.sql # 先在 MySQL 里 TRUNCATE 当前空表,再导入 mysql -u root -p dedecms_db -e 'TRUNCATE TABLE dede_diyform1;' mysql -u root -p dedecms_db < /tmp/restore_diyform1.sql 这种方式恢复的是备份时点的数据。如果 TRUNCATE 是凌晨 3 点做的、备份是凌晨 2 点做的,那中间 1 小时新增的数据是恢复不回来的。 ## 路径 B:从 binlog 恢复 TRUNCATE 后的新增数据 如果 TRUNCATE 之后又新增了数据,路径 A 恢复完后还要把这段 binlog 重放: mysqlbinlog --start-datetime='2026-05-11 03:00:00' --stop-datetime='2026-05-11 09:00:00' \ /var/lib/mysql/binlog.000123 \ | grep -A 20 'dede_diyform1' \ | mysql -u root -p dedecms_db 但前提是 binlog_format=ROW、且 binlog 没被自动删除。云厂商托管 MySQL 的 binlog 一般只保留 7 天,超过这个窗口就只能放弃。 ## 批量 TRUNCATE 后的健康检查清单 每次大批量清空之后,保哥的 5 条健康检查清单: - SHOW CREATE TABLE 每张表,确认 AUTO_INCREMENT 都回到 1(或没列出该字段) - 提交一条测试数据,前台后台都能看到 aid=1 - 检查 dede_diyforms 元数据表行数没变(说明只清了数据没清结构) - 主从复制延迟 SHOW SLAVE STATUS\G 看 Seconds_Behind_Master 是否回到 0 - 用 mysqlcheck -A 跑一遍表完整性,确认没坏表 ## 常见问题解答 ## TRUNCATE之后能用binlog恢复数据吗? 保哥的实测结论是:在 MySQL 5.7/8.0 默认 row 模式下,TRUNCATE 会作为 DDL 写入 binlog,但它不像 DELETE 那样保留每一行的反向 SQL,所以理论上无法直接通过 mysqlbinlog 还原行数据。最稳妥的恢复方式仍然是事前备份加事中观察。如果 TRUNCATE 之前的 INSERT 还在 binlog 里没被冲刷掉,可以反向解析这些 INSERT 重新写一遍,但操作复杂且容易出错,远不如事前备份省事。 ## 织梦的SQL命令行工具支持事务吗? 不支持。SQL 工具是一次性把语句发给数据库执行,没有 BEGIN/COMMIT 的概念。即使你写 START TRANSACTION 它也只是当作一条普通语句执行,TRUNCATE 本身又是隐式提交的 DDL,所以一旦点确定就不可逆。如果你真的需要事务保护,应该走命令行 mysql 客户端或者 Navicat 这类客户端工具,手工开 BEGIN/COMMIT 包住一组 DML。 ## 清空了表单数据,会不会影响后台频道模型自定义表单里的字段配置? 不会。字段配置存放在 dede_diyforms 这张元数据表里,跟具体的 dede_diyform1 数据表是分开的,TRUNCATE 数据表不会动配置。但是反过来,如果你不小心 TRUNCATE 了 dede_diyforms,那所有自定义表单的结构定义就全没了,这是真正的灾难。本文上面的安全清空脚本特意排除了 dede_diyforms,正是为了防这种事故。 ## 能不能用ALTER TABLE AUTO_INCREMENT=1代替TRUNCATE? 可以,但前提是你已经把表里的所有记录都删掉了。先 DELETE FROM 再 ALTER TABLE AUTO_INCREMENT=1 这种写法会触发 DELETE 的逐行操作和 ALTER 的元数据修改,性能比 TRUNCATE 慢得多,对大表非常不友好。保哥建议除非你需要保留 binlog 行级回滚能力,否则直接用 TRUNCATE 更清爽。在云数据库受限场景(高权限账号没 TRUNCATE 权限)下,这种组合是退而求其次的方案。 ## 阿里云RDS、腾讯云CDB上TRUNCATE报权限错误怎么办? 云厂商的"高权限"账号默认不给 TRUNCATE/DROP/ALTER 权限,避免误删。如果业务允许,可以在控制台开通这两个权限——阿里云在"账号管理"里可以单独勾"DDL 权限"。如果不能开通,用 DELETE 加 ALTER AUTO_INCREMENT 的组合替代。另外提醒:云数据库的 binlog 保留期一般只有 7 天,比自建 MySQL 短,恢复窗口要算好。 ## TRUNCATE 会触发 ON DELETE 触发器吗? 不会。TRUNCATE 是 DDL,跳过 DELETE 触发器、跳过 ON DELETE CASCADE 外键级联、跳过 row 格式的 binlog 行事件。如果你的业务里依赖触发器记录审计日志(每删一行写一条审计),用 TRUNCATE 后会丢这些审计——清空前要先评估这一点,必要时用 DELETE 替代。 ## 清空之后再 INSERT,能不能直接指定起始 aid 比如从 1001 开始? 可以。TRUNCATE 后先 ALTER TABLE dede_diyform1 AUTO_INCREMENT=1001,然后再 INSERT,新行 aid 就从 1001 开始。常见用法:客户希望表单 ID 看起来"有量",不要从 1 开始那么寒酸,就预设一个 1001 或 5001 的起跳点。但要避免在已有数据的表上 ALTER 到一个比 MAX(id) 小的值,那样 InnoDB 会自动忽略你的设定,跳回到 MAX(id)+1。 ## TRUNCATE 和 mysqlcheck repair 有什么关系? 没有直接关系。TRUNCATE 是清空数据;mysqlcheck repair 是修复损坏的 MyISAM 表。但有个隐性联系:MyISAM 表在大量 INSERT/DELETE 之后碎片化严重,TRUNCATE 一次相当于重建数据文件,文件碎片消失,相当于做了一次彻底的 OPTIMIZE。InnoDB 上没有这个效果(InnoDB 的 .ibd 文件依然是新分配的)。 ## 权威参考资料 ## DedeCMS自定义表单怎么做必填校验?服务端、JS、HTML5三层防护 - URL:https://zhangwenbao.com/dedecms-custom-form-settings-required-items.html - 分类:织梦CMS教程 - 发布:2018-11-21 | 更新:2026-06-02 - 摘要:DedeCMS自定义表单默认不校验必填项,也挡不住灌水。本文给出三层校验:服务端在trim后加必填与字段名格式校验防注入、原生JS不依赖jQuery做即时反馈、HTML5的required与pattern兜底,再扩展reCAPTCHA v3评分、双token CSRF防护和IP频率限制。 - 关键词:织梦自定义表单,reCAPTCHA,DedeCMS自定义表单,表单必填,前端校验 > **TLDR**:摘要:DedeCMS自定义表单默认不校验必填项,也挡不住灌水。本文给三层缺一不可的校验——服务端改plus/diy.php在trim后加必填与字段名格式校验防注入、前端原生JS不依赖jQuery做即时反馈、HTML5的required与pattern兜底,再扩展reCAPTCHA防机器人、双token的CSRF防护和IP频率限制,附常见故障排查。 > 摘要:DedeCMS自定义表单 (https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html)默认不校验必填项,也挡不住灌水。本文给三层缺一不可的校验——服务端改plus/diy.php在trim后加必填与字段名格式校验防注入、前端原生JS不依赖jQuery做即时反馈、HTML5的required与pattern兜底,再扩展reCAPTCHA防机器人、双token的CSRF防护和IP频率限制,附常见故障排查。 DedeCMS 自定义表单 (https://zhangwenbao.com/dedecms-custom-form-verification-mobile.html)(diy)模块默认所有字段都是可选的,留言、报名、咨询等场景里用户可以提交一份空表单。运营拿到一堆没有联系方式的留言完全没用。本文给出 DedeCMS 自定义表单必填项校验的三层完整方案:服务端 plus/diy.php 强制校验、前端 JS 即时反馈、HTML5 原生校验,并扩展到字段格式校验(手机号、邮箱、URL 正则)、图形验证码防机器人、CSRF token 双 token 防护、必填规则配置化、错误信息友好显示。本文与本站“DedeCMS 自定义表单 CSS 美化”(cid 822)配套使用,是同一组 diy 模块的功能加固而不是 UI 美化。 ## 方案设计:三层校验缺一不可 ## 为什么要三层 - 服务端校验是底线,永远不能省。攻击者构造 POST 请求绕过前端校验是基本操作。 - 前端 JS 校验是体验。用户填错某个字段时不应该等提交后才告知,要在 blur(失去焦点)时立刻红框提示。 - HTML5 校验是兜底。input type=email、required 这种属性让浏览器原生支持基础校验,无需 JS。 三层任一缺失都有问题。只做服务端:用户体验差。只做前端:黑产秒绕过。只做 HTML5:不灵活、错误提示不可定制。 ## 服务端校验:plus/diy.php 改造 ## 原文方案的强化版 编辑 plus/diy.php,在第 40 行附近找到: $dede_fields = empty($dede_fields) ? '' : trim($dede_fields); 下面插入完整的必填校验逻辑: /* ===== DIY 表单必填项校验 START ===== */ if (!empty($required)) { $required_fields = array_filter(array_map('trim', explode(',', $required))); $missing_fields = []; $field_labels = []; /* 取字段中文标签(如果传了 required_labels 参数) */ if (!empty($required_labels)) { $labels_arr = array_filter(array_map('trim', explode(',', $required_labels))); $field_labels = array_combine($required_fields, array_pad($labels_arr, count($required_fields), '')); } foreach ($required_fields as $field) { /* 防止变量名注入:限定字符集 */ if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,30}$/', $field)) { continue; } $value = isset($GLOBALS[$field]) ? $GLOBALS[$field] : ''; if (is_string($value)) $value = trim($value); if ($value === '' || $value === null || (is_array($value) && empty($value))) { $label = !empty($field_labels[$field]) ? $field_labels[$field] : $field; $missing_fields[] = $label; } } if (!empty($missing_fields)) { $msg = '以下字段为必填,请补充:' . implode('、', $missing_fields); ShowMsg($msg, '-1'); exit(); } } /* ===== DIY 表单必填项校验 END ===== */ ## 相比原方案的改进 - 统一处理多字段:原方案在 explode 之后用嵌套 if-else 处理“单字段 vs 多字段”,本版本用 array_filter 统一。 - 字段名格式校验:限定 required 参数只接受字母开头 + 字母数字下划线的合法变量名,防止攻击者传 required=";rm -rf /;" 这种注入。 - 友好错误提示:通过 required_labels 参数传入字段中文名(如“姓名,邮箱”),提示信息从“字段 name 不能为空”变成“以下字段为必填:姓名、邮箱”。 - 批量错误提示:用户一次填错多个字段时一次性显示所有缺失项,不是改一个再发现下一个错。 - 处理多种空值:trim 之后的空字符串、null、空数组都判定为“未填”。 ## 表单端的传参 表单 HTML 模板里,在 form 内加两个隐藏字段:
...其它字段...
required 字段名与 required_labels 中文名按相同顺序对齐。 ## 前端 JS 即时校验 ## JS 实现 把以下 JS 加到模板末尾或单独 .js 文件: ## 关键设计点 - 不依赖 jQuery:原文方案用了 jQuery,但 DedeCMS 默认不引入。纯原生 JS 减少依赖。 - 渐进式提示:blur 时校验该字段,input 时清除错误。用户改错过程中体验流畅。 - 提交时全量校验:防止用户从未 focus 过某些字段导致 blur 没触发的情况。 - 正则按字段名启发:name 含 email 的自动套邮箱正则,含 phone/mobile 的套手机号正则。无需手动配置每个字段的格式。 ## HTML5 原生校验兜底 给关键字段加 HTML5 属性: 浏览器看到 required 属性,提交时如果对应字段为空会自动弹出原生提示阻止表单发送。pattern 正则做格式校验。这一层不需要 JS。 缺点:原生提示样式不可定制(不同浏览器风格不同),错误信息也是浏览器自己的话术。生产环境一般用 JS 接管校验提示,但保留 HTML5 属性作为兜底。 ## 验证码防机器人 ## 为什么需要验证码 必填项校验只防“空表单”,挡不住“填了垃圾内容的表单”。机器人能填上 abc@xxx.com、13800000000 这种格式合法但虚假的数据,绕过所有上面的校验。验证码是防自动化提交的最后一道防线。 ## 方案 A:DedeCMS 自带验证码 diy.php 在表单提交时自动校验 vdcode 字段(如果模板里有的话)。模板里加: plus/diy.php 默认会校验 vdcode,无需额外代码。但 DedeCMS 自带验证码只是 4 位简单字符,OCR 识别率高(自动机器人能识别 90%+)。 ## 方案 B:Google reCAPTCHA 更可靠的选择是接入 reCAPTCHA v3(无感验证): plus/diy.php 校验时调 Google API: $token = $_POST['g-recaptcha-token']; $secret = 'YOUR_SECRET_KEY'; $verify = file_get_contents('https://www.google.com/recaptcha/api/siteverify?secret=' . $secret . '&response=' . $token); $data = json_decode($verify, true); if (empty($data['success']) || $data['score'] < 0.5) { ShowMsg('系统检测到异常请求,请稍后重试', '-1'); exit(); } v3 评分越高越像真人,0.5 是常见阈值。 但 Google reCAPTCHA 在中国大陆访问受限,国内用户体验差。备选方案:geetest(极验)、hCaptcha、aliyun 行为验证。 ## CSRF 防护 ## 问题描述 攻击者在另一个网站放一段代码:
用户访问攻击者网站时浏览器会自动带上 your-site.com 的 cookie 提交表单,导致虚假提交。 ## 双 token 防护 表单页加载时生成 token 写入 session,提交时校验: // plus/diy.php 在表单页输出前 session_start(); if (empty($_SESSION['diy_csrf'])) { $_SESSION['diy_csrf'] = bin2hex(random_bytes(16)); } $csrf_token = $_SESSION['diy_csrf']; // 表单 HTML echo ''; // 提交校验 if (empty($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['diy_csrf']) { ShowMsg('表单失效,请刷新后重试', '-1'); exit(); } unset($_SESSION['diy_csrf']); // 一次性 token ## SameSite Cookie 另一层防护:让 PHPSESSID cookie 设 SameSite=Lax 或 Strict,浏览器不会在跨站请求时自动带 cookie。在 PHP 入口加: session_set_cookie_params([ 'samesite' => 'Lax', 'secure' => true, 'httponly' => true, ]); session_start(); ## 常见故障 ## 故障 1:必填校验不生效 三个排查点:required 隐藏字段是否正确传到了 plus/diy.php;diy.php 改的代码位置是否在 dopost 分支之前;浏览器禁用了 JS 但服务端校验代码也没改对。 ## 故障 2:错误提示中文乱码 diy.php 文件编码与模板编码不一致。统一改成 UTF-8 无 BOM。Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 转换:编码 - 转为 UTF-8(无 BOM)。 ## 故障 3:必填字段填了仍提示空 多数是 trim 后空字符串。检查 input 是否有自动填充的不可见字符。或者 required 字段名拼写错(区分大小写)。 ## 故障 4:reCAPTCHA 验证失败 常见:site key 与 secret key 配错(一个是前端用,一个是后端用,互不兼容);服务器到 google.com 网络不通;token 过期(超过 2 分钟)。 ## 故障 5:CSRF token 校验通不过 session 没启动(session_start 没在最开头调用);token 在多标签页打开时被覆盖(每打开一次表单就生成新 token,老 token 失效)。建议改成“token 在 session 里存数组,校验后 unset 单条不影响其它”。 ## 故障 6:HTML5 required 在 iOS Safari 上不弹提示 iOS Safari 对 required 属性提示样式弱(仅红色边框,无文字)。如果你需要明显提示,必须叠加 JS 校验。 ## 常见问题解答 ## 必填项配置在表单 HTML 里好还是在数据库里好? HTML 隐藏字段(本文方案)维护成本低,每个表单独立配置。数据库存储更灵活但需要后台管理界面。中小项目用 HTML 即可。 ## 能否给文件上传字段做必填? 能。HTML:。服务端校验:if (empty($_FILES['photo']) || $_FILES['photo']['error'] !== 0) { ShowMsg('请上传照片', '-1'); exit(); }。 ## 多选 checkbox 怎么校验必填? checkbox 至少选一个。HTML 里 required 只对单个 checkbox 生效。多选必填要 JS 写:if (!form.querySelectorAll('input[name="hobbies[]"]:checked').length) { ... }。 ## 必填校验影响搜索引擎抓取吗? 不影响。校验只在提交时生效,搜索引擎只 GET 不 POST。但表单提示文字(“* 必填”)会被抓到首页 description,注意控制不要污染。 ## 能否做条件必填(A 字段填了 B 字段才必填)? 能。JS 监听 A 字段变化,A 有值时给 B 加 required 属性,A 空时移除。服务端校验也按这个条件。 ## 表单提交后页面跳转白屏? 多数是 ShowMsg 函数路径错误(dialog 路径找不到)。检查 plus/diy.php 顶部的 require 路径是否被改动过。 ## 校验信息能否多语言? 能。把所有提示文字抽到 languages/zh_cn/diy.php,国际化时分别提供 en、ja 等版本。 ## 能否限制同一 IP 频繁提交? 能。在 plus/diy.php 加 IP 频率限制:$key = 'diy_submit_' . md5($_SERVER['REMOTE_ADDR']); $count = (int)$cache->get($key); if ($count > 5) exit('提交过于频繁'); $cache->set($key, $count + 1, 60);。需要 Redis 或 Memcached 做后端。 ## 验证码刷一下还是错? session 路径问题或者验证码图与提交是不同 session 上下文(cookie 域名不一致)。检查 PHPSESSID cookie 是否在两次请求间保持一致。 ## 表单提交后能否邮件通知运营? 能。在 plus/diy.php 校验通过后调 mail 函数:mail('admin@example.com', '新表单提交', '内容...');。生产环境用 PHPMailer 通过 SMTP 发更可靠。 ## 权威参考资料 ## DedeCMS留言板SQL注入1行addslashes修复实战 - URL:https://zhangwenbao.com/dedecms-message-board-edit-inc-php-injection-vulnerability-repair-method.html - 分类:织梦CMS教程 - 发布:2018-07-16 | 更新:2026-05-16 - 摘要:DedeCMS留言板edit.inc.php的msg参数未做转义存在SQL注入。本文给出一行addslashes的最小补丁、grep与sqlmap验证、Nginx拦截规则与fail2ban封禁配置,并附完整应急响应案例与/plus目录加固清单。 - 关键词:织梦漏洞,织梦SQL注入,织梦留言板,DedeCMS,DedeCMS安全 > **TLDR**:摘要:DedeCMS留言板的edit.inc.php对msg参数没做转义,存在SQL注入。本文给出一行addslashes的最小补丁,用grep定位和sqlmap验证,再配Nginx拦截规则与fail2ban封禁,并附一个完整的应急响应案例和plus目录的加固清单,帮你把这个老洞快速堵上。 > 摘要:DedeCMS留言板的edit.inc.php对msg参数没做转义,存在SQL注入。本文给出一行addslashes的最小补丁,用grep定位和sqlmap验证,再配Nginx拦截规则与fail2ban封禁,并附一个完整的应急响应案例和plus目录的加固清单,帮你把这个老洞快速堵上。 保哥这些年帮客户做织梦站点维护,遇到最多的就是/plus/目录下那一堆历史悠久的入口文件留下的SQL注入 (https://zhangwenbao.com/dedecms-membership-center-pm-php-injection-vulnerability-repair-method.html)漏洞。edit.inc.php是其中一个老熟人,它隶属于留言板模块(guestbook),负责处理留言的修改和回复操作。早期版本对用户提交的msg参数没有做严格的转义,攻击者可以构造特殊的payload让数据库执行额外的SQL语句,轻则改库数据,重则脱裤拖站。 这篇笔记把我自己处理这个漏洞的完整流程整理出来,包括复现思路、补丁原理、改动位置、回归验证、以及后续的加固建议。我尽量避免空话,直接贴改动点和我自己跑过的命令,方便同样在维护老站的朋友照着做。 ## 一、漏洞背景与受影响范围 这个洞最早是2017年前后被白帽提交到漏洞平台的,影响DedeCMS V5.7 SP1及之前的多个分支版本。问题文件路径固定在/plus/guestbook/edit.inc.php,触发条件是:网站启用了留言板功能(默认大多数站点都启用),且攻击者可以访问留言板提交入口。 我整理出来的判断标准很简单: - 服务器目录里能找到/plus/guestbook.php与/plus/guestbook/edit.inc.php这两个文件 - 没有上过官方的安全补丁,或者用的是被二次开发改过的留言板模块 - 后台“系统”-“系统基本参数”里留言板相关项处于开启状态 如果这三条都命中,那么这个洞就是实打实存在的,必须当天处理掉。我自己接手过一台被植入webshell的站点,回溯日志后定位到的就是这个入口被反复探测,所以不要抱侥幸心理。 我维护的另一个客户站点(教育培训行业)启用了留言板做客户咨询入口,2019年被同行业竞争对手 (https://zhangwenbao.com/competitor-outranking-seo-analysis-strategy.html)雇人做了渗透,进入入口就是这个edit.inc.php。事后客户反思:业务上根本没人用留言板,但因为织梦默认开启,又没有专门关掉,结果成了攻击面。 ## 二、漏洞成因的代码级分析 打开/plus/guestbook/edit.inc.php,定位到处理UPDATE语句的那段代码,大约在第55、56行附近,原始写法是这样的: $dsql->ExecuteNoneQuery("UPDATE `#@__guestbook` SET `msg`='$msg', `posttime`='".time()."' WHERE id='$id' "); ShowMsg("成功更改或回复一条留言!", $GUEST_BOOK_POS);这段代码的问题非常典型:$msg变量直接拼进SQL字符串,没有经过addslashes或参数化处理。织梦自己有一套GetCkVdValue、HtmlReplace之类的过滤函数,但这里偏偏没用。当$msg包含单引号时,整条SQL的语义就被截断,后面任意拼接的payload都会被数据库解释执行。 我做过一次内部演示,构造一个最简单的payload,比如把msg设置成xx'闭合后接子查询语句,就能把当前数据库连接用户名灌到ip字段里。如果换成UPDATE嵌套或者通过盲注读mysql.user表,危害就更直接了。 更危险的是,织梦的留言板编辑接口允许通过POST直接调用,不需要任何额外的token校验或CSRF防护。这意味着攻击者写一个简单的Python脚本,几分钟内就能爆破式打几百个站点。我曾经在Google搜inurl:plus/guestbook,搜到的结果里有相当一部分是没修过这个洞的旧站,全部公开可达。 ## 三、我使用的临时修复补丁 官方补丁迟迟未出的那段时间,我和团队用的是最小改动的临时方案:在UPDATE执行前对$msg调用addslashes。改完之后这两行变成: $msg = addslashes($msg); $dsql->ExecuteNoneQuery("UPDATE `#@__guestbook` SET `msg`='$msg', `posttime`='".time()."' WHERE id='$id' "); ShowMsg("成功更改或回复一条留言!", $GUEST_BOOK_POS);注意我故意保留了#@__guestbook这个表名前缀占位符,而不是写死dede_guestbook。原因是织梦允许通过config.cache.inc.php自定义表前缀,写死表名会让换前缀的站点直接报错。这是我踩过的坑,特别提一下。 保存文件之后,记得检查文件编码。织梦默认是GBK,如果用VS Code打开后顺手保存成了UTF-8,前台可能出现乱码或者干脆白屏。我现在的习惯是:在本地用Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html)打开,确认编码为ANSI(GBK),改完直接覆盖上传。也可以用iconv命令做一次显式转码:iconv -f UTF-8 -t GBK edit.inc.php -o edit.inc.php.gbk 然后再覆盖。 如果你用的是FTP上传,注意有些FTP客户端默认按ASCII模式传输,会破坏中文注释。建议切换到二进制模式(FileZilla里"传输"-"传输类型"选"二进制")再上传。 ## 四、验证补丁是否生效的几种方法 光改完代码还不够,我会跑一遍下面这套验证流程: ## 黑盒验证 用curl模拟一次正常的留言修改请求,把msg字段里塞一个单引号,看返回是否是ShowMsg的成功提示,以及数据库里这条留言是否原样保存了带引号的内容: curl -X POST 'https://example.com/plus/guestbook/edit.inc.php' \ --data "action=editmsg&id=1&msg=test%27single&validate=xxxx"如果数据库里的msg字段值变成test\'single(被转义成功),说明补丁生效。如果直接报SQL语法错误,说明补丁没加上去或者位置加错了。 ## 白盒检查 直接grep一下addslashes是否出现在了正确位置: grep -n "addslashes" /www/wwwroot/example.com/plus/guestbook/edit.inc.php应该能看到$msg = addslashes($msg);这一行就在UPDATE语句之前。如果grep结果为空,说明文件没改成功,需要重新上传一次。 ## 日志回溯 我习惯把nginx的access.log用awk过滤一下,看最近七天有没有针对edit.inc.php的高频请求: awk '$7 ~ /\/plus\/guestbook\/edit.inc.php/ {print $1, $4, $7}' access.log | sort | uniq -c | sort -rn | head -20如果发现同一个IP在短时间内反复请求这个文件,几乎可以确定是有自动化扫描器在打。这种情况下除了打补丁,还得在WAF或者nginx层面把这个IP封掉。 我维护客户站点时,会再加一步:用sqlmap跑一次自动化注入扫描,确认补丁后扫描不出injectable。完整命令是sqlmap -u "目标URL" --data "action=editmsg&id=1&msg=test" --batch --level=3 --risk=2。如果sqlmap报告"all tested parameters do not appear to be injectable",才算修干净。 ## 五、长期加固建议 临时补丁只是把这一个洞堵上,织梦的/plus/目录下还有不少类似设计缺陷的文件。我维护客户站点时一般会做以下几件事: 第一,把不用到的/plus/文件直接删掉或者重命名。比如recommend.php、search.php、download.php、guestbook.php这些如果业务上用不到,留着就是给攻击面送人头。我个人的清理脚本会把整个/plus/目录权限改成0750,再单独把业务用到的几个文件改回0755,这样攻击者即使知道文件路径也无法读取。 第二,在nginx层面对/plus/目录做referer和user-agent限制,至少能挡掉相当一部分自动化扫描: location ^~ /plus/ { if ($http_user_agent ~* (sqlmap|nikto|nmap|masscan|acunetix|wpscan)) { return 444; } if ($request_method !~ ^(GET|POST)$) { return 444; } }第三,给数据库账号做权限收敛。织梦默认用一个权限很高的账号连数据库,我会专门为站点建一个只对当前库有SELECT/INSERT/UPDATE/DELETE权限的账号,这样即便SQL注入被打穿,攻击者也没法LOAD_FILE或写webshell。具体MySQL命令是CREATE USER + GRANT SELECT,INSERT,UPDATE,DELETE ON dbname.* TO username。 第四,开启慢查询日志和general_log一段时间,观察是否有异常SQL。这一步对老站点尤其重要,能把潜伏的攻击行为揪出来。general_log非常占磁盘,建议只开几个小时做采样,不要长期开。 第五,认真考虑迁移。织梦DedeCMS已经停止官方维护多年,新出的高危漏洞基本都靠社区打补丁。我自己的客户站点这两年陆续迁到了Typecho或者静态化方案,长远看比一直打补丁省心得多。Typecho社区活跃、安全响应快、内容模型简单,对中小站点来说是个不错的替代品。 ## 六、配套的fail2ban监控规则 修完代码之后,建议再加一道操作系统级的防护。我用fail2ban做这个,规则文件保存在/etc/fail2ban/filter.d/dedecms-guestbook.conf: [Definition] failregex = ^.*"(GET|POST) /plus/guestbook/edit\.inc\.php.*"(?!200) ignoreregex =然后在/etc/fail2ban/jail.local里启用这个规则: [dedecms-guestbook] enabled = true filter = dedecms-guestbook logpath = /www/wwwlogs/example.com.log maxretry = 5 findtime = 600 bantime = 86400意思是10分钟内同一个IP对edit.inc.php发起5次非200响应的请求,就把这个IP封禁24小时。我在客户站点上跑这条规则两年多,每月平均封禁300+个扫描IP,没有一次误封。 ## 七、我自己处理过的一次完整案例 2024年初,有个客户的旧站被挂了博彩黑链。我接手之后的处理时间线大致是: - 第一小时:拉下整站源码和数据库,diff官方原版找出被改动的文件,定位到header.php被注入了base64编码的JS - 第二小时:检查access.log,发现/plus/guestbook/edit.inc.php在三天前被反复POST,参数里带有union select关键字 - 第三小时:确认管理员密码哈希被改动过,回溯到攻击者通过SQL注入读出旧哈希后撞库的可能性 - 第四小时:修复edit.inc.php的注入点,重置所有后台账号密码,删除/plus/下未使用的文件,配置fail2ban监控nginx日志 - 第五小时:清理黑链,验证站点功能,把整套备份做了一份冷拷贝 这个案例让我意识到,单点修补不够,必须把日志审计、权限收敛、备份策略当成一套组合拳来打。我后来给所有客户站点都做了类似的组合:补丁 + WAF + fail2ban + 数据库降权 + 定期备份,这五件事一起做才算把一个站点的安全基线建起来。 ## 八、与其他漏洞修复的对比经验 DedeCMS的漏洞修复套路高度相似,掌握一种思路就能举一反三。我把过去5年修过的几个典型漏洞做个对照: - album_add.php的mtypesid整型注入:用intval解决 - edit.inc.php的msg字符串注入:用addslashes解决(本文主题) - search.php的keyword搜索框:用htmlspecialchars + 关键词黑名单解决 - recommend.php的aid数组注入:用array_map filter_var解决 - mytag_js.php的变量覆盖:禁用register_globals并限制include路径 - feedback.php的feedbacktype枚举:用in_array白名单校验 这套修复思路统称为"输入侧严格类型转换 + 输出侧严格转义",是PHP安全开发的基本功。织梦因为成型于早期PHP4/PHP5时代,大量代码沿用了字符串拼接的写法,用现代视角看就是漏洞密集区。 我建议接手织梦站点的运维团队,把这6个常见漏洞做成内部知识库,新人入职第一周必看。我自己团队里规定,所有交付的织梦项目都必须经过这6个漏洞的人工复测,验证通过才算交付完成。这个流程让我们过去3年零安全事件。 ## 九、与官方补丁的差异说明 DedeCMS官方在2018年发布过针对guestbook模块的安全更新,但官方版本的修改思路与我用的临时补丁略有差异。官方走的是改造SQL执行函数:把所有相关的数据库操作改成参数化查询调用,避免字符串拼接。我用的addslashes临时方案虽然简单,但只能保护单引号注入,对于编码绕过等高级技巧理论上还有空间。 如果你的站点能接受较大改动,建议直接打官方补丁;如果你需要快速止血,我这套addslashes方案足够了。两者并不冲突,可以先临时补一行保命,再排期升级到官方补丁。 ## 十、总结与下一步行动清单 edit.inc.php的SQL注入是织梦留言板模块的典型漏洞,修复成本极低(一行addslashes),但实际事故中却经常成为整站被攻陷的入口。这种"小洞大祸"的情形在DedeCMS这类老旧CMS里很常见。 如果你刚读完这篇笔记,建议立即按下面这个清单行动: - 立即检查/plus/guestbook/edit.inc.php是否存在addslashes调用,没有的话当天补上 - 用sqlmap或手工curl验证补丁生效 - 检查最近30天access.log里是否有针对该路径的高频访问,发现可疑IP立即封禁 - 检查dede_admin、dede_guestbook表是否有异常数据 - 如果发现异常,立即重置所有后台账号密码,扫描webshell - 长期:上WAF + fail2ban + 数据库降权 + 季度审计 这套流程做完,edit.inc.php这条路就算彻底堵死了。但织梦还有几十个类似的入口,需要按同样的思路一个一个梳理。安全是个长期工程,没有一劳永逸的方案,只有持续的关注和迭代。 ## 常见问题解答 ## 我把addslashes加上之后留言里的特殊字符会不会显示出错 不会。addslashes只是在写入数据库前把单引号、双引号、反斜杠转义。读取展示时织梦自己会调用stripslashes还原,所以前台展示效果一致。如果你发现展示出现了多余的反斜杠,多半是另外某个地方也加了一次转义,要追一下展示模板。具体追踪方法是grep -rn stripslashes 整个项目目录,确认是不是有重复转义的地方。 ## 除了edit.inc.php,plus目录下还有哪些文件需要重点关注 根据我自己的维护经验,至少这几个值得审计:recommend.php(早期SQL注入)、search.php(typeArr参数注入)、download.php(任意文件下载)、mytag_js.php(变量覆盖)、guestbook.php(XSS与CSRF)。最稳妥的做法是把不用的全部移除。具体可以用find /plus -name "*.php" 列出所有文件,业务负责人逐个确认是否需要保留,不需要的直接mv到备份目录。 ## 补丁打完之后还要不要升级整个DedeCMS 要看你的版本和二次开发情况。如果你的站点没改过核心,建议直接走官方最新版升级流程;如果改动很多,至少要把/include/、/dede/、/plus/三个核心目录跟官方版做diff,逐个文件对比有没有补丁缺失。我个人更倾向于把内容迁移到Typecho或者静态站,长期成本低很多。Typecho迁移工具我之前写过一篇详细的方案,整套流程跑下来一个中等规模站点(5000-10000篇文章)大约需要1天工时。 ## 怎么判断站点已经被这个漏洞攻击过 三个信号:第一,access.log里/plus/guestbook/edit.inc.php被高频POST且带异常参数;第二,dede_guestbook表里出现SQL关键字(union、select、information_schema);第三,后台管理员表dede_admin的密码哈希、邮箱被异动。任何一条命中都说明站点可能已经被打穿,要走完整的应急响应流程。建议同时检查/uploads/、/data/和/templets/目录,看是否有近期被植入的可疑.php文件。 ## 用WAF能不能完全替代代码层修复 不能。WAF是边界防护,能挡掉绝大多数已知payload,但攻击者总能找到新的绕过手法(编码、分块、CRLF注入等)。代码层修复是最根本的,WAF是兜底。我个人的建议是双管齐下:代码层intval/addslashes修复 + WAF规则拦截,缺一不可。只用WAF的话,一旦WAF规则失效或被绕过,站点直接裸奔;只修代码不上WAF的话,攻击者可以用大流量扫描压垮服务器。 ## 修复后能否把addslashes改成参数化查询 理论上可以,但织梦的DSQL类不支持原生PDO参数化绑定,需要重写一套DSQL包装层,工程量很大。性价比不如继续用addslashes临时方案。如果你愿意做这个改造,可以参考Typecho的Db封装,逻辑相对清晰,移植到织梦也行,但需要相当的PHP经验。这是个值得投入的改造但不是紧急事项,可以排期到下一个迭代。 ## fail2ban规则会不会误封正常用户 低概率会。我用了2年没遇到过一次。原因是规则只针对edit.inc.php这个特定路径,正常用户不会反复POST这个路径,触发条件是10分钟5次非200响应,门槛不低。如果你担心误封,可以把maxretry提高到10、findtime改成300,触发更宽松。也可以加一个白名单,把内部IP段加到/etc/fail2ban/jail.local的ignoreip里。 ## 打完补丁多久需要重新审计一次代码 我建议每季度审计一次,每年做一次完整的渗透测试。织梦因为停更,新爆出的漏洞需要靠社区跟进,错过几次更新就可能被攻击者利用。日常审计可以用RIPS、Codiad这类PHP源码审计工具做基础扫描,年度渗透测试找专业团队做更全面的安全评估。预算有限的话,至少每年自己用sqlmap和burp suite扫一遍核心入口。 ## 权威参考资料 ## 织梦media_add.php任意上传漏洞怎么加固?纵深防御实战 - URL:https://zhangwenbao.com/media_add-php-in-dedecms-has-the-method-of-restoring-arbitrary-uploading-files-in-background-files.html - 分类:织梦CMS教程 - 发布:2018-07-10 | 更新:2026-06-01 - 摘要:织梦后台的media_add.php存在任意文件上传漏洞,还能和CSRF联合利用。本文剖析攻击链、点出黑名单preg_match的五个绕过盲区,给出pathinfo白名单、finfo二次校验、hash重命名、Nginx与Apache禁uploads执行、inotify监控等七层纵深防御,附九个上传入口的统一处置。 - 关键词:织梦漏洞,Web安全,文件上传漏洞,DedeCMS加固,纵深防御 > **TLDR**:摘要:织梦后台的media_add.php存在任意文件上传漏洞,还能和CSRF联合利用。本文剖析这个洞的本质,点出网上流传修复版本的不足,给出pathinfo白名单、finfo二次校验、hash重命名、Nginx与Apache禁uploads执行等加固版,再加Web服务器层的纵深防御、处置后的全站排查清单和同类上传漏洞的统一处置流程。 > 摘要:织梦后台的media_add.php存在任意文件上传漏洞,还能和CSRF联合利用。本文剖析这个洞的本质,点出网上流传修复版本的不足,给出pathinfo白名单、finfo二次校验、hash重命名、Nginx与Apache禁uploads执行等加固版,再加Web服务器层的纵深防御、处置后的全站排查清单和同类上传漏洞的统一处置流程。 保哥前阵子在帮一个做机械加工的客户清后门,溯源到最后又一次落到了织梦那个老朋友——dede/media_add.php。这是织梦后台软件附件管理的入口,问题已经被披露超过八年,至今还有大量遗留站点没修。这篇笔记记录我这次完整的处置过程:从如何确认是这个洞被利用、到为什么网上流传的扩展名正则不够用、再到我自己加固后用到现在的版本,全部摊开讲。文章最后还把web服务器配置层、纵深防御策略、上线后的复盘清单、以及处理同类织梦上传漏洞的通用流程一次性整理出来,给还在维护织梦站的同行一个完整的应急参考。 ## 这个洞的本质是什么 dede/media_add.php是织梦后台用来上传"软件"类型附件的脚本,它的设计假定凡是能进到这一步的用户都已经登录后台,所以对文件名校验非常宽松。第69行附近的原始代码大概是 $fullfilename = $cfg_basedir.$filename,这里的$filename直接来自POST里的用户输入,没做任何扩展名检查就拼到服务器物理路径上,然后move_uploaded_file把临时文件搬过去。 问题来了——只要有一个能进后台的账号,无论是默认弱口令、还是通过前面那个 inc_archives_functions.php cookie泄漏 (https://zhangwenbao.com/dedecms-inc_archives_functions-php-cookies-sql.html) 拿到的越权token,攻击者都可以传一个shell.php,落地后直接通过web访问执行。整个攻击链条只需要3步——拿到一个能登录后台的会话、构造一个multipart/form-data的POST请求、把webshell文件名塞进去——完整执行不超过10秒。 更阴险的是,这个洞经常和CSRF配合打。攻击者根本不需要登录,只要诱导一个已登录的管理员点击一个伪造的页面(带自动提交的POST表单),表单就会以管理员身份把webshell传上去。这种利用链在2018到2021年的针对性攻击里非常常见,保哥那几年至少处理过二十多起。CSRF攻击的隐蔽性在于——管理员自己都不知道发生了什么,只觉得点了一个普通链接,几秒钟后页面跳走了,没有任何提示。直到一周后服务器开始被挂博彩暗链,回头查日志才发现那次点击就是入侵起点。 ## 客户站点的攻击痕迹 这次客户站点的入侵指标IOC非常清晰,按定位顺序列出: - uploads/soft/下出现了2024xxxx-shell.php,文件大小只有800字节左右,明显是一句话木马的特征。一句话木马通常控制在1KB以内,体积大了反而容易被D盾扫出来。 - nginx access.log里有几十条POST /dede/media_add.php的记录,但referer字段是空的——正常后台操作referer必然带着/dede/media_main.php,空referer是CSRF或者curl直接发包的特征。 - 数据库dede_uploads表里多了几条mediatype=3但title是乱码的记录,说明攻击者直接通过SQL注入或者表单字段绕过创建了元数据记录。 - 上传时间窗口和后续/uploads/soft/2024xxxx-shell.php?cmd=ls的访问完全对得上,时间间隔通常在30秒以内,这是自动化攻击脚本的典型特征。 - PHP-FPM的slow log里出现了对应时间点的执行记录,调用栈包含system或exec函数,说明webshell已经实际执行了系统命令。 - 服务器进程列表里曾经出现过短暂的nc监听或wget下载,说明攻击者尝试拉取后续payload建立持久化。 保哥的处置顺序还是老规矩:先快照取证、再止血、最后修复并复盘。取证阶段最关键的是用dd命令对系统盘做一份完整镜像,避免后续操作改变文件系统状态影响事后追责。这次客户站点已经走完取证,直接进到修复环节。 ## 网上流传的修复版本以及它的不足 搜索引擎里能找到的标准修复方案大致是这样:在第69行之前加一段preg_match检查,匹配到php、pl、cgi、asp、aspx、jsp、php5、php4、php3、shtm、shtml这些扩展名就拒绝。这个修复的思路是黑名单——把已知危险扩展名列出来,命中就拒。能挡住绝大多数自动化扫描器,但保哥从实战角度有几个不放心的地方。 第一个不放心点:黑名单永远是漏的。这条正则没有覆盖phtml、phar、pht、php7、phps、html配SSI、htaccess配AddType等等情况。攻击者只要换一个PHP解释器认得、但黑名单没列的扩展名,比如某些环境里把.pht或.phar也交给PHP处理,整个防御就被绕过。我自己在2022年就遇到过一次——客户用了某个标准黑名单修复,结果攻击者传了一个shell.phtml,绕过了正则但被PHP-FPM当作PHP文件解析,重新拿下整站。 第二个不放心点:正则末尾的边界处理 [^a-zA-Z0-9]+$ 看似严谨——意思是扩展名后面必须跟非字母数字才算命中——但这个写法对shell.php.这种Windows下会被自动去掉末尾点的文件名是有效的,对shell.php%00.jpg这种空字节截断也有效,但对Apache的mod_mime多扩展名解析(shell.php.x)反而失效,因为末尾不是危险扩展名结束。这种.php.x的写法在Apache老版本里如果x不是Apache已知扩展名,会回退到前一个扩展名.php来解析,等于完美绕过黑名单。 第三个不放心点:没有限制大小写绕过——其实正则带了i修饰符,这点OK。但没有限制空格、Tab、换行这种文件名里塞奇怪字符的攻击。Windows下shell.php (末尾两个空格)会被NTFS当作shell.php处理,但PHP的strpos比较时是带空格的字符串,正则匹配也会失效。这种边界字符的处理是黑名单方案永远无法穷尽的攻击向量。 第四个不放心点:黑名单方案没法防御后续可能新增的解析器。如果有一天服务器装了一个新的PHP扩展模块或者Wordpress插件,把某个新扩展名也交给PHP处理,黑名单根本不知道要更新。白名单方案则不会有这个问题——只允许列表里的扩展名,未知扩展名一律拒绝。这种"默认拒绝"思路在零信任安全模型里被反复强调,每一项允许都要明确声明,没声明的一律不许通过。 ## 保哥实际部署的加固版本 我给客户落地的版本采用白名单思路,因为软件附件这个场景本身就只应该允许压缩包和文档。具体加固代码放在dede/media_add.php第69行附近,原 $fullfilename 拼接之前。代码结构是先trim文件名去除前后空格、再用pathinfo取扩展名转小写、然后比对白名单数组、最后再做一层多扩展名和控制字符的二次防御。白名单严格限定为zip、rar、7z、gz、tar、pdf、doc、docx、xls、xlsx、ppt、pptx这12种类型,覆盖99%的合法软件附件场景。 这套加固有几个关键点:用pathinfo取扩展名而不是自己写正则,避免了边界bug;白名单严格限定为软件附件场景的合理类型;额外加一层多扩展名和控制字符的检测,防止shell.zip.php这种组合拳;最后对原始 $filename 也做了trim,避免前后空格绕过。检测多扩展名用的是 substr_count($_filename, '.') 大于1的判断——正常的软件附件文件名最多只有一个点(扩展名分隔点),如果有两个或更多点就直接拒绝,能挡住绝大多数双扩展名攻击。 更进一步的加固是在PHP函数层面增加一道防御——move_uploaded_file之后立即用finfo读取实际MIME类型,与扩展名声明的类型做交叉验证。比如声明扩展名是zip,但finfo返回的MIME是text/x-php,立即删除文件并拦截请求。这一层防御能挡住"在压缩包末尾追加PHP代码"的高级绕过技巧。具体实现是 $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmpFile); finfo_close($finfo); 然后判断$mime是否匹配预期。MIME校验的好处是它不依赖文件名,攻击者改不了文件真实内容的MIME标识。 对于文件名本身的安全处理,保哥还会在白名单通过后用一段hash重命名落地——把客户上传的原始文件名替换成 md5(uniqid().mt_rand()).$_ext 这种形式。这样做的好处是:哪怕扩展名校验有漏洞,攻击者也无法预测最终落地的文件名,没法通过web访问到自己上传的webshell。原始文件名作为title字段单独存到dede_uploads表里供后台展示,不参与磁盘路径拼接。这种"路径不可预测"是OWASP A05配置错误条目下专门提到的对抗手法。 ## Web服务器层的纵深防御 光靠PHP代码层是不够的,保哥强烈建议在web server配置层再加一道。不管你的站跑在nginx还是Apache,给上传目录禁用PHP执行是最简单粗暴也最有效的方案。 nginx配置示例如下,添加在站点server块内: location ~* ^/uploads/.*\.(php|phtml|phar|pht|php5|php7|jsp|asp|aspx|cgi|pl|py)$ { deny all; return 403; } 这条规则匹配uploads目录下所有以危险扩展名结尾的请求,直接返回403。注意 ~* 是不区分大小写,能挡住.PHP这种大写绕过。规则放在所有location之前,确保优先级最高。如果有多个server块(HTTPS、HTTP、CDN源站等),每一个都要加这条,不能漏。 Apache在uploads目录下放一个.htaccess文件: Require all denied RemoveHandler .php .phtml .phar 这层防御的好处是:哪怕PHP代码层因为新的绕过技巧失守,最后落地的webshell也根本无法被解析执行,攻击者拿到的只是一个静态文件。这是真正意义上的纵深防御。我自己处理过一个客户站,PHP代码层的白名单被绕过了(用了一个我们没列的新扩展名),但因为nginx配置层禁止了uploads目录的PHP执行,攻击最终失败,对方只是浪费了一个0day在我们站上。 如果你的站点托管在虚拟主机或者宝塔面板上,没有nginx或Apache的直接配置权限,可以通过面板的"伪静态 (https://zhangwenbao.com/discuz-portal-list-rewrite.html)"或"自定义规则"功能加上面这条规则。宝塔面板的网站设置里有"伪静态"标签,把上面的nginx规则粘贴进去保存即可生效。宝塔面板的"安全"标签下还有一个"目录权限"开关,可以一键关闭某个目录的PHP执行权限,效果跟上面那条location规则等价但更直观。 另一个常被忽略的纵深防御是PHP-FPM的open_basedir限制。在php-fpm的pool配置里加 php_admin_value[open_basedir] = /www/wwwroot/yoursite:/tmp,这样即使攻击者拿到webshell,也只能访问站点根目录和临时目录,无法读取/etc/passwd、/var/log这些系统敏感文件。这一层是最后的防线,能限制攻击者拿到shell后的横向移动能力。 ## 处置完成后的全站排查清单 修完这一个文件远远不是终点。保哥的常规收尾包括以下几件事,每一件都要做完才能算这次应急彻底关闭。 后台URL改名:把/dede/改成一段无规律字符串,比如/admin_8x9k2p/,同步改data/common.inc.php里的cfg_admin_dir。让自动化扫描器找不到入口。这一步能把后台被尝试爆破的次数降到几乎为零。改完后所有原来收藏/dede/入口的管理员要改用新地址访问。 后台二次验证:给改名后的后台目录加一层nginx basic auth或者IP白名单,运维同事固定IP访问。nginx basic auth配置示例:先用htpasswd生成密码文件,然后在location块里加 auth_basic "Restricted" 和 auth_basic_user_file /etc/nginx/.htpasswd。这样即使攻击者知道了新后台地址,也要先过一道basic auth才能访问到登录页。 uploads目录权限调整:设为755即可,禁用chmod 777这种偷懒做法。文件所有者设为www(或对应PHP-FPM运行用户),组设为www,目录权限755,文件权限644。这样即使有人在站点目录写了webshell,PHP也无法用chmod改这个文件的权限到可执行。 webshell全量排查:除了D盾这种自动化工具之外,再用 find . -name '*.php' -mtime -90 -ls 列出最近三个月修改过的PHP文件人工核对,自动化工具有时候识别不了变形得很厉害的样本。重点关注的可疑特征:文件大小异常小(小于2KB)但包含base64字符串、文件名是乱码或者纯数字、文件位于uploads或cache目录下、文件修改时间是凌晨2到5点之间。 日志归集:把nginx和PHP的日志往日志服务器或者ELK推一份,本地日志可能被攻击者擦除。具体做法是rsyslog配omfwd模块,或者直接用filebeat把日志发到远端Elasticsearch。日志保留至少90天,方便事后追溯。 定期备份核查:除了每天的全站备份,还要每周做一次"差异备份比对"——把本周备份和上周备份做diff,看是否有未授权的代码修改。这一步能在攻击发生7天内发现,避免长期潜伏。 建立监控告警:给关键文件配inotifywait监控——当dede目录、include目录、uploads目录有任何PHP文件被新增或修改时,立即发邮件或微信告警。这能让你在攻击发生几秒内就收到通知,比被动等用户反馈快得多。具体配置是 inotifywait -m -r -e create,modify,delete /www/wwwroot/site --format '%T %w%f %e' --timefmt '%F %T' 输出到日志,再用logrotate配合grep过滤PHP事件发邮件。 ## 同类织梦上传漏洞的统一处置流程 media_add.php这个洞处理完之后,应该顺手把整个织梦后台的上传通道都加固一遍,否则攻击者今天传不进media_add.php,明天可能从album_add.php进来。下面这套清单是保哥用了5年、覆盖织梦5.7 SP1到SP2的完整加固范围。 需要同步加固的关键文件清单:dede/file_manage_control.php(文件管理任意操作)、dede/album_add.php(图集附件)、dede/soft_add.php(软件主体上传) (https://zhangwenbao.com/dedecms-soft_add-php-has-sql-injection-vulnerability-repair-method.html)、dede/media_main.php(多媒体管理入口)、include/dialog/select_soft_post.php(编辑器附件选择)、include/dialog/select_images_post.php(编辑器图片选择)、include/uploadsafe.inc.php(上传公共入口) (https://zhangwenbao.com/there-is-a-repair-method-for-uploading-vulnerabilities-in-uploadsafe-inc-php-in-dedecms.html)、member/uploads_edit.php(会员中心附件编辑)、include/FCKeditor整个目录(如果启用了FCK编辑器)。 这九个文件配合一起加固,整个上传通道才算闭环。加固方法都是套用前面media_add.php的白名单思路——pathinfo取扩展名、in_array比对白名单、substr_count检测多扩展名、preg_match检测控制字符。每个文件的具体行号会因版本不同有差异,但加固模板是统一的,复制粘贴改文件路径就行。 对于会员中心的上传通道(member/目录下的几个文件),加固标准要更严——因为这个入口面向所有注册会员,不需要后台权限就能访问,攻击面比后台大十倍。会员中心的白名单建议只允许jpg、jpeg、png、gif、bmp这五种图片格式,PDF和压缩包都不要开放。 加固完成后强烈建议跑一次完整的渗透测试——用awvs或者burp对所有上传入口扫描一遍,确认没有漏掉的点。市面上有免费的awvs试用版可以扫一次,不需要采购正版。如果预算允许,找一家专业的安全公司做一次正式渗透测试,费用大约5000到10000元,能给出更详细的报告。 ## 事后复盘要回答的六个核心问题 每次应急处理完,保哥都会跟客户做一次结构化复盘,回答下面六个问题。这六个问题如果不能给出明确答案,说明这次事件还没有真正"结案",可能留有未发现的隐患。 攻击者是怎么进来的——要追溯到第一笔异常请求的时间点、来源IP、利用的具体漏洞。如果只能回答"反正是media_add.php被利用了"但说不清具体哪一次请求是入口,说明日志保留不全或者还有其他更早的入口没被发现。这种情况下要把日志倒查范围从默认的90天扩大到180天或更长,必要时调取CDN层和云服务商防火墙日志补全证据链。 攻击者拿到什么权限——是只能上传webshell在web用户权限下执行,还是已经通过本地提权拿到root。这个问题决定了清理的彻底程度——如果还在web用户权限,清掉webshell和加固代码就够了。如果已经root,那要重装系统才能彻底清干净。判定方法是看 /etc/passwd /etc/shadow 的修改时间、查 auth.log 里的su和sudo记录、看根目录下是否有可疑的反弹shell脚本。 数据是否泄漏——dede_admin表的密码hash是否被读取、用户表里的手机号邮箱是否被导走、订单表的支付信息是否被访问。如果有数据泄漏,按法律要求72小时内要向监管部门报告并通知用户。判定数据是否被读取要看MySQL的general_log或者binlog,没开启的话只能从nginx日志里的请求大小推测——如果某个时间点对某个表查询接口的响应包大小异常大,多半就是被批量导出了。 有没有横向扩散——同一个云服务商账号下的其他主机是否被波及、内网其他业务系统是否被访问、备份系统是否被加密。这一步常常被忽略,但很多大型勒索事件就是从一个看似不重要的小站点横向扩散到核心业务系统。判定方法是看其他主机的last登录记录、检查 .ssh/authorized_keys 是否被植入未知公钥、扫描内网开放端口看是否有异常监听。 攻击窗口期有多长——从首次入侵到完全清理用了多少天。这个数字越大,攻击者获得的横向扩散时间就越多,潜在影响面就越广。理想的窗口期是24小时以内,超过7天就算重大事件。窗口期长的常见原因是:监控告警没配、运维人手不足、定期巡检流于形式。复盘时要把这些根本原因写进改进计划,不能只修了漏洞就完事。 同类站是否还在受同样威胁——如果你管理的不止一个织梦站,要确认其他站是否也存在media_add.php漏洞、是否已被同一攻击者扫描过。这个排查能预防"修了一个站、其他站接着被打"的连环事件。具体做法是把这次的攻击者IP、UA特征、URL访问模式整理成IOC列表,到其他所有站的日志里搜一遍,命中就立即处置。 ## 常见问题解答 ## 业务确实需要让用户传PHP类的源码包怎么办 保哥的建议是让用户传zip或tar.gz格式,里面再装PHP文件。落地到服务器之后,源码包目录配合上面nginx那条规则,PHP永远不会被执行——它只是一个二进制文件躺在那儿,要看内容只能下载。这样既满足业务又不破坏安全边界。如果要让用户在线预览源码内容,可以解压到一个独立目录后用highlight_file函数渲染成HTML显示,渲染过程不会执行任何代码。绝对不要直接include用户上传的文件,那是另一种文件包含漏洞。 ## 白名单里要不要加图片格式 这个文件media_add.php本身是织梦定义的"软件附件"入口,图片应该走media_add.php的图片分支或者专门的图集模块。混着让一个入口什么都收,反而增加攻击面。保哥的建议是不同mediatype走不同白名单,代码里用switch分一下就好。具体来说mediatype=1是图片走图片白名单(jpg/png/gif/webp)、mediatype=2是Flash走swf白名单(已经基本不用)、mediatype=3是软件走压缩包和文档白名单。每种类型独立维护白名单,互不干扰。 ## 改完之后后台上传一直失败提示附件类型不在允许列表内但文件明明是zip的 保哥踩过这个坑。pathinfo在某些PHP版本下对.tar.gz这种双扩展只会返回gz,是OK的。但如果文件名带中文又是GBK编码的旧站点,pathinfo在某些环境会取不到扩展名。解决办法是上传前用mb_convert_encoding把文件名统一成UTF-8再处理。具体代码是 $_filename = mb_convert_encoding(trim($filename), 'UTF-8', 'GBK,UTF-8'); 然后再走pathinfo取扩展名。这样就能正确处理GBK编码的中文文件名。如果你的织梦站本身是GBK编码,整个站都没法直接走UTF-8,那就用iconv转码后处理,处理完再转回GBK写库。 ## 还有哪些织梦后台脚本和这个洞类似必须同步加固 保哥的清单:dede/file_manage_control.php文件管理任意操作、dede/album_add.php图集附件、dede/soft_add.php软件主体上传、include/dialog/select_soft_post.php编辑器附件选择、include/uploadsafe.inc.php上传公共入口。这五个文件配合一起加固,整个上传通道才算闭环。如果启用了member模块,还要加固member/uploads_edit.php和member/album_add.php这两个会员侧入口。FCKeditor如果启用了也要加固include/FCKeditor/editor/filemanager/connectors/php/connector.php这个老牌漏洞文件。 ## nginx禁止uploads执行PHP后织梦自带的图片处理脚本会不会受影响 不会。织梦的图片处理脚本(比如dede/dialog/select_images.php、include/uploadsafe.inc.php)都是放在dede或include目录下,不在uploads目录里。uploads目录只存放最终落地的用户上传文件,本来就不应该有任何PHP代码。如果你发现uploads目录下有正常的PHP文件被禁止访问,那本身就是异常情况,要排查这些PHP是怎么进去的。一个常见误判是某些织梦插件会在uploads下创建.htaccess之外的配置文件,那些不是PHP文件,不会被这条规则影响。 ## 白名单加固之后会被某些自动化扫描工具误报为漏洞吗 会的概率很低。常见的扫描工具(awvs、appscan、xray)扫描上传漏洞的判定逻辑是"尝试上传一个PHP文件并看是否能在web上访问到"。我们的白名单加固直接拒绝了PHP上传,所以扫描工具会判定为"不存在上传漏洞"。如果你用的是织梦专用扫描工具(dedebbs、织梦漏洞扫描器),它们可能会通过版本号或者文件特征字符串识别"这个站是织梦",然后报"该版本存在已知漏洞"。这种误报跟实际防御效果无关——你已经修了,扫描工具只是不知道你修了。可以忽略这种基于版本号的告警。 ## 加固后能不能彻底放弃升级织梦改用其他CMS 保哥的真实建议是——能换就换。织梦从2017年官方停止维护后,所有新发现的漏洞都没有官方补丁,只能靠社区或者你自己手动修。每修一个洞都是临时的,下一个洞可能在三个月后又出来。如果你的站是企业站、内容更新不频繁,迁移到WordPress或Typecho是更可持续的选择。如果是大站、有大量自定义二次开发,迁移成本可能比持续加固还高,那就只能继续走"白名单加固+纵深防御+监控告警"这三件套。无论选哪条路,都不要继续用默认配置裸跑,那是给所有自动化攻击者送大礼。 ## 权威参考资料 ## 织梦uploadsafe.inc.php上传漏洞怎么加固?五项校验实战 - URL:https://zhangwenbao.com/there-is-a-repair-method-for-uploading-vulnerabilities-in-uploadsafe-inc-php-in-dedecms.html - 分类:织梦CMS教程 - 发布:2018-07-09 | 更新:2026-06-01 - 摘要:织梦的文件上传安全卡在uploadsafe.inc.php,原版只看扩展名、不验内容、解析也不严。本文给出五层防御的完整PHP代码:MIME白名单与finfo探测、getimagesize强类型校验、按点拆分查双扩展名、Nginx与Apache禁uploads解析、可疑内容扫描,附六个真实入侵案例。 - 关键词:织梦漏洞,DedeCMS安全,上传漏洞,uploadsafe,文件上传防护 > **TLDR**:摘要:织梦的文件上传安全卡在uploadsafe.inc.php,原版只看扩展名、不验内容、解析也不严。本文讲清它在上传链中的位置,给出五层防御——MIME与扩展名一致性校验、getimagesize二次验证图片真伪、文件名规范化强制重命名、Nginx与Apache禁uploads解析、可疑内容特征扫描,再附四组测试验证、全站审计判断是否已被入侵和上传漏洞历史回顾。 > 摘要:织梦的文件上传安全卡在uploadsafe.inc.php,原版只看扩展名、不验内容、解析也不严。本文讲清它在上传链中的位置,给出五层防御——MIME与扩展名一致性校验、getimagesize二次验证图片真伪、文件名规范化强制重命名、Nginx与Apache禁uploads解析、可疑内容特征扫描,再附四组测试验证、全站审计判断是否已被入侵和上传漏洞历史回顾。 DedeCMS的uploadsafe.inc.php是负责文件上传安全校验的核心文件。2010年首次披露漏洞之后官方修过几次,但2015、2018、2022年又陆续被研究者发现新的绕过方式。如果你还在维护DedeCMS 5.7 SP2或更早版本的站点,本文给出一套防御性加固方案——基于2026年公开披露过的所有变体漏洞做综合防护,把单一图片MIME校验扩展成多层防御。保哥过去4年处理过6个被这类漏洞利用过的DedeCMS站点,本文记录的是真实修复路径,不涉及攻击细节。同一批织梦后台上传漏洞还有 media_add.php任意上传漏洞的白名单加固方案 (https://zhangwenbao.com/media_add-php-in-dedecms-has-the-method-of-restoring-arbitrary-uploading-files-in-background-files.html) 可以一起部署,覆盖面更广。 覆盖范围:DedeCMS 5.7 SP2 / 5.7 UTF8 SP1 / 5.8社区维护版。前缀按官方默认dede_,目录路径以 /www/wwwroot/yoursite.com 为示例。修复前必须备份完整源代码和数据库。 ## uploadsafe.inc.php在文件上传链中的位置 先理清楚DedeCMS文件上传的完整流程,才能知道为什么修这个文件能起到加固作用: - 用户提交文件上传请求(前端表单或者编辑器调用)。 - 请求进入DedeCMS的入口PHP文件(如album_add.php、image_add.php、media_main.php等多个上传入口)。 - 这些入口都会require_once include/uploadsafe.inc.php做文件名和类型的安全过滤。 - uploadsafe.inc.php通过后才走真正的文件保存逻辑(move_uploaded_file)到uploads目录。 uploadsafe.inc.php是上传链的安全闸口。它做得好,所有上传入口都受益;它做得不严,攻击者可以从任一上传入口绕进来。所以加固这个文件是覆盖面最广的防御措施。 ## 原版逻辑的三个明显缺陷 DedeCMS 5.7 SP2原版的include/uploadsafe.inc.php大致逻辑(已脱敏简化): - 读取上传文件的 $_FILES 数组。 - 检查文件扩展名是否在白名单(jpg/png/gif/bmp/zip/rar/doc等)。 - 检查文件大小是否超限。 - 检查文件名是否含恶意字符。 - 通过则放行。 这套逻辑的核心问题: - 仅看扩展名不看MIME:攻击者可以把PHP文件改名为evil.jpg.php或evil.php.jpg绕过简单扩展名检测。客户端声明的 $_FILES['type'] 完全可以伪造,不能作为可信源。 - 没有内容验证:即使扩展名是.jpg,文件内容可能是PHP代码——服务器解析时如果.jpg后缀触发了PHP执行(如Apache的AddHandler配置错误),漏洞被利用。GIF89a开头后追加PHP代码的伪图是经典构造。 - 对multipart/form-data边界处理不严:早期PHP版本对畸形multipart数据的解析有缝隙,攻击者构造特殊边界让PHP把恶意段当作文件名段处理。 ## MIME类型与扩展名一致性校验 第一层防御是检查PHP探测到的MIME类型与扩展名是否一致。在uploadsafe.inc.php大约第42行(不同SP版本行号略有差异,用关键词搜索定位)找到扩展名检查的代码段,在其后追加: // MIME 与扩展名一致性校验 $allowedMimeMap = [ 'jpg' => ['image/jpeg', 'image/pjpeg'], 'jpeg' => ['image/jpeg', 'image/pjpeg'], 'png' => ['image/png', 'image/x-png'], 'gif' => ['image/gif'], 'bmp' => ['image/bmp', 'image/x-ms-bmp'], 'webp' => ['image/webp'], ]; $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); if (isset($allowedMimeMap[$ext])) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $actualMime = finfo_file($finfo, $tmpFile); finfo_close($finfo); if (!in_array($actualMime, $allowedMimeMap[$ext], true)) { ShowMsg('文件类型与扩展名不匹配,禁止上传', '-1'); exit; } } 关键细节: - 用finfo扩展检测文件真实MIME,不要用 $_FILES['file']['type']——后者是客户端声明的可以伪造。finfo读取的是文件头部的magic number,攻击者无法在不破坏文件可读性的前提下篡改。 - 用in_array的严格模式(第三个参数true)避免类型转换攻击。PHP的弱比较会让 '0' 和 0 相等,严格模式才安全。 - 白名单包含的别名(image/pjpeg是Internet Explorer历史版本上传JPEG时的MIME,必须接受否则误伤)。 ## getimagesize二次验证图片真伪 MIME类型可以通过文件头几个字节伪造(攻击者把PHP代码放在伪造的图片头之后)。第二层防御是用PHP的getimagesize函数验证文件确实是合法图片。在大约第53行追加: // getimagesize 返回值验证 if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'], true)) { $imgInfo = @getimagesize($tmpFile); if ($imgInfo === false || !isset($imgInfo[2])) { ShowMsg('图片格式损坏或文件非图片,禁止上传', '-1'); exit; } $expectedType = [ 'jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF, 'bmp' => IMAGETYPE_BMP, 'webp' => IMAGETYPE_WEBP, ]; if ($imgInfo[2] !== $expectedType[$ext]) { ShowMsg('图片真实格式与扩展名不符,禁止上传', '-1'); exit; } } getimagesize会真的去解析图片的尺寸和格式头部。如果文件不是合法图片或者头部被恶意拼接破坏,函数返回false。这是PHP内置的图片验证函数,对绝大多数攻击载荷都能识别。它的判断基于图片的二进制结构而非扩展名或客户端声明,攻击者要绕过必须构造一个既能让getimagesize返回真值又能携带PHP代码的复合文件,难度大幅提高。 ## 文件名规范化与强制重命名 第三层防御针对的是文件名层面的绕过。比如 .php.jpg、.jpg.php、.shtml、.phtml 这类容易被服务器误解析的扩展名。在前两层校验之后追加: // 文件名规范化 $dangerousExts = ['php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'pl', 'py', 'jsp', 'asp', 'aspx', 'sh', 'cgi', 'shtml', 'htaccess']; $nameParts = explode('.', strtolower($filename)); foreach ($nameParts as $part) { if (in_array($part, $dangerousExts, true)) { ShowMsg('文件名包含危险扩展,禁止上传', '-1'); exit; } } // 强制重命名为时间戳 + 随机字符串 $newName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext; $filename = $newName; 核心思路: - 把文件名按 . 拆分,检查每一段都不是危险扩展。这能拦下evil.php.jpg、evil.jpg.php等多扩展构造。 - 强制重命名为时间戳加随机串。这样即使有上传文件,攻击者也不知道最终URL是什么——降低了利用难度。这是OWASP A05配置错误条目下专门提到的对抗手法。 - 用random_bytes而不是mt_rand之类弱随机源,避免攻击者通过种子推测文件名。random_bytes是PHP 7.0+引入的密码学安全随机源,跟 /dev/urandom 等价。 ## Nginx/Apache层禁止uploads目录PHP解析 前三层是PHP代码层面的防御。第四层是服务器配置层面的兜底——即使有恶意文件被上传到uploads目录,也不让服务器作为PHP执行它。 在Nginx配置文件里server块内增加: location ^~ /uploads/ { # uploads 目录禁止任何动态脚本执行 location ~ \.(php|php3|php4|php5|php7|phtml|phar|pl|py|jsp|asp|aspx|sh|cgi|shtml)$ { deny all; return 403; } } Apache用户在uploads目录下放一个.htaccess文件: Require all denied 这层防御的逻辑: - 即使前三层防御被绕过,恶意文件落到uploads目录。 - 攻击者访问/uploads/xxx.php时,Web服务器直接返回403而不是调用PHP解析器执行。 - 恶意代码无法运行,攻击就失败了。 这是纵深防御理念的体现——单层防御不够稳,多层叠加才能在某层被绕过时依然保护住业务。配置后必须 nginx -t 验证语法、nginx -s reload 平滑生效,不要直接 service nginx restart 影响线上请求。 ## 可疑内容特征扫描(按需启用) 对安全敏感的站点(电商、政企、金融),还可以在前面四层之上加第五层——上传后用内容扫描工具检测文件中是否含可疑PHP函数特征。在uploadsafe.inc.php通过所有校验后、move_uploaded_file之前插入: // 可疑内容扫描 $content = file_get_contents($tmpFile); $suspiciousPatterns = [ 'eval(', 'assert(', 'base64_decode(', 'gzinflate(', 'str_rot13(', 'passthru(', 'system(', 'shell_exec(', 'proc_open(', '$_POST[', '$_GET[', '$_REQUEST[' ]; foreach ($suspiciousPatterns as $pat) { if (stripos($content, $pat) !== false) { ShowMsg('文件内容含可疑代码特征,禁止上传', '-1'); exit; } } 注意这层会有误伤——某些合法PSD文件、Word文档的二进制内容偶尔会包含 $_POST 这种字节序列。如果你的站点上传需求多样,这层用stripos简单匹配会拦住正常文件。可以改成检查文件类型是图片时才扫描,或者用更精细的yara规则。商业级方案直接接ClamAV或类似引擎,扫描精度比简单字符串匹配高一个量级。 ## 验证修复是否生效的四组测试 修完上传一些测试样本验证: - 正常图片样本:上传一张正常的.jpg图片。预期:成功,文件被重命名为时间戳格式,落到uploads目录。 - 双扩展名样本:上传一个改了扩展名的PHP文件(test.php改成test.jpg)。预期:被第一层MIME校验拦下,提示文件类型与扩展名不匹配。 - EXIF注入样本:上传一个尾部嵌入PHP代码的伪图片(合法图片头部 + 末尾PHP代码)。预期:被第五层内容扫描拦下(如果开启了第五层);或者文件被强制改名,攻击者无法通过URL触发执行。 - uploads直访样本:直接访问/uploads/xxx.php测试服务器层禁解析。预期:返回403。 每个测试都要在生产环境模拟做一遍,不要只在开发环境验证——服务器配置经常不一致。生产环境Nginx可能有CDN回源、可能开启了不同的fastcgi_pass,开发环境的"通过"不代表生产环境也通过。 ## 全站审计判定是否已经被入侵 修复完后必须做一次全站审计,检查是否已经有恶意文件在站点里。这是修复后的第一件事。 用find命令搜可疑PHP文件: # 在 uploads 目录及其子目录里搜含 eval 的 PHP 文件 find /www/wwwroot/yoursite.com/uploads -name "*.php" -exec grep -l "eval(" {} \; # 搜近 90 天内修改过的 PHP 文件(可疑变更) find /www/wwwroot/yoursite.com -name "*.php" -mtime -90 -type f # 搜文件大小异常的图片(正常 jpg 通常小于 5MB) find /www/wwwroot/yoursite.com/uploads -name "*.jpg" -size +10M 找到可疑文件后逐个查看内容。如果是webshell(远程执行代码的脚本),证明站点曾被入侵——此时需要: - 立即删除所有webshell文件。 - 检查admin表是否有异常账号(可疑用户名、邮箱)。 - 检查dede_admin表的logintime字段是否有可疑登录记录。 - 修改所有管理员密码。 - 检查cookies表和session表是否有持久化的攻击者会话。 - 检查数据库内容是否被篡改(首页文章、栏目设置、友情链接)。 - 更换所有API密钥和敏感配置。 如果不确定是否被入侵,请安全公司做专业审计。自行处理容易遗漏后门。 ## DedeCMS上传漏洞历史回顾 了解过往漏洞演化能更好理解为什么要叠加多层防御: - 2010年首次披露:扩展名白名单不严,简单改后缀绕过。官方修补:增加扩展名严格白名单。 - 2012年绕过:用双扩展名evil.php.jpg绕过单一扩展名检查。 - 2015年绕过:利用multipart边界畸形让PHP解析器误判扩展名。 - 2018年披露:上传图片时通过EXIF字段注入PHP代码,配合服务器配置错误执行。 - 2022年披露:利用SVG文件类型绕过PHP默认的图片类型识别(SVG是文本格式可以含脚本)。 每次漏洞披露后官方都打过补丁,但DedeCMS 2018年之后官方停止维护,部分变体漏洞至今没有官方修复。社区版本(v5.7 SP2 community edition等)部分修了,但完整防护仍需要站长自己做加固。本文给出的五层防御覆盖了所有已知变体。 ## 长期防御建议 - 升级到DedeBIZ或迁出:DedeBIZ是DedeCMS原班人马另起炉灶的新项目,安全性维护比社区版本好。如果业务允许,迁到WordPress或现代CMS是更彻底的方案。整套服务器加固体系可以参考 Linux下DedeCMS生产级安全加固指南 (https://zhangwenbao.com/dedecms-site-security-settings-in-linux-environment.html) 配置mpm-itk、php-fpm权限分离与fail2ban。 - WAF兜底:在Nginx前面加ModSecurity或者云厂商的WAF(如阿里云、Cloudflare WAF),开启文件上传相关的规则集。规则集要选最新版,老版规则对2022年后的新变体覆盖不全。 - 定期审计:每月跑一次上面的find命令搜可疑文件,是防止后门驻留的基本动作。可以把命令写进cron + 邮件告警,自动化值班。 - 最小权限:uploads目录的PHP文件执行权限设为644(不含+x),目录设为755。即使有恶意文件落地,权限受限难以执行。 - 监控日志:Nginx access.log里/uploads/*.php的访问全部记录到独立日志文件,定期分析。任何这类访问都是潜在攻击。 ## 常见问题解答 ## 修了uploadsafe.inc.php后正常的图片上传也失败怎么办 大概率是MIME白名单太严格。先查PHP错误日志找具体ShowMsg的报错点。常见误伤场景:客户端是老版本IE上传JPEG时MIME是image/pjpeg而不是image/jpeg;客户端是Mac Safari上传HEIC转JPEG时MIME可能带image/heif;客户端是企业网络的代理服务器重写了MIME。对这些场景把白名单适当放宽(加image/pjpeg、image/heif)。如果是finfo函数未启用(php.ini没开fileinfo扩展),需要先在PHP里开启 extension=fileinfo。 ## 可选的内容扫描层经常误伤合法文件怎么办 内容扫描层是可选的,不是必须。对内容多样的站点(接受Office文档、PSD等)误伤率会比较高。建议处理方式:第一是只对图片类型扩展启用该层,不扫描其他文件;第二是把stripos简单匹配换成正则上下文匹配(要求 'eval(' 前后有典型PHP代码特征);第三是放弃该层,仅依赖前四层。前四层已经能拦下绝大多数已知攻击变体。 ## 修复后还需要更换管理员密码吗 强烈建议更换。如果漏洞被利用过(即使你不知道),攻击者可能已经拿到管理员凭据或留下后门。修复完上传漏洞之后必须做:第一全量审计PHP文件搜webshell;第二修改所有管理员密码(包括FTP、SSH、数据库、CMS后台);第三检查管理员账号列表是否有可疑新增;第四清除所有session强制所有用户重新登录。这些是事后清理的标准动作。 ## 有没有现成的脚本批量检测DedeCMS站是否含webshell D盾、河马webshell查杀(hm.shellpub.com)、安全狗都有专门针对PHP webshell的扫描工具。免费版能识别90%以上的已知webshell特征。把站点wwwroot目录上传到这些工具扫一遍能初步排查。注意工具有误报率,扫到可疑文件需要人工核实再决定是否删除。商业级安全审计推荐找专业安全公司做静态分析+动态测试。 ## DedeCMS还在更新吗,应该继续用吗 DedeCMS官方在2018年宣告停止商业授权销售,目前的5.7 SP2和5.8都是社区维护版本。安全补丁是社区自发提供,不像WordPress有完善的更新机制。如果你的站点是新项目,强烈不建议选DedeCMS——长期安全风险高。已有的老站点建议要么迁到WordPress等活跃项目,要么按本文方式做安全加固后继续维护。 ## 修了文件后被DedeCMS升级覆盖怎么办 把修改记录到独立的补丁文件dede_security_patch.diff用git管理。每次官方升级前先git diff看你的修改是否会被覆盖;升级后跑一遍diff应用补丁。如果团队有CI/CD流程,把补丁应用纳入部署管道。手工维护时记得在uploadsafe.inc.php的修改段加注释标记 'CUSTOM_SECURITY_PATCH 起止',下次升级前用grep快速定位。 ## uploads目录禁解析后老的图片访问会受影响吗 不会。Nginx的 location ~ \.(php|...) 规则只匹配PHP等动态扩展,对.jpg、.png、.gif这些静态文件不影响。配置后立刻刷新一些图片URL验证。如果发现某些资源被误屏蔽,看location块的具体匹配规则。配置生效需要nginx -s reload。 ## 多站点共用一个服务器加固一个站点要不要也加固其他 必须。同服务器上的多个站点如果有一个被入侵,攻击者通常能横向渗透到其他站点(通过共享的PHP进程、共用的MySQL账号、文件系统权限不严等)。安全加固要全服务器同时做。如果实在不能同时改,至少要在Nginx上对uploads目录全局禁解析,并隔离不同站点的PHP-FPM池。 ## 实战补充:保哥处理过的六个被入侵案例 过去4年保哥处理过6个被DedeCMS上传类漏洞利用过的客户站。整理一下每个案例的入侵路径和修复过程,看完能更直观理解多层防御的必要性。 入侵路径 | 发现时间 | 影响范围 | 修复方案 | 双扩展名evil.php.jpg绕过 | 入侵后32天发现 | 首页被植入跳转JS | MIME校验+文件名规范化+Nginx禁解析 | EXIF字段注入PHP代码 | 入侵后17天发现 | 插入垃圾外链 (https://zhangwenbao.com/ai-spam-backlink-detection-guide.html)页面412个 | getimagesize+Nginx禁解析+图片专用内容扫描 | SVG含script绕过 | 入侵后6天发现 | 管理员账号被盗用一次 | 白名单移除svg+Nginx禁解析 | multipart边界畸形 | 入侵后88天发现 | 整站镜像到攻击者域名 | 升级PHP 7.4到8.1+全部方案 | 文件名编码绕过 | 入侵后14天发现 | 植入挖矿脚本 | 文件名规范化+服务器全局扫 | 编辑器插件漏洞 | 入侵后4天发现 | SEO黑链插入 | 移除老版编辑器插件+MIME校验 | 这6个案例的共同点:单层防御失败时,多层防御能阻断攻击链。比如双扩展名案例绕过了扩展名检查,但如果有Nginx禁解析配合,文件落地后也无法被执行。编辑器插件案例如果同步部署了 KindEditor编辑器深度优化指南 (https://zhangwenbao.com/kindeditor-image-upload.html) 里的上传目录归档与CSRF加固,攻击面会进一步收窄。 ## PHP版本与防御能力的关系 PHP自身版本对文件上传漏洞防御能力影响很大: - PHP 5.6及以下:finfo在某些版本对畸形文件MIME探测有bug;getimagesize对部分构造的图片返回值不可靠。如果服务器还在用PHP 5.6,强烈建议升级。 - PHP 7.0到7.3:基础校验函数稳定,但random_bytes在某些低版本编译时未启用。 - PHP 7.4到8.x:所有本文用到的函数都稳定可用。如果你的DedeCMS跑在PHP 7.4以下,应该优先升级PHP。 DedeCMS 5.7 SP2官方支持PHP 5.6到7.4,5.8社区版兼容到PHP 8.0。如果可能升级到PHP 8.0或更高版本,本文的防御方案能更稳定运行。升级PHP之前必须备份并在测试环境验证DedeCMS兼容性,部分老插件可能在PHP 8.x下报错。 ## 结合宝塔面板的实施步骤 大部分国内DedeCMS站点跑在宝塔面板上。宝塔自带的"网站防火墙"插件能在Nginx层做部分上传防护,但不够细化。建议同时做: - 宝塔后台 - 网站 - 你的站点 - 设置 - 配置文件,把上面Nginx的location段加进去,保存重启。 - 宝塔后台 - 安全 - 防火墙,开启"文件上传过滤",规则集选最严。 - 宝塔后台 - 文件 - 你的uploads目录 - 权限 - 设为755,所有者www,禁止其他用户写。 - 宝塔后台 - 软件商店 - 安装"网站监控"插件,开启文件变更告警。 - 本文MIME校验、getimagesize验证、文件名规范化三层通过SSH编辑include/uploadsafe.inc.php实施。改前备份原文件到backup目录。 宝塔面板的防火墙规则对DedeCMS特定漏洞的覆盖度不全(毕竟是通用WAF不是DedeCMS专用),仍需要本文的代码层加固兜底。安装宝塔之后定期升级到最新版,老版宝塔本身也存在过几个安全公告。 ## 权威参考资料 ## 织梦article_add Cookie泄漏SQL注入:五道防线修复 - URL:https://zhangwenbao.com/article_add-php-in-dedecms-has-cookies-leak-causing-sql-vulnerabilities-to-be-repaired.html - 分类:织梦CMS教程 - 发布:2018-07-08 | 更新:2026-06-02 - 摘要:DedeCMS会员中心的SQL注入根因在fields hash校验机制:md5虽单向,但fields内容能被攻击者拿到,构造合法哈希就绕过了字段类型保护。本文给出五道纵深防线的完整PHP代码——服务端密钥双重md5、PDO参数化、白名单与长度控制、全站定位与Nginx拦截特征字符串。 - 关键词:织梦漏洞,SQL注入,DedeCMS安全,article_add,Cookie安全 > **TLDR**:摘要:DedeCMS会员中心的SQL注入根因在fields hash校验机制——md5虽单向,但fields内容能被攻击者拿到,构造合法哈希就绕过了字段类型保护。本文给出五道纵深防线的完整PHP代码——掺服务端密钥的双重md5、PDO参数化替代拼接、白名单标签与长度控制、横向扫描所有相似入口、服务器层WAF兜底,再附验证、全站入侵审计和迁出建议。 > 摘要:DedeCMS会员中心的SQL注入根因在fields hash校验机制——md5虽单向,但fields内容能被攻击者拿到,构造合法哈希就绕过了字段类型保护。本文给出五道纵深防线的完整PHP代码——掺服务端密钥的双重md5、PDO参数化替代拼接、白名单标签与长度控制、横向扫描所有相似入口、服务器层WAF兜底,再附验证、全站入侵审计和迁出建议。 DedeCMS 的会员中心 member/article_add.php 文件 2016 年首次披露过一个 SQL 注入漏洞:攻击者可以通过构造特殊的 Cookie 绕过后端的 dede_fieldshash 校验机制,向数据库执行任意查询。这个漏洞核心问题是 Cookie 信息泄漏让校验哈希可被预测,配合不严格的参数过滤造成注入。本文不讨论攻击如何构造(攻击细节请阅 CNVD / CVE 数据库的官方披露),只讲修复方案、纵深防御、修复后的入侵审计。保哥过去 4 年帮 9 个被 SQL 注入打过的 DedeCMS 客户做过修复,本文是这些工作的整理。 覆盖版本:DedeCMS 5.7 SP2 / 5.7 UTF8 SP1 / 5.8 社区版。涉及的文件主要是 member/article_add.php、include/dedesql.class.php、include/common.inc.php。修复前必须备份完整源代码和数据库。 ## dede_fieldshash 校验机制的设计意图 先理解 DedeCMS 的设计才能知道为什么会出现这个漏洞。member/article_add.php 是会员发布文章的入口。用户在前台填表单提交文章时,DedeCMS 用一个隐藏字段 dede_fields 描述各个字段的类型和约束(如 title=string,4-100; body=html,200-50000);同时用 dede_fieldshash 字段存这个描述的 md5 哈希,防止前端篡改字段类型。 原版逻辑大致是: - 表单生成时把 dede_fields 写到 hidden input,并计算 dede_fieldshash = md5(dede_fields)。 - 用户提交后,后端取 POST 里的 dede_fields,重新计算 md5,与 POST 的 dede_fieldshash 比较。 - 如果一致,相信 dede_fields 没被篡改,按它描述的字段约束处理表单数据。 这个设计的问题是:md5 是单向哈希,但只要 dede_fields 的内容被攻击者知道,就能自己算 md5 构造合法 hash。攻击者只需要在 Cookie 里或者别的地方拿到一份合法的 dede_fields 样本,就能任意构造新的字段描述,让后端按攻击者的约束处理数据——攻击者可以让 body 字段不做 SQL 转义,直接传入恶意 SQL 片段实现注入。 修复的核心思路是给 md5 计算加一个攻击者无法预测的密钥(HMAC 思路),让攻击者即使知道 dede_fields 也算不出合法 hash。 ## 掺入服务端密钥的双重 md5 改造 在 member/article_add.php 大约第 40 行(具体行号视 SP 版本和你之前的修改略有差异,用关键字 fieldshash 定位)找到 hash 校验段。原代码大致是: $fieldshash = md5($dede_fields); if ($fieldshash !== $_POST['dede_fieldshash']) { ShowMsg('字段描述被篡改', '-1'); exit; } 修改为引入服务端密钥的双重 md5: // 服务端密钥,部署时在 data/common.inc.php 里配置 // $cfg_security_salt = '你自定义的高熵字符串_至少32字符' $securitySalt = isset($GLOBALS['cfg_security_salt']) ? $GLOBALS['cfg_security_salt'] : 'anythingelse_长度至少32位_请在部署时替换'; $expectedHash = md5(md5($dede_fields) . $securitySalt); if ($expectedHash !== $_POST['dede_fieldshash']) { ShowMsg('字段描述被篡改', '-1'); exit; } 核心要点: - 用双重 md5(md5(...) . salt) 而不是简单 md5(... . salt),避免长度扩展攻击。 - 密钥从配置文件读,不要硬编码在 article_add.php 里(否则源码泄漏就废)。 - 密钥长度至少 32 字符,使用高熵随机串(用 bin2hex(random_bytes(16)) 生成一次后写入配置)。 - 每个站点的密钥应该不同,不要复制粘贴常见的示例字符串。 注意:修改这段后,原来的表单生成处也要同步修改 fieldshash 的计算逻辑——否则前端生成的 hash 和后端期望的不一致,所有合法提交都会被拦截。表单生成代码通常在 dede 系统的 include/inc_archives_functions.inc.php 或类似位置,搜 md5($dede_fields) 找到对应代码段同样改为双重 md5 加 salt。 ## 参数化查询替代字符串拼接 第一步只是堵了表单字段类型被篡改的入口。但 DedeCMS 整个项目里有大量字符串拼接的 SQL,根本治理是改用参数化查询。从 member/article_add.php 的数据写入语句开始: 原版(脆弱)写法: $query = "INSERT INTO #@__archives (typeid, title, body, mid) VALUES ($typeid, '$title', '$body', $mid)"; $dsql->ExecuteNoneQuery($query); 改为参数绑定写法(DedeCMS 自带的 dedesql.class.php 支持参数绑定但很多老代码没用): $dsql->SetQuery("INSERT INTO #@__archives (typeid, title, body, mid) VALUES (?, ?, ?, ?)"); $dsql->ExecuteWithParams([$typeid, $title, $body, $mid]); 如果 dedesql.class.php 没有 ExecuteWithParams 方法(老版本可能没有),用 PDO 直接写: $pdo = new PDO("mysql:host=$cfg_dbhost;dbname=$cfg_dbname;charset=utf8mb4", $cfg_dbuser, $cfg_dbpwd); $stmt = $pdo->prepare("INSERT INTO {$cfg_dbprefix}archives (typeid, title, body, mid) VALUES (?, ?, ?, ?)"); $stmt->execute([$typeid, $title, $body, $mid]); 参数化查询的核心保护是:MySQL 服务端把 SQL 模板和参数分开解析,参数值永远不会被当作 SQL 代码执行。这是 SQL 注入的根本防御。 ## 输入过滤与白名单标签 参数化是技术层防御,业务层还需要输入过滤。在 member/article_add.php 接收 POST 数据后立刻做一层过滤: function sanitizeInt($val) { return (int)$val; // 强制转 int,非数字字符全部丢弃 } function sanitizeString($val, $maxLen = 1000) { $val = trim($val); $val = strip_tags($val, '


'); if (mb_strlen($val) > $maxLen) { $val = mb_substr($val, 0, $maxLen); } return $val; } $typeid = sanitizeInt($_POST['typeid'] ?? 0); $title = sanitizeString($_POST['title'] ?? '', 100); $body = sanitizeString($_POST['body'] ?? '', 50000); $mid = (int)$cfg_ml->M_ID; // 会员 ID 从会话拿,不信任 POST 几个关键细节: - 整型字段用强制类型转换((int)),是最严的过滤。 - 字符串字段用 strip_tags 加白名单标签,只保留必要的 HTML 标签,其他全清。 - 会员 ID(mid)一定从会话或登录态拿,绝对不信 POST 里的字段——这是常见的越权漏洞起点。 - 用 mb_strlen 而不是 strlen,UTF-8 中文字符串长度计算才准确。 ## 横向扫描所有相似入口 仅修 member/article_add.php 不够。DedeCMS 的会员中心有多个文件用相似的 dede_fieldshash 校验机制: - member/album_add.php(图集发布) - member/soft_add.php(软件下载发布) - member/info_add.php(资讯发布) - member/edit.php(文章编辑) - member/album_edit.php、soft_edit.php、info_edit.php 等编辑入口 每个文件都需要先加 salt 改造 fieldshash 校验,再改参数化查询,再加输入过滤。漏一个文件就等于留一个口子。 用 grep 全站搜定位需要改的文件: # 搜所有用 fieldshash 校验的文件 grep -r "fieldshash" /www/wwwroot/yoursite.com/member/ # 搜可能含字符串拼接 SQL 的文件 grep -rn "INSERT INTO.*VALUES.*\\\$" /www/wwwroot/yoursite.com/member/ # 搜直接用 $_POST 拼 SQL 的危险写法 grep -rn "\\\$_POST\\[" /www/wwwroot/yoursite.com/member/ | grep -i "select\\|insert\\|update\\|delete" 这三条命令能列出绝大多数需要审计的文件。逐个看每个文件的 SQL 写法是否安全。 ## 服务器层面的 WAF 兜底 代码层防御是主战场,但服务器层面的 WAF 能拦截大量已知 SQL 注入特征。在 Nginx 配置里增加规则: map $args $sql_injection { default 0; "~*union[\s]+select" 1; "~*select[\s]+.*[\s]+from" 1; "~*[\'\"].*[\\\\]+x[0-9a-f]{2}" 1; "~*concat[\s]*\(" 1; } server { if ($sql_injection) { return 403; } # ...其他配置 } 这套规则会拦下 SQL 注入的典型特征(UNION SELECT、CONCAT、十六进制编码等)。注意 WAF 规则是字符匹配,对绕过手法(如 /*!union*/select、空格替换为 %20+%20 等)覆盖不全。WAF 只是兜底,不能替代代码层加固。 商业 WAF(云厂商的 Web 应用防火墙、Cloudflare、阿里云盾)对 SQL 注入的覆盖比上面的简单规则更全面。对预算允许的站点强烈推荐。 ## 验证修复是否生效 修完做几组测试: - 测试 1:用合法会员账号发布一篇正常文章。预期:成功,按预期写入数据库。 - 测试 2:手动篡改前端 dede_fieldshash 字段(用浏览器开发者工具)后提交。预期:服务端拦下,提示"字段描述被篡改"。 - 测试 3:在 title 字段输入 ' or 1=1--。预期:被参数化查询当作字面值处理,title 实际存储这串字符,不触发 SQL 异常。 - 测试 4:在 typeid 字段输入 1; DROP TABLE users。预期:被强制类型转换为整数 1,DROP 部分丢弃。 每个测试都要看后端日志确认行为符合预期。如果有任何一个测试触发了意外异常,需要回溯检查修改是否完整。 ## 修复后的全站入侵审计 如果你的站点已经在生产环境运行了一段时间才修复,必须假设漏洞已经被利用过,做一次全面审计: - 检查 dede_admin 表:看是否有不熟悉的管理员账号、最近一次登录 IP 是否可疑。SELECT id, userid, email, logintime, loginip FROM dede_admin ORDER BY logintime DESC LIMIT 20。 - 检查 dede_member 表:搜近期注册的会员,看是否有批量注册的可疑账号(同 IP、同邮箱域名、同注册时间集中)。 - 检查 dede_archives 表:搜 title 或 body 含 link / a href / iframe 的文章,可能是黑链注入。SELECT id, title, mid, sortrank (https://zhangwenbao.com/method-to-dedecms-article-set-a-dream-day.html) FROM dede_archives WHERE body LIKE '% **TLDR**:摘要:DedeCMS的member/pm.php存在SQL注入CVE-2018-9134,单引号闭合加UNION就能脱库。本文从源码讲清成因,给出intval强类型的最小补丁和PDO参数化的彻底修复,再讲全站SQL注入审计方法论、WAF层防护、密码哈希加固、被脱库后的应急响应,以及迁到现代PHP生态的建议。 > 摘要:DedeCMS (https://zhangwenbao.com/dedecms-commonly-used-batch-sql-statements.html)的member/pm.php存在SQL注入CVE-2018-9134,单引号闭合加UNION就能脱库。本文从源码讲清成因,给出intval强类型的最小补丁和PDO参数化的彻底修复,再讲全站SQL注入审计方法论、WAF层防护、密码哈希加固、被脱库后的应急响应,以及迁到现代PHP生态的建议。 DedeCMS 会员中心 member/pm.php(站内私信功能)在 2018 年被披露存在 SQL 注入 (https://zhangwenbao.com/dedecms-soft_add-php-has-sql-injection-vulnerability-repair-method.html)漏洞,CVE 编号 CVE-2018-9134。攻击者通过构造特殊的 id 参数能直接读取 dede_member 表里的密码哈希、邮箱、手机号等敏感字段。这个漏洞至今仍是 DedeCMS 站点被脱库的最常见入口之一。本文从源码层面讲清漏洞成因(intval 缺失导致字符串拼接进 SQL)、给出最小修复 patch 与几种替代方案(参数化查询、ORM 包装层)、扩展到全站 dede:sql 注入审计的方法论、被脱库后的应急响应、与现代 PHP 生态(PDO 预编译、Doctrine ORM)的迁移路径。 ## 漏洞成因详解 ## 原始漏洞代码 问题代码在 member/pm.php 的 read 分支: else if ($dopost == 'read') { $sql = "SELECT * FROM `#@__member_friends` WHERE mid='{$cfg_ml->M_ID}' AND ftype!='-1' ORDER BY addtime DESC LIMIT 20"; $friends = array(); $dsql->SetQuery($sql); $dsql->Execute(); while ($row = $dsql->GetArray()) { $friends[] = $row; } $row = $dsql->GetOne( "SELECT * FROM `#@__member_pms` WHERE id='$id' AND (fromid='{$cfg_ml->M_ID}' OR toid='{$cfg_ml->M_ID}')" ); // ... } 关键问题在 WHERE id='$id' —— $id 直接来自 $_GET['id'] 或 $_POST['id'],未做任何过滤。SQL 字符串拼接的方式让攻击者可以构造: ?id=1' UNION SELECT pwd FROM dede_member WHERE mid=1 -- 实际拼出的 SQL: SELECT * FROM dede_member_pms WHERE id='1' UNION SELECT pwd FROM dede_member WHERE mid=1 -- ' AND (fromid='current_user' OR toid='current_user') UNION 查询返回了 dede_member 表里管理员(mid=1)的密码哈希。 ## 为什么单引号包围还是有漏洞 很多新手会以为“id='$id'”的单引号已经把输入限制成字符串,攻击者注不进 SQL。但这是错觉:单引号本身可以被攻击者闭合。?id=1' 中的 ' 把 SQL 里的引号闭合了,后面的 UNION 就脱离了字符串上下文。 ## 为什么 mid 字段拼接是安全的 仔细看 SQL 里同样有 mid='{$cfg_ml->M_ID}',但 M_ID 来自服务器端会话(已登录用户的 ID),不可被外部控制。这是关键差别:来自用户输入的字段必须过滤,来自服务器内部的可控变量不需要。 ## 修复方案一:intval 强类型转换 ## 最小补丁 原文给的修复方案是在拼 SQL 之前加 intval: else if ($dopost == 'read') { $sql = "SELECT * FROM `#@__member_friends` WHERE mid='{$cfg_ml->M_ID}' AND ftype!='-1' ORDER BY addtime DESC LIMIT 20"; $friends = array(); $dsql->SetQuery($sql); $dsql->Execute(); while ($row = $dsql->GetArray()) { $friends[] = $row; } /* 修复:$id 强转整数 */ $id = intval($id); /* */ $row = $dsql->GetOne( "SELECT * FROM `#@__member_pms` WHERE id='$id' AND (fromid='{$cfg_ml->M_ID}' OR toid='{$cfg_ml->M_ID}')" ); if (!is_array($row)) { ShowMsg('对不起,你指定的消息不存在或你没权限查看!', '-1'); exit(); } $dsql->ExecuteNoneQuery( "UPDATE `#@__member_pms` SET hasview=1 WHERE id='$id' AND folder='inbox' AND toid='{$cfg_ml->M_ID}'" ); $dsql->ExecuteNoneQuery( "UPDATE `#@__member_pms` SET hasview=1 WHERE folder='outbox' AND toid='{$cfg_ml->M_ID}'" ); include_once(dirname(__FILE__) . '/templets/pm-read.htm'); exit(); } ## intval 的工作原理 PHP 的 intval() 把任意字符串转成整数。intval("1") 是 1,intval("1' UNION SELECT...") 也是 1,因为 PHP 从字符串开头取数字直到第一个非数字字符为止。所有注入 payload 都会被截断为安全的整数。 ## 这个方案的局限 - 只对整数 id 字段有效。如果字段是字符串(用户名、邮箱、UUID),intval 会变成 0。 - 同文件中其它 dopost 分支(write、send、del)可能有相似漏洞,需要逐一审计。 - 修复后下次 DedeCMS 升级 pm.php 可能覆盖你的改动,要做好补丁记录。 ## 修复方案二:参数化查询(更彻底) ## DedeCMS 的 dsql 类支持预编译 DedeCMS 自带的 $dsql 对象其实有 SetQuery 之外的预编译接口,但代码里几乎不用。改成参数化: $id = intval($id); // 防御性双保险 $sql = "SELECT * FROM `#@__member_pms` WHERE id = ? AND (fromid = ? OR toid = ?)"; $pdo = new PDO($dsn, $user, $pass); $stmt = $pdo->prepare(str_replace('#@__', $cfg_dbprefix, $sql)); $stmt->execute([$id, $cfg_ml->M_ID, $cfg_ml->M_ID]); $row = $stmt->fetch(PDO::FETCH_ASSOC); PDO 预编译彻底杜绝 SQL 注入,不依赖 intval 这种类型转换的偶然性。 ## 用 escapestring 兜底 如果不引入 PDO,DedeCMS 自带 addslashes 等转义。但 addslashes 不安全(多字节字符集下能被绕过),改用 mysqli_real_escape_string: $id = mysqli_real_escape_string($GLOBALS['link'], $id); // 仍要确保字段类型,避免空字符串触发逻辑错误 if (!preg_match('/^\d+$/', $id)) { exit('Invalid ID'); } ## 全站 SQL 注入审计方法论 ## 定位所有用户输入入口 DedeCMS 通过 require_once common.inc.php 自动把 $_GET / $_POST 的所有值注册为全局变量。比如 ?id=123 会自动产生 $id=123。这种“自动全局化”让追踪输入入口变得困难——表面上 $id 看起来是局部变量,实际是用户输入。 命令行扫描所有可能涉及用户输入的代码: # 找所有 SQL 字符串拼接的位置 cd /var/www/dedecms grep -rn "WHERE.*=.*'\$" --include="*.php" . grep -rn "INSERT.*\$" --include="*.php" . grep -rn "UPDATE.*=.*\$" --include="*.php" . ## 标记可疑文件 历史上 DedeCMS 漏洞集中爆出的目录: - plus/ —— 几乎每个文件都曾出过漏洞,其中 recommend.php、carbuyaction.php、search.php、mytag_js.php、advancedsearch.php 是高频名单。 - member/ —— pm.php、resetpassword.php、edit_baseinfo.php、ajax_membergroup.php。 - include/ —— common.func.php 里的 GetCookie、PutCookie 函数有签名验证缺失。 - dede/ —— 后台理论上要鉴权,但部分接口的鉴权用 == 而不是 === 比较,可类型混淆绕过。 ## 批量打补丁 对每个识别出的漏洞点应用“intval + addslashes + 严格类型校验”三件套: // 整数字段 $id = intval($id); if ($id <= 0) { ShowMsg('参数错误', '-1'); exit(); } // 字符串字段 $keyword = addslashes(htmlspecialchars($keyword)); if (strlen($keyword) > 50) { ShowMsg('关键词过长', '-1'); exit(); } // 枚举字段(性别、状态) $gender = in_array($gender, ['male', 'female', 'other']) ? $gender : 'other'; ## WAF 层面的防护 ## nginx 层 SQL 注入拦截 Application 层修复之外,可以在 nginx 加正则规则拦截 SQL 注入特征请求: map $args $sql_injection { default 0; "~*(union[\s\+]+select|select[\s\+]+from|insert[\s\+]+into|update[\s\+]+set|drop[\s\+]+table|delete[\s\+]+from|or[\s\+]+1=1|sleep\(|benchmark\(|substr\(|concat\()" 1; } server { if ($sql_injection = 1) { return 403; } # ... } 这种规则有误报风险(合法 URL 中含有 select 关键词会被拦),生产部署前用日志模式(log 而不是 return 403)观察一段时间。 ## 用 ModSecurity 与 OWASP CRS 更专业的做法是上 ModSecurity + OWASP Core Rule Set。OWASP CRS 包含几千条 SQL 注入、XSS、命令注入特征规则,覆盖度比手写正则强得多。安装: sudo apt-get install libmodsecurity3 nginx-module-modsecurity # 下载 OWASP CRS git clone https://github.com/coreruleset/coreruleset /etc/nginx/modsec/ # 在 nginx.conf 启用 ## 云 WAF 阿里云、腾讯云、Cloudflare、AWS WAF 都提供 SQL 注入防护规则集。中小站点上云 WAF 是最省心的方案,每月几十到几百块。 ## 密码哈希加固 即便修了 pm.php 的注入,DedeCMS 的密码哈希(MD5)本身已经不安全。MD5 单次哈希加 ec_salt 在 GPU 字典攻击下几小时就能破解。建议: ## 升级到 password_hash PHP 5.5+ 提供 password_hash() 与 password_verify(),默认用 bcrypt。改造 DedeCMS 用户验证: // 用户注册时 $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); $dsql->ExecuteNoneQuery("INSERT INTO #@__member (userid, pwd) VALUES ('$userid', '$hash')"); // 用户登录时 $row = $dsql->GetOne("SELECT pwd FROM #@__member WHERE userid='$userid'"); if (password_verify($password, $row['pwd'])) { // 登录成功 } cost 参数 12 让 bcrypt 在现代 GPU 上单次哈希耗时约 250ms,字典攻击成本陡增。 ## 历史数据迁移 DedeCMS 库里已有几千上万个 MD5 密码不能直接转成 bcrypt(不知道明文)。处理: - 登录时如果检测到 MD5 格式,校验通过后立刻用 bcrypt 重哈希一次写回。 - 对长期不登录的账号发邮件强制重置密码。 - 给所有账号默认开启二步验证(2FA)。 ## 被脱库后的应急响应 ## 检测被脱库的迹象 - nginx access.log 出现大量带 SQL 关键字的请求。 - MySQL 慢查询日志记录到长查询(attacker 可能在做盲注探测)。 - 用户反馈邮箱密码在其它站被试出(数据被打包出售)。 - HaveIBeenPwned 等数据泄漏数据库出现你站点。 ## 应急动作 - 立刻把站点切到维护页,修复漏洞。 - 强制所有用户改密码(清空 dede_member.pwd 字段或下发邮件链接)。 - review 后台管理员账号的最近活动,重置 root 密码。 - review 数据库表是否被增删(dede_admin 表是否多了未授权账号)。 - 通知用户已发生泄漏(GDPR 与个保法要求 72 小时内通知)。 - 向网信办或公安备案上报(数据规模大的话)。 ## 溯源调查 查 access.log 找首个成功注入的请求时间点: grep -i "union\|select.*from\|sleep(" /var/log/nginx/access.log | head -50 看 IP 地理位置(whois、ipinfo.io)、User-Agent 特征(sqlmap、Havij 等工具有特征 UA)、之后的访问行为模式。完整的攻击链通常是:探测注入 → 拿数据库结构 → 拿用户表 → 离线破解密码哈希 → 登录后台 → 上传 webshell。 ## 迁移到现代 PHP 生态 ## 用 ORM 替代手写 SQL Doctrine ORM、Eloquent(Laravel 的 ORM)从设计上就是参数化查询,不可能写出 SQL 注入: // Eloquent 写法 $pm = MemberPM::where('id', $id) ->where(function($q) use ($currentUserId) { $q->where('fromid', $currentUserId) ->orWhere('toid', $currentUserId); }) ->first(); $id 不管是什么内容,永远作为参数绑定到查询,不会被解释为 SQL。 ## 用 Symfony Form 验证用户输入 Symfony Form 组件提供“类型 + 约束”声明式验证: $builder ->add('id', IntegerType::class, [ 'constraints' => [new Assert\Positive()] ]); Form 验证失败的请求根本到不了 controller,更不会触发 SQL 拼接。 ## 升级到 DedeBIZ 或迁移到 Typecho DedeBIZ 是 DedeCMS 的商业延续版,在原版基础上做了大量安全修复。Typecho 是国产轻量博客系统,代码质量与现代 PHP 实践都更优。如果你的站点不依赖 DedeCMS 特定功能(自定义模型、采集),迁移过去能彻底消除历史漏洞包袱。 ## 常见故障 ## 故障 1:修复后用户无法访问私信 intval 把非数字 id 转成 0,原本应该抛错的请求被当成“查询 id=0 的私信”返回空结果。改进:先校验是否为合法整数: if (!is_numeric($id) || intval($id) <= 0) { ShowMsg('无效的私信 ID', '-1'); exit(); } $id = intval($id); ## 故障 2:升级后补丁丢失 DedeCMS 升级会覆盖 member/pm.php。建议把所有补丁单独维护一份 patches/ 目录,每次升级后用 patch 命令重新应用: diff -u original/pm.php patched/pm.php > patches/pm.php.patch # 升级后 patch /var/www/dedecms/member/pm.php < patches/pm.php.patch ## 故障 3:WAF 规则误拦合法请求 关键词如 select、union 在合法 URL 中也可能出现(比如 ?action=select&item=union_pay)。WAF 规则要精确到“带 SQL 语义的组合”而不是单关键词。先开 log-only 模式观察 1-2 周再启用拦截。 ## 故障 4:参数化查询性能下降 PDO 预编译有缓存机制,第一次执行慢,后续快。如果你看到性能变差,检查是否每次都新建 PDO 连接(连接池缺失)。改用持久连接: $pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_PERSISTENT => true]); ## 故障 5:审计扫描出大量误报 grep 正则会把字符串字面量里的 SELECT 也算上。结合 AST 分析工具(PHP-Parser、psalm)能更精确:psalm 静态分析 DedeCMS 代码能找出真正的 SQL 注入点。 ## 常见问题解答 ## 修了 pm.php 是不是 DedeCMS 就安全了? 远远不够。pm.php 只是一个公开的漏洞点,DedeCMS 还有几十个未公开或低危的注入位置。完整安全靠“所有用户输入都过滤 + WAF 兜底 + 密码哈希升级 + 后台目录与权限分离”组合。 ## intval 修复对 base64 编码的 id 还有效吗? 有效。intval("MQ==" base64 解码前) 取数字部分得 0。但攻击者也可能用其它编码绕过。建议先检查格式(is_numeric 或正则)再 intval。 ## DedeCMS 官方还在修漏洞吗? 原版 DedeCMS 自 2020 年起官方维护停滞。新发现的漏洞官方不会修,社区有人维护非官方补丁但更新慢。建议主动升级到 DedeBIZ 或迁移到 Typecho/WordPress。 ## WAF 与代码层修复哪个更重要? 代码层是根本,WAF 是兜底。只用 WAF 不修代码:WAF 规则总有空子(编码绕过、时序攻击)。只修代码不用 WAF:未来出现新漏洞前没有兜底防线。两者都做最稳。 ## 升级 PHP 到 8.x 后 DedeCMS 报错怎么办? DedeCMS 5.7 在 PHP 8.x 下大量函数废弃错误。需要打 dedeCMS-php8 兼容补丁,或者降回 PHP 7.4。但 PHP 7.4 已经停止安全更新(2022 年起),长期不可持续。 ## 能否直接禁用 pm.php? 能。如果你的会员中心不用站内私信功能,nginx 层直接 deny: location = /member/pm.php { deny all; } 这是最彻底的“关掉攻击面”做法。 ## 如何检查站点是否已被注入? 看 dede_admin 表是否多了陌生账号;看 dede_member 表是否有 mid=1 的密码哈希被替换;用 wpscan 类工具扫描站点指纹;用 phpcheck 扫 webshell 特征。还可以对比 mysqldump 备份与现状的差异。 ## DedeCMS 有没有像 WordPress Wordfence 那样的安全插件? 没有官方维护的。社区有几个第三方安全插件但更新滞后。目前最务实的做法是 ModSecurity + 云 WAF + 自己审计代码。 ## 升级补丁后怎么验证修复有效? 用 sqlmap 工具针对该 URL 测一次:sqlmap -u "https://example.com/member/pm.php?dopost=read&id=1" --cookie="PHPSESSID=xxx" --level=5 --risk=3。修复有效的话 sqlmap 报“not injectable”。 ## 除了 pm.php 还有哪些 DedeCMS 已知 SQL 注入漏洞? CVE 数据库搜“DedeCMS”能查到几十个。最常见的还有 plus/recommend.php、plus/carbuyaction.php、plus/search.php、include/dialog/select_soft.php。打全套补丁参考社区维护的 dedecms-vulnerability-patches 项目。 ## 权威参考资料 ## DedeCMS会员中心mtypes.php SQL注入漏洞修复深析:数组键名注入根因、intval补丁与系统化排查 - URL:https://zhangwenbao.com/dedecms-member-center-mtypes-php-injection-vulnerability-repair-method.html - 分类:织梦CMS教程 - 发布:2018-07-02 | 更新:2026-05-16 - 摘要:DedeCMS的会员中心mtypes.php只过滤了数组的值却漏了键名,攻击者构造特殊键名就能注入SQL(CVE-2018-9134)。本文讲透数组键名注入的根因和intval补丁的关键作用,对比五种过滤方式的可靠性,并给出系统化排查命令和三层防御。 - 关键词:织梦漏洞,织梦会员中心,SQL注入,CVE-2018-9134,DedeBIZ > **TLDR**:摘要:DedeCMS会员中心的mtypes.php只过滤了数组的值却漏了键名,攻击者构造特殊键名就能注入SQL,这就是CVE-2018-9134。本文讲透数组键名注入的根因和intval补丁的关键改动,点出补丁还不够、文件里其它注入点,再用PDO预处理重构、给系统化排查整套代码的方法、WAF兜底和被入侵后的应急响应。 > 摘要:DedeCMS会员中心的mtypes.php只过滤了数组的值却漏了键名,攻击者构造特殊键名就能注入SQL,这就是CVE-2018-9134。本文讲透数组键名注入的根因和intval补丁的关键改动,点出补丁还不够、文件里其它注入点,再用PDO预处理重构、给系统化排查整套代码的方法、WAF兜底和被入侵后的应急响应。 DedeCMS 会员中心的 /member/mtypes.php 在 2014 年被披露存在 SQL 注入 (https://zhangwenbao.com/dedecms-soft_add-php-has-sql-injection-vulnerability-repair-method.html)漏洞——攻击者通过分类管理表单的 mtypename[id] 数组键名注入恶意 SQL,能直接读取/修改数据库内容(含管理员密码哈希)。这是 DedeCMS 历史上影响最广的漏洞之一,至今很多老站没打补丁仍在裸奔。 这一篇把这个漏洞讲透:根因(PHP 数组键名直接拼到 SQL 里)、原帖给的补丁完整解析、为什么 intval() 是关键、补丁后还有哪些 SQL 注入点、如何系统化扫描整套 DedeCMS 的注入风险、与 WAF / fail2ban (https://zhangwenbao.com/dedecms-site-security-settings-in-linux-environment.html) 的协同防御、2026 年还在用 DedeCMS 的安全建议。 ## 漏洞原理:数组键名 SQL 注入 原始漏洞代码(/member/mtypes.php): foreach ($mtypename as $id => $name) { $name = HtmlReplace($name); // ✅ 对 $name 做了过滤 $query = "UPDATE `#@__mtypes` SET mtypename='$name' WHERE mtypeid='$id' AND mid='$cfg_ml->M_ID'"; // ↑↑↑↑ // $id 没过滤! $dsql->ExecuteNoneQuery($query); } 开发者过滤了数组的 值($name)但忘了过滤 键($id)。PHP 数组的键名可以是任意字符串——攻击者构造一个表单:

提交后 PHP 解析得到 $mtypename = ["1' OR '1'='1" => "payload"]。foreach 循环时 $id = "1' OR '1'='1",直接拼到 SQL: UPDATE `dede_mtypes` SET mtypename='payload' WHERE mtypeid='1' OR '1'='1' AND mid='123' WHERE 条件被打穿——所有 mtypes 行都被改成 mtypename='payload'。更高级的攻击者能用 UNION SELECT ... 读取其它表(dede_admin 拿管理员密码哈希)。 ## 补丁的关键改动 原帖给的补丁就一行关键改动: foreach ($mtypename as $id => $name) { $name = HtmlReplace($name); $id = intval($id); // ← 关键:把 $id 强制转成整数 $query = "UPDATE `#@__mtypes` SET mtypename='$name' WHERE mtypeid='$id' AND mid='$cfg_ml->M_ID'"; $dsql->ExecuteNoneQuery($query); } intval("1' OR '1'='1") 会得到 1(PHP 的 intval 解析到第一个非数字字符就停)。攻击 payload 被中和。 ## 为什么 intval 比 HtmlReplace 强 过滤方式 | 能防 SQL 注入吗 | 能防 XSS 吗 | HtmlReplace(DedeCMS 内置) | 不可靠 | 可(转义 HTML 实体) | addslashes | 不可靠(GBK 字符集下能绕过) | 不可 | mysqli_real_escape_string | 对引号内场景可靠 | 不可 | intval | 对纯整数场景绝对可靠 | 不可(但整数 ID 不参与 HTML 输出) | 预处理语句(PDO / mysqli prepare) | 绝对可靠 | 不可 | 对 ID 这种"应该是整数"的字段,intval() 是最稳的——任何非数字内容直接变 0 或截断到第一个数字。预处理语句更通用但 DedeCMS 老代码大量用字符串拼接 SQL,临时性补丁里 intval 性价比最高。 ## 补丁还不够:mtypes.php 里的其它注入点 原帖只补了 UPDATE 这一处,但同文件里还有 DELETE 操作 $delids 也存在风险点: $delids = '0'; $mtypeidarr = array_filter($mtypeidarr, 'is_numeric'); foreach($mtypeidarr as $delid) { $delids .= ','.$delid; } $query = "DELETE FROM `#@__mtypes` WHERE mtypeid IN ($delids) AND mid='$cfg_ml->M_ID';"; 这里 array_filter(..., 'is_numeric') 已经过滤了非数字键值。但 is_numeric() 接受 "123e+0" 这种科学计数法,理论上可以 "1,2);DROP TABLE x;--" 这种被 is_numeric 拒掉,所以这里相对安全。但严谨做法是每个 $delid 也 intval 一遍: foreach($mtypeidarr as $delid) { $delid = intval($delid); // 双保险 $delids .= ',' . $delid; } ## 系统化排查 DedeCMS 整套代码的注入风险 mtypes.php 不是孤例——DedeCMS V5.6 / V5.7 / 早期 SP 版本里类似的"数组键名直拼 SQL"漏洞还有十几处。要系统化排查: ## 用 grep 扫"foreach + 拼 SQL"模式 cd /www/wwwroot/yoursite.com/ # 找所有 foreach 后面跟 query 拼接的代码 grep -rn "foreach.*as.*=>" --include="*.php" -A 5 | \ grep -B 1 -E "(query|sql).*\\\$" | head -100 每条命中行都需要人工审查:"键名是否被过滤?值是否被过滤?"。 ## 重点排查路径 - /member/ — 会员中心,前台用户可达; - /dede/ — 后台,要求登录但 CSRF 可能放大攻击; - /include/payment/ — 支付回调,外部可触发; - /plus/ — 插件目录,历史漏洞最多; - /api/ — 各种 API 入口。 ## 已知的 DedeCMS 高危 CVE CVE | 位置 | 类型 | 受影响版本 | CVE-2018-9134 | /member/mtypes.php | SQL 注入(本文) | V5.7 SP1 之前 | CVE-2018-7700 | /uploads/ | 任意文件上传 | V5.7 SP1 之前 | CVE-2019-8362 | /dede/file_manage_view.php | RCE | V5.7 SP2 之前 | CVE-2020-25008 | /include/dialog/ | 任意文件读取 | V5.8.1 之前 | CVE-2022-23047 | /dede/article_string_mix.php | SQL 注入 | V5.7.106 之前 | 5 条只是"明显的"——实际累计高危漏洞超过 50 条。建议:① 升级到最新社区分叉 DedeBIZ;② 定期跑 D 盾、河马等漏洞扫描器;③ 上 WAF 兜底。 ## SQL 注入的攻击面与影响 ## 攻击者能做什么 - 读取任意表:UNION SELECT password FROM dede_admin 拿管理员密码; - 修改任意数据:UPDATE dede_admin SET pwd='伪造哈希' WHERE id=1; - 删除整表:DROP TABLE dede_archives; - 写文件:MySQL 的 SELECT ... INTO OUTFILE '/path/shell.php'(需要 FILE 权限),写 webshell; - 读文件:MySQL 的 LOAD_FILE('/etc/passwd')(需要 FILE 权限); - RCE:组合注入 + outfile 写入 PHP webshell,再访问执行任意命令。 ## 防御纵深 即使 PHP 代码有注入,也能通过其它层次缓解: - 数据库账号最小权限:DedeCMS 数据库用户只给 SELECT/INSERT/UPDATE/DELETE,不给 FILE / DROP,注入也写不了 webshell; - WAF:Cloudflare / 阿里云 WAF 自带 SQL 注入特征拦截,能挡 80% 自动化扫描; - Web 服务器禁止 PHP 在上传目录 (https://zhangwenbao.com/method-of-disable-directory-permissions-for-php-directory-execution.html)执行:即使 webshell 写到 /uploads/,Nginx 配置 location /uploads/ 拦 .php 请求; - fail2ban 拉黑高频 SQL 注入特征请求。 ## 用 PDO 预处理语句重构 mtypes.php 临时补丁是 intval。彻底修复用 PDO 预处理,永久无忧: // 假设把 DedeCMS 的 $dsql 替换成 PDO 实例 $pdo = new PDO('mysql:host=localhost;dbname=dede;charset=utf8mb4', $user, $pass); foreach ($mtypename as $id => $name) { $stmt = $pdo->prepare("UPDATE dede_mtypes SET mtypename = :name WHERE mtypeid = :id AND mid = :mid"); $stmt->execute([ ':name' => HtmlReplace($name), ':id' => (int)$id, // 显式转 int ':mid' => (int)$cfg_ml->M_ID, ]); } 预处理语句的好处:参数永远不会被解释为 SQL 关键字,无论攻击者怎么构造 payload 都不会破坏 SQL 结构。性能上 PDO 预处理还能让数据库缓存执行计划,比拼字符串 SQL 还略快。 ## 补丁部署的注意事项 ## 升级前务必备份 cp /www/wwwroot/yoursite.com/member/mtypes.php /backup/mtypes.php.$(date +%Y%m%d) 万一打补丁打错或冲突,能 1 分钟回滚。 ## 同时清模板缓存 修改 PHP 文件后清 data/tplcache/,避免老缓存还在用旧版逻辑。 ## 验证补丁生效 用 sqlmap 测试一下: sqlmap -u "https://yoursite.com/member/mtypes.php?dopost=save" \ --data="mtypename[1*]=test&cfg_ml=1" \ --cookie="DedeUserID=xx; DedeLoginTime=xx" \ --level=3 --risk=2 未打补丁的版本会被 sqlmap 立刻发现注入;打了补丁的应报"no injection point found"。 ## 长期建议:彻底升级或迁移 修一处漏洞只是补一个小坑。彻底解决: - 升级到 DedeBIZ V6.x:社区分叉版,活跃维护,已合并大部分历史 CVE 补丁; - 切到 WordPress + 迁移内容:WordPress 的核心代码经过更严格审计,安全生态更成熟; - 用静态站生成器:Hexo / Hugo / Jekyll 等,没有动态后端就没有 SQL 注入面。 2026 年仍然新建 DedeCMS 站风险远大于收益——历史漏洞之多、社区维护之弱,每个上线日都是赌博。 ## 运行时防御:上 WAF 即使代码有补丁,上 WAF 是额外的兜底。Cloudflare 免费版自带 OWASP 核心规则集,能拦绝大多数自动化扫描器: Cloudflare WAF Rule (Free Plan): - SQL Injection (SQLi) protection: ON - Cross-Site Scripting (XSS) protection: ON - File Inclusion (LFI/RFI): ON 阿里云 WAF / 腾讯云 WAF 国内方案类似。WAF 不是替代代码补丁——是补丁的补充。代码必须修对,WAF 是兜底。 ## 监控与告警 装好 WAF 后再加监控: - fail2ban:扫 access.log 里高频的 ?dopost=save、SQL 注入特征字符串,发现一个 IP 反复试探就拉黑; - Web 服务器日志告警:HTTP 500 / 502 错误突增(可能是注入后 SQL 报错); - 数据库监控:突然出现的大量 UPDATE / DELETE 事件,流量异常; - 邮件告警:管理员账号被改密码、登录 IP 异常都触发邮件。 ## 应急响应流程(如果发现已被入侵) 万一晚一步——发现站点已经被注入或挂马,按以下顺序处理: - 立刻断网:暂停 Nginx / Apache 服务,避免攻击者继续操作; - 全量备份当前状态:包括所有 PHP 文件、数据库、access.log、error.log——证据保全; - 修改所有数据库密码:DedeCMS 数据库账号、管理员账号哈希; - 查 access.log 找入侵时间窗:搜可疑 payload(含 '、UNION、SELECT 的 POST 请求),确定攻击起点; - 对比文件 mtime:find . -name "*.php" -newer /var/log/messages -mtime -7 找最近 7 天修改过的 PHP 文件,重点排查是否被加 webshell; - 清理 webshell:常见后门函数 eval() / assert() / preg_replace 加 /e 修饰符,grep 整套代码找; - 修补漏洞:本文 mtypes.php 补丁 + 升级到 DedeBIZ; - 重置所有用户密码:admin、会员都强制重置; - 恢复服务:开 Nginx,监控 24 小时; - 事后复盘:写故障报告,归档攻击轨迹与防御加固清单。 ## 找 webshell 的 grep 命令速查 # 找含 eval 的 PHP 文件 grep -rln "eval(" --include="*.php" /www/wwwroot/yoursite.com/ # 找 base64 编码的 PHP 字符串(webshell 常用伪装) grep -rln "base64_decode" --include="*.php" /www/wwwroot/yoursite.com/ # 找含 assert 的(assert 也能执行 PHP) grep -rln "assert(" --include="*.php" /www/wwwroot/yoursite.com/ # 找文件包含 + 用户输入(LFI/RFI) grep -rEn "(include|require)(_once)?[\s(]+\\\$_(GET|POST|REQUEST)" --include="*.php" /www/wwwroot/yoursite.com/ 每条命中的文件都要人工审查——是合法用法还是 webshell。 ## 常见问题解答 ## 怎么知道我的站有没有被攻击过? 三个排查点:① 看 web 服务器 access.log 找 mtypename[ + 引号 / OR / UNION 等可疑 payload;② 看 dede_admin 表有没有意外新增的管理员账号;③ 看 dede_mtypes 表的 mtypename 字段有没有奇怪的值(被 UPDATE 篡改痕迹)。fail2ban 日志和 WAF 告警也是入口。 ## HtmlReplace 真的不能防 SQL 注入吗? 不能。HtmlReplace 是 DedeCMS 自带的 HTML 转义函数(把 < 变 < 之类),针对 XSS 设计的。对 SQL 没有任何保护——SQL 不解析 HTML 实体。要防 SQL 注入要用 intval / addslashes / 预处理语句。 ## DedeCMS 已经停止维护,补丁从哪里拿? 三个来源:① DedeBIZ 社区分叉(https://www.dedebiz.com/)已经合并了大部分历史 CVE,下载最新版即可;② D 盾 / 河马等国产 PHP 漏洞扫描器自带 DedeCMS 补丁库;③ 自己根据 CVE 公告手动改代码(本文方式)。生产环境推荐方案 ①。 ## 升级 DedeBIZ 会影响现有数据吗? 不会。DedeBIZ 与 DedeCMS V5.7 数据库 schema 兼容——只是替换 PHP 文件,数据库不动。但升级前必须备份所有 PHP 文件 + 数据库,万一新版本与你的自定义模板/插件冲突,能回滚。 ## 会员中心是否要彻底关闭? 看业务。如果你的站不需要会员(纯展示型企业站),关闭整个 /member/ 目录是最安全的——直接 Nginx deny all 这个 location,所有相关漏洞瞬间消失。如果业务需要会员,要么升级 DedeBIZ,要么自己审计代码 + 加 WAF。 ## 修补丁后还要不要清 Web 服务器缓存? 看你用的缓存层。① OPcache(PHP 自带 opcode 缓存)会缓存 PHP 文件解析后的字节码,文件改动后默认监测 mtime 自动失效,但保险起见可 opcache_reset() 或重启 PHP-FPM;② 静态页面缓存(Cloudflare / 阿里云 CDN)跟 PHP 修改无关,但如果你的攻击页 URL 被 CDN 缓存了攻击响应,要清 CDN 缓存。 ## WAF 拦截的请求会影响真实用户吗? 会,但概率极低。WAF 偶尔会误报(比如用户输入的关键词恰好像 SQL)。Cloudflare WAF 有"挑战模式"——可疑请求弹验证码而不是直接 403,平衡安全与用户体验。生产建议上线前用 Log Mode 跑 1 周看告警准确率,再切到 Block Mode。 ## 预处理语句会比拼字符串 SQL 慢吗? 不会。① 数据库会缓存预处理执行计划,二次执行更快;② PHP PDO 的预处理本质就是给参数加引号 + 转义,开销几乎为零;③ 在大查询里两者性能差异在毫秒级。预处理语句永远是更优选择,没有不用的理由。 ## DedeCMS V5.7 SP3 / SP4 修了多少漏洞? SP2 之后官方维护放缓,SP3 / SP4 主要是 PHP 8 兼容修复 + 少量 CVE 补丁。绝大部分高危历史漏洞要靠社区分叉(DedeBIZ)维护。如果你站点版本老于 V5.7 SP2,立刻升级是头等优先。 ## 2026 年还能新装 DedeCMS 吗? 从技术上能,但非常不推荐。原因:① 大量历史漏洞累积;② 官方停更,没有持续安全补丁;③ PHP 8 / MySQL 8 兼容性差;④ 现代 SEO 友好度不如 WordPress;⑤ 模板/插件生态萎缩。新建项目用 WordPress / Hexo / 自研 + 现代框架的成本远低于"用 DedeCMS + 持续打补丁"。 ## 权威参考资料 ## 织梦Cookie泄漏SQL注入漏洞怎么修?保哥落地版加收尾清单 - URL:https://zhangwenbao.com/dedecms-inc_archives_functions-php-cookies-sql.html - 分类:织梦CMS教程 - 发布:2018-06-30 | 更新:2026-06-01 - 摘要:DedeCMS会员投稿模块的inc_archives_functions.php存在Cookie泄漏导致SQL注入的逻辑漏洞,fields hash因签名算法可逆被攻击者打穿会员中心。本文完整复盘真实受损取证、官方临时修复为何不彻底、动态盐值加HMAC-SHA256的稳定方案,以及修复后的七项收尾和五个踩坑。 - 关键词:织梦漏洞,SQL注入,DedeCMS安全,PHP安全,CSRF防御 > **TLDR**:摘要:DedeCMS会员投稿模块的inc_archives_functions.php存在Cookie泄漏导致的SQL注入,fields hash因签名算法可逆被攻击者打穿会员中心。本文复盘客户站点的真实受损,点出官方临时修复为何不够,给出动态盐值加HMAC-SHA256的稳定方案、修复后的收尾清单、其它必须同步修的高危点和迁出织梦的长期判断。 > 摘要:DedeCMS会员投稿模块的inc_archives_functions.php存在Cookie泄漏导致的SQL注入,fields hash因签名算法可逆被攻击者打穿会员中心。本文复盘客户站点的真实受损,点出官方临时修复为何不够,给出动态盐值加HMAC-SHA256的稳定方案、修复后的收尾清单、其它必须同步修的高危点和迁出织梦的长期判断。 这两年陆陆续续接手了不少老站点的安全审计工作,DedeCMS(织梦)依然是绕不开的存量。哪怕官方早已停更多年,仍有一批中小企业站、地方门户、行业资讯站跑在5.7 SP2这条线上。今年三月帮一位做机械配件批发的客户做例行扫描时又一次撞上了member/inc/inc_archives_functions.php (https://zhangwenbao.com/use-the-wordpress-condition-to-determine-the-function-to-execute-specific-code-on-a-specific-page.html)那个老问题——CSRF防御用的hash因为算法可逆而形同虚设,最终被人借此打穿了会员中心。本文把这个洞的完整复盘、修复思路、为什么官方那行替换代码并不能一劳永逸、长期方案的取舍写清楚,给同样要维护织梦遗产的同行做个参考。 ## 这个漏洞到底在做什么坏事 先说背景。inc_archives_functions.php是DedeCMS会员投稿模块的核心函数库,负责把附加字段(addonfields)渲染成表单。表单里会输出一个隐藏字段dede_fieldshash,作用是当用户提交时后端拿这个hash校验字段列表有没有被篡改,本意是防CSRF和参数污染。 问题出在签名算法。原始代码用md5函数对dede_addonfields拼接cfg_cookie_encode做签名输出到隐藏字段。而cfg_cookie_encode这个值在整个DedeCMS体系里是被反复使用的——它同时承担会员登录cookie的加密、支付校验、API接口签名等多种职责。也就是说只要攻击者能在前台拿到任何一个会员投稿表单(这是公开页面,根本不需要登录),就能反推出cfg_cookie_encode的md5参与值,然后拿这个泄漏出来的密钥去伪造其他模块的请求,最典型的就是绕过member/buy.php的支付校验,以及通过后台SQL注入 (https://zhangwenbao.com/ecshop-includeslibinsertphp-file-sql-injection-vulnerability-repair-method.html)接口构造合法签名。 实际看到的攻击链是这样:攻击者先批量抓取互联网上跑DedeCMS的站点,访问/member/article_add.php,从返回的HTML里抠出dede_fieldshash,再结合默认或弱口令的cfg_cookie_encode字典做碰撞,命中后就开始往会员组写入SQL payload。整个过程不需要任何登录态,扫描器一晚上能扫几万个目标。这个漏洞在黑产圈被称为织梦cookie泄漏到SQL注入的一键打穿利用链,至今仍在Github的几个老旧渗透工具里能看到现成的POC。 ## 漏洞的技术细节 要理解修复思路必须先讲清楚dede_fieldshash的设计缺陷。一个合格的CSRF token应该满足三个条件: 条件1:随机性。token值应该来自加密随机源(如openssl_random_pseudo_bytes),每个用户每个会话都不同。dede_fieldshash完全不满足——它是确定性函数md5对固定输入的运算结果,相同输入永远得到相同输出。 条件2:与会话绑定。token应该和当前用户的session ID绑定,攻击者拿到A用户的token不能用在B用户的请求上。dede_fieldshash完全不绑定会话,只要算法已知任何人都能伪造任何用户的有效token。 条件3:算法不可逆。即使攻击者拿到token也不能反推出生成token所需的密钥。dede_fieldshash用的是md5,本身确实不可逆,但它的密钥(cfg_cookie_encode)泄漏后,攻击者就能正向计算出任意dede_addonfields对应的token。这才是真正的命门。 这种设计在2008年DedeCMS诞生时还能接受,但到2014年HMAC-SHA256加salt的标准做法已经普及之后,DedeCMS仍然没有跟进升级。官方在2018年的最后一次安全公告里只是建议用户自行修改salt但没有从根本上重写签名算法。 ## 客户站点的真实受损情况 那个机械配件站被打穿的迹象很明显,我整理了完整的取证清单: 异常账号:dede_member表里多了三个mtype为个人但rank为10(超级会员)的账号,注册时间集中在凌晨2点到4点。这种rank值越权是典型的CSRF借dede_fieldshash绕过的产物。 异常栏目:dede_arctype栏目表里被插入了一条typename为乱码、reid为0的顶级栏目。乱码typename是攻击者用来后续插入跳转链接的容器,正规审核时容易被忽略。 恶意上传:uploads目录下出现了类似1745xxxxxx.gif.php这种双扩展名文件,文件内容是经过Base64编码的WebShell。双扩展名是绕过DedeCMS默认上传过滤的经典手段。 访问日志:Apache access.log里能看到大量带dede_fieldshash参数的POST请求,UA是统一的python-requests/2.28,IP分布在乌克兰、罗马尼亚、巴拿马等三个国家共12个C段。 处理这种事的标准流程是:先断网快照——也就是把整个站点目录打包、数据库dump一份留作取证;然后再做止血修复,最后才是漏洞复盘。这个顺序千万不能乱,很多同行直接上手改代码,结果攻击者留的后门和被篡改的数据库记录就追不回来了。 取证打包的具体命令:用tar czf把整个网站目录打包到加密的目录、用mysqldump导出dede开头的所有表(dede_member、dede_archives、dede_arctype、dede_addonarticle等关键表)、用cp保留Apache access_log与error_log最近30天数据。这套取证包应保存至少90天作为后续追溯证据。 ## 官方推荐的临时修复方案为什么不够好 网上流传最广的修复,也是早年织梦圈互相抄的版本,是把那行代码改成在md5的输入里多塞一个固定字符串dls6.com(原作者站点域名)作为盐值。 核心思路是在参与hash的字符串中间插一段固定盐值让攻击者即使知道算法也无法直接复现hash。这个改法能用但不够好,原因有两点: 第一,盐值是写死的明文,任何拿到源码的人都知道,等于换了把锁但钥匙还挂在门口。开源CMS的源码本身就是公开的,写死盐值等于伪安全。 第二,更关键的是,这个修复只动了输出端,校验端archives_check.php里同样位置的md5计算也得同步改,否则提交后会一直报字段被篡改。我见过不止一次有人只改了一处,结果会员投稿全挂,又把代码改回去结果漏洞复活。 另一个隐藏问题是缓存层的污染。DedeCMS的data/cache目录会缓存表单生成的HTML片段,如果你只改了PHP代码没清缓存,老的hash会从缓存里继续返回攻击者依然能用旧hash提交。修复后必须执行rm -rf data/cache下所有tpl_缓存文件强制重建。 ## 保哥落地的修复版本 给客户实际落地的版本是这样:用defined判断常量DEDE_FIELD_SALT是否存在,存在就用它,不存在就用sha1拼接当前文件路径加文件修改时间作为兜底盐值。然后把这个动态盐值塞进md5的输入里。 同时在data/common.inc.php里加一行define调用,定义DEDE_FIELD_SALT为一段32位随机字符串。这样盐值放进了配置文件,跟着站点权限走,攻击者哪怕拿到源码也拿不到这个常量。32位随机字符串可以用php -r打印bin2hex(random_bytes(16))生成,每次部署都换一组保证不可猜测。 校验端要做对应替换。除了member/inc/inc_archives_functions.php本身,还要去member/inc/archives_check.php、include/arc.archives.class.php这两个文件里搜dede_fieldshash,所有出现md5拼接的地方都要用同一套盐值规则改一遍。改完清空data/cache目录再测。 更彻底的做法是把md5全部换成HMAC-SHA256:用hash_hmac函数对dede_addonfields做SHA256运算,密钥就是DEDE_FIELD_SALT与cfg_cookie_encode的拼接。HMAC的设计就是为了抵御扩展长度攻击和密钥猜测,比简单md5加salt安全得多。但这种改法对DedeCMS的兼容性影响要测——某些插件可能依赖固定md5输出长度(32字符)。 ## 修复完之后还要做的收尾清单 光改代码远远不够。完整的收尾清单: 1. 轮换cfg_cookie_encode。这个值已经被认为泄漏必须改。改的时候注意所有在线会员的登录态会失效需要提前公告。新的cfg_cookie_encode可以用php -r打印bin2hex(random_bytes(20))生成40字符随机串。 2. 清理可疑账号和栏目。按上面取证拿到的特征SQL反查,凡是注册IP在异常区段、注册时间在凌晨、logintime为0的高权限账号全部禁用(设rank为0并加is_disabled标志)而不是删除——删除会丢取证证据。 3. 全站webshell扫描。用D盾或者河马扫一遍uploads、include、plus三个目录。织梦的后门常常伪装成index.php.bak、config.cache.php这种迷惑性文件名,单纯按扩展名扫不出来必须用专业的WebShell特征引擎。 4. 关闭会员中心非必要入口。如果业务上根本用不到会员投稿直接把/member/整个目录改名或加nginx拒绝规则受攻击面立刻砍掉一大半。我的客户案例里80%的中小企业站根本不需要会员投稿功能。 5. 加WAF规则。在nginx层加一条规则拦截所有包含dede_fieldshash但referer不来自本站的POST能挡掉90%的扫描器。具体写法是if判断http_referer不匹配自有域名时直接return 403。 6. 升级PHP版本。很多老织梦站还在跑PHP 5.6,建议升到PHP 7.4或8.0。新PHP版本对md5碰撞攻击有更强的防御,且性能提升40%以上。 7. 启用ModSecurity或安全狗。在Web层加CRS规则集(OWASP Core Rule Set)能拦截大部分常见的SQL注入、XSS、文件上传攻击。CRS对DedeCMS有专门的virtual patches规则集免费提供。 ## 五个真实踩坑记录 坑1:盐值放在GitHub开源仓库的config文件被泄漏。某客户用GitHub管理网站代码,把DEDE_FIELD_SALT写在了纳入版本控制的config文件里。仓库公开后盐值立刻被搜索引擎索引完全失去保护作用。修复方法是把盐值放在.gitignore忽略的本地文件里通过环境变量加载或者用Git Secrets扫描确保密钥不进版本库。 坑2:CDN缓存了带旧hash的HTML页面。修复完代码但CDN边缘节点还缓存着旧hash的HTML,攻击者依然能拿到旧hash绕过新算法。修复方法是修改完代码立刻在CDN控制台执行Purge Everything刷新所有缓存或者把HTML的Cache-Control设为no-store禁止CDN缓存动态页面。 坑3:DedeCMS自动更新模块覆盖了我的修复。某客户开了DedeCMS的在线自动更新功能,更新后我的修复被官方代码覆盖回原版。修复方法是禁用自动更新(删除plus/uploadupdate.php或在后台关闭),用Git或SVN管理代码每次手动审核才合并。 坑4:备份还原导致漏洞复活。客户运维做月度备份还原测试时把6个月前的备份还原回生产,6个月前的代码没有我的修复,漏洞瞬间复活。修复方法是把所有安全补丁打成standalone脚本放在版本库,每次还原后自动执行该脚本应用全部安全补丁。 坑5:开发环境与生产环境盐值同步导致测试数据污染生产。开发同事在自己电脑上用了和生产相同的DEDE_FIELD_SALT,开发产生的测试hash意外提交到了生产数据库导致部分用户数据异常。修复方法是开发、测试、生产三套环境用完全不同的盐值,且开发环境数据库与生产物理隔离。 ## 长期方案:要不要从织梦迁出去 这个问题被问过太多次。真实建议是分情况: 如果站点权重高、外链 (https://zhangwenbao.com/is-external-link-building-important-for-seo.html)多、SEO数据有积累,且团队没有二次开发能力,那就老老实实打补丁守着,严格做好上述清单里的每一项,并把整个member、plus、include三个目录设为只读(chmod 555),写权限只在发文时短暂开放。配合服务器层的SELinux或AppArmor强制访问控制能把入侵难度提升到很高。 如果站点流量本来就一般,或者有改版重做的计划,那2026年了真没必要再守织梦——我手里几个迁到Typecho的客户,半年内被扫描爆破的告警数从每天几百条降到了个位数。迁移本身也不复杂,织梦的dede_archives加dede_addonarticle两张表导出后写个脚本对应到Typecho的contents表,URL用nginx的rewrite做301平滑过渡即可。 迁移到WordPress也是常见选择,但要注意WordPress的攻击面同样不小,每年也有大量WordPress 0day。从安全角度看Typecho的攻击面最小、代码量最少,是中小企业站的理想选择。从生态角度看WordPress插件生态最丰富,对运营人员友好。从未来看Headless CMS加静态生成(如Hugo、Hexo、Astro加Strapi)的组合在新站点里越来越流行,攻击面接近于零,但学习成本高需要技术团队支持。 对一个还在跑DedeCMS的中小企业站来说,迁移决策的关键变量是未来3年是否计划新增功能。如果只是稳定输出现有内容不再扩展,原地修补是性价比最高的选择;如果有新业务(会员体系、电商、社区)要加进来,趁早迁移到现代CMS避免在老平台上叠加新坑。 ## DedeCMS其他必须同步修的高危点 处理这一处之后习惯一次性补完整套清单: plus/search.php的变量覆盖:搜索接口的keyword参数没做严格过滤,攻击者可以构造SQL注入或反射型XSS。修复是在接收keyword后立刻用addslashes加htmlspecialchars双重过滤。 plus/recommend.php的SQL注入:推荐接口的aid参数直接拼接进SQL语句。修复是把所有$_GET和$_POST取到的数字型参数强制intval转换。 include/dialog/select_soft_post.php的任意上传:管理后台的文件选择器如果Cookie验证不严,攻击者可以伪造Cookie上传任意文件。修复是在每个dialog开头加CSRF token校验。 tpl.php的模板执行:模板编辑接口可以写入任意PHP代码到模板文件触发RCE。修复是在tpl.php的文件保存逻辑前增加扩展名白名单校验只允许htm、html、css扩展名。 这几个洞被扫描器盯了快十年,跑织梦的站不补这些等于裸奔。我建议把所有这些补丁打包成一个install_security_patches.php放到根目录,部署完一键运行所有补丁同步生效。 ## 常见问题解答 ## 我改完代码后会员发文一直提示字段被篡改怎么办? 原因99%是输出端和校验端的md5算法没有同步。除了member/inc/inc_archives_functions.php还要去member/inc/archives_check.php以及include/arc.archives.class.php里搜dede_fieldshash,所有出现md5拼接的地方都要用同一套盐值规则改一遍。改完清空data/cache目录里的tpl_开头的缓存文件再测。如果还是失败检查PHP的magic_quotes_gpc设置某些老PHP版本会自动转义引号导致hash输入不一致。 ## cfg_cookie_encode改了之后会员的登录cookie会失效PC端和移动端都要重新登录吗? 会的全部失效。这个值参与了所有cookie的加密签名。建议改的时候避开业务高峰提前在站内挂公告并且改完后顺手把cookie有效期延长一些避免短期内大量登录请求把数据库压满。如果业务对用户体验敏感可以用平滑过渡方案:保留旧cfg_cookie_encode和新cfg_cookie_encode同时存在7天,新登录用新值老cookie继续按旧值校验,7天后下线旧值。 ## 能不能干脆把dede_fieldshash这个隐藏字段去掉让校验端跳过验证? 非常不推荐。这等于把CSRF防御整个拆掉攻击者可以直接构造跨站请求帮你的会员发任意内容、改任意附加字段。正确做法永远是修补签名算法而不是关闭校验。如果你嫌dede_fieldshash实现太弱可以彻底替换为现代的SameSite Cookie加双重提交Cookie模式但工作量较大需要前后端配合。 ## 除了这一处DedeCMS还有哪些已知的高危点必须同步修? 对应回答见前文第九节,包括plus/search.php变量覆盖、plus/recommend.php SQL注入、include/dialog/select_soft_post.php任意上传、tpl.php模板执行四个核心高危点的具体修复方法。补充一个2024年新发现的洞:member/edit_baseinfo.php的头像上传接口绕过可以上传任意文件类型。这个洞需要单独打补丁在头像处理前用finfo_file检测真实MIME类型。还有member/buy_action.php的支付绕过和plus/erraddsave.php的XSS也建议同步修补。 ## 用第三方WAF(如阿里云Web应用防火墙、Cloudflare WAF)能不打代码补丁吗? 不建议。WAF是基于已知攻击模式做特征匹配,对dede_fieldshash这种基于密钥泄漏的逻辑漏洞防御能力很弱——攻击者构造的SQL注入payload可能完全合法(看起来像正常数据),WAF的特征库匹配不到。代码补丁必须打WAF只能作为前置防线增加攻击难度。两者是叠加关系不是替代关系。 ## 修复之后还需要做渗透测试验证吗? 强烈建议。修复完之后用Burp Suite手动构造一组带篡改hash的请求看服务端是否正确拒绝。Burp的Repeater模块可以反复修改dede_fieldshash值测试边界条件。也可以用sqlmap工具自动扫描验证修复有效性。如果你不会渗透测试可以请第三方安全公司做一次黑盒测试,2025年中小企业的Web渗透测试报价通常在3000到8000元。 ## 升级PHP版本会破坏DedeCMS的功能吗? 有部分破坏需要适配。DedeCMS 5.7对PHP 7.4基本兼容但对PHP 8.0以上有几个已知问题:mysql_系列函数被移除导致部分老代码报致命错误(需要全局替换为mysqli或PDO)、非严格模式下的字符串与数字比较行为改变(影响某些SQL拼接逻辑)。建议升级前先在测试环境跑一遍完整功能验证,过不了的代码逐个修复。我维护过的几个客户站点从PHP 5.6升到PHP 7.4工作量约8到16人时,升到PHP 8.0需要20到40人时。 ## 如果客户预算有限只能做一件事应该做什么? 把/member/目录加nginx拒绝规则。绝大多数中小企业站根本不用会员投稿功能,直接屏蔽这个目录能消除90%以上的攻击面。具体做法是在nginx的server块里加一条location ^~ /member/ 然后return 403,重新加载nginx即可生效。这个操作只要5分钟,零代码改动,是性价比最高的应急措施。其他补丁可以等预算到位后再分批完成。 ## 2026年DedeCMS的官方支持还有可能恢复吗? 不会。DedeCMS的官方维护团队2018年解散后再无任何官方更新,原域名dedecms.com在2022年被卖给第三方运营,现在的站点和原作者已无关系。社区也没有形成统一的非官方维护组织(曾有几个分支项目如DedeCMSv5.7Beta但都没坚持下去)。所以从制度上看DedeCMS已经事实上死亡。如果你的站点还跑在织梦上长期方案要么自己接手当永久维护者要么趁早迁移其他CMS。 ## 权威参考资料 ## DedeCMS随机文章arclist实战:参数+3种性能优化 - URL:https://zhangwenbao.com/dedecms-random-access-to-articles.html - 分类:织梦CMS教程 - 发布:2018-06-25 | 更新:2026-06-02 - 摘要:深度讲解DedeCMS arclist orderby=rand在28万行文章表上的执行计划与优化路径:weight列加索引方案完整SQL与cron、ID区间随机PHP实现、Redis集成代码、Fisher-Yates JS二次打乱与Ajax异步加载、多DedeCMS版本arclist行为差异表。 - 关键词:织梦文章调用,DedeCMS arclist,随机推荐,ORDER BY RAND > **TLDR**:摘要:DedeCMS的arclist用orderby rand在28万行文章表上会拖垮性能。本文讲清最基本的随机调取、限定栏目、详情页随机推荐排除当前文章,再给性能优化的三条路——weight列加索引配cron、ID区间随机、Redis集成,以及静态化站点用Fisher-Yates加Ajax的假随机破解和各版本arclist行为差异。 > 摘要:DedeCMS的arclist用orderby rand在28万行文章表上会拖垮性能。本文讲清最基本的随机调取、限定栏目、详情页随机推荐排除当前文章,再给性能优化的三条路——weight列加索引配cron、ID区间随机、Redis集成,以及静态化站点用Fisher-Yates加Ajax的假随机破解和各版本arclist行为差异。 保哥从2014年开始用织梦DedeCMS给客户搭企业站、地方门户、行业资讯站,最高峰同时维护过十几个DedeCMS项目。在这些项目里,首页随机推荐、文章详情页底部猜你喜欢、侧栏热门几乎是标配,而最稳妥、最不依赖插件的实现办法,就是用DedeCMS自带的arclist标签配合 orderby='rand' 参数来随机抓取。 这篇笔记把我这些年在生产环境用过的所有姿势整理成一份可直接复制粘贴的速查手册:从最基础的随机10篇文章,到限定栏目、限定缩略图、控制摘要长度、避免抓到当前文章本身,再到大数据量下随机抓取的性能问题,全部覆盖。文末附 8 条常见 FAQ。 ## 最基本的随机调取写法 这是最常用的版本,调10篇文章的标题,按随机方式排序: {dede:arclist pagesize='10' titlelen='35' orderby='rand'}
  • [field:title/]
  • {/dede:arclist} 几个关键参数解释一下: - pagesize='10':抓取10篇文章,常见取值是5、8、10、12,跟前端栅格数对齐就好。 - titlelen='35':限制标题最大长度。注意DedeCMS这里的长度指字节,2字节算一个汉字,所以 titlelen='35' 在纯中文标题里实际是 17 个汉字。 - orderby='rand':核心参数,让DedeCMS在SQL里使用 ORDER BY RAND() 随机排序。 保哥踩过的小坑:早期我习惯写 [field:title/] 不加链接,结果客户在后台改完文章顺序后发现随机推荐压根没起作用——因为单元格里没放 [field:arcurl/],用户点不进去,自然以为标签坏了。记得一定要把链接补上。 ## arclist 标签的全部可用参数速查 参数 | 含义 | 默认值 | 实战取值 | pagesize | 抓取条数 | 10 | 5/8/10/12 | titlelen | 标题最大字节 | 30 | 30~50 | infolen | 摘要最大字节 | 250 | 80~160 | typeid | 栏目ID列表(逗号分隔) | — | 5,8,12 | channelid | 模型ID(普通文章=1) | — | 1/2/3 | flag | 属性筛选(c=推荐、h=头条、s=幻灯、a=特荐) | — | c/h/s/a | orderby | 排序字段(rand/id/click/lastpost/sortrank (https://zhangwenbao.com/method-to-dedecms-article-set-a-dream-day.html)/senddate) | id | rand | orderway | 升降序 | desc | asc/desc | imgwidth | 缩略图宽度 | 120 | 240 | imgheight | 缩略图高度 | 90 | 160 | keyword | 关键词包含筛选(用|分隔多个) | — | SEO|关键词 | idlist | 显式指定aid列表 | — | 123,456 | notypeid | 排除栏目ID | — | 99 | limit | 偏移与条数(MySQL LIMIT) | — | 3,5 | arcid | 排除文章ID(用于详情页猜你喜欢) | — | ~aid~ | 这张表里 limit 和 pagesize 经常被搞混——limit='3,5' 等价于 MySQL 的 LIMIT 3,5(从第 4 条开始取 5 条),pagesize='5' 是从头取 5 条。两者不要同时给,会产生歧义。 ## 限定栏目随机调取 企业站经常有这种需求:产品中心首页只随机推 5 个产品,新闻中心首页只随机推 5 篇新闻。这时候要加 typeid 参数,值就是 DedeCMS 后台“常用操作 - 栏目管理”里看到的栏目ID: {dede:arclist pagesize='10' titlelen='35' typeid='5' orderby='rand'}
  • [field:title/]
  • {/dede:arclist} 如果想同时随机抓多个栏目,可以用英文逗号分隔,例如 typeid='5,8,12'。如果想包含子栏目一起抓,再加 idlist='' 或者写在 channelid 里都可以,但保哥个人习惯是直接把要的栏目ID全列出来,逻辑最直白,避免DedeCMS内部递归判断带来的歧义。 保哥的小经验:如果客户后台老是新增、合并、删除栏目,写死 typeid 维护成本会变高。这时候我会用 channelid='1' 按内容模型筛选(比如普通文章是 channelid='1'、图集是 channelid='2'),这样新增栏目也能自动被覆盖。 ## 跨多栏目随机的执行计划 typeid='5,8,12' 会被 DedeCMS 翻译成 WHERE typeid IN (5,8,12)。配合 ORDER BY RAND() 在 MySQL 8 上的执行计划长这样: EXPLAIN SELECT id, title FROM dede_archives WHERE typeid IN (5,8,12) AND arcrank > -1 ORDER BY RAND() LIMIT 10; +----+-------------+---------------+------+----------------+--------+---------+------+-------+----------------------------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+---------------+------+----------------+--------+---------+------+-------+----------------------------------------------+ | 1 | SIMPLE | dede_archives | range| typeid,arcrank | typeid | 4 | NULL | 18420 | Using index condition; Using temporary; ... | +----+-------------+---------------+------+----------------+--------+---------+------+-------+----------------------------------------------+ 关键看 Extra 列的 Using temporary——MySQL 用临时表存所有候选行+随机数,然后 filesort 一次。文章表过 10 万条这个临时表会落盘,IO 一上来 TTFB 就崩。后面性能优化章节有具体的破解办法。 ## 控制缩略图与摘要的随机推荐 详情页底部猜你喜欢通常需要带封面图和一句话摘要,写法是这样的: {dede:arclist pagesize='6' titlelen='30' infolen='80' orderby='rand' imgwidth='240' imgheight='160'}
  • [field:title/]

    [field:title/]

    [field:description function='cn_substr(@me,80)'/]

  • {/dede:arclist} - infolen='80':摘要长度,单位还是字节。 - imgwidth 和 imgheight:DedeCMS在生成缩略图链接时会按这个尺寸返回缩略图路径,配合CSS里的 object-fit: cover 排版会更整齐。 - [field:description function='cn_substr(@me,80)'/]:用 cn_substr 强制按汉字截断,避免半个字符的乱码。 如果某些文章压根没传缩略图,渲染出来会是空 src,影响用户体验。保哥通常会在 arclist 标签里加一个判断,没缩略图的就用占位图: [field:litpic runphp='yes']if(empty(@me)) @me='/images/placeholder.png';@me=@me;[/field:litpic] ## 详情页随机推荐排除当前文章 你有没有遇到过这种尴尬:详情页底部的随机推荐里,第一个就是用户当前正在看的这篇?解决办法是在 arclist 上加 notypeid 或者直接利用 idlist 排除当前 aid: {dede:arclist pagesize='6' titlelen='30' orderby='rand' idlist='~aid~' notid='yes'}
  • [field:title/]
  • {/dede:arclist} 但 idlist 在DedeCMS不同版本里的排除语义不一致,更稳的写法是直接用 likearticle 标签或者自定义SQL: {dede:sql sql='SELECT id, title, litpic FROM dede_archives WHERE typeid=~typeid~ AND id<>~aid~ AND arcrank>-1 ORDER BY RAND() LIMIT 6'}
  • [field:title/]
  • {/dede:sql} 这里 ~aid~、~typeid~ 是 DedeCMS 在文章页可以直接读到的两个魔术变量,分别表示当前文章 ID 和栏目 ID。arcrank 大于 -1 是为了过滤掉未审核的稿件。 ## 随机抓取的性能优化 ORDER BY RAND() 在 MySQL 里是出了名的慢——MySQL 会对每一行算一次随机数再排序,文章表上万条以后就能感觉到首页 TTFB 明显变长。保哥的几个实战经验: - 加缓存。DedeCMS 的 arclist 默认是不缓存的,如果首页随机块每次都打数据库,并发一上来就挂。可以借助 {dede:include filename='xxx.htm'/} 加 OPcache 之类的方案,把随机块单独生成。 - 改用伪随机。在文章表里加一列 weight INT,每天凌晨用 cron 随机刷一次值,前端 ORDER BY weight LIMIT N,性能从全表扫排序变成索引扫描。 - 用 ID 区间随机。先 SELECT MAX(id), MIN(id) FROM dede_archives,PHP 里在区间内随机生成 N 个 ID,再 WHERE id IN (...) 抓回来,速度比 RAND() 快一个数量级。 - 限制 typeid 范围。orderby='rand' 配合 typeid 能让 MySQL 走二级索引子集,比全表 RAND() 强很多。 保哥前年给一个地方资讯站做优化,文章表 28 万条,首页 4 块随机推荐合计加载 1.8 秒。改成 weight 列加每日 cron 刷新以后,首页随机块整体耗时压到了 60ms 以内。 ## 方案 A:weight 列伪随机的完整 SQL 与 cron -- 一次性建列与索引 ALTER TABLE dede_archives ADD COLUMN weight INT UNSIGNED DEFAULT 0; ALTER TABLE dede_archives ADD INDEX idx_typeid_weight (typeid, weight); -- 每日凌晨刷新随机权重 UPDATE dede_archives SET weight = FLOOR(RAND() * 4294967295) WHERE arcrank > -1; -- 前端 SQL SELECT id, title, litpic FROM dede_archives WHERE typeid IN (5,8,12) AND arcrank > -1 ORDER BY weight LIMIT 10; cron 配置(每天凌晨 2 点跑一次): 0 2 * * * /usr/bin/mysql -u root -p$DB_PASS dedecms -e 'UPDATE dede_archives SET weight=FLOOR(RAND()*4294967295) WHERE arcrank>-1;' > /dev/null 2>&1 实测 28 万行 UPDATE 全表大约 9 秒。建议在低峰期跑,避免和正常发稿的 INSERT 抢行锁。 ## 方案 B:ID 区间随机的 PHP 实现 function get_random_articles($count = 10, $typeid = null) { global $dsql; $where = "arcrank > -1"; if ($typeid) $where .= " AND typeid IN ({$typeid})"; $range = $dsql->GetOne("SELECT MIN(id) AS minId, MAX(id) AS maxId FROM dede_archives WHERE {$where}"); if (!$range) return []; $candidates = []; for ($i = 0; $i < $count * 3; $i++) { $candidates[] = mt_rand($range['minId'], $range['maxId']); } $ids = implode(',', array_unique($candidates)); $rows = []; $dsql->SetQuery("SELECT id, title, litpic FROM dede_archives WHERE id IN ({$ids}) AND {$where} LIMIT {$count}"); $dsql->Execute(); while ($r = $dsql->GetArray()) $rows[] = $r; return $rows; } 这种方案对 ID 连续性敏感——如果你的 archive ID 间隔很大(比如频繁删稿留下空洞),mt_rand 可能命中空洞导致候选不足。所以我才生成 3 倍候选,多取一些保证总能拿到 $count 条。 ## 方案 C:Redis SRANDMEMBER 集成 把所有文章 ID 提前丢进 Redis 的 Set 里,前端用 SRANDMEMBER 拿 N 个: // 每日 cron 刷新(PHP CLI) $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->del('article_ids'); $rows = $dsql->GetArray("SELECT id FROM dede_archives WHERE arcrank > -1"); foreach ($rows as $r) $redis->sAdd('article_ids', $r['id']); // 前端随机抓 $ids = $redis->sRandMember('article_ids', 10); $inIds = implode(',', $ids); $rows = $dsql->GetArray("SELECT id, title FROM dede_archives WHERE id IN ({$inIds})"); Redis 的 SRANDMEMBER 是 O(N) 内存采样,10 个元素无论从 1000 还是 100 万个 ID 里取都是亚毫秒级。这是 28 万文档站点跑下来最快的方案——实测 P99 1.2ms。 ## 静态化站点的"假随机"破解 DedeCMS 的核心卖点之一是全静态化,但 arclist 标签是在生成 HTML 时执行的,意味着随机一次后所有访客看到的都是同一批文章。要破解这个矛盾,三种思路: - 把随机块改成 SSI/ESI include:Nginx ssi on; + 这种方式 Nginx 边缘处理,每次请求都拿到新的随机块,但其它静态部分依然走文件系统缓存。 - 前端 JS 二次打乱:DedeCMS 生成时多取 30 条文章,前端用 Fisher-Yates shuffle 二次打乱后只渲染前 10 条。 - Ajax 异步加载:HTML 里只放占位 div,DOMContentLoaded 后请求一个 random.php 接口。 ## JS Fisher-Yates 二次打乱完整代码 // DedeCMS 模板里多生成 30 条,data-pool 存到一个隐藏元素
      这种方案对 SEO 影响小(页面里依然有 30 条文章的链接被搜索引擎抓到),用户体验上每次刷新都不一样。 ## Ajax 异步加载方案 // 后端 /random.php -1"; if ($typeid) $where .= " AND typeid = {$typeid}"; $rows = $dsql->GetArray("SELECT id,title,litpic FROM dede_archives WHERE {$where} ORDER BY weight LIMIT {$count}"); header('Content-Type: application/json'); echo json_encode($rows, JSON_UNESCAPED_UNICODE); // 前端 fetch('/random.php?n=10').then(r => r.json()).then(data => { document.getElementById('recommend').innerHTML = data.map(x => `
    • ${x.title}
    • ` ).join(''); }); 注意 /random.php 要加 cache-control: public, max-age=300 让 CDN 缓存 5 分钟——纯随机其实不需要每个用户都不同,5 分钟刷新一次的"窗口随机"既减压又保留新鲜感。 ## 不同 DedeCMS 版本 arclist 行为差异 版本 | orderby='rand' 是否原生支持 | notypeid 参数 | limit 起始位 | 5.6 SP3 | 是 | 支持 | 从 0 开始 | 5.7 | 是 | 支持 | 从 0 开始 | 5.7 SP2 | 是(部分二开补丁会改) | 支持 | 从 0 开始 | 5.7.106 GBK | 是 | 支持 | 从 0 开始 | 5.7.110 UTF-8 | 是 | 支持 | 从 0 开始 | 5.8 RC1 | 是 | 改名为 notype | 从 0 开始 | 5.8 RC1 把 notypeid 改成了 notype,但实测兼容旧写法,主要是文档和参数名有冲突时按 5.8 文档为准。如果你跨版本迁移模板,建议先在测试环境跑一遍所有 arclist 标签。 ## 常见踩坑提醒 保哥简单列几个新人最容易栽进去的坑: - titlelen 单位是字节不是字数,全中文站建议直接给 titlelen='40'(也就是 20 个汉字)做缓冲。 - orderby='rand' 和 flag 同时用要小心。比如 flag='c' 推荐文章,如果推荐稿件本身就少,加上 pagesize='10' 可能根本凑不齐 10 条。 - 后台预览看到不随机? DedeCMS 默认会缓存模板和 SQL 解析结果,改完模板记得到后台“生成 - 更新主页 HTML”,并清掉浏览器缓存。 - 静态化站点 arclist 是在生成 HTML 时执行的,意味着随机一次后所有访客看到的都是同一批文章。要做成真随机得切换为动态调用,或者在前端用 JS 二次打乱。 - UTF-8 与 GBK 站点的字节算法不一样:UTF-8 下中文 3 字节、GBK 下中文 2 字节,所以同一段标题在不同编码站点用 titlelen='30' 截出来的汉字数完全不同。 - arcrank 字段的过滤一定不能省:未审核稿件 arcrank=-1,如果你的 SQL 没写 arcrank>-1,会把后台还在审稿状态的文章也随机抓出来挂到前台。 ## 常见问题解答 ## orderby='rand' 在 DedeCMS 5.7 SP2 上报错怎么办? 5.7 SP2 默认的 arclist 标签解析是支持 rand 的,如果报未知排序方式,先检查 include/taglib/arclist.lib.php 里有没有被二开过,或者插件改写了排序白名单。把 orderby 临时换成 id 看看是否正常调用,能正常就八九不离十是排序白名单的问题。具体定位办法是在 arclist.lib.php 里搜 sortenum 或者 orderby 字符串,找到那段白名单数组手工补 rand 进去。 ## 随机抓出来的文章总是同一批,怎么破? 大概率是站点开启了静态化,HTML 已经生成好了,自然就不会变了。把对应模板里的 arclist 改用动态调用(比如换成 dede:php 加 SQL 的方式),或者把这一块单独做成 ajax 异步加载。本文第六节有 JS 二次打乱和 Ajax 加载的完整代码。 ## typeid 写了多个 ID 为什么只生效第一个? 注意中间的逗号必须是英文半角逗号,而且 ID 之间不能有空格。另外 typeid='5,8,12' 这种写法在某些早期版本里只识别第一个,碰上这种情况可以改用 idlist='5,8,12' channelid='1' 的组合写法。具体识别情况跟 include/taglib/arclist.lib.php 里 explode 逗号的逻辑有关,5.7 之后版本基本都没问题。 ## 能不能让随机推荐每小时换一批,而不是每次刷新都换? 可以。最简单的办法就是上一节里说的 weight 列方案,把刷新 cron 改成每小时跑一次。如果不想改表结构,也可以用 PHP mt_srand(date('YmdH')) 给随机数种子做时间分桶——同一小时内的 RAND 序列固定,跨小时自动变化,效果就是每小时一换。这种做法另一个好处是 CDN 缓存可以直接命中,因为同一小时内所有访客拿到的是同一份 HTML。 ## 能不能根据用户兴趣做"个性化随机"? 狭义来说 arclist 是按全站随机的,做不到个性化。但可以变相实现:在文章详情页底部用 arclist 加 typeid=~typeid~(当前栏目随机),用户读哪个栏目就推哪个栏目的随机,是最低成本的"上下文相关推荐"。再进一步可以用 keyword 参数把当前文章 tag 传进去,arclist 会按 keyword LIKE 模糊匹配,做到关键词相关随机。 ## 大流量站随机块怎么避免数据库连接打满? 第一步:上 Redis 缓存方案(本文第五节方案 C),把数据库压力下降一个数量级。第二步:在 PHP 端用 single flight 或者 mutex 锁,避免缓存击穿——同一时刻只有一个请求去打数据库回源,其它请求等结果。第三步:CDN 边缘缓存 random.php 的 JSON 输出 60-300 秒。这三层叠加,单机 QPS 可以从原本的 500 撑到 50000 以上。 ## 随机出来的文章可不可以避免推荐过去 30 天的旧文? 可以。在 SQL WHERE 里加 senddate > UNIX_TIMESTAMP(NOW())-2592000 就是只随机最近 30 天的稿件。但要注意这种做法对内容更新 (https://zhangwenbao.com/old-blog-content-update-merge-delete-seo-sop.html)慢的站点不友好——如果你最近 30 天只发了 20 篇,随机块每次都是这 20 篇,新鲜感反而不如全量。一般我会用"加权时间衰减":weight 列每周刷一次,新稿权重高 5 倍,老稿权重正常,自然倾斜到新文章但不完全排除老文章。 ## arclist、likearticle、specart 这三个标签有什么区别? arclist 是通用列表标签,可任意组合参数;likearticle 是 DedeCMS 内置的"相关文章 (https://zhangwenbao.com/wordpress-adds-related-article.html)"标签,按 keywords/tag 做模糊匹配,参数较少但更智能;specart 是专题文章列表,只在专题页可用。随机推荐场景 95% 用 arclist 加 orderby='rand' 就够了,likearticle 的内部 SQL 写得不够规范,在大表上性能反而比 arclist 差。 ## 织梦手机端文章图片不自适应怎么修?runphp正则加CSS - URL:https://zhangwenbao.com/dedecms-mobile-article-picture-adaptive-screen-css.html - 分类:织梦CMS教程 - 发布:2018-06-24 | 更新:2026-06-01 - 摘要:DedeCMS手机端文章图片老是溢出屏幕。本文给出修复:在article_article.htm把正文改成runphp,用四条preg_replace剥掉img的width、height属性和内联宽高,再配max-width:100%与height:auto的CSS实现自适应,附性能基准、与其他CMS对比和回滚预案。 - 关键词:图片自适应,织梦手机端,移动端SEO,runphp,织梦DedeCMS > **TLDR**:摘要:DedeCMS手机端文章图片老是溢出屏幕,根源是编辑器写死的宽高。本文给修复——在article_article.htm把正文改成runphp,用四条preg_replace剥掉img的width和height属性与内联宽高,再配max-width与height auto的CSS实现自适应,讲模板里小于号的转义、图片懒加载与CDN路径替换,附性能基准、与其他CMS对比和回滚预案。 > 摘要:DedeCMS手机端文章图片老是溢出屏幕,根源是编辑器写死的宽高。本文给修复——在article_article.htm把正文改成runphp,用四条preg_replace剥掉img的width和height属性与内联宽高,再配max-width与height auto的CSS实现自适应,讲模板里小于号的转义、图片懒加载与CDN路径替换,附性能基准、与其他CMS对比和回滚预案。 保哥从 2014 年开始陆续接手过几十个织梦 DedeCMS 项目,2016 年前后开始大量遇到一个共性问题:PC 端编辑器里粘贴的图片在手机模板上显示不下,要么超出屏幕被横向裁切,要么把整页撑爆出现横向滚动条。原因很简单——百度编辑器、UEditor 在保存富文本时会把图片的 width、height 写死成像素值,比如 ,PC 看着没问题,到了 375px 宽的手机屏自然就溢出了。 这篇笔记记录保哥目前在生产环境跑了五年多的解决方案:用 {dede:field.body runphp=yes} 配合正则把 img 标签里所有写死的宽高(包括 HTML 属性和内联 style)剥掉,再交给 CSS 接管自适应。覆盖六个维度:根因分析、核心正则方案、模板转义机制、CSS 配套、懒加载/CDN 进阶、踩坑回顾,文末附 FAQ 总结生产环境最常被问到的疑难。 ## 问题根源:编辑器写死的宽高 打开 DedeCMS 后台 → 系统 → 系统配置 → 核心设置,看一下 body 字段使用的编辑器。绝大多数老站是 UEditor 或者百度编辑器旧版,它们的默认行为是: - 上传图片时按图片真实分辨率写入 width / height。 - 用户在编辑器里手动拖动图片缩放时,会把缩放结果写进 style="width:800px;height:600px"。 - 复制其他网站文章过来时,原网站的 style 也会一起带过来,甚至连 class="size-large" 这种 WordPress 风格的钩子类名都顺带带进来。 这三种写法在手机模板上都会导致溢出。仅仅靠 CSS img{max-width:100%;} 是搞不定 style="width:800px" 的,因为内联 style 优先级最高,CSS 必须 !important 才能覆盖,而 !important 又会破坏其他需要保持原尺寸的图片(比如表情、二维码图标),最终只能用更高优先级的选择器叠加 !important,写法越堆越长,维护成本一路飙升。 所以更稳妥的思路是:在输出 HTML 时,把 img 标签里写死的宽高全部抹掉,然后让 CSS 用普通优先级接管。这样 CSS 文件不需要写一堆 !important,仅仅用类选择器就能控制图片表现,对未来主题升级也最友好。 ## 核心方案:runphp 正则替换 打开手机端文章内容页模板(一般在 /templets/default_m/article_article.htm 或者你站点对应的手机模板路径),找到这一行: {dede:field.body/} 替换成下面这段: {dede:field.body runphp=yes} global $cfg_basehost; $str = @me; $search = '/(]+>)/is'; $search1 = '/(]+>)/is'; $search2 = '#()#i'; $search3 = '#()#i'; $content = preg_replace($search, '$1$3', $str); $content = preg_replace($search1, '$1$3', $content); $content = preg_replace($search2, '$1$2', $content); $content = preg_replace($search3, '$1$2', $content); @me = $content; {/dede:field.body} 四条正则分别在做这些事: - $search:匹配并去掉 里的 width="800" 这种 HTML 属性。 - $search1:去掉 height="600" 这种 HTML 属性。 - $search2:去掉 style 里的 width:800px;。 - $search3:去掉 style 里的 height:600px;。 保哥提醒一句:保存模板时一定要用 UTF-8 无 BOM 编码,否则 DedeCMS 解析 runphp 段会出诡异错误。Windows 下可以用 Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 或 VS Code 切换编码模式后再保存。如果你看到前端报 "Unexpected character" 之类的提示,9 成是 BOM 引起的。 另外注意 (?(2)\2|\s) 这种条件正则的写法:当第二个捕获组(即引号字符)匹配成功时使用 \2 作为终止符,否则用空白符 \s 终止。这是为了同时兼容 width="800"、width='800'、width=800 三种写法——是的,UEditor 老版本在某些复制场景下确实会输出无引号的属性值,不少同行的正则恰好漏了这一种,导致替换不完整。 ## 模板里的 < 转义说明 眼尖的同学会发现,原始示例代码里写的是 <img.*? 而不是 当成标签分界,直接写 。 这是织梦官方文档里给出的标准写法,跨版本兼容性最好——5.6、5.7 SP1、SP2 全部能跑。如果你只跑 5.7 SP2,直接写 < > 多数情况也没问题,但保哥的建议是统一遵守官方写法,避免哪天升级或迁移到其他分支(比如 ECTouch (https://zhangwenbao.com/ecshop-mobile-ectouch.html)、织梦升级版社区分叉)时翻车。 ## CSS 端配合:让图片真正自适应 光把宽高抹掉还不够,得让 CSS 接管尺寸。在手机端的全局样式里加一段: .article-content img { max-width: 100%; height: auto; display: block; margin: 12px auto; border-radius: 6px; } .article-content img[src*="qrcode"], .article-content img.icon { max-width: none; width: auto; display: inline-block; margin: 0; } 保哥的小习惯: - .article-content 用具体类名而不是直接写 img,避免误伤导航 logo 之类的小图。 - 二维码、表情等小图单独豁免,否则 max-width:100% 会让它们跟着容器拉伸变模糊。 - height: auto 必须加,不然有些浏览器会以 0 计算高度,造成图片塌陷。 - display: block; margin: auto; 让单图水平居中,符合移动端阅读习惯。 - border-radius: 6px; 移动端图片加个微圆角更现代,但电商类、说明书类图片建议保留直角避免误导比例。 有同学可能会问:能不能直接用 CSS 的 img { width: auto !important; height: auto !important; } 一把梭?理论上能盖住内联宽高,但代价是会顺带把那些原本就没写宽高的图片也强制 auto,碰上 display: flex 容器会让图片塌成 0×0,得不偿失。在 HTML 端先净化、CSS 用普通优先级接管,是 2026 年最稳妥的组合。 ## 进阶:图片懒加载与 CDN 路径替换 抹掉宽高之后,可以顺手把懒加载和 CDN 路径替换也一起做了,避免再起一个钩子。在 runphp 块里继续加几行: // 懒加载:把 src 改成 data-src,再用 JS 滚动到视口时还原 $content = preg_replace( '/ { entries.forEach((e) => { if (e.isIntersecting) { const img = e.target; img.src = img.dataset.src; io.unobserve(img); } }); }, { rootMargin: '200px' }); document.querySelectorAll('.article-content img[data-src]').forEach((i) => io.observe(i)); 保哥实测下来,这一套组合拳打完,手机端首屏图片字节数能压到原来的 30%-40%,LCP 在 4G 下基本能稳定在 2 秒内。如果服务器走的是 HTTP/2 或 HTTP/3、再叠加 WebP/AVIF 格式自动协商,首屏 LCP 还能再砍 200~400ms。 有一点需要提醒:原生 loading="lazy" 属性其实在 2026 年已经被全部主流浏览器支持,理论上你可以在正则里直接给 加上这个属性而不是搞 IntersectionObserver。但原生 lazy 的触发距离由浏览器决定,无法手动调,碰到长文章首屏要预加载第二张图时不够灵活;保哥个人偏好仍然是 IntersectionObserver + 自定义 rootMargin。 ## 性能与 SEO 影响评估 有同学担心 runphp 段每次输出都跑正则会拖慢页面响应。保哥用 microtime(true) 在生产服务器上量过,4 条 preg_replace 处理一篇正文(约 8000 字、20 张图)的耗时大约在 0.3~0.6 ms 之间,远小于 DedeCMS 模板编译开销。如果你启用了 DedeCMS 静态生成,这段正则只在生成 HTML 时执行一次,前端访问读静态文件,根本没运行时损耗。 SEO 角度看,去掉硬编码宽高反而对移动可用性评分有正向贡献。Google PageSpeed Insights 检查"图像具有显式宽度和高度"时是会希望宽高存在的(避免布局抖动),不过它判断的是"渲染尺寸"而非"原图尺寸",只要 CSS 给了稳定的容器宽度并配合 aspect-ratio 属性,CLS(累积布局偏移)依然可以稳定在 0.1 以下。所以 2026 年比较推荐的写法是:抹掉硬编码宽高 + CSS 给图片容器加 aspect-ratio: 16/9; 这类提示。 ## 常见踩坑提醒 保哥列几个最容易翻车的点,照着避就行: - 正则里的引号要看清是单引号还是双引号。runphp 块本身是单引号字符串,里面再嵌套单引号要用 \' 转义,复制粘贴时容易丢掉反斜杠。 - 修改完模板要清缓存。DedeCMS 后台 → 生成 → 更新文档 HTML,否则前端看不到变化。如果开了 OPcache 还得 service php-fpm reload。 - 如果用了静态生成,记得整站重新生成一次 HTML,否则只有新发布的文章会用新模板。 - PC 端别也套这套正则。PC 模板上图片往往依赖原尺寸做版式,强行抹宽高会把版心搞乱。这套方案只在手机模板里用就够了。 - 图集模型 addonimages 不走 body 字段,所以这套方案不影响图集页,图集页另有 arclistsg 等标签。 - 注意 data-original、data-src 等懒加载属性的兼容。如果你的站点早期用过其他懒加载插件(比如 LazyLoad.js),原文里可能已经存在 data-original,强行二次替换会把原图地址搞丢。建议先 preg_match 探测有没有这种属性,再决定要不要走懒加载分支。 - CDN 替换要小心 https 站点引用 http 资源。如果你的 DedeCMS 还是 http,但 CDN 是 https,浏览器会上报混合内容警告。一律推荐站点先全站切到 https 再做 CDN 替换。 ## 与其他 CMS 的对比参考 这套思路并非 DedeCMS 独占。其他主流 CMS 都有类似机制,迁移或同时维护多套站点的同学可以参考: - WordPress:通过 the_content 过滤器钩子,使用 add_filter('the_content', 'remove_img_dim') 在 functions.php 里挂载正则。WP 6+ 自带 wp_filter_content_tags,能批量改写图片输出,配合自定义过滤器去宽高更优雅。 - Typecho:在主题 functions.php 里挂 $archive->content 过滤插件,或者直接重写 Content_Plugin。 - Discuz!:在 source/class/discuz/discuz_application.php 或者插件钩子里改写帖子正文。 - ECShop / ECTouch:直接在 article.php 控制器里 $smarty->assign 之前替换正文。 核心思路都是一致的——"在输出层做净化,让 CSS 接管表现层",写死的宽高在哪个 CMS 都不该出现在前端 HTML 里。 ## 生产环境实战清单 保哥把上线流程整理成 7 步检查表,照单做就行: - 先备份原 article_article.htm 模板。 - 在测试环境替换为新模板,发一篇含 5 张图的测试文章验证。 - 用 Chrome DevTools 切到 iPhone 12 Pro 视口(390×844)检查图片是否自适应。 - 用 Lighthouse 跑一次,关注 Cumulative Layout Shift、Largest Contentful Paint。 - 登录 Search Console (https://zhangwenbao.com/google-search-console-branded-query-filter.html) → 移动设备易用性报告,等爬虫抓取后看新模板是否触发新的告警。 - 正式环境部署,立刻 tail -f /var/log/nginx/error.log 观察是否有 PHP 错误。 - 整站重新生成 HTML,逐分类抽查若干文章页面,确认效果一致。 这 7 步走完,基本可以把一个老 DedeCMS 站点的移动端图片问题彻底解决,剩下的就是日常发文章时养成习惯——上传图片不要在编辑器里手动拖动缩放,让模板层统一处理。 ## 运维监控与回滚预案 这套方案虽然成熟,但任何修改模板的操作都建议有监控和回滚预案。保哥的标准做法: 第一步是用 git 管理整个 templets 目录。修改前先 git add . && git commit -m "before runphp patch",万一线上出问题,git checkout HEAD~1 -- templets/default_m/article_article.htm 一秒回滚。如果你不熟 git,至少在服务器上 cp article_article.htm article_article.htm.bak.$(date +%Y%m%d) 一份。 第二步是用 GTmetrix 或 PageSpeed Insights 在改动前后各跑一次,关注几个关键指标:FCP(First Contentful Paint)、LCP、CLS、TBT(Total Blocking Time)。改完之后 LCP 应该有 200ms 以上的下降,CLS 在配套 aspect-ratio 后应该比改前更低。如果反而恶化了,立刻回滚检查。 第三步是挂一个简单的前端错误监控,比如 Sentry 的免费额度或者自建一个 window.onerror 日志收集端点。runphp 输出错误一般会以
      形式注入到正文里,被错误监控捕获是反应最快的。 第四步是抽样回访旧文章。改完模板后,挑十来篇有大量图片的老文章,逐篇用真机访问,看是否有任何图片显示异常。如果发现某些早期文章里的 写法很怪(比如带了 hspace、vspace、border 这些 HTML 4 时代的属性),可以再补几条正则把这些过期属性也一并清掉。 第五步是每月跑一次模板编译验证。DedeCMS 自带 plus/recommend.php 之类的入口偶尔会被攻击者扫描,触发编译错误时模板缓存会出问题;定期跑一次 php -f /www/wwwroot/zhangwenbao.com/dede/template_test.php(自己写一个简单的批量编译脚本)能提前发现隐患。 ## 为什么不直接用 PHP 内置 DOMDocument 有同学会问:现成的 DOMDocument + DOMXPath 不是比正则更稳吗?理论上是的,但保哥推荐继续用正则,原因有三: - DOMDocument 加载 HTML 片段会自动补上 、 等外层标签,输出时还得手动剥掉,代码量反而比正则更长。 - 编辑器吐出的 HTML 经常不严格(无引号属性、漏闭合标签),DOMDocument 默认会报一堆 warning,需要 libxml_use_internal_errors(true) 屏蔽,再加 LIBXML_HTML_NOIMPLIED 控制行为,初学者很容易踩坑。 - 性能差距明显。同样处理一篇 8000 字、20 张图的正文,DOMDocument 大约耗时 2~3 ms,是 4 条 preg_replace 的 5 倍以上。在静态生成场景下差距不大,但在动态渲染场景下成本会累积。 当然如果你要做的不只是抹宽高,还要给图片加 alt、加水印、转 多源标签等更复杂的改写,那 DOMDocument 才是正确选择,正则会很快变成不可维护的怪物。 ## 常见问题解答 Q1:用了 runphp=yes 之后页面 500 怎么办? 先检查模板文件编码,必须 UTF-8 无 BOM。然后到 /data/tplcache/ 把对应缓存文件删掉,让 DedeCMS 重新解析模板。如果还是 500,打开 /include/common.inc.php 顶部把错误显示打开看具体行号,多半是引号或转义出问题。最后确认服务器 PHP 版本,DedeCMS 5.7 SP2 在 PHP 8.0+ 上 runphp 解析器有些函数会被 deprecate,建议保留 PHP 7.4 跑老站。 Q2:替换之后图片虽然不溢出了,但宽高比变形了怎么办? 检查 CSS 有没有写 height: auto,缺这一句浏览器就不会按比例缩放。还有一种情况是父容器有 display: flex + align-items: stretch,图片会被拉成容器高度,把对齐方式改成 flex-start 即可。第三种情况是站点同时启用了 lazysizes 之类的库,它会临时给图片设 data-aspectratio,跟 height: auto 撞车,需要在它的初始化参数里关掉自适应。 Q3:为什么有些文章图片还是溢出? 大概率是文章里用了 嵌套图片,CSS 选择器只覆盖了 .article-content img 没覆盖到表格里。补一条 .article-content table { max-width: 100%; } 一般就好了。还有一种情况是图片用了
      包裹,记得给 figure 也加 max-width: 100%。再深一层就是 iframe 嵌入的视频或地图,那需要额外的响应式包裹层 .video-wrapper。 Q4:我想让 PC 端保留宽高、只在手机端抹掉,可以吗? 完全可以,这套 runphp 方案本来就只放在手机模板里。如果你的手机端是和 PC 端共用模板靠 CSS 媒体查询区分的,可以改成在 PHP 里判断 User-Agent:检测到移动设备再执行正则替换,PC 直接 @me = $str; 跳过。逻辑大约 5 行代码就能写完。更优雅的做法是写一个全局插件,在 arc.archives.class.php 里基于 $GLOBALS['cfg_ismoblie'](DedeCMS 内部是否移动端的标志位)来分流。 Q5:替换会不会影响 SEO 收录? 不会。Googlebot (https://zhangwenbao.com/googlebot-crawl-limits-2mb-deep-analysis.html) 渲染页面时执行的是最终 HTML,runphp 改写过的 标签和原始标签从爬虫角度看完全等价。事实上去掉硬编码宽高后,移动可用性评分还会上升,对移动优先索引下的排名只有正面影响。需要做的是配套用 aspect-ratio CSS 属性给图片容器一个比例提示,这样 CLS 不会因为去掉 width/height 而恶化。 Q6:模板里能不能把这套正则封装成函数复用? 可以。在 /include/extend.func.php 里写一个 function img_strip_dim($str){...},然后模板里 {dede:field.body function="img_strip_dim(@me)"/} 就能直接调用,比 runphp 块更干净,也方便多个模板共用。注意 extend.func.php 修改后要重启 PHP-FPM(或者清空 OPcache)才生效。 Q7:正则会不会误伤 markdown 文章里 字面量? DedeCMS 的 body 字段无论是 HTML 还是 markdown,最终都会经过编辑器转成 HTML 存入数据库。runphp 看到的 @me 已经是 HTML 字符串,不存在原始 markdown 残留。只有在你的站点用了 markdown 插件并且没启用即时渲染时才需要担心,这种情况建议先在 markdown 渲染器里处理图片宽高,runphp 仅作兜底。 以上就是保哥这套手机端图片自适应方案的全部细节,从根因分析到正则、CSS、懒加载、CDN、性能/SEO 评估、再到常见坑位与多 CMS 对比,覆盖了五年多生产环境验证下来的所有关键点,希望对还在用 DedeCMS 维护移动端的同学有所帮助。下一篇笔记保哥会接着讲怎么把 DedeCMS 老站的内容批量导出 markdown 迁到 Typecho/Hugo,敬请期待。 ## 权威参考资料 ## DedeCMS恶意文件删除漏洞修补实战:47步排查+完整应急方案 - URL:https://zhangwenbao.com/dedecms-v5-7-registered-user-arbitrary-file-deletion-vulnerability-archives_check_edit-php-vulnerability-repair.html - 分类:织梦CMS教程 - 发布:2018-06-23 | 更新:2026-05-16 - 摘要:保哥处理过十次类似事故的实战经验,本文给出删common.inc.php到拿webshell的完整攻击链解析、URL编码绕过的加强补丁、关键文件备份脚本、入侵响应完整流程和迁移决策。 - 关键词:织梦漏洞,网站安全,DedeCMS,任意文件删除,路径遍历 > **TLDR**:摘要:DedeCMS的恶意文件删除漏洞危害极大——删掉common.inc.php就能配合安装程序拿到webshell。本文解析完整攻击链,给出官方修复的代码层解读、针对URL编码绕过的加强补丁、关键文件的备份脚本,再讲除了打补丁还该做什么、万一已被删文件的入侵响应完整流程和迁移决策。 > 摘要:DedeCMS的恶意文件删除漏洞危害极大——删掉common.inc.php就能配合安装程序拿到webshell。本文解析完整攻击链,给出官方修复的代码层解读、针对URL编码绕过的加强补丁、关键文件的备份脚本,再讲除了打补丁还该做什么、万一已被删文件的入侵响应完整流程和迁移决策。 保哥这几年帮客户处理DedeCMS(织梦)的安全事故太多了,光是任意文件删除 (https://zhangwenbao.com/temporary-repair-method-of-post-php-arbitrary-file-deletion-vulnerability-in-wordpress.html)这一类漏洞前前后后修过不下十几次。这次我把member/inc/archives_check_edit.php文件中那个老牌的高危漏洞从原理到补丁完整复盘一遍,方便还在用v5.7老版本的朋友照着自查。文章里所有路径、代码片段、命令行操作都是我自己在测试服务器上一行一行敲过的,不是网上抄来的,请放心参考。这个漏洞的可怕之处不在于"删个文件",而在于通过删除特定文件能直接拿到WEBSHELL——下面会讲完整的攻击链。 ## 漏洞背景和危害评估 ## 为什么这个文件会被反复盯上 DedeCMS v5.7已经停止主线维护很多年了,但国内站长圈里至少还有几十万站点跑着这个版本。会员模块(member目录)在历史上爆出的漏洞最多,原因是这一块的代码写得比较早,对参数过滤的思路停留在2010年前后——大量地方直接信任POST和GET传过来的字符串再丢到文件系统操作里。 archives_check_edit.php这个文件的作用是会员投稿后审核编辑的接口,里面有一段处理缩略图字段(litpic)的逻辑。当用户提交编辑请求时,如果选择了删除原图,程序会调用unlink函数直接删掉文件路径所指向的文件。问题就出在这里: 第一缩略图路径来自前端表单字段;第二程序只校验了是不是空、文件是否存在;第三没有校验路径是否仍然在该用户的私人目录范围内。 结果就是:任何一个注册会员都能构造一个三个点点斜杠include/common.inc.php这样的相对路径让服务器把核心配置文件、首页文件、甚至数据库连接文件一删了之。 ## 漏洞CVE编号和披露时间线 这个漏洞在CNVD的编号是CNVD-2018-01221,CVE系列对应CVE-2018-9123。首次披露是2018年1月,POC在乌云镜像(已下线)和exploit-db上有公开记录。2018年4月织梦官方在论坛发了部分修补建议但没有合入主分支版本,导致后续下载的官方版本仍然存在漏洞。 2019到2022年间这个漏洞被广泛利用,是最常见的DedeCMS入侵手法之一。2023年之后随着WordPress和Typecho吃掉DedeCMS的市场份额,相关攻击有所减少但仍未消失。 ## 漏洞危害的完整攻击链 很多人以为任意文件删除最坏情况就是删个文件,其实这只是攻击链的起点。完整的攻击链: 第一步:删除install目录的index.php.bak文件(或者其他能触发install逻辑的文件),让站点重新进入安装界面。第二步:在安装界面里填入攻击者控制的数据库地址,让站点连到攻击者的MySQL。第三步:安装过程中织梦会写入一些初始化文件,攻击者控制数据库内容可以诱导写入webshell。第四步:拿到shell后植入持久后门。 另一条更直接的攻击链:删除特定的认证文件让某些防御机制失效,然后利用其他漏洞(比如soft_add.php模板注入)直接拿shell。我去年帮一个客户处理过类似事件,攻击者直接把data/common.inc.php删了整站立刻502,而nginx日志里只能看到一条普通的POST请求迷惑性极强。 所以这个漏洞的实际危害评级是Critical(CVSS 3.1基础分9.8),不是"删个文件"那么简单。 ## 漏洞验证和事故确认 在动手打补丁之前先确认一下版本和文件指纹。保哥的习惯是先做四步检查再决定怎么修。 ## 确认DedeCMS版本 登录FTP或SSH进入网站根目录查看data/admin/ver.txt: cat /www/wwwroot/example.com/data/admin/ver.txt 如果输出是20180109或更早的日期那就是受影响版本。官方在2018年之后也发过零星补丁但这个文件的修复始终没有合入主分支,很多人手里的最新版其实根本没修。也可以通过后台首页的版本信息确认,但有的攻击者会篡改后台显示的版本号迷惑管理员,文件层面的版本号最准。 ## 检查目标文件是否被改动过 stat /www/wwwroot/example.com/member/inc/archives_check_edit.php md5sum /www/wwwroot/example.com/member/inc/archives_check_edit.php 保哥实测过原版v5.7 SP2这个文件MD5是3a4e1b开头(不同子版本会有差异),如果你本地算出来的值和你之前备份的不一致那有可能已经被人动过手脚,需要先做完整的入侵排查再谈修复。stat显示的modify时间如果跟其他系统文件不一致,特别是远在系统装机时间之后的修改,重点怀疑。 ## 翻查会员相关日志 grep -r "archives_check_edit" /www/wwwlogs/example.com/ | tail -n 50 重点关注POST请求里出现litpic等于、oldlitpic等于字段且值里带两个点点或绝对路径的记录,那就是典型的尝试痕迹。如果发现大量来自同一IP的高频请求基本可以确认被定点攻击。 更精细的过滤:grep过滤出POST方法、URI包含archives_check_edit、且请求体包含点点(用URL编码%2e%2e或者裸点点),然后按IP聚合看分布。 ## 临时止血 如果发现已经有恶意请求先把整个member目录禁掉(nginx加一条location段deny all),再静下心来打补丁。不要在受攻击的过程中边打补丁边开放服务,很多时候攻击者会留有第二个webshell。临时止血的另一个选项是直接给文件加上拒绝执行权限:chmod 000 archives_check_edit.php,这样PHP无法读取该文件,相应功能直接报错但其他功能不受影响。 ## 官方修复方案的代码层面解读 ## 原始有问题的代码 原代码: $litpic = $oldlitpic; 这一行直接把表单提交的oldlitpic赋值给litpic后续用于unlink调用。完全没有任何校验。 ## 社区标准补丁 社区给出的标准补丁很简单,只是在原本的litpic赋值这一行后面追加了一段路径校验: $litpic = $oldlitpic; if (strpos($litpic, '..') !== false || strpos($litpic, $cfg_user_dir."/{$userid}/") === false) { exit('not allowed path!'); } 这段补丁做了两件事: 第一拒绝两个点点出现。直接堵掉相对路径回退的可能性。一旦字符串里包含两个点点立刻终止脚本。 第二强制校验前缀。cfg_user_dir是会员附件目录的根,比如/uploads/userup,再拼上斜杠userid斜杠就形成了当前会员的私人空间。如果传过来的路径里压根不包含这个前缀说明这个文件就不是当前会员的,更没资格删。 看似只有两行if实则把绝对路径绕过和相对路径绕过两条主要攻击链同时切断。需要说明的是这个补丁假设了cfg_user_dir和userid在到达这一行时已经被正确赋值——保哥审计过整个文件的执行路径确认在调用之前都做过session校验和配置加载,所以放心用。 ## 加强版补丁(推荐) 标准补丁能挡掉大多数攻击但仍有边界场景。比如攻击者用URL编码绕过(%2e%2e代替两个点点)或者用UTF-8变体编码。加强版补丁: $litpic = $oldlitpic; // 先做URL解码再校验,挡掉编码绕过 $decoded = urldecode( $litpic ); // 标准化路径(消除中间的./和.) $decoded = preg_replace( '#/+#', '/', $decoded ); // 多重检查 if ( strpos( $decoded, '..' ) !== false || strpos( $decoded, $cfg_user_dir . "/{$userid}/" ) === false || preg_match( '#[\\x00\\r\\n]#', $decoded ) // 空字节和换行符 || preg_match( '#\\.(php|phtml|inc|cgi|pl|jsp)$#i', $decoded ) ) { exit( 'not allowed path!' ); } // 用realpath再次校验 $realPath = realpath( DEDEDATA . '/../' . $litpic ); $allowedBase = realpath( DEDEDATA . '/../' . $cfg_user_dir . "/{$userid}/" ); if ( $realPath === false || strpos( $realPath, $allowedBase ) !== 0 ) { exit( 'not allowed path!' ); } 这个版本增加了:URL解码后再校验、空字节和换行符过滤(防止某些OS特性绕过)、禁止删除PHP和其他可执行扩展名的文件(即便在用户目录里)、用realpath做最终的物理路径校验。 ## 保哥的实战修复流程 ## 备份原文件 cd /www/wwwroot/example.com/member/inc/ cp archives_check_edit.php archives_check_edit.php.bak.20260507 备份文件名我习惯带上日期方便日后回滚或对照。注意备份文件不要以.bak结尾后直接放在公网可访问目录,建议挪到根目录之外或加上deny规则。否则攻击者可能通过guess URL直接下载备份文件查看修补前后的diff。 ## 编辑文件 用vim或者宝塔面板的在线编辑器都行但要注意编码必须保持UTF-8无BOM否则织梦会出现乱码或500错误。 vim archives_check_edit.php 搜索关键字oldlitpic:在vim里输入斜杠oldlitpic然后回车定位到litpic等于oldlitpic这一行把第三节里的修补代码贴在它的下面。 ## 校验语法 保存退出后先做语法检查再上线别让一个分号毁了整站: php -l archives_check_edit.php 输出No syntax errors detected才算OK。如果有Parse error立即用备份恢复重新编辑。 ## OPcache刷新 如果服务器开了OPcache(PHP 7和8默认开),修改文件后OPcache可能还在用旧版本。重启PHP-FPM或者强制刷新OPcache: service php-fpm reload # 或者 php -r "if(function_exists('opcache_reset')) opcache_reset();" ## 端到端验证 保哥喜欢用最朴素的方式验证:注册一个测试会员登录后台投稿然后在浏览器开发者工具里手动改oldlitpic字段分别测试以下三个payload: 正常路径:uploads/userup/2/img.jpg,期望执行成功。 带两个点点的路径:uploads/userup/2/三个点点斜杠data/common.inc.php,期望返回not allowed path! 越权访问别的会员:uploads/userup/3/img.jpg,期望返回not allowed path! URL编码绕过:uploads/userup/2/%2e%2e/%2e%2e/data/common.inc.php,期望也被拦截。 四种情况全部符合预期才能说明补丁真的生效了。 ## 加固延伸:除了打补丁还应该做的事 打完这一处补丁不代表万事大吉。DedeCMS v5.7的会员模块里类似的字符串拼接加文件操作模式还有不少,保哥的建议是把以下几件事一起做完。 ## 关闭会员模块(如果业务用不到) 后台系统-系统基本参数-会员设置里把会员功能关掉再在nginx配置里直接location段return 404,从入口就堵死比一行行打补丁高效得多。如果业务必须开会员功能至少把投稿、上传、修改等高危入口全部deny,只保留登录、注销、密码修改这几个基本入口。 ## 加上WAF规则 在宝塔自带的免费WAF或ModSecurity里加一条规则: SecRule ARGS:litpic|ARGS:oldlitpic "@rx \\.\\." \\ "id:1009001,phase:2,deny,status:403,msg:'DedeCMS path traversal',\\ tag:'attack-lfi',severity:'CRITICAL'" SecRule ARGS:litpic|ARGS:oldlitpic "@rx %2e%2e" \\ "id:1009002,phase:2,deny,status:403,msg:'DedeCMS encoded traversal'" SecRule ARGS:litpic|ARGS:oldlitpic "@rx \\.(php|phtml|inc)$" \\ "id:1009003,phase:2,deny,status:403,msg:'DedeCMS PHP file deletion'" 这三条规则会在请求进入PHP之前就把带两个点点、URL编码点点或者PHP文件名的危险参数拦截下来作为补丁失效时的兜底。 ## 文件权限收紧 会员上传目录给755,PHP文件目录给644,所有者统一为www用户: find /www/wwwroot/example.com -type d -exec chmod 755 {} \\; find /www/wwwroot/example.com -type f -exec chmod 644 {} \\; chown -R www:www /www/wwwroot/example.com 对核心文件(common.inc.php、config.inc.php等)可以更严格: chmod 444 /www/wwwroot/example.com/data/common.inc.php chattr +i /www/wwwroot/example.com/data/common.inc.php chattr +i会让文件变成immutable状态,连root都无法直接删除(必须先chattr -i),是最强的防删除手段。但要记住自己加了之后,正常更新数据库连接配置的时候要先去掉immutable属性。 ## 关键文件备份与恢复脚本 就算被删了文件,能秒级恢复也是好的。写一个简单的备份脚本: #!/bin/bash BACKUP_DIR=/root/dedecms_backup mkdir -p $BACKUP_DIR SOURCE=/www/wwwroot/example.com KEY_FILES=("data/common.inc.php" "include/config_base.php" "include/common.inc.php" "index.php") for f in "${KEY_FILES[@]}"; do cp "$SOURCE/$f" "$BACKUP_DIR/$(echo $f | tr / _).bak" done 配合cron每小时执行一次。一旦发现某关键文件被删立即从备份目录恢复。同时建议把备份目录从web root剥离,免得攻击者顺着备份策略反推。 ## 升级或迁移 说句实话v5.7这种十年前架构的CMS靠打补丁是补不完的。保哥已经把手里几个还在用织梦的项目陆续迁到Typecho或者纯静态生成器了,迁移本身一两天就能搞定,长期来看比一直追着打补丁省心得多。具体迁移方法见保哥的"DedeCMS转Typecho (https://zhangwenbao.com/dedecms-to-typecho.html)迁移"完整指南。 ## 入侵响应:万一已经被删了文件怎么办 ## 应急止血 第一时间把网站设为维护模式:在nginx里加一个临时配置返回503维护页面,所有流量都看到维护页。这样既不让攻击者继续操作也不让搜索引擎抓到500错误页面影响SEO。 ## 完整入侵排查 按时间线倒查最近30天的服务器日志:grep过滤Googlebot (https://zhangwenbao.com/google-404-crawl-seo-positive-signal.html)之外的所有POST请求按响应code 200成功的优先看;查crontab是否有陌生任务;查iptables是否有陌生规则;查/etc/passwd和/etc/shadow是否有陌生账号;查authorized_keys是否有陌生公钥。 find命令找出最近修改过的所有文件:find /www/wwwroot -mtime -30 -type f排除掉自己修改的剩下的全部列出来审查。 ## 数据库审计 dede_admin表如果多了陌生admin账号立即删除并修改剩余账号密码;dede_member表如果有时间集中的大批量注册(攻击者用注册功能批量创建会员)查看注册IP分布;dede_archives表如果有时间集中的批量发布查看内容是否包含外链 (https://zhangwenbao.com/is-external-link-building-important-for-seo.html)或SEO Spam。 ## 完整恢复 从最近一次干净备份恢复全部文件加数据库,然后按本文方法重新加固。不建议只恢复被删的几个文件——攻击者很可能还留了后门,必须整站恢复。 ## 常见问题解答 ## 补丁打完后会员上传缩略图功能正常吗? 会的。这段补丁只对路径中包含两个点点或不在当前用户目录下的请求做拦截,正常的上传、编辑、替换缩略图操作都不会触发exit。如果你打完补丁出现无法保存,多半是cfg_user_dir配置不对或者文件编码改坏了。先回滚备份再排查具体原因。常见的错误:cfg_user_dir在不同DedeCMS版本里的默认值不一样有的是/uploads/userup有的是uploads/userup(没有前导斜杠),如果不一致前缀匹配会失败导致所有合法上传都被拦截。 ## 这个漏洞可以远程利用吗?需要登录吗? 需要会员登录但DedeCMS的会员注册一般是开放的,注册加登录加触发漏洞整套动作可以脚本化完成,所以从攻击者视角来看接近无门槛。如果你的站点对外开放注册强烈建议立刻打补丁。如果暂时没法打补丁先在后台关闭注册功能也能临时缓解(但已注册的恶意账号仍能利用)。 ## 打了补丁还需要重新生成全站静态吗? 不需要。这个文件属于会员模块的PHP后端逻辑不参与前端模板渲染也不影响已经生成的静态页面。保存上传加清一下OPcache就行:php执行opcache_reset函数即可。如果你的站点没开OPcache直接保存上传就好。 ## 能不能把整段会员模块的代码替换成第三方安全版本? 社区里确实有人fork过加固版的DedeCMS比如DedeBIZ和DedeV6,但兼容性参差不齐。保哥的建议是:业务还在跑又不想大动的老老实实打补丁;准备重构的直接迁站不要在中间状态拖太久。第三方加固版的另一个问题是无法保证代码本身的可信度——如果加固版自身含有后门,相当于把锁换了把更糟的锁。 ## 除了archives_check_edit.php还有哪些任意文件删除漏洞? DedeCMS历史上的任意文件删除漏洞还有:member/album_edit.php(CVE-2018-7700)、member/uploads_edit.php(CVE-2018-9119)、include/dialog/select_images_post.php(删除图片接口)、plus/recommend.php(早期版本的删除推荐文件)。建议把这些文件都按本文方法审一遍。 ## WAF规则会不会误伤正常用户? 如果配置得当不会。本文的三条WAF规则只检查litpic和oldlitpic两个特定参数,对其他请求完全不影响。正常用户的litpic值永远是合法图片路径,不会包含两个点点或PHP扩展名。如果上线后发现误伤通常是WAF规则被配置成全局检查所有参数而不是限定到这两个字段,重新检查规则的scope即可。 ## 修补完之后如何持续监测同类漏洞? 四个工具组合:Fail2Ban自动封禁高频访问/member/路径的IP;Wazuh或OSSEC做HIDS入侵检测,对核心文件做完整性监控;Filebeat加Elasticsearch做日志集中分析;定期跑OpenVAS或Nessus做漏洞扫描。预算紧张选Fail2Ban加md5sum脚本(前面提到的)够用,预算充裕上完整SOC方案。 ## 这种老旧CMS继续守值不值得? 看业务情况。如果站点流量稳定、内容有沉淀、SEO权重高,迁移成本高于继续维护成本可以继续守但必须做完整加固。如果站点流量小、内容数量少、没有特别的SEO积累,直接迁到Typecho或WordPress省事得多。保哥的判断标准:年访问UV低于10万的小站建议直接迁移;年UV超过50万的站点评估迁移成本和持续维护成本再决定;UV在10万到50万之间的中间地带,看运维团队的技术能力和心理承受能力。 ## DedeCMS soft_add.php注入漏洞怎么修?三层防御加事故响应 - URL:https://zhangwenbao.com/dedecms-soft_add-php-has-sql-injection-vulnerability-repair-method.html - 分类:织梦CMS教程 - 发布:2018-06-22 | 更新:2026-06-01 - 摘要:保哥处理过30起DedeCMS事故的实战经验:soft_add.php占28%,漏洞披露时间线从2018到2024年,本文给出黑名单加白名单双保险修补、access_log排查、md5sum监控、迁移决策的全流程。 - 关键词:织梦漏洞,网站安全,DedeCMS,SQL注入,模板注入 > **TLDR**:摘要:在保哥处理过的30起织梦事故里,soft_add.php占了近三成。本文给完整修补与事故响应——黑名单加白名单双保险的补丁、顺手把同目录其他高危入口一起加固、用access_log排查、md5sum监控文件变更,再讲数据库层的善后审计、配置层的长期加固、WAF规则配置和迁到Typecho的决策。 > 摘要:在保哥处理过的30起织梦事故里,soft_add.php占了近三成。本文给完整修补与事故响应——黑名单加白名单双保险的补丁、顺手把同目录其他高危入口一起加固、用access_log排查、md5sum监控文件变更,再讲数据库层的善后审计、配置层的长期加固、WAF规则配置和迁到Typecho的决策。 保哥这几年帮朋友处理过三十多起DedeCMS老站被挂马、被植入暗链的事故,其中和member/soft_add.php相关的模板注入和SQL注入 (https://zhangwenbao.com/dedecms-message-board-edit-inc-php-injection-vulnerability-repair-method.html)是出现频率极高的一类——粗略统计占DedeCMS事故的28%。织梦从2018年之后官方维护就基本停滞,2021年最后一个正式补丁发布之后五年内陆续披露的47个高危漏洞都没有官方修复,很多站点还跑在那一版上,漏洞被反复利用。今天这篇文章把这个漏洞的成因、利用思路、修补代码、加固方案以及事后审计要做的事,按真实排查顺序完整写下来。这篇内容偏运维向,不涉及任何攻击实操细节,所有代码都是修复用途。看这篇之前默认你已经能SSH登录服务器、能改PHP文件、看得懂基本的正则。 ## 漏洞背景与影响范围 ## soft_add.php是干什么的 DedeCMS的member/soft_add.php是会员中心里的软件发布入口。注册会员可以通过这个接口提交软件信息,包括下载链接、服务器消息、说明文字等等。这个接口设计的本意是方便软件资源类站点接受用户投稿,但代码层做的过滤很弱,在多个版本里都有问题。 核心问题在于参数servermsg1。这个参数最后会被拼成DedeTag模板片段写到生成的栏目链接里,用花括号dede:link花括号包裹。如果不对内容做严格过滤,攻击者可以构造一个提前闭合标签的字符串再开新的dede:标签,相当于把任意织梦模板代码注入到模板编译流程里。织梦模板支持PHP标签和系统函数调用,模板注入直接等于GETSHELL,危害极高。 ## 漏洞披露时间线 这个漏洞家族的披露记录很长:2018年4月乌云镜像首次公开POC(已下线);2018年12月CNVD-2018-26535正式收录;2019年3月互联网爆发大规模利用,多家安全厂商发出预警;2021年织梦最后一次官方更新只修了其中部分变体,整个漏洞家族其实没有完整修复;2023年和2024年仍有新变体被披露,主要利用思路是绕过最后一次官方补丁的过滤逻辑。 ## 受影响版本 DedeCMS 5.6 SP2及之前:原始漏洞所有版本通用;DedeCMS 5.7 GBK所有版本:受影响;DedeCMS 5.7 UTF8到5.7.106:受影响(5.7.106有部分补丁但仍存在变体绕过);最后官方版本5.7.110也仍存在已知变体。 简单判定标准:你的DedeCMS如果是2024年之后没有打过自定义补丁的版本,假设受影响。 ## 定位有问题的代码行 先用SSH连到服务器进到站点根目录下的member文件夹。漏洞代码在soft_add.php里,不同版本行号略有差别,5.7 SP2版本大约在171行,5.7 UTF8版本可能在168行附近。直接搜一下定位最稳: cd /www/wwwroot/yoursite/member grep -n "dede:link" soft_add.php 你会看到类似这样一行: $urls .= "{dede:link islocal='1' text='{$servermsg1}'} $softurl1 {/dede:link}\r\n"; 问题就在这里。servermsg1直接拼接进字符串,没有任何转义、没有任何过滤。同时建议grep其他几个关联文件: grep -rn "servermsg1\|servermsg2\|softurl1\|softurl2" member/ include/ 把所有使用这些参数的位置一并审一遍。常见有问题的还有inc/inc_archives_functions.php、article_add.php、edit_softurl_action.php。 ## 三层防御的修补方案 ## 最小修补:黑名单兜底 社区里流传最广的一个改法是在拼接前先用正则判断,如果发现内容里出现了能闭合dede:link再重新开dede标签的形态直接跳过拼接: if ( preg_match( "#}(.*?){/dede:link}{dede:#sim", $servermsg1 ) != 1 ) { $urls .= "{dede:link islocal='1' text='{$servermsg1}'} $softurl1 {/dede:link}\r\n"; } 这个改法保哥实测有效能挡掉绝大多数公开POC,但它属于黑名单防御,思路是不对劲就不执行,攻击者只要绕过这个正则就还是能打进来。所以这是底线方案不是最佳方案。 ## 推荐方案:白名单加转义双保险 保哥更推荐的做法是:第一步把servermsg1强制限制成无害字符集,第二步对花括号、单双引号做实体化处理,让它即便被写进模板片段也变成字面字符: // 强制类型 加长度限制 $servermsg1 = isset( $servermsg1 ) ? (string) $servermsg1 : ''; $servermsg1 = mb_substr( $servermsg1, 0, 200, 'UTF-8' ); // 移除模板标签必备字符 $servermsg1 = str_replace( array( '{', '}', '<', '>', '"', "'", '`', '\\\\', "\r", "\n" ), array( '{', '}', '<', '>', '"', ''', '`', '', '', '' ), $servermsg1 ); // 再做一次黑名单兜底 if ( preg_match( '#dede:|\\{/?[a-z]+:#i', $servermsg1 ) ) { $servermsg1 = ''; } $urls .= "{dede:link islocal='1' text='{$servermsg1}'} $softurl1 {/dede:link}\r\n"; 这一段代码做了三层防御:长度限制把刷量行为压住、字符替换让任何模板标签关键字符都失去语法意义、最后的正则兜底再扫一遍dede:这种关键字。三层叠在一起黑客要绕过基本要找到PHP字符串处理本身的漏洞,难度上一个量级。 ## 同时修补softurl1参数 除了servermsg1,soft_add.php里的softurl1(下载URL)也是常被利用的参数。它应该是合法URL但代码层没强制检查。修补方法: $softurl1 = isset( $softurl1 ) ? trim( (string) $softurl1 ) : ''; if ( $softurl1 !== '' ) { // 只允许 http https ftp 开头 if ( ! preg_match( '#^(https?|ftp)://[^\\s\\'"<>]+$#i', $softurl1 ) ) { $softurl1 = ''; } } 这样softurl1只能是合法的http/https/ftp URL,不能包含空格、引号、尖括号等可能用于跳出语境的字符。 ## 验证修补是否生效 修改完保存,用grep再确认一次代码已经更新:grep -n "mb_substr" member/soft_add.php应该能看到刚加的那行。如果没有说明保存的不是修改后的版本(可能编辑器没保存或者权限问题)。 访问站点member/soft_add.php页面看是否能正常加载,先不提交内容。如果页面500错误说明改坏了,立即回滚备份(修改前必须先cp一份备份)。 ## 顺手把同目录其他高危入口一起加固 ## member目录的其他危险文件 member目录是织梦漏洞的重灾区,保哥每次处理事故都会把整个member一起审一遍。除了soft_add.php下面这些文件也建议同时加固或者直接禁用。 article_add.php:会员发布文章入口,2019年披露过模板注入;inc/inc_archives_functions.php:辅助函数库,被article_add.php和soft_add.php共同调用历史多个漏洞;resetpassword.php:旧版本的密码重置接口,CNVD-2018-13063任意密码重置;reg_new.php:注册入口,2020年披露过XSS;image_add.php:图片上传入口,存在文件上传绕过。 ## 整体deny member目录 如果站点根本不开放会员投稿,最干净的处理是直接在Nginx或Apache配置里把member目录整体deny: location ^~ /member/ { deny all; return 403; } 修改完nginx -t检查语法,再nginx -s reload。这样所有/member/路径的请求都返回403,相当于这部分功能彻底关闭。如果业务需要会员中心,至少把不必要的入口单独拒绝只放行登录、注册、个人中心这几个: location = /member/soft_add.php { deny all; return 403; } location = /member/article_add.php { deny all; return 403; } location = /member/image_add.php { deny all; return 403; } location = /member/resetpassword.php { deny all; return 403; } ## 数据库层面的善后审计 模板注入漏洞如果已经被利用过,光改代码不够,必须假设站点已经被GETSHELL按事故响应的标准流程走一遍。 ## 全站文件mtime排查 把最近一周内被修改过的PHP文件全列出来: find /www/wwwroot/yoursite -name "*.php" -mtime -7 -type f -ls 逐个审查这些文件,看是否是你手动修改的。如果有任何陌生文件出现,特别是在templets、data、uploads目录下,立即停下排查。 ## 搜常见webshell特征 eval、assert、base64_decode、gzinflate、preg_replace带/e修饰符的都要重点看: grep -rEn "eval\\(|assert\\(|base64_decode\\(|gzinflate\\(|preg_replace.*\\\\/e" /www/wwwroot/yoursite --include="*.php" 注意:织梦原代码里有大量eval和assert是合法用途(模板编译),不能见到就删。需要的是判断"陌生位置出现的可疑函数",比如uploads/2024/03/xxx.php里出现eval(uploads应该只有图片不应该有PHP)。 ## 检查管理员表 织梦的dede_admin表里如果多了陌生账号立刻删除并重置所有现有管理员密码: SELECT id, userid, uname, logintime, loginip FROM dede_admin; UPDATE dede_admin SET pwd = MD5( CONCAT( 'salt_', RAND() ) ) WHERE id = 1; 登录IP(loginip字段)是排查的关键线索。如果某个管理员账号最后登录IP来自境外(特别是俄罗斯、乌克兰、印尼这几个高频地区),高度怀疑被入侵。 ## 隐蔽位置的webshell data目录、uploads目录、templets目录全部翻一遍。这三个地方是webshell最爱藏的位置,特别是templets下面的.htm、.lib.php文件如果文件名很怪(一串随机字符),优先怀疑。常见隐藏模式: 模式一:伪装成图片的PHP,比如data/cache/inc_pic.php内容是PHP代码但文件名看起来像图片相关。 模式二:嵌入正常文件的backdoor,比如include/common.inc.php里被偷偷加了一行eval调用。需要md5sum对比官方版本相同文件。 模式三:cron job植入,crontab -l看是否有陌生的定时任务(每分钟拉远程脚本执行的典型木马手法)。 ## access_log分析 看访问日志。把过去30天里访问过member/soft_add.php、plus下面各种脚本的IP全摘出来分析是不是有异常POST: grep -E "soft_add\\.php|/plus/.*\\.php" /www/wwwlogs/yoursite.log | awk '{print $1}' | sort | uniq -c | sort -nr | head -30 访问频次远高于普通用户的IP(比如一天访问500次以上)基本是攻击源。把这些IP做iptables封禁加到WAF黑名单。 ## 配置层面的长期加固 保哥处理过的事故里有一半是因为站点开了不该开的功能。织梦默认配置非常宽松,强烈建议按下面这套关一遍。 ## 后台改默认路径 /dede是默认后台目录全网都知道必须改成无规律字符串,比如/admin_8f3a2/。修改方法:把dede目录重命名为新名字,同时修改include/common.inc.php里的cfg_cookie_encode或者直接搜代码里所有"/dede/"硬编码并替换。 ## plus目录权限收紧 plus下面的ad_js.php、download.php、guestbook.php、search.php这些是历年漏洞集中区。不需要的全删,需要的也最好用nginx限制只允许特定IP访问: location ~* /plus/(ad_js|guestbook|search)\\.php { allow 192.0.2.0/24; deny all; } ## 关闭会员投稿 系统设置里把会员投稿开关关掉。如果业务必须开至少把发布权限提到指定会员等级,新注册账号默认不能投稿。在include/dedebiz.class.php里的会员权限定义中调整。 ## 禁止PHP在uploads目录执行 这是任何CMS站都该做的: location ~* ^/uploads/.*\\.(php|php5|phtml|inc|cgi)$ { deny all; return 403; } ## PHP open_basedir限制 把PHP进程能访问的目录限定在站点根目录内即便webshell跑起来也出不去: open_basedir = /www/wwwroot/yoursite/:/tmp/ 在php.ini或者宝塔的网站设置里加。注意这会影响某些插件的兼容性,加完一定要全站测试一遍特别是文件上传和图片处理功能。 ## disable_functions屏蔽危险函数 在php.ini里禁用exec、system、shell_exec、passthru、popen、proc_open这些系统命令执行函数: disable_functions = exec,system,shell_exec,passthru,popen,proc_open,proc_close,assert,eval 但要注意:assert和eval织梦核心代码会用,禁用之后织梦本身可能跑不动。先在测试环境验证。 ## WAF规则的具体配置 ## 云WAF的快速防御 Cloudflare免费版WAF能拦截大部分公开POC。开启方法:在Cloudflare面板的Security - WAF - Managed Rules里启用OWASP Core Ruleset。同时在Custom Rules里添加针对织梦的规则: 规则一:拦截访问member/soft_add.php但POST数据包含dede:或者模板标签关键字的请求。 规则二:拦截User-Agent为空或者明显是脚本工具(curl、wget、python-requests)的POST请求。 规则三:拦截访问plus目录的非标准请求(比如带?id=union select的明显SQL注入payload)。 ## 自建WAF(modsecurity) 预算紧张可以用modsecurity开源WAF,规则比云WAF更灵活但维护成本高。规则示例(OWASP CRS v3.3): SecRule REQUEST_URI "@contains /member/soft_add.php" \\ "id:9001,phase:2,deny,status:403,msg:'DedeCMS soft_add.php template injection attempt',\\ chain" SecRule ARGS "@rx (?:\\{\\s*dede:|\\{\\s*/dede:)" \\ "t:none,t:lowercase" 规则的含义:访问soft_add.php时如果POST参数中出现织梦模板标签的开闭标记直接返回403。 ## 迁移到Typecho的决策 ## 织梦该不该继续守 保哥说句实在话:织梦这个CMS已经多年不维护,所有公开漏洞都不会再有官方补丁,社区补丁碎片化、质量参差不齐对站长来说维护成本只会越来越高。 如果是企业站、信息发布站建议迁到WordPress或Typecho。迁移有现成的工具能把文章、栏目、图片批量搬过去URL结构也能用伪静态 (https://zhangwenbao.com/discuz-portal-list-rewrite.html)规则保持兼容对SEO影响可控。具体迁移方法见保哥另一篇DedeCMS转Typecho (https://zhangwenbao.com/dedecms-to-typecho.html)的完整教程。 如果是政府、学校、政务公开类站点强制有等保要求的建议换成有官方维护的国产CMS,比如PageAdmin、动易、帝国CMS这种至少还在更新的,至少漏洞披露有渠道、有补丁。 继续守在织梦上能省一时迁移成本但每一次新漏洞披露都要紧急排查,长期算下来并不划算。 ## 迁移过渡期的处理 从决策迁移到迁移完成通常需要2到4周时间。这期间织梦站点还要持续在线,必须做好临时加固: 第一立即按本文修补soft_add.php和其他高危入口;第二Cloudflare WAF开到I'm Under Attack模式拦截可疑流量;第三nginx日志每天监控异常访问;第四关闭所有非必要功能(会员中心、评论、留言板);第五备份数据库每天一次保留7天。 ## 持续监测与应急响应 ## 文件完整性监控 每天凌晨用脚本对站点关键目录做md5sum记录,下次执行时对比差异: #!/bin/bash find /www/wwwroot/yoursite -name "*.php" -type f -exec md5sum {} \\; | sort > /tmp/php_md5_$(date +%F).txt # 对比昨日 diff /tmp/php_md5_$(date -d yesterday +%F).txt /tmp/php_md5_$(date +%F).txt > /tmp/diff_$(date +%F).txt [ -s /tmp/diff_$(date +%F).txt ] && mail -s "PHP file changed" admin@yoursite < /tmp/diff_$(date +%F).txt 有任何文件变化邮件告警。这是最有效的入侵检测手段之一。 ## 数据库异常监测 dede_admin表的行数、dede_member表的新增账号、dede_archives表的modified字段如果出现非正常波动都要告警。可以写一个简单的cron脚本每小时检查。 ## 应急响应预案 发现入侵后的SOP:第一立即把网站设为维护模式(nginx返回503维护页);第二备份当前所有文件和数据库到安全位置(不要覆盖之前的备份);第三按本文事故响应流程做全面排查;第四从最近一次干净备份恢复站点;第五重置所有管理员密码和数据库密码;第六做完所有加固再重新上线。 ## 常见问题解答 ## 我已经按本文改了soft_add.php但还是被挂马怎么办? 说明站点之前就已经被植入了后门,这次新挂的文件是后门重新执行的产物。必须按上面提到的事故响应流程从mtime排查、webshell关键字扫描、管理员表清理一路走完。光修单个漏洞不解决已经存在的后门。建议立即把网站设为维护模式做完整的后门清除流程再重新上线。 ## 把member整个目录deny掉会影响普通会员登录吗? 会。member/index.php和member/login.php是会员中心入口全deny之后会员功能完全不能用。建议只deny不需要的脚本文件或者用白名单方式只放行登录、注销、个人中心几个文件。如果站点本来就没有真正的会员业务(只有装样子的注册功能),直接deny整个member目录是最干净的处理。 ## DedeCMS的官方更新地址还能用吗? 官方升级服务器多年不稳定多数情况下连不上。市面上靠谱的修补来源是大型安全社区的整理版补丁包,下载后请先在测试环境跑一遍确认没引入新的兼容问题再上生产。注意区分官方版本号和社区魔改版本,魔改版本可能包含自带后门,必须从可信社区下载(阿里云安全、腾讯云T-Sec、奇安信都有DedeCMS加固方案文档)。 ## 可以只开WAF不改代码吗? 可以缓解但不能根治。云WAF比如阿里云、腾讯云、Cloudflare都能拦截大部分公开POC但WAF是基于规则的,遇到0day或定制payload会失效。代码层修补是最后一道防线必须做。WAF加代码修补加服务器加固三层一起上才放心。预算紧张优先选代码修补(一次投入)+免费版Cloudflare WAF(持续保护)。 ## 修补后用什么工具验证漏洞已经修复? 三种方式:第一手工测试,按公开POC的payload向soft_add.php发POST看响应是否被拦截(注意必须在自己服务器上测,不要测别人的站点);第二用开源扫描器,OpenVAS、Nessus社区版能扫DedeCMS的已知漏洞;第三付费方案,Acunetix、Burp Suite Pro能做更全面的Web漏洞扫描。推荐每季度做一次扫描。 ## 除了soft_add.php还要重点关注哪些DedeCMS文件? 历年高危TOP 10:plus/recommend.php(SQL注入)、plus/search.php(SQL注入)、plus/feedback.php(XSS加SQL注入)、plus/download.php(SQL注入)、member/article_add.php(模板注入)、member/inc/inc_archives_functions.php(多个)、include/dialog/select_soft_post.php(任意文件上传)、include/dialog/select_images_post.php(任意文件上传)、tpl.php(模板木马写入)、include/uploadsafe.inc.php(上传过滤绕过)。建议把这十个文件都按本文方法审一遍。 ## 有没有开箱即用的DedeCMS加固脚本? 保哥github上整理过一份dedecms-security-hardening.sh脚本(开源),包含本文提到的所有加固步骤的自动化版本。功能:自动备份原文件、应用本文的代码修补、生成nginx加固配置、生成WAF规则模板、cron文件完整性监控脚本。脚本经过6个客户站点的实战检验,可以SSH执行一次完成绝大部分加固。注意:脚本执行前先做完整数据库和文件备份,加固有可能影响某些自定义功能。 ## 修补后SEO会受影响吗? 不会负面影响反而是正面的。原因:被挂马的站点会被Google和百度标记为不安全网站,搜索结果里会有警告标记,CTR大幅下降;及时修补能避免这种情况。另外修补本身不修改任何前端URL、HTML结构、Sitemap,对SEO是完全透明的。Google Search Console (https://zhangwenbao.com/gsc-regex-mine-ai-search-prompts-guide.html)的安全问题报告也会因为修复而消除警告。修复后建议立即在GSC提交"已修复"申请,加速安全标记清除。 ## 权威参考资料 ## 织梦DedeCMS修改文章保留原发布时间4步实操 - URL:https://zhangwenbao.com/dedecms-changes-the-time-of-release-after-updating-the-article.html - 分类:织梦CMS教程 - 发布:2018-05-07 | 更新:2026-05-16 - 摘要:织梦改完文章后发布时间会被刷新,连带打乱SEO时序和排序。本文给实操:定位archives_edit.htm的nowtime定义行,把time()换成原pubdate锁住前端默认值,再在article_edit.php加后端兜底和带开关的强制更新选项,配合双时间字段方案和结构化数据恢复正确语义。 - 关键词:织梦发布时间,DedeCMS修改,pubdate保留,织梦SEO,织梦后台 > **TLDR**:摘要:织梦改完文章后发布时间会被刷新,连带打乱SEO时序和首页排序。本文给实操——定位archives_edit.htm的nowtime定义行,把time换成原pubdate锁住前端默认值,再在article_edit.php加后端兜底和带开关的强制更新选项,扩展分离发布时间和最近编辑时间的双字段方案,附全流程验证、版本兼容和应急回滚。 > 摘要:织梦改完文章后发布时间会被刷新,连带打乱SEO时序和首页排序。本文给实操——定位archives_edit.htm的nowtime定义行,把time换成原pubdate锁住前端默认值,再在article_edit.php加后端兜底和带开关的强制更新选项,扩展分离发布时间和最近编辑时间的双字段方案,附全流程验证、版本兼容和应急回滚。 做织梦DedeCMS站点的朋友应该都遇到过一个让人抓狂的问题:明明只是改了一下错别字,文章的发布时间却被自动刷新成了当前时间,整篇文章在前台插队到了首页最上面,一下子打乱了整站的内容节奏。保哥这些年帮客户处理过几十次类似需求,今天把这个问题彻底讲清楚,从原理到改法到风险点一次写完,附带完整的回归验证步骤、扩展双时间字段方案和常见踩坑案例。 ## 问题背景:为什么默认行为是这样 织梦DedeCMS默认在修改文章时会把 pubdate 字段重新写一次,写入的是当前服务器时间。这个设计在最初的版本里其实是有道理的——早期博客系统普遍把修改时间等同于发布时间,更新即意味着重新发布。 但放到今天的SEO和内容运营场景里,这个默认行为非常不友好: - 影响SEO时序信号:搜索引擎对文章首次发布时间是有记忆的,频繁刷新pubdate会让爬虫误以为你在做老文翻新,反而可能稀释权重;Google的Freshness算法会对短期内多次"重新发布"的内容降权处理。 - 影响首页排序:织梦默认按pubdate倒序排列,改一篇老文等于把它推到首页第一条,干扰整站编排; - 影响sitemap:自动生成的sitemap.xml里 也跟着变,提交给搜索引擎后看起来像全站大幅更新,触发不必要的重新抓取,浪费爬虫预算(Crawl Budget); - 影响RSS订阅:所有订阅器会把这篇文章当作新发布重新推送给读者,造成"已读再次推送"的体验问题。 所以保哥的做法是:分两个字段管理,pubdate 锁死为首发时间,senddate 或者新增一个 lastedit 字段记录最近编辑时间,前后台展示按需取用。本文先讲怎么把默认行为关掉,下一节再讲扩展玩法。 ## 定位关键文件 archives_edit.htm 要修改的文件路径是: /dede/templets/archives_edit.htm 注意这里的 /dede/ 是后台管理目录,很多站长会改名(建议改名,能挡掉90%的扫描器),如果你的后台目录是 /admin123/,那路径就变成 /admin123/templets/archives_edit.htm。 这个文件本质上是文章编辑页的模板,DedeCMS的后台模板和前台模板是两套不同的引擎,后台模板里夹杂了大量原生PHP代码——PHP标签直接嵌在HTML里。我们要修改的就是其中一行PHP代码。 用SSH或FTP把文件下载下来,搜索关键字 nowtime: grep -n "nowtime" /www/wwwroot/example.com/dede/templets/archives_edit.htm 应该能看到类似这样的输出: 42: 行号会因子版本不同略有差异,但格式是固定的。如果你用的是DedeBIZ或者其他二开版本,搜索关键字保持不变也能定位到。 ## 核心修改:一行代码搞定 原代码: 这句的意思是:调用 GetDateTimeMk() 把当前时间戳格式化成织梦能识别的日期字符串,赋值给 $nowtime,然后在表单里作为发布时间字段的默认值显示出来。 保哥的修改方案是: 关键变化是 time() 换成了 $arcRow["pubdate"]。这里的 $arcRow 是织梦在加载编辑页前从 dede_archives 表里读出来的当前文章原始数据,pubdate 字段就是这篇文章首次发布时的Unix时间戳。把它直接传给格式化函数,就能让编辑页表单里显示的发布时间和数据库里存的首发时间一致。 保存后保哥编辑任何一篇老文章,进入编辑页 → 直接点最下方的确定按钮 → 不修改时间字段 → 提交,pubdate字段在数据库里就纹丝不动了。 整个修改只动了一个函数参数,对其他逻辑零侵入,是最干净的修法。如果你的版本里没有 $arcRow(少数极老版本),可以替换成 $row、$data 等同等含义的变量名,原理一样。 ## 配合后端逻辑确保万无一失 第三节的修改只是改了编辑页表单的默认显示,理论上只要管理员不去主动改时间字段,提交回去也就是同一个时间。但保哥实测过一种边缘情况:如果你的浏览器开了某些表单自动填充插件,或者你的同事手抖点了设为当前时间的小按钮,pubdate还是会被覆盖。 所以保哥的做法是再加一道后端保险——直接修改 /dede/article_edit.php,把保存时的pubdate写入逻辑也锁死。找到这一段: $pubdate = GetMkTime($pubdate); 在它前面或后面加一行: // 强制保留首发时间,注释掉本行可恢复默认行为 $pubdate = $arcRow["pubdate"]; 这样无论前端表单提交了什么时间值,最终落库的还是原始pubdate。如果你希望某些文章可以手动改时间,可以加一个判断条件,比如只有当用户勾选了强制更新发布时间复选框时才执行覆盖。完整带开关的逻辑: if (!isset($_POST['keep_pubdate']) || $_POST['keep_pubdate'] != '1') { // 用户没勾"强制更新",保留首发时间 $pubdate = $arcRow["pubdate"]; } else { // 用户主动勾选了,按表单提交的时间走 $pubdate = GetMkTime($pubdate); } 这种带开关的写法是保哥给客户做生产部署时的标准方案——既保证了默认安全(不会误改),又给运营留了灵活操作的口子。 ## 扩展玩法:分离"发布时间"和"最近编辑时间" 保哥经手的稍微大一点的内容站,都会做一个改进:在文章表里新增 lastedit 字段,专门记录最近一次编辑时间,前台模板里同时展示两个时间。 ## 新增字段 ALTER TABLE dede_archives ADD COLUMN lastedit INT(10) NOT NULL DEFAULT 0 COMMENT '最近编辑时间戳'; -- 把现有数据初始化为pubdate的值,避免显示1970-01-01 UPDATE dede_archives SET lastedit = pubdate WHERE lastedit = 0; ## 在 article_edit.php 的保存逻辑里写入 找到执行UPDATE的位置,把 lastedit 加进去: $query = "UPDATE `dede_archives` SET title = '$title', pubdate = '$pubdate', lastedit = " . time() . ", sortrank = '$sortrank' WHERE id = '$id'"; ## 在前台模板里调用 用织梦的自定义字段标签:

      首发:{dede:field name='pubdate' function='strftime("%Y-%m-%d",@me)'/}

      最近更新:{dede:field name='lastedit' function='strftime("%Y-%m-%d",@me)'/}

      这样首页和列表页继续按pubdate排,但文章详情页能展示最近更新,对用户和搜索引擎都更友好。 ## 让 sitemap 改用 lastedit 找到sitemap生成脚本(一般是 /plus/sitemap.php 或 /data/sitemaps/ 下的生成器),把 改成读 lastedit: 这样sitemap反映的是真实的内容更新 (https://zhangwenbao.com/revise-old-content-for-aeo-ai-search-optimization.html)时间,搜索引擎拿到这个信号能更精准地决定是否重新抓取。 ## 加上 schema.org 结构化数据 现代SEO还要求在页面里输出 datePublished 和 dateModified 两个 JSON-LD 字段: Google会基于这两个字段在搜索结果里展示"更新于X月X日"的提示,对点击率有正向影响。保哥的客户站接入这套结构化数据 (https://zhangwenbao.com/shopify-schema-seo-guide.html)后,平均点击率提升了12%到18%。 ## 修改后的全流程验证 保哥的习惯是改完任何一个底层逻辑都要跑一遍完整验证,避免线上翻车。 第一步:在测试环境复制一篇文章,记录原pubdate。 SELECT id, title, pubdate, FROM_UNIXTIME(pubdate) FROM dede_archives WHERE id = 1234; 第二步:进入后台编辑该文章,只改一下正文内容,不动时间字段,保存。 第三步:再次执行上面的SQL,确认pubdate字段没变,只有正文变了。 第四步:到前台首页和分类页看一眼,确认这篇文章没有冒泡到顶部。 第五步:触发sitemap重新生成,检查 是否仍然是原值(如果你按第五节加了lastedit字段,可以让sitemap改用lastedit)。 第六步:用Google Search Console (https://zhangwenbao.com/domain-property-vs-url-prefix-property-in-gsc-which-is-better.html)的URL检查工具实际抓一次这个URL,看渲染后页面里的JSON-LD时间字段是否符合预期。 六步全过,才算这次修改稳了。如果有任何一步异常,逐个回退到上一步定位问题。 ## 版本兼容性与升级注意 这套修改在DedeCMS 5.7、5.8、DedeBIZ各版本下都验证过,行号略有差异但函数名和字段名都一致。一些特殊情况: - DedeCMS V5.7 SP2:archives_edit.htm 里的nowtime定义在第38到45行之间,找到后按本文方案修改即可。 - DedeBIZ 6.0+:DedeBIZ对编辑页做了Vue化重构,前端表单从API取数据,需要同时修改后端 /admin/api/archives_edit.php。 - UTF8与GBK版本:核心逻辑完全一致,只是文件编码不同,修改时注意保存时不要改变原编码(用Sublime或VSCode打开时确认右下角编码标识)。 - GBK版本:保存为GBK需要确保编辑器没有BOM,否则会引发"Headers already sent"错误。 升级DedeCMS时这个修改会被覆盖。保哥的标准做法是把所有自定义修改记录在一个 patch.md 文档里,每次升级后照着清单重新打一遍。如果你用git管理代码,更优雅的做法是把每个补丁做成独立commit,升级后cherry-pick即可。 ## 保哥的踩坑案例 2019年帮一个汽车配件B2B站做SEO诊断,客户反馈"我家文章发布的时候排名挺好,过几天就掉下去了"。保哥拿Google Search Console一看,每篇文章的Last Crawled时间和pubdate都对得上——说明每次客户编辑文章(修改产品参数、补充图片),pubdate都被刷新了,Google把它当作"新发布的内容"重新评估,但这些"新发布"的内容其实没有真正的内容增量,于是被Freshness算法降权。给客户上了本文的方案后,三个月内整站平均排名提升了 17 个位置,这个项目让保哥彻底意识到 pubdate 锁定对老站SEO的重要性。 另一个案例是2021年某个本地生活资讯站,编辑团队习惯每篇文章发布后多次微调,结果首页永远是"今天编辑的几篇"占据,真正的新闻爆款被压在第二屏。保哥引入双时间字段后,首页严格按pubdate排,编辑动作不影响排序,编辑团队反馈"现在终于能放心改文章了"。 ## 应急回滚方案 万一改完上线之后发现有问题(极少见,但保哥的工程习惯是任何变更都要有回滚预案),按以下顺序回滚: - 恢复 archives_edit.htm:从备份目录或者DedeCMS官方包里复制一份原始文件覆盖回去; - 恢复 article_edit.php:把第四节加的"强制保留首发时间"那行注释掉或删除,恢复到执行 GetMkTime 的默认逻辑; - 清后台缓存:DedeCMS后台 → 系统 → 系统设置 → 更新缓存,避免旧编译模板继续生效; - 验证:编辑任意一篇测试文章,确认编辑页发布时间字段又恢复成了"当前时间"默认填充。 如果你按第五节加了 lastedit 字段,要回滚整个双时间字段方案,按反向顺序:先把模板里的 lastedit 调用去掉,再删除数据库字段(或保留字段但不再使用)。删字段的SQL是 ALTER TABLE dede_archives DROP COLUMN lastedit;,注意先备份。 保哥的实战经验:从来没有真正用过这套回滚预案,但每次部署生产前都会把回滚步骤写在工单里、贴到团队飞书群。有备无患是工程的基本素养,特别是在动后台核心文件的时候。 ## 保哥的部署 SOP 给客户做这套修改时,保哥的标准 SOP 是: - 测试环境先复制一份生产数据库和文件; - 测试环境按本文方案修改并跑完六步验证; - 把修改前后的文件 diff 输出保存到工单; - 选业务低峰期(凌晨2点到4点)部署到生产; - 部署前打 mysqldump 全量备份,文件备份打 tar.gz; - 部署后立刻在生产环境复测六步验证; - 持续监控 7 天,看是否有用户反馈或后台报错; - 7天没问题后归档工单,关闭变更窗口。 这套 SOP 看起来繁琐,但对于运营了多年、流量上万的内容站来说,每一步都是在防止"小改动引爆大事故"。 ## 常见问题解答 ## 改完之后所有老文章的时间会怎么样? 不会动。这个修改只影响未来再次编辑文章时的行为,已经存在于数据库里的pubdate维持原样。如果你想批量把已经被刷掉的pubdate修回去,需要从备份里找数据,或者从senddate字段(部分版本里这个字段保留了首发时间)里恢复。具体SQL:UPDATE dede_archives SET pubdate = senddate WHERE pubdate > senddate; 注意先备份再执行。 ## 升级DedeCMS时这个修改会被覆盖吗? 会。织梦的版本更新本质上是覆盖文件,所有手动改过的核心文件都会被覆盖回原版。保哥的建议是把所有自定义修改记录在一个patch笔记里,每次升级后照着清单重新打一遍。或者使用git管理整个站点目录,升级前先git stash,升级后git stash pop解决冲突。如果是大型团队,建议把所有patch做成shell脚本一键应用,避免人工漏打。 ## 会不会影响DedeCMS的伪静态或者静态生成? 不会。pubdate影响的是文章URL中的日期段(如果你的伪静态 (https://zhangwenbao.com/tools/rewrite-generator.php)规则用了Y/m/d这种),但只要pubdate不变,URL就不会变,已生成的静态文件也不需要重新生成。如果你的修改是让pubdate始终保留首发时间,反而能避免URL漂移这种糟糕的SEO事故。如果你担心,可以在改之前先备份一遍 /a/ 目录(生成的静态HTML),改完后对比文件modified时间是否有异常变化。 ## 如果只想偶尔保留首发时间,平时还是按当前时间,怎么做? 做一个开关。在archives_edit.htm里加一个checkbox:,然后在article_edit.php里判断 if (!empty($_POST['keep_pubdate'])) { $pubdate = $arcRow["pubdate"]; }。这样默认行为不变,只有在编辑时主动勾选才锁定时间,灵活度更高,也更适合编辑团队协作。 ## 修改之后织梦的文章管理列表页排序还正常吗? 正常。后台文章管理列表默认按发布时间倒序,pubdate锁定后这个排序也会保持稳定,不会因为你改了文章就跑到列表第一页。如果你想让后台列表按"最近编辑时间"排序,可以改 /dede/content_list.php 里的ORDER BY子句,把 ORDER BY arc.pubdate DESC 改成 ORDER BY arc.lastedit DESC(前提是你按第五节加了lastedit字段)。这样运营人员能优先看到最近改过的文章,方便复核。 ## 批量发布的文章如何保证pubdate正确? 织梦的批量发布工具(比如采集插件、API发布)默认走 article_add.php 而不是 article_edit.php,所以本文的修改不会影响批量发布的初始时间。如果你的批量发布工具想自定义pubdate(比如把采集源的原始发布时间作为pubdate),需要在调用API时显式传入pubdate参数,并确保 article_add.php 里没有强制覆盖逻辑。具体实现因插件而异,参考所用插件的API文档。 ## 这套修改对其他CMS有参考意义吗? 有。本质问题在所有CMS都存在:WordPress的 post_modified vs post_date、Typecho的 modified vs created、PHPCMS的 updatetime vs inputtime、帝国CMS的 newstime。WordPress和Typecho默认就分开管理,不需要改。PHPCMS和帝国CMS的默认行为和DedeCMS类似,需要类似的修改。保哥给客户做SEO优化时,第一件事就是确认CMS的发布时间和编辑时间是否分离,没分离的全部加上。 ## 有没有现成的DedeCMS插件可以一键搞定? 保哥找过,市面上有几个号称能锁定pubdate的插件,但都是十年前的老插件,对DedeCMS V5.7 SP2及以后版本兼容性都不好,安装后经常报错。最稳妥的还是按本文手动改两行代码,全程5分钟搞定,且改动可见可控。如果你的团队有多个DedeCMS站点要批量处理,保哥建议把改法封装成一个 fix_pubdate.sh 脚本,sed替换+grep验证,几十秒处理一个站。 ## DedeCMS列表页无缩略图不显示:arc.listview.class.php改造、runphp单双引号陷阱与CSS现代化方案 - URL:https://zhangwenbao.com/dedecms-listpage-thumbnail-setting-up-method.html - 分类:织梦CMS教程 - 发布:2017-03-09 | 更新:2026-05-16 - 摘要:DedeCMS列表页没传缩略图的文章会渲染出defaultpic占位图,破坏视觉一致。本文从arc.listview.class.php的替换逻辑讲起,剖析runphp语法和单双引号陷阱触发array错误的根因,再给出改源码、识别占位字符串、CSS等五种实战方案和各自适用场景。 - 关键词:织梦缩略图,DedeCMS模板,列表页缩略图,runphp,tplcache > **TLDR**:摘要:DedeCMS列表页没传缩略图的文章会渲染出defaultpic占位图,破坏视觉一致。本文从arc.listview.class.php的缩略图判定逻辑讲起,剖析模板里runphp的工作机制和单双引号陷阱触发array错误的真实原因,再对比改源码、识别占位字符串、CSS等几种无图不显示的写法和性能,附跨版本兼容、SEO影响和从正文首图回写litpic。 > 摘要:DedeCMS列表页没传缩略图的文章会渲染出defaultpic占位图,破坏视觉一致。本文从arc.listview.class.php的缩略图判定逻辑讲起,剖析模板里runphp的工作机制和单双引号陷阱触发array错误的真实原因,再对比改源码、识别占位字符串、CSS等几种无图不显示的写法和性能,附跨版本兼容、SEO影响和从正文首图回写litpic。 DedeCMS 列表页文章如果没上传缩略图,默认会显示一张通用占位图(defaultpic.gif)——一个灰白底蓝字 "暂无图片" 的丑图。在追求美观的现代列表页布局里,这种占位图比"什么都不显示"还糟糕。要做到"有图就显示、无图就隐藏 dt 容器"看似简单,实际上 DedeCMS 模板引擎的 runphp / @me / 单双引号陷阱里有一连串小坑等着踩。 这一篇把 "DedeCMS 列表页缩略图条件显示" 从模板引擎的工作机制讲到几种实战写法、CSS 现代化替代、SEO 影响、跨版本兼容与 FAQ。每段代码都标明了适用版本(V5.6 / V5.7 / V5.7 SP2 / DedeBIZ)。 ## arc.listview.class.php 内部缩略图判定逻辑 原文提到的关键代码在 /include/arc.listview.class.php 大约第 883-885 行(V5.7 SP2 实际行号是 891-893,跨版本略有差异): if ($row['litpic'] == '-' || $row['litpic'] == '') { $row['litpic'] = $GLOBALS['cfg_cmspath'] . '/images/defaultpic.gif'; } 这段代码的语义是:"如果数据库里 litpic 字段是空字符串或 '-',就把它替换成默认占位图路径"。数据库 litpic 字段在文章未上传图时填 '-' 或空——这是 DedeCMS 历史设计。 要做到"无缩略图时不显示而不是显示默认图",有两条路径: - 改 PHP 源码:删除 / 注释掉这段 if 判断,让 litpic 保持空,模板里再判断; - 不改源码,模板里识别 defaultpic:用 strpos(@me['litpic'], 'defaultpic') 检查路径里有没有 defaultpic 字串,是的就视为无图。 两种各有优劣。改源码彻底但升级会被覆盖;模板判断字符串需要 PHP 文件里默认图路径恰好含 "defaultpic"——大部分 DedeCMS 站符合这个特征。生产推荐方案 1(改源码 + 子模板覆盖),方案 2 适合"不能改源码但要快速生效"的场景。 ## DedeCMS 模板里 runphp 的工作机制 DedeCMS 模板支持两种"嵌入 PHP" 的语法: 语法 | 用途 | 变量名 | [field:array runphp='yes']...[/field:array] | 在 {dede:arclist} 循环内引用整行数据 | @me 是关联数组(可访问 @me['litpic']) | {dede:field.litpic runphp="yes"}...{/dede:field.litpic} | 专门处理某一个字段 | @me 是字段值(直接是字符串) | {dede:php}...{/dede:php} | 任意 PHP 代码 | 无固定变量 | 这三种语法在 DedeCMS 编译模板时被转成 块嵌进生成的 PHP 文件。编译产物缓存在 /data/tplcache/,所以改完模板要在后台清缓存(系统 → 系统设置 → 更新缓存 → 模板缓存)。 ## @me 变量的实际含义 @me 不是 PHP 原生语法,是 DedeCMS 模板编译时的占位符。编译后 @me['litpic'] 变成 $row['litpic'] 之类的 PHP 引用。所以模板里的 @me 必须严格按 PHP 数组访问语法写,包括引号和花括号。 ## 单双引号陷阱的真实原因 原帖警告"单双引号问题,只能统一用单引号"——这条警告是真的,但原因没说清。看这段代码: [field:array runphp='yes'] @me=(strpos(@me['litpic'],'defaultpic')?'':"
      {@me[
      "); [/field:array] 整个 [field:array runphp='yes']... 标签的属性 runphp='yes' 用单引号——这是 DedeCMS 模板解析器的硬性要求(双引号会被认为是属性结束)。所以内部代码里所有"看起来该用双引号"的地方都得改单引号。 更深层的原因:DedeCMS 模板编译器把 [field:array] 标签转成 PHP eval 调用,参数是字符串。如果内部含没转义的双引号,eval 字符串提前结束,剩下代码作为乱码触发 PHP 错误,DedeCMS 容错时会返回 array 提示"出错"——就是原帖说的"返回 array"现象。 ## 复杂 HTML 内嵌的写法 如果实在需要双引号(比如 HTML 标签),用 " HTML 实体: @me=""; 或者用 chr(34): $dq = chr(34); @me="..."; ## 几种"无图不显示"的写法对比 ## 方法 A:改源码 + 模板里直接判断空 删 arc.listview.class.php 里的默认图替换逻辑后,模板: {dede:field.litpic runphp="yes"} if (!empty(@me) && @me != '-') { @me = "
      {@me[
      "; } else { @me = ""; } {/dede:field.litpic} 优点:清晰直观;缺点:改源码升级被覆盖。 ## 方法 B:不改源码 + 模板识别 defaultpic 字符串 {dede:field.litpic runphp="yes"} @me = (strpos(@me, 'defaultpic') !== false ? '' : ""); {/dede:field.litpic} 优点:不动源码升级安全;缺点:依赖默认图路径含 defaultpic 字符串。如果站长改了默认图文件名(比如改成 placeholder.png),这条规则失效。 ## 方法 C:用 [field:array] [field:array runphp='yes'] @me = (strpos(@me['litpic'], 'defaultpic') !== false ? '' : "
      {@me[
      "); [/field:array] 这是原帖给的版本。优点:可访问当前文章的所有字段(arcurl、title、seotitle 等);缺点:单双引号陷阱多。 ## 方法 D:CSS 现代化替代 不改 PHP 也不写 runphp,用 CSS 控制:
      [field:title/]
      优点:极其简单,无需懂 DedeCMS 模板;缺点:依赖 JS(无 JS 用户看到默认图),且 onerror 触发时机有时不可控。 ## 方法 E:把默认图本身做成透明 SVG 最优雅但最少人想到的方案——把 /images/defaultpic.gif 替换成 1×1 透明 PNG 或空白 SVG。这样 PHP 层 if 判断照样跑,模板里拿到的是"看起来空"的图。配合 CSS 的 img:empty 或 img[src=""] 选择器隐藏 dt。 /* 1×1 透明的图片自动隐藏父级 dt */ .thumb img[src*="defaultpic"] { display: none; } .thumb:has(img[src*="defaultpic"]) { display: none; /* 父级也隐藏(CSS :has 在现代浏览器支持) */ } 优点:纯 CSS,无 JS 依赖,无需改模板;缺点:需要 CSS :has 选择器支持(2023 年起所有现代浏览器支持)。 ## 各方案性能对比 方案 | 每篇文章额外开销 | 适用场景 | A 改源码 + 模板判断空 | 0(无 runphp) | 能改源码的项目 | B 模板识别 defaultpic | ~0.05ms(runphp + strpos) | 不能改源码 | C [field:array] 完整版 | ~0.1ms(runphp + 数组访问) | 需要访问多字段 | D JS onerror | ~5ms(DOM 操作) | 急救快速上线 | E CSS :has | ~1ms(选择器匹配) | 现代浏览器项目 | 对中等规模站点(单页 20 条文章),方案 A/B/C/E 都在 1-5ms 总开销内,肉眼无差。方案 D 因为是浏览器端事件触发,会有轻微闪烁(默认图加载失败再隐藏)。 ## 跨 DedeCMS 版本兼容 不同版本里 arc.listview.class.php 的关键行号略有差异: 版本 | 判定缩略图的代码位置 | 默认图路径 | DedeCMS V5.6 | ~ 第 845 行 | /images/defaultpic.gif | DedeCMS V5.7 | ~ 第 875 行 | /images/defaultpic.gif | DedeCMS V5.7 SP1 | ~ 第 880 行 | 同上 | DedeCMS V5.7 SP2 | ~ 第 891 行 | 同上 | DedeBIZ V6.x | ~ 第 905 行 | 同上 + 后台可配置开关 | 所有版本的判定逻辑和默认图路径基本一致,本文写的几种方案在所有版本都通用。DedeBIZ 已经在后台加了"无图时是否显示默认图"的开关,新装站点可直接走 DedeBIZ 省掉模板改造。 ## SEO 影响考量 "无图不显示" 这个改造对 SEO 的影响: ## 用户体验提升 整页布局更美观——无图的文章不会出现破碎的灰色占位,列表页视觉一致性提升,用户停留时间 (https://zhangwenbao.com/user-behavior-signals-reshaping-seo-dwell-time-bounce-rate.html)和点击率多数会上升 5-15%。这是 Google Core Web Vitals 之外的"用户行为信号",间接帮排名。 ## 不要把所有图都隐藏 如果站点 70% 文章都没图,列表页就变成"几个图 + 大片空白"——视觉不平衡。这种情况建议:① 给文章批量补封面图(用首图自动提取或 AI 配图);② 改用纯文本列表,不展示缩略图位。 ## 与 lazy load 协同 显示出来的缩略图加 loading="lazy": [field:title/] 对列表页 LCP 性能贡献明显——只有视口内的图实际加载,下面没看到的图懒加载。 ## alt 属性必填 给图片加 alt 是图片 SEO 基础。DedeCMS 默认 alt 是 [field:title/]——文章标题 (https://zhangwenbao.com/how-to-write-catchy-article-titles.html)。这是合理的,但更优是用 [field:keywords/] 或 [field:description/],让 alt 更精准。 ## 升级时的覆盖问题 方案 A 改了 arc.listview.class.php,DedeCMS 升级时会被覆盖。三种保护: - 升级前 git diff 备份,升级后手动 merge; - 把改造放到独立的 hook 文件(DedeBIZ 提供事件 hook 机制,可在不动核心文件的情况下注入逻辑); - 切到 DedeBIZ,用后台开关代替源码改动。 ## 与现代 CMS 的等价做法 如果未来要把 DedeCMS 站迁出,"无图不显示" 的等价做法: CMS | 等价做法 | WordPress | 主题 PHP if (has_post_thumbnail()) the_post_thumbnail(); | Hexo | 模板 {% if post.thumbnail %}...{% endif %} | Hugo | {{ if .Params.thumbnail }}...{{ end }} | Typecho | 模板 fields->img): ?>... | 所有现代 CMS 都把"是否有缩略图"做成了原生模板能力,不需要 strpos 这种字符串技巧。 ## 批量从正文首图提取写回 litpic 字段 很多 DedeCMS 站点的痛点是历史文章 litpic 大量为空——当年发布时没设缩略图。要批量补,最实用的做法是从 article body 里提取第一张 标签的 src 写回 litpic: // 一次性脚本:扫所有 litpic 为空的文章,从正文提取首图回写 require_once 'include/common.inc.php'; $db = $GLOBALS['dsql']; $rows = $db->GetAll(" SELECT a.id, a.body FROM dede_archives ar JOIN dede_addonarticle a ON a.aid = ar.id WHERE (ar.litpic = '' OR ar.litpic = '-' OR ar.litpic IS NULL) AND a.body IS NOT NULL "); $updated = 0; foreach ($rows as $row) { if (preg_match('/]+src=[\'"]([^\'"]+\.(?:jpg|jpeg|png|webp))[\'"]/', $row['body'], $m)) { $litpic = $m[1]; // 转相对路径 $litpic = preg_replace('#^https?://[^/]+#', '', $litpic); $db->ExecuteNoneQuery(" UPDATE dede_archives SET litpic = '$litpic' WHERE id = {$row['id']} "); $updated++; } } echo "更新 $updated 篇文章的 litpic\n"; 这段代码跑完之后大部分历史文章会有 litpic——配合本文的 "无图不显示" 改造,列表页视觉效果会大幅提升。 ## 配套生成缩略图 从正文提取的图通常是原始大图(比如 1920×1080),直接用作缩略图浪费带宽。建议同时生成对应的小尺寸缩略图: // 配合本文的 image.helper.php 改造,对每张回填的 litpic 生成 240×160 缩略图 require_once 'include/helpers/image.helper.php'; $thumb_dir = '/uploads/thumbs/'; foreach ($rows_with_new_litpic as $row) { $original = DEDEROOT . $row['litpic']; $thumb_path = DEDEROOT . $thumb_dir . basename($row['litpic']); if (!file_exists($thumb_path)) { ResizeImg($original, $thumb_path, 240, 160); } // 回写到 litpic 字段为缩略图路径 } ## 和图片 SEO 联动的最佳实践组合 "无图不显示" 是单点优化。配合下面几个改造能让 DedeCMS 列表页 SEO 提升一个量级: - 给每张缩略图加完整 alt:alt 用 [field:title/] 或更精准的 [field:description/]; - 用 WebP 格式替代 JPG / PNG:体积减小 25-40%,加载更快; - 缩略图 lazy load:loading="lazy" 让首屏外的图延后加载; - 生成 image sitemap (https://zhangwenbao.com/wordpress-free-plug-in-automatically-updates-sitemap-xml.html):在 sitemap.xml 里加 image:image 节点,让 Google 图片搜索能发现; - 缩略图 URL 含语义:用 {文章标题拼音}-thumb.webp 而不是 1234567890-thumb.jpg; - 响应式图片:用 srcset 给不同屏幕给不同大小的图: [field:title/] 每个尺寸的缩略图都需要预先生成(用 image.helper.php 改造或后处理脚本)。 ## 常见问题解答 ## 改完模板前台没生效,怎么办? 清模板缓存。后台 → 系统 → 系统设置 → 更新缓存 → 模板缓存。或者手动删 /data/tplcache/ 目录下所有文件。模板编译产物清空后下次访问会重新编译。 ## defaultpic.gif 改名后方案 B 失效,怎么办? 方案 B 依赖路径里的 "defaultpic" 字符串。改名后改判断条件,比如 strpos(@me, 'placeholder')。或者改回方案 A——直接改源码删默认图替换逻辑,模板里判断空字符串。 ## runphp 写错导致整个列表页报错怎么办? 检查三处:① 单双引号是否统一为单引号;② @me 用法是否正确(field:array 里 @me 是数组、field.litpic 里 @me 是字符串);③ 整体结构是否完整闭合([field:array]...[/field:array])。在 PHP error_log 里能看到具体错误行号。 ## 多列表(dede:arclist 嵌套 dede:list)的缩略图判定有什么差异? 无差异——arclist 和 list 都共用 arc.listview.class.php 的判定逻辑。区别在于上下文变量:arclist 里的 [field:xxx] 引用当前 article,list 里嵌套时小心变量作用域不要混。 ## 改造后能正常生成 sitemap.xml 吗? 能。模板层的图片显示与否不影响 sitemap 生成——sitemap 直接从数据库 litpic 字段读取生成 image:image 节点。如果你想让"无图文章不进 image sitemap",要单独改 sitemap 生成代码(/data/sitemap.xml 生成器)。 ## 能不能给"有图"和"无图"的文章用不同的 CSS 样式? 能。在 dt 元素加 class: {dede:field.litpic runphp="yes"} if (strpos(@me, 'defaultpic') !== false) { @me = "
      "; } else { @me = "
      "; } {/dede:field.litpic} CSS 里就可以 .no-thumb 给完全不同的布局(比如纯文本卡片、彩色背景代替图等)。 ## 列表里图片和文字应该上下排列还是左右排列? 看场景:① 内容站(博客 / 新闻)建议左图右文,符合 F 型阅读路径;② 产品站建议上图下文,让图本身成为主导;③ 移动端无论 PC 哪种布局都建议改成上下,左右在窄屏会挤压。用 CSS @media 切换。 ## 能不能用 AI 自动给无图文章配图? 能。两条路:① 从正文提取首图:用 PHP 正则提取 article.body 里第一张 的 src,写回 litpic 字段;② 调用 AI 配图 API(DALL-E / Midjourney API / 国产文心一格 / 通义万相)按文章标题生成主题图。前者免费,对老站补图最实用;后者每张图 0.05-0.5 美元,适合新文章自动配图。 ## 缩略图懒加载(loading=lazy)会影响 SEO 吗? 不会。Googlebot (https://zhangwenbao.com/why-googlebot-ignores-resource-hints.html) 自 2018 年起完全支持原生 lazy loading,能正确抓取懒加载的图。Core Web Vitals 的 LCP 指标反而会因为 lazy load 变好(首屏加载更快)。但 LCP 元素(首屏第一张图)不要 lazy load——它必须 eager 加载否则评分扣。 ## 方案 A 改了源码升级被覆盖,能不能写 hook 而不动核心? 原版 DedeCMS V5.7 没有完整的 hook 机制——只有零星几个钩子。要做"不动核心"的扩展,要么改用 DedeBIZ V6.x(有了 plugin 体系),要么把改造做成独立 PHP include 在 common.inc.php 里加载。后者依赖 common.inc.php 不被覆盖(实际上 common.inc.php 升级也会被覆盖),所以最稳的办法是切 DedeBIZ 或迁移到现代 CMS。 ## 权威参考资料 ## 织梦DedeCMS升级补丁后无法生成HTML:5步排查修复 - URL:https://zhangwenbao.com/dedecms-online-update-solution-can-not-be-generated-after-the-patch-html.html - 分类:织梦CMS教程 - 发布:2017-03-04 | 更新:2026-05-16 - 摘要:织梦升级补丁后生成不了HTML,根因是公共函数库被官方版覆盖、丢了自定义函数。本文拆从PHP错误日志定位、追加listtag、flink、mytypelist、getauthor四大函数实现,到集中独立到custom.func.php避免再次被覆盖的完整方案,附升级运维清单和PHP 8.x兼容建议。 - 关键词:织梦生成html,DedeCMS,织梦升级,common.func.php,listtag函数 > **TLDR**:摘要:织梦升级补丁后生成不了HTML,根因是公共函数库被官方版覆盖、丢了自定义函数。本文给出listtag的标准修复代码、flink与mytypelist与getauthor三个常用自定义函数的完整实现,再系统排查其他失效函数、集中独立到custom.func.php避免下次再被覆盖,附回归验证流程、升级前的运维清单和PHP 8.x兼容建议。 > 摘要:织梦升级补丁后生成不了HTML,根因是公共函数库被官方版覆盖、丢了自定义函数。本文给出listtag的标准修复代码、flink与mytypelist与getauthor三个常用自定义函数的完整实现,再系统排查其他失效函数、集中独立到custom.func.php避免下次再被覆盖,附回归验证流程、升级前的运维清单和PHP 8.x兼容建议。 这事儿是保哥前几年帮一位老客户排查织梦DedeCMS故障时遇到的:客户在后台点了“在线更新升级”,过了几分钟回来,发现“更新栏目HTML”“更新文档HTML”点下去全部没反应,进度条直接卡死或者一闪而过,前台访问文章页虽然还能看,但生成出来的HTML文件停留在升级前的版本。我之前在自己的几个Dede老站上也遇到过这个症状,今天就把完整的排查思路与修复方法整理出来,希望能帮到同样卡在升级补丁后无法生成HTML的朋友。 ## 故障表现与首次定位 升级补丁后无法生成HTML的典型表现有三个:第一,后台“生成-更新文档HTML”点击后页面空白或者直接刷出一行红色PHP Notice;第二,templets_c目录下生成的编译模板文件大小为0KB;第三,前台访问刚发布的文章会跳到404或者直接返回500。 保哥的第一反应通常是先看PHP错误日志,因为织梦本身的错误提示很简陋,真正能告诉我们“函数未定义”“文件读不到”这种关键信息的还是PHP自己的error_log。打开PHP错误日志(不同主机位置不同,常见的是/www/wwwroot/<站点目录>/php_errors.log或者/var/log/php-fpm/error.log),重点搜索Call to undefined function这一类报错。 如果你看到类似Call to undefined function listtag()或者Call to undefined function flink()之类的提示,那基本可以确定问题就是升级补丁覆盖文件时遗漏了某些自定义函数,导致原本依赖这些函数的模板在生成阶段直接抛错。 ## 为什么补丁会破坏HTML生成 织梦的在线升级机制说白了就是从官方服务器下载差异文件,然后批量覆盖站点对应的PHP文件。问题在于,很多人之前为了实现TAG调用、友情链接调用、自定义字段输出等功能,会手动往include/common.func.php、include/extend.func.php这两个公共函数库里追加自定义函数。 升级补丁覆盖时并不会智能merge,它会把整个common.func.php替换成官方原版,结果就是你之前加的所有自定义函数全部没了。模板里还在{dede:field name='tags' function='listtag(@me)'/}这样调用,引擎在生成时找不到listtag函数,触发fatal error,整个生成流程就被中断。 保哥过去帮客户处理过四五次类似事故,每次的表象都是“升级后无法生成HTML”,但根因百分百都是公共函数库被覆盖。所以这篇排查的核心思路就是:恢复被覆盖的自定义函数,并加固后续不再被覆盖。 ## 修复listtag函数的标准代码 如果错误日志明确指向listtag未定义,那就把下面这段代码追加到include/common.func.php文件的最末尾。这是织梦社区流传度最广的标准实现,保哥自己也在十几个站上验证过,能稳定支持{dede:field name='tags' function='listtag(@me)'/}标签输出。 // TAG 调用函数(注意:将下方的 dede_ 表前缀替换为你站点实际的前缀) function listtag($aid) { $tsql = new DedeSql(false); $tags = ''; $tsql->SetQuery("Select i.tag From dede_taglist t left join dede_tagindex i on i.id=t.tid where t.aid='$aid'"); $tsql->Execute('t'); while($row = $tsql->GetArray('t', MYSQL_ASSOC)){ $tags .= "".$row['tag']." "; } return $tags; } 几个细节务必注意: 第一,表前缀。Dede默认是dede_,但很多站为了防注入会在安装时改成xx_dede_或者随机字符串,请用phpMyAdmin进数据库确认taglist与tagindex这两张表的真实前缀,再回到代码里替换。 第二,MYSQL_ASSOC这个常量在PHP 7.0之后被废弃,PHP 7.4上还能用但会报deprecated警告,PHP 8.0直接报致命错误。如果你的服务器PHP已经升到8.x,需要把这一行换成$tsql->GetArray('t')不带常量参数的形式,或者改用MYSQLI_ASSOC,否则函数本身又会成为新的报错源头。 第三,建议在函数前面加一个if (!function_exists('listtag'))判断,避免与未来某个补丁里Dede官方自带的同名函数冲突,写成下面这样更稳: if (!function_exists('listtag')) { function listtag($aid) { // 上面的实现照搬过来 } } ## 3个常用自定义函数的完整实现代码 除了listtag,保哥统计了客户Dede站点最常被升级覆盖的另外几个函数,这一节给出可直接复用的实现,避免你升级之后还要满社区翻代码。 第一个是flink友情链接分组调用函数。Dede默认的友链调用不支持按分组输出,运营经常要求“首页只显示行业类友链,栏目页只显示综合类友链”,这种场景就需要自定义flink函数: if (!function_exists('flink')) { function flink($typename = '默认分组', $row = 20) { global $dsql; $sql = "SELECT * FROM dede_flink WHERE typename='$typename' ORDER BY sortrank ASC LIMIT $row"; $dsql->SetQuery($sql); $dsql->Execute(); $out = ''; while($r = $dsql->GetArray()){ $out .= ''.$r['webname'].''; } return $out; } } 第二个是mytypelist自定义栏目列表函数。这个函数被很多企业站用来在首页输出指定栏目的最新文章,覆盖之后会导致首页大半个屏幕空白: if (!function_exists('mytypelist')) { function mytypelist($typeid, $row = 10) { global $dsql; $sql = "SELECT id,title,filename FROM dede_archives WHERE typeid='$typeid' AND arcrank>-1 ORDER BY id DESC LIMIT $row"; $dsql->SetQuery($sql); $dsql->Execute(); $out = '
        '; while($r = $dsql->GetArray()){ $url = '/plus/view.php?aid='.$r['id']; $out .= '
      • '.$r['title'].'
      • '; } $out .= '
      '; return $out; } } 第三个是getauthor作者信息函数。多作者站点经常用这个函数在文章页输出作者头像、简介、文章总数等,覆盖后文章页底部会缺一大块: if (!function_exists('getauthor')) { function getauthor($writer) { global $dsql; $sql = "SELECT * FROM dede_member WHERE userid='$writer'"; $dsql->SetQuery($sql); $dsql->Execute(); $r = $dsql->GetArray(); if (!$r) return ''; return '作者:'.$r['uname'].',文章数:'.$r['articles']; } } 把这三个函数加上前面的listtag一起放在include/common.func.php末尾,或者更优雅一些,单独放在include/custom.func.php里再require_once进来。保哥强烈推荐后一种做法,下面的“长期防护”一节会展开讲。 ## 系统性排查升级后失效的其他自定义函数 光修一个listtag不一定够。保哥处理过的一个站,补丁覆盖之后总共少了七个自定义函数,包括TAG调用、附件批量统计、友链分组输出等等。一次性排查完,比每次报错再补一个要省心得多。 排查的思路是:把当前的common.func.php、extend.func.php与升级前的备份做diff。Linux服务器上直接用diff命令: diff /backup/2025-03-30/common.func.php /www/wwwroot/site/include/common.func.php Windows本地可以用VSCode的Compare Selected或者Beyond Compare。把备份里多出来的、官方版本没有的自定义函数全部提取出来,整段追加回新版的common.func.php末尾。 如果你没有升级前的备份,也可以从模板里反向找。打开templets/default下所有.htm模板,搜索function=',把所有引用过的函数名收集起来,再去common.func.php与extend.func.php里逐个确认是否存在;不存在的就是被覆盖掉的,需要从社区找回标准实现或者按需重写。 ## 修复后回归验证的完整流程 代码追加完之后不要急着关闭文件,按下面的顺序做一次完整回归,避免修了A又坏了B这种连锁反应。 1. 后台-系统-系统基本参数-保存(强制刷新缓存) 2. 后台-生成-更新系统缓存 3. 后台-生成-更新主页 HTML 4. 后台-生成-更新栏目 HTML(勾选 全部栏目) 5. 后台-生成-更新文档 HTML(勾选 全部文档) 6. 前台抽查首页、栏目页、文章页各一篇,确认无 PHP 报错 7. 用 curl -I 抓三个文章页响应头,确认返回 200 且 Content-Type 正常 保哥自己的习惯是在第6步打开浏览器开发者工具的Network面板,刷新文章页,逐项查看是否有4xx或5xx资源;在第7步用命令行curl -I https://example.com/news/123.html检查响应头,避免页面看着正常其实是缓存的假象。 如果某一步还是报错,错误信息会直接回写到PHP错误日志,定位起来比之前容易得多——因为现在大头的listtag已经修好了,剩下的报错往往是另一两个自定义函数没回归到位,按同样思路再补就行。 ## 长期防护:避免下次升级再次踩坑 这件事的根本解法不是“下次再修”,而是“下次不再坏”。保哥结合自己的运维经验给出三条长期防护建议。 第一,把所有自定义函数集中到一个独立文件,比如include/custom.func.php,再在common.func.php末尾通过require_once引入。这样升级补丁覆盖common.func.php时,你的自定义文件原封不动。 // 在 common.func.php 文件末尾追加 if (file_exists(DEDEINC.'/custom.func.php')) { require_once(DEDEINC.'/custom.func.php'); } 第二,在升级前一定要做版本快照。宝塔面板有“网站快照”,云服务器有“实例快照”,本地也可以用tar -czf命令打包整站。十分钟的备份能省下两小时的修复时间,绝对划算。 第三,认真评估是否真的需要升级。织梦官方早已经停止常规维护,所谓的在线补丁很多时候只是社区或第三方修补的安全补丁。除非补丁明确修复了高危漏洞,否则保哥更倾向于保留稳定版本,只针对漏洞做精准热补,避免大面积覆盖文件带来的连锁问题。 ## Dede升级补丁的安全策略与替代方案 这一节保哥单独把“补丁是否值得打”这件事讲透。织梦自从2021年商业授权风波之后,官方基本停止了主线维护,市面流传的所谓“在线补丁”绝大多数来自第三方或者社区分支,质量参差不齐。盲目升级带来的破坏力,往往比补丁修复的漏洞本身更严重。 保哥给客户的标准建议是分级处理:高危漏洞(远程代码执行、SQL注入、任意文件上传)必须打,但优先选择官方安全公告里点名修复的精准补丁,而不是整包覆盖;中低危漏洞(XSS、信息泄漏)可以延后,先用Nginx层的WAF规则或宝塔防火墙做临时拦截;非安全类的功能更新(界面优化、新插件)基本不打,因为收益远低于风险。 另外一个替代方案是迁移到现代CMS。如果你的Dede站点流量稳定但维护成本越来越高,可以考虑迁移到Typecho、WordPress或者Headless方案(如Strapi + Next.js)。保哥帮过几位老客户做迁移,平均成本是站点规模100篇文章约5000元、1000篇文章约2万元,迁移之后再也不用每次都和这种“升级即崩”的故事抗争。 但如果实在不想动主站,至少做好以下三件事:站点放进Cloudflare做基础DDoS (https://zhangwenbao.com/wordpress-ddos-protection-guide.html)和WAF防护、PHP版本停在7.4不要升级到8.x(Dede很多代码在8.x上直接报错)、数据库定期异地备份(建议每天一次增量、每周一次全量)。这三件事做齐,老Dede站再战三五年完全没问题。 ## 一个真实排查案例:从凌晨两点到清晨六点的修复全过程 保哥前年帮一位做地方门户的朋友处理过一次比较棘手的事故。事情起因是他听说织梦有一个新的安全补丁可以堵SQL注入漏洞,半夜十一点自己点了在线升级,过了几分钟就发现首页打不开、栏目页502。他凌晨两点联系到我的时候已经焦头烂额,因为站点白天有大量推广流量进来,第二天上午就要开会跟广告主对账。 我接入服务器之后,第一步先把站点切到维护页面,避免持续返回5xx影响搜索引擎抓取频次。第二步打开PHP错误日志,立刻看到一连串Call to undefined function错误,包括listtag、flink、mytypelist三个函数。第三步对比客户提前一个月做的整站快照,把这三个函数的实现从老common.func.php里抠出来,按照本文前面的“集中到custom.func.php”方案重新部署。 第四步是回归生成。我先生成栏目HTML,跑完没有报错;再生成文档HTML,发现还有一个getauthor函数缺失,回头继续从快照里补上。第五步关闭维护页面,前台访问几篇热门文章确认正常,再把站点重新提交搜索引擎抓取。 整个过程从凌晨两点到清晨六点,正好赶在客户开会之前修完。这次事故让我和客户都意识到一件事:不是所有补丁都值得升级,特别是对一个流量已经稳定的老站来说,“不动可能比动更安全”。从那以后他规定:任何升级动作必须先在测试环境跑一周,且必须有完整快照备份,否则一律不上线。这条规矩到今天还在执行。 ## 升级前必须建立的运维清单 借这次案例的教训,保哥后来把Dede升级流程整理成一份运维清单,分享给大家: 1. 升级前 24 小时:完整备份网站文件 + 数据库 2. 升级前 12 小时:克隆一份到测试环境跑全流程 3. 升级前 1 小时:截图记录当前关键页面、关键 SQL 表结构 4. 升级时:开维护模式、记录开始时间 5. 升级后立即:刷错误日志、跑首页 + 三个栏目 + 五篇文章的回归 6. 升级后 30 分钟:观察 PHP-FPM 进程数、MySQL 慢查询日志 7. 升级后 24 小时:检查搜索引擎抓取日志、流量监控数据 按这份清单走一遍,再大的事故都能在可控范围内被发现并回滚,比起“点了升级然后祈祷一切正常”要踏实得多。 ## 常见问题解答 ## 补丁覆盖之后没有备份能恢复成升级前的版本吗 如果服务器开启了快照或者宝塔自动备份,可以直接回滚整站文件。如果什么备份都没有,只能从社区下载与原版本号对应的官方包,再把数据库表结构对照一下;这条路风险高,强烈不建议在生产环境裸跑,最好先在测试环境验证。补救前一定要先把当前损坏状态的文件再次备份一份,万一回滚也失败还能回到现在的“半坏”状态做下一轮排查。 ## PHP版本是8.1按这篇文章加listtag之后还是报错怎么办 PHP 8.x对Dede这种基于过时写法的代码并不友好,常见的会报MYSQL_ASSOC未定义、each函数已移除等等。建议用MYSQLI_ASSOC替换MYSQL_ASSOC,或者在不影响业务的前提下把站点PHP切回7.2/7.4,这是Dede兼容性最好的版本区间。如果业务上必须停留在PHP 8.x,建议把所有自定义函数里涉及mysql_*的全部改成mysqli_*,工作量不小但是一劳永逸。 ## 升级后文档HTML能生成但栏目HTML还是空白怎么办 这种情况大概率是栏目模板里调用了未定义的自定义函数,比如flink、mytypelist之类。打开对应栏目的模板(默认是templets/default/list_article.htm与index_article.htm),搜索function=,把里面引用的所有函数名挨个核对一遍是否存在于公共函数库。另一种可能是栏目缓存没刷,先去后台“生成-更新系统缓存”跑一遍再重试。 ## 有没有办法一次性看出补丁覆盖了哪些文件 升级前后做一次整站MD5快照对比就能看出来。Linux下命令是find . -type f -name *.php -exec md5sum {} \; | sort > before.txt,升级后再跑一次得到after.txt,diff before.txt after.txt输出的差异行就是被覆盖或新增的文件清单。保哥每次帮客户做大版本升级前都会跑一遍这个流程,省去后续无穷无尽的猜谜。 ## 能不能完全跳过Dede在线升级机制自己手动patch 可以而且推荐。在线升级机制本身就是个隐患,因为它会盲目覆盖文件。手动patch的标准做法是:拿到官方或社区发布的diff文件(通常是一组PHP文件加上一份说明),自己对照备份逐个文件做合并;如果不会合并代码,可以请熟悉PHP的开发协助。手动patch的好处是清楚每一行改了什么,出问题能精准定位。保哥维护的几个核心客户站全部是这种手动patch模式,已经三年没出过一次升级事故。 ## Dede升级失败之后SEO收录数据会不会受影响 短时间(24小时以内)的5xx错误对SEO影响有限,搜索引擎会自动重试。但如果错误持续超过48小时,Googlebot (https://zhangwenbao.com/googlebot-crawl-limits-2mb-deep-analysis.html)会大幅降低抓取频率,Bing也会标记网站为不可用。所以发现故障第一时间一定要切维护页面(返回503 Retry-After头而不是502/500),告诉搜索引擎我在维护稍后再来,这样能最大程度保护排名。修复完之后立刻去Google Search Console (https://zhangwenbao.com/domain-property-vs-url-prefix-property-in-gsc-which-is-better.html)和必应站长工具提交sitemap (https://zhangwenbao.com/tools/sitemap-generator.php)强制重新抓取。 ## 写在最后 织梦的故事到今天已经接近尾声,但它留给互联网世界的是大量仍在线的老站点和经验丰富的运维社群。每次遇到“升级补丁后无法生成HTML”这类问题,保哥的态度都是把它当作一次系统加固的机会:不仅修复表面错误,还顺手把自定义函数集中、备份机制完善、回归流程标准化。这样即使将来再遇到类似事故,也能在半小时内定位、一小时内恢复,而不必像最初那样在凌晨四点对着报错信息抓狂。希望这篇排查记录能帮你把同样的弯路走得更短一些。 ## 织梦DedeCMS文章命名规则怎么批量改才利于SEO - URL:https://zhangwenbao.com/dedecms-batch-modify-article-naming-rules.html - 分类:织梦CMS教程 - 发布:2017-03-03 | 更新:2026-06-01 - 摘要:织梦想批量改文章命名规则做SEO,得改两个文件。本文详解common.inc.php第251行与catalog_add_quick.htm第190行的改法、全站重新生成的五步骤、Apache和Nginx的301跳转规则、五个常见踩坑和一个1.2万篇的实战案例。 - 关键词:301跳转,织梦批量修改,DedeCMS,DedeCMS SEO,URL重写 > **TLDR**:摘要:织梦默认的日期型URL对SEO不友好,正在拖垮收录。本文讲清为什么不友好、改前的三项准备,给出改common.inc.php第251行与catalog_add_quick.htm第190行的同步改法、改完必做的全站重新生成与校验、Apache和Nginx的301跳转保收录,再附五个常见踩坑和一个从日均200UV做到800UV的真实改造案例。 > 摘要:织梦默认的日期型URL对SEO不友好,正在拖垮收录。本文讲清为什么不友好、改前的三项准备,给出改common.inc.php第251行与catalog_add_quick.htm第190行的同步改法、改完必做的全站重新生成与校验、Apache和Nginx的301跳转保收录,再附五个常见踩坑和一个从日均200UV做到800UV的真实改造案例。 ## 写在前面:默认日期URL正在拖垮你的SEO 保哥从2010年左右开始接触织梦DedeCMS,那时候手里同时维护着十几个企业站和资讯站,几乎清一色都是用Dede搭起来的。最让保哥头疼的不是模板,也不是栏目结构,而是它默认的URL命名规则——只要新建一个栏目,文章页就被强行塞进年月日的目录里,URL层级 (https://zhangwenbao.com/impact-of-hierarchical-urls-on-seo.html)一深,搜索引擎抓取效率立刻打折扣,关键词权重也被稀释得稀里哗啦。 这篇文章就把保哥自己反复用过十几次、踩过坑也修补过坑的批量修改命名规则方案完整记下来,希望能帮到还在用Dede维护老站的朋友。所有步骤在保哥本地的DedeCMS 5.7 SP2 UTF8、DedeCMS 5.8、DedeBIZ三套环境上实测过,差异点会单独标出。 ## 不同URL结构对SEO的量化影响 先用一张表展示不同URL结构在SEO上的差异,这是保哥实测多个站点后的统计: URL结构 | 典型示例 | 层级 | SEO评分 | 适用 | 纯日期型(Dede默认) | /news/2017/0303/123.html | 5层 | 差 | 历史遗留站点 | 栏目+ID(推荐) | /news/123.html | 2层 | 优 | 新发布的Dede站 | 栏目+slug | /news/article-name.html | 2层 | 最优 | 双语或外贸站 | 纯slug | /article-name.html | 1层 | 优(小站) | 博客类小站 | 栏目+ID+slug | /news/123-article-name.html | 2层 | 最优 | 需双重唯一性的中型站 | ## 为什么织梦默认URL规则对SEO不友好 要解决问题,先得明白问题出在哪里。织梦默认的命名规则是{typedir}/{Y}/{M}{D}/{aid}.html,转成实际地址就是类似/news/2017/0303/123.html这种五层目录的形态。从SEO视角看,这种结构有三个明显短板。 ## 短板一:层级冗余 Google与百度的爬虫虽然不会因为目录深就拒绝抓取,但会按URL深度评估页面在站点结构中的相对重要性。一个文章页被埋在第五层目录,就比同样内容放在第二层的页面更难获得首页传递过来的权重。保哥实测过的数据:同一篇文章在2层URL下的Google排名平均比5层URL高出8到15个位次。 ## 短板二:日期目录干扰主题相关性 2017/0303这种纯日期片段对关键词没有任何贡献,反而把真正承载主题的栏目目录news给挤到了URL中段,搜索引擎在切词时不容易把目录词识别成主题词。GSC的Index Coverage报告里,这种深层URL经常被标记为"Crawled - currently not indexed"——抓了但不收录。 ## 短板三:改版迁移成本高 一旦哪天想从Dede换到Typecho或WordPress,日期目录会让301映射规则写起来异常复杂,因为新系统通常按栏目加slug的两段式组织URL,源端却是四段式甚至五段式。保哥自己2018年迁移张文保笔记的时候就吃过这个亏,所以从那以后接手的每一个Dede站,第一件事就是改命名规则。 ## 修改前需要做的三件准备工作 动手改源码之前,强烈建议先把下面三件事做完,不要嫌麻烦,几年前保哥就因为没做备份直接改common.inc.php,结果改错符号导致整站白屏,恢复花了两个小时。 ## 准备一:完整备份网站根目录与数据库 网站文件用FTP全量打包就行,或者用rsync同步到本地: # SSH 方式备份 tar -czf /tmp/site_backup_$(date +%Y%m%d).tar.gz /www/wwwroot/dede_site mysqldump -uroot -p dede_db > /tmp/db_backup_$(date +%Y%m%d).sql # 下载到本地 scp root@server:/tmp/site_backup_*.tar.gz ./ scp root@server:/tmp/db_backup_*.sql ./ ## 准备二:检查服务器是否开启静态化生成 织梦默认是把文章生成静态HTML,如果服务器是伪静态模式,那么修改命名规则之后还得同步调整.htaccess或nginx的rewrite规则,否则新规则不会生效。检查方法: # 看一篇文章的实际URL,如果是 .html 结尾且物理文件存在 -> 静态化 ls -la /www/wwwroot/dede_site/news/2017/ # 如果只有伪静态 -> 看 nginx 或 .htaccess 的 rewrite 规则 cat /www/wwwroot/dede_site/.htaccess ## 准备三:清点已有文章数 改完规则之后需要后台一键更新全站,文章越多生成时间越长。保哥有一个三万篇文章的站,整站重新生成花了将近四十分钟,所以最好挑访问低谷期操作。统计文章数: -- 在 phpMyAdmin 或 SQL 命令行执行 SELECT COUNT(*) FROM dede_archives; SELECT typeid, COUNT(*) FROM dede_archives GROUP BY typeid; ## 修改common.inc.php的具体位置与代码 第一个要动的文件是data/config/common.inc.php(部分老版本是直接在根目录的data/common.inc.php),用编辑器打开后定位到大约第251行,找到$cfg_df_namerule这一行变量定义。原始定义大致长这样: $cfg_df_namerule = '{typedir}/{Y}/{M}{D}/{aid}'.$cfg_df_ext; 把它整行替换成下面这一句: $cfg_df_namerule = '{typedir}/{aid}'.$cfg_df_ext; 这里{typedir}会被替换成栏目目录名,{aid}是文章自增ID,$cfg_df_ext默认值是.html。改完之后保存,立刻打开一个全新建的栏目尝试发布文章,URL就会变成/news/12345.html这种紧凑形态。 ## 命名规则变量速查表 变量 | 含义 | 示例 | 是否推荐 | {typedir} | 栏目目录名 | news, products | 强烈推荐 | {aid} | 文章自增ID | 123 | 强烈推荐 | {Y} | 4位年份 | 2026 | 避免使用 | {M} | 2位月份 | 05 | 避免使用 | {D} | 2位日期 | 12 | 避免使用 | {alias} | 文章别名 | article-name | 外贸站推荐 | {title} | 文章标题 (https://zhangwenbao.com/how-to-write-catchy-article-titles.html) | 会被转拼音 | 中文站不推荐 | {typename} | 栏目中文名 | 新闻资讯 | 不推荐(URL编码丑) | ## 5种推荐的命名规则模板 - 纯ID型(最简洁):{typedir}/{aid}.html 输出 /news/123.html - 双重唯一型:{typedir}/{aid}-{alias}.html 输出 /news/123-seo-guide.html - 纯slug型(外贸站):{typedir}/{alias}.html 输出 /products/seo-guide.html - 顶级slug型:{alias}.html 输出 /seo-guide.html(仅适合主题集中的小站) - 带年份的折中型:{typedir}/{Y}/{aid}.html 输出 /news/2026/123.html(资讯类时效内容) 保哥这里多提一句:如果你需要保留更具语义的URL,比如使用文章别名alias,可以把规则写成{typedir}/{aid}-{alias}或者直接{typedir}/{title},但{title}在中文语境下会被转成拼音或编码字符串,反而不利于SEO,保哥个人不推荐。 ## 修改catalog_add_quick.htm模板的同步步骤 仅改common.inc.php还不够,因为Dede后台在新建栏目的快速向导里有一个独立的命名规则字段,默认值是从模板硬编码读取的。如果不同步修改,每次新增栏目还得手动把字段改一遍,量大了非常容易漏。 打开dede/templets/catalog_add_quick.htm,定位到大约第190行,找到name="namerule"那个input标签。原始代码长得像这样:
      把整段value属性替换成下面这一段: 注意dede这个目录名在很多老站为了安全考虑会被改成admin1234之类的自定义路径,找文件的时候请按你实际的后台目录名定位。改完之后清空浏览器缓存,重新进入后台的"核心 -> 栏目管理 -> 增加顶级栏目",就能看到默认命名规则已经变成新规则。 ## 同步修改的其他3个模板文件 除了catalog_add_quick.htm,下面这3个文件也包含namerule字段的默认值,建议一起改: - dede/templets/catalog_add.htm — 完整版栏目添加界面 - dede/templets/catalog_edit.htm — 栏目编辑界面(编辑时的默认值) - dede/templets/catalog_class.htm — 栏目分类列表显示 这4个文件用同样的方式批量替换{typedir}/{Y}/{M}{D}/{aid}.html为新规则即可。Linux下可以用sed一行搞定: sed -i 's|{typedir}/{Y}/{M}{D}/{aid}.html|{typedir}/{aid}.html|g' \ dede/templets/catalog_*.htm ## 完成修改后必须执行的全站更新与校验 两个文件都改完之后,不要以为就大功告成。Dede是把已生成的静态HTML缓存在archives等目录的,新规则只对未来生成的文件生效,老文章的URL还停留在旧路径。这一步必须做完整的全站更新。 ## 5步全站更新顺序 依次进入后台"生成 -> 一键更新网站",按下面的顺序点击: - 更新系统缓存 - 更新主页HTML - 更新栏目HTML(勾选"更改所有栏目命名规则") - 更新文档HTML(勾选"更改所有文档命名规则") - 更新站点地图 第三步与第四步勾选"更改命名规则"选项是关键,没有勾选就只会按旧路径重新生成,新规则等于白改。生成完成之后,使用浏览器访问几篇老文章,看URL是否已经变成新格式;同时打开Google Search Console或百度搜索资源平台,提交sitemap,并在"URL检查"里抽测三五个新地址,确认返回200且能被抓取。 ## 命令行批量更新(大站点必备) 3万篇以上的站点用后台一键更新经常超时。命令行方式更稳: # 用 wget 触发 Dede 的 makehtml.php 命令 wget --no-check-certificate -O /tmp/genlog.txt \ "http://example.com/dede/makehtml_archives_action.php?upnamerule=1&startid=1&endid=99999" # 或者用 curl 配合 cookie curl -b "dede_admin=cookie_value" \ "http://example.com/dede/makehtml_archives_action.php?upnamerule=1" ## 改完命名规则之后的301跳转与收录恢复 这一步是十有八九的教程会忽略的,但保哥从经验出发必须强调:URL一旦变化,老的搜索引擎索引会瞬间变成404,如果不做301,前期至少会有两到四周的流量低谷。保哥的做法是在.htaccess(Apache)或站点的nginx配置里写一段正则,把旧的日期路径全部跳到新路径。 ## Apache环境的写法 RewriteEngine On # 把 /news/2017/0303/123.html 跳转到 /news/123.html RewriteRule ^([^/]+)/[0-9]{4}/[0-9]{4}/([0-9]+)\.html$ /$1/$2.html [R=301,L] # 兜底:如果新URL也访问不到,跳回栏目页 RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^([^/]+)/[0-9]+\.html$ /$1/ [R=302,L] ## Nginx环境的写法 # 在 server 块内 rewrite ^/([^/]+)/[0-9]{4}/[0-9]{4}/([0-9]+)\.html$ /$1/$2.html permanent; # 也可以用 location 加 try_files 兜底 location ~ ^/([^/]+)/[0-9]+\.html$ { try_files $uri $uri/ /index.php?$args; } ## 301跳转的5步验证SOP 规则上线之后,到搜索引擎的资源平台提交"死链文件"与"改版规则",让搜索引擎尽快识别301,把权重转移到新地址。 - 用curl -I http://example.com/news/2017/0303/123.html检查返回301 Moved Permanently - 用curl -I -L跟随跳转,确认最终落到新URL返回200 - 访问几篇热门老文章的旧URL,浏览器自动跳转到新URL - 在GSC的"URL检查"工具里输入旧URL,看是否报告301 - 提交改版规则到百度搜索资源平台的"网站改版"工具 一般两周左右流量就能基本恢复,三到六周后新URL的权重会比旧的还要稳定,因为层级浅了,内链传递效率也提高了。 ## 常见踩坑提醒 保哥自己改过的站点不下五十个,最容易翻车的是下面几种情况,特别提醒一下。 ## 坑一:忘记替换catalog_add_quick.htm 替换了common.inc.php但忘记替换catalog_add_quick.htm,导致新建栏目时默认命名规则又变回老样子。修复:全文件搜索{Y}/{M}{D}这种字符串,确认所有引用都改完。 ## 坑二:cfg_df_ext被魔改为空字符串 cfg_df_ext这个变量在某些魔改版Dede里被改成空字符串,导致生成的URL是/news/12345不带.html,看起来更短但有些主机会把它识别成目录请求,需要额外配置。修复:检查common.inc.php里$cfg_df_ext的值,恢复为.html。 ## 坑三:栏目表里的namerule字段未同步 Dede的栏目命名规则字段在数据库的dede_arctype.namerule里另存了一份,如果你不勾选"更改所有栏目命名规则",数据库里那一份永远是旧值。修复:执行UPDATE dede_arctype SET namerule='{typedir}/{aid}.html'批量更新。 ## 坑四:文档表里的namerule字段未同步 Dede在dede_archives表里也有一个namerule字段,记录每篇文章生成时的路径模板。如果只改全局变量没有同步更新这张表,老文章重新生成时还会用旧模板。最稳妥的办法是在生成全站之前,手工执行一句SQL: UPDATE dede_archives SET namerule='{typedir}/{aid}.html'; ## 坑五:栏目交叉引用未处理 有的栏目其实是另一个父栏目的副本,使用了交叉栏目功能,文章会同时在多个栏目下生成HTML。这时候光改父栏目的命名规则不够,得在交叉栏目设置里也手动改一次,否则副本路径还是日期形态。 ## 一个真实改造案例:从日均200UV到800UV的过程 保哥2019年接手过一个客户的家居建材站,使用Dede已经四年,文章总数大约一万二千篇,日均流量长期卡在200UV上下。我接手之后第一件事就是按今天这套方法改命名规则,整个迁移过程用了大约一周时间。 ## 第一阶段:分析现状 用爬虫工具把全站URL全部抓出来,统计目录深度,发现87%的文章页都在第五层目录,平均路径长度58个字符。这种URL在搜索结果页里展示出来已经被截断,连面包屑都看不全。GSC的Coverage报告显示"Crawled - currently not indexed"页面占比超过40%。 ## 第二阶段:离线测试 在内网搭了一个克隆站点,先在测试环境跑一遍完整流程,确认全站重新生成不会出现致命错误,再把301跳转 (https://zhangwenbao.com/tools/htaccess-redirect.php)规则在测试机上验证一周,观察是否有死循环或者性能问题。测试期间用ab压测确认301跳转的性能损耗在5%以内。 ## 第三阶段:上线 选在凌晨两点开始执行,先备份再改源码,紧接着全站生成,最后部署301规则、提交改版规则到搜索引擎资源平台。第二天清晨流量数据没出现异常波动,三天后开始有新URL被收录,两周后老URL全部301完成索引迁移,第四周流量开始稳步爬升,到第八周日均UV翻到了800左右,关键词排名整体提升了30多个位次。 ## 核心指标变化 指标 | 改造前 | 改造2周 | 改造8周 | 日均UV | 200 | 180 | 800 | 收录页面数 | 1.2万 | 0.9万(迁移期) | 1.3万 | 核心词排名(top10) | 5个 | 3个 | 23个 | 外链点击率 | 1.8% | 2.5% | 4.2% | 平均访问时长 | 1:24 | 1:18 | 2:43 | 这件事让保哥对"URL结构是SEO基础设施"这个判断更加笃定。如果你也在维护一个停滞已久的Dede老站,又找不到流量增长的突破口,从命名规则改造入手往往是性价比最高的一步。 ## 常见问题解答 ## 修改之后老的日期URL还能访问吗? 如果没做301,老URL在重新生成全站之后会直接变成404。所以无论老站是否还有外链,保哥都建议加一段301跳转规则,既保留了老用户的访问体验,也避免搜索引擎流量断崖。301跳转可以在Apache的htaccess或Nginx的server配置里实现,保哥推荐用本文给的正则一次性覆盖所有日期路径模式,不要逐条URL写映射规则。 ## 可以把命名规则改成纯英文别名形式吗? 可以,把aid替换成title或者aid加title即可。但中文标题会被自动转成拼音串或哈希值,并不一定比纯数字ID友好。保哥个人最推荐的折中方案是{typedir}/{aid}-{alias},发文章时手动填一个英文alias,兼顾可读性与SEO。这种URL在Google搜索结果里展示效果最好,关键词权重传递也最高。 ## 生成全站的时候服务器一直502怎么办? 这通常是PHP执行超时或内存不足导致的。在php.ini里把max_execution_time调到600,memory_limit调到512M,再分批次生成,先按栏目分别更新最后再统一更新文档,多数情况下都能跑通。如果文章数超过五万,建议直接用命令行调用Dede提供的更新脚本,避免浏览器层面的连接超时。或者用nohup后台跑:nohup php /www/wwwroot/dede/dede/makehtml_archives.php > /tmp/gen.log 2>&1 & ## Dede已经停止维护了现在改这个还有意义吗? 保哥的看法是有意义但要做好迁移规划。如果站点还在产生稳定流量,改URL规则能让SEO表现立刻提升一档;同时新规则的浅层结构也更接近Typecho、WordPress的默认形态,将来真要迁移的时候301映射会简单很多。如果你打算1年内迁移到其他CMS,URL规则改造也是迁移前的必要准备——让源站URL与目标CMS的默认形态尽量接近,迁移规则会简单得多。 ## 301跳转规则会影响网站性能吗? 实测影响很小。Apache和Nginx的rewrite模块都是高度优化的,单条规则的处理延迟在微秒级。即使全站每个请求都要走一次rewrite,对TTFB的影响也在5%以内。如果你的站点QPS超过500,担心性能影响可以把301规则改成永久缓存的map指令(Nginx专属),或者直接生成静态文件层级的301页面跳过PHP处理。 ## 百度的网站改版工具具体怎么用? 登录百度搜索资源平台,左侧菜单进"链接提交"-"网站改版"。选择"路径改版",填入旧URL规则和新URL规则的对应关系,例如旧规则http://example.com/news/(\d+)/(\d+)/(\d+).html映射到新规则http://example.com/news/$3.html。提交后百度会在7到14天内逐步把旧URL的索引迁移到新URL。Google端没有类似工具,主要依靠301信号让Googlebot自己识别。 ## 同时改了多个CMS的URL规则后,怎么验证哪个没生效? 三个验证工具:第一用Screaming Frog爬一遍全站,对比改造前后的URL列表;第二在GSC的Coverage报告里看"Page with redirect"是否数量与预期一致;第三随机抽样100个旧URL,用curl测试每个的状态码,统计301、200、404的比例。三个工具结合用,能定位到所有未生效的规则。 ## 改完URL规则后老的内链需要批量更新吗? 需要,但可以用SQL批量做。Dede的内链通常存在dede_archives.body字段里,可以用UPDATE语句批量替换:UPDATE dede_archives SET body = REPLACE(body, '/news/2017/', '/news/')。注意先备份再做。如果文章body里的链接非常混杂(带各种日期目录),可以写个PHP脚本逐篇文章解析body里的href,按正则匹配并替换。 ## 写在最后 织梦虽然已经停止官方维护,但仍然有数以十万计的网站在使用它,特别是2015年到2020年那段时间搭起来的中小企业站和地方门户。命名规则改造看起来只是改两个文件,但背后牵动的是整个站点的SEO健康度。 如果你正打算给Dede老站做一次系统性的优化,强烈建议把这件事放在第一项。先把基础设施改对,再做内容更新 (https://zhangwenbao.com/revise-old-content-for-aeo-ai-search-optimization.html)和外链建设,整体效果会比单纯堆内容好得多。这篇文章里的每一段代码、每一条命令,都是保哥过去这些年在真实生产环境跑过的,希望能帮你少走一些弯路。 最后给一个时间预算参考:3000篇以内的小站,改造全流程1到2天可以完成;3000到3万篇的中型站,需要3到5天(主要时间在全站重新生成与301规则验证);3万篇以上的大站,建议2周以上,分批改、分批验、分批上线,降低风险。 ## 权威参考资料 ## 织梦怎么批量删除未审核文章?两条SQL加事故复盘 - URL:https://zhangwenbao.com/dedecms-batch-delete-audit-articles-and-reviews.html - 分类:织梦CMS教程 - 发布:2017-02-26 | 更新:2026-06-01 - 摘要:DedeCMS网站积累几万条未审核垃圾内容,本文用arcrank=-1和ischeck=0两条SQL批量清理,讲清织梦一文多表架构、孤儿数据连带删除、UNIX_TIMESTAMP时间范围过滤、OPTIMIZE TABLE碎片回收、reCAPTCHA防垃圾源头切断,附SQL命令行工具操作步骤。 - 关键词:织梦批量删除,网站运维,DedeCMS,数据库清理,织梦CMS > **TLDR**:摘要:织梦站积累几万条未审核垃圾内容,本文用arcrank等于-1和ischeck等于0两条SQL批量清理。先讲清织梦一文多表的结构和动手前的五项准备,给出两条核心SQL,再讲怎么把垃圾源头一并断掉、一次少加WHERE的真实事故、不同版本差异、百万级清理的性能调优、静态HTML残留处理和清理后的健康检查。 > 摘要:织梦站积累几万条未审核垃圾内容,本文用arcrank等于-1和ischeck等于0两条SQL批量清理。先讲清织梦一文多表的结构和动手前的五项准备,给出两条核心SQL,再讲怎么把垃圾源头一并断掉、一次少加WHERE的真实事故、不同版本差异、百万级清理的性能调优、静态HTML残留处理和清理后的健康检查。 保哥维护过的几十个DedeCMS站点,几乎每一个都会在某个阶段遇到同一种灾难——评论审核队列里堆了几万条垃圾评论,未审核的草稿文章塞了半个数据库。后台一条条点删除,先不说效率,浏览器卡得连页面都打不开;用插件清理又怕动到正常数据;让运维直接 drop 表又怕牵连主站。织梦的特殊在于它的“一文多表”架构——一篇文章的数据分散在 dede_archives、dede_addonarticle、dede_arctiny 三张表里,删一篇文章如果只删主表,剩下两张表里的孤儿数据会让后台报错、前台 500,一年后想恢复连关联都找不回来。 这篇文章保哥把当年沉淀下来的批量清理方案完整写出来,包含两条核心SQL、五项必做的事前准备、织梦三张关联表的清理顺序、不同织梦版本的差异、一个真实事故复盘、以及清理之后必须跟进的健康检查清单。如果你正在或即将面对一个堆满垃圾内容的织梦后台,可以严格按本文流程操作,并且能从中读到很多保哥过去十几个客户站翻车后才学到的细节。 ## 为什么不能直接在后台一条条删 织梦后台的评论管理列表分页通常每页20或30条,5万条垃圾评论意味着至少1700次翻页加勾选加提交,每次提交的POST请求会触发以下操作。 - 写一次dede_log操作日志,单条日志大概100字节,5万条要新增近5MB日志。 - 重建一次评论缓存,data/cache下面的pl_*.inc会被重写。 - 触发一次主题模板的钩子(如果你装了某些插件),相当于额外执行一段PHP。 - 写一次会话状态到session,记录最后操作时间。 这意味着删除速度被限制在每分钟几百条左右,删完整个队列需要几个小时,期间MySQL还会因为大量小事务而IO被打满。保哥2018年就经历过一次:客户的虚拟主机被空间商误判为攻击源,强制重启了MySQL,后台清理工作直接中断,已经删了三千条但织梦的arcrank标记并没有持久化完整,重新登录后看到的依然是原来的5万条,等于白干。 相比之下,直接在SQL命令行工具里跑一条DELETE语句,5万条数据通常200毫秒内完成,且只产生一次事务。这就是为什么保哥推荐SQL方案。一次事务也意味着要么全部成功、要么全部回滚,不会出现“删一半中断”这种最难处理的中间状态。 ## 织梦的数据表结构:理解了再动手 很多新手以为“一篇文章对应一行数据”,对织梦来说大错特错。织梦从5.3版本开始引入“主辅分表”架构,一篇文章的数据是这样分布的。 ## 主表 dede_archives 每篇文章在这张表里有一行,存放最重要的元数据:id、typeid(栏目)、title、shorttitle、color、writer、source、litpic(缩略图)、pubdate、senddate、arcrank、click、ismake、channel等字段。前台分页查询、栏目页生成都是查这张表。arcrank的含义:-1表示未审核,0表示已审核(开放浏览),其他正整数表示需要付费或会员等级才能查看。 ## 副表 dede_addonarticle(普通文章正文) 这张表里只有aid、typeid、body、redirecturl、templet、userip这几个字段。body就是文章正文HTML。dede_archives和dede_addonarticle通过id和aid一一对应。如果你只删主表不删副表,副表里会出现aid指向已删主表的孤儿数据,长此以往数据库膨胀。 ## 微数据表 dede_arctiny 这是织梦自己维护的一个“轻量索引表”,存所有内容的id、typeid、channel、senddate、sortrank、mid,用于快速做关联查询。织梦的文档列表筛选、Tag关联检索都靠它。这张表的孤儿数据会让后台“文档列表”里出现一行行空白记录,点进去500。 ## 评论表 dede_feedback 评论的所有字段都在这一张表里:aid(关联文章)、typeid、username、ip、ischeck、dtime、mid、msg。ischeck=0是未审核,ischeck=1是已审核。和文章不同,评论没有副表,所以清理评论比清理文章简单很多。 ## 其他特殊频道的副表 如果你的站点装了图集、软件、商品、专题等额外频道,每个频道都有自己的副表:图集是dede_addonimages,软件是dede_addonsoft,商品是dede_addonshop。清理时这些表都要一并处理,否则同样产生孤儿数据。 ## 操作前必须完成的五项准备 直接执行DELETE的风险不在SQL本身,而在于一旦删错没法恢复。保哥给所有客户的清理流程,第一步永远是这五件事,少做一件都不行。 ## 全库备份 mysqldump -u root -p --single-transaction --routines --triggers \ your_dede_db > /backup/dede_$(date +%Y%m%d_%H%M).sql --single-transaction保证备份过程中不锁表,对在线站点很重要。备份文件至少留三份,本地一份、对象存储一份、邮箱发自己一份。备份完后用du -sh看一下大小,再用head -50看一下文件开头的“Dumping data for table”能不能正常出现,确保备份没有中途断掉。 ## 单表导出 如果整库太大,至少把dede_archives、dede_addonarticle、dede_arctiny和dede_feedback四张表单独导一次: mysqldump -u root -p your_dede_db dede_archives dede_addonarticle dede_arctiny dede_feedback > /backup/four_tables.sql 这份单表备份能让你在出问题时快速回滚单张表,比整库恢复快十倍以上。 ## 用SELECT先确认要删多少 SELECT COUNT(*) FROM `dede_archives` WHERE arcrank = -1; SELECT COUNT(*) FROM `dede_feedback` WHERE ischeck = 0; 如果第一条返回了几十万,先别急着删。可能是有人误把所有正常文章批量改成了未审核状态。保哥真的遇到过:客户实习生在后台批量操作时勾错了选项,把12万篇正常文章打成arcrank=-1,要是直接DELETE了,整个站就空了。 ## 抽样查10条记录人工肉眼审 SELECT id, title, pubdate, arcrank FROM `dede_archives` WHERE arcrank = -1 ORDER BY id DESC LIMIT 10; 看一下抽样里的title是不是都长得像垃圾内容(乱码、外链广告、博彩词)。如果出现正常的标题,立刻停止,回到3.3重新评估。也要倒着看一遍ORDER BY id ASC LIMIT 10,因为有些客户的“未审核”堆积是几年前的存货,新增的可能反而是正常的还没审完。 ## 关闭前台访问入口(可选但推荐) 如果你的清理量很大(百万级),删除期间可能产生表锁。先在.htaccess里加一段维护页,只放行你自己的IP,其他用户看到维护页。或者直接在Nginx里 return 503,让搜索引擎蜘蛛 (https://zhangwenbao.com/5-ways-to-judge-search-engine-spiders-jumping-to-specified-pages.html)看到“Service Unavailable”状态码主动延迟下次抓取,避免清理过程中蜘蛛拿到不完整数据。 ## 核心SQL:两条命令搞定批量清理 做完上面五件事后,进入织梦后台、系统、SQL命令行工具,分两次执行下面两条SQL。不要复制带反引号的版本到部分国产建站环境,有些MySQL 5.5老版本对反引号兼容差,保哥下面给的是最稳的版本。 ## 批量删除未审核文档 DELETE FROM `dede_archives` WHERE arcrank = -1; 执行后,织梦的主表dede_archives里所有arcrank为-1的记录都会被清空。但织梦的文档数据是分表的——还有dede_addonarticle、dede_arctiny等附属表里的数据。完整清理需要再补两条: DELETE FROM `dede_addonarticle` WHERE aid NOT IN (SELECT id FROM `dede_archives`); DELETE FROM `dede_arctiny` WHERE id NOT IN (SELECT id FROM `dede_archives`); 这两条会把附属表里失去主表关联的孤儿数据也删掉。如果你的站点有图集、软件、商品等额外频道,要把对应副表也清一遍: DELETE FROM `dede_addonimages` WHERE aid NOT IN (SELECT id FROM `dede_archives`); DELETE FROM `dede_addonsoft` WHERE aid NOT IN (SELECT id FROM `dede_archives`); DELETE FROM `dede_addonshop` WHERE aid NOT IN (SELECT id FROM `dede_archives`); ## 批量删除未审核评论 DELETE FROM `dede_feedback` WHERE ischeck = 0; ischeck = 0表示未通过审核。如果你想保留某些已审核但被错误标记成0的评论,可以加上时间范围: DELETE FROM `dede_feedback` WHERE ischeck = 0 AND dtime < UNIX_TIMESTAMP('2024-01-01'); 这样只清理2024年之前的未审核评论。dtime字段是Unix时间戳格式,UNIX_TIMESTAMP函数把字符串日期转成时间戳。 ## 评论关联数据清理 织梦的评论数本身保存在dede_archives.feedback字段(每篇文章的评论数)。删完评论后这个数字会变“假”,前台“评论数N”显示和实际查到的评论数不一致。修正用这条SQL: UPDATE `dede_archives` a SET a.feedback = (SELECT COUNT(*) FROM `dede_feedback` f WHERE f.aid = a.id AND f.ischeck = 1); 跑完之后每篇文章的评论数就回到真实值。 ## 重建文档计数与缓存 清理完后,到织梦后台、系统、数据库内容修复、一键修复,让织梦重新统计每个栏目下的文档数量。然后到系统、更新缓存,刷一次站点缓存。这两步不做的话,前台栏目页可能会出现“显示100条文档但实际只有30条”的错位。也可以直接跑SQL让dede_arctype的totalarticle字段重新统计: UPDATE `dede_arctype` t SET t.totalarticle = (SELECT COUNT(*) FROM `dede_archives` a WHERE a.typeid = t.id AND a.arcrank >= 0); ## 进阶清理:把垃圾源头一并断掉 光删数据不够,垃圾评论会持续涌入。保哥的标准方案是删完之后立刻部署三道防线。 ## 关闭未登录用户评论 后台、模块、评论管理、评论权限设置,把“游客评论”关闭,只允许会员评论。这一刀下去能砍掉80%的垃圾。绝大多数自动化垃圾评论机器人不会模拟完整的注册流程,只会硬怼提交接口。 ## 添加敏感词与正则过滤 在/data/admin/forbidword.txt里加上常见博彩、私彩、五金外链域名。织梦自带的过滤词文件支持每行一个关键词,匹配到就拒绝评论提交。可以粗暴一点直接禁所有带http://的评论,因为正常用户极少会在评论里贴外链。保哥的客户站这条规则上线第一周拦了2.3万条提交,前台体验完全没受影响。 ## 加图形验证码或行为验证 织梦默认验证码弱到几乎可以被OCR直接识别。保哥推荐换成Google reCAPTCHA v3或者腾讯云的天御验证码,集成代码网上很多。reCAPTCHA v3完全无感,根据用户行为评分自动放行或拦截,体验最好。 ## 限频与IP黑名单 在Nginx层加一段限频规则: limit_req_zone $binary_remote_addr zone=plzone:10m rate=2r/m; location ~ ^/plus/feedback\.php { limit_req zone=plzone burst=3 nodelay; fastcgi_pass 127.0.0.1:9000; } 同一个IP每分钟最多提交2次评论,超出直接返回429。再配合fail2ban定期扫描访问日志,把频繁触发429的IP直接ban进iptables,长效防护。 ## 真实事故复盘:一次少加WHERE的代价 2019年保哥帮一个本地媒体站做清理,操作员当时复制了下面这条SQL: DELETE FROM `dede_archives`; 你看出来问题了吗——少了WHERE arcrank = -1。这条SQL一旦执行,整个dede_archives表会被清空,等于网站所有文章瞬间消失。所幸保哥前一天刚做完全库备份,并且binlog是开着的。整个恢复流程是这样: - 立刻STOP SLAVE(如果有从库),防止删除被复制过去; - 用mysqlbinlog --start-datetime=... --stop-datetime=...截取事故前的位置; - 把昨晚的mysqldump备份恢复到一个临时库; - 从临时库把dede_archives拷回生产; - 用binlog重放事故备份点之后到DELETE之前的所有操作。 整个过程花了6个小时,前台用503维护页顶着。事后保哥定了一条规矩:所有DELETE语句必须先以SELECT形式跑一次,确认行数,再改成DELETE提交。流程如下: -- 第一步:先这样跑 SELECT COUNT(*) FROM `dede_archives` WHERE arcrank = -1; -- 第二步:确认数字合理后,再这样跑 DELETE FROM `dede_archives` WHERE arcrank = -1; 从那次之后保哥再没出过事故。这种“先SELECT再DELETE”的双重确认在所有数据库高危操作里都该默认采用,包括UPDATE批量改字段、TRUNCATE清表。 ## 不同织梦版本的差异 织梦自从被收购后官方维护停滞,社区分化出多个分支版本,清理SQL在不同版本下可能有细节差异。 ## 织梦5.6/5.7 这是市面上最常见的版本,表结构稳定,本文所有SQL都直接适用。注意5.7修补了几个已知的SQL注入 (https://zhangwenbao.com/dedecms-message-board-edit-inc-php-injection-vulnerability-repair-method.html)漏洞但没改表结构,所以清理逻辑不变。 ## 织梦5.8/dede biz 商业版增加了dede_archives_extend表用于存放扩展字段,清理时要补一条: DELETE FROM `dede_archives_extend` WHERE aid NOT IN (SELECT id FROM `dede_archives`); ## 织梦M版/手机版独立分表 如果你的织梦装了手机版插件且采用了分表方案,dede_archives_m是手机版主表。要把M版同步清理,否则手机端继续显示已删的垃圾文章。 ## 国产二开版本 市面上常见的“织梦内核XX建站系统”一般会改前缀(比如dede_改成xx_),但表名后缀和字段含义大多保留。清理前用SHOW TABLES LIKE '%archives%'确认表名再动手。 ## 清理之后的健康检查清单 清理完成不代表事情结束,下面这些检查项是保哥从无数次翻车里总结出来的标准动作,每一项都别省。 - 前台所有栏目首页随机刷5个,看有没有500报错或者“无数据”空页。 - 后台“文档列表”翻到最后一页,确认分页正常,每页都有数据。 - 用mysqlcheck -u root -p --auto-repair --optimize your_dede_db跑一次表优化,回收DELETE留下的碎片空间。 - 检查data/cache目录大小,如果还大于50MB手动清空一次。 - 用site:yourdomain.com在百度搜一下,看搜索结果有没有大量已删除文章的快照(如果有,去站长平台提交死链)。 - 检查 /data/sitemap (https://zhangwenbao.com/wordpress-free-plug-in-automatically-updates-sitemap-xml.html).xml 是否需要重新生成,把已删文章从sitemap里剔掉。 - 看Nginx access日志最近一小时有没有突然增加的404,那就是搜索引擎或外部链接还在访问已删文章。 - 检查RSS输出,确保已删文章不再出现在/data/rss/或/feed路径。 ## 性能调优:百万级清理怎么做 如果你的未审核内容已经堆到百万级,一次DELETE可能跑几个小时甚至几十个小时。这种情况要分批处理,避免锁表过久。 ## 分批删除 DELETE FROM `dede_feedback` WHERE ischeck = 0 LIMIT 5000; 每次只删5000条,写一个shell脚本循环跑,每次跑完sleep 1秒,给MySQL喘口气。脚本逻辑如下: #!/bin/bash while true; do AFFECTED=$(mysql -u root -pPASS your_dede_db -e "DELETE FROM dede_feedback WHERE ischeck = 0 LIMIT 5000;" -B 2>&1 | grep -oP '\d+') if [ "$AFFECTED" -lt 1 ] 2>/dev/null; then break; fi sleep 1 done ## 表碎片整理 DELETE之后InnoDB不会立刻回收磁盘空间,表上的空隙叫做“碎片”。100万条记录的表DELETE 80万行后磁盘占用可能还是原来的尺寸。要回收用: OPTIMIZE TABLE `dede_archives`; OPTIMIZE TABLE `dede_addonarticle`; OPTIMIZE TABLE `dede_feedback`; OPTIMIZE对InnoDB等价于ALTER TABLE ... ENGINE=InnoDB,会重建整张表回收碎片。注意这个操作会锁表,要在低峰期跑。 ## 索引重建 大量删除后索引页可能稀疏,扫描效率下降。重建索引: ALTER TABLE `dede_archives` DROP INDEX `arcrank`, ADD INDEX `arcrank`(`arcrank`); ## 静态HTML文件残留处理 织梦默认生成静态HTML文件存放在/a/或/html/目录下。清理数据库后这些静态文件不会自动删除,搜索引擎可能继续抓到孤立的静态页。处理方法有两种。 ## 物理删除 跑一个find命令找出所有比清理时间早的HTML: find /www/wwwroot/yoursite/a -name "*.html" -mtime +30 -delete 这条命令删除30天没修改过的HTML,配合清理时机就能把已删文章的静态文件物理移除。 ## robots与404 如果你的搜索引擎收录量很大,物理删除可能让大量404瞬间出现,对SEO不友好。更稳的做法是让织梦正常重建静态文件(被删的会自然消失),然后在站长平台批量提交死链,让搜索引擎主动从索引里移除。 ## 常见问题解答 ## 删完之后文档ID还能复用吗? 不能。织梦的dede_archives.id是AUTO_INCREMENT,删除后下一个新增文档的ID会从当前最大值加1继续,不会回填空缺。如果你强迫症想让ID连续,需要ALTER TABLE dede_archives AUTO_INCREMENT = N,但保哥不推荐——一旦有外部链接引用了被删ID,连续化反而会让旧链接指向错误的新内容,对SEO是灾难。 ## 执行DELETE时报Lock wait timeout exceeded怎么办? 说明有其他进程正在锁这张表,最常见的是后台正在重建索引或者有大量前台访问。先SHOW PROCESSLIST看正在跑的连接,确认没有重要任务后KILL掉冲突连接,再分批DELETE。每次只删5000条,循环跑直到affected rows为0,这样能避开长事务锁。如果是定时任务在跑,先临时停掉cron。 ## 能不能用一条SQL同时删主表和附属表? 可以用JOIN。但保哥不推荐在生产库这样写。原因是这种DELETE JOIN一旦写错条件,影响面是双倍的,事故复盘会更复杂。分两条独立SQL更稳——主表删完先COUNT一下,确认附属表的孤儿数量合理,再删附属表,能多一道保险。 ## 删了正常评论怎么办? 如果你按本文3.1做了备份,从备份里mysqldump --tables your_dede_db dede_feedback单独导出,然后挑出被误删的行INSERT IGNORE回去即可。如果没备份,基本无解,只能从binlog回放。所以保哥再次强调:清理前的备份不是可选项,是必选项。binlog默认开启时间一般是7天到30天,超出窗口就找不回了。 ## 清理后栏目页文档数还是旧的怎么办? 织梦栏目页的“文档数”来自dede_arctype.totalarticle字段,是静态缓存的。后台“数据库内容修复”有一键重算入口;命令行的话跑UPDATE dede_arctype SET totalarticle = (SELECT COUNT(*) FROM dede_archives WHERE typeid = dede_arctype.id AND arcrank >= 0) 也能批量同步。 ## SQL命令行工具找不到怎么办? 织梦后台、系统、SQL命令行工具默认路径是/dede/sys_sql_query.php。如果你的后台菜单看不到,可能是权限不够(必须用超级管理员),或者后台插件冲突。直接在浏览器输完整路径访问即可。还不行就用phpMyAdmin或者Navicat直连数据库执行,效果一样。 ## 清理后搜索引擎一直来抓已删文章怎么办? 这是清理后2到4周内的正常现象,搜索引擎缓存里还有旧URL。处理方法:第一去百度站长平台、Google Search Console提交死链文件(每行一个URL);第二在Nginx里给已删URL返回410 Gone而不是404,告诉搜索引擎“这页面永久不见了”,回收速度更快;第三在robots.txt (https://zhangwenbao.com/page-types-to-block-in-robots-txt-for-ecommerce.html)里Disallow已删的目录路径,防止再被抓取。 ## 批量清理对SEO有负面影响吗? 看你删的是什么。如果删的是真的垃圾内容,对SEO是正向的——Google和百度都讨厌站点里有大量低质量页面,“低质内容比例”过高会拖累整站权重。如果不小心删了正常文章,那确实是负面,因为收录页面减少、内链断裂、外链指向404。所以清理前一定要做好抽样审核,确认确实是垃圾再删。 ## 写在最后 批量清理织梦未审核的文档和评论这件事本身只有两条SQL,但围绕这两条SQL的备份、抽样、防注入、关联表清理、缓存重建、健康检查、SEO跟进,才是保哥这些年沉淀下来的全部价值。SQL越简单,操作越要慎重。下一次你打开织梦后台准备一键清理时,请先把本文的五项准备从头到尾过一遍,再动手。一次完整执行能让你的站点焕然一新,一次失手能让你忙活一整周。慢一点,稳一点,比快十倍更划算。 ## 权威参考资料 ## 织梦手机站搜索跳错PC页怎么修?DEDEMOB常量两套方案 - URL:https://zhangwenbao.com/methods-dedecms-own-mobile-phone-terminal-dedecms-web-search-page-to-jump-directly-to-the-searchmhtm-mobile-phone-station-page.html - 分类:织梦CMS教程 - 发布:2017-02-25 | 更新:2026-06-01 - 摘要:织梦5.7手机站搜索老跳回PC站,根因在arc.searchview.class.php里DEDEMOB常量的判断。本文给两种方案:改plus/search.php加mobile参数定义DEDEMOB,和复制wapsearch.php完全隔离的备用方案,再附.htaccess双向跳转、防刷限速和UA日志验证收尾。 - 关键词:织梦跳转,DedeCMS,织梦SEO,手机搜索,织梦修复 > **TLDR**:摘要:织梦5.7手机站搜索老跳回PC站,根因在arc.searchview.class.php里DEDEMOB常量没被正确赋值。本文给两种修复——用隐藏字段传mobile标识、复制一份手机专用搜索文件完全隔离,再配套跳转、安全限流、监测三件事,讲常见报错的排查思路和修复后的性能与安全加固,并提醒搜索修复只是织梦停更后的缓兵之计。 > 摘要:织梦5.7手机站搜索老跳回PC站,根因在arc.searchview.class.php里DEDEMOB常量没被正确赋值。本文给两种修复——用隐藏字段传mobile标识、复制一份手机专用搜索文件完全隔离,再配套跳转、安全限流、监测三件事,讲常见报错的排查思路和修复后的性能与安全加固,并提醒搜索修复只是织梦停更后的缓兵之计。 保哥从 2012 年开始接手第一个 DedeCMS 项目,到 2017 年那波移动化改造高峰期,一共维护过 40 多个基于织梦 5.7 的网站。其中绝大部分都遇到过同一个问题:PC 端用 default 模板,手机端用 default_m 模板,但访问者在手机上点搜索按钮,结果直接跳到了 PC 站的搜索结果页。这种情况下不仅样式错乱,整个搜索体验跟手机端完全不匹配,移动端跳出率 (https://zhangwenbao.com/user-behavior-signals-reshaping-seo-dwell-time-bounce-rate.html)会瞬间拉高 20% 到 40%。 这篇文章把当年踩过的所有坑、两种修复方案的代码细节、配套的反向跳转和速率限制、以及怎么验证修复效果一次性说清楚。如果你正在维护一个还没退役的织梦站,或者在做老站迁移前的应急修补,可以照着流程走一遍。整个修复过程大约 30 分钟,能解决你 90% 的手机搜索跳转问题。 ## 问题的根本原因:DEDEMOB 常量没有被正确赋值 织梦 DedeCMS 在/include/arc.searchview.class.php文件里通过判断常量DEDEMOB来决定加载哪一套模板。当DEDEMOB等于Y时使用search_m.htm(移动端模板),否则使用search.htm(PC 端模板)。 问题在于,/plus/search.php这个入口文件并没有把当前请求的设备类型透传给arc.searchview.class.php。也就是说,手机用户提交搜索表单后,PHP 端无法知道这是来自手机端的请求,于是默认走 PC 流程。 保哥当年第一次排查时,绕了不少弯路:先是怀疑是.htaccess的跳转规则误伤了/plus/search.php,又怀疑是 CDN 在 PC 和 Mobile 分流时忽略了?keyword=这种带参数的请求。最后用var_dump打印define结果才确认——是织梦自身的 PHP 流程缺少了一个变量传递。 核心代码段在/include/arc.searchview.class.php大约 60 到 70 行: // 检查 DEDEMOB 常量决定模板路径 if (defined('DEDEMOB')) { $this->Templet = $cfg_basedir.$cfg_templets_dir.'/'.$cfg_df_style.'/search_m.htm'; } else { $this->Templet = $cfg_basedir.$cfg_templets_dir.'/'.$cfg_df_style.'/search.htm'; } 知道根本原因后,修复思路就很清晰:让/plus/search.php在判定为手机请求时,主动定义DEDEMOB。下面给出两种实现方案,第一种是保哥推荐的最小改动方案,第二种是隔离改动的备用方案。 ## 修复方案一:通过隐藏字段传递 mobile 标识 这是保哥推荐的方案,改动小、回滚也简单、对后续升级影响小。整个流程分三步。 ## 修改 /plus/search.php 打开/plus/search.php,搜索下面这行代码(大约在第 18 至 22 行之间,不同版本行号略有差异): $mid = (isset($mid) && is_numeric($mid)) ? $mid : 0; 在它的下方追加: $mobile = (isset($mobile) && is_numeric($mobile)) ? $mobile : 0; if ($mobile == 1) { define('DEDEMOB', 'Y'); } 这段代码的逻辑很简单:从$_REQUEST拿到mobile参数,如果它等于 1,就告诉后续的arc.searchview.class.php,当前请求要用手机端模板。织梦内部使用$GLOBALS注册超全局变量,所以isset($mobile)能直接读到表单参数。 ## 在手机模板的搜索表单里加隐藏 input 打开/templets/default_m/head.htm(或者你的手机模板对应的搜索框所在文件),找到这一段,在之前加上: 这样手机端任何一次搜索请求,都会带上mobile=1,PHP 端就能识别。如果你的手机模板把搜索框放在多个地方(比如顶部、侧边栏、footer 都有搜索框),每个 form 都要加这一行隐藏字段。 ## 清空缓存并测试 保哥当年最常踩的坑就是改完代码忘了清缓存。完整测试流程: 进入后台 → 系统 → 系统基本参数 → 更新缓存。这一步清掉织梦自身的模板缓存和数据缓存。 用浏览器隐私模式访问手机站,确认Set-Cookie没有把旧的 PC 跳转记下来。 用 Chrome DevTools 的 Device Mode 切到 iPhone 12 Pro 或者其他手机型号,提交搜索关键词,看返回的 HTML 头部是否引用了default_m的 CSS 而不是default的 CSS。 如果你用了 CDN,记得去 CDN 后台手动刷新/plus/search.php这个 URL 的缓存。CDN 通常会缓存动态页面的输出,不刷新的话用户仍然看到旧版本。 ## 修复方案二:复制一份手机专用搜索文件 如果第一种方案因为模板权限或者甲方不允许改/plus/search.php,可以走方案二,思路是“复制隔离”。这种方案的优点是改动只限于手机端,PC 端的逻辑文件一行都不动;缺点是以后官方升级(虽然织梦官方已经停更)、或者你想给搜索加防刷限流时,要同时维护两份代码。 具体步骤: # 在服务器上备份并复制 cp /plus/search.php /plus/wapsearch.php cp /include/arc.searchview.class.php /include/arc.wapsearchview.class.php 然后改三个地方: 第一,/plus/wapsearch.php顶部把require_once的目标从arc.searchview.class.php改成arc.wapsearchview.class.php。 第二,在wapsearch.php里强制define('DEDEMOB', 'Y');这行放在require_once之前,确保 include 时已经定义。 第三,/include/arc.wapsearchview.class.php中把默认模板路径硬写成search_m.htm。直接修改文件里的$this->Templet =那一行,强制返回手机端模板。 手机端模板的搜索表单 action 改成/plus/wapsearch.php,PC 端继续用/plus/search.php,两条路径完全隔离。这种方案下即使第二种方案的某个文件被改坏,PC 端的搜索也不受影响。保哥更倾向于方案一,除非你接手的是一个完全不允许碰核心文件的项目。 ## 配套要做的三件事:跳转、安全、监测 仅仅修好搜索页的模板加载还不够。当年保哥交付一个客户站时,专门列了三项配套清单。 ## PC 与手机的双向跳转 在.htaccess里加 UA 判断,让 PC 用户访问/m/子目录时反向回到 PC 站,反之亦然。否则你的搜索改完,但首页跳转还是错的,整体体验依旧别扭。 RewriteEngine On RewriteCond %{HTTP_USER_AGENT} (iPhone|Android|Mobile) [NC] RewriteCond %{REQUEST_URI} !^/m/ RewriteRule ^(.*)$ /m/$1 [L,R=302] # 反向:PC UA访问/m/ 跳回主站 RewriteCond %{HTTP_USER_AGENT} !(iPhone|Android|Mobile) [NC] RewriteCond %{REQUEST_URI} ^/m/ RewriteRule ^/m/(.*)$ /$1 [L,R=302] 注意状态码用 302(临时跳转)而不是 301(永久跳转),因为有些用户会切换 UA 模式(比如 iPad 横屏切换桌面版),302 能让搜索引擎和浏览器记住跳转关系但不缓存得太死。 ## 防止 search.php 被刷 搜索接口最容易被薅,保哥处理过的站点里有一个三天里被刷了 80 万次/plus/search.php?keyword=xxx,把数据库 CPU 打到 100%。在改完模板逻辑后,建议同时加一段速率限制: // /plus/search.php 顶部 $ip = $_SERVER['REMOTE_ADDR']; $cacheKey = 'search_rate_' . md5($ip); if (file_exists(DEDEDATA . '/' . $cacheKey)) { $last = (int)file_get_contents(DEDEDATA . '/' . $cacheKey); if (time() - $last < 2) { die('请求过于频繁'); } } file_put_contents(DEDEDATA . '/' . $cacheKey, time()); 两秒一次的频率,基本能挡住绝大多数自动化脚本,又不影响真实用户。如果你想做更精细的限流,可以基于 IP 加关键词哈希做粒度更细的统计,或者直接上 Redis 做秒级计数器。 ## 用日志验证修复是否真正生效 保哥习惯在/include/arc.searchview.class.php顶部临时加一行日志,跑两天后再撤掉: file_put_contents( DEDEDATA . '/search_debug.log', date('Y-m-d H:i:s') . ' UA=' . $_SERVER['HTTP_USER_AGENT'] . ' DEDEMOB=' . (defined('DEDEMOB') ? 'Y' : 'N') . PHP_EOL, FILE_APPEND ); 两天后打开search_debug.log,看手机端 UA 的请求是不是 100% 都标了DEDEMOB=Y。如果中间出现 N,就说明还有遗漏的入口(比如某个旧主题里的搜索框没加隐藏 input)。验证完成后记得删掉这段日志,避免长期写文件占用磁盘。 ## 迁移建议:织梦已停止维护,搜索修复只是缓兵之计 2018 年织梦官方停止商业授权和安全更新后,保哥就开始劝甲方迁移。手机搜索跳转这个 Bug 即使修好,DedeCMS 还有一长串遗留问题:会员模块的 SQL 注入、tag.php 的 XSS、/plus/recommend.php的 GETSHELL、/include/dialog/select_soft_post.php的任意文件上传,都是公开漏洞。即使你给搜索打了补丁,其他入口仍然是攻击者的肉。 如果你正在做内容站迁移,可以参考保哥之前整理的几条路径。 内容量小(少于 1000 篇):直接迁到 Typecho,写个简单的 Python 脚本读dede_archives表转成 Markdown 即可。Typecho 的数据库结构干净、社区主题多、维护活跃,是织梦小站的最佳出路。整个迁移过程通常 2 到 4 小时。 内容量中(1000 到 10000 篇):迁到 WordPress,社区有比较成熟的 dede2wp 插件。WordPress 的优势是生态完整、插件丰富、商业化路径清晰。迁移周期 1 到 3 天。 内容量大或带商城:迁到 Drupal 或者直接做 Headless(Strapi 加 Next.js)。这种规模的站点已经超出小型 CMS 的承载能力,建议做现代化重构。预算允许的话,可以考虑 Shopify (https://zhangwenbao.com/shopify-blog-breadcrumb.html) 加 Headless 前端的方案,运维成本更低。 搜索页修复后,先稳住流量,再用 6 到 12 个月做平稳迁移,是更现实的路线。 ## 修复完成后的性能与安全加固 搜索页跳转修好之后,保哥强烈建议顺手把这个模块的性能和安全也加固一遍。织梦的/plus/search.php历史上曝过几个高危漏洞,且默认实现性能很差,正好借这次改动一起处理。 ## SQL 查询缓存 织梦搜索的核心 SQL 是SELECT * FROM dede_archives WHERE keywords LIKE '%关键词%'这种全表 LIKE 查询,没有任何索引能用,每次搜索都走全表扫描。文章数过万的站点单次搜索可能耗时 2 到 5 秒,被刷的时候很容易把 MySQL 拖死。 简单的缓存方案是给搜索结果加 30 秒到 5 分钟的文件缓存: $cacheKey = 'search_' . md5($keyword . '_' . $page); $cachePath = DEDEDATA . '/cache/' . $cacheKey . '.html'; if (file_exists($cachePath) && time() - filemtime($cachePath) < 300) { readfile($cachePath); exit; } // 正常搜索逻辑... ob_start(); // 输出搜索结果 $content = ob_get_clean(); file_put_contents($cachePath, $content); echo $content; 这一段加完之后相同关键词的二次搜索响应时间从 2 秒降到 10 毫秒以内,性能提升约 200 倍。 ## 关键词过滤与防注入 织梦旧版本的搜索参数过滤不严格,存在 SQL 注入风险。建议在/plus/search.php顶部加一层手动过滤: $keyword = isset($_REQUEST['keyword']) ? trim($_REQUEST['keyword']) : ''; $keyword = htmlspecialchars($keyword, ENT_QUOTES, 'UTF-8'); $keyword = preg_replace('/[\'"\\\\;\(\)]/', '', $keyword); if (mb_strlen($keyword, 'UTF-8') > 50) { die('搜索词过长'); } if (preg_match('/(union|select|insert|update|delete|drop|exec)/i', $keyword)) { die('非法搜索词'); } 这段代码做了 4 件事:去除前后空格、HTML 实体编码、过滤特殊字符、长度限制、SQL 关键字黑名单。基本能挡住 99% 的注入尝试。 ## 禁止搜索结果页被收录 在search_m.htm和search.htm的里加: 这样搜索引擎不会收录搜索结果页(避免大量低质量页面被收录拖累整站权重),但仍然会跟踪页面里的外链 (https://zhangwenbao.com/google-seo-link-building-strategies.html)。同时在 robots.txt (https://zhangwenbao.com/wordpress-add-robots-txt-files-and-optimize-website-collection.html) 里加Disallow: /plus/search.php双重保险。 ## 常见的报错与排查思路 保哥处理过的实际案例里,按修复后仍然不工作的频次列一下常见报错和对应的排查方法。 报错“未找到关键字”但参数确实提交了:检查搜索表单的 method 是不是 get,织梦默认走 GET。如果 form 没指定 method,部分浏览器(特别是早期 Android 的 WebView)会丢参数。统一改为method="get"显式声明。 搜索结果加载样式错乱:检查search_m.htm里引用 CSS 的路径是不是相对路径。如果用了../这种相对路径,在某些 URL 重写下会失效。建议改成绝对路径或者用{dede:global.cfg_templeturl/}这种织梦标签。 搜索结果中文显示乱码:织梦 5.7 UTF-8 版的 PHP 文件必须保存为 UTF-8 无 BOM 格式。用记事本编辑会带 BOM 头导致页面顶部多一个空行,且部分浏览器解析时把中文识别为 GBK。用 VS Code 或 Notepad++ 重新保存为 UTF-8 无 BOM 即可。 修复后过几天又失效:检查是不是 CDN 缓存 PC 版结果。让 CDN 把/plus/search.php这个路径加入“不缓存”规则,或者在响应头里加Cache-Control: no-cache。 ## 常见问题解答 ## 我修改完 /plus/search.php 后,手机搜索还是跳到 PC,可能是什么原因 按出现频率从高到低排查:第一,模板的搜索 form 里没加;第二,后台缓存没清;第三,CDN 把search.php的 HTML 缓存了,去 CDN 后台手动刷新这个 URL;第四,你的手机模板用的是 GET 提交但 form 没写 method,少数浏览器会丢参数;第五,多入口搜索框的某些 form 还在用旧版本,需要逐个检查。 ## 能不能不通过隐藏字段,而是直接用 PHP 检测 UA 来判断 可以。把/plus/search.php里那段判断改成if (preg_match('/iPhone|Android|Mobile/i', $_SERVER['HTTP_USER_AGENT'])) define('DEDEMOB','Y');即可。但保哥不推荐这种做法,因为 UA 误判率比较高,平板、爬虫、PC 浏览器开 DevTools 模拟移动端时,都会被错误识别。隐藏字段的方式更稳,也更显式。 ## 改完后 SEO 会不会受影响 不会。手机搜索结果页本来就不应该被搜索引擎收录(带?keyword=的页面通常 robots 已 disallow)。如果你担心,就在里加,让搜索引擎不收录但仍然跟踪外链。Google 和百度都会尊重这个标签。 ## 织梦官方有没有正式补丁 截至 2026 年,织梦官方早已停止常规更新,这个手机搜索跳转的 Bug 在 5.7 SP2 UTF-8 版本里依然存在,没有官方补丁。你看到的所有“DedeCMS 移动端搜索修复”都是社区自发整理,包括本文的两种方案。建议把修复方案文档化保存到内部 Wiki,避免下次接手的人重复踩坑。 ## 用方案一会被官方升级覆盖吗 不会,因为织梦官方已经停更。但如果你出于安全考虑给织梦打过第三方补丁包,部分补丁可能会覆盖/plus/search.php。打完补丁后建议立即检查这个文件,重新加上修复代码。可以把修复代码做成 patch 文件保存好,每次补丁后快速重新打入。 ## 这个修复方案对其他 PHP 内容管理系统适用吗 核心思路(通过隐藏字段传递设备类型)适用于任何 CMS,但具体实现要根据系统的搜索入口文件位置和模板加载逻辑调整。比如 PHPCMS、帝国 CMS、ECMS、PBootCMS 都有类似的搜索模块,需要找到各自的搜索入口文件和模板判断逻辑后做相应修改。基本步骤都是“表单传参 → 入口接收 → 模板判断”三段式。 ## 修复后老页面的搜索链接还在用旧 URL 怎么办 分两种情况。第一种,老链接来自用户书签或者外站友链,这种没法主动改,但用户访问时会走新的入口文件,仍然能正确加载。第二种,老链接来自你网站内的硬编码(比如某个静态页面引用了 PC 端搜索 URL),需要在网站搜索一遍替换为新的入口。织梦的全文搜索功能可以快速找到所有 .htm 和 .php 文件中的旧引用。 ## 有没有更现代的替代方案而不是改这些老代码 有。最现代的方案是把搜索功能完全独立出去,用前端 JS 调用 Algolia、TypeSense、ElasticSearch 等专业搜索服务。这种方案的好处是搜索体验更好(毫秒级响应)、可以做拼写纠错和模糊匹配、不占用源站资源。Algolia 每月有 1 万次免费搜索额度,对中小织梦站完全够用。但实施成本比改 PHP 代码高,需要前端开发和搜索引擎运维能力。 ## 权威参考资料