# 保哥笔记 — 织梦CMS教程 > 本分片含 13 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:织梦CMS教程 **生成**:2026-06-11 18:49:05 CST --- ## 织梦置顶想加“置顶一天”?改sortrank字段就能自动到期 - URL:https://zhangwenbao.com/method-to-dedecms-article-set-a-dream-day.html - 分类:织梦CMS教程 - 发布:2017-02-24 | 更新:2026-06-01 - 摘要:为什么织梦置顶机制只用一个sortrank字段就能实现自动到期?为什么批量操作仍然漏掉新选项?读完你会知道5.7sp1和dedebiz分支的具体修改位置、grep批量定位法、binary模式上传的换行符陷阱、以及织梦vs WordPress vs Typecho置顶设计的对比。配8个真实FAQ。 - 关键词:织梦置顶,DedeCMS,DedeCMS二开 > **TLDR**:摘要:织梦的置顶机制只靠一个sortrank字段就能做到自动到期,但批量操作常漏掉新加的选项。本文讲清底层逻辑,定位article_add与article_edit的下拉源,给出备份、修改、回写、验证的改造步骤,再讲自定义模型与批量编辑的兼容、改完必刷的几处缓存、与sortrank排序冲突的常见情况,以及和WordPress与Typecho置顶设计的对比。 > 摘要:织梦的置顶机制只靠一个sortrank字段就能做到自动到期,但批量操作常漏掉新加的选项。本文讲清底层逻辑,定位article_add与article_edit的下拉源,给出备份、修改、回写、验证的改造步骤,再讲自定义模型与批量编辑的兼容、改完必刷的几处缓存、与sortrank排序冲突的常见情况,以及和WordPress与Typecho置顶设计的对比。 2014 年到 2018 年我用织梦做过 6 个新闻类站点,最常被编辑投诉的就是置顶机制。织梦自带的 sortup 下拉只给"置顶一周/一月/三个月/半年/一年"五档,最短的"一周"对快讯节奏来说仍然太长——编辑想让一条快讯在首页顶上一天就够,第二天自动落回时间序,但织梦默认做不到。这篇笔记把我当年改造 sortup 字段的全过程整理成一份可直接复用的方案,包括模板修改、字段含义、批量编辑、自定义内容模型兼容、置顶到期自动化处理,以及一个被很多教程忽略的细节——批量操作 ajax 白名单。 所有代码我都在织梦 5.7sp1(最后一个官方版本)和 dedebiz 0.8.x 分支上验证过。dedeCMS 官方早在 2022 年宣布停更并由社区分叉,但生产环境上仍有大量站点在跑 5.7sp1,本文方案对这两个分支都适用。 ## 织梦置顶机制的底层逻辑 要改置顶时长,先得搞清楚织梦怎么实现置顶。打开 dede_archives 表,会看到一个 sortrank 字段——置顶逻辑就藏在它身上。织梦把 sortrank 默认设为发布时间的 Unix 时间戳,列表页 SQL 是按 ORDER BY sortrank DESC 排序的,所以越新的文章 sortrank 越大、排在越前面。 当编辑选择"置顶一周"时,织梦实际做的事是:把 sortrank 改成"当前时间戳 + 7 × 86400",相当于这条文章的"虚拟发布时间"被推到一周后。一周内任何新发布的文章都没办法用真实时间戳超过它,自然就被顶在首页。等真实时间走过这个未来时间点之后,新文章重新追上,被置顶的内容自动落回时间序。 这个机制聪明的地方在于: - 不需要专门的定时任务 - 不需要额外字段 - 不需要后台轮询 - 纯靠时间戳算术就实现了"到期自动取消置顶"的效果 理解了这一点,加"置顶一天"就只是在选项列表里多塞一行 value="1",没有任何数据库结构改动,回滚极其简单。这种"用最小改动得到最大复用"的设计哲学是织梦早期开发组的特色,可惜后期产品停滞才让人忽略了它的工程美感。 ## 定位文件:article_add 与 article_edit 的下拉源 织梦后台所有发布、编辑表单都在 dede/templets 目录下。文章对应的两个核心模板是 article_add.htm(新建)和 article_edit.htm(编辑)。两份模板里都有一段 sortup 下拉框: 这里的 value 值就是"置顶天数",提交表单后织梦会乘以 86400 加到当前时间戳上,写回 sortrank 字段。我们要做的就是在这段下拉里增加 value="1" 的选项。 ## 放在哪个位置最合理 建议把新选项放在"正常排序"和"置顶一周"之间,从短到长排列符合用户阅读习惯,编辑选起来更顺手: 顺手把"置顶三天"也加上——快讯类内容三天比一周更合适,是被遗漏的中间档。两份模板都要改,新建页和编辑页要保持一致,否则会出现"新建时能选一天、编辑时却没有这个选项"的诡异情况,编辑改完原来一天的文章会变成默认排序。 ## 改造步骤:备份、修改、回写、验证 改造流程一共四步,每步都不能跳。 ## 备份 SSH 登录服务器把 dede/templets 整个目录打包一份,文件命名带日期,万一改坏了至少能秒回滚: cd /www/wwwroot/yoursite tar -czf templets-backup-20260507.tar.gz dede/templets/ 顺便把当前文件 hash 值记录一下,回滚时可以快速比对: md5sum dede/templets/article_add.htm dede/templets/article_edit.htm > templets-md5-20260507.txt ## 修改 用 vim 或者通过宝塔面板 (https://zhangwenbao.com/nginx-dedecms-php-deny-all.html)编辑器打开 dede/templets/article_add.htm,搜索 sortup 定位到下拉框,按上一节给出的内容插入新行。然后打开 dede/templets/article_edit.htm 做同样修改。 织梦 5.7sp1 里这两段代码大致在 article_edit.htm 第 423 行附近、article_add.htm 第 502 行附近,但具体行号会随主题模板和打补丁顺序略有偏移,搜索关键词比死记行号靠谱。 ## 回写 如果是用本地 IDE 改的,必须用二进制方式上传 htm 文件,避免编辑器把换行符从 LF 改成 CRLF 后织梦解析异常。Linux 服务器上推荐 LF,Windows 自带记事本会偷偷把它改成 CRLF——这是常年被坑的雷。VSCode/PHPStorm 都能在状态栏直接切换 LF/CRLF。 FTP 上传时主动选 binary 模式,自动模式下 .htm 后缀容易被识别为文本而触发自动换行符转换。 ## 验证 登录织梦后台进入"核心 → 内容发布",新建一篇测试文章,检查 sortup 下拉里是否多出了"置顶一天"选项,选中并保存。然后去文章管理列表里编辑这篇文章,再次确认下拉里也有"置顶一天",并且当前选中状态正确。 用 SQL 直接查 sortrank 字段确认置顶逻辑生效: SELECT id, title, sortrank, FROM_UNIXTIME(sortrank) AS sort_time FROM dede_archives WHERE id = 你的测试文章ID; 查询结果里 sort_time 应该比当前时间晚一天左右,这说明置顶一天已经写入。如果显示的是当前时间附近,那是表单提交逻辑没拿到新 value,需要回查 article_add.php 的处理代码。 ## 二次开发兼容:自定义模型与批量编辑 ## 自定义内容模型 如果你的站点有自定义内容模型——比如 spec_add.htm/spec_edit.htm 对应专题,或者 archives_add.htm 对应自由文档——每一个独立内容模型都有自己的 sortup 下拉,需要单独修改。织梦的模板复用程度不高,一处改不能全站生效,这是它的老毛病。 建议写一个简单的 shell 命令扫一遍 dede/templets 目录里所有包含 sortup 关键字的文件: grep -l "sortup" dede/templets/*.htm 输出的文件列表就是需要修改的全部位置。改完之后再 grep 一次,确认所有文件都已经包含 value="1" 这一行: grep -c 'value="1"' dede/templets/*.htm | grep -v ':0$' 这条命令会列出含 value="1" 的文件以及出现次数,方便核查覆盖完整度。 ## 批量编辑的 ajax 白名单 这个细节很多教程都漏掉了——织梦后台列表页的"更多操作"按钮里有批量置顶功能,但它的天数白名单是硬编码的。要让批量操作也支持"置顶一天",需要修改 dede/content_list.php 或对应的 ajax 处理文件 dede/ajax_album.php/dede/content_list_ajax.php(不同补丁版本文件名不同)。 定位办法:在 dede 目录下 grep "sortup": grep -rn "sortup" dede/ | grep -v templets 找到对应的 PHP 文件,把允许的天数白名单从: $allowedDays = array(7, 30, 90, 180, 360); 扩展为: $allowedDays = array(1, 3, 7, 30, 90, 180, 360); 这步对一般站点不是必须,因为绝大多数编辑还是单篇操作。如果你的运营团队真的有批量短置顶的需求(比如发完一组热点快讯统一置顶一天),再考虑加上。 ## 自动化与运营层延伸 改完模板后通常还要做几件事让这个功能在运营层更顺手。 ## 视觉提示:今日置顶徽章 在文章列表模板里判断 sortrank 是否在"未来一天内",是的话在标题前加一个红色徽章,让运营和编辑一眼看清哪些是短期置顶: time() && $fields['sortrank'] - time() <= 86400) : ?> 今日置顶 样式: .badge-today-top { display: inline-block; padding: 2px 6px; background: #f56c6c; color: #fff; font-size: 11px; border-radius: 3px; margin-right: 6px; } ## 定时巡检:超量置顶报警 虽然织梦置顶能自动到期回落,但编辑有时候会手滑把多条快讯都置顶,首页瞬间被顶满,反而稀释流量分配。写一个 cron 任务每小时跑一次,把超过五条同时置顶的情况发到企业微信或钉钉报警,让值班编辑及时清理: getOne("SELECT COUNT(*) AS c FROM dede_archives WHERE sortrank > {$now}"); if ($row['c'] > 5) { $msg = "首页置顶数已达 {$row['c']} 条,请检查是否有误顶"; // 推送到企业微信 webhook file_get_contents("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY&msg=" . urlencode($msg)); } crontab 配置: 0 * * * * /usr/bin/php /www/wwwroot/yoursite/scripts/check_top_count.php ## 埋统计:跟踪置顶位的引流效果 给短期置顶的文章 URL 加一个 utm 参数,专门跟踪从首页置顶位过来的点击量。在首页模板里判断置顶状态,加 utm: time()) { $utm = '?utm_source=site&utm_medium=hometop&utm_campaign=' . date('Y-m-d', $fields['sortrank']); } ?> 跑一周数据就能知道"置顶一天"对快讯是否真的有引流效果。我自己运营过的一个站点跑出来的数据:置顶位文章 24 小时内引流是非置顶位的 3.4 倍,但 7 天累计反而是非置顶位高 1.8 倍——说明短期置顶在快讯上有效,长期置顶反而压制其他内容总量,这个反直觉的结论是改造完才看到的。 ## 性能与缓存:改完之后必须刷的几处 织梦的列表页一般都是生成静态 HTML 的,改完模板和置顶后,必须刷新对应静态页才能让前台用户看到新顺序: - 首页静态:后台"生成 → 更新主页 HTML" - 栏目页静态:后台"生成 → 更新栏目 HTML",要勾选"重新生成所有栏目" - 列表页静态:栏目页里点"更新列表" - CDN 缓存:如果挂了 Cloudflare (https://zhangwenbao.com/cloudflare-markdown-for-agents-ai-seo-geo.html)/七牛等 CDN,触发一次 purge cache - 内存缓存:用了 Memcached 或 Redis 缓存的话,调用 data/cache 目录清理脚本 大型站点的全站重新生成可能耗时几分钟到几十分钟,建议在低峰时段操作。如果只想刷新当天置顶的那一篇,单篇内容页生成即可,不用全站重生成。 ## 跟其他 CMS 的实现对比 顺便对比一下其他 CMS 的置顶机制,理解不同设计取舍: - WordPress:用 sticky_posts 选项,存的是 cid 数组,不带过期时间。默认插件不支持"自动取消置顶",要装 Sticky Post Expiration 这类插件。 - Drupal:node 表有 sticky 字段(0/1),同样无过期时间,靠手动取消。 - Typecho:核心不支持置顶,要装 Sticky 插件,存在 typecho_fields 自定义字段里。 - EmpireCMS(帝国):用 isgood 字段控制推荐属性,分级别的"推荐到首页",没有时间窗概念。 - 织梦的方案:用 sortrank 一个字段同时承担"排序"和"置顶到期"两个职责,最优雅但仅在中文 CMS 里独此一家。 从工程角度看,织梦的"sortrank 时间戳算术"是最优解:零额外字段、零定时任务、自动到期。现代 CMS 应该多借鉴这种思路,而不是堆栈各种插件来弥补核心功能。 ## 跟 sortrank 排序冲突的常见情况 实战中有几类容易混淆的"看起来没生效"情况: ## 情况一:列表页 SQL 没按 sortrank 排序 有些自定义模板(特别是从其他来源复制过来的)列表标签写成了 orderby='id desc' 而不是 orderby='sort',这种情况下 sortrank 改了也没用。织梦默认的列表标签是 orderby='sort',对应的就是 sortrank。检查方法:找到当前列表模板(一般是 templets/default/list_article.htm 等),grep "orderby",确认是 sort 而非 id/pubdate。 ## 情况二:用了置顶但列表里仍按时间倒序 这是 SQL 没用 sortrank 但用了 pubdate 的典型表现。织梦在 5.7sp1 之前有个补丁修复过这个问题,如果你的站点版本特别老(5.7 未打 sp1)建议先升级。 ## 情况三:批量置顶后只生效部分文章 批量操作 ajax 接口的天数白名单没改全,导致 value="1" 提交过去被服务端拒绝(默默改回 0)。检查方法见上一节"批量编辑的 ajax 白名单"。 ## 情况四:置顶时间精度问题 sortrank 是秒级时间戳,但织梦后台批量置顶时是按"天"算的——也就是从当前时间起整 N 天后。这意味着如果你下午 3 点置顶 1 天,第二天下午 3 点才到期,不是当天 24 点到期。需要"当日置顶"语义的话,要单独写逻辑把 sortrank 设为"今晚 23:59:59 的时间戳"而不是"现在 + 86400"。 ## 什么时候应该放弃改织梦换 CMS 说点逆耳的话。织梦虽然好用、机制聪明,但官方 2022 年停更后已经没有安全更新。如果你的站点有以下任一情况,建议直接换其他 CMS 而不是继续二开织梦: - 有金融/医疗/政务类敏感数据:织梦历史漏洞多(栏目跨站、SQL 注入、任意文件上传),停更后新发现的漏洞没人修 - 需要 HTTPS 严格合规:织梦默认输出大量 HTTP 资源,全站迁 HTTPS 工作量极大 - 有移动端/小程序对接:织梦没原生 API,每接一处都要写适配 - 需要长期维护超过 3 年:长期来看托管成本超过迁移成本 替代方案:内容站换 Typecho 或 WordPress;电商类换 Shopify (https://zhangwenbao.com/shopify-collection-pagination-seo-guide.html) 或 WooCommerce;门户类换 Strapi 或 Directus(headless)。迁移成本一次性高,但长期回报远大于继续给织梦打补丁。 ## 常见问题解答 ## 改完模板后下拉里没有"置顶一天",是不是织梦缓存问题? 有可能。织梦后台的模板修改一般是即时生效的,但如果你启用了 Opcache 或 PHP 加速器,旧模板可能还被缓存。重启 PHP-FPM 或在面板里点一次清空 Opcache,刷新后台页面再看。如果还没出现,回头检查是不是改错了文件,比如把 article_add.htm 改成了 article_add.bak.htm 这种带后缀的备份。还有一种可能是浏览器缓存了 htm 表单,强制刷新(Ctrl+Shift+R)即可。 ## 选了"置顶一天"但第二天没自动取消,怎么办? 先用 SQL 查这条文章的 sortrank 字段,把数值用 FROM_UNIXTIME 换成可读时间,看是不是真的写成了未来一天。如果时间正确但首页依然置顶,多半是首页静态缓存还没刷新,去后台"生成 → 更新主页 HTML"跑一遍即可。如果连 sortrank 都不对,那是表单提交逻辑问题,检查 dede/article_add.php 里有没有改过 sortup 的处理代码。 ## 这个改动会影响以前已经置顶的文章吗? 不会。我们只增加了下拉选项,没有动数据库里任何已有数据,已经置顶一周、一个月的文章 sortrank 字段保持不变,到期照样自动回落。回滚也很简单,把模板里那行 option 删掉、或者直接还原备份目录就行。整个改造是无状态的,对生产数据零影响。 ## 5.7sp1 之后的版本(dedebiz 分支等)这个方法还能用吗? 实测在 dedebiz 上同样适用。dedebiz 是社区维护的分支,sortrank 机制没有改动,只是部分模板路径有调整,搜索 sortup 关键字定位即可。如果你用的是更激进的二次开发版本,比如有人把 sortup 改成了独立的 expire 字段,那需要单独看那个版本的实现,本文方案不能直接套。dedeV6 等社区分支还在用 sortrank,所以方案通用。 ## 批量置顶时新选项不生效,是不是只改前端就行? 不行。批量操作走 ajax,服务端有天数白名单硬编码。要让批量操作也支持新天数,必须同时改 dede/content_list.php 或对应的 ajax 处理文件里的 allowedDays 数组。grep -rn sortup dede/ 找到对应 PHP 文件,把白名单从 array(7,30,90,180,360) 扩展为 array(1,3,7,30,90,180,360)。这一步漏掉是教程里最常见的坑。 ## 置顶一天能不能改成"今晚到期"而不是"24 小时后到期"? 可以但要单独写逻辑。织梦默认 sortup 提交的是天数乘以 86400 加当前时间戳,所以下午置顶就是第二天下午到期。要做"今晚到期"语义,需要在 article_add.php 处理 sortup 时判断 value 是不是特殊标记(比如 endofday),如果是就把 sortrank 设为 mktime(23,59,59) 而不是 time()+86400。这种改动需要懂织梦表单处理逻辑,属于二次开发不是简单模板改。 ## 改完之后想让"置顶一天"在前端有特殊样式怎么办? 在文章列表模板里加判断:sortrank 大于当前时间戳且差值小于等于 86400 时,渲染一个红色徽章。HTML 用 span class,CSS 给个 background 颜色和 padding。判断条件用 PHP 写:if (\$fields['sortrank'] > time() && \$fields['sortrank'] - time() <= 86400)。这样即使编辑选了置顶一天但忘了刷新静态页,前端用户也能看到醒目的标识。 ## 织梦官方停更了,还要不要继续在它上面做二次开发? 分情况。如果是稳定运行多年、没有敏感数据、流量稳定的站点,继续维护是划算的,毕竟迁移成本巨大;但如果是新建站点,2026 年了不建议再选织梦——官方停更后安全漏洞没人修,每年新增 CVE 都要自己打补丁。新站建议 WordPress(生态最完善)或 Typecho(轻量优雅),电商类直接 WooCommerce 或 Shopify,门户类考虑 headless CMS(Strapi/Directus)。 ## 织梦DedeCMS支付宝付款后自动发邮件给站长:alipay.php改造与生产级队列重写 - URL:https://zhangwenbao.com/dedecms-users-use-alipay-after-payment-reminder-emails-to-the-webmaster-method.html - 分类:织梦CMS教程 - 发布:2017-02-23 | 更新:2026-06-02 - 摘要:DedeCMS支付宝插件验签成功后默认不发任何通知,站长只能刷后台才知道来了订单。本文从两条回调路径的差异讲起,给出最小代码改造、SMTP端口取舍、同步发信阻塞回调超时的坑,再升级到先入队列后异步发的生产级写法和反垃圾邮件配置。 - 关键词:织梦邮件,DedeCMS,支付宝,SMTP,PHP邮件 > **TLDR**:摘要:DedeCMS的支付宝插件验签成功后默认不发任何通知,站长只能靠刷后台才知道来了订单。本文从alipay.php里两条回调路径的差异讲起,给最小inline发邮件改造,再点出同步发信会阻塞回调这个隐藏陷阱,升级到Redis队列异步发的生产级写法,并讲邮件进垃圾箱的处理、发到企业微信和钉钉的替代渠道、发不出去时的排查,以及通知邮件里不能放什么。 > 摘要:DedeCMS的支付宝插件验签成功后默认不发任何通知,站长只能靠刷后台才知道来了订单。本文从alipay.php里两条回调路径的差异讲起,给最小inline发邮件改造,再点出同步发信会阻塞回调这个隐藏陷阱,升级到Redis队列异步发的生产级写法,并讲邮件进垃圾箱的处理、发到企业微信和钉钉的替代渠道、发不出去时的排查,以及通知邮件里不能放什么。 织梦 DedeCMS 商城用户在前台用支付宝完成付款后,订单状态会更新成"已支付",但站长后台不会主动弹通知,邮箱也不会收到提醒。运营节奏快的小商城,错过订单意味着错过发货时机;而 DedeCMS 自带的支付宝插件 include/payment/alipay.php 里只把验签结果写入了一个本地 .txt 日志文件——这点功能在生产环境完全不够用。 这篇笔记把"付款成功 → 邮件通知"这条看似很小的功能写透:从 alipay.php 回调函数的执行链路开始,讲最小改动的代码版本,到 SMTP 服务商的端口与 TLS 选择、邮件被丢进垃圾箱的反垃圾配置、同步发邮件阻塞回调的真实陷阱,再到生产级的"队列异步发"重写、企业微信/钉钉/飞书 webhook 替代渠道。最后会聊 2026 年还在用 DedeCMS 的实情:核心团队 2019 年就停止维护了,社区分叉版能用,但要怎么把这个改造做得"既能跑又有退路"。 ## 回调链路拆解:alipay.php 里的两条执行路径 DedeCMS 支付宝插件的核心文件是 include/payment/alipay.php。看完源码就会发现这个文件里其实有两条平行的回调入口,分别对应两种支付宝通知机制: - verifyReturn():处理支付宝同步返回(return_url)。用户付款完成后,浏览器会从支付宝重定向回这个 URL,参数带在 URL 上。这条路径的特点是只在用户实际跳转回来时才触发——如果用户付完款关掉浏览器、断网、误操作,这条路径就不会执行。 - verifyNotify():处理支付宝异步通知(notify_url)。支付宝服务器会主动 POST 到这个 URL,无论用户在前端做什么。这条路径是真正的"付款成功唯一可信信号源"——会重试 8 次(间隔 4m / 10m / 10m / 1h / 2h / 6h / 15h),所以可靠性远高于 return_url。 很多老教程把改造点放在 verifyReturn() 后面,结果用户没跳回来就漏发邮件。正确的做法是改 verifyNotify()——只要支付宝侧确认收到付款,无论用户在前端怎么折腾,邮件都能发出去。 ## 在 alipay.php 里精确定位插入点 不同 DedeCMS 版本,alipay.php 文件结构略有不同。我整理了三个主要版本的差异: 版本 | 插入位置(搜索锚点字符串) | 典型行号 | DedeCMS V5.6 | $this->log_result("verify_success | ≈ 158 | DedeCMS V5.7 | $this->log_result("verify_success | ≈ 175 | V5.7 SP1 / SP2 / 社区分叉版 | $this->log_result("verify_success | ≈ 178-205(不同分叉版略有调整) | 所有版本都用同一个锚点字符串 verify_success。打开文件搜索这串字符,紧接着的下一行就是付款成功且验签通过的执行点——把邮件发送代码加在这里最稳。 ## 最小改动方案:直接在 alipay.php 里 inline 发邮件 这是改动最小、起步最快的写法。打开 include/payment/alipay.php,找到锚点行: $this->log_result("verify_success,订单号:".$order_sn); //将验证结果存入文件 在它后面添加: // === 付款成功后给站长发邮件 === $cfg_sendmail_bysmtp = 'Y'; $cfg_smtp_server = 'smtp.qq.com'; // SMTP 服务器 $cfg_smtp_port = 465; // 端口:465 = SSL,25 = 明文,587 = STARTTLS $cfg_smtp_usermail = 'admin@yoursite.com'; // 发件邮箱 $cfg_smtp_user = 'admin@yoursite.com'; // 登录用户名 $cfg_smtp_password = '【你的 SMTP 密码或授权码】'; $cfg_webname = '保哥商城'; $to_email = 'webmaster@yoursite.com'; // 接收人邮箱 $mailtitle = "[新订单] " . $order_sn . " 支付成功 ¥" . $payment_fee; $mailbody = "订单号: {$order_sn}\n"; $mailbody .= "金额: ¥{$payment_fee}\n"; $mailbody .= "时间: " . date('Y-m-d H:i:s') . "\n"; $mailbody .= "管理后台: https://yoursite.com/dede/order_view.php?oid={$order_sn}\n"; if ($cfg_sendmail_bysmtp == 'Y' && !empty($cfg_smtp_server)) { require_once(dirname(__FILE__) . "/../mail.class.php"); $smtp = new smtp($cfg_smtp_server, $cfg_smtp_port, true, $cfg_smtp_user, $cfg_smtp_password); $smtp->debug = false; $smtp->sendmail($to_email, $cfg_webname, $cfg_smtp_usermail, $mailtitle, $mailbody, 'TXT'); } else { $headers = "From: " . $cfg_smtp_usermail . "\r\nReply-To: " . $cfg_smtp_usermail; @mail($to_email, $mailtitle, $mailbody, $headers); } // === 邮件发送结束 === 这段代码相比网上常见的版本有几处差异,都是踩过坑后才加上的: - 端口默认用 465(SSL) 而不是 25——QQ 邮箱、163 邮箱在 2018 年后陆续封禁了 25 端口的明文连接,云服务商也大量限制 25 出站,465 是更稳的选择。 - 邮件标题里加了 金额 + "新订单" 字样,方便手机端 push 一眼看到。 - 正文里附管理后台直链,点击直接跳订单详情,节省"打开后台 → 找订单 → 看详情"三步。 - 'TXT' 邮件类型而不是 'HTML'——付款通知邮件最好走纯文本,进站率高得多(HTML 邮件容易被反垃圾算法拦)。 ## SMTP 服务商配置参数表(实测 2026 年仍可用) 邮箱 | SMTP 服务器 | 端口 | 加密 | 密码字段 | QQ 邮箱(个人 / 企业) | smtp.qq.com | 465 | SSL | 授权码(不是 QQ 密码) | 163 邮箱 | smtp.163.com | 465 | SSL | 授权码 | 126 邮箱 | smtp.126.com | 465 | SSL | 授权码 | Gmail | smtp.gmail.com | 587 | STARTTLS | App password(启用两步验证后生成) | Outlook / Office 365 | smtp.office365.com | 587 | STARTTLS | 账户密码或 OAuth Token | 阿里云邮件推送 | smtpdm.aliyun.com | 465 | SSL | SMTP 密码 | 腾讯云 SES | smtp.qcloudmail.com | 465 | SSL | 授权 Token | SendGrid | smtp.sendgrid.net | 587 | STARTTLS | API Key | QQ 邮箱踩坑提醒:直接用 QQ 密码登录 SMTP 必失败——必须先到 QQ 邮箱网页设置 → 账户 → 开启 IMAP/SMTP 服务,会拿到一串 16 位授权码,那个才是 SMTP 密码。授权码每次重新生成都不一样,把它写到代码里要做好定期更新计划。 ## 2.2 25 / 465 / 587 端口怎么选 三个端口对应不同的协议变体: - 25:明文 SMTP,最古老。云服务商(阿里云 / 腾讯云 / AWS)默认全部封禁出站 25 以防被滥用发垃圾邮件,所以服务器端用这个端口几乎必败。 - 465:SSL 加密 SMTP(SMTPS),从连接开始就加密。QQ / 163 / 126 默认推荐这个。 - 587:STARTTLS,先建立明文连接再升级为加密。Gmail / Outlook 默认推荐。 如果手上代码用的是 25 但发不出去,先试 465,多数情况是端口被云服务商封了。 ## 第一个隐藏陷阱:同步发邮件会阻塞回调 上面那段最小改动方案有一个生产环境慢慢才显现的问题——它把邮件发送做成了同步阻塞的。支付宝异步通知的回调有一个隐式约束:在收到通知后必须 5 秒内返回 success,否则支付宝会判定本次推送失败,安排重试。 常见的 SMTP 发送耗时实测: - QQ / 163 国内邮箱(同机房网络):500ms – 1.2s - QQ 邮箱(跨网络):1.5s – 4s - Gmail(境外服务器,国内访问):5s – 15s 甚至更高,常常超时 - SendGrid / 阿里云 SES:300ms – 800ms(HTTP API 比 SMTP 快很多) 用 Gmail 在国内服务器同步发邮件,几乎一定会卡爆 5 秒回调时限——结果就是支付宝判定失败 → 重试 → 触发 8 次回调 → 每次都发 1 封邮件 → 站长收 8 封重复通知。这个坑在我接手的几个老项目里都踩过。 ## 如果暂时不重构,最小补救:发邮件前先返回 success 如果不想立刻上队列,最小的补救做法是把"返回 success 给支付宝"这一步前置——保证回调时限内先把成功响应发出去,再慢慢发邮件。改造后的流程: // === 验签成功后立刻吐 success 给支付宝 === @ob_end_clean(); echo 'success'; @ob_flush(); @flush(); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); // PHP-FPM 模式下立刻断开 HTTP 连接,但脚本继续跑 } // === 后面再发邮件,超时不影响支付宝 === require_once(dirname(__FILE__) . "/../mail.class.php"); $smtp = new smtp(...); $smtp->sendmail(...); 这里 fastcgi_finish_request() 是 PHP-FPM 提供的一个鲜为人知的函数,调用后 HTTP 连接立刻断开但脚本继续运行——实际上把"同步回调"扭成了"异步处理"。这是一个不需要任何外部组件就能用的优雅 trick,比上 Redis 队列轻得多。 注意:fastcgi_finish_request() 只在 PHP-FPM 下有效。Apache + mod_php 不支持,那种环境只能上真正的队列。 ## 生产级方案:Redis 队列异步发邮件 当订单量起来(比如日均 50+ 单),或者邮件通道是 Gmail / Outlook 这类境外慢通道,必须把邮件发送从回调链路里拆出去走异步队列。基本架构: 支付宝异步通知 ↓ alipay.php verifyNotify() → [入队] Redis LPUSH "mail_queue" $payload ↓ 立即返回 success 给支付宝 ↓ [出队] worker.php 后台进程 BRPOP "mail_queue" → 发邮件 → 失败重试 → 终态入库 ## 入队代码(替换最小改动方案里的 SMTP 调用) $payload = [ 'to' => 'webmaster@yoursite.com', 'title' => "[新订单] {$order_sn} 支付成功 ¥{$payment_fee}", 'body' => "订单号: {$order_sn}\n金额: ¥{$payment_fee}\n时间: " . date('Y-m-d H:i:s'), 'order' => $order_sn, 'try' => 0, // 重试次数 'enqueue' => time(), ]; $redis = new Redis(); $redis->connect('127.0.0.1', 6379, 1.0); // 1 秒超时 // 用 LPUSH 入左端、worker 用 BRPOP 出右端,构成 FIFO $redis->lPush('mail_queue', json_encode($payload, JSON_UNESCAPED_UNICODE)); $redis->close(); 入队耗时 < 5ms,远小于 5 秒回调时限,所以无论邮件链路多慢,都不会再阻塞回调。 ## 后台 worker 进程(独立 PHP 脚本,由 supervisord 守护) // worker.php — 长驻后台进程 require_once __DIR__ . '/include/payment/mail.class.php'; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); while (true) { // BRPOP 阻塞最多 30 秒,没新任务就 timeout 重新等 $task = $redis->brPop(['mail_queue'], 30); if (!$task) continue; $payload = json_decode($task[1], true); try { $smtp = new smtp('smtp.qq.com', 465, true, 'admin@yoursite.com', '【授权码】'); $smtp->debug = false; $ok = $smtp->sendmail($payload['to'], '保哥商城', 'admin@yoursite.com', $payload['title'], $payload['body'], 'TXT'); if (!$ok) throw new Exception('SMTP 返回 false'); // 成功,写一条 success 日志 file_put_contents('/var/log/mail_success.log', date('Y-m-d H:i:s') . " {$payload['order']}\n", FILE_APPEND); } catch (Exception $e) { // 失败,重试逻辑 $payload['try']++; $payload['last_error'] = $e->getMessage(); if ($payload['try'] < 3) { // 退避:1 分钟、5 分钟、15 分钟 $delay = [60, 300, 900][$payload['try'] - 1]; sleep($delay); $redis->lPush('mail_queue', json_encode($payload, JSON_UNESCAPED_UNICODE)); } else { // 终态失败,写死信日志,等人工介入 file_put_contents('/var/log/mail_dead.log', date('Y-m-d H:i:s') . " {$payload['order']} {$payload['last_error']}\n", FILE_APPEND); } } } 这段 worker 实现了三个关键能力:① 失败 3 次以内自动退避重试(1m / 5m / 15m),覆盖 SMTP 临时不可用的短暂故障;② 超过 3 次进死信日志,等人工排查;③ 成功失败都有持久化日志,事后审计有据可查。 ## supervisord 守护 worker(防进程被 kill 后没人重启) 把 worker.php 用 supervisord 守护: ; /etc/supervisor/conf.d/dedecms-mail-worker.conf [program:dedecms-mail-worker] command=/usr/bin/php /www/wwwroot/yoursite.com/worker.php process_name=%(program_name)s_%(process_num)02d numprocs=1 autostart=true autorestart=true startsecs=5 startretries=3 user=www redirect_stderr=true stdout_logfile=/var/log/dedecms-mail-worker.log stdout_logfile_maxbytes=10MB stdout_logfile_backups=5 supervisord 装好之后 supervisorctl reread && supervisorctl update,worker 就会被守护住,进程崩了立刻拉起来。 ## 没有 Redis 怎么办?文件队列简化版 共享主机 / 老虚拟主机没法装 Redis。退而求其次的方案是用文件目录当队列: // 入队:alipay.php verifyNotify 后 $qDir = __DIR__ . '/mail_queue/'; if (!is_dir($qDir)) mkdir($qDir, 0700, true); $file = $qDir . microtime(true) . '_' . $order_sn . '.json'; file_put_contents($file, json_encode($payload, JSON_UNESCAPED_UNICODE)); // worker:crontab 每分钟跑一次 worker.php // worker.php 扫描 mail_queue/ 目录,发完邮件就 unlink 文件,失败就 rename 加重试计数 文件队列没有 Redis 的实时性(最长延迟 1 分钟),但优点是零依赖,几乎所有 PHP 主机都能跑。订单量 < 100/天的小商城用这个就够。 ## 第二个陷阱:邮件被丢进垃圾邮件箱 真正发出去之后会发现一个新问题——邮件确实从 SMTP 走了,但收件人邮箱里收不到,到处找发现在垃圾箱里。这是反垃圾邮件机制(DKIM/SPF/PTR)没配置导致的。 ## SPF:声明哪些 IP 可以代表你域名发邮件 给你域名(假设 yoursite.com)的 DNS 加一条 TXT 记录: 类型: TXT 主机: @ 值: v=spf1 ip4:你的服务器IP/32 ~all 这条记录告诉收件方:"只有这个 IP 发出的、声称来自 yoursite.com 的邮件才是合法的,其它一律警告"。如果通过第三方 SMTP 中继(QQ 邮箱、SendGrid),值要改成对应服务商的官方 SPF 包含: - QQ 邮箱:v=spf1 include:spf.mail.qq.com ~all - 163 邮箱:v=spf1 include:spf.163.com ~all - SendGrid:v=spf1 include:sendgrid.net ~all ## DKIM:邮件签名,证明内容未被篡改 DKIM 让发件方在邮件头里签一个加密哈希,收件方用 DNS 公钥验证。配置稍复杂: - 在邮件服务商后台(QQ 企业邮箱、阿里云邮件推送、SendGrid)开启 DKIM; - 它会给一对 key + 一条 DNS 记录的"主机名 + 值"; - 把 DNS 记录加到你的 DNS 控制台。 例子: 类型: TXT 主机: default._domainkey 值: v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB...(一串很长的 base64) ## PTR:反向 DNS,IP 反查域名 这个不在邮件配置层面,而是要找 IDC / 云服务商把 IP 的反向 DNS 设成你的域名。例如服务器 IP 192.0.2.10 反查应该返回 mail.yoursite.com。腾讯云 / 阿里云 / AWS 都有提交 PTR 工单的入口。 ## 实测进站率提升数据 三件套全配 vs 都不配,在 Gmail / Outlook / 网易邮箱的进站率: 配置 | Gmail | Outlook | 网易 | 都不配 | ≈ 30% | ≈ 50% | ≈ 60% | 仅 SPF | ≈ 60% | ≈ 75% | ≈ 80% | SPF + DKIM | ≈ 90% | ≈ 92% | ≈ 95% | SPF + DKIM + PTR | ≈ 98% | ≈ 98% | ≈ 99% | 数据来源是我自己跑过的几个商城实测——同样发 100 封同样标题/正文的邮件,分别用 4 种 DNS 配置发,统计 Gmail/Outlook/网易的进收件箱比例。三件套全配下进站率 98%+,可以认为基本"稳了"。 ## 替代方案:把通知发到企业微信 / 钉钉 / 飞书 邮件实时性差、容易丢、需要打开邮箱才能看到。订单通知更适合用 IM webhook——手机一震就到。三家主流 IM 都支持自定义机器人 webhook。 ## 企业微信群机器人 $webhook = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=【你的 webhook key】'; $msg = [ 'msgtype' => 'markdown', 'markdown' => [ 'content' => "## 🛒 新订单提醒\n" . "**订单号**: {$order_sn}\n" . "**金额**: ¥{$payment_fee}\n" . "**时间**: " . date('Y-m-d H:i:s') . "\n" . "[查看订单](https://yoursite.com/dede/order_view.php?oid={$order_sn})", ], ]; $ch = curl_init($webhook); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($msg, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 3); curl_exec($ch); curl_close($ch); 企业微信机器人有发送频率限制:每分钟最多 20 条,超了会限流 5 分钟。日订单量 < 1000 单的小商城完全够用。 ## 钉钉自定义机器人 钉钉机器人需要"安全设置"——在创建时选"加签"或"自定义关键词",否则任何人都能往群里发。代码同上,只是 webhook URL 不同,并且要把 sign 拼到 URL 上: $secret = '【加签 secret】'; $timestamp = floor(microtime(true) * 1000); $sign_str = "{$timestamp}\n{$secret}"; $sign = base64_encode(hash_hmac('sha256', $sign_str, $secret, true)); $webhook = "https://oapi.dingtalk.com/robot/send?access_token=xxx×tamp={$timestamp}&sign=" . urlencode($sign); ## 飞书自定义机器人 飞书的 webhook 协议跟钉钉接近,也有"加签"机制。注意飞书的 markdown 格式叫 "interactive" 卡片,比简单的 markdown 富一些,可以做按钮。订单通知场景做成"查看 / 处理 / 标记已发货"三个按钮,点击直接跳后台。 ## 邮件 vs IM:什么时候用哪个? - 邮件:保存归档需要、外部通知(客户、合作方)、有附件需要; - IM:内部团队实时响应、需要手机 push、需要按钮快捷操作; - 组合:站长用 IM 实时收,财务用邮件保存归档——两条通道并行最稳。 ## 调试方法:发不出去时怎么排查 SMTP 发邮件的失败模式很多,按下面这个顺序逐层排查: ## 看 mail.class.php 的 debug 输出 把 $smtp->debug = false; 改成 true,会把 SMTP 协议交互的每一步原样打印——类似这样: S: 220 smtp.qq.com Esmtp QQ MTA C: EHLO smtp.qq.com S: 250-smtp.qq.com S: 250-AUTH LOGIN PLAIN C: AUTH LOGIN S: 334 VXNlcm5hbWU6 C: YWRtaW5AeW91cnNpdGUuY29t S: 334 UGFzc3dvcmQ6 C: 【密码或授权码】 S: 535 Login Fail. Please enter your authorization code to login. 从 S 行(服务器返回)能直接看到失败原因。535 Login Fail 几乎都是没用授权码(QQ 邮箱),550 Mailbox not found 是收件人邮箱写错,421 Cannot connect 是网络/端口问题。 ## 用 telnet / openssl 直连 SMTP 端口 排除是不是网络层问题: telnet smtp.qq.com 465 # 465 是 SSL 端口,telnet 直连会乱码但能确认连通 openssl s_client -connect smtp.qq.com:465 # 直接握 SSL 连接,看证书 连不上 = 服务器出站被防火墙拦了,要联系机房放开。 ## 用 PHP error_log 看 SMTP 类内部异常 DedeCMS 的 mail.class.php 内部如果有 PHP warning(比如 fsockopen 超时),会写到 PHP 默认 error_log 里: tail -f /var/log/php-fpm/error.log # 或其它 php.ini 配置的位置 ## 用 swaks 这类工具旁路验证 如果想绕开 PHP 直接验证 SMTP 凭据有没有问题,用 swaks(Swiss Army Knife for SMTP): swaks --to test@example.com \ --from admin@yoursite.com \ --server smtp.qq.com:465 \ --auth-user admin@yoursite.com \ --auth-password '授权码' \ --tls-on-connect swaks 能跑通就说明凭据 + 网络都没问题,剩下问题在 PHP 调用层。 ## 安全考量:不能在通知邮件里放什么 付款通知邮件里有几条不能写的东西,否则会触发反钓鱼策略或暴露隐私: - 客户的完整支付宝账户 / 手机号 / 身份证号——隐私合规线(《个人信息保护法》)。脱敏处理:手机号显示中间 4 位星号,身份证只显示后 4 位。 - 外部支付链接 / 第三方跳转 URL——很多企业邮箱网关会把含有支付字样 + 外链 (https://zhangwenbao.com/free-backlink-building-strategies.html)的邮件归类为钓鱼邮件直接拦截。所有链接都用站内 URL(管理后台直链 OK,外链不行)。 - 邮件正文里嵌图片 / 大量 HTML 装饰——纯文本最稳。Spam 评分系统对图文比例有严格要求,HTML 太花的邮件被打分低。 - 退订链接(unsubscribe)——付款通知不属于营销邮件,不需要这个;带了反而会让收件人误以为可以退订然后投诉为垃圾邮件。 ## 2026 年还在用 DedeCMS 的现实 这一节不属于操作步骤,但任何还在用 DedeCMS 的项目方都该知道。 DedeCMS 官方核心团队 2019 年起停止主版本维护,最后一个官方版是 V5.7 SP2。从那以后所有更新都是社区分叉版做的——比较活跃的有: - DedeBIZ:2020 年起的社区分叉,跟进 PHP 8.x 兼容、安全补丁、部分小功能。 - DedeCMS-V5.7-UTF8-SP2.20220112:官方最后一个安全补丁版本。 - 不知名社区维护版:百花齐放,质量参差。 现状的几个关键点: - DedeCMS 自 2018 年起出过多个高危 RCE 漏洞,老版本基本都中。如果服务器上跑的是 V5.7 SP1 之前的版本,强烈建议立刻升级到 SP2 + 装最新补丁。 - PHP 8.x 上跑 DedeCMS 老版本会有大量 deprecation warning,小心 each() / create_function() 这些被移除函数。能切到 DedeBIZ 就切。 - 支付宝插件 alipay.php 用的还是老版支付宝接口(PID + key)签名方式。如果支付宝侧把老接口下线,整套就要重写到当前版本(应用 ID + RSA2 签名)。这个时间节点未知但会到来。 - 新项目 不建议再选 DedeCMS 上线——存量项目可以维护,新项目走 WordPress + WooCommerce (https://zhangwenbao.com/woocommerce-points-rewards-loyalty-membership-plugin-operations.html) / 自研的电商框架更稳。 ## 迁移参考:脱离 DedeCMS 后的等价方案 如果未来要把这个商城从 DedeCMS 迁出去,"付款成功后发邮件"这个功能在新平台的等价做法: - WooCommerce:直接用 woocommerce_thankyou 或 woocommerce_order_status_completed action hook,挂一个 PHP 函数发邮件。WooCommerce 自带的邮件系统已经做好了模板、HTML、SMTP,不需要重新写一遍。 - Shopify (https://zhangwenbao.com/shopify-seo-ai-optimization-playbook.html):在管理后台 Settings → Notifications 里直接勾"New order"通知,加 webhook 转发到自己的服务器再异步处理。 - 自研电商框架:通常会有"事件总线"或"领域事件"机制,OrderPaid 事件订阅一个 EmailNotificationListener 即可。 所有这些新平台都把"付款 → 通知"做成了开箱即用的标配,省去了 alipay.php 改文件的痛苦。这也是迁移的隐性收益之一。 ## 常见问题解答 ## 修改 alipay.php 后 DedeCMS 升级会丢失吗? 会。DedeCMS 升级时 include/payment/alipay.php 会被覆盖回官方默认版本,自定义代码丢失。两种解决思路:① 把改造代码独立到 include/extend.func.php 里写成函数,alipay.php 里只调函数——升级时只需重新加一行调用;② 不改 alipay.php,改 verifyNotify() 的下游 updateOrder() 钩子。生产环境推荐方法 ①。 ## QQ 邮箱发送频繁失败被封了怎么办? QQ 邮箱个人版 SMTP 有每天 500 封 / 每小时 50 封的发送上限,超过后账号会被临时冻结(一般 24 小时自动恢复)。订单量上来超过这个限制就要换企业邮箱(QQ 企业邮 / 阿里企业邮),或换专业邮件推送服务(阿里云邮件推送、SendGrid、Mailgun)。换之前先评估业务体量。 ## 邮件每次都进垃圾箱,SPF + DKIM 都配了还是不行? 三个常见原因:① 服务器 IP 被列入 RBL(实时黑名单),去 mxtoolbox.com 用 IP 查一下,发现在哪个黑名单里就走对应申诉流程;② 邮件正文有触发关键词("中奖"、"免费"、"立即点击"),换文案;③ 发件域名注册时间太短(< 30 天),新域名信任度低,给它点时间。 ## Gmail 在国内服务器上发不出去怎么办? Gmail 的 SMTP 服务器在境外,国内服务器走过去经常超时。三种思路:① 改用国内 SMTP 服务(QQ / 163);② 走境外中转服务(SendGrid 在新加坡有节点,国内联通到节点延迟可控);③ 把 Gmail 改成"应用专用密码 + 587 端口 + STARTTLS",配合超时调到 30 秒以上勉强能用,但不可靠。生产环境推荐 ①。 ## 怎么测试 verifyNotify 在没有真实付款的情况下能触发? 三种方法:① 进支付宝沙箱环境(openhome.alipay.com/platform/appDaily.htm),用沙箱账号发一笔测试支付,会触发完整 notify_url 回调;② 模拟支付宝 POST 请求,构造一个带签名的回调,但这需要拿到对应版本的 RSA 私钥和签名算法,工作量大;③ 在 alipay.php 临时加 file_put_contents('/tmp/notify.log', json_encode($_POST), FILE_APPEND);,等真实订单触发后从日志里复盘。日常调试推荐方法 ①。 ## 没有 Redis、没有 supervisord,crontab + 文件队列能撑多大业务量? crontab + 文件队列方案的瓶颈在扫描频率:crontab 最快每分钟跑一次,所以邮件最长延迟 1 分钟。日订单量 < 200 单(每分钟 < 1 单),文件队列完全够用,存储几十个 JSON 文件而已;超过 500 单就会出现"上一次扫描还没扫完,下一次又起来了"的并发问题,必须加锁或换 Redis。 ## 用 IM 机器人通知,还要不要保留邮件通道? 看场景:① 站长 / 团队内部用 IM 即可,邮件可关;② 财务对账需要邮件归档,保留一份每天汇总邮件足够;③ 给客户的发货通知必须用邮件(SMS 短信也可,但短信成本是邮件的 50-100 倍)。两条通道并行成本不高,故障互为兜底,强烈推荐。 ## 支付宝异步通知重复推送 8 次,会触发 8 次邮件吗? 会,如果不做去重处理。支付宝 8 次重试是出于"网络层兜底",每次都会触发完整 verifyNotify 流程,8 封邮件就会发出去。解决:用订单号 + 状态做幂等——发邮件前先查 typecho_member_msg 或自定义日志表,如果该订单号已发过通知,跳过。Redis 也可以用 SETNX mail:sent:{order_sn} 1 EX 86400,原子性更好。 ## 用 Gmail OAuth 而不是密码登录 SMTP 怎么做? Google 2022 年起逐步关停"低安全性应用"通道(即用账户密码登录 SMTP),新申请已经用不了。要走 OAuth 2.0:① 在 Google Cloud Console 创建 OAuth Client;② 走授权流程拿到 refresh_token;③ 用 refresh_token 每次换 access_token,配合 SMTP 的 XOAUTH2 机制登录。这个改造工作量不小,DedeCMS 自带的 mail.class.php 不支持 XOAUTH2,需要换 PHPMailer 或 SwiftMailer。 ## 有没有"零代码"的方案? 有,但有局限:可以用支付宝商家中心的"商家通知"设置——在 my.alipay.com 设置短信通知 / 邮件通知,付款成功后支付宝直接给商家手机/邮箱发提醒。优点:完全零代码、不依赖 DedeCMS;缺点:通知模板固定、不能附管理后台直链、不能区分"哪家分店收到的款",自定义弱。小商家用这个就行;多店铺 / 需要个性化提醒还是要走代码改造。 ## 织梦DedeCMS默认favicon怎么换成自己的图标 - URL:https://zhangwenbao.com/change-the-default-faviconico-icon-for-the-dedecms-web-site.html - 分类:织梦CMS教程 - 发布:2017-02-22 | 更新:2026-06-01 - 摘要:换掉织梦默认的favicon,得把多尺寸和缓存都做对。本文讲16到512多尺寸图标制作、用ImageMagick打包ICO、后台更新缓存与首页HTML的标准步骤、各家CDN的Purge差异、Google对favicon的四项要求、暗色模式自适应,附五个真实踩坑和跨平台管理工作流。 - 关键词:favicon.ico,网站图标,DedeCMS,ICO制作,网站维护 > **TLDR**:摘要:换掉织梦默认的favicon,得把多尺寸和缓存都做对。本文讲16到512的多分辨率图标制作、用ImageMagick打包ICO、PWA和Manifest图标的额外要求、根目录与子站与移动版三处上传覆盖坑、让织梦后台和静态缓存认账,再到各家CDN的Purge差异、Google对favicon的四项要求、暗色模式自适应和跨平台品牌一致性,附五个真实踩坑。 > 摘要:换掉织梦默认的favicon,得把多尺寸和缓存都做对。本文讲16到512的多分辨率图标制作、用ImageMagick打包ICO、PWA和Manifest图标的额外要求、根目录与子站与移动版三处上传覆盖坑、让织梦后台和静态缓存认账,再到各家CDN的Purge差异、Google对favicon的四项要求、暗色模式自适应和跨平台品牌一致性,附五个真实踩坑。 维护过的织梦站点不下五十个,每次接手老站第一件让我下意识皱眉的事,往往不是后台漏洞,也不是模板里塞满的死链,而是浏览器标签上那个灰扑扑的织梦默认小图标。它像一块没擦掉的出厂贴纸告诉所有访客:这个站还没认真打理过。本文把替换favicon.ico这件小事完整拆开讲一遍,包含图标尺寸的取舍、ICO多分辨率打包、织梦缓存机制、HTML里的link写法、CDN场景下的强制刷新方案、HTTPS下的混合内容处理与浏览器层级验证流程,让你一次性把这块出厂贴纸彻底揭干净。 ## 为什么织梦站点的favicon总是改不动 很多新手第一次替换favicon时往往遇到的不是技术问题而是一种灵异现象:文件已经传上去了浏览器里就是不变。我总结过三类高频原因。 第一类是浏览器缓存层。Chrome、Edge、Firefox都会单独缓存favicon并且缓存周期比一般静态资源长得多,常规的Ctrl加F5只刷新当前文档,并不会把旧图标顶掉。Chrome的favicon缓存默认TTL是1周,Edge是2周,Safari更长达1个月。这种长缓存设计的初衷是减少图标请求开销,但对站点维护者来说就是大坑。 第二类是织梦自身的系统缓存。织梦在生成首页index.html的时候会把head区域的链接连同当时的资源版本一起固化进静态文件,如果你只替换了favicon.ico没重新生成静态首页,访客拿到的依然是旧HTML。织梦的data/cache目录下还有一份模板编译缓存(tpl_前缀的php文件),这份缓存不清空的话即使重新生成静态页面也用的是老模板编译结果。 第三类是部署层。比较新的织梦站点前面普遍挂了CDN或者宝塔的反向代理缓存,这些组件对ico文件默认会启用长缓存,没有手动刷新缓存节点源站再怎么改都白搭。Cloudflare免费版的Cache Everything默认TTL是7200秒(2小时),阿里云CDN国内节点默认是1天,腾讯云CDN是1小时。CDN层不刷新的话源站的修改要等TTL过期后才能生效。 搞清楚这三层之后,下面的所有操作都围绕替换源文件、让织梦再生成、让中间层放行、让浏览器拉新这一条主线展开顺序错了就会出现看起来改了实际没改的尴尬局面。 ## 图标制作:尺寸、配色与多分辨率打包 建议直接放弃只做16乘16的老思路。现代浏览器、Windows磁贴、iOS添加到主屏、Android PWA图标、各种聚合阅读器对图标尺寸的需求差异极大单一16乘16会被强行拉伸结果就是糊成一团。 我自己的工作流是这样的: 第一步在Photoshop (https://zhangwenbao.com/images-cannot-be-dragged-into-the-photoshop-cs6-solution.html)或者Figma里画一张512乘512的矢量稿件色块尽量大、线条尽量粗,避免1像素细节因为缩到16乘16时它一定消失。导出512乘512的PNG作为最高分辨率源文件。 第二步用ImageMagick的convert命令打包多尺寸ICO,参数define icon:auto-resize指定要包含的尺寸列表如256、128、64、48、32、16,输出到favicon.ico。这条命令会把单张512乘512的PNG自动缩放生成多个尺寸版本,全部封装到一个ICO文件里。 如果你不熟悉命令行用在线工具也行(推荐realfavicongenerator.net或favicon.io),但要确认导出的ICO至少包含16、32、48三个尺寸最好再补64和256这样Windows资源管理器把站点拖到桌面变快捷方式时也好看。 配色上我有一个习惯:背景色不要用纯白,原因是绝大多数浏览器的标签栏底色是浅灰或浅色渐变,纯白图标会和底色融在一起看起来像没图标。挑一个和品牌主色对比度足够的纯色背景再叠主体图形,这样不论亮色还是暗色主题都不会被吃掉。我自己常用的配色搭配是:浅灰底色配深蓝主体(适合企业站)、浅黄底色配橙红主体(适合电商站)、白底配深色主体加细描边(适合内容站)。 另一个进阶细节是iOS Touch Icon。iOS Safari把网站添加到主屏时会读取apple-touch-icon系列图标,建议单独准备180乘180的PNG(不是ICO)放在根目录命名为apple-touch-icon.png。这个图标在iOS主屏上显示效果比favicon.ico更好,是Apple Web Apps设计规范的标准做法。 ## PWA和Manifest图标的额外要求 如果你的站点支持PWA(Progressive Web App)或者想被Android作为应用安装,还需要准备manifest.json里的图标配置。manifest.json在网站根目录放一份,里面声明icons数组,每个对象包含src(图标URL)、sizes(尺寸)、type(MIME类型)三个字段。 推荐的icons配置至少包含5个尺寸:192乘192(Android主屏标准)、512乘512(启动屏幕)、144乘144(Windows磁贴中等)、96乘96(GCM通知)、48乘48(兜底)。每个尺寸都准备一个PNG文件。 这套PWA图标配置看起来麻烦但只需要做一次后续不用动。织梦默认不支持PWA但只要在head.htm里加一行link rel manifest指向manifest.json就能让现代浏览器把网站当PWA对待。这种小投入对Google Search Console (https://zhangwenbao.com/gsc-regex-mine-ai-search-prompts-guide.html)的Mobile Friendly评分有正面影响。 ## 上传覆盖:根目录、子站和移动版的三处坑 标准织梦站点的favicon.ico放在网站根目录下。用FTP或者面板的文件管理器进入根目录,删掉旧的favicon.ico再上传新的同名文件。注意编码模式选二进制,不要选ASCII,否则ICO会被破坏。 但我见过太多站点不止根目录一处图标。 第一处坑是模板目录。有些织梦模板会在templets/default/或templets/yourtheme/下另外放一份favicon.ico并在head.htm里写死指向它。如果你只覆盖了根目录那份模板调用的依然是模板目录里的旧图。 第二处坑是手机版独立目录。早年织梦做移动适配习惯单独建一个m目录或mobile目录里面是另一套模板,favicon也是另一份。手机端打开看到旧图标多半就是这里漏了。 第三处坑是plus目录。有的二次开发会把favicon.ico复制到plus下,给广告页或者跳转页用。这种情况比较罕见但确实存在老站接手时建议全站搜索一遍。 用find命令一次性找出所有favicon副本:在SSH里执行find指令配合-iname参数(不区分大小写匹配文件名)找到的所有副本统一替换为同一个新文件,避免出现首页是新图、文章页是旧图的割裂体验。 第四处坑是CDN边缘节点缓存。即使源站全部替换完成,CDN边缘节点的缓存可能还是旧版本,需要在CDN控制台逐个URL刷新或者一键全站purge。这个步骤后面单独说。 ## 让织梦后台和静态缓存认账 文件传完之后按标准流程必须走一遍后台缓存刷新否则前台首页大概率不变。 第1步登录织梦管理后台左侧菜单点生成再点更新系统缓存,点击开始执行按钮等待提示完成。这一步会清掉data/cache下的模板编译缓存下次访问时织梦会重新解析模板里的head标签。 第2步回到生成菜单点更新主页HTML让index.html重新生成。如果你是分目录生成的栏目页再点更新栏目HTML和更新文档HTML把所有静态页面里旧的link标签一并刷掉。这一步如果文章数量大(如5000篇以上)可能要等30分钟到1小时。 第3步在head.htm模板里写link标签时加版本参数。link标签的rel属性设为icon、href属性设为/favicon.ico?v=20260507(v参数是日期或版本号),type属性设为image/x-icon。同样的写法再来一行rel设为shortcut icon兼容老浏览器。再加一行rel apple-touch-icon指向180乘180的PNG。 这里的关键是v参数每次替换图标时把日期或版本号往后调一位,对浏览器来说这就是一个新的URL会强制重新拉取。这个小习惯能省掉用户被迫Ctrl加F5的尴尬。 如果你的站点开了静态缓存插件、或者宝塔面板里启用了OPcache、Redis全页缓存那么织梦后台刷新完之后还要去对应面板再清一次否则访客拿到的还是更早一层的快照。我维护的某客户站启用了Redis全页缓存,favicon替换完没清Redis导致访客看到的旧图标整整持续了一周才被发现。 ## CDN、HTTPS与浏览器层的最终验证 站点前面挂了CDN的话favicon.ico这个URL必须单独刷一次。无论是阿里云、腾讯云、还是Cloudflare控制台里都有刷新缓存或Purge入口输入完整URL(如https协议加上你域名加上slash favicon.ico)提交即可。 三大CDN的具体操作差异: Cloudflare:仪表盘、Caching、Configuration、Purge Cache,可以选Purge by URL(精确刷新)或Purge Everything(一键全清)。免费版每天3000次purge配额对于普通站点完全够用。 阿里云CDN:刷新预热、URL刷新,每天免费额度2000条。批量刷新用文本框每行一个URL。 腾讯云CDN:缓存刷新、URL刷新,每天免费额度10000条。免费额度比阿里云大但单次最多刷新50条URL。 HTTPS站点还要注意一点:浏览器对favicon的安全策略比页面更严格,如果你的图标URL走了HTTP而页面走了HTTPS,混合内容会让浏览器静默丢弃图标请求。把所有link里的协议写成相对路径或者强制HTTPS可以避免这种隐性问题。Chrome 80加之后甚至会在Console里给出明确的Mixed Content警告,便于定位。 验证环节我自己用三套手法: 手法1:直接访问图标URL。在浏览器里打开你域名加slash favicon.ico看返回的是不是新图、Last-Modified头是不是今天的时间。如果时间没刷新说明CDN没清干净。 手法2:无痕窗口加Ctrl加F5。无痕模式下没有持久化缓存能直接看到访客第一次访问的真实效果。这个方法的优势是模拟新用户首次访问,是验证整条链路最真实的方式。 手法3:curl看响应头。用curl加大写I参数(HEAD请求只看响应头不下载内容)查询favicon URL。如果ETag和Last-Modified都已经更新说明源站、CDN、HTTP头都是新的剩下用户端的浏览器缓存最多7天就会自然过期到那时全网访客看到的都会是新图标。 ## 五个真实踩坑记录 坑1:服务器对ICO返回了404但浏览器显示的是织梦默认图标。某次替换后发现Chrome还是显示老的织梦图标,curl返回404。原因是Apache的mod_rewrite规则把favicon.ico当成动态请求转发到了index.php,PHP没处理这个URL返回404后浏览器fallback到了缓存的织梦默认图标。修复是在.htaccess加一条RewriteCond排除favicon.ico的rewrite。 坑2:ICO文件大小过大导致部分浏览器不渲染。某客户给我的favicon.ico有3.2MB(其实是错把PNG改了扩展名),浏览器静默丢弃没显示。修复是用ImageMagick重新打包真正的ICO文件,正常大小应该在5KB到50KB之间。 坑3:CDN对favicon.ico的Content-Type头错误。某次CDN把ICO文件以text/html的Content-Type返回,Chrome报警告并不显示图标。修复是在CDN控制台为ico扩展名手动指定MIME类型为image/x-icon。 坑4:HTTPS证书过期导致favicon请求失败。某客户的SSL证书过期但页面证书还有效(多证书部署),favicon请求走了某个独立子域名证书已经过期,浏览器拒绝加载。修复是把所有静态资源URL统一到主站点子域名共用一张证书。 坑5:织梦后台的更新主页HTML按钮一直转圈不返回。原因是data/cache目录权限是root所有web用户写不进去。修复是chown改所有者为www-data再chmod 755。这个错误在Linux服务器上特别常见尤其是用FTP上传文件后默认所有者错乱。 ## SEO层面的favicon优化 Google在2019年开始在搜索结果(移动端)显示网站favicon,所以favicon对SEO有了实际意义。Google对favicon的要求: 要求1:尺寸至少48乘48的倍数。Google会从你提供的图标里选最接近48乘48的版本。如果你只提供16乘16Google会拉伸成48乘48显示效果差。建议至少提供48乘48的ICO或PNG。 要求2:不能是空白或纯色。Google会拒绝渲染没有视觉特征的图标,原因是这种图标无法帮助用户识别站点。建议图标至少有一个明显的字母、Logo或图形元素。 要求3:与站点品牌一致。Google的算法会检测图标与站点品牌的视觉一致性,频繁更换或与站点风格不符的图标会被Google忽略。建议图标设计完成后保持长期不变。 要求4:可被Googlebot (https://zhangwenbao.com/google-404-crawl-seo-positive-signal.html)抓取。robots.txt里不能屏蔽favicon URL。某客户的robots.txt里写了Disallow root导致Google无法抓取favicon搜索结果显示默认地球图标。 验证Google对你的favicon识别情况:在Google搜索你的品牌词,看移动端结果是否显示自定义favicon。如果显示默认地球图标说明Google没正确识别需要按上述四条要求逐个排查。 ## 品牌一致性:跨平台favicon管理 对运营多个站点的团队来说favicon管理需要标准化避免每个站风格各异。我自己的做法: 1. 主图标库统一:所有站点的高分辨率主图(512乘512)放在团队共享设计库(如Figma、Sketch Cloud),任何修改都从这一份开始版本控制清晰。 2. 一键导出脚本:写一个bash脚本调用ImageMagick从单张PNG批量生成所有需要的尺寸(ICO、Touch Icon、Manifest图标全套),减少手动操作错误。 3. CI/CD集成:图标更新提交Git后CI流程自动跑导出脚本、上传到所有站点的根目录、刷新CDN。整个流程从图标变更到全网生效不超过5分钟。 4. 监控与告警:用UptimeRobot或Pingdom监控每个站点的favicon URL响应状态,404或Content-Type错误时立刻发钉钉告警。 这套流程在我管理的15个客户站上跑了两年没出过favicon相关的问题,比手动维护至少节省70%时间。 ## 常见问题解答 ## 为什么我换了favicon搜索引擎结果里显示的还是旧图标? 搜索引擎抓取favicon有自己的节奏百度、Google、Bing都不是实时的。一般两到四周才会重新抓取一次期间你能做的就是在Search Console里提交首页重新抓取并且确保首页HTML里的link rel icon标签写法标准、URL可访问、文件200返回。如果4周后Google搜索结果还是旧图标,可能是Google的视觉一致性算法把新图标判断为不一致拒绝更新,建议保持新图标至少6到8周让算法重新评估。 ## 我的图标在Chrome里是新的但Edge还是旧的需要改什么吗? 不需要改服务器。Chrome和Edge虽然内核相同但favicon缓存数据库是分开的。Edge里去地址栏输入edge冒号双斜杠favicon-internals找到对应站点删掉条目,再刷新页面就能看到新图标。Chrome是chrome冒号双斜杠favicon,操作方式一致。Firefox需要在about冒号cache里手动清。如果团队成员都遇到这个问题最快的方式是发一条公告让大家无痕模式打开网站验证。 ## 织梦后台刷新缓存按钮点了没反应是不是权限问题? 大概率是data/cache目录或者a/index.html没有写权限。SSH登录服务器执行chown -R命令把所有者改成web服务用户(如www或www-data再把data目录改成755重新点一次刷新缓存就会生效。如果还是不行看一下PHP错误日志通常会直接告诉你哪个文件写不进去。/var/log/httpd/error_log或/www/wwwlogs/yoursite-error.log是常见路径。 ## 我能不能直接用PNG当favicon省掉ICO转换这步? 可以但不推荐。现代浏览器确实支持link rel icon type image png但IE老版本、部分国产浏览器、以及很多聚合工具仍然只认根目录的favicon.ico。我的建议是ICO和PNG都放,PNG走link标签提供高分辨率,ICO留在根目录兜底,两手准备最稳。这种双轨方案能覆盖99.5%的访客环境。 ## 多语言站的favicon应该按语言分别准备吗? 不建议。Google的视觉一致性算法对同一域名下的多语言站期望共用一个favicon。如果中文版用一个图标英文版用另一个图标Google会判定为不一致并降级处理。正确做法是用同一个图标对应所有语言,需要文化适配的话在图标设计阶段做出兼容多种文化的视觉元素(如抽象几何图形而不是带有文化偏向的具象图形)。 ## 有没有快速测试favicon所有平台显示效果的工具? 有。realfavicongenerator.net提供免费的favicon checker,输入你的网站URL它会模拟Chrome、Firefox、Safari、iOS主屏、Android主屏、Windows磁贴、macOS Dock等十几个平台的显示效果,并指出每个平台上的问题。这是验证favicon全平台兼容性最方便的工具,建议每次替换后跑一次。 ## 暗色模式下favicon会自动适配吗? 不会自动适配但可以手动指定。HTML5支持link rel icon加media属性指定不同主题下加载不同图标。具体做法是写两个link标签,第一个media属性是prefers-color-scheme light指向浅色版图标,第二个是prefers-color-scheme dark指向深色版图标。浏览器会按用户系统主题自动选择。Safari 12.1加、Chrome 100加、Firefox 100加都支持这个特性。 ## Favicon对网站性能有影响吗? 影响极小但不是零。一个50KB的favicon在4G网络上下载约0.1秒,在WiFi上几乎瞬时。但有一个隐藏开销:浏览器会在加载HTML之前优先请求favicon.ico即使HTML里没显式声明,这个早期请求会占用一个TCP连接。优化方法是在HTML的head里显式声明link rel icon标签让浏览器知道favicon URL避免无脑请求根目录的默认favicon.ico。Chrome 89加之后这种优化能减少首屏加载约30到50毫秒。 ## 替换favicon会影响Google搜索排名吗? 不会直接影响排名但会影响点击率(CTR)从而间接影响排名。Google搜索结果里清晰、有辨识度的favicon可以让用户更容易识别你的站点提升CTR。Google的Quality Search文档明确表示Favicon是搜索结果展示的辅助元素之一。我自己做过A/B测试 (https://zhangwenbao.com/ab-testing-ctr-conversion-optimization.html):替换为高对比度的彩色favicon后某站的Google移动端搜索结果CTR从3.2%提升到4.7%相对提升47%。 ## 权威参考资料 ## DedeCMS会员相册SQL注入1行intval修复实战 - URL:https://zhangwenbao.com/dedecms-albumaddphp-file-sql-injection-vulnerability.html - 分类:织梦CMS教程 - 发布:2017-02-17 | 更新:2026-05-16 - 摘要:DedeCMS会员中心album_add.php存在mtypesid整型参数注入。本文给出精准修复方案:在HtmlReplace下一行加$mtypesid=intval($mtypesid)即可消除。文章含成因拆解、sqlmap验证、WAF配合、历史漏洞脉络与运维清单。 - 关键词:织梦漏洞,织梦SQL注入,织梦会员中心,DedeCMS,DedeCMS安全 > **TLDR**:摘要:DedeCMS会员中心的album_add.php存在mtypesid整型参数注入。本文给出精准修复——在HtmlReplace下一行加一句intval即可消除,并拆解漏洞成因、用sqlmap验证、配WAF防护,再梳理DedeCMS会员模块同类整型注入的历史脉络和日常运维清单,帮你把这一类一行就能补的洞一次堵干净。 > 摘要:DedeCMS会员中心的album_add.php存在mtypesid整型参数注入。本文给出精准修复——在HtmlReplace下一行加一句intval即可消除,并拆解漏洞成因、用sqlmap验证、配WAF防护,再梳理DedeCMS会员模块同类整型注入的历史脉络和日常运维清单,帮你把这一类一行就能补的洞一次堵干净。 我是保哥,做织梦DedeCMS二次开发和安全运维有十几年了,从5.6一直跟到5.7 SP2,再到后来的所谓"商业授权时代"。今天聊的这个洞,是会员中心模块下相册新增脚本member/album_add.php的SQL注入 (https://zhangwenbao.com/dedecms-membership-center-pm-php-injection-vulnerability-repair-method.html),影响面看起来小,实际上但凡开了会员注册的织梦站,都属于公开可达的攻击面。 我自己经手过的几十起织梦应急里,至少有四五次最终的入口就是这个文件。下面我把成因、修复、复现、上线加固一次讲清楚,给还在维护织梦的同行一个完整的处置流程。 ## 一、漏洞背景与影响范围 member/album_add.php是织梦会员中心给注册用户提供的相册创建入口。普通注册用户登录后就能访问,无需任何额外权限。这意味着: - 攻击者只要花几分钟注册一个会员账号,就能直达这个脚本 - 不存在"后台漏洞"那种"需要先拿到管理员密码"的门槛 - 在搜索引擎上能搜到大量带member/album_add.php的织梦站点 按我自己复测过的版本,受影响范围至少包括: - DedeCMS 5.7 SP1 - DedeCMS 5.7 SP2 早期补丁版本 - 各种基于5.7的二开版本(例如带有"SEO增强"或"移动版"后缀的整站源码) 如果你的织梦站点开放了会员注册功能,又没有针对该文件打过专门补丁,那基本可以默认它是中招的。我给一个山东客户做应急时,2台同IP段的服务器都跑织梦5.7 SP2,一台关了注册一台没关,关了的那台一年内零事件,没关的那台被同一个攻击IP用过3次同一个album_add.php注入入口。 ## 二、漏洞成因:mtypesid没做整型转义 打开织梦目录下的member/album_add.php,定位到大约220行附近,会看到一段类似这样的代码: $description = HtmlReplace($description, -1); // 2011.06.30 增加 html 过滤(by:织梦的鱼)这一行上面会有一段处理表单字段的逻辑,其中$mtypesid来自前端表单,按预期是相册分类的整型ID。但织梦在这里只对description做了HTML过滤,mtypesid全程没经过任何强制类型转换就被拼到SQL语句里了。 后续代码大约会是这样(不同版本细节略有差异): $query = "INSERT INTO `#@__uploads`(... mtypesid ...) VALUES(... '$mtypesid' ...)"; $dsql->ExecuteNoneQuery($query);注意拼接处用的是单引号包裹的字符串。攻击者只要构造一个带单引号闭合的mtypesid值,例如: mtypesid=1', (select user()))-- -就能在INSERT语句里塞进自己的子查询。配合织梦默认的dede_uploads等表结构,可以走出错回显(有些版本cfg_debug没关)或时间盲注,最终把dede_admin表里的管理员账号密码摸出来。 我现场抓过一次实际攻击的payload,是这样的:mtypesid=0)+UNION+SELECT+userid,pwd+FROM+dede_admin+WHERE+1+IN+(1)-- - ,URL编码完看着像乱码,解码后能看到完整的SQL注入意图。 ## 三、修复方案:一行intval解决问题 这个洞的修复思路非常简单:既然mtypesid应该是整型,那就强制把它cast成整型。具体做法是打开member/album_add.php,找到那行HtmlReplace: $description = HtmlReplace($description, -1); // 2011.06.30 增加 html 过滤(by:织梦的鱼)在它的下一行加上: $mtypesid = intval($mtypesid);完整修复后的代码块如下: $description = HtmlReplace($description, -1); // 2011.06.30 增加 html 过滤(by:织梦的鱼) $mtypesid = intval($mtypesid);保存上传,注入点立刻消失。 这里我要强调两点保哥实战中的细节: - 不要用addslashes替代intval:很多老教程会说"拼SQL之前addslashes一下就好",但addslashes只能处理引号,对整型注入(数字直接拼进SQL而不带引号的场景)是无效的。intval才是整型字段的标准答案。 - 注意mtypesid的赋值顺序:必须放在它被用到的SQL拼接之前。织梦5.7 SP2的某些二开版本,相册类型字段在两处分别拼接,确保intval在最早一次使用之前就执行。 我见过的另一个常见错误是开发者把intval写在了InsertSQL函数之外,结果SQL拼接走的还是原始变量。修完之后必须grep一下文件里所有出现mtypesid的地方,确保每一处都引用的是intval后的版本。最稳的写法是直接覆盖原变量:$mtypesid=intval($mtypesid),不要新建一个$mtypesid_safe这种容易混淆的变量。 ## 四、修复后的回归验证 改完代码不能想当然,必须验证。我在现场固定跑这套流程: - 正常用例:用注册会员账号登录,进入相册中心,正常创建一个相册,选择一个真实的相册分类(比如"风景"),看是否能成功保存 - 空值用例:把mtypesid留空提交,确认不报PHP Warning,业务流程优雅降级 - 非数字用例:用浏览器开发者工具把表单mtypesid的值改成abc,提交后应该被当作0处理,不影响其它字段 - 注入用例:把mtypesid改成1' OR '1'='1、1) UNION SELECT 1,2,3-- -、1 AND SLEEP(5),逐个提交,确认页面行为与正常请求一致,无报错、无延迟 - MySQL日志校验:开启general_log,提交一次包含payload的请求,确认进入MySQL的SQL已经是mtypesid=0这种纯整型,没有恶意片段 第5步是我最看重的,它能从数据库视角证明攻击载荷已经在PHP层被中和。开general_log的方法是登录MySQL执行SET GLOBAL general_log = 'ON'; SET GLOBAL general_log_file = '/tmp/mysql_general.log'; 验证完务必关掉,不然几分钟就能撑爆磁盘。 ## 五、织梦站点的纵深加固建议 单点修这一个文件,对于织梦这种几百个PHP入口的系统来说远远不够。我接到客户站点之后,习惯叠加以下几层防御: - 关闭不必要的会员注册:很多企业站根本不需要会员功能,直接在data/admin/config_update.php或后台"会员设置"里关掉注册入口,攻击面瞬间砍半 - 会员目录全局过滤:在member/index.php顶部加一段对所有$_GET和$_POST的统一关键字过滤(针对union、select、sleep、benchmark等关键词),作为兜底 - 管理目录改名:把默认的/dede/改成随机字符串,挡掉绝大多数自动化扫描 - 数据库账号降权:织梦使用的MySQL账号去掉FILE、SUPER权限,杜绝INTO OUTFILE写shell - 定期sqlmap巡检:用sqlmap命令带cookie参数指向member/album_add.php,定期复查 - 整站静态化:如果业务允许,干脆把前台全部静态化输出,PHP入口只保留必要的几个,这是终极防御 加固完之后建议把这套规则写到运维文档里,迁移服务器或者交接给新同事时候照着复盘一遍,避免重复踩坑。 ## 六、保哥的踩坑教训 我自己第一次遇到这个洞是2016年,客户是一家做企业培训的站,织梦5.7 SP1,开了会员中心给学员上传作业相册。攻击者从member/album_add.php进来,盲注拿到admin密码(弱密码admin/admin888),登录后台用模板SQL命令执行写了个webshell,整站被挂了一周博彩页面才被发现。 复盘时我们发现,这个客户的运维其实定期跑过360网站卫士扫描,但因为album_add.php需要登录态才能访问,自动化扫描器没覆盖到。这件事教会我两个事情: - 自动化扫描必须带登录cookie,否则会员区漏洞永远扫不到 - 织梦这种"会员区也是公开攻击面"的逻辑,必须显式纳入威胁建模 后来我给所有客户的扫描脚本都加了一段:先用CURL模拟会员注册,拿到Cookie,再把这个Cookie喂给sqlmap和Acunetix,扫描出来的高危漏洞数量直接翻了一倍。这个流程现在已经是我团队的标准动作。 ## 七、与其他织梦SQL注入漏洞的对比 album_add.php这个洞在DedeCMS漏洞史上属于"低危但实际危害大"的类型。同期类似漏洞还有: - member/uploads.php的mediatype参数注入:跟album_add几乎一样的成因,也是少了一个intval - plus/feedback.php的aid参数:会员区评论模块,影响面更大,2018年补丁里官方修过 - plus/search.php的keyword参数:搜索框注入,无需登录态,是最严重的几个之一 - include/dialog/select_soft.php:后台编辑器附带的,需要管理员权限 - plus/recommend.php的mid参数:推荐文章入口,开了UC通信的站点会被影响 这些洞的修复套路高度一致:找到拼SQL的入口变量,按字段类型加intval(整型)、addslashes(字符串)、HtmlReplace(富文本)、in_array(枚举值)。把这套思路记住,下次再遇到新爆出的织梦洞,自己照着分析也能修。 ## 八、织梦SQL注入漏洞的历史脉络 为了让大家对album_add.php这类漏洞有完整的认知,我把过去十年织梦SQL注入相关的几个里程碑事件串起来讲讲。 2013年织梦5.7发布,会员中心模块大改。这次改造引入了大量新的PHP入口,但代码审查环节没跟上,导致从2014年开始,乌云、漏洞盒子等漏洞平台陆续爆出十几个会员中心SQL注入。当时织梦官方修复响应非常慢,很多洞从爆出到正式补丁要等半年以上,催生了第三方"织梦补丁集合"这种灰色服务。 2016年是织梦漏洞爆发最集中的一年。仅这一年就有album_add.php、uploads.php、edit_face.php、recommend.php四个会员区SQL注入被公开。我自己当时给至少6个客户做过应急,每次场景都极度相似:开放注册、攻击者注册账号、SQL注入拿密码、写shell挂博彩。 2018年织梦正式宣布商业授权,免费版停止安全更新。这意味着自此之后所有新爆出的漏洞,都需要站长自己打补丁。但当时国内织梦存量站点估计还有几十万个,绝大多数没有专业安全团队,结果就是这批存量站长期处于裸奔状态。 2020年开始,攻击者用大规模自动化扫描批量打织梦站点,每天能扫到上千个未修补的album_add.php注入点。我那段时间接到的应急工单几乎每周都有1-2单,全部都是织梦相关。 2023年之后,织梦攻击事件显著减少,主要是因为存量站点持续衰减,新立项基本不用织梦。但剩下的存量站点反而更危险,因为这部分要么是企业懒得管的老站,要么是已经无人维护的"僵尸站",遇到攻击基本没人响应。如果你接手的就是这种站点,album_add.php这个洞值得列在第一优先级。 ## 九、给运维和站长的检查清单 如果你接手了一个老织梦站点,按下面这个清单走一遍,能扫掉90%的常见入口: - 检查member/目录下所有.php文件,grep "$_(GET|POST|REQUEST)" 找参数入口,确认每个数字字段都做了intval - 检查plus/目录下的feedback.php、search.php、recommend.php、guestbook.php,全部有官方安全补丁要打上 - 检查include/dialog/目录,所有select_*.php文件应该限制只能后台访问 - 删除install/目录或重命名,避免被人重新走一遍安装向导 - data/目录下的backupdata、tplcache、textdata子目录设置不可执行PHP(nginx里加location ~ /data/.*.php$ { deny all; }) - 后台/dede/目录改名,并在nginx层加basic auth双重保护 - 定期备份数据库和会员上传目录,备份文件不要放在web可访问路径下 跑完这个清单不能100%阻止织梦被攻击,但能挡掉绝大多数自动化攻击。后续遇到针对性的人工渗透还是要靠应急响应和监控告警来兜底。 ## 十、配合WAF做请求级拦截 代码层修复是底线,请求层用WAF做正则拦截能再加一道墙。我给客户做织梦防护时常用的两套规则: 第一套针对member目录的SQL注入特征。在Nginx里放一段: location ~ ^/member/.*\.php$ { if ($args ~* "(union[\s+]+select|sleep\(|benchmark\(|0x[0-9a-f]{8,}|\bcase\s+when\b)") { return 403; } }这条规则会拦截union select、sleep、benchmark等典型注入特征。织梦正常业务不会用到这些SQL关键词,所以误杀率极低。我用了4年多,没遇到过一次误杀。 第二套针对整型参数加白名单。把所有应该是整数的参数(aid、tid、mid、mtypesid、id等)单独拎出来,强制必须是数字: if ($args ~* "(aid|tid|mid|mtypesid|id)=[^0-9&]+") { return 400; }这条规则只允许这些参数取纯数字值,混入任何字母、引号、空格直接400。比代码层的intval更激进,但能在请求到达PHP之前就拦下来,节省服务器资源。 如果你用宝塔面板 (https://zhangwenbao.com/bt-panel-automatic-disk-mount.html),可以在"网站→设置→Nginx配置"里直接粘上面两段代码。云服务商提供的WAF(阿里云盾、腾讯天御、Cloudflare (https://zhangwenbao.com/cloudflare-markdown-for-agents-ai-seo-geo.html))默认规则集对织梦SQL注入也有覆盖,开启托管规则即可,不用自己写。但托管WAF有月费,规则细节也不可控,自建Nginx规则的优势是免费且可调。 我个人的方案是双层防护:托管WAF做粗筛挡掉自动化扫描,Nginx自定义规则做细筛拦截针对性payload,再加上代码层intval兜底。三层叠加做下来,过去5年我维护的几个织梦站点零事件。 ## 十一、写在最后 织梦这套系统在国内站长圈的存量极大,很多企业站、政府站、学校站还在跑。这种"一行intval就能修"的洞,看起来不起眼,却是真实事故里最常见的入口。希望这篇笔记能帮到还在守着织梦的同行:多花十分钟做一次源码级修复,比事后熬通宵做应急体面太多。 修代码、清后门、改密码、上WAF、做监控,五件事按顺序做完,album_add.php这条入口就算彻底关闭了。不要只修代码就以为完事,攻击者可能早已留下账号或webshell,光修补丁等于关上了门但忘了换锁。 如果你的织梦版本比较特殊(比如3.x的远古版本,或者高度二开过的整站源码),或者修完之后会员功能出现异常,欢迎留言把版本号和现象贴出来,我看到了会回。安全这件事最忌讳"自己摸索",多交流多对照才能避免重复踩坑。 ## 常见问题解答 ## 我的织梦不开放会员注册,是不是就不用修这个洞 建议还是修。一方面,织梦的会员注册开关有时会在升级或备份恢复后被重置;另一方面,如果攻击者通过其它途径(比如SSRF、密码爆破)拿到任意一个会员账号,这个洞依然成立。修一行代码的成本远低于潜在风险。我有个客户就是这种情况,主站关了注册,但备份站点恢复后注册被重置开启,攻击者从备份站点进来打了主站的会话凭据,最终把主库导走了。 ## 织梦官方还在更新吗,是不是直接升级到最新版就好 织梦自从进入商业授权阶段后,免费版的安全更新非常有限。我建议是以打补丁为主,整站升级为辅。盲目整站升级可能会破坏二开代码,反而引入新问题。如果你的项目是新立项的,建议直接选Typecho、WordPress、Joomla等仍在持续维护的开源CMS。织梦只适合维护已有存量站点,不适合新项目立项。 ## 除了album_add.php,会员中心还有哪些类似的文件需要重点检查 根据我维护客户站点的经验,至少member/uploads.php、member/edit_face.php、member/mtypes.php、member/pm.php都出现过相似套路的问题。建议在member目录下用grep -rn 命令搜索所有$_GET、$_POST、$_REQUEST 入口,把所有数字字段都过一遍intval。这种人工审计每年做一次,结合自动化扫描,基本能扛住绝大多数已知漏洞。 ## 加了intval之后,相册分类ID如果是负数会有问题吗 织梦的相册分类ID都是正整数自增,正常业务下不会出现负数。即便用户故意传-1,intval也会正常返回-1,SQL查询会查不到匹配记录,业务上等价于"没选分类",不会引发安全问题。如果你确实担心负数语义错误,可以再加一句max判断,比如$mtypesid = max(0, intval($mtypesid)),强制非负。 ## 修完之后能不能用sqlmap自动验证 可以而且强烈推荐。完整命令是sqlmap -u 目标URL --data 参数串 --cookie 登录态 --level 5 --risk 3 --batch。注意必须带登录cookie,因为album_add.php需要会员登录态。跑完sqlmap如果输出"all tested parameters do not appear to be injectable"才算修干净。如果还能跑出injectable,说明同一个参数还有其他拼接位置漏改了。 ## 修复会不会破坏正常的相册创建功能 不会。intval是PHP原生函数,对原本就是整型的输入返回值不变,对非整型输入按字符串前缀的数字部分转换。正常用户从下拉框选分类传过来的mtypesid本来就是数字,加intval完全无感。我修过几十个客户站点,没有一次出现过功能异常的反馈。如果你修完之后客户报相册创建失败,大概率是改文件时手抖加了多余的字符,比对前后版本即可定位。 ## 除了intval还有哪些常用的整型过滤写法 常用的有四种:intval()最简单粗暴;(int)$x强转,性能略好;ctype_digit()判断只含数字字符(注意不接受负号);filter_var($x, FILTER_VALIDATE_INT)更严格,能区分0和false。织梦原生代码里用intval最多,保持一致性即可。如果你写新模块,建议用filter_var方案,对类型校验更严格。 ## 同样的洞修完后还需要做哪些后续操作 三件事:第一,把修复后的album_add.php纳入版本控制,方便后续审计;第二,扫一遍服务器上是否已经被植入webshell,重点查/uploads/、/data/、/templets/目录,找出大小异常或修改时间近期的.php文件;第三,强制所有现存会员重置密码,因为旧库可能已经被脱过。这三步完成才算彻底闭环,光修代码不查后门等于刚补完一个口子,之前进来的人还能照常出入。 ## 权威参考资料 ## uaredirect.js时代终结:百度SiteApp下线复盘、JS跳转的SEO灾难、DedeCMS响应式改造完整迁移路径 - URL:https://zhangwenbao.com/baidu-uaredirect-js-dedecms-jumps-to-mobile.html - 分类:织梦CMS教程 - 发布:2017-02-06 | 更新:2026-05-16 - 摘要:百度SiteApp下线,m.子域名加uaredirect.js的JS跳转方案如今是SEO灾难,会引发内容自我蚕食和重复页问题。本文拆解uaredirect.js源码与UA判断的边界坑,给出服务端301加Vary的正解,以及DedeCMS六步响应式改造的完整迁移路径。 - 关键词:uaredirect.js,DedeCMS跳转,DedeCMS手机端,响应式设计,DedeCMS > **TLDR**:摘要:百度SiteApp下线后,m子域名加uaredirect.js的JS跳转方案如今是SEO灾难,会引发内容自我蚕食和重复页。本文拆解uaredirect.js的历史与源码、UA判断的边界坑、JS跳转为何伤SEO,再给2026年的现代替代——服务端301加Vary,以及DedeCMS响应式模板改造的六步迁移路径和客户端UA检测的现代替代库。 > 摘要:百度SiteApp下线后,m子域名加uaredirect.js的JS跳转方案如今是SEO灾难,会引发内容自我蚕食和重复页。本文拆解uaredirect.js的历史与源码、UA判断的边界坑、JS跳转为何伤SEO,再给2026年的现代替代——服务端301加Vary,以及DedeCMS响应式模板改造的六步迁移路径和客户端UA检测的现代替代库。 原文这段 uaredirect.js 是百度 SiteApp 平台 2012 年推出的脚本,用 JS 检测浏览器 UserAgent,识别移动设备就跳转到 m. 子域名。当年织梦 / DiscuzX / 帝国 CMS 几乎所有"PC + 手机分站"教程都引用这个文件。但有两件事让原文写法在 2026 年已经不能用:第一,百度 SiteApp 项目 2018 年下线,http://siteapp.baidu.com/static/webappservice/uaredirect.js 已经 404,远程引用直接挂掉;第二,Google Mobile-First Indexing 之后 m. 子域名 + UA 跳转的方案被搜索引擎视为反模式,对 SEO 是慢性毒药。这篇文章把 uaredirect.js 源码逐行拆开,标出隐藏 bug、UA 识别的边界场景、JS 跳转的 SEO 灾难、然后给出 2026 年的现代替代方案——响应式设计 + 服务端 301 + canonical (https://zhangwenbao.com/canonical-url-seo-guide.html)/alternate 声明的完整迁移路径。 ## uaredirect.js 的历史与下线 百度 SiteApp(站长工具下的"百度移动建站"功能)2012 年上线,目标是给没钱做手机版的中小站长一键生成移动适配。生成的服务包括两块: - 云端转码:百度服务器抓取 PC 版页面,自动转码成手机版(类似 Google Mobilizer)。 - JS 跳转:uaredirect.js + isSubdomain() 函数,让站长在 PC 版里嵌入一段 JS,检测移动端 UA 自动跳到 m. 子域名(自建的或百度托管的)。 2015 年百度宣布 SiteApp 转型,2017 年开始限制新接入,2018 年彻底下线。siteapp.baidu.com 域名仍解析(指向百度静态页"功能下线公告"),但子路径 /static/webappservice/uaredirect.js 已经 404。任何还在引用这个远程脚本的站点都会触发"等待 30 秒超时 → 报错 → 后续 JS 阻塞"的连锁反应——这是原文为什么强调"网站打开速度缓慢"的根因。 Web Archive(archive.org)还能找到 uaredirect.js 的多个历史版本,跨度 2013–2018。版本之间差异很小,主要是修了几个 IE 兼容 bug 和加了 fromapp hash 标记防循环。原文给的源码是 2015 年版本的精简化。 ## uaredirect.js 源码逐行拆解 ## 主函数 uaredirect(f) function uaredirect(f) { try { if (document.getElementById("bdmark") != null) { return } // ... } catch(d) {} } 第一行 document.getElementById("bdmark") 检测页面里是否已经有 id="bdmark" 的元素。这是为什么?因为百度 SiteApp 转码服务器抓取 PC 页面后会自动注入一个隐藏 div ,标记"这个页面已经被百度转码"。如果 uaredirect 在转码后的页面再次执行(被搜索引擎转码缓存),bdmark 存在直接 return,避免无限重定向循环。 2018 年 SiteApp 下线后,再也没有页面会被注入 bdmark——这段判断在现在等于死代码。但保留它没害处,因为遗留站点可能仍依赖。 外层 try-catch 静默吞掉所有异常。这是浏览器兼容代码常见做法——某些老 IE 不支持 location.replace 或 navigator.userAgent,与其报错不如沉默。但代价是调试很痛苦:脚本不工作时连 Console 都没有错误信息,只能逐行注释来定位。 ## 子域名检测分支 if (arguments[1]) { var e = window.location.host; var a = window.location.href; if (isSubdomain(arguments[1], e) == 1) { f = f + "/#m/" + a; b = true } else { if (isSubdomain(arguments[1], e) == 2) { f = f + "/#m/" + a; b = true } else { f = a; b = false } } } else { b = true } 这段是判断"目标移动版 URL 与当前 PC 域名的关系"。三种情况: - isSubdomain == 1:完全相同域名(极少见,通常表示 m. 跳到 m.)。 - isSubdomain == 2:当前是目标的子域名(PC 是 www.xxx.com,目标是 xxx.com 的 m. 子域名)。 - isSubdomain == 0:完全不同域名(比如 PC 是 a.com,跳 m.b.com,跨站跳转)。 case 1 和 case 2 都拼接 "/#m/" + 当前 URL——这是百度 SiteApp 转码 URL 的格式:把当前页面 URL 当成 anchor 拼到目标 m. 域名后面,转码服务收到后从 anchor 解析出原页面再去抓取转码。 原文用法不传 arguments[1]: uaredirect(window.location.href.replace("www.","m.")); 只传一个参数 f,arguments[1] 是 undefined,进 else 分支 b = true——直接跳。这是简化版,跳过子域名判断,假设目标 m. 域名能正常处理 URL 映射。这种用法的隐患:如果当前 URL 不是以 www. 开头(比如直接访问 https://xxx.com/page),replace("www.","m.") 不替换任何东西,f 还是当前 URL,触发"跳到自己"。修复: var target = window.location.host.indexOf('www.') === 0 ? window.location.href.replace("www.", "m.") : window.location.protocol + "//m." + window.location.host + window.location.pathname + window.location.search; uaredirect(target); ## UA 检测部分 if (b) { var c = window.location.hash; if (!c.match("fromapp")) { if ((navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i))) { location.replace(f) } } } 核心是 navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i) 这个正则。命中就 location.replace(f) 跳转。 fromapp hash 检测:移动端用户从 m. 站点点链接回到 PC 页面(比如分享给桌面端朋友)时,URL 里加 #fromapp 标记表示"我从 app 来,不要再跳回 m."。这是防止"PC ↔ 移动"无限循环的关键。 UA 正则的边界场景: - iPad 不在列表里。iPad UA 含 iPad 但不含 iPhone/iPod,所以 iPad 用户不会被跳转——这通常是预期行为(iPad 屏幕大,PC 版能正常浏览)。但 iPad mini 7 寸可能更适合 m. 版,原文没区分。 - Android 平板被强跳。Android 平板的 UA 含 Android,会被强制跳到 m.——但平板屏幕够大,跳到 m. 后体验更差。修复:检测 Android.*Mobile(手机 Android 才有 Mobile 关键字,平板 Android 没有)。 - iPad iOS 13+ 默认伪装成 macOS。2019 年 iOS 13 起,iPad Safari 默认 UA 被改成 Macintosh——为了让网站把 iPad 当 PC 处理。如果想识别 iPad,要检测 navigator.maxTouchPoints > 1 && /Mac/.test(navigator.userAgent)。 - 微信浏览器:含 MicroMessenger,UA 里同时有 iPhone 或 Android,所以会被跳。但微信内打开的页面跳到外站需要"在浏览器打开"才能看,体验差。微信内通常希望保持当前域名,可以加 !/MicroMessenger/i.test(ua) 例外。 - QQ / 微博 / 钉钉浏览器:类似微信问题,UA 含 QQ、Weibo、DingTalk。各自有独立的 webview,跳转后场景割裂。 - Chrome DevTools 模拟模式:调试时切换到 iPhone 模拟,UA 真的会变成 iPhone。开发期间会被频繁误跳。 - 折叠屏手机展开:三星 Galaxy Z Fold / 华为 Mate X 展开后屏幕超 7 寸,但 UA 仍是 Android Mobile。会被跳到 m. 版浪费屏幕。 ## isSubdomain 函数 function isSubdomain(c, d) { this.getdomain = function(f) { var e = f.indexOf("://"); if (e > 0) { var h = f.substr(e + 3) } else { var h = f } var g = /^www./; if (g.test(h)) { h = h.substr(4) } return h }; // ... } this.getdomain 是函数内部赋值给 this 的方法——但 isSubdomain 不是用 new 调用的,this 在严格模式下是 undefined,在非严格模式下指向 window。也就是说每次 isSubdomain 调用都会污染 window.getdomain 全局——典型的 2010 年代 JS 反模式。 g = /^www./ 这个正则有 bug:点号在正则里是"任意字符",不是字面量点。www.example.com 会被匹配(点匹配任何字符),wwwa.com 也会被错误匹配("wwwa" 中的 "a" 被点号匹配)。正确写法是 /^www\./。 h.substr(4) 假定 "www." 总是 4 个字符——但如果原 URL 是 wwwa.com 被错误识别为 www.开头,substr(4) 会切掉 "wwwa",剩下 ".com" —— 后续 isSubdomain 比较时一定不匹配。 这些 bug 在 2015 年那个年代百度内部测试可能也没发现——因为业务场景非常窄(就是 www / m / wap 三个子域名互转),bug 没暴露。 ## JS 跳转的 SEO 灾难 这是原文方案最致命的问题,2026 年的 SEO 已经不能再这么做。 ## Google Mobile-First Indexing 后的内容索引规则 2018 年 Google 完成 Mobile-First Indexing 切换,主要索引依据从 PC 版改为移动版页面内容。如果 PC 和移动版分两个域名(www / m),Google 期望看到: - PC 版每个页面有 声明对应移动版 URL。 - 移动版每个页面有 声明权威 URL 是 PC 版。 - 服务端响应 vary: User-Agent 头,告诉 CDN 不同 UA 缓存不同内容。 uaredirect.js 全是 JS 跳转,三个声明一个都没有。Google 看到的情况:PC 页面没有 alternate,移动版页面(如果 Googlebot (https://zhangwenbao.com/why-googlebot-ignores-resource-hints.html) 直接访问)没有 canonical。两套页面对 Google 是完全独立的——同样的内容被拆成两份,关键词排名互相 cannibalize(自相残杀)。最终结果通常是 PC 版排名下降、m. 版根本进不了前 10。 ## JS 跳转 vs 服务端 301 跳转方式 | SEO 影响 | 用户体验 | 性能 | JS 跳转(uaredirect.js) | 严重负面(Googlebot 默认不跑 JS,留在 PC) | 白屏闪一下再跳 | 多发一次 HTTP 请求 | 服务端 301 | 权重传递清晰 | 无白屏 | 额外一跳 80–150ms | 服务端 302 | 权重不传递(用于临时跳) | 无白屏 | 同上 | 响应式设计(无跳转) | 最佳(单 URL 索引) | 无延迟 | 无额外请求 | 2026 年的最佳实践:用响应式设计单 URL 解决所有设备。如果实在要分站(比如功能差异大),用服务端 301 + alternate / canonical 三件套。 ## JS 跳转触发的 GSC 警告 Google Search Console (https://zhangwenbao.com/domain-property-vs-url-prefix-property-in-gsc-which-is-better.html) 在 Coverage 报告里会显示 JS 跳转相关的几类警告: - Page with redirect (但未跟随):Googlebot 抓取 PC 页面,发现含 location.replace 但没跟踪到目标。页面被标记为"已发现但未编入索引"。 - Crawled - currently not indexed:Googlebot 抓取了 m. 版本,但因为没有 canonical 不知道权威 URL,索引悬挂。 - Duplicate without user-selected canonical:同样内容在 www 和 m 都出现,Google 自己挑一个作 canonical(通常挑得不对)。 这些警告累积到一定数量,整个站点的"质量信号"被拉低。 ## 2026 年的现代替代方案 ## 方案 1:响应式设计(最优) 单一 URL,CSS @media query 控制布局。DedeCMS 改造响应式步骤: - 给 PC 模板的 head 加 。 - 把 fixed 宽度(width:1200px)改成 max-width + percentage(max-width:1200px; width:100%)。 - 给 nav / sidebar / table 加 @media (max-width: 768px) 的折叠规则。 - 图片用 srcset / sizes 声明多分辨率版本。 - 测试:Chrome DevTools 切换设备模拟,验证 320px / 768px / 1024px / 1920px 四档布局。 响应式改造的工作量:纯 CSS 调整通常 2–5 天(看模板复杂度)。涉及 DOM 结构重写(比如 PC 是横向 tab,移动要纵向手风琴)的 5–10 天。一次投入终生受益,不需要再维护两套模板。 ## 方案 2:服务端 UA 检测 + 301 如果一定要保留 m. 子域名(比如已积累 SEO 权重,迁移成本高),用 PHP 在服务端检测 UA 跳转: // dedecms /include/common.inc.php 开头添加 $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; $isMobile = preg_match('/(iPhone|iPod|Android.*Mobile|Windows Phone)/i', $ua); $isWeixin = preg_match('/MicroMessenger/i', $ua); $isPCDomain = stripos($_SERVER['HTTP_HOST'], 'www.') === 0; if ($isMobile && !$isWeixin && $isPCDomain) { $target = 'https://m.' . substr($_SERVER['HTTP_HOST'], 4) . $_SERVER['REQUEST_URI']; header("Vary: User-Agent"); header("Location: $target", true, 301); exit; } 关键点: - Vary: User-Agent 头告诉 CDN / 浏览器缓存按 UA 区分缓存键。否则 CDN 第一次缓存了 PC 版,后续移动用户直接拿到 PC 版(或反之)。 - 301 而不是 302——301 传递 SEO 权重,302 不传。 - 排除微信浏览器(!isWeixin),避免微信内点 PC 链接被跳转后无法正常浏览。 - 用 Android.*Mobile 而非纯 Android,避免 Android 平板被误跳。 ## 方案 3:rel="alternate" / rel="canonical" 双向声明(必加) 不管 JS 跳转还是服务端 301,都要给两版页面加双向声明。PC 版 head: m. 版 head: DedeCMS 模板里用 {dede:field name="aid"/} 或 {dede:field name="typelink"/} 拼出对应 URL。注意每个页面的 alternate / canonical 必须是 1:1 映射——首页对应首页、列表页对应列表页、详情页对应详情页,不能全站只指首页。 ## 方案 4:sitemap 分别声明 给 sitemap (https://zhangwenbao.com/tools/sitemap-generator.php) 加移动声明: https://m.example.com/page1.html 提交两份 sitemap:www.example.com/sitemap.xml(PC 页面)+ m.example.com/sitemap-mobile.xml(移动页面)。Search Console 分别添加两个 property 监控。 ## DedeCMS 响应式模板改造的具体步骤 原文是 DedeCMS 教程,给出在 DedeCMS 上做响应式改造的步骤(替代 m. 子域名方案): ## 模板路径规划 DedeCMS 模板在 /templets/yourtheme/。建议复制一份为 /templets/yourtheme-responsive/,在副本上改造,原模板保留作回滚。后台"系统 → 模板管理"切换。 ## viewport meta 与字体单位 在所有 head.htm(如果模板抽离了 head)加 viewport meta。把所有 px 字号改成 rem(基准 16px): html { font-size: 16px; } @media (max-width: 768px) { html { font-size: 14px; } } .article-title { font-size: 1.5rem; } /* 而不是 24px */ ## 栅格系统 DedeCMS 默认模板多用 table 或 float 布局。改成 flexbox 或 grid: .layout { display: grid; grid-template-columns: 1fr 300px; gap: 30px; } @media (max-width: 768px) { .layout { grid-template-columns: 1fr; } } ## 导航响应化 PC 横向菜单 → 移动汉堡菜单(前文 cid 114 的纯 CSS 折叠方案可直接用)。 ## 图片响应 DedeCMS 上传的图通常单尺寸。改造:用 dede 的 thumb 函数生成多尺寸,配合 srcset: ## 301 m. 子域名到 www(关键步骤) 响应式改造完成后,旧 m. 子域名的所有 URL 必须 301 到 www 对应 URL。nginx 配置: server { listen 80; server_name m.example.com; return 301 https://www.example.com$request_uri; } 这一步保留了之前 m. 子域名积累的 SEO 权重——301 会把权重传递给目标 URL。 ## 客户端 UA 检测的现代替代库 如果某些功能必须在客户端检测设备类型(比如 PWA 安装提示、APP 下载链接),不要再用 uaredirect.js 这种 2015 年的脚本。现代选择: - UAParser.js:体积 17KB(min+gzip),支持 200+ 设备 / 浏览器 / OS 识别。维护活跃,npm 月下载 800 万。 - Bowser:体积 12KB,API 链式调用清晰。const browser = Bowser.getParser(window.navigator.userAgent); browser.getOSName()。 - navigator.userAgentData(Client Hints):Chrome 90+ 内置 API,navigator.userAgentData.mobile 直接返回 boolean。最现代但 Safari 还不支持。 - CSS @media (pointer: coarse):不靠 UA,靠输入设备能力检测。手机 / 平板触屏 = coarse,PC 鼠标 = fine。最语义化,但只能在 CSS 用,不能 JS 检测。 建议组合:CSS @media + navigator.userAgentData,老浏览器 fallback 到 UAParser.js。 ## 百度 SiteApp 之外的同类项目历史 2012–2016 年有几个类似 SiteApp 的产品: - 百度 SiteApp:2012 上线,2018 下线。 - 360 智能建站:类似产品,2019 关停。 - Google Mobilizer:2014 推出,2014 年底关闭。 - Bing Mobile Friendly:从未推出独立服务,整合到 Bing Webmaster Tools。 这些项目的共同问题:UA-based 自动适配在响应式设计普及后失去意义,且 SEO 副作用越来越明显。"两套域名 + UA 跳转"的时代已经结束。 ## 常见问题解答 ## uaredirect.js 远程引用为什么失效? 百度 SiteApp 平台 2018 年正式下线,远程脚本 URL http://siteapp.baidu.com/static/webappservice/uaredirect.js 已经返回 404。如果还在引用这个远程地址,浏览器会等待响应直到超时(通常 30 秒),期间后续 JS 阻塞执行——表现为页面加载慢、白屏。修复方法:把脚本本地化到自己服务器(参考原文做法),或者更彻底——直接放弃 JS 跳转方案改用响应式设计。本地化只是把"加载慢"问题修了,没解决 SEO 灾难,建议彻底重构。 ## UA 检测正则 /(iPhone|iPod|Android|ios)/i 漏掉了什么? 主要漏掉:iPad(不在列表)、Windows Phone(虽然死了)、KaiOS / Tizen 等小众系统。误识别:iPad iOS 13+ 默认 UA 伪装成 Macintosh 不会被识别为移动;Android 平板(不带 Mobile 关键字的 Android)会被错误识别为手机;Chrome DevTools 模拟模式时开发者 PC 也会被识别为移动。修复:用 navigator.maxTouchPoints + window.innerWidth 配合判断,比纯 UA 可靠。或者直接放弃 JS 检测改用 CSS @media (pointer: coarse)。 ## Google Mobile-First Indexing 后还能用 m. 子域名吗? 能用,但必须做完全的双向声明。每个 PC 页面 head 加 link rel=alternate media=only screen and (max-width:640px) href=m. 对应URL;每个 m. 页面 head 加 link rel=canonical href=www. 权威 URL。响应头加 Vary: User-Agent。提交两份 sitemap(一份 PC 一份 mobile,mobile sitemap 用 mobile namespace 标记)。Search Console 添加两个 property 分别监控。任何一处缺失 Google 都可能误判内容重复。整个 setup 比响应式设计复杂得多——除非已经积累了大量 m. 域名的 SEO 权重不舍得放弃,否则推荐迁移到响应式。 ## uaredirect 跳转后能保留 query string 和 anchor 吗? 原文调用 uaredirect(window.location.href.replace(www., m.))——location.href 包含完整 URL(含 query string 和 hash),replace 只动域名部分,所以 query string 会保留。但 hash(#xxx)不会发到服务器(hash 是浏览器本地状态),如果目标 m. 站点的 SPA 路由依赖 hash,跳转后路由可能丢失。修复:在跳转 URL 拼接时显式带上 hash,let target = window.location.href.replace(www., m.) + window.location.hash;。但更稳妥的还是用响应式或服务端跳转。 ## uaredirect 与 SEO 蜘蛛识别有冲突吗? 有冲突。uaredirect.js 用 navigator.userAgent 检测移动设备——但 Googlebot Mobile 的 UA 含 Android 关键字(Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/...)。意思是 Googlebot Mobile 抓 PC 页面时会被 uaredirect 跳到 m. 域名,然后又抓 m. 页面——重复抓取浪费 crawl budget。如果 m. 页面没有 canonical 指 PC,Googlebot 会困惑。修复:在 UA 检测正则里排除 Googlebot:if (!/Googlebot|Bingbot|Baiduspider/i.test(navigator.userAgent) && /Mobile/i.test(navigator.userAgent))。但即使排除了爬虫,普通用户的跳转仍然影响 SEO(Google 用真实用户行为评估,跳转过多影响 dwell time)。 ## DedeCMS 怎么改造成响应式(不用 m. 子域名)? 六步:复制模板目录创建 -responsive 副本;所有 head 加 viewport meta,px 字号改 rem;PC 的 table/float 布局改 flexbox/grid;导航改汉堡菜单(CSS input checkbox 折叠);图片用 dede thumb 函数生成多尺寸配 srcset;老 m. 子域名 nginx 加 301 到 www. 对应 URL(保留 SEO 权重)。完整改造工作量纯 CSS 调整 2-5 天,涉及 DOM 重写 5-10 天。改完后所有设备共用一份代码,维护成本远低于双站方案。 ## 百度 siteapp 关闭后还有同类一键移动适配工具吗? 没有同类的一键转码工具。百度、Google、Bing 都关闭了云端转码服务,因为响应式设计普及让这种服务失去意义。现在的"移动适配"路径都是在源站做响应式或独立 m. 站点。如果实在要做云端转码(比如老旧 ASP 站点改造成本太高),可以考虑:用 Cloudflare Workers / Vercel Edge Functions 写一个简单的 HTML 改写代理,把 PC 版抓取后插入 viewport meta + 简单 CSS 覆盖。但这是临时方案,长期还是要做响应式。 ## JS 跳转 vs 服务端 301 性能对比? 服务端 301 单次额外耗时 80-150ms(一次 HTTP RTT + 重定向头解析)。JS 跳转更慢:浏览器先下载 PC 页面(200ms+)、解析 HTML、执行 JS(30-100ms)、再 location.replace 触发新请求(200ms+)——总耗时 400-700ms,而且白屏闪烁。SEO 上服务端 301 远胜 JS 跳转——301 是 HTTP 标准重定向,所有爬虫都正确处理;JS 跳转在 Googlebot 简单抓取模式下不执行,留在 PC 版。任何场景下,能用服务端 301 就不要用 JS 跳转。最优方案是响应式设计完全无跳转。 ## 权威参考资料 ## 织梦版ereg_replace弃用完整修复指南:2套方案+47例实战 - URL:https://zhangwenbao.com/dedecms-function-ereg-replace-is-deprecated.html - 分类:织梦CMS教程 - 发布:2017-01-31 | 更新:2026-05-16 - 摘要:为什么织梦在PHP 5.3就开始报错?为什么PHP 7直接整页崩溃?读完你会知道DEDEADMIN常量的3种修复写法、grep全站定位的命令、批量替换的sed与PhpStorm技巧、PHP 7升级要修的6类雷、临时降级的代价、什么时候必须迁移CMS的4条红线。 - 关键词:ereg_replace,preg_replace,DedeCMS,DedeCMS二开,PHP兼容性 > **TLDR**:摘要:织梦在PHP 5.3就开始报ereg_replace弃用、到PHP 7直接整页崩溃,根子是POSIX正则函数被废弃。本文按三档严重程度定位你的版本,给出Linux和Windows主机的修复写法、grep全站定位ereg_replace的命令、批量替换的sed与PhpStorm技巧,再讲PHP 7升级还有哪些雷、临时降级的代价,以及什么时候必须迁移CMS的四条红线。 > 摘要:织梦在PHP 5.3就开始报ereg_replace弃用、到PHP 7直接整页崩溃,根子是POSIX正则函数被废弃。本文按三档严重程度定位你的版本,给出Linux和Windows主机的修复写法、grep全站定位ereg_replace的命令、批量替换的sed与PhpStorm技巧,再讲PHP 7升级还有哪些雷、临时降级的代价,以及什么时候必须迁移CMS的四条红线。 2017 年前后国内主机商开始大规模把默认 PHP 版本从 5.2/5.3 升到 5.6 甚至 7.x。很多老织梦站长一觉醒来网站后台直接打不开,浏览器里赫然写着一行: Deprecated: Function ereg_replace() is deprecated in /home/wwwroot/your-site/dede/config.php on line XX 严重一点的连前台都打不开,整个网站白屏。当时我每周接到的求助里至少有三分之一是因为这个报错。这篇笔记把当时整理给客户的解决方案重新梳理一遍,包含根因、Linux/Windows 两套修复办法、PHP 7 与 PHP 8 升级路径、工程化的长期维护建议、以及若干踩坑后的经验补充。所有代码都在 PHP 5.6 / 7.0 / 7.4 / 8.1 四个环境验证过。 ## 错误的根本原因:PHP 5.3 起废弃了 POSIX 正则函数 要解决问题先得明白它怎么来的。ereg_replace 属于 PHP 早期的 POSIX 风格正则扩展(ext/ereg),从 PHP 5.3.0 开始被官方标记为 Deprecated(已废弃),到 PHP 7.0 直接被移除。被替代的是 PCRE 风格的 preg_replace,速度更快、语法更现代,是目前 PHP 唯一推荐的正则方案。 整个 POSIX 正则函数家族在不同 PHP 版本下的命运: - ereg / eregi / ereg_replace / eregi_replace / split / spliti / sql_regcase:PHP 5.3 弃用,PHP 7.0 移除 - each(不是 POSIX 但同期废弃):PHP 7.2 弃用,PHP 8.0 移除 - create_function:PHP 7.2 弃用,PHP 8.0 移除 - 魔术引号 magic_quotes_gpc:PHP 5.3 弃用,PHP 5.4 移除 DedeCMS 由于多年没大版本更新,源码里依旧大量保留 ereg_replace、split、eregi 等旧函数。一旦把这套老代码扔到 PHP 5.3 以上的主机环境,PHP 解释器就会持续抛出 Deprecated 警告。如果 php.ini 里 display_errors = On 且 error_reporting 包含 E_DEPRECATED,这些警告就会直接打印到页面,看起来就像"网站坏了"。 强调一句:这不是织梦源码本身被改坏了,也不是空间商在搞鬼,而是 PHP 语言本身的演进让老代码不兼容。 ## 三档严重程度:理解你正在面对的版本 不同 PHP 版本的报错严重度不同,应对策略也不同: - PHP 5.3-5.6:ereg_replace 仍可用,但持续抛 Deprecated 警告。功能正常,只是页面顶部冒红字。这是最温和的情况,关掉 E_DEPRECATED 输出就行。 - PHP 7.0-7.4:ereg_replace 直接抛 Fatal Error 整页崩溃。必须改代码或回退 PHP 版本。 - PHP 8.0-8.x:除了 ereg 系列还有更多函数被移除(如 each、create_function),织梦源码里出现的多个隐藏雷会同时炸,工作量翻几倍。强烈建议不要把织梦升 PHP 8。 诊断当前版本:在站点根目录建一个 phpinfo.php(用完立刻删除): 访问该文件最上面就能看到 PHP 版本号。或者用 SSH 跑 php -v。 ## Linux 主机的修复方法 绝大多数 DedeCMS 站点跑在 Linux 服务器(CentOS/Ubuntu/Debian + Nginx 或 Apache)。修复步骤: ## 定位文件 用 SFTP 或 SSH 进入服务器,定位到网站根目录下的 dede/config.php。注意 dede 这个目录名很多老站长出于安全考虑改过——比如改成了 admin8848、wjm888 等,需要按你自己的情况找对路径。 如果不确定后台目录在哪,可以 grep 整个站点: grep -rln "DEDEADMIN" /home/wwwroot/your-site/ --include="*.php" ## 核心替换:DEDEADMIN 常量定义 用编辑器打开 config.php,定位到这一行: define('DEDEADMIN', ereg_replace("[/\\]{1,}", '/', dirname(__FILE__) ) ); 替换成 PCRE 写法: define('DEDEADMIN', preg_replace("/[\/\\\\]{1,}/", '/', dirname(__FILE__) ) ); 这里有几个细节必须强调,因为我当年帮人改的时候踩过坑: - preg_replace 的正则需要使用定界符(这里用 /),而 ereg_replace 不需要。这是两套正则引擎最明显的语法差异。 - 反斜杠在 PHP 字符串里要写两层,在正则里再要一层转义,所以最终是四个反斜杠 \\\\,看起来吓人但是必须的。少一个反斜杠正则就匹配不到。 - 替换后保存,建议直接用 UTF-8 无 BOM 编码,避免中文目录路径出问题。Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 选"以 UTF-8 无 BOM 格式编码",VSCode 在底部状态栏点 UTF-8 with BOM 切换。 刷新网站 Deprecated 报错应该立刻消失。 ## 更稳妥的替代方案 如果你不想琢磨四个反斜杠的正则,最稳妥的方法其实是用 str_replace: define('DEDEADMIN', str_replace('\\', '/', dirname(__FILE__) )); 这个写法不需要正则引擎,效率更高、可读性更好,也不用担心反斜杠转义陷阱。原本 ereg_replace 的目的就是把路径里所有反斜杠或多斜杠统一成单斜杠,str_replace 完全能做到。我后来给客户的标准方案就是这个,几乎没有出错过。 ## Windows 主机的修复方法 少数站长用 Windows + IIS 主机,路径分隔符是反斜杠。这种环境下用 preg_replace 处理路径反而绕弯,更简洁的方案是直接用 strtr: define('DEDEADMIN', strtr(dirname(__FILE__), '\\/', '//')); strtr 的作用是把第一个参数里出现的反斜杠和正斜杠统一替换成正斜杠,效率比 preg_replace 高(不需要正则引擎),代码也更直观。 Windows 路径处理还有一个被忽视的点——盘符大小写。Windows 文件系统不区分盘符大小写(C:\ 和 c:\ 等价),但 PHP 字符串比较是区分的,会导致某些缓存或路径判断逻辑失效。如果你看到 Windows 织梦站偶发"路径不一致"问题,记得给 dirname 结果调用一次 strtolower。 ## 不止一处:建议全站搜索 ereg_replace 刚才修的只是 config.php 里的一处。DedeCMS 源码里其实还有很多地方在用 ereg_replace、split、eregi 等。只改这一处的话进入"采集"、"附件管理"等模块时还会冒出新的报错。推荐用 grep 全站扫一遍: # Linux 下递归搜索所有 PHP 文件中的 ereg_replace grep -rn "ereg_replace" /home/wwwroot/your-site/ --include="*.php" # 同时搜索其他已废弃函数 grep -rnE "ereg_replace|eregi|eregi_replace|split|sql_regcase|each\(|create_function" /home/wwwroot/your-site/ --include="*.php" # 输出统计 grep -rEc "ereg_replace|eregi" /home/wwwroot/your-site/ --include="*.php" | grep -v ':0$' 搜出来的每一处都要按上面的思路替换。如果替换量大,建议先在本地用 PhpStorm 这类 IDE 做正则批量替换,再上传覆盖。改完务必通过浏览器访问后台所有常用功能至少一次,确认没有新错误冒出来。 ## 批量替换的 sed 一行命令 对于熟悉 shell 的运维,sed 可以一次性把简单情况搞定(先备份再用): # 备份 cp -r /home/wwwroot/your-site/ /backup/your-site.bak.20260507/ # 简单替换:把 ereg_replace 中没有特殊字符的部分替换为 preg_replace(仅适用于不含路径分隔符的情况) find /home/wwwroot/your-site/ -name "*.php" -exec sed -i 's/ereg_replace(\("[^"\\]*"\)/preg_replace(\/\1\//g' {} \; 但是路径处理这种带反斜杠的复杂情况 sed 搞不定,必须人工修。批量改完要彻底过一遍。 ## 常见替换映射表 旧(POSIX) | 新(PCRE) | ereg($pattern, $str) | preg_match("/{$pattern}/", $str) | eregi($pattern, $str) | preg_match("/{$pattern}/i", $str) | ereg_replace($pat, $rep, $str) | preg_replace("/{$pat}/", $rep, $str) | eregi_replace($pat, $rep, $str) | preg_replace("/{$pat}/i", $rep, $str) | split($pat, $str) | preg_split("/{$pat}/", $str) | spliti($pat, $str) | preg_split("/{$pat}/i", $str) | 注意 PCRE 与 POSIX 正则语法的几个差异点:PCRE 用 \d 而 POSIX 用 [:digit:];PCRE 支持非贪婪量词(*?、+?)POSIX 不支持;PCRE 支持反向引用 (\1) 而 POSIX 写法不同。所以批量替换后要测试每条正则的实际匹配结果。 ## PHP 7 升级:除了 ereg 还有这些雷 如果你被空间商强制升级到 PHP 7+,光修 ereg 系列还不够。织梦在 PHP 7 下还会遇到这些问题: - each() 函数被废弃。织梦的某些迭代代码用了 each,PHP 7.2 弃用、PHP 8.0 移除。替换为 foreach。 - 魔术引号 magic_quotes_gpc。PHP 5.4 已移除,但织梦代码里有 get_magic_quotes_gpc() 检查,PHP 8 直接 Fatal。 - mysql_* 函数。PHP 5.5 弃用、PHP 7.0 移除。织梦的部分插件还在用,必须换成 mysqli_* 或 PDO。 - session_register / session_unregister。PHP 5.4 移除。 - 变量赋值方式 list($a, $b) = $array在某些情况下行为变化,特别是字符串拆分。 - 整型溢出行为变化。PHP 7 整型超出 PHP_INT_MAX 后变浮点而不是从 -PHP_INT_MAX-1 重新开始。 - 错误处理机制变化。PHP 7 把许多原来的 Fatal Error 改成可被 try-catch 捕获的 Throwable,但织梦的错误处理代码没适配。 这些坑相加意味着把织梦升到 PHP 7+ 的工作量大约是 50-200 工时,对老站点来说回报远低于直接迁移到现代 CMS。 ## 临时降级:在不改代码情况下"假装"修好 如果你急着应付客户而暂时没空改代码,有两个临时方案: ## 方案一:关掉 Deprecated 输出 在 php.ini 里设置: error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT & ~E_NOTICE display_errors = Off log_errors = On error_log = /var/log/php_errors.log 这样 Deprecated 警告不再打到页面但日志里仍然记录。注意:这只对 PHP 5.6 及以下版本有效。PHP 7+ 上 ereg_replace 是 Fatal Error,靠改 error_reporting 关不掉。 ## 方案二:宝塔面板降级 PHP 版本 在宝塔面板 (https://zhangwenbao.com/bt-panel-automatic-disk-mount.html)里给该站单独设置 PHP 版本为 5.6:网站 → 域名 → 设置 → PHP 版本。这是不改代码就能恢复的最快方法,但代价是失去 PHP 5.6 之后的所有性能改进和安全更新。PHP 5.6 官方支持已于 2018 年终止,2026 年再用相当于裸奔。 这两个方案都是临时的,根本解决还是改代码或迁移。 ## 工程化建议:长期维护老织梦站的几条原则 做了这么多年织梦,给手上还在维护老站点的朋友几条经验: ## PHP 版本不要超过 5.6 织梦官方早已停更,强行升 PHP 7.4 或 8.x 靠手工替换几乎修不完所有兼容问题。如果空间商强制升级,要么换主机要么考虑迁移到 Typecho、WordPress 等还在更新的 CMS。2026 年仍跑 PHP 5.6 的代价是失去近 8 年的性能与安全提升,这个账要算清楚。 ## 关闭前台的 Deprecated 输出 即使你修不完所有报错,至少在 php.ini 里设置 error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT,让普通访客看不到这些警告,避免影响 SEO(搜索引擎爬虫看到 Deprecated 字符串可能影响内容评估)。 ## 定期备份 织梦站点的备份要做到"数据库 + 上传目录 + 模板目录"三件套缺一不可。建议用 crontab 每天凌晨打包一次到对象存储: 0 3 * * * /usr/bin/mysqldump -u user -p'pass' dedb > /backup/dedb_$(date +\%Y\%m\%d).sql 0 4 * * * tar -czf /backup/site_$(date +\%Y\%m\%d).tar.gz /home/wwwroot/your-site/uploads/ ## 能静态化就静态化 织梦本身就是为静态生成而设计的,把所有列表页、详情页都生成静态 HTML 可以最大程度规避 PHP 兼容性问题——即使 PHP 报错已经生成的静态页依旧可以访问。这是织梦"老但仍能跑"的核心原因。 ## 加 WAF/CDN 屏蔽后台 织梦的安全漏洞多,建议在 Nginx/WAF 层面把 dede 目录的访问限定到固定 IP(运维办公网)。这样即使代码有漏洞攻击者也进不来: location ~ ^/dede/ { allow 你的办公IP; deny all; } ## 定期跑漏洞扫描 用 wpscan、nikto 这类开源扫描器对自己站点跑一次,发现明确漏洞立即修补或下架。织梦的高危漏洞(任意文件上传、SQL 注入)每年仍有新增 CVE。 ## 什么时候必须迁移:四个红线 说点逆耳的话。出现以下任一情况建议立即从织梦迁出: - 空间商强制升级到 PHP 8:修复工作量超过迁移工作量 - 站点曾被入侵或挂马:织梦的漏洞链太长,修一个洞下次还会被利用 - 有金融/医疗/政务类敏感数据:合规要求必须用受支持的软件 - 团队内已经没有人懂 PHP 5:维护成本指数增长 迁移的可行路径: - 内容站:迁 Typecho(最轻量)或 WordPress(生态最广) - 电商类:迁 Shopify (https://zhangwenbao.com/shopify-collection-pagination-seo-guide.html) 或 WooCommerce - 门户类:考虑 headless CMS(Strapi、Directus)+ 静态站点生成器 - 政府/事业单位站:迁国产开源 CMS(如 PHPCMS V9 的开源分支) 迁移成本一次性高(5-50 万元不等,看站点规模),但长期来看是负债清算,越拖代价越大。 ## 常见问题解答 ## 替换后保存上传,前台仍然报错怎么办? 先确认两件事。第一,你修改的是网站根目录下真正在跑的 config.php,而不是本地下载的副本。SFTP 上传完一定要在服务器端 cat 一下确认改了。第二,你的服务器是否有 OPcache 缓存——如果有,需要重启 PHP-FPM 或者执行 opcache_reset() 让修改生效。宝塔面板可以直接在 PHP 设置里点重启。 ## 能不能直接关掉报错,不修代码? 技术上可以,把 error_reporting 设置成 E_ALL 异或 E_DEPRECATED 就能让 Deprecated 警告不再输出。但不推荐这么做,因为 PHP 7.0 开始这些函数会变成 Fatal Error,到时候连关都关不掉,必须改代码。最好趁早把代码改对,不要把问题往后推。 ## DedeCMS 还值得继续用吗? 实话说不太建议继续用于新站。它最后一次较大的安全更新是 2014 年了,已知的漏洞在外部扫描器上一抓一大把。老站如果短期没法迁移,可以选择关闭后台公网访问加静态化前台加定期人工巡检的姑息方案。新站请优先考虑 WordPress、Typecho 或其他活跃维护的 CMS。继续在织梦上做新建站的 ROI 已经不存在。 ## 替换 ereg_replace 之后还会遇到 split() 报错吗? 会。split 也是 POSIX 正则系列,PHP 5.3 起被废弃,PHP 7 移除。替换思路一样:把 split($pattern, $string) 改成 preg_split('/'.$pattern.'/', $string),注意补上正则定界符。织梦源码里 split 用得也不少,建议跟 ereg_replace 一起用 grep 一次性扫出来全改。 ## 用 str_replace 代替 preg_replace 行不行? 对于路径分隔符这种简单替换完全可以,性能反而更好。原文里 ereg_replace 的目的就是把多个反斜杠或正斜杠统一成单斜杠,用 str_replace('\\', '/', $path) 也能做到。但如果原代码用了真正的正则模式(带 ., *, [a-z] 等),那就必须用 preg_replace 不能用 str_replace。改之前要看清楚原正则的真实用途。 ## PhpMyAdmin 也报 ereg_replace 怎么办? 那是你装的 phpMyAdmin 版本太老。phpMyAdmin 4.x 之后已经全部用 PCRE,不会有这个问题。直接到 phpmyadmin.net 下载最新版替换 admin 目录即可。这种情况下不要去改 phpMyAdmin 源码——升级版本是更干净的解决方案。 ## 修复完后台能进了,但生成静态页时报错怎么办? 织梦的"生成"功能调用了多个底层模块(include 目录下),可能那些模块里也有 ereg_replace 没修完。建议跑一遍 grep -rn ereg_replace include/ 把这个目录里的所有出现都改完。include 目录是织梦的核心库,几乎所有功能都依赖它,是 ereg_replace 出现密度最高的位置。 ## 2026 年还有多少站在跑老织梦? 没有官方数据,但从我自己接到的咨询量估算,国内 2026 年仍在生产环境跑 DedeCMS 的站点至少在 5-10 万家级别。多数集中在地方门户、企业站、教育系统、政府单位站。这些站普遍长期没人维护,被攻破后用作矿池或代理跳板的案例每月都有。如果你正好在维护这类站,强烈建议尽快制定迁移计划——继续打补丁的边际成本一年比一年高。 ## 权威参考资料 ## DedeCMS批量处理SQL实战指南:dede:sql调用、JOIN替换、分批UPDATE与时间字段同步 - URL:https://zhangwenbao.com/dedecms-commonly-used-batch-sql-statements.html - 分类:织梦CMS教程 - 发布:2017-01-29 | 更新:2026-06-02 - 摘要:DedeCMS批量改数据,绕不开拆表模型和动态参数限制。本文讲解dede:sql标签语法的绕过、archives与addonarticle拆表下的JOIN批量替换、百万行分批UPDATE防锁、三个时间字段一致性、从正文抓首图配缩略图,再附备份回滚和最小权限用户。 - 关键词:批量替换,DedeCMS SQL,DedeCMS批量处理,dede:sql标签,dede_archives > **TLDR**:摘要:DedeCMS批量改数据,绕不开拆表模型和dede sql标签的动态参数限制。本文先讲执行SQL前的强制备份流程,再给dede sql标签的现代用法、archives与addonarticle拆表下的JOIN批量替换、标题正文描述的批量修改、三个时间字段的批量重置、栏目批量操作、从正文抓首图配缩略图,以及索引重建和SQL注入防御。 > 摘要:DedeCMS批量改数据,绕不开拆表模型和dede sql标签的动态参数限制。本文先讲执行SQL前的强制备份流程,再给dede sql标签的现代用法、archives与addonarticle拆表下的JOIN批量替换、标题正文描述的批量修改、三个时间字段的批量重置、栏目批量操作、从正文抓首图配缩略图,以及索引重建和SQL注入防御。 DedeCMS 数据量上万篇之后,所有“批量改标题、批量替换关键词、批量重置发布时间、批量修复 URL”的需求都不可能用后台 UI 完成——必须直接写 SQL。织梦的 dede:sql 标签让模板里就能跑查询,后台“系统-SQL 命令行工具”让你能直接执行任意语句。但很多老教程的代码用的是 dede_archives 这种已经过时的字段(DedeCMS 5.7 之后部分字段拆到 dede_addonarticle 了),跑下去会拿不到数据或者只改一半。本文按场景重新整理:dede:sql 标签的现代用法、批量改正文与标题的 JOIN 写法、备份与回滚、避免锁表的分批 UPDATE、跨字段一致性、SQL 注入防御、与现代 MySQL 8 的兼容。 ## 执行 SQL 前的强制流程 ## 三件必做的事 所有批量 SQL 在生产库上执行前必须完成: - 完整备份数据库:mysqldump 整库 + gzip,保存到非站点目录与异地。具体命令:mysqldump --single-transaction --hex-blob -u root -p dedecms_db | gzip > /backup/dedecms_$(date +%Y%m%d_%H%M%S).sql.gz。 - 本地副本演练:把备份恢复到一台测试机,先在测试机跑一遍要执行的 SQL,确认结果符合预期。 - 开事务封装:能用 BEGIN/COMMIT 封装的尽量包起来。BEGIN; UPDATE ...; -- 检查影响行数 -- COMMIT 或 ROLLBACK。注意 DDL(ALTER TABLE)不能回滚,只能 mysqldump 兜底。 ## SQL 命令行工具的位置 DedeCMS 后台“系统-系统设置-SQL 命令行工具”是默认入口。某些二开版本会禁用这个功能(管理员权限不足),可以直接登录服务器用 mysql CLI: mysql -u dedecms_user -p dedecms_db 命令行执行历史会落到 ~/.mysql_history,事后能溯源。 ## dede:sql 标签的语法与现代用法 ## 基础语法 {dede:sql sql="SELECT id, title FROM dede_archives WHERE channel=1 ORDER BY id DESC LIMIT 10"} [field:title /] {/dede:sql} 三个关键点: - SQL 必须放在 sql="" 的引号内,内容里不能再有相同引号(用 " 或者反引号代替)。 - 字段必须在 SELECT 列表里出现,模板才能用 [field:xxx /] 引用。 - WHERE 条件能写但不能用变量,sql 标签不接受动态参数(绕过方法见下)。 ## 动态参数的限制与绕过 原生 dede:sql 不支持模板变量做参数,比如下面这种写法 不工作: {dede:sql sql="SELECT * FROM dede_archives WHERE typeid={$typeid}"} 绕过方法是改 PHP 模板里直接用 $dsql 对象: SetQuery("SELECT * FROM dede_archives WHERE typeid=$typeid LIMIT 10"); $dsql->Execute(); while ($row = $dsql->GetArray()) { echo "{$row['title']}"; } ?> 务必 intval 强转参数防止 SQL 注入。 ## 常用查询模板 ## 调用某会员发布的文章 {dede:sql sql="SELECT id, title, pubdate FROM dede_archives WHERE mid=1 AND arcrank=0 ORDER BY id DESC LIMIT 10"}
  • [field:title /] [field:pubdate function="MyDate('Y-m-d', @me)" /]
  • {/dede:sql} 多了 arcrank=0 过滤未审核文章,pubdate 字段做日期格式化。 ## 调用最新评论 {dede:sql sql="SELECT f.id, f.aid, f.username, f.msg, a.title FROM dede_feedback f LEFT JOIN dede_archives a ON a.id = f.aid WHERE f.ischeck=1 ORDER BY f.id DESC LIMIT 8"}
  • [field:title /] [field:username /] : [field:msg function="cn_substr(@me, 30)" /]
  • {/dede:sql} ## 会员积分排行 {dede:sql sql="SELECT mid, userid, uname, scores FROM dede_member WHERE matt=0 ORDER BY scores DESC LIMIT 10"}
    [field:uname /] 积分:[field:scores /]
    {/dede:sql} ## 分类统计 {dede:sql sql="SELECT typeid, typename, COUNT(*) AS cnt FROM dede_archives a INNER JOIN dede_arctype t ON t.id=a.typeid WHERE arcrank=0 GROUP BY typeid ORDER BY cnt DESC LIMIT 5"}
  • [field:typename /] ([field:cnt /] 篇)
  • {/dede:sql} ## 常用统计聚合 {dede:sql sql="SELECT COUNT(*) AS c FROM dede_archives WHERE channel=1 AND arcrank=0"}已发表文章:[field:c /] 篇{/dede:sql} {dede:sql sql="SELECT COUNT(*) AS c FROM dede_archives WHERE channel=2 AND arcrank=0"}图集:[field:c /] 个{/dede:sql} {dede:sql sql="SELECT COUNT(*) AS c FROM dede_feedback WHERE ischeck=1"}已审评论:[field:c /] 条{/dede:sql} {dede:sql sql="SELECT COUNT(*) AS c FROM dede_member"}注册会员:[field:c /] 名{/dede:sql} {dede:sql sql="SELECT SUM(click) AS c FROM dede_archives"}文章总点击:[field:c /] 次{/dede:sql} {dede:sql sql="SELECT COUNT(*) AS c FROM dede_archives WHERE pubdate > UNIX_TIMESTAMP(CURDATE())"}今日新增:[field:c /] 篇{/dede:sql} ## 批量修改类 SQL:标题、正文、描述 ## DedeCMS 数据存储的拆分模型 DedeCMS 5.7 把文章拆成两张表: - dede_archives:标题、关键词、描述、栏目、发布时间、点击量等元信息。 - dede_addonarticle:正文 body、redirecturl 等大字段。 批量改正文要操作 addonarticle,批量改标题要操作 archives。两表通过 archives.id = addonarticle.aid 关联。 ## 批量替换标题中的关键词 UPDATE dede_archives SET title = REPLACE(title, '保哥笔记', '张文保博客') WHERE title LIKE '%保哥笔记%'; WHERE 子句里加 LIKE 限定能减少不必要的全表扫描,特别是 title 没建索引时。 ## 批量替换正文中的关键词 UPDATE dede_addonarticle SET body = REPLACE(body, '原关键词', '新关键词') WHERE body LIKE '%原关键词%'; 注意 body 是 mediumtext 类型,1600 万字符上限。如果你的替换会让正文长度大幅增加(比如把 5 字短词替换成 50 字长句),先估算字段是否会溢出。 ## 批量替换描述与摘要 UPDATE dede_archives SET description = REPLACE(description, '原词', '新词') WHERE description LIKE '%原词%'; ## 跨表条件批量改 “替换正文里包含 SEO 博客的所有文章的标题”需要 JOIN: UPDATE dede_archives a INNER JOIN dede_addonarticle ad ON ad.aid = a.id SET a.title = REPLACE(a.title, '保哥笔记', '张文保博客') WHERE ad.body LIKE '%SEO博客%'; ## 大表分批 UPDATE 避免锁表 百万行级别的 archives 表上跑 UPDATE 会触发表锁,业务请求几分钟内 502。分批写法: -- 每批 1000 行,循环执行直到没有匹配 UPDATE dede_archives SET keywords = '' WHERE keywords LIKE '%旧词%' LIMIT 1000; SQL 命令行工具不能写循环,需要在命令行 mysql 里写 shell 脚本: while true; do AFFECTED=$(mysql -u user -p"pass" dedecms_db -N -e " UPDATE dede_archives SET keywords='' WHERE keywords LIKE '%旧词%' LIMIT 1000; SELECT ROW_COUNT(); ") if [ "$AFFECTED" = "0" ]; then break; fi echo "Updated $AFFECTED rows, sleeping..." sleep 1 done 每批之间 sleep 1 秒让其它查询有机会插队,避免长时间持锁。 ## 时间字段的批量重置 ## 三个时间字段的关系 dede_archives 有三个时间字段: - pubdate:发布时间,前端按这个排序。 - senddate:入库时间,作者发文章的实际时间。 - sortrank (https://zhangwenbao.com/method-to-dedecms-article-set-a-dream-day.html):自定义排序权重,多数情况等于 pubdate。 SEO 优化中常需要把老文章的 pubdate 拉新让搜索引擎重新抓取。但要保持 senddate 真实记录历史。 ## 把指定 typeid 文章的 pubdate 重置为今天 UPDATE dede_archives SET pubdate = UNIX_TIMESTAMP(NOW()), sortrank = UNIX_TIMESTAMP(NOW()) WHERE typeid = 5 AND pubdate < UNIX_TIMESTAMP('2020-01-01'); 不要动 senddate,作为历史溯源依据。 ## 批量按比例重新分布发布时间 采集来的文章如果 pubdate 都堆在同一天,前台分页会很丑。用随机时间打散: UPDATE dede_archives SET pubdate = UNIX_TIMESTAMP(NOW()) - FLOOR(RAND() * 86400 * 30), sortrank = pubdate WHERE typeid = 5; 这条把 typeid=5 的所有文章 pubdate 随机分布到过去 30 天内。86400 是一天的秒数。 ## 栏目相关批量操作 ## 修改栏目动静态 -- 全部改为静态 UPDATE dede_arctype SET isdefault = 1; -- 全部改为动态 UPDATE dede_arctype SET isdefault = -1; -- 仅改某父栏目下的子栏目 UPDATE dede_arctype SET isdefault = -1 WHERE reid = 10; 动态栏目(isdefault=-1)每次访问实时查 DB;静态栏目(isdefault=1)需要在后台“生成-栏目 HTML”后才更新。SEO 角度静态更友好(缓存命中率高),但维护成本高(每次发新文章要重生成)。 ## 批量移动文章到新栏目 -- 把 typeid=10 的所有文章移到 typeid=20 UPDATE dede_archives SET typeid = 20 WHERE typeid = 10; -- 同时更新 dede_arctiny 这张缓存表 UPDATE dede_arctiny SET typeid = 20 WHERE typeid = 10; dede_arctiny 是 archives 的精简缓存版(只保留 id、typeid、channel、arcrank 等),生成静态页时用。改 archives 不改 arctiny 会造成“栏目页能看到文章但点进去找不到”。 ## 批量删除空栏目 DELETE FROM dede_arctype WHERE id NOT IN (SELECT DISTINCT typeid FROM dede_archives) AND id NOT IN (SELECT DISTINCT reid FROM dede_arctype WHERE reid > 0); 避免删到当前是其它栏目父级的栏目。 ## 批量处理图片与缩略图 ## 批量替换图片域名 站点搬家或者 CDN (https://zhangwenbao.com/cdn-edge-caching-strategy-ttl-cache-control-purge-origin-shield.html) 切换后,正文里可能写死了旧域名的图片 URL: -- 替换 archives 表 litpic(缩略图)字段 UPDATE dede_archives SET litpic = REPLACE(litpic, 'http://oldcdn.example.com/', 'https://newcdn.example.com/') WHERE litpic LIKE 'http://oldcdn.example.com/%'; -- 替换 addonarticle 表 body(正文)字段中的图片 URL UPDATE dede_addonarticle SET body = REPLACE(body, 'http://oldcdn.example.com/', 'https://newcdn.example.com/') WHERE body LIKE '%oldcdn.example.com%'; ## 批量给文章配缩略图(自动从正文取首图) 很多老文章 litpic 字段是空的,前台缩略图位显示为占位符。从正文里抓首张 img 地址: UPDATE dede_archives a INNER JOIN dede_addonarticle ad ON ad.aid = a.id SET a.litpic = SUBSTRING_INDEX(SUBSTRING_INDEX(ad.body, 'src="', -1), '"', 1) WHERE a.litpic = '' AND ad.body LIKE '% UNIX_TIMESTAMP('2023-01-01') AND dtime < UNIX_TIMESTAMP('2023-12-31'); ## 清理评论中的常见垃圾词 DELETE FROM dede_feedback WHERE ischeck = 0 AND (msg LIKE '%viagra%' OR msg LIKE '%cialis%' OR msg LIKE '%casino%' OR msg LIKE '%http://%' OR msg LIKE '%https://%'); 未审核评论里出现外链 (https://zhangwenbao.com/google-seo-link-building-strategies.html) URL 的,99% 是 spam。 ## 会员相关批量操作 ## 批量删除未激活的注册 DELETE FROM dede_member WHERE matt = 10 -- 未审核 AND jointime < UNIX_TIMESTAMP() - 86400 * 30; -- 注册超过 30 天 ## 批量重置会员密码 不到万不得已不要操作,会让所有用户必须找回密码。如果是清测试库可以: UPDATE dede_member SET pwd = MD5('reset123'); DedeCMS 密码哈希是直接 MD5(password) 不带 salt,所以可以这么写。 ## 会员积分批量调整 -- 给所有会员加 100 积分 UPDATE dede_member SET scores = scores + 100; -- 给注册超过 1 年的老用户加 500 积分 UPDATE dede_member SET scores = scores + 500 WHERE jointime < UNIX_TIMESTAMP() - 86400 * 365; ## 自定义字段与扩展属性 ## 给文章添加自定义属性 INSERT INTO dede_arcatt (sortid, att, attname) VALUES (9, 'd', '评论'); ALTER TABLE dede_archives MODIFY flag SET('c','h','p','f','s','j','a','b','d') DEFAULT NULL; 这个改动让你能在文章编辑页勾选“评论”属性。 ## 批量给老文章打属性 -- 给点击量超过 10000 的文章打“热门 h”属性 UPDATE dede_archives SET flag = CONCAT_WS(',', NULLIF(flag, ''), 'h') WHERE click > 10000 AND (flag IS NULL OR flag NOT LIKE '%h%'); -- 给最近 7 天文章打“最新 f”属性 UPDATE dede_archives SET flag = CONCAT_WS(',', NULLIF(flag, ''), 'f') WHERE pubdate > UNIX_TIMESTAMP() - 86400 * 7 AND (flag IS NULL OR flag NOT LIKE '%f%'); ## 批量同步与索引重建 ## 修复 dede_arctiny 与 dede_archives 不一致 arctiny 是 archives 的索引缓存,长期使用后可能丢失同步。重建: TRUNCATE TABLE dede_arctiny; INSERT INTO dede_arctiny (id, typeid, typeid2, arcrank, channel, senddate, sortrank, mid) SELECT id, typeid, typeid2, arcrank, channel, senddate, sortrank, mid FROM dede_archives; ## 重建栏目文章数缓存 栏目页显示“本栏共 N 篇”如果不准,更新栏目计数: UPDATE dede_arctype t LEFT JOIN ( SELECT typeid, COUNT(*) AS cnt FROM dede_archives WHERE arcrank=0 GROUP BY typeid ) AS s ON s.typeid = t.id SET t.smalltypes = IFNULL(s.cnt, 0); ## SQL 注入防御与安全实践 ## dede:sql 标签自带的限制 DedeCMS 的 SQL 命令行工具默认禁用了一些危险动作(DROP DATABASE、TRUNCATE 部分系统表)。但 UPDATE / DELETE 是不限制的,所有破坏性命令都能跑。 ## 权限分离 给 DedeCMS 的 MySQL 用户分配最小权限,不要用 root: GRANT SELECT, INSERT, UPDATE, DELETE ON dedecms_db.* TO 'dedecms_user'@'localhost' IDENTIFIED BY 'strong_password'; REVOKE DROP, ALTER, GRANT OPTION ON dedecms_db.* FROM 'dedecms_user'@'localhost'; 这样即便有人 SQL 注入也 DROP 不了表。需要 ALTER TABLE 时切到 root 临时执行再切回去。 ## 审计日志 MySQL 8.0+ 启用 general log 记录所有 SQL: SET GLOBAL general_log = 'ON'; SET GLOBAL general_log_file = '/var/log/mysql/general.log'; 事后排查时能精确知道哪个时间点执行了什么 SQL。生产环境注意 general log 会让性能下降 20%,仅在调查问题时短期开启。 ## MySQL 8 兼容性 ## 保留字变化 MySQL 8 新增了几十个保留字,如 RANK、ROW、GROUPS。如果你的字段名跟这些撞了,要加反引号: SELECT `rank` FROM dede_archives; ## 默认认证插件 MySQL 8 默认 caching_sha2_password,老 PHP 客户端连不上。两种方案:升级 PHP 到 7.4+ 用新的 mysqlnd;或者在 MySQL 8 配置里改回 mysql_native_password: ALTER USER 'dedecms_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'pass'; ## UTF8MB4 全面替代 UTF8 MySQL 8 推荐 utf8mb4,老 DedeCMS 表是 utf8。可能在插入 emoji 字符时插不进去。批量转换: ALTER DATABASE dedecms_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE dede_archives CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 对每张表执行同样的 CONVERT ## 常见故障 ## 故障 1:UPDATE 后受影响行数为 0 多数是 WHERE 条件没匹配。先用 SELECT 验证:SELECT COUNT(*) FROM dede_archives WHERE 你的条件,看返回数字。 ## 故障 2:UPDATE 报“Lock wait timeout exceeded” 另一个事务正在锁这些行。看进程:SHOW PROCESSLIST; 找到长时间运行的事务,KILL 终止。或者增大 innodb_lock_wait_timeout。 ## 故障 3:执行后前台没生效 DedeCMS 有“文档 HTML”与“栏目 HTML”两层缓存。改完 SQL 必须在后台“生成”菜单里点“更新文档 HTML”与“更新主页 HTML”让缓存重生成。 ## 故障 4:REPLACE 替换后多出乱码 字符集不一致导致。SET NAMES utf8mb4 之后再执行 REPLACE。 ## 故障 5:dede:sql 标签输出空白 三个排查:SQL 在命令行直接跑能否拿到数据;模板里 [field:xxx /] 字段名是不是与 SELECT 列表完全一致;arcrank 过滤是不是把所有文章都筛掉了。 ## 故障 6:批量改完发布时间排序乱了 没同步更新 sortrank。dedeCMS 的排序优先级是 sortrank > pubdate。改 pubdate 时务必同步改 sortrank:UPDATE dede_archives SET pubdate=X, sortrank=X。 ## 常见问题解答 ## SQL 命令行工具被禁用了怎么办? 多数二开版本会出于安全考虑禁用 SQL 命令行。直接用 phpMyAdmin 或者 SSH 进服务器跑 mysql CLI 都能替代。如果你必须从后台跑,找到 dede/sys_sql_query.php 检查权限校验代码是否被篡改。 ## 批量替换时如何只改正文不改标题? 用具体的 UPDATE 表名+字段:UPDATE dede_addonarticle SET body = REPLACE(body, 'a', 'b') 只改 addonarticle 表的 body 字段。其它字段不会被改。 ## UPDATE 跑了一半中断了,怎么知道哪些已经改了? 没有简单方法。这就是为什么必须先 mysqldump 备份。如果中断了,先 mysqldump 现状,再 diff 与原始备份,能看出哪些已改。复杂场景建议加自定义 status 字段标记:ALTER TABLE dede_archives ADD COLUMN _migrated TINYINT DEFAULT 0; UPDATE dede_archives SET title=..., _migrated=1 WHERE _migrated=0 LIMIT 1000; ## 大批量 SQL 执行后数据库变得很慢? UPDATE 大表后 InnoDB 索引可能碎片化。OPTIMIZE TABLE dede_archives 重建索引。生产库的 OPTIMIZE 会锁表几分钟,建议低峰期执行。 ## 数据库被填满怎么办? 多数是 dede_search_keywords 表(搜索关键词记录)疯涨。SELECT TABLE_NAME, DATA_LENGTH/1024/1024 AS MB FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='dedecms_db' ORDER BY DATA_LENGTH DESC LIMIT 10; 看哪张表占空间。TRUNCATE TABLE dede_search_keywords 清掉历史记录。 ## 能否用 SQL 跨字段批量推送到搜索引擎? 不能直接 SQL 推。但 SQL 可以筛出待推送 URL 清单导出 csv,再用 Python 调百度推送 API:SELECT CONCAT('https://example.com/article/', id, '.html') FROM dede_archives WHERE pubdate > UNIX_TIMESTAMP() - 86400 INTO OUTFILE '/tmp/urls.csv'。 ## UPDATE 一次改太多行触发 binlog 撑爆磁盘? 分批 UPDATE 是更优方案。或者临时关 binlog:SET sql_log_bin = 0; UPDATE ...; SET sql_log_bin = 1;。注意关闭 binlog 后改的内容不会同步到 slave 与备份系统。 ## 已备份的 mysqldump 怎么验证完整性? 把它恢复到一个空的测试库:mysql -u test_user -p test_db < backup.sql。如果导入过程没报错就是完整的。 ## SQL 命令行工具支持注释吗? 支持。MySQL 三种注释语法都可以:-- xxx、# xxx、/* xxx */。注释在 SQL 命令行历史中方便事后回看自己的意图。 ## 能不能写存储过程批量处理? 可以。但 DedeCMS 的 SQL 命令行工具多数禁用了 DELIMITER 关键字(必须用它来定义存储过程)。改用命令行 mysql CLI 或者把存储过程定义放到外部 .sql 文件 source 进来。 ## 权威参考资料 ## DedeCMS后台验证码错误终极排查指南:从GD库到sessions_xxx升级残留全覆盖 - URL:https://zhangwenbao.com/a-dedecms-background-verification-solution-error-code.html - 分类:织梦CMS教程 - 发布:2017-01-28 | 更新:2026-06-02 - 摘要:织梦后台验证码识别不了,得按状态码分类排查。本文从vdimgck.php的验证流程切入:500多半是GD扩展没启用、403可能是nginx deny或SELinux标签错、200但断流大概率是BOM污染。提交错则深挖到session升级残留双写、cookie SameSite等五种成因,附关闭验证码的加固方案。 - 关键词:DedeCMS验证码,DedeCMS安全,session权限,GD扩展,DedeCMS 5.7 > **TLDR**:摘要:织梦后台验证码识别不了,得按现象分类排查。本文从vdimgck.php的工作原理切入,逐一拆五种故障——图片根本不显示多半是GD扩展没启用、图片显示但提交永远报错、磁盘满导致session写入失败、插件二开把session启动顺序搅乱、MySQL连接慢导致超时,再给data目录搬家的特殊场景、PHP版本兼容的子故障,以及兜底的关闭验证码加固。 > 摘要:织梦 (https://zhangwenbao.com/dedecms-batch-modify-article-naming-rules.html)后台验证码识别不了,得按现象分类排查。本文从vdimgck.php的工作原理切入,逐一拆五种故障——图片根本不显示多半是GD扩展没启用、图片显示但提交永远报错、磁盘满导致session写入失败、插件二开把session启动顺序搅乱、MySQL连接慢导致超时,再给data目录搬家的特殊场景、PHP版本兼容的子故障,以及兜底的关闭验证码加固。 DedeCMS 后台登录验证码错误这个问题,几乎每个搞过织梦运维的人都撞过一两回。最难的不是修,是先判断到底是哪一类故障——同样一句“验证码不对”背后可能是会话目录权限错了、可能是 GD 库压根没装、可能是升级遗留了一个名字带乱码后缀的会话目录、也可能纯粹是浏览器 Cookie 被 SameSite 限制。本文把 DedeCMS 5.7 / V5.7 SP1 / V5.7 SP2 / V5.7 UTF8 各版本下我亲自处理过的验证码故障案例汇总成一份排查清单,配套给到具体代码改动与命令。 ## DedeCMS 验证码工作原理速览 要修验证码,得先知道 DedeCMS 这套机制是怎么走的。整个链路有四步: - 浏览器请求登录页 dede/login.php,页面 HTML 里有一个 的标签。 - 浏览器请求 vdimgck.php,这个脚本随机生成 4 位验证码字符串,调用 GD 库画到 PNG 图里返回;同时把字符串写进 PHP session 的 $_SESSION['svali']。 - 用户填完表单提交,login.php 拿表单里的 validate 与会话里的 svali 比较,相同就放行。 - 会话依赖 PHP session,session 文件存放路径由 data/common.inc.php 与 include/common.inc.php 里的 session_save_path 决定。 这条链条上任何一个环节断掉都会触发“验证码错误”或“不显示”。下面按断点位置分别讲。 ## 故障一:验证码图片根本不显示 登录页加载完,输入框旁边只有一个红叉或裂图占位符,这是图片请求本身失败了。打开浏览器开发者工具的 Network 面板,找 vdimgck.php 那一行,看 HTTP 状态码: ## 500 Internal Server Error 大概率是 PHP GD 扩展没装或没启用。在服务器执行 php -m | grep -i gd,如果没有 gd 输出,就是缺扩展。修复手段按系统不同: - CentOS 7:yum install php-gd,然后 systemctl restart php-fpm(或 httpd)。 - Ubuntu/Debian:apt install php7.4-gd(按你的 PHP 版本)。 - 宝塔面板 (https://zhangwenbao.com/bt-panel-upgrade-failed.html):软件商店 - PHP - 设置 - 安装扩展,勾选 gd 后重启 PHP。 - Windows IIS:打开 php.ini,把 ;extension=gd 前面的分号去掉;老版本 Windows 上是 extension=php_gd2.dll。重启 IIS。 一个容易忽略的细节:如果你装了多个 PHP 版本(宝塔常见情况),网站绑定的 PHP 版本和命令行 php -m 看到的可能是两个不同的 ini 文件。修改前先确认网站绑定的具体版本。 ## 403 Forbidden vdimgck.php 文件存在但被拒绝访问。两个常见原因: - nginx/apache 配置里把 include/ 目录整个 deny 了。多数织梦安全加固教程会建议“锁死 include 目录禁止外部访问”,但如果做到把 vdimgck.php 也锁了就会触发这个问题。修复方式是改 nginx 配置加白名单:location = /include/vdimgck.php { allow all; }。 - SELinux 策略阻拦。CentOS 7 默认开启的 SELinux 会把网站目录标记为 httpd_sys_content_t,但如果你拷文件进来时打破了这个标签,httpd 进程访问就会被拒。修复:restorecon -Rv /var/www/html/include/。 ## 200 OK 但图片仍然不显示 状态码正常但浏览器渲染不出来。点开 Response 面板看返回内容: - 如果是一段 PHP Warning 或 Notice 文本(比如“Cannot modify header information”),说明 PHP 在 imagepng() 之前已经输出了内容,破坏了 PNG 头。常见原因是某个被 require 的文件存了 BOM。用 dos2unix 或专门的 BOM 清理工具处理 data/common.inc.php 与 include/common.inc.php。 - 如果返回是空的,可能是 imagepng 输出失败,去看 PHP error_log,搜 vdimgck.php 关键词。 ## 故障二:图片显示,但提交永远报“验证码错误” 这是最常见也最难判断的一种。验证码画出来了,输得也对,但 login.php 一比对就说错。本质都是“图片端写的 svali”和“登录端读到的 svali”不在同一个 session 里。原因可以拆成五种: ## 原因 A:session.save_path 目录没有写入权限 php.ini 里 session.save_path = "/tmp"(Linux 默认)或 "C:\Windows\Temp"(Windows 默认)。如果目录不存在或当前 PHP 进程对它没有写权限,session_start() 会失败,写入的 svali 拿不到。 排查命令: php -r 'echo session_save_path();' ls -ld $(php -r "echo session_save_path();") 看 owner 与 mode。CentOS 7 上 php-fpm 默认运行用户是 apache 或 nginx,必须保证目录至少有 drwx-wx-wx(1733 或 1777)权限。 ## 原因 B:DedeCMS 自定义的 session 路径权限不对 这是织梦特有的一个坑。include/common.inc.php 里有这么一段: $enkey = substr(md5(substr($cfg_domain_cookie,0,5)),0,10); $sessSavePath = DEDEDATA."/sessions_{$enkey}"; if ( !is_dir($sessSavePath) ) mkdir($sessSavePath); if(is_writeable($sessSavePath) && is_readable($sessSavePath)) { session_save_path($sessSavePath); } 它会根据域名 cookie 名称的 md5 值生成一个像 sessions_8ab3842ff8 这种名字的目录,然后切换 session 路径。这套设计的初衷是让多个站点共享 PHP-FPM 也不会串 session,但在以下三种场景会反复出问题: - data/ 目录权限是 755 或 700,PHP 进程没法在里面 mkdir,路径切换失败回退到默认 /tmp,但默认 /tmp 也可能被其它原因清空。 - 站点经历过迁移,data/ 目录从一台主机搬到另一台,新主机的 PHP 进程用户与旧机不同,sessions_xxx 目录的 owner 还是旧用户,新进程 is_writeable 返回 false,路径切换失败但与默认 /tmp 也不连通。 - 同一站点先后用过 cfg_domain_cookie 不同的设置(比如先空再填了主域名),之前生成的 sessions_xxx 目录留在那里没人理,新生成另一个名字。重复的目录会让维护者困惑哪个才是当前用的。 修复方式分两类: 临时修复(最快):chmod -R 755 data/ 后 chmod -R 777 data/sessions_*,登录看是否好转。如果一时找不到对应的 sessions_xxx 目录,就把 data/ 下所有 sessions 开头的目录全部 777 一次。 根治:把 include/common.inc.php 里 $sessSavePath = DEDEDATA."/sessions_{$enkey}" 改成 $sessSavePath = DEDEDATA."/sessions",永远走单一目录。改完之后建 data/sessions 目录并赋权。这种改法最大的好处是后续无论域名怎么调整,session 都不会因为目录名漂移而失效。 ## 原因 C:升级遗留的 sessions_xxx 与新 sessions_yyy 互相覆盖 原文里提到过这个场景,再展开一下:DedeCMS 从 5.6 升到 5.7 SP2 这个跨版本路径上,common.inc.php 的 enkey 算法没变,但 cfg_domain_cookie 默认值有差异。结果就是升级后会用新的 sessions_yyy 目录,老的 sessions_xxx 还在那里。 问题出在 DedeCMS 的某些场景(比如某些插件、某些 hooks)会硬写 svali 到老目录。表现就是“图片画的验证码写到老目录,登录读 svali 时去新目录拿——空的,永远对不上”。 判断方式:登录失败后立刻去 data/ 目录看哪个 sessions 目录有最新文件。 find data/sessions* -type f -mmin -2 -ls 如果命中两个不同 sessions_xxx 目录都有最近 2 分钟内修改的文件,就是双写场景,必须把 common.inc.php 改成单一目录解决。 ## 原因 D:cookie 域写错或 SameSite 拦了 PHP session 的会话 ID 通过 PHPSESSID cookie 在浏览器与服务器间传递。cookie 域 path 写错或者 SameSite 限制都会让浏览器不再发送 PHPSESSID,下一次请求 server 就拿不到 session。 具体几种触发: - 站点从 http 切换到 https 但 cookie 还设了 secure=false,跨协议被丢。 - 站点用了多个二级域名(比如登录页在 admin.example.com,验证码图请求在 www.example.com 的 vdimgck.php),如果没显式 setcookie 的 domain=.example.com,cookie 不跨子域。 - Chrome 80+ 默认 SameSite=Lax,在某些跨站嵌入场景(iframe 后台管理)会丢 cookie。 排查方法:浏览器开发者工具 Application 面板,看 Cookies,确认 PHPSESSID 在登录页与 vdimgck.php 请求里都存在且值相同。 ## 原因 E:浏览器禁用了 Cookie 或开了无痕 少数情况下用户自己关掉了 cookie。换一个浏览器或开普通窗口验证。 ## 故障三:磁盘空间满导致 session 写入失败 这个原因大家不太想得到,但实战里出现频率不低。空间满时 PHP 创建 session 文件会失败,svali 就写不进去。 排查命令: df -h du -sh /tmp /var/www/html/data/sessions* 2>/dev/null 常见塞满 /tmp 的元凶: - old session 没被 GC,phpinfo 里 session.gc_probability 默认 1,session.gc_divisor 默认 1000,意味着 1/1000 的请求触发清理。低流量站点几年不清一次,几百万个 sess_xxx 文件堆在 /tmp。 - php-fpm 的 access.log、error.log 在 /tmp 滚到几个 GB。 - 系统 /tmp 被设为 tmpfs(内存盘)且尺寸只有几百 MB,写满立刻失败。 解决方式:定期 find /tmp -name 'sess_*' -mtime +1 -delete,或者把 session 改用 redis、数据库存。 ## 故障四:插件、二开把 session 启动顺序搅乱 DedeCMS 二次开发时常遇到的坑:某个插件在 require_once common.inc.php 之前就 echo 了内容,导致后续 session_start() 报“session already sent”错误。如果你刚装了某个第三方插件之后才出现验证码问题,先停掉这个插件验证一下。 排查方法:临时把 include/common.inc.php 第一行加 error_reporting(E_ALL); ini_set('display_errors', 1);,登录失败时看屏幕上是否有 Warning。修完后记得改回去。 ## 故障五:MySQL 连接慢导致 vdimgck.php 超时 不太常见但确实见过:vdimgck.php 这个脚本本身不依赖 MySQL,但它会 require common.inc.php,common.inc.php 会建立 MySQL 连接。如果 MySQL 连不上,整个脚本超时被 kill,浏览器看到的是断流图片。 排查:服务器手动跑 php /var/www/html/include/vdimgck.php > /tmp/test.png,看是否能正常出图,多大延时。 ## 修复后仍未解决的兜底方案:直接关闭验证码 如果排查了一圈还是修不好(多见于客户托管主机不给 root 权限,php.ini 改不了),可以直接关闭后台验证码作为应急。原文里给的两段改法仍然可用,但必须配合下面的安全加固,不然你的后台就裸奔了。 ## 关闭代码改动(DedeCMS 5.7 适用) 第一步,编辑 dede/login.php,找到这行: if($validate=='' || $validate != $svali) 替换为: if(false) 第二步,编辑 dede/templets/login.htm,把验证码相关的 HTML 标签整段移除:
  • 验证码: 看不清?点击更换
  • ## 关闭后必须做的安全加固 没了验证码后,机器人爆破成本降到零。后台目录会在几小时内被各种自动化工具扫到。必须立即做以下三件事: - 把 dede 目录改名为不可猜的字符串。修改方式:服务器上 mv dede dede_xyz123,然后改 data/admin/allowurl.txt 与 data/config.cache.inc.php 里的“dede”为新名字。改完之后 dede.zhangsanlisi 这种地址访问。 - 给 dede 目录加 nginx basic auth 双重保护: location ^~ /dede_xyz123/ { auth_basic "admin only"; auth_basic_user_file /etc/nginx/.htpasswd; } - 用宝塔或 fail2ban 配置 IP 白名单,只允许办公 IP 访问后台。 三件做齐之后才算把“关闭验证码”的安全代价还回去了。 ## data/safe/inc_safe_config.php 的局部关闭法 除了硬改 login.php,还有一种更柔和的方法是改 data/safe/inc_safe_config.php。这个文件里有: $safe_gdopen = '1,2,3,5,6'; 每个数字对应一个开启验证码的位置:1 是后台登录、2 是会员登录、3 是会员注册、5 是发文章、6 是评论。把 1 去掉就只关后台登录验证码,其他位置仍然有验证码。 这个改法相当于把后台“验证码安全设置”界面里的某些开关程序化设置,可以在 PHP 直接修改文件而不依赖后台能进。但前提是“后台进不去”这个故障已经修了——其实如果能进后台就直接在“验证码安全设置”勾掉了。 ## data 目录搬家引起的特殊场景 原文提到的“data 目录转移引起的验证码错误”是 5.7 版本里一个独立的故障类型。织梦 5.7 SP2 版本之后官方推荐把 data 目录从 web 目录里挪出来(比如挪到 /home/wwwdata),通过 include/common.inc.php 里 define('DEDEDATA', xxx) 来指向新位置。 挪了之后 vdimgck.php 与 login.php 这两个脚本读取 session 的路径会因为下面这段代码出错: $sessSavePath = DEDEDATA."/sessions_{$enkey}"; 如果 DEDEDATA 已经是绝对路径就没问题,但如果当时定义成相对路径 ../data,PHP CLI 与 PHP-FPM 的 cwd 不一样,就会出现 session 路径解析到不存在的位置。 修复方式:把 DEDEDATA 定义改成绝对路径,例如 define('DEDEDATA', '/home/wwwdata/example')。 ## PHP 版本兼容相关的子故障 DedeCMS 5.7 原生兼容 PHP 5.2-5.6,到 PHP 7 之后有几处不兼容: - session_register() 在 PHP 7 被删除,部分老版本登录脚本仍调用,触发 fatal error。需要全文搜索 session_register 替换成 $_SESSION['xxx'] = ...。 - mysql_* 系列函数被废弃,dede 默认还在用,需要替换成 mysqli 或者用 PDO 适配层。 - preg_replace 的 /e 修饰符在 PHP 7 取消,改成 preg_replace_callback。 这三处不解决,PHP 7+ 环境下登录直接崩,自然报“验证码错误”(其实是后续逻辑没跑到)。社区有 dedeCMS-php7 的兼容补丁,搜索这个关键词找带 git 仓库的版本而不是百度网盘的版本,避免被植入后门。 ## 常用的快速验证手段 遇到“验证码错误”第一步先做这个十分钟内能跑完的快速验证: - 浏览器打开 站点根/include/vdimgck.php,看是否能直接看到验证码图片。看不到就是图片生成端坏了,按故障一处理。 - 右键图片,看看图片源,按下 F12 看 Network 里这个请求的 Set-Cookie 是不是含 PHPSESSID。没有就是 cookie 设置出问题。 - 刷新登录页,再看 Network,确认登录页的 Cookie 请求头里 PHPSESSID 与上一步 Set-Cookie 的值一致。不一致就是 cookie 没存住。 - SSH 进服务器执行 ls -lt /tmp/sess_* | head(或对应 sessions_xxx 目录),看刚才请求是否产生新 session 文件。没产生就是 session.save_path 不可写。 - 手动 cat sess_xxx 看里面是否有 svali 字段。 这五步走完,故障定位精度可以到具体哪个环节。 ## 常见问题解答 ## 验证码图片能显示但永远输错,是什么原因? 大概率是 svali 写入的 session 与登录提交读到的 session 不是同一个。先检查 data/sessions_xxx 目录权限是否对 PHP 进程可写,再确认 cookie 里 PHPSESSID 在两次请求间没变。第三个排查点是看 data 目录有没有同时存在多个 sessions_xxx 目录、是否双写。 ## 升级到 PHP 7.4 后验证码全坏了怎么办? DedeCMS 5.7 原生不兼容 PHP 7+,常见崩点是 session_register、mysql_* 函数、preg_replace 的 /e 修饰符。临时方案是降回 PHP 5.6,长期方案是打 dedeCMS-php7 兼容补丁,或者考虑迁移到 Typecho、WordPress 等仍在维护的 CMS。 ## 关闭验证码后被爆破怎么办? 关闭验证码必须配合后台目录改名、nginx basic auth、IP 白名单这三件事。三件做齐才算把验证码挡掉的爆破阻力还回来。如果做不到这些,建议优先修验证码本身而不是关掉。 ## 为什么 sessions_8ab3842ff8 这种带后缀的目录会出现多个? DedeCMS 用 cfg_domain_cookie 的 md5 值作为目录后缀,如果你站点曾改过 cfg_domain_cookie 设置(包括从空字符串变成有值),新旧两个值会生成两个不同后缀的目录。新目录正在被使用,老目录留在那里没人清。彻底解决方法是把 include/common.inc.php 改成 sessions_save_path 走单一 data/sessions 目录。 ## 验证码刷新后看到的图片字符与提交时的不同,是不是浏览器缓存问题? 不是。点击刷新验证码会向 vdimgck.php 发新请求,重新生成 svali 写到 session。每次刷新后页面上看到的字符就是当前会话里最新的 svali。如果你看到旧字符,是浏览器对图片做了 cache 没真正请求 vdimgck.php,可以在 src 后面加随机参数 ?t=随机数 强制刷新。原文模板里那个 onclick="this.src=this.src+'?'" 就是干这个的。 ## 已经把 data/sessions 目录权限改成 777 了还是不行? 检查三个细节:第一,是否 data 上层目录权限不对(比如 data 是 755 但 data/sessions 是 777,PHP 也得有 data 的 x 权限才能 cd 进去);第二,SELinux 是否启用且未对 sessions 目录做 restorecon;第三,是否有多个 sessions 目录而你只改了其中一个。Linux 命令 find data -type d -name 'sessions*' -exec ls -ld {} \; 一次性看清。 ## 修改 common.inc.php 后是否需要重启 PHP-FPM? 修改 PHP 文件不需要重启 PHP-FPM,因为 PHP-FPM 每次请求都会重新读文件(除非开了 OPcache)。如果开了 OPcache 且 opcache.validate_timestamps=0,那确实需要重启 PHP-FPM 或者 reload 让缓存失效。 ## 多站点共用一个 PHP-FPM,会不会互相串验证码? 原版 DedeCMS 用 enkey 后缀的设计就是为了避免这种串。enkey 基于 cfg_domain_cookie 的 md5,只要每个站点设置了不同的 cfg_domain_cookie,就会落到不同的 sessions_xxx 目录。但如果你按本文建议改成单一 data/sessions 目录,就要单独考虑跨站隔离——简单做法是各站点的 data 目录本来就分开放,所以单一 sessions 也不会冲突。 ## 验证码图片出现但全是黑色或乱码字符? GD 库装了但没装 freetype,无法渲染中文字体。验证码模板使用了 imagettftext,缺 freetype 时退化为 imagestring 用 ASCII 像素字体,遇到中文位置画黑块。修复:CentOS 上装 php-gd 同时确保附带 freetype,或者把 vdimgck.php 改成只画英文与数字。 ## 验证码错误的故障是否会影响前台会员登录? 同一个故障原因(session 不可写、GD 缺、目录权限)会同时影响后台与前台。但织梦前台默认会员登录的验证码是另一段代码(用的 member/snsbutton.php 与 include/vdimgck.php 同源但调用上下文不同),症状可能略有差异。修了后台之后顺便去会员登录页测一遍。 ## Linux下DedeCMS生产级安全加固指南:mpm-itk/php-fpm权限分离、目录迁移、fail2ban与应急响应 - URL:https://zhangwenbao.com/dedecms-site-security-settings-in-linux-environment.html - 分类:织梦CMS教程 - 发布:2017-01-27 | 更新:2026-06-02 - 摘要:在Linux生产环境部署DedeCMS,安全加固得成体系。本文给出方案:用Apache mpm-itk配独立账号做vhost级权限隔离,或Nginx加php-fpm多pool隔离前后台进程,后台目录迁移并配IP白名单与Basic Auth,再到禁脚本执行、删高危文件、禁危险函数、fail2ban监控和应急响应。 - 关键词:DedeCMS安全设置,fail2ban,Linux安全加固,权限分离,php-fpm pool > **TLDR**:摘要:在Linux生产环境部署DedeCMS,安全加固得成体系。本文给出两条权限隔离路线——Apache用mpm-itk配独立账号做vhost级隔离,或Nginx加php-fpm独立pool隔离前后台进程,再到文件权限精细化、php.ini危险函数禁用、SELinux与AppArmor、fail2ban暴力破解防护、SSL强制升级、定期备份与日志审计,以及被入侵后的应急响应。 > 摘要:在Linux生产环境部署DedeCMS,安全加固得成体系。本文给出两条权限隔离路线——Apache用mpm-itk配独立账号做vhost级隔离,或Nginx加php-fpm独立pool隔离前后台进程,再到文件权限精细化、php.ini危险函数禁用、SELinux与AppArmor、fail2ban暴力破解防护、SSL强制升级、定期备份与日志审计,以及被入侵后的应急响应。 DedeCMS 是国内被入侵率最高的几个 CMS 之一,2020 年起官方维护停滞之后情况更糟。安装后的默认权限基本是“写一个 webshell 进 uploads 目录就能拿管理员权限”的状态。我维护过的几台被攻陷的服务器,事后回溯都能追到三类根因:后台目录可猜、php 进程对全站可写、uploads 与 data 目录有脚本执行权限。本文按 Linux 部署场景给出系统化的加固方案,覆盖 Apache mpm-itk 与 Nginx php-fpm 两套权限隔离模型、后台目录迁移与重命名、php.ini 危险函数禁用、SELinux 策略、fail2ban 暴力破解防护、SSL/TLS 强制升级,以及如何在被入侵后快速止血并溯源。 ## DedeCMS 默认部署的攻击面 ## 典型攻击路径 过去几年公开的 DedeCMS 攻击案例归纳起来就五条路径: - uploads 目录上传 webshell:默认 uploads/ 目录有写入权限+脚本执行权限,结合后台未鉴权的 file_manager 接口或者前台某个有上传漏洞 (https://zhangwenbao.com/there-is-a-repair-method-for-uploading-vulnerabilities-in-uploadsafe-inc-php-in-dedecms.html)的 plus/ 程序,攻击者拿到 webshell。 - 后台爆破:默认后台目录就叫 dede/,有人写脚本扫遍了互联网上的 dede.php 登录入口做密码爆破。 - SQL 注入:plus/ 目录下若干历史漏洞(recommend.php、search.php、carbuyaction.php 等),未打补丁的站点容易被自动化工具命中。 - data 目录任意文件读写:data/ 目录在某些版本不被 web server 拒绝,攻击者直接读 config.cache.inc.php 拿到数据库密码。 - 会话劫持:sessions_xxx 目录权限过松,多用户共享主机时跨站点拿对方 session。 ## 加固的核心思路 用“最小权限原则”分层隔离: - web server 进程 ≠ 文件 owner:让 nginx/apache 进程对站点目录默认只读,写入只在白名单目录开放。 - 前台业务 ≠ 后台管理:前台 PHP 跑 www-data 账号,后台 PHP 跑 www-admin 账号,分别拥有自己的可写区域。 - uploads 与 data 不可执行:即便上传成功 .php 文件,web server 也拒绝执行。 - 后台 URL 不可猜:dede 改名 + IP 白名单 + Basic Auth 三重保护。 ## Apache 方案:mpm-itk 模块前后台权限分离 ## 为什么必须装 mpm-itk Apache 默认所有虚拟主机共享同一个进程用户(多数 Linux 发行版是 www-data 或 apache)。结果就是 A 站被入侵,攻击者立刻能读 B 站的 wp-config.php、config.inc.php 这些敏感配置。mpm-itk 模块允许每个 vhost 指定不同的运行用户,从根上隔离。 ## 安装与配置 sudo apt-get install apache2-mpm-itk sudo a2enmod mpm_itk sudo systemctl restart apache2 注意 mpm-itk 与 mpm_event、mpm_worker 互斥,安装时会自动卸载其它 MPM 模块。 ## 后台运行账号 sudo useradd -g www-data -d /dev/null -s /usr/sbin/nologin www-admin sudo usermod -L www-admin # 锁定密码登录 nologin shell + 锁定密码确保 www-admin 账号无法直接登录,只能被 Apache 用作进程身份。 ## 迁移后台目录 默认 /dede 目录是公开秘密,必须改名 + 移到独立路径。建议改成不可猜字符串: sudo mkdir /var/dedecms-admin sudo mv /var/dedecms/dede/* /var/dedecms-admin/ sudo rmdir /var/dedecms/dede sudo chown -R www-admin:www-data /var/dedecms-admin sudo chmod -R 750 /var/dedecms-admin chmod 750:owner(www-admin)可读写执行,group(www-data)只读执行,other 无权限。 ## 独立后台 vhost 配置 新建 /etc/apache2/sites-available/dedecms-admin.conf: ServerName admin-9k7x.example.com DocumentRoot /var/dedecms-admin SSLEngine on SSLCertificateFile /etc/ssl/certs/example.com.crt SSLCertificateKeyFile /etc/ssl/private/example.com.key AssignUserId www-admin www-data # IP 白名单(办公网/VPN 出口) Options -Indexes -FollowSymLinks AllowOverride None Require ip 203.0.113.0/24 Require ip 198.51.100.0/24 # Basic Auth 二重门 AuthType Basic AuthName "Admin Only" AuthUserFile /etc/apache2/.htpasswd Require valid-user # 错误日志 ErrorLog ${APACHE_LOG_DIR}/dedecms-admin-error.log CustomLog ${APACHE_LOG_DIR}/dedecms-admin-access.log combined 三个细节: - 子域名不可猜:admin-9k7x.example.com 这种带随机串的子域名比 admin.example.com 安全很多,攻击者扫域名时找不到。 - IP 白名单:限定办公网或 VPN 出口 IP 才能访问。这是抵抗 90% 自动化扫描的最简手段。 - Basic Auth:哪怕 IP 被绕过(攻击者拿到内网跳板),还有一道密码门。 生成 htpasswd: sudo htpasswd -c /etc/apache2/.htpasswd dedeops 启用 vhost: sudo a2ensite dedecms-admin sudo systemctl reload apache2 ## 修复后台代码引用路径 后台目录迁移后,原本的相对路径都要调整。最少修改三个文件: /var/dedecms-admin/config.php 第 12 行: require_once(DEDEADMIN.'/../include/common.inc.php'); 改为: require_once(DEDEADMIN.'/../dedecms/include/common.inc.php'); /var/dedecms-admin/login.php 第 11 行与 /var/dedecms-admin/exit.php 第 11 行同样的 include 路径修复。 /var/dedecms/data/safe/inc_safe_config.php 第 2 行: $safe_gdopen = '1,2,3,4,5,7'; 这个开关控制各处验证码是否开启,开越多越好。 ## 必要的 include 资源同步 后台 UI 依赖 /var/dedecms/include/ 下的 dialog、js、ckeditor 等文件夹。把这些复制或软链到后台目录: sudo mkdir /var/dedecms-admin/include sudo ln -s /var/dedecms/include/dialog /var/dedecms-admin/include/dialog sudo ln -s /var/dedecms/include/js /var/dedecms-admin/include/js sudo ln -s /var/dedecms/include/ckeditor /var/dedecms-admin/include/ckeditor 用软链而不是 cp -R 的好处是 DedeCMS 升级 include 时自动同步到后台。 ## Nginx 方案:php-fpm 独立 pool 实现权限隔离 ## 为什么 php-fpm 比 mpm-itk 更优 php-fpm 默认就支持“不同 pool 不同用户”机制,无需额外模块。性能上 php-fpm + nginx 比 apache + mpm-itk 高 30-50%(因为 mpm-itk 每次请求 fork 一个进程,php-fpm 是常驻进程池)。 ## 安装基础组件 sudo apt-get install nginx php-fpm sudo apt-get install php-mysql php-gd php-curl php-mbstring php-xml php-zip sudo systemctl stop apache2 # 如有旧 apache ## 创建后台专属 php-fpm pool 复制默认 pool 配置: cd /etc/php/8.1/fpm/pool.d/ sudo cp www.conf admin.conf 编辑 admin.conf 改三处: [admin] user = www-admin group = www-data listen = /run/php/php8.1-fpm-admin.sock listen.owner = www-admin listen.group = www-data listen.mode = 0660 用 unix socket 而不是 127.0.0.1:9001 端口,性能更优、不暴露在外网。 ## nginx 前台站点配置 /etc/nginx/sites-available/dedecms-front: server { listen 443 ssl http2; server_name www.example.com; root /var/dedecms; index index.php index.html; ssl_certificate /etc/ssl/certs/example.com.crt; ssl_certificate_key /etc/ssl/private/example.com.key; # 禁止访问敏感目录 location ~ ^/(data|templets|include|dede|member|special|company)/.*\.(php|php5|phtml)$ { deny all; } # uploads 目录禁止脚本执行 location ~* ^/uploads/.*\.(php|php5|phtml|pht|phar|cgi)$ { deny all; } # 隐藏文件保护 location ~ /\. { deny all; } # PHP 处理 location ~ \.php$ { try_files $uri =404; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # 静态资源缓存 location ~* \.(jpg|jpeg|png|gif|css|js|woff|woff2|ico)$ { expires 30d; access_log off; } } server { listen 80; server_name www.example.com; return 301 https://$server_name$request_uri; } ## nginx 后台站点配置 /etc/nginx/sites-available/dedecms-admin: server { listen 443 ssl http2; server_name admin-9k7x.example.com; root /var/dedecms-admin; index index.php; ssl_certificate /etc/ssl/certs/example.com.crt; ssl_certificate_key /etc/ssl/private/example.com.key; # IP 白名单 allow 203.0.113.0/24; allow 198.51.100.0/24; deny all; # Basic Auth auth_basic "Admin Only"; auth_basic_user_file /etc/nginx/.htpasswd; location ~ \.php$ { try_files $uri =404; fastcgi_pass unix:/run/php/php8.1-fpm-admin.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~ /\. { deny all; } } 关键差异:admin 站点用 admin pool 的 socket(fpm-admin.sock),跑 www-admin 进程身份;前台用 default pool socket(fpm.sock),跑 www-data 进程身份。 ## 启用站点 sudo ln -s /etc/nginx/sites-available/dedecms-front /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/dedecms-admin /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx sudo systemctl restart php8.1-fpm ## 文件权限的精细化设置 ## 分层权限模型 # 整体默认 750(owner 读写执行,group 读执行) sudo chown -R www-admin:www-data /var/dedecms sudo find /var/dedecms -type d -exec chmod 750 {} \; sudo find /var/dedecms -type f -exec chmod 640 {} \; # uploads 与 data 需要 web 进程可写 sudo chown -R www-data:www-data /var/dedecms/uploads /var/dedecms/data sudo chmod -R 770 /var/dedecms/uploads /var/dedecms/data # install 目录可以删 sudo rm -rf /var/dedecms/install ## data/safe 目录强加固 data/safe/ 里的 inc_safe_config.php 是站点配置,攻击者最想读。改 600 只让 owner 读: sudo chmod 600 /var/dedecms/data/safe/inc_safe_config.php sudo chmod 600 /var/dedecms/data/common.inc.php ## plus 目录的处理 plus/ 下有几个高危文件历史上被反复曝出漏洞。如果你不用对应功能,直接删: cd /var/dedecms/plus sudo rm -f recommend.php carbuyaction.php carbuy.php advancedsearch.php mytag_js.php 这一招能挡掉自动化扫描的 80%。 ## php.ini 危险函数禁用 编辑 /etc/php/8.1/fpm/php.ini 找到 disable_functions: disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source,phpinfo,assert,eval,gzinflate,base64_decode,str_rot13 这些函数是 webshell 最常用的入口。一旦禁用,绝大多数公开 webshell 工具直接失效。 同时修改: expose_php = Off allow_url_fopen = Off allow_url_include = Off display_errors = Off log_errors = On error_log = /var/log/php-fpm/php-fpm-error.log session.cookie_httponly = 1 session.cookie_secure = 1 重启 php-fpm 生效。 ## SELinux 与 AppArmor ## CentOS / RHEL 用 SELinux 默认开启的 SELinux 会给 /var/www/ 下的文件打 httpd_sys_content_t 标签。如果你的 DedeCMS 装在非标准路径(比如 /opt/dedecms),需要手动打标: sudo semanage fcontext -a -t httpd_sys_content_t "/opt/dedecms(/.*)?" sudo restorecon -Rv /opt/dedecms uploads 与 data 需要可写: sudo semanage fcontext -a -t httpd_sys_rw_content_t "/opt/dedecms/uploads(/.*)?" sudo semanage fcontext -a -t httpd_sys_rw_content_t "/opt/dedecms/data(/.*)?" sudo restorecon -Rv /opt/dedecms/uploads /opt/dedecms/data ## Ubuntu 用 AppArmor Ubuntu 默认开启的 AppArmor 提供类似功能。看当前状态: sudo aa-status 如果 nginx 或 apache 没有 profile,可以创建一个 enforce 模式的策略限制其访问范围。 ## fail2ban 暴力破解防护 即便后台改了名加了 IP 白名单,互联网层面仍有大量尝试性扫描。fail2ban 监控访问日志,发现异常行为自动封 IP: sudo apt-get install fail2ban sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local 编辑 jail.local 启用 nginx-http-auth jail: [nginx-http-auth] enabled = true filter = nginx-http-auth logpath = /var/log/nginx/error.log maxretry = 5 findtime = 600 bantime = 86400 10 分钟内 5 次 Basic Auth 失败的 IP 封禁 24 小时。 对 DedeCMS 后台登录接口写自定义 filter: # /etc/fail2ban/filter.d/dedecms-login.conf [Definition] failregex = ^ .* "POST /login\.php.*" 200 ignoreregex = 启用: [dedecms-login] enabled = true filter = dedecms-login logpath = /var/log/nginx/dedecms-admin-access.log maxretry = 3 findtime = 600 bantime = 86400 ## SSL/TLS 强制升级 HTTP 到 HTTPS 强制 301 跳转前面已经在 nginx/apache 配置里给了。补充几个加固头: # nginx 后台站点 server 块内 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';" always; HSTS (https://zhangwenbao.com/https-hsts.html) 头让浏览器一年内强制走 HTTPS,连 HTTP 都不发起请求;CSP 防止 XSS 注入跨域脚本。 ## 定期备份与日志审计 ## 每日备份脚本 #!/bin/bash # /usr/local/bin/dedecms-backup.sh DATE=$(date +%Y%m%d) BACKUP_DIR=/var/backups/dedecms mkdir -p $BACKUP_DIR # 数据库备份 mysqldump -u root -p'YOUR_PASS' --single-transaction --hex-blob dedecms_db | gzip > $BACKUP_DIR/db_$DATE.sql.gz # 文件备份(排除大附件) tar --exclude='/var/dedecms/uploads/big_files' -czf $BACKUP_DIR/files_$DATE.tar.gz /var/dedecms /var/dedecms-admin # 保留最近 30 天 find $BACKUP_DIR -name "db_*.sql.gz" -mtime +30 -delete find $BACKUP_DIR -name "files_*.tar.gz" -mtime +30 -delete cron 设置每天凌晨执行: 0 3 * * * /usr/local/bin/dedecms-backup.sh ## 异地备份 本地备份不够,必须传到异地(OSS、S3、另一台机器)。用 rclone 或 awscli: 0 4 * * * rclone sync /var/backups/dedecms remote:dedecms-backup/$(date +\%Y\%m) ## 被入侵后的应急响应 ## 第一时间止血 - nginx/apache 改 server 配置返回 503,关掉访问。 - 断开 MySQL 远程访问:iptables 封 3306 端口。 - 临时拒绝所有 PHP 执行:在 nginx 配置里把 location ~ \.php$ 块整个 deny。 ## 溯源调查 - 看 nginx access.log 找异常 POST 请求(特别是 plus/ 与 uploads/ 路径)。 - find /var/dedecms -newer /tmp/some-marker -type f 找最近修改过的文件。 - find / -name "*.php" -newer /tmp/some-marker 找全盘新增的 PHP 文件(webshell 嫌疑)。 - 看 mysql binlog 找异常 SQL(特别是 INSERT 进 dede_admin 表的)。 ## 恢复流程 不要在被入侵的系统上“修一修就好”——攻击者多半已经留了多个后门。正确做法:从备份里彻底重建系统、重置所有密码(数据库、SSH、后台、SMTP)、重新生成 SSL 证书、清空 sessions。 ## 常见问题解答 ## 把后台目录改名后还需要其它防护吗? 需要。改名只解决“目录可猜”,但攻击者拿到一份你的备份文件或日志也能知道新目录名。完整防护是“改名 + IP 白名单 + Basic Auth + fail2ban”四件套。 ## mpm-itk 性能损失大吗? 每个请求 fork 进程比常驻进程慢 10-30%。中小站点流量不大感知不到,QPS 上千的高流量站点建议改用 nginx + php-fpm 多 pool 方案。 ## uploads 目录禁止 PHP 执行后图片缩略图功能受影响吗? 不影响。缩略图生成是 PHP 进程内 GD 调用,与“上传目录里的 .php 文件能不能被外部请求”是两回事。本文 nginx 配置只拦截 location ~* ^/uploads/.*\.(php|...) 这种通过 URL 直接访问的请求,PHP 进程内的 image_create 调用不走这条路径。 ## php.ini disable_functions 禁用 exec 后某些功能挂了? 少数 DedeCMS 模块(特别是图像处理、备份模块)会调 exec 调外部 ImageMagick。三种处理:用 PHP 内置函数替代(imagemagick 改 GD);指定具体可调的安全命令而不是禁用整个 exec;把这些功能转移到独立的内网服务通过 HTTP 调用。 ## 为什么不直接用 chroot 把每个站点关进笼子? chroot 配置复杂,要把所有依赖库(libc、libz 等)拷进 chroot 目录。对单台服务器跑多个 PHP 站点,php-fpm 多 pool + 不同 user 已经够用,chroot 边际收益不大。容器化(Docker (https://zhangwenbao.com/wordpress-docker-containerized-deployment-environment-consistency.html))是更现代的隔离方案。 ## CentOS 的 SELinux 总是拦我的功能怎么办? 不要 setenforce 0 关掉 SELinux。正确做法是看 /var/log/audit/audit.log,找出具体哪个动作被拒,然后用 audit2allow 生成对应的策略允许。SELinux 的拦截多数是合理的安全限制。 ## fail2ban 把我自己也封了怎么办? 把办公网 IP 加到 fail2ban 的 ignoreip:在 jail.local 顶部 [DEFAULT] 区段加 ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24。 ## HTTPS 证书续期失败怎么办? 多数证书续期失败是 Let's Encrypt 验证 .well-known 路径取不到(被 nginx 全站 deny 拦了)。在 server 块顶部加白名单:location /.well-known/acme-challenge/ { allow all; } 优先级高于其它规则。 ## 已经被入侵了能不能不重装直接清理? 极不推荐。攻击者可能在系统的 cron、systemd unit、动态库注入、内核模块、ssh authorized_keys 等多处留了持久化后门。仅清理 webshell 就好的概率不到 30%。 ## 有没有现成的扫描工具能查 DedeCMS 是否已被入侵? 可以用 maldet(Linux Malware Detect)扫描已知 webshell 特征:maldet -a /var/dedecms。但只能查已知样本,定制 webshell 查不到。配合人工审计 access.log 与近期文件修改才是完整方案。 ## 权威参考资料 ## 织梦手机站首页一直是旧数据?一个字符改对缓存判断 - URL:https://zhangwenbao.com/dedecms-mobile-index-html-update-solution.html - 分类:织梦CMS教程 - 发布:2017-01-21 | 更新:2026-06-01 - 摘要:织梦手机站首页/m/index.html不更新,后台按钮还失效。本文从缓存判断逻辑解读,给出把!file_exists改成file_exists的一字符修复方案、压测前后对比、TTL短时缓存优化、cron定时刷新备选,以及栏目页和文章页同类问题,覆盖V5.7 SP1到DedeBIZ 6.x全版本。 - 关键词:DedeCMS移动版,DedeCMS手机站,静态文件缓存,DedeCMS生成,m目录修复 > **TLDR**:摘要:织梦手机站首页/m/index.html一直停在旧数据、后台更新按钮还失效,根子在/m/index.php的缓存判断写反了。本文拆解缓存逻辑,给出把file_exists前的非号去掉这个一字符修复,附压测数据评估性能影响、上线回归清单,再讲cron定时刷新的备选方案、栏目页和文章页的同类问题,覆盖V5.7 SP1到DedeBIZ 6.x全版本。 > 摘要:织梦手机站首页/m/index.html一直停在旧数据、后台更新按钮还失效,根子在/m/index.php的缓存判断写反了。本文拆解缓存逻辑,给出把file_exists前的非号去掉这个一字符修复,附压测数据评估性能影响、上线回归清单,再讲cron定时刷新的备选方案、栏目页和文章页的同类问题,覆盖V5.7 SP1到DedeBIZ 6.x全版本。 保哥做织梦站点的运维和二次开发已经超过八年。这篇文章想完整复盘一个看似很小、但实际困扰过不少站长的老问题:织梦DedeCMS移动端的 m/index.html 静态文件,怎么改都不更新。保哥之前在客户站上踩过这个坑,跑遍了官方论坛和各种交流群,最后通过逐行阅读 /m/index.php 才把它解决。 下面把整个排查过程、原理、最终的修复方法、性能压测数据、上线回归清单和长期维护建议都写出来,希望能帮到正在头疼的同行。 ## 问题现象:PC 端正常,手机端首页一直停留在旧数据 保哥接手的这个项目是一个面向地方资讯的内容站,PC端用织梦默认模板,手机端是单独开发的子目录 /m/。客户反馈说,PC端文章发布以后,PC首页能正常生成新内容,但是手机端首页 https://站点/m/ 一直显示几天前的旧文章。 保哥做的第一轮排查是这样的: - 进后台生成菜单到更新主页HTML,点击生成,提示成功,但访问 /m/index.html 没变化。 - 后台更新系统缓存、更新所有文档HTML依次跑了一遍,依旧不更新。 - 直接SSH到服务器,把 /m/index.html 删掉,再用手机访问 /m/,这时候静态页面会自动重新生成,并且内容是最新的。 - 但是再过几个小时发新稿,/m/index.html 又卡住,不再更新。 - 给文件设置不可写权限(chmod 644),希望系统会因为无法写入而报错从而暴露问题,但织梦居然静默失败,前台仍然显示旧内容。 这条线索很关键:只有当 index.html 不存在时,系统才会重新写入这个文件。这意味着DedeCMS在 /m/index.php 里有一段缓存判断逻辑,问题大概率出在那段判断上。 ## 原理拆解:阅读 /m/index.php 的缓存判断 保哥把 /m/index.php 用VSCode打开,定位到生成首页静态文件的那段代码,核心是这一行: if(isset($_GET['upcache']) || !file_exists('index.html')) { // 重新读取数据库内容,渲染模板,写入 index.html } else { // 直接 include 已有的 index.html,作为缓存命中 } 这段代码的逻辑用人话翻译就是: - 条件A:URL里带了 ?upcache 参数; - 条件B:当前目录下不存在 index.html 文件; - 只要A或B任一成立,就重新生成;否则直接读取已有静态页。 看到这里保哥就明白了。织梦默认的"更新主页HTML"按钮,其实只针对PC端的根目录主页,根本不会触发 /m/index.php。也就是说,移动端的 index.html 只能通过两条路径更新: - 手动给 /m/index.php?upcache=1 这个URL发请求; - 把 /m/index.html 物理删除,下一次手机访问时再生成。 这就解释了为什么客户每次都觉得"不更新"——后台的所有按钮都不会触发这段逻辑。从软件设计角度看,这是织梦移动端模块在2010年前后初版设计时遗留的一个"特性"——当时移动端流量还很小,开发者认为静态文件可以长期不更新,节省服务器资源。但2026年的内容站根本接受不了这种设计。 ## 官方推荐方案的副作用:为什么没有采用 社区里流传的一种做法,是去后台生成菜单到更新主页HTML里,把"主页位置"改成 /m/index.html、模板改成移动端模板,再点生成。理论上可行,保哥也实测过,确实能更新移动端首页。但是它有一个致命副作用: - 这个配置是全局的,一旦改成移动端,下一次想更新PC首页时,还要再切回PC的路径和模板; - 如果忘记切回去,PC首页就会被移动端模板覆盖,直接出大事; - 客户那边的运营同事不可能每次发稿都来回切换。 - 客户站如果有多个编辑同时操作,还会出现并发竞争——A刚切到移动端模板,B在另一个标签页点了"更新PC首页",结果PC首页就被移动端模板覆盖了。 所以这条路对保哥来说是不可持续的。需要一个一次性改完、之后零维护的方案。 ## 保哥的修复方案:把 !file_exists 的"非"去掉 回到 /m/index.php 那段判断。保哥重新读了一遍: if(isset($_GET['upcache']) || !file_exists('index.html')) 这里有一个隐含假设:文件存在就直接用缓存。但对于移动端首页这种数据频繁变动的场景,这个假设并不合理。把判断里 file_exists 前面的 !(取反)去掉,让逻辑变成文件存在的时候才重新生成: if(isset($_GET['upcache']) || file_exists('index.html')) { // 每次有 index.html 文件存在,就重新读数据并写入 } 这一改动看似反直觉——文件存在反而要重新生成?但配合后面的 include 逻辑读一遍源代码就会发现,只要重新走一次生成流程,最新的数据就会被写入 index.html 并立即输出,相当于把缓存命中分支废掉,强制每次访问都更新。 保存上传,访问 https://站点/m/,刷新两次,内容立刻变成最新文章。问题解决。 这个改法的优雅之处在于:只动一个字符(删一个感叹号),可读性高、改动可见、回滚一秒搞定。任何接手代码的人看到这一行都能理解意图。 ## 性能影响评估与压测数据 有读者会担心:每次访问都重新读数据库渲染模板,会不会把服务器搞垮?保哥在客户的测试环境用 wrk 做了一轮压测,记录如下: # 测试环境:2核4G,PHP 7.4,MariaDB 10.5 # 修改前:纯静态 include wrk -t4 -c100 -d30s https://test.example.com/m/ # Requests/sec: 2841.32 # Latency avg: 35.2ms # Errors: 0 # 修改后:每次重新生成 wrk -t4 -c100 -d30s https://test.example.com/m/ # Requests/sec: 612.45 # Latency avg: 163.7ms # Errors: 0 吞吐确实下降到原来的1/4左右。对一个日PV不到5万的中小资讯站来说,这个性能完全够用;但如果你的站点首页QPS上千,建议再加一层短时缓存,比如: $cacheFile = 'index.html'; $ttl = 300; // 5分钟 if(isset($_GET['upcache']) || !file_exists($cacheFile) || (time() - filemtime($cacheFile)) > $ttl) { // 生成 } 这样既保证5分钟内首页一定刷新一次,又不至于每个请求都打数据库。保哥后来在另一个流量更大的客户站上就是用的这个TTL版本,运行至今稳定。 更进阶的方案是把缓存放到Redis或Memcached,TTL同样5分钟,但避免filemtime这种文件IO操作。日PV超过百万的站点这么改能进一步把延迟压到50ms以下。 ## 上线前的回归清单 改动这种核心入口文件,保哥有一个固定的回归清单,建议你也照着走一遍: - 备份原文件:cp /m/index.php /m/index.php.bak.20260507,万一出问题可以一键回滚。 - 本地或测试环境先验证:确认手机访问能正常跳转、首页内容能更新、CSS/JS资源加载正常。 - 检查SEO影响:/m/index.html 的 、<meta description>、结构化数据 (https://zhangwenbao.com/seo-schema-guide.html)是否仍然完整输出。用Chrome的View Source功能验证。 - 检查H5跳转:从PC域名的 / 访问,是否仍然能正确通过UA判断跳到 /m/。常见的UA判断在 include/dedemobile.class.php。 - CDN缓存:如果你前面挂了Cloudflare或者七牛CDN,需要把 /m/index.html 的缓存策略改短,否则改完也不生效。Cloudflare的Cache Level建议改成"Bypass"或Edge TTL改成5分钟。 - 观察7天:连续观察一周,确认数据库压力、慢日志、错误日志都没异常。 - 监控移动端访问指标:用百度统计或Google Analytics查移动端首页的UV、PV和跳出率,对比改动前后是否有异常波动。 按这个清单走,基本不会翻车。保哥给客户做这套改动时,平均部署时间25分钟,从未出过事故。 ## 备选方案:cron 定时刷新 如果你不想改源码(比如担心升级被覆盖、或者团队对动核心代码有顾虑),可以用cron定时任务的方式实现"准实时刷新": # 每 5 分钟刷新一次移动端首页 */5 * * * * curl -s "https://站点/m/index.php?upcache=1" > /dev/null 2>&1 # 如果担心 cron 失败,可以加上日志记录 */5 * * * * curl -s "https://站点/m/index.php?upcache=1" >> /var/log/m_refresh.log 2>&1 这种方法的好处是:完全不动源码,DedeCMS升级不会被覆盖;坏处是有最长5分钟的延迟,对实时性要求高的站不合适。 如果你的站点是热点新闻类,希望编辑发文后秒级生效,可以在DedeCMS的发文成功钩子里加一段: // 放在 /dede/article_add.php 末尾 register_shutdown_function(function() { @file_get_contents('https://站点/m/index.php?upcache=1'); }); 这样发文成功后会异步触发一次移动端首页刷新,用户基本无感。 ## 长期维护建议 从架构层面,保哥的几条建议: - 把所有自定义改动记录到 patch.md:每次升级DedeCMS后照着清单重打补丁。 - 用git管理 /m/ 目录:所有改动都走commit,可以追溯变更历史。 - 移动端模板与PC模板逻辑解耦:避免移动端模板里直接读PC端的栏目数据,否则未来重构会很痛苦。 - 关注DedeCMS的安全公告:移动端模块在历史上出过几次SQL注入 (https://zhangwenbao.com/ecshop-includeslibinsertphp-file-sql-injection-vulnerability-repair-method.html)漏洞,订阅CNVD的DedeCMS标签关键词推送。 - 考虑长期迁移:DedeCMS官方维护节奏越来越慢,如果是新建站点不再推荐DedeCMS,老站长期看也建议规划迁移到WordPress、Typecho或自研系统的路径。 ## 保哥的实战案例:三个客户站的应用记录 这个修复方案保哥在不同类型的客户站上都验证过,结果列在下面,给读者一个真实的参考: 案例一:地方资讯站,日PV 3万 客户是某三线城市的本地资讯站,DedeCMS V5.7 SP2,2018年上线。问题是发完稿编辑总是抱怨"我刚发的文章手机端首页看不到"。保哥用本文方案改完后,编辑实时刷新就能看到新文章。性能层面,由于日PV才3万,纯实时生成完全顶得住,CPU使用率只比之前高了 8%,数据库连接数从平均20增加到35,仍然在承受范围内。客户运维同事甚至没察觉性能变化。改动从备份到上线总共用了20分钟,至今稳定运行三年。 案例二:行业资讯门户,日PV 50万 客户是某垂直行业(机械制造)门户站,访问量较大且首页内容更新 (https://zhangwenbao.com/old-blog-content-update-merge-delete-seo-sop.html)频繁。直接用基础修复方案后,PHP-FPM进程数飙升到默认上限80,数据库慢查询日志开始报警。保哥立刻切换到第五节的TTL缓存版本,TTL设为3分钟,性能瞬间回归正常。运行半年后,把TTL进一步降到1分钟,配合opcache和MySQL查询缓存,性能仍然稳定。客户的SEO负责人反馈,移动端百度收录速度比之前快了一倍,新闻类内容的"小时级收录"成功率从35%提升到78%。 案例三:电商资讯+商品混合站,日PV 200万 客户是某跨境电商资讯站,首页有商品价格、库存、汇率等高频变动数据。基础方案完全扛不住,TTL方案也只能支撑到80%的请求。保哥引入了Redis缓存层,把整个index.html内容缓存到Redis(key带版本号),PHP只做读Redis和直接echo,不再走DedeCMS模板渲染。每次发文或商品上下架时,通过钩子主动invalidate对应的Redis key。这套架构下,首页平均响应时间稳定在30毫秒以下,CPU使用率从原方案的80%降到15%。这个项目让保哥意识到:DedeCMS这种老CMS的内置缓存机制对中型以上站点已经不够用,必须引入外部缓存层。 ## 和其他CMS的对比 顺便对比一下其他CMS的移动端首页缓存策略,方便有迁移计划的读者参考: WordPress:默认完全动态渲染,每次访问都走数据库。生产环境普遍配 W3 Total Cache 或 WP Rocket 这类缓存插件,缓存策略比织梦灵活得多——可以按用户角色、按设备类型、按页面类型分别配置。如果你的站点未来要迁移到WordPress,缓存层基本不用操心。 Typecho:默认动态,但模板编译后的中间产物会缓存,性能介于织梦的纯静态和WordPress的纯动态之间。Typecho 1.3之后增加了片段缓存能力,可以按需缓存特定模板块。 PHPCMS:和DedeCMS类似走静态化,但缓存逻辑更复杂,有"前台静态、后台触发"的明确分工,移动端首页的更新比DedeCMS更可控。 帝国CMS:默认走全静态化,所有页面都生成HTML文件,更新逻辑由后台菜单触发。优势是性能最好,劣势是更新延迟感最强,需要严格的发文流程。 从架构成熟度看,2026年的最佳实践是WordPress + 缓存插件,灵活性最高。但如果你的站点已经在DedeCMS上跑了多年,强行迁移成本太高,还是按本文方案优化为主。 ## 常见问题解答 ## 去掉感叹号之后,第一次访问 /m/ 会怎么样? 第一次访问时,如果index.html还不存在,按修改后的逻辑会进入else分支直接include,这时会因为文件不存在而报错。所以建议你保留 isset($_GET['upcache']) 那个条件,并且第一次手动跑一次 /m/index.php?upcache=1 把文件先生成出来。保哥的实际做法是把判断写成 isset($_GET['upcache']) || !file_exists('index.html') || file_exists('index.html'),看起来冗余但兼容性最好——三个条件覆盖:手动刷新、文件不存在首次生成、文件存在每次更新。 ## 会不会影响SEO?百度蜘蛛抓到的内容会变吗? 蜘蛛抓到的依然是 /m/index.html 的最新内容,反而比之前更友好——因为现在每次抓取都是最新文章列表。保哥在客户站观察了两个月,百度移动端收录量提升了大概18%,时效性强的资讯类内容尤其明显。Google Search Console (https://zhangwenbao.com/google-search-console-branded-query-filter.html)里的"已编入索引页面"曲线也呈现了明显的上升趋势。 ## 除了改源码还有别的方案吗? 有。最干净的做法是写一个cron定时任务,每5分钟curl一次 /m/index.php?upcache=1,让织梦自己用upcache参数走重新生成的分支。这种方法不改源码,升级时也不会被覆盖,更适合长期维护。如果你能在发文流程里加钩子,更优雅的做法是发文成功后异步触发一次刷新,用户基本无感。 ## 织梦官方为什么默认要用 !file_exists 这种缓存策略? 织梦诞生于2004年,那个时候服务器配置普遍很低,能少跑一次数据库就少跑一次,所以默认走静态优先。今天的服务器性能已经不是瓶颈,但织梦多年没有大版本更新,这套老逻辑就被留下来了。理解这个历史背景,再看代码就不会觉得奇怪。从产品演进的角度看,这种"缓存优先"的设计在Web 1.0时代是合理的,但现代内容运营节奏完全不同了。 ## 这个问题在DedeCMS哪些版本里存在? 保哥实测过的DedeCMS V5.7 SP1、SP2、DedeBIZ 6.0以上版本都有这个问题,因为移动端模块的核心逻辑这十年没怎么变过。如果你用的是更老的DedeCMS V5.6或更早版本,可能根本没有 /m/ 目录,需要先按文档部署移动端模块再修这个洞。新版的DedeBIZ 6.x虽然界面焕然一新,但 /m/index.php 的判断逻辑保持原状,本文方案直接通用。 ## 修改后能否同时解决移动端栏目页和文章页不更新的问题? 不能。/m/index.php 只管首页,栏目页 /m/list.php 和文章页 /m/view.php 有各自的缓存判断逻辑,需要分别去改。栏目页的判断在 list.php 第30行附近,文章页在 view.php 第40行附近,改法和首页完全一样:把 !file_exists 改成 file_exists。如果嫌一个个改麻烦,可以用sed一键批量替换:sed -i 's/!file_exists/file_exists/g' /m/*.php,注意先备份。 ## 改完之后服务器CPU飙高怎么办? 说明你的站点QPS较高,纯实时生成扛不住。按第五节的TTL方案改,把5分钟内的请求合并到一次生成。如果TTL方案还是扛不住,就上Redis缓存层,把整个index.html的内容缓存到Redis,PHP只做读Redis和输出,不再走DedeCMS的模板渲染。这套方案保哥在一个日PV 200万的客户站验证过,CPU占用率从80%降到15%。 ## 如果忘记备份直接改坏了 /m/index.php,怎么恢复? 从DedeCMS官方下载包里复制对应版本的 /m/index.php 覆盖回去就行。如果不记得自己用的是哪个版本,可以查 /data/admin/ver.txt 里的版本号。如果连这个文件都丢了,用git或者宝塔面板的"文件历史"功能恢复——大部分服务器面板都有这个保险。最差情况下,从昨晚的全量备份里拉一份回来,损失最多就是当天的发文。这也是保哥反复强调"任何改动前都要先备份"的原因。 ## 修改之后 PC 端首页会受到影响吗? 不会。本文修改的只有 /m/index.php 一个文件,对应的是移动端首页生成逻辑,与PC端的 /index.php 完全独立。PC端走的是另一套缓存机制,由后台菜单的"更新主页HTML"按钮控制,本次修改不会触碰这部分逻辑。所以你可以放心修改,不会影响PC端的稳定性和性能。如果你想给PC端首页也做类似的实时更新优化,需要单独修改 /index.php 或者直接用cron定时刷新。 ## 修改之后是否需要重启nginx或PHP-FPM? 不需要。/m/index.php 是一个普通的PHP脚本文件,修改后立即生效,不需要重启任何服务。如果你修改后访问发现没生效,先排查opcache——PHP的opcache会缓存编译后的脚本,可能需要 opcache_reset() 或者重启 php-fpm 才能让新代码被加载。生产环境保哥通常会执行 service php-fpm reload 而不是 restart,前者不会中断现有连接。 ## 本文方案能否用于织梦的多站点环境? 能。如果你的服务器上跑了多个DedeCMS站点,每个站点的 /m/index.php 都需要独立修改,因为这套修改属于站点本地配置,不会自动应用到其他站点。保哥给客户做多站点改造时的标准做法是写一个shell脚本,遍历所有站点目录,自动备份并替换 /m/*.php 里的判断逻辑,配合diff检查变更内容是否符合预期,全程零失误。 ## 织梦手机版静态HTML生成:5步arctype切表法实战 - URL:https://zhangwenbao.com/dedecms-mobile-generation-static-html.html - 分类:织梦CMS教程 - 发布:2017-01-20 | 更新:2026-05-16 - 摘要:详解DedeCMS手机版生成静态HTML的arctype切表方案:CREATE TABLE双胞胎备份、RENAME原子重命名、UPDATE路径回滚、PHP-FPM调优与cron定时脚本,覆盖5.7到5.8版本差异、1146/1050/1064错误码排查与RWD方案对比。 - 关键词:DedeCMS手机站,DedeCMS静态html,arctype切表,织梦移动端SEO > **TLDR**:摘要:织梦默认手机端不能生成静态HTML,本文用给arctype表做双胞胎的SQL切表方案解决:复制一份arctype当手机端模板表,用RENAME原子切换到手机配置、跑生成输出手机列表页和文档页、再把arctype切回PC配置,全程不动现网。还给配套的Nginx路由、三种规模的性能基线、一键Bash脚本与定时任务封装,以及版本差异和常见错误码排查。 > 摘要:织梦默认手机端不能生成静态HTML,本文用给arctype表做双胞胎的SQL切表方案解决:复制一份arctype当手机端模板表,用RENAME原子切换到手机配置、跑生成输出手机列表页和文档页、再把arctype切回PC配置,全程不动现网。还给配套的Nginx路由、三种规模的性能基线、一键Bash脚本与定时任务封装,以及版本差异和常见错误码排查。 我是保哥,运维织梦DedeCMS站点已经有八年时间。这篇文章想跟大家分享一个我用了很多年、并且一直稳定有效的方法:通过切换arctype数据表,让织梦的手机版(移动端)也能生成静态HTML文件。这个需求看起来冷门,但只要你做过移动端SEO (https://zhangwenbao.com/mobile-seo-guide.html),就一定会遇到——动态PHP页面在移动端百度搜索里收录速度比静态页面慢一截,特别是新站。下面把完整步骤、底层原理、上线流程和踩过的坑全部写出来。 ## 为什么织梦默认手机端不能生成静态 先说结论:织梦的“生成”功能是按arctype表里登记的目录和模板路径来工作的,而arctype默认只存了PC端的目录和模板路径。手机端虽然有自己的模板(一般在/templets/default_m/里),但它的栏目目录信息从来没有被写进arctype,所以后台“生成HTML”按钮根本看不到手机端栏目。 这是为什么你点遍后台所有“生成”按钮,移动端永远是PHP动态访问的根本原因。要让它生成静态HTML,思路有两条: - 改源码:让织梦的生成器同时识别PC模板和手机模板。这种改动量大、容易和后续升级冲突。 - 临时切表:备份一份arctype,临时把里面的目录和模板改成手机端的,跑一次生成,再切回来。代价小,可重复,是我个人最推荐的做法。 这篇我们重点讲第二种。 ## DedeCMS 5.7 与 5.8 在生成逻辑上的差异 在动手之前你必须先确认一件事:你的织梦版本。我维护过的客户站里,5.7 SP2 和 5.8 RC1 在 arctype 表上有两处差别——5.8 给 arctype 加了 cross_tid(跨站点引用)和 sitepath(多站点根路径)两列,这两列对单站点用户没影响,但如果你机械地照搬 5.7 时代的切表脚本到 5.8,CREATE TABLE LIKE 出来的备份表会带上这两列的默认空值,跑生成时部分自定义模板里如果 if 判断了 cross_tid 会走入死分支。我在客户站上踩过这个坑,最后的修复是把切表 SQL 改成显式列出列名,而不是 SELECT *。 另一个差别:5.8 之后的 arc.partview.class.php 在 SetTypelink 方法里会按 typeid 拉一次 arctype,如果 RENAME 中途有写请求,会触发 PHP fatal。所以切表必须在低峰期、并且把后台对运营临时关闭。后面第二步会再讲怎么用一个 IP 白名单临时锁住后台。 ## 核心思路:用SQL给arctype做“双胞胎” 这套方法的精髓在于:让数据库同时拥有两份arctype表,一份对应PC配置,一份对应手机配置。需要生成谁的静态时,就把对应的那份表RENAME到dede_arctype这个生效名字下,跑完生成再切回来。 整个流程一共五步,下面一步一步写明白。先用一张表概括“三个状态”: 状态 | dede_arctype | dede_arctype1 | dede_arctype2 | 初始(PC生效) | PC配置 | — | — | 第一步后 | PC配置 | — | PC快照 | 第二步后 | PC快照(待改手机) | PC配置(下线收纳) | — | 第三步后 | 手机配置 | PC配置(下线收纳) | — | 第五步后(PC生效) | PC配置 | — | 手机配置 | 切表的本质就是让“生效名字”在 PC 配置和手机配置之间反复轮替。理解了这张表后,下面五步就只是 SQL 操作而已。 ## 复制一份arctype作为手机端模板表 登录后台,进入“系统”→“SQL命令行工具”,执行下面的SQL。注意,织梦默认的表前缀是dede_,但SQL命令里要用占位符#@__,织梦会自动替换成你站点真实的前缀(这点对多站环境特别重要): CREATE TABLE `#@__arctype2` SELECT * FROM `#@__arctype`; 执行成功后,数据库里会多出一张dede_arctype2表,结构和数据都和dede_arctype完全一致。这一步的目的是先做一份“PC配置的备份”。 稍后我们要做的事,是把dede_arctype改造成“手机配置”,等生成完静态再用dede_arctype2把PC配置还原回来。 关于索引和主键的细节:因为CREATE TABLE...SELECT不会复制原表的索引和主键,所以严格来说dede_arctype2不是一个完美的克隆。但因为我们只是把它当作中转表用,索引差异不会影响RENAME操作和数据恢复,可以放心使用。如果你担心生成期间有非常重的 reorder by id 操作,可以补一条 ALTER: ALTER TABLE `#@__arctype2` ADD PRIMARY KEY (`id`), ADD INDEX `topid` (`topid`), ADD INDEX `sortrank` (`sortrank`); 不加这条 ALTER 的话,5 万条以内的栏目数据走全表扫描也不会有明显的体感差异——我在 87 栏目和 312 栏目两个站上对比过,差距大概在 0.4 秒以内。 ## 把现网生效的arctype切换为手机端配置 继续在“SQL命令行工具”里执行: ALTER TABLE #@__arctype RENAME #@__arctype1; ALTER TABLE #@__arctype2 RENAME #@__arctype; 这两条SQL干的事: - 把当前生效的dede_arctype(PC配置)改名成dede_arctype1,相当于把它“下线收纳”; - 把第一步建好的dede_arctype2改名成dede_arctype,让它成为新的生效表。 执行完,目前数据库里有两张表:dede_arctype(即将被改成手机配置)、dede_arctype1(PC配置的快照)。 原子性提醒:这两条SQL必须一起执行,中间不能有其他人在后台发文章,否则织梦会因为找不到arctype表而报错。我一般会选择凌晨2点到4点流量低谷期操作,并且在操作前先把/dede/login.php这个后台入口在Nginx里临时改成只允许我自己的IP访问: location ~ ^/dede/ { allow 1.2.3.4; deny all; fastcgi_pass unix:/tmp/php-cgi.sock; ... } 这一招比改后台密码安全得多——任何运营同事即使在切表的 30 秒里点开了发文页面,都会被 Nginx 返回 403,不会真的命中 PHP 业务逻辑。生成跑完再把这段 allow/deny 删掉就行。 RENAME 原子性的真相:MySQL 的 RENAME TABLE 在单个语句里是原子的,但你写成两条语句中间是有间隙的。更严谨的写法是放在一个 RENAME 里: RENAME TABLE `#@__arctype` TO `#@__arctype1`, `#@__arctype2` TO `#@__arctype`; 这种写法 MySQL 会在内部用一次表锁完成两次重命名,对外完全不可见中间态。我后来都改用这种写法,前面那种两条 ALTER 只是为了讲清楚两步逻辑而已。 ## 到栏目管理里把所有栏目的目录和模板改成手机版 进入后台“核心”→“网站栏目管理”,逐个点开每个栏目,做两件事: - 把“保存目录”从a/news这样的PC路径,改成m/news这样的手机端路径; - 把“列表模板”“文档模板”“封面模板”从default/list_article.htm这种PC模板,改成default_m/list_article.htm这种手机端模板。 如果你的栏目数量很多(我之前一个客户站有87个栏目),手动改非常折磨。可以直接写一条SQL批量更新: UPDATE `#@__arctype` SET `typedir` = REPLACE(`typedir`, '/a/', '/m/'), `templist` = REPLACE(`templist`, 'default/', 'default_m/'), `temparticle` = REPLACE(`temparticle`, 'default/', 'default_m/'), `tempindex` = REPLACE(`tempindex`, 'default/', 'default_m/'); 执行前务必先备份整张arctype,因为REPLACE是字符串替换,如果你的路径里恰好有/a/这种子串(比如某个栏目的 typedir 写成了 /article/a/2024/),可能会被误改。备份命令很简单: CREATE TABLE `#@__arctype_safe_20260507` SELECT * FROM `#@__arctype`; 路径冲突自检 SQL:在执行 UPDATE 之前先跑下面这条查询,找出潜在被误伤的栏目: SELECT id, typename, typedir, templist, temparticle FROM `#@__arctype` WHERE typedir LIKE '%/a/%' AND typedir NOT LIKE 'a/%' OR templist LIKE '%default/%' AND templist NOT LIKE 'default/%'; 如果有返回行,说明你的栏目里有路径包含 /a/ 但不是以 a/ 开头的,REPLACE 会把它一起改坏。这种情况我会手工列出来用 WHERE id IN(...) 精确更新。 UPDATE 出错的回滚:如果发现 UPDATE 改坏了,立刻在“SQL 命令行工具”里跑: DROP TABLE `#@__arctype`; CREATE TABLE `#@__arctype` SELECT * FROM `#@__arctype_safe_20260507`; 这就是为什么前面强调要先建 _safe_ 备份表——它就是用来撑 1 分钟内回滚的保险。 ## 跑生成,输出手机端列表页和文档页 回到后台“生成”菜单,按以下顺序操作: - “更新栏目HTML”→ 全部更新; - “更新文档HTML”→ 全部更新; - “更新主页HTML”→ 把主页位置改成/m/index.html、模板改成default_m/index.htm,再点生成。 生成完毕,在服务器/m/目录下应该会出现完整的栏目目录树和静态HTML文件。这时候用手机访问任意一个栏目URL,都能看到纯静态的页面。 实测耗时基线:生成耗时取决于你的文档总量。我手头有三个长期跟踪的站,给你做个参考: 站点规模 | 栏目数 | 文档总数 | 全量生成耗时 | 磁盘占用 | 小站 | 12 | 3,800 | 2 分 14 秒 | 92 MB | 中站 | 87 | 34,500 | 11 分 47 秒 | 820 MB | 大站 | 312 | 121,800 | 47 分 26 秒 | 3.1 GB | 建议在生成前临时把PHP的max_execution_time调大到600秒以上,并且把 memory_limit 调到 512M。我在大站上还把 php-fpm 的 pm.max_children 临时从 50 调到 80,避免后台生成长事务把 fpm 池塞满影响前台访问。 分批生成而不是全量:如果你的文档超过 5 万篇,建议不要一次点“全部更新”,而是用“按栏目更新”分批跑,每跑完一个栏目去 /m/ 目录下用 find 抽检几个文件: find /www/wwwroot/yoursite.com/m/news -name "*.html" -newer /tmp/.gen_start -type f | head -20 find /www/wwwroot/yoursite.com/m/news -name "*.html" -size -1k -type f | head -5 第二条命令专门找小于 1KB 的 HTML——这通常是生成超时或者模板渲染出错产生的“空壳文件”。一旦发现就要查 PHP 错误日志。 ## 把arctype切回PC配置 生成完一定要切回去!不然下次发稿时,文章会按手机配置写入到/m/目录下,把PC站搞乱。继续在“SQL命令行工具”里执行: RENAME TABLE `#@__arctype` TO `#@__arctype2`, `#@__arctype1` TO `#@__arctype`; 这一步的作用: - 把刚刚跑过手机生成的dede_arctype改名成dede_arctype2,作为“手机配置”的快照保留下来; - 把第二步存起来的dede_arctype1(PC配置)改回dede_arctype,让PC重新生效。 切完之后,去后台随便点一篇PC文章,确认目录路径是a/...、模板是default/...,就说明切换成功了。 下次需要重新生成手机端静态时,只要重复第二、四、五步即可,不用再建中转表。 ## 切回后的烟雾测试清单 我每次切回 PC 配置后,会跑一遍 5 条“烟雾测试”,确认线上服务正常: - 后台“发布文章”点开,列表页能正常显示所有栏目; - 后台“生成”→“更新一篇文档”拿最新一篇试跑,输出到 /a/ 目录而不是 /m/; - 前台首页 / 栏目页用浏览器无痕模式打开,无 PHP fatal、无空白; - Nginx access.log 看最近 200 行,确认 200 比例 ≥ 99%; - 用 curl 抓一个 /m/ 目录下的旧文件,确认能 200 返回,证明 Nginx try_files 没坏。 ## 配套的Nginx路由策略 生成静态文件只是一半的工作,另一半是要让搜索引擎和真实用户访问/m/...时优先吃静态。我习惯在Nginx里加一条try_files: location /m/ { try_files $uri $uri/ $uri.html /m/index.php?$args; expires 5m; add_header Cache-Control "public, max-age=300"; } 这样请求/m/news/123.html时,Nginx会先去文件系统找静态文件,找不到才落到PHP。配合短期5分钟的浏览器缓存,对百度蜘蛛和移动用户都很友好。 ## 与 HTTPS、HSTS 的配合 站点上 HTTPS 之后,要在这条 location 里同步加 HSTS 头,避免 PC/手机端缓存的 max-age 不一致触发降级: location /m/ { try_files $uri $uri/ $uri.html /m/index.php?$args; expires 5m; add_header Cache-Control "public, max-age=300" always; add_header Strict-Transport-Security "max-age=31536000" always; add_header X-Robots-Tag "index,follow" always; } 额外加 X-Robots-Tag 是因为我曾经吃过亏——/m/ 目录被运营同事拿来放过临时活动页,他们在 robots.txt (https://zhangwenbao.com/page-types-to-block-in-robots-txt-for-ecommerce.html) 里把 /m/ 整个 Disallow 了,结果手机版整体被百度收录降权。后来我在 Nginx 里强制吐 X-Robots-Tag: index,follow,再配合 robots.txt 只屏蔽 /m/promo/ 这类临时子目录,问题就稳定了。 ## URL 二级目录方案 vs 自适应 RWD 方案 有人会问:现在响应式(RWD)这么流行,为什么还要走 /m/ 二级目录的老路子?我的回答是:织梦绝大部分 5.x 模板是 2014 年前后写的,原生 RWD 模板少而且坑多,临时改造比重做手机版要费得多。如果你的站是 2018 年之后才搭起来的、模板原生支持 viewport meta,那当然走 RWD 路线更清爽。但对于做过几年的老站、有大量历史 PC 文档需要继续吃流量,二级目录方案是迁移成本最低的。下面表对比一下: 方案 | 改造工作量 | 移动端首屏 | SEO 风险 | 适合站点 | arctype 切表生成 /m/ 静态 | 低 | 快 | 需 alternate/canonical (https://zhangwenbao.com/google-canonical-url-selection-logic.html) 互指 | 老站、海量历史文档 | 响应式 RWD 重写模板 | 高 | 取决于模板 | 低 | 新站、模板可控 | 纯 PHP 动态 + Memcache 缓存 | 中 | 取决于缓存命中 | 动态 URL 收录慢 | 不允许写文件系统的虚拟主机 | ## 性能基线与三种规模的真实数据 给你三个真实站点的“切表+生成”实测数据,方便你估算自己的窗口: - 站点 A(小型博客,3800 文档):切表 SQL 耗时 0.18 秒,全量生成 2 分 14 秒,磁盘多占 92 MB。每天凌晨 3:00 跑一次,几乎不影响白天访问。 - 站点 B(中型行业站,34500 文档):切表 SQL 耗时 0.71 秒,全量生成 11 分 47 秒,磁盘多占 820 MB。改用每天 02:30 跑增量(只对当日修改的文档生成),耗时降到 90 秒以内。 - 站点 C(大型新闻站,121800 文档):切表 SQL 耗时 2.4 秒,全量生成 47 分 26 秒,磁盘多占 3.1 GB。这种规模只能做增量,全量改成每周日凌晨跑一次。 给你一个经验公式:全量生成耗时 ≈ 文档数 × 0.022 秒(这是在 SSD + 8 核 CPU + PHP 7.4 + 5400 转 SATA 备份盘上测的)。如果你用 PHP 8.0,把 0.022 改成 0.017 左右——主要来自 OPcache 改进和字符串处理性能。 ## 一键 Bash 脚本与定时任务封装 我自己用 Bash 把上面五步封装成一个脚本 dedecms_m_static.sh,配合 cron 每天凌晨自动跑。骨架长这样: #!/usr/bin/env bash set -euo pipefail SITE=/www/wwwroot/yoursite.com DB_NAME=$(grep DB_NAME ${SITE}/data/common.inc.php | head -1 | sed -E "s/.*'(.*)';/\1/") PFX=$(grep DB_PREFIX ${SITE}/data/common.inc.php | head -1 | sed -E "s/.*'(.*)';/\1/") LOCK=/var/lock/dedecms_m_static.lock LOG=/var/log/dedecms_m_static.log # 单实例锁,防止上一轮没跑完时被定时任务再次触发 exec 200>${LOCK} flock -n 200 || { echo "$(date -Iseconds) skip: another instance running" >> ${LOG}; exit 0; } mysql_run() { mysql --defaults-file=/root/.my.cnf ${DB_NAME} -e "$1" } echo "$(date -Iseconds) === round start ===" >> ${LOG} mysql_run "CREATE TABLE IF NOT EXISTS ${PFX}arctype2 SELECT * FROM ${PFX}arctype;" mysql_run "RENAME TABLE ${PFX}arctype TO ${PFX}arctype1, ${PFX}arctype2 TO ${PFX}arctype;" # 切到手机配置 mysql_run "UPDATE ${PFX}arctype SET typedir=REPLACE(typedir,'/a/','/m/'), templist=REPLACE(templist,'default/','default_m/'), temparticle=REPLACE(temparticle,'default/','default_m/'), tempindex=REPLACE(tempindex,'default/','default_m/');" # 触发生成(curl 后台生成接口,带 cookie) curl -s -b /root/.dede_cookie -o /dev/null "${SITE_URL}/dede/makehtml_all_action.php?dopost=make&mtype=arc" curl -s -b /root/.dede_cookie -o /dev/null "${SITE_URL}/dede/makehtml_list_action.php?dopost=make" curl -s -b /root/.dede_cookie -o /dev/null "${SITE_URL}/dede/makehtml_homepage.php?dopost=make" # 切回 PC 配置 mysql_run "RENAME TABLE ${PFX}arctype TO ${PFX}arctype2, ${PFX}arctype1 TO ${PFX}arctype;" echo "$(date -Iseconds) === round end ===" >> ${LOG} # 异常告警:如果 / 目录下 a/index.html 在过去 10 分钟没被生成,触发钉钉告警 if [ -z "$(find ${SITE}/a/index.html -mmin -10 2>/dev/null)" ]; then curl -s "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"msgtype":"text","text":{"content":"[警告] DedeCMS 手机版静态生成可能失败,请检查"}}' fi 脚本里几个细节值得说一下: - flock 单实例锁:如果上一轮跑了 47 分钟没结束,定时任务再次触发会被 flock 直接拒绝,避免两份生成进程同时改 arctype。 - defaults-file:把 MySQL 密码写在 /root/.my.cnf 里(chmod 600),脚本里不出现明文密码。 - curl 后台接口:用 cookie 文件保持登录态,避免脚本里硬编码后台账号密码。具体登录态怎么持久化,可以用一次性 curl --cookie-jar 拿到,然后只读复用。 - 钉钉告警兜底:脚本最后用 find -mmin -10 判断生成是否真的产出了新文件,没产出就钉钉报警——这一步比单纯依赖脚本退出码靠谱得多。 crontab 配置: 0 3 * * * /root/scripts/dedecms_m_static.sh > /dev/null 2>&1 ## 升级与迁移注意事项 这套切表方案在以下三种场景里要特别小心: - 织梦官方 patch 修改 arctype 字段:5.7 到 5.8 之间出过 23 个 patch,其中 patch-2023-08-15 给 arctype 增加了 cross_tid 列。如果你在打 patch 之前先做了 CREATE TABLE...SELECT,再打 patch 时官方 SQL 只会 ALTER 你当前的 dede_arctype(PC 配置),不会动 dede_arctype2(手机快照)——下次切手机配置时就会因为列数不一致而失败。修复办法:每次打 patch 后立即同步给 _arctype2 也 ALTER 一遍。 - 迁移到 ECTouch (https://zhangwenbao.com/ecshop-mobile-ectouch.html) 或其他 CMS:ECTouch 不再用 arctype 表结构,迁移工具默认只读取 dede_arctype。如果你迁移时 dede_arctype 恰好处于“手机配置”状态,新站会拿到手机端的 typedir,PC 文档全部 404。迁移前一定要确认 typedir 是 a/news 这种 PC 格式。 - 主从复制延迟:如果你的 MySQL 是一主一从,RENAME TABLE 在主上是原子的,但从库重放时会有几百毫秒的间隙。这段时间如果有读请求打到从库,会 ER_NO_SUCH_TABLE 报错。生成期间最好把从库摘掉,或者在应用层切到“主库优先”。 ## 常见错误码与排查 切表过程中最高频的三个 MySQL 错误码: - 1146 (Table doesn't exist):99% 是因为 RENAME 只跑了一半就被中断了。立刻执行 RENAME TABLE dede_arctype1 TO dede_arctype 把 PC 配置恢复,然后查 mysql.err 看为什么被中断(通常是磁盘满或者表锁超时)。 - 1050 (Table already exists):你之前的某轮没有完整跑完,dede_arctype2 没被消化掉。要先 DROP TABLE dede_arctype2 再重新跑第一步。如果你不确定 dede_arctype2 里面是 PC 还是手机配置,先 SELECT * FROM dede_arctype2 LIMIT 1 看 typedir 字段值再决定要不要 DROP。 - 1064 (SQL syntax error):极有可能是你直接复制了文档里的 SQL,没把 #@__ 换成实际表前缀。织梦后台“SQL 命令行工具”会自动替换,但用 mysql 命令行直接跑就不会替换,必须手工把 #@__ 改成 dede_(或者你自己的前缀)。 ## 常见问题解答 ## 每次发新文章,都要重新切表跑一遍生成吗? 是的。这套方案的本质是“定期手动批处理”。我的解决办法是写一个shell脚本封装这五步,配合cron每天凌晨3点自动跑一次。如果你的内容更新非常频繁(每小时新增 50+ 篇),建议改成增量生成:只对当天新增/修改的文档做生成,可以用织梦自带的 task/index_task.php 改造。具体改法是把它里面的 GetMatchType 函数替换成读取 dede_archives.senddate > UNIX_TIMESTAMP(NOW())-3600 的 archives,然后只调用 MakeArc 重生成这部分文档。 ## 切表过程中如果断电或者SQL执行一半失败怎么办? 这就是为什么反复强调要在低峰期操作、并且每一步都先备份的原因。万一RENAME只成功了一半,最坏的情况是dede_arctype这张表暂时不存在,织梦后台所有功能会报错。这时候手动执行 RENAME TABLE dede_arctype1 TO dede_arctype 把PC配置先恢复,站点就能立刻恢复服务。我每次操作前都把这条“应急SQL”准备好放在剪贴板里。如果你跑的是脚本,前面提到的 flock 单实例锁会避免脚本被并发触发,但断电那一刻已经在执行的 SQL 还是可能出问题——MySQL 重启后会用 InnoDB redo log 把已提交的 SQL 重放完,所以丢的最多是最后一条没提交的语句。 ## 还有更优雅的方案吗?比如不切表? 有。两种思路:一是改 include/arc.partview.class.php 让它支持双模板路径;二是用 Cron 定时 curl 全站手机端URL,让 /m/index.php 那段缓存逻辑自己生成静态(前提是已经按我之前的文章修改了缓存判断)。但这两种都需要不少代码改动,对于不熟 PHP 的站长来说,切表法依然是性价比最高的。第三种思路是直接上 Nginx fastcgi_cache,让 PHP 动态页第一次被访问后就被 Nginx 缓存 5 分钟到磁盘——对中等流量站点效果不错,但 fastcgi_cache 的命中率对冷门 URL 不友好,热门页(百度蜘蛛常爬的那几个栏目)才会真的吃到缓存。 ## 生成出来的HTML文件占用空间会不会很大? 实测数据可参考:12 万文档、平均每个 HTML 25KB、栏目页 87 个,全量生成大约 3GB。如果磁盘紧张,建议把 /m/ 单独挂到一块更大的盘上,或者只对最近 30 天的文章做静态化、老文章保持动态。另外可以打开 Nginx 的 gzip_static,把 /m/news/123.html 同时生成一份 /m/news/123.html.gz,Nginx 会优先送 gz 版本,磁盘 IO 和带宽都能砍掉 70% 以上。 ## 百度移动端和 PC 端会不会因为内容相同被判重复? 不会,前提是你做对了两件事:一是在 PC 页 head 里加 <link rel="alternate" media="only screen and (max-width: 640px)" href="https://example.com/m/...">,二是在 /m/ 页 head 里加 <link rel="canonical" href="https://example.com/a/...">。这两个互指标签是 Google 和百度都明确推荐的做法,能让搜索引擎理解 PC 和移动是同一份内容的两种呈现,不会触发重复内容降权。 ## 切表期间发布的新文章会丢吗? 不会丢,但会写错位置。如果切表期间有运营同事偷偷发了一篇文章,这篇文章的 typedir 会拿到当前生效的 dede_arctype 里的值——也就是手机配置 m/news。表面看 PC 站会少一篇,但 dede_archives 表里这篇文章是存在的,只是 typedir 是错的。切回 PC 配置后,手工 UPDATE 这篇 archives 的 typedir 字段就能恢复。这就是为什么前面强调切表前要在 Nginx 里 deny 后台。 ## 多语言站点(中英双语)怎么处理? 同样思路,把 arctype 翻成三份:arctype(当前生效)、arctype_pc_zh、arctype_pc_en、arctype_m_zh、arctype_m_en。切表脚本里多两次 RENAME 即可。我在一个东南亚客户站上跑过这种四套配置切表,没有问题,只是要把脚本里的“锁”做得更严格——任意时刻只允许一个生成进程在跑。 ## 能不能不依赖后台“生成”按钮,直接用 PHP 命令行生成? 可以。织梦的 /plus/makehtml.php 和 /dede/makehtml_*_action.php 本质上都是几个 GetIDList + MakeArc 的循环。你完全可以写一个 cli_make.php 放在站点根目录下,include 完 common.inc.php 后调用同样的内部 API。这种纯 CLI 方案最大的好处是绕开了 PHP-FPM 的 max_execution_time、绕开了 Nginx 的 504 超时,跑大站点全量生成时稳得多。具体代码我之前在另一篇文章里贴过,这里不再展开。 ## 写在最后 切表生成静态这套方案,本质上是用“数据库视角的临时配置切换”绕开了织梦只支持单套生成配置的限制。它不改源码、不破坏升级路径,只要你在每次操作前做好备份、严格按顺序跑SQL,基本不会出问题。 我自己写过一个Bash一键脚本,把上面五步全部串起来,带备份、带断点恢复、带钉钉报警。如果有读者感兴趣,可以在评论里说一声,抽时间整理成一个开源版本发出来。 另外补一句:如果你的站点已经稳定吃到了 PC 端搜索流量,但移动端 PV 一直起不来,先别急着升级模板或者重做小程序,把这套静态化做完往往就能看到立竿见影的收录变化——我个人维护的几个站,落地这套方案两到三周内移动端百度收录量都翻倍以上。 ## 织梦批量移动栏目SQL实战:reid字段3秒改完 - URL:https://zhangwenbao.com/dedecms-column-batch-sql-statements-sub-column.html - 分类:织梦CMS教程 - 发布:2017-01-12 | 更新:2026-05-16 - 摘要:织梦想批量移动栏目,手动一个个改后台要命。本文以arctype表的父栏目和顶层栏目字段为核心,给出最小UPDATE语句、同步archives与arctiny的处理、循环引用与孤儿栏目避坑,再附80个子栏目重构的真实步骤、sortrank批量重排和三个版本的字段差异。 - 关键词:织梦SQL,dede_arctype,DedeCMS,栏目移动,reid字段 > **TLDR**:摘要:织梦想批量移动栏目,靠后台一个个拖是噩梦。核心是直接改arctype表的reid父栏目和topid顶层栏目两个字段,本文给出一行搞定的最小UPDATE语句,并讲清topid必须同步、文章表与arctiny索引怎么一起处理、执行前怎么备份、循环引用和孤儿栏目怎么避坑。再附80个SKU子栏目重构的真实步骤、URL 301保排名、多层缓存清理、sortrank批量重排和不同版本的字段差异。 > 摘要:织梦想批量移动栏目,靠后台一个个拖是噩梦。核心是直接改arctype表的reid父栏目和topid顶层栏目两个字段,本文给出一行搞定的最小UPDATE语句,并讲清topid必须同步、文章表与arctiny索引怎么一起处理、执行前怎么备份、循环引用和孤儿栏目怎么避坑。再附80个SKU子栏目重构的真实步骤、URL 301保排名、多层缓存清理、sortrank批量重排和不同版本的字段差异。 给 DedeCMS 老站做栏目结构重构,最磨人的是后台栏目管理那个拖拽界面——一次只能挪一个栏目,鼠标按住没准就误触到隔壁分类,确认按钮还得逐个点。一个 80 个栏目的电商站重构整个父子关系,纯手工拖拽要花 5 到 7 个小时;用 SQL 一行命令搞定只要 3 秒。本文把保哥过去 5 年帮 23 个 DedeCMS 客户做栏目重构积累的 SQL 实战、关联数据同步、URL 301 处理、缓存清理顺序系统整理出来。 覆盖版本:DedeCMS v5.7 SP2 / v5.8 / v6.0(DedeBIZ 衍生版)。表名前缀按官方默认 dede_,自己改过前缀的根据实际情况替换。 ## 为什么后台拖动是噩梦 DedeCMS 后台 - 核心 - 网站栏目管理界面用的是 2010 年的 jQuery UI 拖拽实现。在一个 80 个栏目的层级树上拖拽时有三个具体痛点: - 拖到目标位置高亮指示不准:经常拖到 A 栏目想放到 B 栏目下面,结果实际进了 C。每次拖完都要肉眼核对当前位置。 - 多层级展开/折叠状态频繁丢失:拖完一次树状结构会重新渲染,所有展开节点折叠回去,再拖下一个又要重新展开点几次。 - 批量同类操作每次都重复:把 30 个 SKU 子栏目从"服装-男装"挪到"服装-户外",前 29 个都做完了第 30 个一手抖拖错位置——前面的工作全部要重新核对。 保哥实测过:23 个客户里有 11 个做过完整的栏目重构。后台拖拽的平均耗时是每 100 个栏目 6 到 8 小时。SQL 一次性操作的平均耗时是每 100 个栏目 2 到 4 分钟(含备份、执行、验证、清缓存)。差距 100 倍以上。 ## dede_arctype 表结构关键字段 DedeCMS 的所有栏目都存在 dede_arctype 表里。理解几个核心字段是写 SQL 的前提: 字段 | 类型 | 含义 | 修改栏目层级时的作用 | id | mediumint unsigned | 栏目 ID(主键) | UPDATE 时 WHERE 用 | reid | mediumint unsigned | 父栏目 ID | 这就是要批量改的核心字段 | topid | mediumint unsigned | 顶级栏目 ID(多级嵌套时记最上层) | 改 reid 后需要同步更新 | sortrank | smallint unsigned | 同级栏目排序权重 | 批量移动后可能需要重排 | typename | varchar(60) | 栏目名称 | 用来定位栏目 | typedir | varchar(60) | 栏目目录名(静态生成时用) | 移动后要重新生成静态需要更新 | ispart | tinyint | 0 列表 / 1 单页 / 2 外链 (https://zhangwenbao.com/free-backlink-building-strategies.html) | 不影响移动操作 | channeltype | smallint | 内容模型 ID(默认 1 是普通文章) | 不影响移动操作 | 核心是 reid 字段。改 reid 等于改父栏目。改完后再同步 topid(顶级父)字段,整个栏目层级就重构完成。 ## 核心 SQL 一行搞定 最简单的批量移动语句: UPDATE dede_arctype SET reid = 100 WHERE id IN (12, 15, 18, 22, 25, 31, 35, 42); 这条 SQL 把 id 为 12、15、18、22、25、31、35、42 的 8 个栏目全部挂到 id 为 100 的父栏目下面。括号里栏目 ID 数量没有硬限制,一次塞几百个都行(MySQL 单条 SQL 的 IN 列表上限是 max_allowed_packet 决定的,几千个 ID 通常没问题)。 如果你想把全部子栏目从一个父栏目挪到另一个父栏目: UPDATE dede_arctype SET reid = 100 WHERE reid = 50; 这条 SQL 把所有 reid=50 的子栏目改成 reid=100。常见场景是父栏目重组:原来"服装"下面有男装/女装/童装三个子栏目,现在要把这三个挪到"零售-服装"父栏目下面,一行搞定。 ## topid 字段必须同步更新 很多教程到 UPDATE reid 就结束了,这是错的。DedeCMS 内部除了 reid 还维护 topid 字段记录"最顶层的父栏目 ID",用于面包屑导航 (https://zhangwenbao.com/google-mobile-breadcrumbs-removed-seo.html)和栏目树构建。如果只改了 reid 没改 topid,前台显示会出现栏目树错乱、面包屑 (https://zhangwenbao.com/shopify-blog-breadcrumb.html)不对、列表页内容混乱。 同步更新 topid 的写法(假设你已经把 reid 改成 100,且 100 这个栏目是顶层栏目即 reid=0): UPDATE dede_arctype SET topid = 100 WHERE reid = 100; 如果 100 不是顶层栏目,比如它本身的 reid 是 50,那 topid 应该是 50 的 topid。这种情况下用嵌套查询: UPDATE dede_arctype t1 SET t1.topid = (SELECT t2.topid FROM (SELECT topid FROM dede_arctype WHERE id = 100) t2) WHERE t1.reid = 100; 注意 MySQL 不允许直接在 UPDATE 的子查询里查同一张表,要用衍生表(再套一层 SELECT)。 更保险的做法是分两步:先查出 100 的 topid 值,再写一条 UPDATE 直接赋值。 ## 关联数据同步:文章表的处理 DedeCMS 的文章数据存在 dede_archives 表里,每篇文章有一个 typeid 字段记录所属栏目。如果只是改 dede_arctype 的层级关系,dede_archives.typeid 不需要动——文章的"直接所属栏目"没变,只是这个栏目挂到不同父栏目下了。 但有些场景下确实需要把文章批量从一个栏目挪到另一个栏目(不是改层级而是真的换栏目归属): -- 把原本属于栏目 50 的所有文章挪到栏目 100 UPDATE dede_archives SET typeid = 100 WHERE typeid = 50; 同时还需要同步更新 dede_arctiny 表(DedeCMS 的内容索引表): UPDATE dede_arctiny SET typeid = 100 WHERE typeid = 50; dede_arctiny 这张表很多新手都忽略——它存的是文章 ID 与栏目、发布时间的简略关系,是 DedeCMS 内部做列表分页和性能优化用的。两张表的 typeid 必须保持一致,否则前台列表会少文章。 ## 执行前必须备份 SQL 操作的爽快和它的危险性是同等的。一条错的 UPDATE 让全站栏目结构错乱,恢复要花一天。执行前必须备份。 最简单的备份方式: -- Linux mysqldump -u root -p dedecms_db dede_arctype dede_archives dede_arctiny > backup_$(date +%Y%m%d_%H%M%S).sql -- Windows mysqldump -u root -p dedecms_db dede_arctype dede_archives dede_arctiny > backup_20260512.sql 如果不能命令行 mysqldump,phpMyAdmin 导出这三张表也行。备份文件先下载到本地保存。 备份后建议在 phpMyAdmin 里做一次"试跑"——把 UPDATE 改成 SELECT 看看影响的行: SELECT id, reid, topid, typename FROM dede_arctype WHERE id IN (12, 15, 18, 22, 25, 31, 35, 42); 核对返回的栏目是不是你预期要改的那批。确认无误再执行真正的 UPDATE。 ## 实战案例:80 个 SKU 子栏目的重构 保哥 2024 年帮一个母婴电商客户重构栏目。客户原来的结构是: 母婴用品(id=1) ├── 奶粉(id=10) │ ├── 婴儿奶粉(id=101) │ ├── 幼儿奶粉(id=102) │ └── 学生奶粉(id=103) ├── 尿不湿(id=20) │ └── ...(25 个 SKU 子栏目) └── 童装(id=30) └── ...(50 个 SKU 子栏目) 客户的需求是按"使用场景"重组:婴儿期、幼儿期、儿童期三个新顶级,把所有 SKU 子栏目按目标年龄段重新分配。 第一步:先在 dede_arctype 里新增三个顶级栏目"婴儿期 0-1 岁"(id=200)、"幼儿期 1-3 岁"(id=201)、"儿童期 3-12 岁"(id=202)。后台手动加,3 个栏目大约 2 分钟。 第二步:导出当前 dede_arctype 表,在 Excel 里标注每个 SKU 子栏目应该归到哪个新顶级。80 个 SKU 大约 30 分钟。这步是脑力工作不是技术问题。 第三步:写 SQL 批量更新。三条 UPDATE: -- 婴儿期 0-1 岁 UPDATE dede_arctype SET reid=200, topid=200 WHERE id IN (101, 202, 305, 410, ...); -- 幼儿期 1-3 岁 UPDATE dede_arctype SET reid=201, topid=201 WHERE id IN (102, 203, 307, 412, ...); -- 儿童期 3-12 岁 UPDATE dede_arctype SET reid=202, topid=202 WHERE id IN (103, 204, ...); 第四步:清理 dede_arctype 里原来的"奶粉""尿不湿""童装"三个旧父栏目——它们的所有子栏目都搬走了,但栏目本身还在。如果完全用不到了就 DELETE,否则就标记为隐藏。 第五步:DedeCMS 后台 - 生成 - 更新栏目缓存。这步至关重要——DedeCMS 在 data/cache/ 目录下有栏目结构缓存,SQL 直接改库后缓存还是旧的。不清缓存前台列表会出错。 第六步:DedeCMS 后台 - 生成 - 重新生成所有栏目静态页面。这步只在你用了静态化(HTML 生成)的站点才需要。 整个操作(含备份、SQL、清缓存、重生成)约 50 分钟。客户原本预算 8 小时,实际省下 6 小时多。 ## URL 301 重定向:保 SEO 排名 栏目重构最容易忽略的副作用是 URL 变化。原本"母婴用品/奶粉/婴儿奶粉"的栏目 URL 是 /list/10/101/ 或者 /naifen/yinger-naifen/,重构后变成 /list/200/101/ 或 /yingerqi/yinger-naifen/。Google 已经收录的老 URL 全部 404,SEO 流量会断崖。 处理方法:在 Nginx(或 IIS)加 301 重定向规则。Nginx 例子: location / { # 旧栏目 URL 301 到新栏目 rewrite ^/naifen/yinger-naifen/(.*)$ /yingerqi/yinger-naifen/$1 permanent; rewrite ^/naifen/youer-naifen/(.*)$ /youerqi/youer-naifen/$1 permanent; # ...每个变化的栏目一条规则 } 如果你的栏目 URL 数量超过 100,不要手写 rewrite——保哥的做法是先用 SQL 把变化前后的 URL 对应关系导出成 CSV,再写一个 Python 脚本生成 Nginx 配置或 .htaccess 规则。 更现代的做法是在 DedeCMS 里加一个轻量级 PHP 处理器,按数据库映射表做 301 跳转——这样不需要重启 Nginx。 ## 清缓存的多个层级 DedeCMS 的栏目数据有多层缓存,SQL 改完不清缓存前台不会变。按以下顺序清理: - 栏目缓存:DedeCMS 后台 - 系统 - 更新栏目缓存(或 data/cache/inc_catalog_base.inc 文件直接删)。 - 文档关键词缓存:data/keywords.dat。 - 导航菜单缓存:data/cache/inc_menu.inc 文件。 - 整站静态文件:如果用了 HTML 静态化,去后台"一键更新网站"重新生成。 - 浏览器缓存:Ctrl+F5 强制刷新自己测试。 - CDN 缓存:如果用了 CDN,去 CDN 控制台批量刷新栏目页面。 第 1 步是最关键的——保哥见过太多人改完 SQL 看前台没变就以为 SQL 没生效,反复重跑 SQL 把数据搞乱,根因就是没清栏目缓存。 ## 不同 DedeCMS 版本的差异 DedeCMS 5.7 SP2、5.8、6.0(DedeBIZ 版)在 arctype 表上几乎没有差异,UPDATE SQL 通用。但有几个细节版本依赖: - v5.7 SP2 之前的版本没有 topid 字段,只有 reid——这种老版本只需要改 reid 不需要管 topid。 - v5.8 引入了 ishidden 字段控制栏目是否隐藏——批量移动时如果某些栏目要标记隐藏,多加一个 UPDATE ishidden=1。 - v6.0 DedeBIZ 版加了"管理员组栏目权限"机制——栏目移动后要看权限表(dede_admintype 等)有没有跟着同步。 升级版本前先看一下 dede_arctype 的表结构是否变化(DESCRIBE dede_arctype 看一下),有新字段不要慌,写 SQL 时把新字段的值也带上即可。 ## 排序权重 sortrank 的处理 批量移动栏目后,新父栏目下原本可能已经有几个子栏目,加上挪过来的批新栏目,整体排序可能错乱。sortrank 字段的值是数字,数字越大显示越靠前。 批量重排序的写法: -- 按 ID 升序重设 sortrank SET @rank = 0; UPDATE dede_arctype SET sortrank = (@rank := @rank + 1) WHERE reid = 100 ORDER BY id ASC; 这条 SQL 把 reid=100 下面的所有子栏目按 id 升序重新分配 sortrank 1、2、3、...。 如果你想按栏目名拼音排序,那 ORDER BY 改成 typename ASC。 ## 常见的几个反复出现的坑 - UPDATE 漏写 WHERE:UPDATE dede_arctype SET reid=100 不加 WHERE 会把所有栏目的 reid 都改成 100,瞬间全站栏目层级崩溃。执行前一定要看清楚 WHERE 条件是否完整。 - reid 指向了不存在的栏目 ID:UPDATE reid=999 但 999 这个栏目不存在,会让对应子栏目变成"孤儿",后台和前台都看不到。执行前确认目标 ID 存在。 - 循环引用:UPDATE 让栏目 A 的 reid 指向栏目 B,但 B 的 reid 是 A——这种循环引用会让前台递归遍历时栈溢出。执行前画一下层级图避免循环。 - topid 没同步:前面提过的坑,再强调一次——只改 reid 不改 topid,面包屑和栏目树会乱。 - 跨服务器 SQL 直接复制:从开发环境 dump 出的 SQL 拿到生产环境直接跑,因为栏目 ID 不一致全部错位。SQL 移植要先核对两边的 ID 对应关系。 ## 用脚本批量生成 UPDATE 语句 当要批量移动的栏目数量超过 30 个,手写 IN(...) 列表容易出错。保哥的工程化做法是用 Excel 或者 CSV 维护"栏目 ID 到目标父栏目"的映射表,然后用脚本生成 SQL。Python 的例子: import csv # CSV 字段:栏目 ID, 新父栏目 ID, 新顶层 ID with open('mapping.csv') as f: reader = csv.DictReader(f) by_parent = {} for row in reader: key = (int(row['new_reid']), int(row['new_topid'])) by_parent.setdefault(key, []).append(int(row['id'])) for (reid, topid), ids in by_parent.items(): ids_str = ','.join(map(str, ids)) print(f"UPDATE dede_arctype SET reid={reid}, topid={topid} WHERE id IN ({ids_str});") 跑这个脚本会按"目标父栏目"分组输出多条 UPDATE。生成的 SQL 复制到 phpMyAdmin 或者命令行 mysql 执行即可。500 个栏目映射用脚本生成大概 1 秒,手写要半天还容易出错。 ## 常见问题解答 ## UPDATE dede_arctype 改 reid 后前台栏目结构没变化怎么处理 99% 是栏目缓存没清。DedeCMS 后台 - 系统 - 更新栏目缓存按一下;如果还不行直接删除 data/cache/inc_catalog_base.inc 文件,DedeCMS 下次访问时会重新生成。如果你用了 HTML 静态化,还需要去后台"一键更新网站"重新生成栏目静态页面。Ctrl+F5 刷新自己测试,看不到就开无痕窗口验证。 ## 批量移动栏目后文章的归属会自动跟着变吗 不会。dede_arctype 改的只是栏目层级关系(reid 父子关系)。文章存在 dede_archives 表里,每篇文章的 typeid 字段记录直接所属的栏目 ID。这个字段不会因为父栏目变了而自动更新——文章依然属于原栏目,只是这个栏目挂到了新父栏目下面。如果你需要批量把文章从一个栏目搬到另一个栏目,需要单独 UPDATE dede_archives SET typeid=新栏目 WHERE typeid=旧栏目,并且同步更新 dede_arctiny 表。 ## topid 字段是什么作用,必须改吗 topid 字段记录的是"最顶层父栏目的 ID",DedeCMS 用它做面包屑导航和栏目树递归优化。如果只改 reid 不改 topid,前台显示会出现栏目树错乱、面包屑指向错误的顶层栏目。所以批量移动栏目后必须同步更新 topid。最简单的写法是先查目标父栏目的 topid 值,然后 UPDATE dede_arctype SET topid=查到的值 WHERE reid=目标父栏目 ID。 ## 批量移动后 URL 变了,SEO 流量会受影响吗 会,而且影响很大。Google 已经索引的老 URL 全部 404,对应的搜索流量会消失。处理方法是在 Nginx 或 IIS 配 301 重定向把老 URL 跳到新 URL,保留权重传递。批量栏目重构后立刻配置 301,最好在重构前先导出老 URL 清单备用。301 配好后 Google 通常需要 2 到 6 周完成索引切换。 ## 能不能用 SQL 一次性新建多个栏目 可以,用 INSERT。比如 INSERT INTO dede_arctype (reid, topid, typename, typedir, ispart, channeltype) VALUES (0, 0, '婴儿期 0-1 岁', 'yingerqi', 0, 1), (0, 0, '幼儿期 1-3 岁', 'youerqi', 0, 1)。但要注意 typedir 字段如果重复会导致 URL 冲突,typename 重复 DedeCMS 不会阻止但管理上会混乱。批量新建栏目后还要去后台"更新栏目缓存"才生效。 ## DedeCMS 5.7 SP2 和 5.8 之间的 dede_arctype 字段有变化吗 大部分字段相同。5.8 新增了 ishidden 字段(控制栏目是否在前台菜单隐藏,但内容依然能访问)和 cross 字段(跨栏目调用配置)。批量移动 SQL 用的 reid、topid 字段两个版本都有。如果你的目标是从 5.7 升 5.8,先升级 DedeCMS 再做栏目重构,避免字段不一致的兼容问题。 ## 批量 UPDATE 会不会锁表影响线上访问 会短暂锁定 dede_arctype 表。但即便是 1000 个栏目的批量 UPDATE,MySQL InnoDB 引擎的锁定时间也通常在 100 毫秒以内。对正在加载栏目列表的用户来说几乎无感知。如果你的站点流量极大(每秒数百次栏目查询),建议在低峰时段执行;普通中小站点直接执行没有可见影响。 ## 能否用 SQL 把栏目转换成单页或外链 可以。ispart 字段 0 表示列表栏目(默认),1 表示单页(content 字段存内容),2 表示外链(typeurl 字段存目标 URL)。批量转换 UPDATE dede_arctype SET ispart=2, typeurl='https://example.com/' WHERE id IN (10, 20, 30)。注意转成单页后 content 字段也要写入内容否则前台空白;转成外链后栏目本身的子栏目和文章会失效。 ## 从 DedeCMS 迁出的现实选择 这一节虽然不直接关系到批量移动栏目的 SQL,但保哥每次帮客户重构栏目时几乎都会被问到:"这次重构后值不值得继续用 DedeCMS"。给一个实用的答案。 DedeCMS 官方在 2018 年宣告停止商业授权销售(创始人离世后维护团队解散),目前的 5.7 SP2 和 5.8 都是社区维护版本。2026 年还在用 DedeCMS 的站点主要分三类: - 老内容站:靠 SEO 流量吃饭的内容站,DedeCMS 的栏目和文章结构对 SEO 友好(URL 简洁、HTML 干净),换平台就是 SEO 重置。这类站点建议继续用 DedeCMS 直到流量不足以支撑维护成本。 - 个人博客和企业宣传站:访问量小、改动需求低的站点。继续用 DedeCMS 边际成本低。 - 电商分类站:流量集中在商品类目页的站点。如果电商功能要扩展(支付、物流、会员),DedeCMS 力不从心,建议迁到 WordPress + WooCommerce 或者其他现代电商。 迁出 DedeCMS 的最佳路径是 WordPress——两者都是 PHP 系,数据库结构虽然不同但可以写转换脚本。保哥手里有现成的 dedecms-to-wordpress 转换脚本,处理 5000 篇文章的小站迁移大约 4 到 6 小时。栏目层级、文章、附件、评论都能迁;TAG 标签需要手工核对;自定义字段需要额外映射。 不建议迁去 Drupal、Joomla 等其他 CMS——这些平台的国内社区已经太小,遇到问题求助困难。Magento、Shopify (https://zhangwenbao.com/shopify-seo-ai-optimization-playbook.html) 适合纯电商但不适合内容站。Hugo、Jekyll 等静态站生成器适合个人博客但不适合需要后台运营的站点。WordPress 是绝大多数 DedeCMS 用户最自然的迁移目标。 ## SQL 操作审计与日志 正经的生产站点必须做 SQL 操作审计。DedeCMS 自己不提供这个功能,但你可以在 MySQL 层面开 general log 或 binary log 记录所有执行过的 SQL,遇到问题能回溯查证。 开启 binary log 的简单配置(在 my.cnf 的 [mysqld] 段添加): log-bin = /var/lib/mysql/mysql-bin binlog-format = ROW expire_logs_days = 14 开启后所有 UPDATE / INSERT / DELETE 都会记录到 binlog 里,可以用 mysqlbinlog 命令查看。如果你执行了一条错误的 UPDATE,binlog 能帮你定位"哪一刻发生了什么"并按行级别恢复数据。binlog 也是 MySQL 主从复制的基础设施。 对小站点来说 binlog 占用磁盘约 1% 到 5% 的数据量(按 ROW 格式),代价小但安全保障大。强烈建议开启。 ## 权威参考资料 ## 织梦DedeCMS报CGP错误:3种修复实战指南 - URL:https://zhangwenbao.com/dedecms-please-set-request-order-ini-value-to-include-cgp.html - 分类:织梦CMS教程 - 发布:2017-01-05 | 更新:2026-06-02 - 摘要:织梦CGP报错修复需要根据服务器权限选择策略。本指南拆解request_order配置背后的PHP安全机制、4步验证流程、common.inc.php修改的安全加固清单、6条避坑建议,并诚实告诉你为什么2026年应该把这次修复作为迁移到WordPress或现代CMS的契机。 - 关键词:DedeCMS,织梦CMS,PHP配置,服务器迁移,CMS运维 > **TLDR**:摘要:织梦报CGP错误,根子在request_order这项PHP配置,修法要按服务器权限选。本文拆清报错的真实含义,给出三条路径——独立服务器或VPS改php.ini、虚拟主机改common.inc.php、共享主机用.htaccess,配三种方法的对比与决策树,再附九年踩坑速查表和六条避坑建议,最后诚实告诉你为什么2026年该把这次修复当成迁到WordPress或现代CMS的契机。 > 摘要:织梦报CGP错误,根子在request_order这项PHP配置,修法要按服务器权限选。本文拆清报错的真实含义,给出三条路径——独立服务器或VPS改php.ini、虚拟主机改common.inc.php、共享主机用.htaccess,配三种方法的对比与决策树,再附九年踩坑速查表和六条避坑建议,最后诚实告诉你为什么2026年该把这次修复当成迁到WordPress或现代CMS的契机。 ## 保哥踩这个坑的来龙去脉 大家好,我是保哥。这个报错保哥从2014年第一次接触DedeCMS起就遇到过——那一年帮一家小公司搬家,从万网共享主机迁到一台独立服务器,PHP升到5.4,结果织梦后台一打开就是一行刺眼的红字: DedeCMS Error: (PHP 5.3 and above) Please set 'request_order' ini value to include C,G and P (recommended: 'CGP') in php.ini, more... 安装界面打不开、后台进不去、前台还能勉强渲染——那一晚保哥重装了3次PHP,最后才搞明白根本不需要重装,只要改一个配置项。这个报错十年来在织梦圈子里一直存在,只要你的PHP是5.3以上、且服务商默认把request_order设成了"GP"(不含C),就会触发。 这一篇保哥把DedeCMS这个CGP报错的所有解决方法、各方法的适用场景、以及背后的PHP安全机制原理全部讲清楚。所有命令保哥都在CentOS 7+PHP 5.6/7.4/8.0三套环境验证过,DedeCMS用的是V5.7 SP2 UTF-8版本。文末还附上保哥过去9年帮40+客户处理织梦搬家的踩坑速查表。 ## 报错的真实含义:request_order是什么 request_order是PHP的一个ini配置项,控制$_REQUEST这个超全局变量从哪些来源合并数据。可选的字母含义如下: - G:从$_GET(URL查询参数)取 - P:从$_POST(表单提交)取 - C:从$_COOKIE(浏览器Cookie)取 - E:从$_ENV(环境变量)取,几乎不用 - S:从$_SERVER(服务器变量)取,几乎不用 PHP 5.3之后默认值是"GP",即$_REQUEST不再包含Cookie数据。这是出于安全考虑——Cookie可以被用户篡改,混入$_REQUEST容易让程序员误以为它是可信输入。而DedeCMS的会话和后台登录状态依赖$_REQUEST读取Cookie字段(这是织梦老代码的设计选择,从今天的安全标准看不算优秀但也能用)。所以织梦在初始化时会检测request_order的值,如果不包含C就直接报错拒绝运行。 include/common.inc.php里的关键代码大致是: if (strtoupper(ini_get('request_order')) == 'GP') { exit("DedeCMS Error: ... Please set 'request_order' ini value to include C,G and P"); } 知道这个原理,就理解了所有解决方法都围绕让request_order包含C展开。 ## 方法一:修改php.ini(独立服务器/VPS推荐) 如果你有PHP配置文件的修改权限——比如自己的VPS、阿里云ECS、独立服务器——这是最规范、最一劳永逸的解决方案。 ## 找到你正在使用的php.ini路径 不同环境php.ini路径不同。最可靠的方法是写一个临时PHP文件phpinfo.php: <?php phpinfo(); ?> 把它放到网站根目录访问https://你的域名/phpinfo.php,搜索关键字Loaded Configuration File,那一行显示的就是当前生效的php.ini路径。命令行环境也可以执行: php --ini 输出里Loaded Configuration File那一行就是。安全提示:调试完一定要把phpinfo.php删掉,留着会泄露服务器配置给攻击者,保哥见过攻击者通过留下的phpinfo页面识别PHP版本+扩展,然后针对性扫漏洞的真实案例。 ## 修改request_order 用编辑器打开php.ini,搜request_order,找到这一行: ; This directive determines which super global data (G,P,C,E & S) should ; be registered into the super global array REQUEST. request_order = "GP" 改成: request_order = "CGP" 顺便建议把variables_order也确认一下,应该是"GPCS"或"EGPCS": variables_order = "EGPCS" ## 重启Web服务 php.ini改完必须重启PHP-FPM或Apache才会生效。常见命令: # Nginx + PHP-FPM systemctl restart php-fpm systemctl reload nginx # Apache + mod_php systemctl restart httpd # CentOS / RHEL systemctl restart apache2 # Ubuntu / Debian # 宝塔面板用户可以在面板上点击 软件商店 → PHP → 重启 重启后再访问织梦后台,CGP报错应该消失。如果没消失,多半是有多个PHP版本(比如系统装了5.4和7.0),你改的不是Web实际加载的那个。回phpinfo.php再确认一次Loaded Configuration File路径,确保你改的就是这个文件。 ## 修改后的验证步骤 改完不要直接相信,按以下4步验证: - 访问phpinfo.php,搜request_order,确认值已变为CGP。 - 访问http://yourdomain/install/看安装界面是否正常打开。 - 访问http://yourdomain/dede/看后台登录页是否正常。 - 登录后台执行一次"系统→系统基本参数"看是否能保存成功(保存动作会触发对$_REQUEST的完整调用)。 ## 方法二:修改common.inc.php(虚拟主机推荐) 虚拟主机用户经常没有php.ini修改权限,或者改了不生效。这时候直接绕过织梦的检测: ## 定位检测代码 打开/include/common.inc.php,搜索字符串request_order,会找到类似下面的代码: if (PHP_VERSION >= '5.3.0' && strtoupper(ini_get('request_order')) == 'GP') { exit("DedeCMS Error: (PHP 5.3 and above) Please set 'request_order' ini value to include C,G and P"); } ## 两种修改思路 思路A(推荐):注释掉检测,但手工合并$_COOKIE到$_REQUEST。把上面代码改成: // 检测取消,手工合并 $_COOKIE 到 $_REQUEST foreach ($_COOKIE as $_k => $_v) { if (!isset($_REQUEST[$_k])) $_REQUEST[$_k] = $_v; } 这样既绕过了检测,又保证了织梦后续代码能从$_REQUEST里读到Cookie数据。这是保哥过去9年使用最多的方案,对42个客户全部生效。 思路B(不推荐但常见):直接注释掉检测代码。把整段判断改成空: // if (PHP_VERSION >= '5.3.0' && strtoupper(ini_get('request_order')) == 'GP') { // exit("..."); // } 这种做法的隐患是织梦后续代码仍然假设$_REQUEST里有Cookie。如果Cookie相关功能不工作(比如后台登录后立刻跳回登录页),就需要回到思路A。保哥见过3次这个坑——绕过了检测但登录死循环。 ## 修改后必须做的安全加固 注意:织梦原本检测request_order是为了它自己的运行需要,跟安全没有直接关系。但修改common.inc.php本身意味着你正在改核心代码,必须做3件事: - 备份原文件:cp include/common.inc.php include/common.inc.php.bak.20260513。下次升级织梦时这个修改可能被覆盖。 - 记录修改清单:在团队Wiki里写下"何时改了什么文件、为什么改、改了什么内容"。 - 设置文件监控:用tripwire或简单的md5sum定期校验核心文件,避免未授权修改。 ## 方法三:虚拟主机.htaccess方式(共享主机绝招) 极少数共享主机不允许改php.ini也不让改源码(比如某些托管型织梦服务),这时候可以用.htaccess注入PHP配置(前提:Apache+mod_php且AllowOverride All)。 在网站根目录的.htaccess里加: <IfModule mod_php5.c> php_value request_order "CGP" php_value variables_order "EGPCS" </IfModule> <IfModule mod_php7.c> php_value request_order "CGP" php_value variables_order "EGPCS" </IfModule> <IfModule mod_php.c> php_value request_order "CGP" php_value variables_order "EGPCS" </IfModule> 这种方法的限制是:只对Apache+mod_php生效。Nginx或PHP-FPM不读.htaccess,需要其他方法。保哥实测过万网、阿里云虚拟主机、华为云Stack共享主机,三家中只有万网一家走mod_php可以用这招。 ## 三种方法对比与选择决策树 方法 | 适用场景 | 修复时间 | 持久性 | 风险 | php.ini修改 | 独立服务器/VPS | 10分钟 | 永久 | 低 | common.inc.php修改 | 虚拟主机/无php.ini权限 | 5分钟 | 升级后失效 | 中 | .htaccess方式 | Apache+mod_php共享主机 | 3分钟 | 持久 | 低 | local.user.ini | FastCGI+特定面板 | 5分钟 | 持久 | 低 | 宝塔/cPanel面板设置 | 带控制面板的服务器 | 2分钟 | 永久 | 低 | 保哥的决策树: - 有完整服务器权限+独立PHP环境→优先用方法一(修改php.ini)。 - 用宝塔/cPanel面板→直接在面板的PHP配置里改request_order,本质上还是改php.ini但操作更直观。 - 没有php.ini权限但Apache+mod_php→用方法三(.htaccess)。 - 没有php.ini权限+Nginx/PHP-FPM→用方法二(改common.inc.php)。 - 多个PHP版本共存→改对应版本的php.ini文件,phpinfo一定要先看清楚。 ## 保哥过去9年踩坑速查表 处理过40+客户的织梦CGP错误后总结的6个最常见的"改了还不生效"陷阱: - 改了错的php.ini。一台服务器装了多个PHP版本,CLI用的是5.6、Web用的是7.4,你改的是5.6的不生效。解法:phpinfo.php确认Loaded Configuration File路径。 - 修改了php.ini但忘记重启服务。这是最低级也最常见的错误。解法:systemctl restart php-fpm。 - 修改文件后保存但带了BOM。某些Windows编辑器保存PHP文件会加UTF-8 BOM,导致common.inc.php执行异常。解法:用Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html)保存为"UTF-8无BOM"或用VS Code的"Save with Encoding"功能。 - OPcache缓存了旧版本。修改common.inc.php后OPcache仍然加载旧版本。解法:执行service php-fpm reload或在php.ini里临时关闭OPcache验证。 - 权限问题导致.htaccess不生效。AllowOverride None会让.htaccess失效。解法:检查Apache主配置文件里AllowOverride All是否设置。 - 升级DedeCMS后修改丢失。直接覆盖式升级会覆盖common.inc.php。解法:每次升级前先备份,升级后立刻重新应用补丁。 ## 真实案例:客户U的搬家全过程 2025年12月保哥帮客户U(一家工业产品贸易公司)做织梦搬家,从一台共享主机迁到阿里云ECS。完整过程: - 搬家前夜:备份数据库+全站文件+原服务器php.ini,记录原服务器PHP版本5.6.40。 - 新服务器配置:CentOS 7+Nginx 1.20+PHP-FPM 7.4。安装完先跑php --ini记录路径。 - 第一次访问失败:CGP错误出现。phpinfo.php显示Loaded Configuration File为/etc/php.ini,request_order值为GP。 - 应用方法一:编辑/etc/php.ini改为request_order = "CGP",systemctl restart php-fpm。 - 第二次访问:CGP消失但出现新错误"Deprecated: Required parameter $sFirst follows optional parameter"——PHP 8.x新警告。降到PHP 7.4后正常。 - 验证:完成1.4节4步验证,全部通过。 - 整体搬家耗时:3.5小时(其中CGP+PHP版本兼容性问题占1.5小时)。 这个案例的核心教训是织梦搬家千万不要一次性升到PHP 8.x——织梦5.7的代码包含大量PHP 8.x不兼容的写法(弃用的mysql_*函数、过时的Optional parameter顺序等)。保哥的推荐版本是PHP 7.4,稳定性和兼容性最好。 ## 常见踩坑场景的6条避坑建议 - 搬家前先在新环境跑一遍空织梦测试。不带数据先装一个全新的DedeCMS看是否CGP错误,能提前发现问题。 - 多PHP版本环境用version-specific php.ini。CentOS下用/etc/php-7.4.ini这种命名方式,避免混淆。 - 修改common.inc.php时同步修改index.php检测。某些DedeCMS版本在index.php里也有CGP检测,只改common.inc.php不够。grep一遍request_order找到所有检测点。 - 启用OPcache前确保所有CGP相关修改已生效。OPcache的"file change check"功能要打开(opcache.validate_timestamps=1),否则修改后OPcache不会刷新。 - 使用Docker容器化织梦时把php.ini写进Dockerfile。比如RUN echo "request_order=CGP" >> /usr/local/etc/php/php.ini,否则容器重建后配置丢失。 - 定期审计织梦的核心文件是否被恶意修改。织梦因为长期不更新+老代码安全漏洞多,是黑产渗透的高频目标。建议每月对核心文件做MD5校验。 ## 2026年织梦DedeCMS的现状和迁移建议 保哥不得不说一句:织梦DedeCMS的官方在2021年就基本停止维护了,最后一个稳定版本是V5.7 SP2。在2026年还在用织梦的站点面临三个问题: - PHP 8.x不兼容。织梦的mysql_*函数在PHP 7就已弃用、PHP 8.x完全移除。新服务器越来越难找到PHP 7.4环境。 - 安全漏洞历史问题严重。织梦过去5年累积的SQL注入 (https://zhangwenbao.com/dedecms-membership-center-pm-php-injection-vulnerability-repair-method.html)、文件上传、XSS漏洞CVE超过50个,没有官方补丁的情况下风险持续累积。 - 商用版权风险。织梦的商用授权问题在2021年集中爆发,企业用户被追讨授权费的案例上百起。新站绝对不应该用织梦。 保哥的迁移推荐: - 个人博客/小型企业站→迁移到WordPress(最成熟生态,免费)。 - 中型企业官网→迁移到帝国CMS或EmpireCMS(国产CMS活跃维护)。 - 有开发能力→重构到Headless (https://zhangwenbao.com/headless-cms-seo-infrastructure-rebuild-sitemap-meta-canonical-redirect.html) CMS(Strapi/Sanity)+ Next.js/Nuxt前端。 - 暂时无法迁移→至少升级到PHP 7.4+完整应用安全补丁包+加上WAF防护。 ## 常见问题解答 ## 修改php.ini后CGP报错仍然存在怎么办? 三个排查方向:第一确认你改的php.ini是Web实际加载的那个(phpinfo.php确认);第二确认服务已重启(systemctl status php-fpm看是否真的reload);第三确认OPcache已刷新(重启php-fpm会刷新,但如果用了mod_php需要重启Apache)。三个都做了还不行,可能是宝塔等面板有自己的"用户级php.ini"覆盖了系统配置,需要在面板上单独改。 ## 共享主机改不了php.ini也不让用.htaccess怎么办? 三个选择:第一改common.inc.php绕过检测(详见方法二);第二换主机商——2026年仍然不开放任何PHP配置自定义的共享主机已经很少了;第三迁移到WordPress——比起跟织梦兼容性问题斗争,迁移成本可能更低。保哥的实际经验是大约30%的客户最终选择了直接迁移。 ## 修改common.inc.php会不会被升级覆盖? 会。每次DedeCMS升级(虽然官方已不更新但社区还有补丁包发布)都会覆盖核心文件。保哥的应对方案是:建一个独立的fix_request_order.php脚本,每次升级后跑一次自动重新应用补丁。脚本逻辑很简单——读common.inc.php→检测是否有补丁标识注释→没有就插入补丁代码→保存。 ## 这个错误跟PHP安全漏洞有关吗? 没有直接关系。request_order的"GP"默认值是PHP 5.3的安全增强,避免开发者把Cookie当作可信输入。织梦对"CGP"的依赖是织梦自身代码的设计选择,不是安全漏洞。但织梦的代码本身有大量真正的安全漏洞,建议同时检查并应用社区维护的安全补丁。 ## 升级到PHP 8.x后织梦报新错误怎么办? 织梦5.7对PHP 8.x兼容性差。常见问题:mysql_*函数移除、参数顺序问题、Optional参数前置弃用警告。两个解决方案:第一降回PHP 7.4稳定运行;第二应用社区维护的PHP 8兼容补丁包(dedebiz论坛有发布)。保哥强烈建议降到PHP 7.4而不是打补丁,因为织梦5.7的整体架构跟PHP 8.x理念差距太大,打补丁是治标不治本。 ## 方法二修改后Cookie功能不工作怎么办? 这是因为只注释了检测但没有手工合并$_COOKIE到$_REQUEST。按方法二思路A的代码——在注释检测的同一位置加上foreach ($_COOKIE as $_k => $_v) { if (!isset($_REQUEST[$_k])) $_REQUEST[$_k] = $_v; }。这样既绕过了检测又保证织梦后续代码能从$_REQUEST读到Cookie数据。 ## 有没有一键修复脚本? 保哥过去几年写过几版自动化脚本但都不通用——服务器环境差异太大、PHP路径不同、面板各异。建议手工按方法一/二/三做,每一步都先在phpinfo.php里验证再继续,整个过程10-15分钟足够。如果你要在多台服务器上批量修复,可以用Ansible Playbook管理——保哥的GitHub有发过一个简化版的dede-fix.yml文件,社区可以参考。 ## 修复后是不是就可以安心继续用织梦? 不是。CGP报错只是织梦运行的最低门槛之一,修复之后还要面对:第一织梦5.7已不维护,每年新增的安全漏洞约8-12个;第二与现代PHP版本(8.x)兼容性持续恶化;第三移动端体验、SEO友好度跟现代CMS差距越来越大;第四商用授权问题持续发酵。修复CGP错误能让你的网站继续运行,但保哥强烈建议把这次修复作为"启动迁移规划"的契机,而不是"继续用织梦下去"的理由。理想的时间表是修复CGP后6-12个月内完成迁移到WordPress或现代CMS。