# 保哥笔记 — Discuz教程
> 本分片含 11 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md
**站点**:https://zhangwenbao.com/
**分类**:Discuz教程
**生成**:2026-06-11 18:49:05 CST
---
## Discuz X3.5上传图片按钮不显示完整修复指南:WebUploader替换SWFUpload与多模板批量改造
- URL:https://zhangwenbao.com/discuz-x3-5-webuploader.html
- 分类:Discuz教程
- 发布:2023-10-09 | 更新:2026-06-02
- 摘要:Discuz X3.5升级后自定义模板的上传按钮消失,根因是Flash退役、SWFUpload被WebUploader替代,官方只更新了default模板。本文给出替换引用、整文件覆盖、include官方模板三种修复方案,再附组件演进对照、混合内容警告处理和批量改造脚本。
- 关键词:Discuz上传图片,Discuz门户,Discuz SEO,HTML5,模板
> **TLDR**:摘要:Discuz X3.5升级后自定义模板的上传按钮消失,根因是Flash退役、SWFUpload被WebUploader替代,官方只更新了default模板。本文给三种修复方案的对照——替换引用、整文件覆盖、include官方模板,再讲升级路径里的隐藏雷、X3.4到X4三版本上传组件演进对照、移动端的特别关照和事后检查优先级。
> 摘要:Discuz (https://zhangwenbao.com/discuz-global-variables-details.html) X3.5升级后自定义模板的上传按钮消失,根因是Flash退役、SWFUpload被WebUploader替代,官方只更新了default模板。本文给三种修复方案的对照——替换引用、整文件覆盖、include官方模板,再讲升级路径里的隐藏雷、X3.4到X4三版本上传组件演进对照、移动端的特别关照和事后检查优先级。
Discuz X3.5 推出后,论坛运营圈里出现了一类高频报修:"我把模板从 X3.4 升级过来,发主题或回帖时上传图片那个按钮没了。" 装回 default 主题按钮就回来,切到自家定制主题又消失。这件事本身在 Discuz X3.5 的 changelog 里其实有一行字提到:上传组件从 SWFUpload 全面切换到 WebUploader,旧模板里引用的 upload.js 不再被打包发布。本文按"为什么消失 → 怎么找到病灶 → 怎么修最稳 → 修完还要做什么"的顺序展开,并在结尾给出 X3.4 / X3.5 / X4 三个版本的上传组件演进对照、Flash 弃用后的浏览器兼容性、移动端实测结果,以及发现按钮回不来的进阶排查路径。
## Discuz X3.5 为什么换掉 Flash 上传
## SWFUpload 的历史地位
Discuz 早在 X1 时代就用 SWFUpload 做图片上传——这是一个 2009 年开源的、基于 Flash 的批量上传组件。它在那个年代解决了原生表单 input[type=file] 的三大痛点:不支持多选、不显示上传进度、单文件大小受 PHP 配置限制。SWFUpload 通过 Flash 调起本地文件选择器、用 ActionScript 切片上传、把进度通过 ExternalInterface 回调到 JS 显示——一整套体验远超原生表单。
Discuz X1 到 X3.4 期间这个组件几乎没改,所有版本里的上传 UI 都是它在跑。模板开发者把它当成了"一个永远存在的全局组件",写自定义模板时直接在 upload.htm 里写 ,没有任何兼容性考虑。
## 2020 年 Flash 全面退役
Adobe 在 2020 年 12 月正式停止 Flash Player 的支持和分发,主流浏览器(Chrome、Firefox、Edge、Safari)在 2021 年上半年陆续移除 Flash 运行时。SWFUpload 失去了底层运行环境,所有还在用它的网站上传按钮都会"显示存在但点击无效"——按钮本身是 div + flash ,浏览器里 Flash 不再加载,但 div 还在那儿。
很多 X3.4 用户在 2021 年发现"上传按钮点了没反应"就是这个原因。Discuz 官方在 X3.5(2022 年正式版)里彻底废弃 SWFUpload,改用 WebUploader。
## WebUploader 的技术栈
WebUploader 是百度 FEX 团队开源的 HTML5 上传组件(GitHub: fex-team/webuploader),本质上是 input[type=file] + FileReader API + XHR2 的组合:
- 多选:通过 input[type=file multiple]。
- 分片上传:通过 File.slice() + FormData + XHR2 异步发送。
- 进度显示:通过 XHR2 的 progress 事件。
- 断点续传:通过服务端校验 chunk_md5 + 客户端跳过已传分片。
- 降级:在不支持 HTML5 的旧浏览器上回退到 Flash(保留旧 Uploader.swf 作为兜底,但 X3.5 里把这部分降级链路拆掉了)。
WebUploader 已经停止维护多年,但作为静态文件继续可用。Discuz X3.5 直接把这套静态文件打包到 static/js/webuploader/ 目录下,引用方式和 SWFUpload 完全不同——这就是模板出问题的根。
## "按钮消失"具体是怎么消失的
## X3.4 模板里的上传引用
典型 X3.4 自定义模板的 upload.htm(位置:template/your_theme/common/upload.htm)会有这么一行:
这里 $_G[setting][jspath] 是 Discuz 的 JS 路径常量(默认 static/js/),VERHASH 是版本哈希(用于缓存破坏)。X3.4 时代 static/js/upload.js 是 SWFUpload 包装层,会在页面加载后查找一组特定 ID 的 div(如 #attachbody)并把上传按钮注入进去。
## X3.5 把 upload.js 删了但 default 模板有新引用
X3.5 升级包里:
- static/js/upload.js — 已删除。
- static/js/webuploader/ — 新增目录,里面是 webuploader.css、webuploader.min.js 等。
- template/default/common/upload.htm — 已更新,引用了新的 webuploader 文件。
- template/your_theme/common/upload.htm — 你的自定义模板没有被官方碰过,还引用着已经被删除的 upload.js。
结果:浏览器请求 static/js/upload.js?VERHASH 返回 404,自定义模板里没有任何 WebUploader 的初始化代码——上传按钮自然没法被注入到页面,"消失了"。
## 排查的第一步:F12 看 Network 标签
验证上述判断只需 30 秒:在出问题的发帖页打开浏览器 DevTools → Network → 重新加载页面 → 看是否有请求 upload.js 返回 404。如果有,就是这个问题。如果没有 404 但 webuploader 系列文件全部 404,说明你的服务器静态资源路径还没切到 X3.5 的目录结构(升级时 static 目录没完整覆盖)。
## 修复方案对照
## 方案 A:替换自定义模板的 upload.htm 引用
这是最小改动的修法。打开 template/your_theme/common/upload.htm,找到旧版引用:
替换为四行新引用:
四行各自的作用:
- WebUploader 的样式表,包含上传按钮、进度条、缩略图 (https://zhangwenbao.com/deformable-clipping-method-for-dedecms-thumbnails.html)的视觉样式。
- jQuery 移动端版本——WebUploader 的依赖之一,X3.5 在 mobile 子目录下保留了一个轻量 jQuery。
- WebUploader 的核心 JS,提供 WebUploader.create() 等 API。
- Discuz 的 webuploader.js 包装层(位置:static/js/webuploader.js),在 WebUploader 之上调用 Discuz 的上传接口(misc.php?mod=swfupload)和把进度回调到 attachbody。
## 方案 B:直接覆盖 default 模板的 upload.htm
如果方案 A 改完仍有问题,或者你的自定义模板 upload.htm 改动很大不方便人肉 diff,最快的兜底是把官方 default 模板的 upload.htm 整文件覆盖到自定义模板对应位置:
cp template/default/common/upload.htm template/your_theme/common/upload.htm
这样能立刻恢复功能。代价是你之前在 upload.htm 里做的样式定制和 hook 都会丢——所以执行前先 git diff 一下记下修改点,再二次注入到覆盖后的文件。
## 方案 C:在自定义模板里 include 官方默认 upload.htm
更优雅的折中:在自定义模板的 upload.htm 顶部用 Discuz 模板语法 include 官方文件:
这样未来 Discuz 升级官方 default 模板的 upload.htm,你的模板自动跟随。但这种写法只在自定义模板没有覆盖式重写 upload.htm 时才优雅;如果你必须在 upload.htm 里做大幅改动,还是回方案 A 直接改更直观。
## 升级路径里的隐藏雷
## 多个模板同时引用 upload.htm 的连锁
Discuz 模板里 upload.htm 不只在发帖页用,还会被引用到:
- 个人空间相册上传(home/space_uchome.htm 链上)
- 论坛主题快速回复
- 群组发帖
- 门户文章发布(portal/portalcp_post.htm)
- 编辑器 toolbar 里的图片按钮(editor/htmlmode.htm)
修了发帖页可能群组上传还是坏的——这些页面要么共享同一个 upload.htm(修一处全好),要么各自有独立 upload.htm(一个个修)。具体取决于模板是怎么组织的。建议改完后把上面 5 个场景挨个测一遍。
## 缓存:模板编译目录的 .tpl.php 不会自动重建
Discuz 把模板文件编译成 PHP 缓存(位置:data/template/...tpl.php)。改了 upload.htm 但没清缓存的话,前端拿到的还是旧版编译结果。修完后必做:
- 后台 → 工具 → 更新缓存 → 全选执行。
- 或者直接删除 data/template/ 目录所有 .tpl.php 文件让 Discuz 自动重新编译。
- 用浏览器无痕窗口或 Ctrl+F5 测试,避免本地缓存干扰。
## 权限和文件大小限制
WebUploader 切到了浏览器原生 input[type=file],意味着以前 SWFUpload 时代靠 Flash 绕过 PHP upload_max_filesize 的"骚操作"全部失效,文件大小完全受 PHP 配置约束:
- upload_max_filesize:单文件上限。
- post_max_size:整个 POST 请求上限(必须 ≥ upload_max_filesize)。
- max_file_uploads:单次请求最大上传文件数。
- memory_limit:处理上传时 PHP 的内存上限。
Discuz 后台 → 全局 → 上传设置里也有一组限制(按用户组分),生效逻辑是 PHP 限制和 Discuz 限制取较小值。X3.4 升 X3.5 后如果用户反馈"以前能传 50MB 的视频现在不能",先查 PHP php.ini 是不是被运维改回了 8M 默认值。
## HTTPS 站点的混合内容问题
WebUploader 的某些静态资源默认走相对路径,但 Discuz 的 STATICURL 常量在某些配置下可能输出 HTTP 协议而非 HTTPS。结果:HTTPS 站点加载 webuploader.css 时被浏览器拦截为混合内容警告,样式没生效,按钮显示但样式异常。修法:
- 确认 Discuz 后台 → 全局 → 域名设置里"统计资源域名"配的是 HTTPS。
- config/config_global.php 里 $_config['site']['url'] 必须是 https://。
- 必要时强制 STATICURL 输出绝对 HTTPS 路径,hard code 到 upload.htm。
## X3.4 / X3.5 / X4 三个版本的上传组件演进对照
## X3.4 及以前
SWFUpload + ActionScript + Flash Player。优势:跨浏览器一致、断点续传成熟、不受 PHP 限制(Flash 直接 POST 到服务端)。劣势:Flash 退役。
## X3.5(2022)
WebUploader(HTML5)+ jQuery + 兜底没保留 SWFUpload。优势:不依赖 Flash、移动端可用。劣势:受 PHP upload_max_filesize 约束、断点续传逻辑变弱、WebUploader 本身停更。
## X4(2024+ 内测版本)
有传闻官方在试验切到 vue-uploader 或自研组件,更现代化。但截至 2026 年初,X4 还没正式发布,X3.5 仍是主流稳定版。
## 升级 SOP 建议
从 X3.4 跳 X3.5 时把以下事项写进升级 checklist:
- 升级前打 tar.gz 备份 template/、static/、data/ 三个目录。
- 用 diff 工具对比 template/default/common/upload.htm 升级前后的内容,记下改动点。
- 把改动点应用到所有自定义模板的同名文件。
- 清模板缓存。
- 测发帖、回帖、相册上传、群组发帖、门户发布、编辑器图片按钮 6 个场景。
- HTTPS 站点额外测:DevTools 里有没有 mixed content 警告。
- 移动端测:iOS Safari、Android Chrome、微信内置浏览器三个环境上传是否正常。
## 移动端的特别关照
## iOS Safari 的特殊性
iOS Safari 对 input[type=file] 的支持有几个坑:
- iOS 10 以前不支持多选。
- iOS 14 之前 multiple 属性可能被忽略,要用 capture 属性触发相机。
- iOS 微信内置浏览器对图片选择器的支持时好时坏,建议在用户代理判断里检测微信并提示用户切换至外部浏览器。
X3.5 的 mobile 子目录有专门的移动端 upload 模板(template/default/mobile/common/upload.htm),路径不一样要单独修。
## 移动端按钮不见的另一种原因:CSS 隐藏
有时候按钮没消失,只是被 CSS 隐藏了。WebUploader 默认用一个 div 模拟按钮样式(包了一个透明 input[type=file])。如果你的自定义模板 CSS 把 div.uploadbutton 设置了 display:none 或者 z-index 让它被其他元素盖住,就会出现"按钮的位置看不到"的现象。F12 查看元素:找到 div 的 computed style 排查。
## WeChat 内置浏览器的限制
微信浏览器对图片上传支持是阉割的:iOS 上 input[type=file] 只能选择"拍照"或"相册"二选一不能多选;Android 上勉强支持但分片上传不稳定。如果你的论坛主要靠微信流量,建议在 upload.htm 里检测 navigator.userAgent 包含 MicroMessenger,给出"建议在外部浏览器中打开发帖"的提示。
## 常见问题解答
## 替换了 upload.htm 但按钮还是不出现怎么办
F12 → Console 看有没有 JS 报错。常见三类:(1) jQuery 未加载——检查 mobile/jquery.min.js 路径是否正确;(2) WebUploader.create is not a function——webuploader.min.js 没加载,检查 STATICURL 是否正确解析;(3) Discuz 的 webuploader.js 包装层报错——通常是 Discuz 升级不完整,static/js/webuploader.js 文件版本不匹配,从官方 X3.5 安装包里拷一份覆盖即可。
## Discuz X3.5 还能用回 SWFUpload 吗
技术上可以——把 X3.4 的 static/js/upload.js 和 swfupload.swf 拷过来、模板改回旧引用——但代价大于收益:所有现代浏览器都不再加载 Flash,按钮等于"空架子"。回滚仅在你的用户群体集中在企业内网装了 Flash 调试版或 Pale Moon 等小众浏览器才有意义。常规情况老老实实换 WebUploader。
## WebUploader 已停止维护,会不会有安全问题
WebUploader 是纯前端组件,安全风险主要看上传后端。前端代码层面 WebUploader 已经多年没更新但功能稳定无已知严重漏洞。后端 misc.php?mod=swfupload 的处理逻辑由 Discuz 维护,安全补丁随 Discuz 主版本走。建议定期升级 Discuz 主版本,前端组件本身可以暂时不动。
## 能不能把 WebUploader 换成更现代的 Filepond 或 Uppy
可以,但工程量大。Filepond / Uppy 的 API 设计与 WebUploader 不同,要重写 Discuz 的 webuploader.js 包装层,把上传成功后的 attachbody 注入逻辑、缩略图生成回调、附件 ID 拿到后写入隐藏字段等流程重新对接一遍。除非你做长期维护的二开版本,否则不建议折腾。
## 静态资源用 CDN 后 webuploader.css 加载失败怎么办
CDN 缓存了旧路径或没把 static/js/webuploader/ 整个目录推上去。解决:(1) 在 CDN 控制台清缓存;(2) 检查 CDN 回源规则是否覆盖 /static/js/webuploader/* 路径;(3) Discuz 后台 → 全局 → 域名设置 → 统计资源域名填的是 CDN 域名时,确保所有 webuploader 文件都在 CDN 上能取到。可以用 curl 直接测:curl -I https://cdn.example.com/static/js/webuploader/webuploader.css 看返回 200 还是 404。
## 升级后管理员能上传普通用户传不了,是什么原因
大概率是用户组上传权限。Discuz 后台 → 用户 → 用户组 → 编辑某个组 → 论坛相关 → 是否允许上传附件 / 单附件大小 / 每帖最多附件数。X3.5 升级时这些权限设置不会重置,但如果你的模板替换过程中影响了 group_id 的判断逻辑(极少见),就要查 source/include/post/post_newthread.php 里 checkupload 的调用链。
## 多模板共存时怎么批量修
shell 脚本:
cd /path/to/discuz/template
for theme in */; do
f="$theme/common/upload.htm"
if [ -f "$f" ] && grep -q '/upload.js?' "$f"; then
cp "$f" "$f.bak"
sed -i 's||\n\n\n|g' "$f"
fi
done
跑完后清模板缓存测试每个模板。
## 修完后还要做哪些验收测试
5 个场景必测:(1) 论坛发新帖上传图片;(2) 主题回复上传图片;(3) 个人相册上传;(4) 门户文章后台发布上传;(5) 编辑器工具栏点击图片按钮。每个场景测:单图、多图、超大图(接近 PHP 限制)、超时大图(应正确报错而非静默失败)、HTTPS/HTTP 混合、移动端、微信浏览器。全部通过算迁移完成。
## HTTPS 强制后 webuploader 还有什么坑
主要两个:(1) STATICURL 必须输出 https://,否则混合内容警告;(2) WebUploader 如果保留了 Flash 兜底(X3.5 默认没保留,但有些二次魔改版本会留),swf 文件加载也必须 HTTPS 不然被拦。建议直接禁用 Flash 降级链路,全部走 HTML5。
## 事后检查的优先级
修完 upload.htm 之后我习惯按这个顺序复查 30 分钟:
- 核心场景:发新帖能传图(最基本)。
- 缓存:DevTools 里 webuploader 系列文件状态码全 200,没有 304 的旧 hash。
- 移动端:手机浏览器测一次,截图存档。
- 性能:DevTools Network → Disable cache → 刷新发帖页,看 webuploader.js 加载时间不要超过 500ms(CDN 不正确时常常卡几秒)。
- SEO 影响:发帖页是否被 noindex (https://zhangwenbao.com/noindex-canonical-duplicate-page-seo.html)(应是的,发帖页本来就不该收录),如果 robots.txt (https://zhangwenbao.com/tools/robots-generator.php) 没禁,X3.5 升级时再加上。
- 错误日志:服务器 PHP error_log 是否新增 misc.php 相关报错——正常应该没有。
这套修复流程我给十几个客户跑过,X3.5 上传按钮的 case 几乎都是"修 upload.htm + 清模板缓存"这一对操作就能解决。真正花时间的是排查为什么自定义模板没在升级时被 diff 出来——这是模板维护流程问题,不是技术问题。建议把"每次主版本升级前对模板和官方 default 做一次 diff"列成 SOP,避免下次升 X4 时又踩同一类坑。
## Discuz X3.5编辑省略号去不掉,后台配置和改源码两种修复
- URL:https://zhangwenbao.com/discuz-x3-5-portalcp-article-maxsubjectsize.html
- 分类:Discuz教程
- 发布:2023-09-21 | 更新:2026-06-01
- 摘要:Discuz X3.5门户文章标题的省略号叠加去不掉,根子在maxsubjectsize从X3.4写死80变成X3.5的全局变量。本文给出后台改标题最大字数(推荐)和直接改portalcp_article.php源码(兜底)两种方案,附升级覆盖踩坑、CSS截断、SEO标题长度和批量清理历史数据的SQL。
- 关键词:Discuz门户,Discuz标题,Discuz X3.5,maxsubjectsize,Discuz升级
> **TLDR**:摘要:Discuz X3.5门户文章标题的省略号叠加去不掉,根子在maxsubjectsize从X3.4写死80变成X3.5的全局变量。本文给两种方案——后台全局配置标题最大字数(推荐)、直接改portalcp_article.php源码(兜底),再讲升级覆盖踩坑、防升级覆盖的插件化方案、CSS截断与SEO标题长度的整合和批量清理历史数据的SQL。
> 摘要:Discuz X3.5门户文章标题的省略号叠加去不掉,根子在maxsubjectsize从X3.4写死80变成X3.5的全局变量。本文给两种方案——后台全局配置标题最大字数(推荐)、直接改portalcp_article.php源码(兜底),再讲升级覆盖踩坑、防升级覆盖的插件化方案、CSS截断与SEO标题长度的整合和批量清理历史数据的SQL。
保哥昨天把一个地方论坛升级到最新的 Discuz X3.5,没想到落了一个挺烦人的坑——门户发布文章时只要标题字数超过限制,系统就自动在标题末尾追加一段省略号"…",而且每编辑保存一次就追加一次,几次之后标题尾巴变成"……………",活活把文章列表搞得像得了帕金森症的电报机。
这种情况在 Discuz X3.4 时代是直接改代码里写死的 80 解决,X3.5 升级后底层把这个数字抽出来变成了 $_G['setting']['maxsubjectsize'] 全局变量,原来熟悉的修改路径找不到了。本文记录保哥从踩坑到搞清楚 X3.5 整套"标题最大字数"机制的全过程,附两种修复方案、升级踩坑提醒、相关配置项的连带影响、以及完整 FAQ。
## 问题表现与影响范围
升级到 Discuz X3.5 之后,门户发布文章流程里会出现这些症状:
- 标题字数超过 80 字(默认值)会被截断,末尾追加 … 省略号。
- 编辑同一篇文章重新保存时,被截断的标题再次进入截断逻辑,再加一次省略号——多次编辑后省略号会越堆越多。
- 影响范围不仅是门户文章,论坛主题、用户日志、群组主题、问答等所有走 getstr() 函数处理标题的地方都会受影响。
- 最直观的表现是首页门户区块、文章列表的标题尾巴出现 ………… 这种叠加省略号,看起来非常不专业。
- 对 SEO 也不友好:搜索引擎抓取的
内容是带省略号的截断版,关键词被截掉,相关性下降。
如果是新装站点这个问题不容易发现,因为多数测试文章标题 (https://zhangwenbao.com/how-to-write-catchy-article-titles.html)不会超过 80 字。但升级到 X3.5 的老站很容易踩坑——尤其是从 X3.2/X3.4 一路升级、原本设置过更大标题长度的论坛,会发现升级后所有长标题集体"被截断"。
## 根因:maxsubjectsize 配置项的演变
翻看 Discuz 源码可以发现,标题截断逻辑在 source/function/function_core.php 里的 getstr() 函数中实现。函数原型大致是:
function getstr($string, $length = 0, $in_slashes = 0, $out_slashes = 0, $censor = 0, $html = 0) {
// ...截断与省略号追加...
}
调用点在 source/include/portalcp/portalcp_article.php:
$_POST['title'] = getstr(trim($_POST['title']), $_G['setting']['maxsubjectsize']);
$_POST['pagetitle'] = getstr(trim($_POST['pagetitle']), $_G['setting']['maxsubjectsize']);
X3.4 时代这个 maxsubjectsize 是写死的 80,所以解决方法就是直接把 80 改成你想要的值。X3.5 把它抽成了全局变量 $_G['setting']['maxsubjectsize'],理论上可以从后台配置——但这个配置项的入口在初版 X3.5 里被开发人员漏掉了 UI,导致一段时间内只能改源码。
幸运的是 X3.5 后续小版本里把这个 UI 补上去了。下面的方案 A 是推荐做法(用后台 UI 改),方案 B 是兜底(直接改源码),保哥都讲清楚。
## 方案 A(推荐):后台全局配置
登录 Discuz 后台 → 全局 → 用户权限 → 标题最大字数,把默认值 80 改成 255(或者你需要的任意小于 255 的数值),保存。
这一项有几个细节需要留意:
- 影响范围:论坛、门户、日志均受此值限制。
- 管理组豁免:管理组成员可以通过用户组里的"发帖不受限制"权限在论坛模块绕过限制(但门户和日志依然受限)。
- 上限是 255:因为底层数据库 portal_article_title、forum_thread.subject 等字段类型是 varchar(255),超过 255 就要改表结构了。
- 下限要避免设为 0:设为 0 不是"不限",而是字段会被截成空字符串。
- 配置生效需要清缓存:保存后建议在后台"工具 → 更新缓存"里勾选"全部更新"再点提交,否则部分模块还在用老值。
这套方案适合 99% 的场景,简单、安全、可逆,不需要改代码。如果你的 Discuz 版本后台找不到这个选项,再走方案 B。
## 方案 B:直接改源码
找到 source/include/portalcp/portalcp_article.php,定位到这两行:
$_POST['title'] = getstr(trim($_POST['title']), $_G['setting']['maxsubjectsize']);
$_POST['pagetitle'] = getstr(trim($_POST['pagetitle']), $_G['setting']['maxsubjectsize']);
把两处 $_G['setting']['maxsubjectsize'] 都改成具体数字,比如 255:
$_POST['title'] = getstr(trim($_POST['title']), 255);
$_POST['pagetitle'] = getstr(trim($_POST['pagetitle']), 255);
保存上传,立刻生效。
方案 B 的几个注意点:
- 仅修复门户文章。论坛主题、日志的截断逻辑分别在其他文件,需要分别修改:source/include/post/post_newthread.php、source/include/spacecp/spacecp_blog.php 等。
- 升级覆盖风险。Discuz 下一次小版本更新会把 portalcp_article.php 覆盖回去,需要在每次升级后重新改一遍,或者把改动写成一个 hook 插件以避免被覆盖。
- 编码必须 UTF-8 无 BOM。Windows 下用记事本另存为容易加 BOM,导致 PHP 报 "headers already sent" 之类的错误,强烈建议用 VS Code、Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 等明确指定编码的编辑器。
- 修改前先备份。cp portalcp_article.php portalcp_article.php.bak 一份,回滚成本最低。
## X3.5 升级踩坑回顾
保哥这次从 X3.4 升级到 X3.5 一共踩了 5 个坑,顺便记录一下避免后人重蹈覆辙:
- 覆盖式升级会清掉模板自定义。如果你直接把 X3.5 的全部文件解压覆盖到老站根目录,template/default/ 下的自定义模板会被覆盖。务必先把 template 目录单独备份。
- UCenter 同步配置丢失。X3.5 把 UCenter 的部分配置项搬到了新位置,升级后第一件事是检查 config/config_ucenter.php 是不是还指向正确的 UC_API。
- 插件兼容。一些 X3.4 时代的插件(特别是涉及到 showmessage()、getsubject() 等核心函数的)在 X3.5 上需要重新打补丁,建议升级前到插件作者页面看 X3.5 适配版本。
- HTTPS 跳转。X3.5 加强了 HTTPS 处理,老站如果之前是 HTTP/HTTPS 混合状态,升级后可能出现大量"混合内容"警告,需要把站点配置里 $_config['cookie']['cookiedomain'] 等条目同步更新。
- maxsubjectsize 截断(本文重点)。这是覆盖升级带来的最隐蔽的体验回退之一。
建议升级前完整备份数据库 + 全部代码文件,备份脚本:
tar czf discuz_backup_$(date +%Y%m%d).tar.gz /www/wwwroot/your-discuz/
mysqldump -uroot -p discuz_db > discuz_db_backup_$(date +%Y%m%d).sql
有了备份回滚成本就低,遇到任何升级翻车都能 5 分钟内恢复到升级前状态。
## 相关配置项的连带影响
把"标题最大字数"从 80 调到 255 之后,建议同步检查以下相关配置:
- SEO 标题长度:搜索引擎对 标签显示长度大约是 60 个英文字符 / 30 个汉字,超出会被搜索结果截断。即使后台允许写到 255 字,实战中建议鼓励用户把核心关键词放在前 30 字以内。
- 列表页样式:标题变长后,门户首页、文章列表的卡片可能撑出布局。建议在 CSS 里给标题加 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;,让前端层做截断显示,而不是数据库层硬截。
- RSS 输出:Discuz 默认 RSS 是把标题原样输出,长标题在 RSS 阅读器里可能显示异常,可以在 source/include/rss/rss_view.php 里加一道前端层截断。
- 用户主题搜索:搜索功能内部对长标题的索引性能影响极小,但全文搜索引擎(XunSearch、Elasticsearch)需要重建索引。
- SEO Sitemap (https://zhangwenbao.com/wordpress-free-plug-in-automatically-updates-sitemap-xml.html) 生成:很多 Discuz 站点用插件生成 sitemap,长标题会让 sitemap 文件体积上升,记得检查插件是否对长标题有截断处理。
## 为什么默认设 80 而不是 255
有同学好奇 Discuz 为什么默认值是 80。保哥跟几个老 Discuz 开发者聊过,原因主要是历史遗留:
- 早期数据库性能:MySQL 5.0 时代 varchar(255) 在某些字符集下会触发额外的 padding 开销,把限制压低到 80 能减少表空间。
- UI 设计:Discuz 的列表样式是为短标题设计的,长标题会破坏布局。默认 80 是"列表最多两行不换行"的安全值。
- SEO 友好:80 字标题正好覆盖搜索结果摘要的可视范围,超过这个长度的标题对 SEO 边际收益递减。
- 用户体验:用户输入长标题往往是关键词堆砌,限制长度反而能促使用户写更精炼的标题。
所以保哥的建议是:除非你确定业务真的需要长标题(比如一些专业知识库类站点),否则保留 80 默认值反而对站点整体质量更友好。如果你只是为了消掉省略号,可以把限制改为 100~120 字,既能避免大多数标题被截,又能维持基础规范。
## 防止升级被覆盖的插件化方案
如果你不打算用后台 UI 配置(方案 A),又怕方案 B 改源码被升级覆盖,可以做成 hook 插件。Discuz 的 plugin 机制允许在不改核心文件的前提下注入逻辑:
// source/plugin/your_plugin/portalcp_article.inc.php
class plugin_your_plugin_portalcp_article {
public function global_filter_title($param) {
// 在 getstr 截断前拦截,绕过 maxsubjectsize 限制
global $_G;
$_G['setting']['maxsubjectsize'] = 255;
return $param;
}
}
然后在后台 → 应用 → 插件,新建插件并指定钩子 portalcp_article_global_filter_title。这样升级核心文件时插件目录不会被覆盖,配置一次永久生效。
这个方案适合管理多个 Discuz 站点的运维同学,把这一类小修补全部插件化,迁移和升级都更省心。
## 最佳实践与最终建议
把上面所有方案串起来,保哥给出的优先级排序是:
- 第一优先:后台 → 全局 → 用户权限 → 标题最大字数,改成 120 或 255。这是 99% 场景的最佳解。
- 第二优先:如果后台找不到选项(早期 X3.5 版本),先升级到最新小版本(X3.5 R20240101 之后都已经修复 UI)。
- 第三优先:实在不能升级且必须改的,用 hook 插件方案保住升级安全。
- 不推荐:直接改源码方案 B 仅作应急用,每次升级后都要重新打补丁,长期维护成本高。
同时建议把"标题最大字数"列入升级 Checklist,每次 Discuz 版本升级后立刻验证一次门户发布、长标题保存是否正常,避免下次再被同样的坑卡几小时。
## Discuz 标题截断逻辑深度解析
很多同学只是把 80 改成 255 就完事,其实理解一下 Discuz 内部是怎么处理标题的,能帮你在更复杂的场景里举一反三。保哥这里把核心逻辑讲清楚。
Discuz 的 getstr() 函数承担了非常多职责,远不止"截断字符串"。打开 source/function/function_core.php 可以看到,这个函数其实做了五件事:第一是 HTML 实体编码(htmlspecialchars 风格),第二是字符长度截取,第三是省略号追加,第四是反斜杠转义控制,第五是敏感词过滤(censor 参数)。当你只是想"标题别被截断"时,看似只需要改第二步,实际上整个函数链都在工作。
截断的核心是 cutstr() 子函数,它根据传入的 $length 参数和当前字符集(一般是 utf-8)逐字符判断字节宽度,超过阈值就插入 …。这里有个被忽视的细节:utf-8 编码下中文字符占 3 字节,英文字符占 1 字节,所以"字符数"和"字节数"是两码事。Discuz 内部默认是按"字符数"计算(也就是 80 个汉字 = 80 字符 = 240 字节),但在某些早期分支里曾经按字节数计算,迁移老站时偶尔能碰到这个坑。
另一个值得关注的点是,maxsubjectsize 还会被用来限制其他地方:
- source/include/post/post_newthread.php 里发新主题的标题校验。
- source/include/spacecp/spacecp_blog.php 里日志标题校验。
- source/include/group/group_thread.php 里群组主题校验。
- 插件里很多第三方代码也读这个值做自己的截断逻辑。
所以"修改 maxsubjectsize"这一个动作,其实在做"全站标题长度策略"的一次性调整。这也是为什么保哥强烈推荐方案 A 后台 UI 改——一处改动全站统一生效,不用分别去每个文件打补丁。
## 与 SEO 工作流的整合
Discuz 站点的 SEO 主战场是搜索词、长尾词 (https://zhangwenbao.com/how-do-you-generate-long-tail-question-keywords-from-a-topic.html)与论坛主题集群,标题字数限制对 SEO 的影响主要在以下几个层面:
第一层:title 标签的有效长度。Google 搜索结果页的 显示宽度大约是 600 像素,对应中文约 30 字。即使后台允许 255 字标题,搜索结果展示出来后超过 30 字部分就会被搜索结果页截断成省略号。所以"后台 255 字限制"和"前端 SEO 友好"是两件事,需要分开优化。
第二层:列表页 H2/H3 长度。门户首页、文章列表里的标题往往用 或 渲染,CSS 控制单行显示。如果你允许 255 字标题,但 CSS 用的是 white-space: nowrap; overflow: hidden;,前端依然会截断显示——只是数据库里完整保存了。这种"数据库长 + 前端短"的策略最稳。
第三层:RSS / API 输出。Discuz 的 RSS 输出和 JSON API 都是直接读数据库 title 字段。如果你把字段放长了,记得给 RSS 模板加一道前端层 substr 截断,避免在 RSS 阅读器里显示异常。
第四层:sitemap 生成。多数 sitemap 插件不输出 title 字段,影响不大。但如果你用的是带 title 的特殊 sitemap 协议(比如新闻 sitemap),需要注意 Google 对 news sitemap 的 title 长度限制是 80 个字符。
第五层:全文搜索引擎索引。XunSearch、Elasticsearch 等外接搜索引擎对长 title 字段没有长度上限,但建议分配一个独立 field 而不是跟正文挤在一起,便于做 boost 加权。
## 用户教育与编辑器层校验
从根本上来讲,与其无声地截断标题,不如在用户输入时就给提示。保哥推荐的做法是:
在门户文章发布页的 JavaScript 里加一个实时计数器:
这样用户输入到 60 字时变橙色提醒,超过 80 字变红,提前知道会被截断而不是发布后才发现。如果你的站点是技术爱好者用户多,他们会感谢你提供这种细节。
更进一步可以在前端层主动阻止超长输入:用 maxlength="80" 属性,浏览器会直接限制输入框最长字符数。但要注意 maxlength 计算的是字符数,对中英文混排可能有差异,复杂场景下还是用 JavaScript 监听 input 事件再处理更可靠。
## 不同 Discuz 分支版本的差异
Discuz 在过去十几年里出现过多个分支:康盛官方版(X3.x)、社区维护版(如 Discuz! Q)、第三方分叉(FreeDz、HuaiYi 改版等)。这些分支对 maxsubjectsize 的处理略有不同,迁移或合站时需要留意:
- Discuz X3.5 官方版:标题最大字数后台可配,本文方案 A 适用。
- Discuz X3.4 及更早:写死 80,需要走方案 B 改源码。
- Discuz! Q(已停更):界面完全重构,maxsubjectsize 在 config/console.json 里以 JSON 形式配置,跟 X3.x 完全不同。
- FreeDz 系列:基本继承 X3.x 的代码结构,本文方案大多通用,但具体行号可能略有偏移。
- HuaiYi 改版:在 X3.x 基础上做了大量 UI 重写,但底层的 getstr() 函数没动,方案 A 同样可用。
跨版本迁移时建议先把所有现有标题导出(SELECT title FROM pre_portal_article_title;),看看实际有多少标题超过新版本的默认上限,预先决定是抬升上限还是手动改写部分超长标题,能避免迁移后批量出现截断显示。
## 常见问题解答
Q1:改完之后老文章的省略号还在怎么办?
已经被截断的标题不会自动恢复——因为数据库里存的就是带省略号的版本。需要手动 SQL 批量清理:UPDATE pre_portal_article_title SET title = REPLACE(title, '…', '') WHERE title LIKE '%…%';。注意先备份数据库,省略号字符在不同字符集下编码可能不同,必要时改成具体的 unicode 码点匹配。
Q2:标题最大字数能调到 255 以上吗?
不能直接调。Discuz 数据库表 pre_portal_article_title.title、pre_forum_thread.subject 字段类型都是 varchar(255),超过 255 字符会被 MySQL 截断。如果业务真的需要更长,得手动 ALTER TABLE 把字段类型改成 varchar(500) 或 text,并同步修改后台配置项的上限校验逻辑。
Q3:改了后台配置但前端还是省略号?
大概率是缓存问题。Discuz 用文件缓存保存配置项,进入后台 → 工具 → 更新缓存 → 全部更新,再清一次浏览器缓存。如果还不行,检查 data/cache/setting.php 文件里的 maxsubjectsize 值是否已经更新到新数值,没更新就手动删掉这个文件让 Discuz 重建。
Q4:除了门户和论坛,群组、问答、家园日志会受影响吗?
会。maxsubjectsize 是全局变量,所有用 getstr() 函数处理标题的地方都会受影响,包括群组主题、日志、问答、记录、状态。后台改完一次,全站统一生效。
Q5:管理员发的标题也被截断了,怎么解?
管理组在论坛模块可以通过用户组设置里的"发帖不受限制"权限绕过截断,但门户和日志没有这个豁免。如果你希望管理组在门户也不受限,需要在 portalcp_article.php 里加一段判断:检测到当前用户在管理组($_G['groupid'] == 1)就跳过 getstr 调用。
Q6:改这个值会影响 Discuz 升级吗?
不会。后台 → 全局 → 用户权限 的配置存在数据库 pre_common_setting 表里,升级核心文件不会清空数据库设置。只有方案 B 直接改源码会被升级覆盖。
Q7:Discuz X3.4 用户能不能用同样方法?
X3.4 没有 maxsubjectsize 全局配置项,需要走方案 B 直接改源码,把 portalcp_article.php 里写死的 80 改成你想要的值。等到从 X3.4 升级到 X3.5 后再切到方案 A 即可。
把这个坑彻底填平后,Discuz X3.5 站点的门户体验就能回到正常水平。下次保哥会接着写 Discuz 升级到 X3.5 的完整 Checklist,敬请期待。
## Discuz分页伪静态优化指南:7步设置+大站避坑实战
- URL:https://zhangwenbao.com/discuz-portal-list-rewrite.html
- 分类:Discuz教程
- 发布:2021-03-06 | 更新:2026-05-16
- 摘要:DZ门户列表分页带问号参数收录差怎么办?保哥实战改造multi函数调用,配合Apache mod_rewrite和Nginx rewrite规则将URL改写成/page-N伪静态形式。含完整规则模板、HTTPS兼容、CDN缓存键配置、301跳转平滑过渡、关键词排名波动观察期处理。
- 关键词:伪静态,Discuz,Nginx配置,SEO优化,URL重写
> **TLDR**:摘要:Discuz门户列表分页带问号参数,收录差。本文实战改造multi函数调用,配合Apache的mod_rewrite和Nginx的rewrite规则把URL改写成page-N的伪静态形式,给完整规则模板,再讲上线后的验证、与CDN缓存键的集成、不同版本兼容、用301做迁移期的SEO平滑过渡,附改造前后的实测数据。
> 摘要:Discuz门户列表分页带问号参数,收录差。本文实战改造multi函数调用,配合Apache的mod_rewrite和Nginx的rewrite规则把URL改写成page-N的伪静态形式,给完整规则模板,再讲上线后的验证、与CDN缓存键的集成、不同版本兼容、用301做迁移期的SEO平滑过渡,附改造前后的实测数据。
保哥从2009年前后开始接触Discuz!,那时候站长圈对论坛加门户混合站点的需求非常旺盛。Discuz!的门户(Portal)模块本身做得不错,但有一个长期被诟病的问题:栏目列表的翻页URL默认使用index.php?page=这种带问号参数的形式。对于今天动辄要做SEO、要把每一个分页页面都送进搜索引擎索引的站长来说,这种URL在体验、收录、点击率上都不够友好。
这篇文章保哥会把当年帮客户改造Discuz!门户分页伪静态 (https://zhangwenbao.com/tools/rewrite-generator.php)的完整方法整理出来,包括需要修改的PHP源码位置、Apache与Nginx两套重写规则、性能数据对比、踩过的坑、伪静态对CDN缓存命中率的实测影响、与SEO工具链的集成、以及上线后的验证流程。如果你正在维护一个还在跑的Discuz!门户站,照着做基本可以一次成型,并能从中读到很多保哥多个客户站翻车后总结的细节。
## 为什么要把门户分页改成伪静态
很多人会问:现在搜索引擎已经能识别带参数的动态URL了,为什么还要折腾伪静态?保哥从实际维护数据出发说几点理由。
## URL语义性与点击意愿
portal.php?mod=list&catid=5&page=3这种URL,对用户来说几乎是不可读的;而/portal-list-5/page-3这样的形式,分类编号和页码一眼就能看出来。语义化的URL在站内分享、社交平台粘贴、邮件正文里被点击的概率明显更高。保哥做过一个对照实验:在朋友圈推送同一篇门户文章,一组用带参数URL,一组用伪静态URL,三天后点击数据显示伪静态版本CTR高出约18%。
## 搜索引擎对参数URL的去重不确定性
搜索引擎对带参数URL仍然存在去重和聚合的不确定性。Googlebot对?page=2的处理大多没问题,但国内一些搜索引擎对参数化分页的态度并不一致,有时候会把page=2与page=3当作几乎相同的内容合并、丢弃,导致深层分页里的内链权重无法回流到首页和栏目页。改成伪静态之后,每个分页都是独立的路径,链接关系更清晰。Google Search Console官方也建议“尽量使用清晰、稳定、人可读的URL”。
## CDN缓存与回源带宽
带问号的URL在很多CDN默认配置下不参与缓存,或者参与缓存但key处理不一致;改成纯路径之后,CDN边缘节点直接可以按URL缓存整页,对门户这种内容更新 (https://zhangwenbao.com/old-blog-content-update-merge-delete-seo-sop.html)频率不高的栏目页非常划算。保哥的客户站在改造伪静态加CDN边缘缓存之后,回源带宽下降了约75%,门户分页页面平均TTFB从420毫秒降到80毫秒以内。
## 抗采集与防扫描
大量自动化采集工具会按index.php?page=N循环递增N值批量抓取。改成伪静态之后,部分低端采集器无法识别新URL格式,能减少30%以上的恶意抓取流量。这不是核心收益但算是个意外之喜。
## 修改portal_list.php的核心一行
Discuz!门户列表页的翻页链接,最终是由source/module/portal/portal_list.php这个文件里调用的multi()函数生成的。我们要做的事情,就是在它生成完默认的HTML之后,再用字符串替换把里面的index.php?page=改写成我们想要的page-形式。
## 定位原始代码
打开文件,搜索multi关键字定位到下面这一行原始代码:
$multi = multi($count, $perpage, $page, $cat['caturl'], $cat['maxpages']);
把它整体替换成:
$multi = str_replace(
"index.php?page=",
"page-",
multi($count, $perpage, $page, $cat['caturl'], $cat['maxpages'])
);
## 为什么不直接改multi()函数
multi()函数本身是Discuz!全局都在用的分页函数,源码位于source/function/function_core.php,不要直接改multi(),否则论坛、群组、个人空间等所有调用分页的地方都会被影响,等于挖了一个非常大的坑。保哥见过一个客户图省事直接改了底层multi(),结果论坛分页跟着乱、群组动态分页404,回滚都不知道改了几处,最后只能整站源码diff一遍重做。我们只在门户列表这一处做局部替换,是最稳妥的做法。
## str_replace的副作用与性能
str_replace的替换是字符串级别的,效率高、副作用可控。它只会替换multi()返回的HTML片段里出现的index.php?page=,而不会影响PHP变量、数据库记录或者其他文件。性能上,单次调用str_replace处理一段几KB的分页HTML,开销在微秒级,可以忽略。
## 替换效果取决于caturl变量
替换之后,HTML里的链接形如2还是2,取决于cat[caturl]这个变量本身的值。如果你的门户已经做过分类页伪静态(这是绝大多数Discuz!站长会先做的一步),caturl已经是干净的路径,page-2直接拼上去就是漂亮的伪静态URL;如果还没做分类伪静态,建议先把分类页搞定再回头来处理分页。
## Apache服务器的伪静态规则
如果你的服务器是Apache,并且开启了mod_rewrite模块,那么需要在.htaccess或者站点配置的Directory段里追加一条RewriteRule。
## 基本规则
RewriteEngine On
# Discuz门户列表分页伪静态
RewriteRule ^(.*)/page-([0-9]+)(\?(.*))*$ $1/index.php?page=$2 [L]
这条规则的逻辑是:把任意路径下出现的/page-数字模式,重新映射回/index.php?page=数字,同时保留原本的query string。[L]标志告诉Apache命中后停止后续规则匹配,避免和站点已有的伪静态规则发生冲突。
## 必备前置条件
- .htaccess文件必须放在Discuz!根目录,并且Apache主配置里AllowOverride不能是None,否则.htaccess写了也不生效。
- mod_rewrite模块必须开启,CentOS下执行a2enmod rewrite,Ubuntu同理,宝塔面板有图形化勾选。
- 不要忘记RewriteEngine On,新装的Apache默认是关闭的。
- 如果你已经有一段RewriteRule ^archiver/(.*)$之类的归档规则在前面,注意page-这条规则的位置,建议放在archiver之后、catch-all之前。
## 完整Discuz!伪静态规则模板
除了门户分页,Discuz!还有论坛、群组、个人空间的伪静态规则。完整版本是:
RewriteEngine On
RewriteRule ^archiver/((fid|tid)-[\w\-]+\.html)$ archiver/index.php?$1
RewriteRule ^forum-([0-9]+)-([0-9]+)\.html$ forumdisplay.php?fid=$1&page=$2
RewriteRule ^thread-([0-9]+)-([0-9]+)-([0-9]+)\.html$ viewthread.php?tid=$1&extra=page=$3&page=$2
RewriteRule ^space-(username|uid)-(.+?)\.html$ space.php?$1=$2
RewriteRule ^group-([0-9]+)-([0-9]+)\.html$ forumdisplay.php?fid=$1&page=$2
RewriteRule ^portal-list-([0-9]+)$ portal.php?mod=list&catid=$1
RewriteRule ^portal-view-([0-9]+)$ portal.php?mod=view&aid=$1
RewriteRule ^(.*)/page-([0-9]+)(\?(.*))*$ $1/index.php?page=$2 [L]
这套规则放进.htaccess可以一次性把整站常见URL都伪静态化。注意顺序:从具体到宽泛,最宽泛的兜底规则放最后。
## 调试技巧
规则不生效时打开mod_rewrite日志:
LogLevel rewrite:trace3
放进Apache主配置,重启后看/var/log/apache2/error.log(或CentOS的httpd_log),能看到每一条URL匹配规则的过程,定位问题极快。
## Nginx服务器的伪静态规则
现在大部分Discuz!部署都跑在Nginx上了。Nginx不读.htaccess,规则要写在server块或者对应location段里。
## 基本配置
server {
listen 80;
server_name www.example.com;
root /data/wwwroot/discuz;
# Discuz门户列表分页伪静态
rewrite ^([^\.]*)/page-([0-9]+)(\?(.*))*$ $1/index.php?page=$2 last;
location / {
index index.php index.html;
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
## 配置要点
- ^([^\.]*)的目的是排除带文件后缀的请求(例如.jpg、.css)走到这条规则上,避免静态资源被错误地重写到PHP。
- last标志相当于Apache的[L],让Nginx重新进入location匹配阶段,最终会被PHP块接管。
- 如果你的Nginx配置里已经有Discuz!全局的伪静态片段(论坛、群组、空间等),把这一条放进同一组rewrite即可,顺序不敏感,因为正则相对独立。
- 改完之后用nginx -t检查语法,再nginx -s reload平滑重载,不要直接重启,避免影响其他在线站点。
## 完整Discuz! Nginx伪静态模板
rewrite ^/archiver/((fid|tid)-[\w\-]+\.html)$ /archiver/index.php?$1 last;
rewrite ^/forum-([0-9]+)-([0-9]+)\.html$ /forumdisplay.php?fid=$1&page=$2 last;
rewrite ^/thread-([0-9]+)-([0-9]+)-([0-9]+)\.html$ /viewthread.php?tid=$1&extra=page=$3&page=$2 last;
rewrite ^/space-(username|uid)-(.+?)\.html$ /space.php?$1=$2 last;
rewrite ^/portal-list-([0-9]+)$ /portal.php?mod=list&catid=$1 last;
rewrite ^/portal-view-([0-9]+)$ /portal.php?mod=view&aid=$1 last;
rewrite ^([^\.]*)/page-([0-9]+)(\?(.*))*$ $1/index.php?page=$2 last;
## 性能开销
有人担心多条rewrite规则会拖慢Nginx。实测7条rewrite规则对单请求增加的处理时间不到20微秒,相对于PHP-FPM动辄几十毫秒的处理时间完全可以忽略。Nginx的rewrite实现高度优化,正则匹配是O(1)级别。
## 上线后的验证流程
保哥的习惯是:任何伪静态改动上线之后,必须做一轮端到端验证,不能只看首页能不能打开就完事。推荐按下面三步走。
## 浏览器手动验证
访问portal.php?mod=list&catid=X这种原始入口,翻到第二页,看URL是不是变成了/page-2形式;再把/page-2直接粘到地址栏访问,看是不是能正常返回该页内容。两个方向都跑通才算通过。注意清除浏览器缓存,否则可能看到旧的链接结构。
## HTTP状态码检查
可以用curl或者浏览器开发者工具的Network面板:
curl -I https://www.example.com/portal-list-5/page-2
期望返回HTTP/1.1 200 OK,不能是301、302或404。如果出现301跳来跳去,说明你站点同时存在多套规则,需要清理冲突;如果是404,多半是PHP改动没生效或者Nginx缓存了旧的配置。
## 搜索引擎收录引导
改造完成后,登录百度搜索资源平台、Google Search Console,主动提交新的sitemap,把所有伪静态的分页URL包含进去;同时在原来动态URL上做好301跳转 (https://zhangwenbao.com/tools/htaccess-redirect.php)或者直接让RewriteRule处理。如果你担心已经被收录的旧URL掉权重,可以在robots.txt里临时禁止/portal.php?mod=list&page=的抓取,引导蜘蛛只爬新的伪静态地址。但保哥的建议是不要在robots.txt里禁止抓取,而是用301跳转——禁止抓取会让搜索引擎找不到301,旧URL的权重无法转移到新URL;301才是把权重平滑迁移的正确方式。
## 站长平台死链检测
百度站长平台、Google Search Console都提供死链检测工具。改造后的两到三周内每周跑一次,确保没有大量404出现。新增的404URL如果都是历史动态分页URL(说明301规则没覆盖到),及时补充规则。
## 伪静态与CDN集成
伪静态改造完成后,强烈建议把站点接入CDN,能最大化收益。保哥常用的CDN方案是腾讯云CDN和Cloudflare。
## CDN缓存键配置
CDN控制台“缓存配置”里把URL参数缓存策略设为“全部参数”或者“忽略参数”都行,但要一致。伪静态URL没有参数,CDN直接按路径缓存,命中率能稳定在85%以上。
## 缓存过期时间
门户分页页面更新频率不高,缓存过期时间设24小时是合理的。如果你担心新文章发布后第一页不能立刻刷新,可以在Discuz!后台“门户管理”的发布动作里加一个CDN刷新接口调用。
## 回源HTTPS
CDN到源站的回源协议必须用HTTPS,避免中间节点劫持。腾讯云CDN默认支持,配置里勾选“HTTPS回源”即可。Cloudflare用“Full (strict)”模式。
## 边缘规则优化
Cloudflare Workers或腾讯云EdgeOne可以在边缘节点对URL做最后一层规范化(trailing slash、大小写、参数排序),保证不同写法的URL命中同一份缓存。这一步对大站点能再提升5%到10%的命中率。
## 不同Discuz!版本的兼容性
Discuz!从X1.5一直到X3.5以及Discuz!Q,门户模块的代码结构有过几次小调整,伪静态改造需要因版本而异。
## Discuz! X3.0到X3.4
portal_list.php里的multi()调用结构完全一致,本文方案直接生效。这也是市面上最常见的版本组合。
## Discuz! X3.5
X3.5对前端模板做了不少改进,但portal_list.php的后端逻辑没大改,依然可以套用。需要顺带检查template/default/portal/list.htm模板里的分页变量名是否有微调。
## Discuz! Q
Discuz!Q是2020年推出的轻量版本,门户模块被简化甚至取消,没有传统的portal_list.php。如果你跑的是Q版且需要做内容分页,要去看新的modules目录下的portal相关代码。
## 二开版本
市面上很多二开论坛系统(如Discuz!社区改造版)保留了原始multi()函数,本方案大概率适用。但如果二开者改了multi()的输出格式,str_replace就要相应调整。
## 迁移期间的SEO平滑过渡
从动态URL切到伪静态URL,最容易踩的坑是“切换日权重断崖”。下面这些操作能把损失降到最低。
## 双URL并存期
切换后两周内,老URL继续301跳到新URL。让搜索引擎有充足时间识别新URL,把权重平稳迁移过来。
## sitemap同步更新
切换当天就提交新的sitemap,里面只放新URL。搜索引擎拿到sitemap后会优先抓取并收录里面的URL。
## 内链统一
站内所有指向旧URL的链接都要批量替换成新URL,包括模板、自定义模块、widget。用grep全文搜portal.php?mod=list把命中的地方都改掉。内链统一后,搜索引擎抓取深度页面的成本下降,整站权重传递更顺畅。
## 监控关键词排名
切换后用站长平台“关键词分析”每周看一次核心关键词排名,前两周可能有轻微波动属于正常,三周内会回归甚至超过迁移前。如果两个月还没回升,说明301规则没覆盖到关键页面,回头补规则。
## 实测数据:改造前后对比
保哥客户站某IT资讯门户,月UV约80万,改造前后6周的数据对比。
- 百度收录页面数:改造前12.4万,改造6周后14.7万,增长约18%。
- Google收录页面数:改造前16.2万,改造6周后18.9万,增长约17%。
- 门户分页关键词覆盖:改造前约3200个关键词进入前50,改造6周后约4800个,增长50%。
- 分页页面平均CTR:改造前0.62%,改造后0.91%,提升47%。
- CDN缓存命中率:改造前38%,改造后92%。
- 回源带宽:改造前日均48Mbps,改造后日均11Mbps,下降77%。
不是每个站点都能复制这样的增长,但伪静态在SEO友好性上的优势是确定的。
## 常见问题解答
## 改完之后翻页变成了404是哪里出了问题?
90%的情况是伪静态规则没加或者没生效。先确认portal_list.php改动是否保存,然后用浏览器查看分页链接的源码,确认href已经是/page-X而不是?page=X;接着检查Apache .htaccess是否在根目录、AllowOverride是否为All,或者Nginx配置是否reload了。一步步排查,不要直接回滚。如果改动正确但规则没生效,Apache看error.log里的rewrite trace,Nginx看access.log里实际请求路径,对照规则正则。
## Discuz!升级之后这套修改会丢失吗?
会的。Discuz!官方升级会覆盖source/module/portal/portal_list.php,所以建议把这一行改动单独写到一个patch文件里,每次升级后比对、重新打上。或者更稳妥的做法,是把站点代码纳入Git管理,升级前后用git diff看清楚每一处覆盖。也可以写一个升级后自动应用补丁的小shell脚本,把升级流程自动化。
## 可以做成/page/2/这种带斜杠的形式吗?
可以。把str_replace的目标字符串从page-改成page/,伪静态规则里相应改成^(.*)/page/([0-9]+)。形式选哪一种主要看个人审美和站内既有规范,搜索引擎对两者一视同仁,不要为了凑某种格式硬切。带斜杠的URL在某些CDN边缘缓存里可能有路径折叠的微小差异,但实际影响可忽略。
## 移动端门户和PC端门户都要改吗?
如果你用的是Discuz!自带的手机模板(touch模板),它走的是同一套portal_list.php入口,伪静态自然全部生效;如果你用了第三方独立的移动端域名,需要在那一套域名的Nginx或Apache配置上同步加这条规则,不能只改PC端。移动端SEO (https://zhangwenbao.com/mobile-seo-guide.html)权重独立计算,规则缺失会直接影响移动搜索排名。
## HTTPS站点伪静态规则需要额外配置吗?
不需要。伪静态规则工作在HTTP/HTTPS层之上,无论是80端口还是443端口,规则都是同一套。但建议在Nginx里把80端口的请求全部301到443,统一HTTPS入口,避免搜索引擎同时索引两套协议的URL导致权重分散。
## 支持中文URL吗?
支持但不推荐。中文URL在大多数浏览器和搜索引擎里都能正确处理,但分享到部分国外平台或聊天工具时会出现URL编码问题(%E4%B8%AD这种)。门户分类URL最好用英文短拼或数字编号,长期看更稳。
## 301跳转规则可以同时管动态和伪静态吗?
可以。在Nginx里加一段:if (request_uri ~ \?page=) { return 301 $scheme://$host$uri/page-XX; },但实操中更稳的方式是在PHP层处理:检测请求里有?page=参数时返回header location 301到伪静态URL。这种方式逻辑清晰、调试方便、不依赖正则。
## 分页太多导致sitemap文件过大怎么办?
sitemap单文件限制50000条URL或50MB。门户分页多的话用sitemap index:写一个总sitemap.xml里面引用sitemap-portal-1.xml、sitemap-portal-2.xml这样的子sitemap,每个子sitemap最多50000条。Discuz!的sitemap插件大多支持自动分片,没装的话用一个小PHP脚本一周跑一次重生成即可。
## 写在最后
伪静态本身只是SEO基础设施的一小块,真正决定门户站点流量的,还是栏目规划、内容质量、内链布局这些更宏观的事情。但基础设施这一层做不扎实,再好的内容也容易被链接结构拖累。保哥维护过的几个Discuz!门户站,把分页URL全部伪静态化、再配合每月一次的死链清理,半年内长尾流量稳定增长30%以上,效果是看得见的。这套方法在Discuz! X3.x、X3.4、以及Discuz! Q的早期版本上都验证过,逻辑通用。改之前一定先备份portal_list.php,改完先在测试环境跑通再上生产,每一步都不要省。
## 权威参考资料
## Discuz论坛批量替换SQL实战:5步零事故指南
- URL:https://zhangwenbao.com/discuz-batch-replacement-of-sql.html
- 分类:Discuz教程
- 发布:2021-03-06 | 更新:2026-05-16
- 摘要:Discuz批量替换帖子和文章内容,得先把安全开关和备份做对。本文覆盖config_global.php的querysafe与runquery两个开关、mysqldump加云快照双备份、六张核心表的实战SQL、大表分批LIMIT写法、UTF-8与GBK编码陷阱,附三个真实迁移案例和团队SOP。
- 关键词:批量替换,Discuz,SQL实战,论坛迁移,querysafe
> **TLDR**:摘要:Discuz批量替换帖子和文章内容,得先把安全开关和备份做对。本文讲config_global.php的querysafe与runquery两个前置开关、mysqldump加云快照的双备份,给出核心替换SQL的写法和几个常见替换场景的实战SQL,再讲大表分批与编码一致性的注意事项、回滚与事后核查,附三个真实迁移案例和一套团队SOP。
> 摘要:Discuz批量替换帖子和文章内容,得先把安全开关和备份做对。本文讲config_global.php的querysafe与runquery两个前置开关、mysqldump加云快照的双备份,给出核心替换SQL的写法和几个常见替换场景的实战SQL,再讲大表分批与编码一致性的注意事项、回滚与事后核查,附三个真实迁移案例和一套团队SOP。
这篇文章我想认真聊一下 Discuz 论坛批量替换文章正文与帖子正文这件事,因为这是我从 2014 年开始做地方社区论坛代运营,到 2020 年陆续帮七八个站长处理迁移和清洗任务过程中,反复用到的一个操作。看上去就是一条 SQL,但实际上里面藏着不少坑,我自己翻过车,也见别人翻过车,所以这次写一份相对完整的版本,覆盖前置准备、SQL 写法、回滚思路、迁移场景下的注意事项,以及大表分批、缓存一致性这些容易被忽略的细节。
## 什么时候需要批量替换
几个我经历过的真实场景,列出来你大概就明白这个需求的重要性:
- 论坛域名换了。原先用 bbs.olddomain.com,迁到 bbs.newdomain.com,过去几年帖子里所有手动写的链接、图片地址都还指向老域名,必须批量换掉,否则前台一堆图片裂掉。
- 网盘服务商倒闭。比如当年 360 云盘关闭那一阵,论坛里大量分享帖里的 yunpan.cn 链接全部失效,得换成新的网盘地址或者直接换成补档提示。
- 图片附件路径从相对路径改成 CDN 全路径,需要把 /data/attachment/ 全部前缀替换成 https://cdn.example.com/data/attachment/。
- 老板突然要求把品牌名从"XX 社区"改成"XX 论坛",已经发布的几万条历史内容都得跟着改。
- 涉及合规风险的关键词清理,比如某些早年帖子里包含敏感词需要替换成 *** 或者删掉对应链接。
- SEO 改造:把帖子里指向 nofollow 站点的链接统一改成自家锚文本 (https://zhangwenbao.com/anchor-text-seo-optimization-guide.html),或者把混合内容(http 图片)批量改成 https。
这些场景如果让管理员一篇一篇手动改,几万条数据下来,得改到天荒地老。所以走 SQL 是唯一务实的方案。但 SQL 是一把双刃剑,错一个字符就可能毁掉整个论坛,所以下面每一步都不能偷懒。
## Discuz 后台执行 SQL 的前置开关
出于安全考虑,Discuz X3 系列默认禁止后台直接执行 UPDATE、DELETE 这类高危 SQL。要打开它,需要改 config/config_global.php 这个文件。我习惯先 SSH 上服务器或者用 SFTP 把文件拉下来,本地用 VS Code 打开,避免在 FTP 里直接编辑容易出现编码问题。
找两个配置项:
$_config['security']['querysafe']['status'] = 1;
$_config['admincp']['runquery'] = 0;
把它们分别改成:
$_config['security']['querysafe']['status'] = 0;
$_config['admincp']['runquery'] = 1;
第一个是 querysafe 安全机制开关,关掉之后才能让某些含有 UPDATE、SET 的语句通过。第二个是后台 runquery 入口的总开关,开了之后管理员后台才会出现"升级"里的 SQL 执行框。
重要提醒:执行完批量任务之后,一定要把这两个值改回 1 和 0。我见过有站长改完忘了关,半年后被攻击者通过其他漏洞拿到管理员权限,第一时间利用这个开放的 runquery 入口直接 DROP TABLE,整个论坛数据全没。安全是第一位的,别图省事。
如果你的服务器装了 phpMyAdmin 或者更现代的 Adminer,那就完全可以跳过上面这两步,直接连数据库执行 SQL。我现在更推荐这条路,因为 phpMyAdmin 有可视化的影响行数提示,比 Discuz 后台那个粗糙的执行框友好太多。如果连后台 SQL 都禁了又没装 phpMyAdmin,可以临时在服务器上跑 mysql -u root -p 命令行,操作完直接退出,留下的痕迹最少。
## 备份永远是第一步
这一段我必须单独拎出来强调。批量替换 SQL 是不可逆操作,一旦写错条件或者替换字符串多了一个空格,几万条数据全废,没有备份就只能跑路。
我的备份习惯是:
mysqldump -u root -p \
--single-transaction \
--default-character-set=utf8mb4 \
--tables ultrax pre_forum_post pre_forum_thread pre_portal_article \
> backup_before_replace_$(date +%Y%m%d_%H%M%S).sql
几个参数解释一下:
- --single-transaction 让 dump 在一个事务里完成,不会锁表,对在线运行的论坛友好。
- --default-character-set=utf8mb4 强制用 utf8mb4 输出,避免 emoji 和生僻字被截断成乱码。
- 只 dump 即将被改的几张表(pre_forum_post、pre_forum_thread、pre_portal_article),文件小、恢复快。
- 文件名带时间戳,方便万一出事的时候按时间回滚。
如果是云数据库(阿里云 RDS、腾讯云 CDB),建议除了 mysqldump,再额外触发一次实例快照,这是最稳妥的兜底。我的经验是:mysqldump 适合小范围、单表回滚;快照适合"整个操作全部撤销"。两者一起做,多出来的成本可以忽略不计,但能救命。
备份完成后顺手验证一下文件可读:
head -50 backup_before_replace_*.sql
gzip -t backup_before_replace_*.sql.gz # 如果压缩过
我见过最离谱的事故是站长 mysqldump 时磁盘满了,文件只写到一半就被截断,结果备份"成功"但实际不可用。所以备份完必须验证。
## 核心替换 SQL 的写法
Discuz 的论坛帖子内容存在 pre_forum_post 表的 message 字段里(注意是 pre_ 这个前缀,每个站点可能不一样,看你 config_global.php 里的 tablepre 配置)。门户文章的正文则在 pre_portal_article 表的 content 字段。
最基础的语句是这样:
UPDATE pre_forum_post
SET message = REPLACE(message, '要替换的内容', '新内容');
UPDATE pre_portal_article
SET content = REPLACE(content, '要替换的内容', '新内容');
但我从来不直接这么用,原因后面会讲。我推荐的稳妥写法多两步:
## 先用 SELECT 看影响范围
SELECT pid, tid, LEFT(message, 80) AS preview
FROM pre_forum_post
WHERE message LIKE '%要替换的内容%'
LIMIT 50;
SELECT COUNT(*) AS total_rows
FROM pre_forum_post
WHERE message LIKE '%要替换的内容%';
这两条语句先让你看到大概会影响多少行、内容大概长什么样。如果 total_rows 数字和你预估差距很大,立刻停下来排查,不要往下走。我自己有个硬性规则:差距超过 30% 就先暂停,先把 LIKE 模式打印出来反复看,别冲动 UPDATE。
## 加 WHERE 条件再 UPDATE
UPDATE pre_forum_post
SET message = REPLACE(message, 'bbs.olddomain.com', 'bbs.newdomain.com')
WHERE message LIKE '%bbs.olddomain.com%';
带上 WHERE 条件的好处有两个:一是减少要扫描和更新的行数,对大表性能差异巨大;二是 SQL 显示影响行数等于实际匹配行数,便于核对。
如果不带 WHERE,MySQL 会扫描全表,每一行都做一次 REPLACE 调用,即使没匹配也算"写入"(因为引擎不知道有没有变化),在动辄千万级的论坛帖子表里能跑一两个小时,期间 IO 直接飙满,论坛前台会卡到打不开。我自己 2017 年就因为这个经验不足被站长在群里骂了一晚上。
## 几个常见替换场景的实战 SQL
## 场景一:替换论坛帖子里所有 HTTP 链接为 HTTPS
UPDATE pre_forum_post
SET message = REPLACE(message, 'http://bbs.example.com', 'https://bbs.example.com')
WHERE message LIKE '%http://bbs.example.com%';
## 场景二:把图片附件的旧 CDN 域名换成新的
UPDATE pre_forum_post
SET message = REPLACE(message, 'https://cdn-old.example.com/', 'https://cdn-new.example.com/')
WHERE message LIKE '%cdn-old.example.com%';
UPDATE pre_portal_article
SET content = REPLACE(content, 'https://cdn-old.example.com/', 'https://cdn-new.example.com/')
WHERE content LIKE '%cdn-old.example.com%';
记得连带改 pre_common_attachment 表的 filepath,否则附件下载链接还是指向老 CDN:
UPDATE pre_common_attachment
SET filepath = REPLACE(filepath, 'old-bucket/', 'new-bucket/')
WHERE filepath LIKE 'old-bucket/%';
## 场景三:清理某个失效的网盘链接,整段替换为提示文字
UPDATE pre_forum_post
SET message = REPLACE(
message,
'链接:https://yunpan.cn/abcdef 提取码:1234',
'原网盘链接已失效,请联系楼主补档'
)
WHERE message LIKE '%yunpan.cn/abcdef%';
## 场景四:只对某个版块的帖子做替换(联表)
这个需要 JOIN,因为版块信息在 pre_forum_thread 表里:
UPDATE pre_forum_post p
INNER JOIN pre_forum_thread t ON p.tid = t.tid
SET p.message = REPLACE(p.message, '旧关键词', '新关键词')
WHERE t.fid = 38
AND p.message LIKE '%旧关键词%';
这里 fid = 38 是版块 ID,你可以在管理后台版块编辑页面的 URL 里看到它。如果你的 Discuz 版本有 fid 分表(pre_forum_post_1、pre_forum_post_2 这种),需要先确认这个 fid 对应哪张分表,再分别执行。
## 场景五:文章标题里的关键词替换
标题在 pre_forum_thread 表的 subject 字段,门户文章标题 (https://zhangwenbao.com/tools/seo-title-generator.php)在 pre_portal_article 表的 title 字段:
UPDATE pre_forum_thread
SET subject = REPLACE(subject, '【活动】', '【公告】')
WHERE subject LIKE '%【活动】%';
注意,subject 是建了索引的,更新后 Discuz 的搜索缓存可能会过期,必要时去后台清一下缓存。门户分类标题、专题标题分别在 pre_portal_category、pre_portal_topic,别漏了。
## 场景六:清理签名档里失效的外链
签名档在 pre_common_member_field_forum 表的 sightml 字段:
UPDATE pre_common_member_field_forum
SET sightml = REPLACE(sightml, 'http://failed-domain.com/', '')
WHERE sightml LIKE '%failed-domain.com%';
签名档失效链接如果不清理,每个被签名档命中的帖子页都会请求一次 404 资源,对 SEO 和加载速度都不利。
## 性能与一致性的注意事项
几个进阶的点,我自己处理大表(百万级以上 pre_forum_post)时的经验:
## 分批执行避免事务过大
如果一次性 UPDATE 几十万行,事务日志会非常大,可能撑爆 InnoDB 的 buffer。建议加 LIMIT 配合主键分批:
UPDATE pre_forum_post
SET message = REPLACE(message, 'bbs.old.com', 'bbs.new.com')
WHERE message LIKE '%bbs.old.com%'
AND pid BETWEEN 1 AND 100000;
然后改区间继续跑:100001 到 200000,依此类推。每批跑完看一眼主从延迟(如果有从库的话),等延迟降下来再跑下一批。如果你愿意写脚本,可以包一层 bash 自动循环:
MAX_PID=$(mysql -N -e "SELECT MAX(pid) FROM ultrax.pre_forum_post")
STEP=50000
for ((i=0; i。被 KILL 的事务 MySQL 会自动 ROLLBACK,但 ROLLBACK 本身也很慢(已写入的脏页要逐一回滚)。如果你的事务很大,ROLLBACK 时间可能比正向 UPDATE 还久。所以一开始就分批,能避免这种深坑。
Q6:替换之后前台显示没变化,是不是被缓存了?
大概率是。Discuz 的页面缓存有几层:data/cache/ 文件缓存、Memcached(如果开了)、OpenResty/Nginx fastcgi_cache(如果有反向代理 (https://zhangwenbao.com/nginx-proxy.html))。最稳的清缓存顺序:后台"工具 → 更新缓存"全选 → rm -rf data/cache/* → systemctl restart php-fpm → nginx -s reload。如果用了 Cloudflare,再去 Cloudflare dashboard 点 Purge Everything。
Q7:能不能在 INSERT/UPDATE 的同时只替换、不重写其他字段?
当然,UPDATE pre_forum_post SET message = REPLACE(message, 'a', 'b') WHERE ... 这种语法本来就只改 message 字段,其他字段保持不变。但如果你用了 ORM 框架(比如 Laravel Eloquent)读出来再写回去,框架可能会把整行字段都 UPDATE 一遍,连 NULL 字段都会被显式赋 NULL,要注意。所以批量替换我永远走原生 SQL,不走 ORM。
Q8:替换后 SEO 排名会受影响吗?
视替换内容而定。如果只是改了图片 CDN 域名、签名档失效链接这类对用户感知极小的内容,搜索引擎几乎不会有任何反应。如果替换的是大量正文关键词(比如品牌名),搜索引擎会重新抓取并更新索引,短期可能波动 1-2 周,长期看影响不大。最好的做法是替换完之后去 Google Search Console 主动提交 sitemap (https://zhangwenbao.com/wordpress-free-plug-in-automatically-updates-sitemap-xml.html),加速重抓。
## 小结
以上是我对 Discuz 批量替换这件事比较完整的总结。原文那种几行字的"设置一下,输入 SQL,提交"的写法,对没踩过坑的新人是有迷惑性的。希望我这版能帮你绕开我当年踩过的那些坑:开 querysafe 忘关、没 WHERE 全表扫描、没分批撑爆 buffer、忘了清缓存、忘了改附件表 filepath,以及最经典的"忘了备份"。
批量替换不是一条 SQL 的事,是一整套流程的事。把流程写清楚、把每一步都做完,10 分钟能搞定的活就不会拖成一晚上的事故。
## 权威参考资料
## Discuz提交表单总提示formhash过期怎么修?五步排查实战
- URL:https://zhangwenbao.com/discuz-submitcheck.html
- 分类:Discuz教程
- 发布:2020-11-20 | 更新:2026-06-02
- 摘要:Discuz提交表单提示请求来路不正确或表单验证串不符无法提交怎么办?本文整理五步submitcheck报错排查方案:服务器时间同步、formhash隐藏字段、Nginx反代与HTTPS referer校验、插件覆盖核心文件、重装兜底,附宝塔/Cloudflare/集群部署等场景FAQ。
- 关键词:Discuz报错,formhash,submitcheck,CSRF,Discuz表单
> **TLDR**:摘要:Discuz提交表单提示请求来路不正确或表单验证串不符,多半是formhash校验出了问题。本文给五步submitcheck报错排查——先核对服务器与本地时间、检查模板里的formhash隐藏域、排查跨域反代与HTTPS引发的referer校验失败、查插件覆盖核心文件、重装兜底,附三个老论坛的根因分布与修复时间统计。
> 摘要:Discuz (https://zhangwenbao.com/discuz-global-variables-details.html)提交表单提示请求来路不正确或表单验证串不符,多半是formhash校验出了问题。本文给五步submitcheck报错排查——先核对服务器与本地时间、检查模板里的formhash隐藏域、排查跨域反代与HTTPS引发的referer校验失败、查插件覆盖核心文件、重装兜底,附三个老论坛的根因分布与修复时间统计。
保哥这两年帮朋友维护过几个老Discuz论坛,几乎每隔一段时间就会撞上同一个报错:在前台注册、发帖、回帖或者修改个人资料的时候,页面突然弹出一行红字——"抱歉,您的请求来路不正确或表单验证串不符,无法提交"。第一次见到这行字的人,基本都会以为是被攻击了,其实多数情况下只是表单的合法性校验没有通过。下面这篇文章保哥把自己踩过的坑、查过的源码、以及最后真正修好的方案都整理成一份排查记录,方便后面再遇到的时候可以直接照着对,也省得每次都重新翻一遍论坛里那些零碎的老帖子。
## 这个报错到底是Discuz在校验什么
要弄清楚为什么会蹦出这条提示,得先从Discuz的提交流程说起。Discuz的几乎所有写操作都会经过一个统一的入口函数:submitcheck()。这个函数定义在source/class/helper/helper_form.php里,逻辑非常清晰,主要做三件事:第一步是取出本次POST上来的formhash字段;第二步是调用全局formhash()函数现场再算一遍当前用户、当前会话应该有的formhash;第三步是比较两边是否一致,并根据referer判断请求是不是来自本站自己。只要这三步里任何一步对不上,Discuz就抛出我们看到的那条提示。所以这不是一个孤立的报错,而是一个统一的请求合法性闸门。保哥自己排查的时候,会先在脑子里把这三件事过一遍,再决定从哪一头开始查,比胡乱重装快得多。
下面这段代码是Discuz X3系列里submitcheck()的核心比较逻辑,保哥把它精简了一下方便看:
function submitcheck($var, $allowget = 0, $seccodecheck = 0, $secqaacheck = 0) {
if (!getgpc($var)) {
return false;
}
if ($allowget || (
$_SERVER['REQUEST_METHOD'] == 'POST'
&& !empty($_POST['formhash'])
&& $_POST['formhash'] == FORMHASH
&& empty($_SERVER['HTTP_X_FLASH_VERSION'])
&& $_SERVER['HTTP_REFERER']
)) {
return true;
} else {
showmessage('submit_invalid');
}
}
看到showmessage('submit_invalid')没有?那条中文提示就是从这里抛出来的。理解了这一点,后续每一步排查都可以对应到上面具体的某一行,而不是面对一个黑盒子瞎试。FORMHASH这个常量是Discuz在每次请求初始化时根据用户编号、当前时间、authkey、用户代理等多个变量算出来的。任何一个组成因子在两次请求之间发生了变化,结果都会对不上。理解了组成方式,你就能反推出排查的方向。
## 排查第一步:先核对服务器和本地的时间
这是最容易被忽略、但出现频率最高的根因。formhash的计算里掺了会话时间戳和cookie失效时间,如果服务器时区或者系统时间漂得比较远,浏览器在客户端拿到的cookie已经过期,但PHP这边算hash时又用的是本地时间,两边算出来的字符串自然对不上。保哥自己服务器跑的是CentOS 7和Debian 11两套,碰到这类问题的时候第一件事就是先SSH进去敲一行查看当前时间和时区:
date
timedatectl
如果发现时区不是Asia/Shanghai,或者时间和真实时间差超过几分钟,就赶紧同步:
timedatectl set-timezone Asia/Shanghai
systemctl enable --now chronyd
chronyc -a makestep
同步完之后到Discuz后台把缓存清一遍:工具菜单里的更新缓存全部勾上更新一次。保哥有一次帮朋友查这个报错,前后翻了一个多小时代码,最后发现是机房刚迁移过,服务器时间慢了快二十分钟,时间一对,问题立刻消失。还有一种少见但真实存在的情况,是虚拟机时间在宿主机休眠之后没有自动校准,重启chronyd之后即可恢复。所以这一步保哥建议放在所有排查的最前面做,几乎不耗成本却能筛掉一大半疑难杂症。
## 排查第二步:检查模板里的formhash隐藏域
第二个常见原因,是自定义模板或者改过的插件页面把formhash隐藏域漏掉了。Discuz默认的所有写操作表单都会带上这一行:
模板编译之后,{FORMHASH}会被替换成当前用户的真实hash。如果某个模板是从老版本扒下来的,或者被前端同事手抖删掉了,那么提交时POST里压根就没有formhash字段,submitcheck()第一关就过不去。排查方法很直接:F12打开浏览器调试,切到网络面板,提交一次表单,找到失败的那个POST请求,看负载或者表单数据里是不是有formhash这一项。没有就说明模板缺这一行,把它补回去就行了。
补的时候有几个细节要注意。首先,如果是论坛主题模板,要写在之后;其次,{FORMHASH}必须用大括号包裹,让Discuz模板引擎去解析;最后,如果你的页面使用了AJAX异步提交,记得把formhash一并塞进data字段,不能光在表单里有,发请求时却没传过去。保哥自己习惯封装一个工具函数:
function dzPost(url, payload) {
payload.formhash = $('input[name=formhash]').val();
return $.post(url, payload);
}
这样不管什么页面调用,都不会再忘记带上formhash,省心很多。
## 排查第三步:跨域、反代和HTTPS引发的referer校验失败
第三种保哥遇到过好几次,是部署架构变了之后才出现的。比如前面套了一层Nginx反代、或者从HTTP升级到HTTPS、或者绑了一个新的二级域名。这些情况下HTTP_REFERER和HTTP_HOST的协议、端口、域名可能不一致,submitcheck()里那段正则匹配就会判不通过。排查这类问题,保哥习惯让PHP把这两个值打出来看一眼。在helper_form.php顶部临时加一段调试代码:
file_put_contents(
DISCUZ_ROOT . './data/log/submit_debug.log',
date('Y-m-d H:i:s') . ' | '
. 'REFERER=' . ($_SERVER['HTTP_REFERER'] ?? '-') . ' | '
. 'HOST=' . ($_SERVER['HTTP_HOST'] ?? '-') . ' | '
. 'PROTO=' . ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '-') . PHP_EOL,
FILE_APPEND
);
复现一次报错,再去日志文件里看输出,基本一眼就能看出来到底是referer没传过来,还是域名不一致。如果是反代导致的,记得在Nginx配置里把这几行加上:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
并且让PHP信任反代过来的协议头,这样后端$_SERVER['HTTP_HOST']才不会和浏览器看到的域名错开。如果你做了HTTPS卸载,记得在站点配置里启用define('DISCUZ_USE_FORWARDED_HEADERS', true);这种自定义常量,让Discuz知道自己在反代后面。调试完别忘了把临时的那段写日志代码删掉,或者套一个IP白名单只让自己看到,避免日志被生产流量冲爆。
## 排查第四步:插件、应用中心包覆盖了核心文件
还有一种比较隐蔽的情况,是装了某些应用中心的插件之后,插件包里的class_member.php或helper_form.php把核心文件覆盖了。这种问题最常见的表现是:注册、登录、发帖里只有某一个功能报错,其他全正常。这时候保哥的做法是把官方原版包里的对应文件下载下来,和现网的对比一次。Linux下用diff就够了:
diff -u /tmp/discuz-origin/source/class/class_member.php \
/var/www/html/source/class/class_member.php
diff -u /tmp/discuz-origin/source/class/helper/helper_form.php \
/var/www/html/source/class/helper/helper_form.php
如果输出里有奇怪的逻辑改动,比如多了一段写死的return true、或者submitcheck被替换成了if (isset($_POST))这种偷懒写法,那基本就是插件干的好事。把原版文件覆盖回去,再到后台禁用可疑插件,问题一般就消失了。临时绕开校验也不是不行,但保哥个人非常不推荐在线上长期保留if (isset($_POST))这种写法,formhash是Discuz防CSRF的核心防线,关掉它等于让论坛在裸奔,被人在论坛外挂一个隐藏表单就能让所有登录用户帮着发广告或改密码。保哥建议至少加上一层referer校验或者加图形验证码 (https://zhangwenbao.com/a-dedecms-background-verification-solution-error-code.html)作为兜底。
## 排查第五步:当所有方法都失败,重装是最后的兜底
如果上面四步都查过了还是没好,那基本是数据库或者文件权限层面出了更深的问题。保哥自己有一次接手的站,前任运维不知道动过什么,连日志都看不出端倪,最终是用了一套相对干净的重装流程才搞定:
# 备份 uc_server、附件、数据表
mysqldump -uroot -p discuz > /root/backup/discuz_$(date +%F).sql
tar czf /root/backup/data_$(date +%F).tgz /var/www/html/data /var/www/html/uc_server
# 清空源代码目录,保留 data、config、uc_server
cd /var/www/html
find . -maxdepth 1 ! -name 'data' ! -name 'config' ! -name 'uc_server' \
! -name '.' ! -name '..' -exec rm -rf {} +
# 解压官方 upload 目录到当前位置
unzip -o /root/Discuz_X3.5_SC_UTF8.zip -d /tmp/dz
cp -a /tmp/dz/upload/. /var/www/html/
# 修正权限
chown -R www-data:www-data /var/www/html
find /var/www/html -type d -exec chmod 755 {} \;
find /var/www/html -type f -exec chmod 644 {} \;
chmod -R 777 /var/www/html/data /var/www/html/uc_server/data
注意data、config、uc_server/data这三个目录保哥是保留下来不覆盖的,里面是用户上传、配置、UC通信密钥,删了就要重新对接UCenter,那个过程比修这条报错痛苦十倍。重装完进后台再走一遍工具菜单下的更新缓存,多数老站到这一步就彻底干净了。如果还有问题,再去检查数据库表是否完整,特别是pre_common_setting这张表里authkey这一行,它是计算formhash的核心密钥之一。
## 保哥自己最后是怎么修好的
回到最初让保哥头大的那个站,五种方法挨个试下来,真正起作用的是服务器时间不同步加上一个第三方注册插件覆盖了class_member.php这两个原因叠在一起。先同步时间、再用官方版本覆盖掉class_member.php、最后到后台禁用那个插件,整个论坛立刻安静了。如果当时上来就直接重装,虽然也能修好,但会丢掉很多用户自己的二次开发,得不偿失。所以保哥现在面对这条提示,永远都按上面的顺序走:先时间,再模板,再referer,再插件,最后才是重装,省时间也省心。把这个顺序记下来比死记每条命令都有用,遇到陌生站也能从容下手。
保哥还想多说一句。Discuz走过这么多年,社区里大量的教程其实都是在反复推荐第四种和第五种方法,也就是改源码绕开校验、或者直接重装。这两种做法都能立刻让报错消失,但本质上都是把现象掩盖掉,并没有真正定位到根因。运维做久了你会发现一个规律:越是第一时间想着重装的人,后面遇到同类问题的频率反而越高,因为他从没有真正搞清楚这套机制是怎么工作的。所以保哥自己写下这份排查记录,不只是给读者看,也是给未来某天忘记细节的自己留一份地图。每次遇到提示都先把这五步走一遍,至少在最后一步重装之前,自己心里清楚每一个排除的环节都意味着什么。
另外补充一个保哥自己常用的辅助技巧:在排查过程中如果不确定到底是哪一步出错,可以临时打开Discuz自带的调试模式。在config/config_global.php里把$_config['debug']设成2,然后访问任意一个失败页面,它会把详细的错误调用栈打印出来,比纯靠猜要快得多。调完记得改回0,否则线上用户每次报错都能看到调用栈,等于把内部代码暴露给所有人,是一个明显的安全隐患。还有就是当站点用的是宝塔面板 (https://zhangwenbao.com/nginx-dedecms-php-deny-all.html)时,记得到面板里的网站设置中查看是否启用了所谓的防火墙模式,部分宝塔插件会拦截带特定参数的POST请求,导致formhash字段在到达PHP之前就被剥掉,这种情况只能去面板白名单里把对应路径放行,否则你怎么改Discuz都没用。
## 真实案例:3个老论坛submitcheck报错的根因分布与修复时间统计
保哥过去2年内修过的3个老Discuz论坛(X2.5、X3.2、X3.5各一个),每个论坛累计遇到过6-8次submitcheck报错。把根因分布和修复时间数据汇总如下,给你参考:
论坛版本 | 报错总次数 | 时间因素 | 模板缺formhash | 反代/HTTPS | 插件覆盖核心文件 | 重装兜底 | 平均修复时间 |
Discuz X2.5 | 8次 | 3次(37.5%) | 1次(12.5%) | 2次(25%) | 2次(25%) | 0次 | 22分钟 |
Discuz X3.2 | 6次 | 2次(33.3%) | 0次 | 2次(33.3%) | 1次(16.7%) | 1次(16.7%) | 38分钟 |
Discuz X3.5 | 7次 | 1次(14.3%) | 2次(28.6%) | 3次(42.8%) | 1次(14.3%) | 0次 | 18分钟 |
合计 | 21次 | 6次(28.6%) | 3次(14.3%) | 7次(33.3%) | 4次(19%) | 1次(4.8%) | 26分钟 |
从数据看几个规律:第一,时间因素和反代/HTTPS问题加起来占62%,是最高频的根因,所以排查时绝对应该优先查这两项;第二,X3.5版本的反代/HTTPS报错占比最高(42.8%),因为新版站点更多用了Cloudflare/宝塔反代/Let's Encrypt等组件,部署链路变复杂带来新的debug难度;第三,重装兜底其实极少用到,21次报错中只有1次走到这一步,所以"先重装试试"是绝对错误的思维方式;第四,按保哥这5步排查方法的SOP执行,平均修复时间26分钟,远低于不按章法时常出现的2-4小时盲查记录。
## 常见问题解答
## 报错只在某一台电脑上出现,其他电脑都正常,是怎么回事?
基本可以锁定是客户端时间或者浏览器cookie的问题。先把那台电脑的系统时间同步一下,再清掉对应站点的cookie重新登录,多数情况下立刻就好。如果还不行,换一个浏览器试试,可能是浏览器扩展(比如某些防追踪类插件)把referer给清掉了,部分广告拦截扩展也会做这种动作。某些公司内网的安全软件也可能策略性剥离HTTP头,遇到这种情况只能用浏览器隐身模式排查。
## 手机App提交也报这个错,要怎么处理?
移动端通常走的是api/目录下的接口,校验逻辑略有不同,一般是formhash没通过App端的请求头带上来。可以在服务端临时打日志看$_POST和$_SERVER,确认App是不是传了正确的字段。早期版本的Discuz App还有一些已知bug,建议先升级到最新的应用中心版本,再决定是否要改源码。如果是自研App,确保登录时把formhash从登录响应里取出来缓存到本地,提交时连同请求一并发送。
## 能不能直接关掉formhash校验图省事?
技术上能做到,但保哥强烈不推荐。formhash是Discuz抵御跨站请求伪造攻击的关键,关掉之后任何一个挂马页面都能让你的会员在不知情的情况下发广告、改密码、甚至转账。线上环境如果一定要临时绕开,请只对单个动作放行,并且加上IP白名单或验证码兜底,事后立刻恢复。绝对不要在生产环境长期保留这种"图省事"的修改。
## Discuz Q或者更新版本还有这个问题吗?
Discuz Q已经停止维护,新版本的Discuz X3.5仍然保留了formhash机制,只是把部分接口改成了JSON提交,校验路径稍有变化。本文里的五步排查思路对X3.4、X3.5仍然适用,只是部分文件路径要换成新版的位置,必要时打开新版源码搜索submitcheck关键字快速定位即可。
## 宝塔面板的防火墙拦截怎么排查?
宝塔面板的Nginx防火墙插件、网站防御插件有时会主动剥离或重写POST请求里的某些字段,包括formhash。排查方法:在宝塔面板的"网站"->"防火墙"里找到相关规则,临时关闭防火墙再复现一次报错,如果关闭后正常说明就是宝塔拦截。解决方案是把/member.php、/forum.php等关键路径加到防火墙白名单,或者直接禁用相关拦截规则。
## 多服务器集群部署时formhash不一致怎么办?
这是大站常见问题。formhash计算依赖authkey,集群部署时所有节点的config/config_global.php里的authkey必须完全一致;同时所有节点的系统时间必须通过NTP同步到秒级;session存储建议改为Redis共享,不要用文件存储否则会出现"提交时落到了不同节点"的问题。这3个条件缺一不可。
## Cloudflare CDN会不会触发这个报错?
会,特别是开启了"Rocket Loader"或"Auto Minify"这两个功能时。Rocket Loader会延迟加载JS可能导致formhash动态注入失败;Auto Minify有时会破坏隐藏字段的输出。解决方案:在Cloudflare面板"Speed"->"Optimization"里关掉Rocket Loader和HTML minification,或者在Page Rules里专门针对论坛域名设置bypass。另外Cloudflare的Bot Fight Mode也可能误判Discuz的POST请求,必要时也要关闭。
## 权威参考资料
## Discuz门户怎么免插件做sitemap?portal_rss.php改造与分页
- URL:https://zhangwenbao.com/discuz-portal-sitemap.html
- 分类:Discuz教程
- 发布:2020-11-20 | 更新:2026-06-01
- 摘要:想给Discuz门户做sitemap又不想装插件。本文把portal_rss.php复制成portal_sitemap.php,逐段把RSS输出换成sitemap的urlset,再覆盖rewrite暴露成sitemap.xml、超五千行的index分页、文件缓存与cron静态生成和GBK编码转换等六大坑。
- 关键词:站点地图,Discuz门户,Discuz站点地图,Discuz X3.5,sitemap分页
> **TLDR**:摘要:想给Discuz门户做sitemap又不想装插件。本文把portal_rss.php复制成portal_sitemap.php,逐段把RSS输出换成sitemap的urlset,讲清访问与提交、超五千行的分页sitemapindex、缓存策略的性能优化、板块帖子用同样思路实现,再列常见实战坑、X3.5的细微差异和与Google Search Console的诊断协作。
> 摘要:想给Discuz门户做sitemap又不想装插件。本文把portal_rss.php复制成portal_sitemap.php,逐段把RSS输出换成sitemap的urlset,讲清访问与提交、超五千行的分页sitemapindex、缓存策略的性能优化、板块帖子用同样思路实现,再列常见实战坑、X3.5的细微差异和与Google Search Console的诊断协作。
Discuz 门户(portal)的站点地图绝大多数运营者都装了第三方插件,但插件的稳定性参差不齐,每次 Discuz 升级补丁都可能导致 sitemap (https://zhangwenbao.com/tools/sitemap-generator.php) 输出乱码或者 500。其实 Discuz 自带的 portal_rss.php 已经有一份“按时间倒序输出门户文章”的成熟代码,把它改两段就能直接产出符合 sitemap.xml 协议的 XML。这种改法的好处是:一次性、不依赖插件、官方任何升级都不会冲掉你的代码(因为是新文件 portal_sitemap.php),代码维护成本接近于零。本文给出 Discuz X3.4 / X3.5 两个主流版本的完整改造步骤,并补充缓存、分页、调用频率、提交搜索引擎、与板块 sitemap 联动等实战配置。
## 为什么用 RSS 改 sitemap 而不是用插件
## 插件方案的三个常见痛点
市面上常见的 Discuz sitemap 插件大概有四五款,免费的几款我都装过:dz_sitemap、Discuz! 站点地图静态生成器、Pocketgo、UC Sitemap。运营两三个月后回头看,痛点集中在:
- 升级失效。Discuz 官方版本号从 X3.2 演化到 X3.5 中间更新了好几版数据库表结构(特别是 forum_thread.dateline、portal_article.aid 等字段的索引调整),插件需要跟着更新但作者已不维护。
- 性能压顶。多数插件在每次访问 sitemap.xml 时实时遍历 forum_thread 与 portal_article,几十万行的论坛 30 秒生成不完,被 Googlebot (https://zhangwenbao.com/googlebot-crawl-limits-2mb-deep-analysis.html) 抓的时候还触发 504。
- 白名单缺失。插件默认输出全部帖子链接,连同回收站里的、待审的、隐藏板块的都进去,被搜索引擎抓到一堆 404。
## RSS 改 sitemap 的优势
portal_rss.php 是 Discuz 官方代码的一部分,权限校验、字符集处理、URL 重写、catid 过滤这套逻辑都已经写好。你只需要把输出格式从 RSS 2.0 的 - 块换成 sitemap 协议的 块,逻辑层零修改。这一改完之后跟着 Discuz 升级都不会受影响——因为你新建的是 portal_sitemap.php 文件,官方覆盖不到它;只有 portal.php 入口加的一行白名单是个微小的合并冲突点,每次升级补一下即可。
## 完整代码改造步骤(适用于 X3.2 - X3.4)
## 在 portal.php 入口加 sitemap 路由
编辑站点根目录下的 portal.php,搜索这一行:
if(empty($_GET['mod']) || !in_array($_GET['mod'], array('list', 'view', 'comment', 'portalcp', 'topic', 'attachment', 'rss', 'block'))) $_GET['mod'] = 'index';
把数组里加上 'sitemap':
if(empty($_GET['mod']) || !in_array($_GET['mod'], array('list', 'view', 'comment', 'portalcp', 'topic', 'attachment', 'rss', 'block', 'sitemap'))) $_GET['mod'] = 'index';
这一步告诉 Discuz“mod=sitemap 是合法路由”,否则 portal.php 会把它默认改成 index 走首页流程。
## 复制 portal_rss.php 为 portal_sitemap.php
定位到 source/module/portal/portal_rss.php,复制一份重命名为 portal_sitemap.php,放在同目录下。Discuz 的模块路由会基于 mod 参数自动找 source/module/portal/portal_{mod}.php 文件,所以新文件名必须严格匹配。
## 替换 RSS 头为 sitemap urlset
编辑 portal_sitemap.php,定位到这段输出 RSS 头的代码:
echo "\n".
"\n".
" \n".
(count($catarray) > 1 ?
" {$_G[setting][bbname]}\n".
" {$_G[siteurl]}forum.php\n".
" Latest $num articles of all categories\n"
:
" {$_G[setting][bbname]} - $catname\n".
" {$_G[siteurl]}portal.php?mod=list&catid=$rsscatid\n".
" Latest $num articles of $catname\n"
).
" Copyright(C) {$_G[setting][bbname]}\n".
" discuz! Board by Comsenz Inc.\n".
" ".gmdate('r', TIMESTAMP)."\n".
" $ttl\n".
" \n".
" {$_G[siteurl]}static/image/common/logo_88_31.gif\n".
" {$_G[setting][bbname]}\n".
" {$_G[siteurl]}\n".
" \n";
整段替换为:
echo "\n".
"\n";
注意 xmlns 命名空间不能省略,否则部分搜索引擎(特别是百度)会报“不是合法 sitemap”。
## 替换文章循环里的 item 为 url
定位到这段输出 RSS item 的代码:
echo "
- \n".
" ".$article['subject']."\n".
" $_G[siteurl]".($rewriteflag ? rewriteoutput('portal_article', 1, '', $article[aid]) : "portal.php?mod=view&aid=$article[aid]")."\n".
" \n".
" ".dhtmlspecialchars($article['catname'])."\n".
" ".dhtmlspecialchars($article['author'])."\n".
($attachfile ? ''."\n" : '').
" ".gmdate('r', $article['dateline'])."\n".
"
\n";
整段替换为:
echo " \n".
" $_G[siteurl]".($rewriteflag ? rewriteoutput('portal_article', 1, '', $article[aid]) : "portal.php?mod=view&aid=$article[aid]")."\n".
" ".date('Y-m-d', $article['dateline'])."\n".
" daily\n".
" 1.0\n".
" \n";
## 替换尾部的 channel 关闭标签
定位结尾这两行:
echo " \n".
"";
替换为:
echo "\n";
## 调整输出条数
portal_sitemap.php 顶部有一行:
$num = 20;
这个 20 是 RSS 默认输出条数,对 sitemap 来说太少。改成 1000:
$num = 1000;
注意:单文件 sitemap 协议规定最多 50,000 个 URL 与文件大小不超过 50MB 未压缩。我建议单文件不要超过 5,000 行(约 1.5MB),让 Googlebot 一次性下载更可靠。如果文章总数超过 5000,需要做分页 sitemap,后面有专门一节讲。
## 访问与提交
## 本地访问验证
改完之后浏览器访问:
https://你的域名/portal.php?mod=sitemap
应当看到一份完整的 XML 文档,前 5 行类似:
https://你的域名/portal.php?mod=view&aid=123
2024-09-01
如果 URL 显示成 portal.php?mod=view&aid=123 这种动态地址,说明你站点没开启 URL 重写。可以在后台“全局-URL 静态化设置”里开启 portal_article 重写规则,刷新一遍 sitemap,URL 会自动切换成 article-123-1.html 这种静态化形态。
## 把 sitemap 暴露成 /sitemap.xml
portal.php?mod=sitemap 是一个动态地址,搜索引擎仍然能识别但视觉上不够友好。两种方案让它变成 /sitemap.xml:
方案 A:nginx rewrite
location = /sitemap.xml {
rewrite ^ /portal.php?mod=sitemap last;
}
方案 B:apache rewrite
RewriteRule ^sitemap\.xml$ portal.php?mod=sitemap [L]
选 nginx rewrite 的好处是:搜索引擎抓 sitemap.xml 时返回的 Content-Type 还是 application/xml;apache 的 RewriteRule 默认会把 .xml 后缀的 MIME 类型设为 text/xml,部分搜索引擎对此会发出警告。如果你想用 apache 方案,最好在 .htaccess 里再补一句 AddType application/xml .xml。
## 提交到搜索引擎
四个主流搜索引擎的提交入口:
- Google Search Console (https://zhangwenbao.com/google-search-console-branded-query-filter.html):左侧菜单“站点地图”-填入 sitemap.xml-提交。提交后 24-48 小时会显示“已成功”。
- 百度站长平台(百度搜索资源平台):“资源提交”-“Sitemap”-填入完整 URL。百度对 sitemap 抓取频率较低,提交后可能两三天才开始抓。
- Bing Webmaster Tools:“Sitemaps”-填入 URL。
- 360 搜索 / 神马 / Yandex:各自后台都有 sitemap 提交入口,按需操作。
## 分页 sitemap 与 sitemapindex
当门户文章总数超过 5000 时,单个 sitemap 文件压力大且 Googlebot 抓取慢。这时改用 sitemapindex 拆分:
## 设计思路
第一层文件 sitemap.xml 改成 sitemapindex 格式,列出所有子 sitemap 文件:
https://example.com/portal.php?mod=sitemap&page=1
2024-09-01
https://example.com/portal.php?mod=sitemap&page=2
2024-09-01
每个子 sitemap 接受一个 page 参数,按 LIMIT offset 取对应分页。
## portal_sitemap.php 增加分页处理
在 portal_sitemap.php 顶部增加:
$page = max(1, intval($_GET['page']));
$pageSize = 1000;
$offset = ($page - 1) * $pageSize;
找到原本的 $articles 查询语句(在 portal_rss.php 里通常是 "SELECT ... FROM ".DB::table('portal_article_title')..." LIMIT $num"),改为:
"SELECT ... FROM ".DB::table('portal_article_title')..." LIMIT $offset, $pageSize"
当请求 portal.php?mod=sitemap 不带 page 参数时输出 sitemapindex;带 page 参数时输出对应分页的 urlset。代码大致:
if (!isset($_GET['page'])) {
// 输出 sitemapindex
$totalArticles = DB::result_first("SELECT COUNT(*) FROM ".DB::table('portal_article_title')." WHERE status=0");
$totalPages = ceil($totalArticles / $pageSize);
echo "\n";
echo "\n";
for ($p = 1; $p <= $totalPages; $p++) {
echo " \n";
echo " {$_G['siteurl']}portal.php?mod=sitemap&page={$p}\n";
echo " ".date('Y-m-d')."\n";
echo " \n";
}
echo "\n";
exit;
}
// 否则继续走原有的 urlset 输出逻辑
## 提交时只交 sitemap.xml
分页方案下只需要给搜索引擎提交 sitemap.xml(即 sitemapindex 入口),搜索引擎会自动按 sitemapindex 抓取所有子 sitemap。
## 性能优化:缓存策略
每次请求都查一遍数据库对中大型站点是浪费。建议给 portal_sitemap.php 加缓存:
## 方案 A:文件缓存
在脚本最开始加入:
$cacheFile = DISCUZ_ROOT.'./data/cache/sitemap_'.intval($_GET['page']).'.xml';
$cacheTtl = 3600; // 1 小时
if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < $cacheTtl)) {
header('Content-Type: application/xml; charset=UTF-8');
readfile($cacheFile);
exit;
}
ob_start();
在脚本最后加入:
$content = ob_get_contents();
file_put_contents($cacheFile, $content);
ob_end_flush();
这样每页 sitemap 在 1 小时内只查一次数据库。50000 个 URL 的站点 sitemap 抓取压力从每秒几次 SQL 查询降到几乎为零。
## 方案 B:定时任务生成静态文件
对超大站点(10 万+ 文章)建议直接定时任务生成静态 XML 文件。在 Discuz 后台“系统设置-计划任务”新建一条计划任务,每小时执行一次自定义脚本:
= 0 AND closed = 0 AND status = 0。
## 常见的实战坑
## 坑 1:URL 重写规则没匹配
很多站点会把 sitemap.xml 通过 nginx rewrite 转到 portal.php?mod=sitemap,但同时根目录又有一个 sitemap.xml 静态文件(之前用插件留下的)。nginx 的 location 匹配优先级里静态文件优先,rewrite 不生效。解决:删除根目录的旧 sitemap.xml 静态文件,或者用 try_files 指令显式跳过文件查找。
## 坑 2:URL 里 & 没转义为 &
portal.php?mod=view&aid=123 这种 URL 在 sitemap 协议里 & 必须写成 &。Discuz 的 dhtmlspecialchars 函数会做这个转义,但部分二开版本去掉了这一步。如果搜索引擎报 sitemap 格式错误,先检查 URL 里是不是 & 而不是 &。原代码里 portal.php?mod=view&aid=$article[aid] 这种写法是正确的,注意保留。
## 坑 3:utf-8 与 gbk 字符集冲突
Discuz 老站点常见 GBK 编码。sitemap 协议要求 UTF-8。在 portal_sitemap.php 顶部强制:
header('Content-Type: application/xml; charset=UTF-8');
$charset = 'UTF-8';
// 如果原始数据是 GBK,每条 URL 的 title/desc 需要 mb_convert_encoding
对 GBK 编码的标题做转换:
$loc = mb_convert_encoding($loc, 'UTF-8', 'GBK');
## 坑 4:lastmod 时间格式错
sitemap 协议接受 YYYY-MM-DD 或 YYYY-MM-DDThh:mm:ss+TZ 两种格式。原代码用的是 date('Y-m-d', $article['dateline']),是合法的。如果你想精确到秒以让 Google 知道更新时刻:
date('Y-m-d\TH:i:sP', $article['dateline'])
这样输出 2024-09-01T15:30:00+08:00 形式。
## 坑 5:404、301、403 的 URL 进了 sitemap
有些已删除文章在 portal_article_title 里仍有记录,但 status=1(待审核)或 status=2(已删除)。原 SQL 查询可能没过滤这些状态。修复:在 SQL WHERE 后加 AND status = 0。
## 坑 6:抓取频率过高反而触发反爬
Googlebot 在 sitemap 提交后短时间内会高频访问。如果你的 sitemap 没缓存且服务器配置不高,可能会因为高 SQL 压力被 fail2ban 误判封 IP。建议:在 nginx 层针对 Googlebot 单独限速但不阻断;或者直接使用方案 B 的静态文件,从根上避免动态生成。
## X3.5 版本的细微差异
Discuz X3.5 把部分 source 目录的文件做了重组,但 portal_rss.php 与 portal_sitemap.php 的位置仍然在 source/module/portal/ 下。核心改动:
- X3.5 默认走 PHP 7.4+,原来 PHP 5.x 的某些写法会触发 deprecated 警告。原 RSS 代码里没有 deprecated 调用,本文改造也不引入。
- X3.5 的 URL 重写默认开启 SEO 静态化为 article-{aid}-{page}.html,rewriteoutput 函数行为没变,本文代码无需调整。
- X3.5 后台的 sitemap 提交入口在“全局-性能优化-Sitemap”,这里只能配置插件型 sitemap,本文方案不依赖它。
## 与 Google Search Console 的诊断协作
提交 sitemap 后 24-48 小时去 Google Search Console“站点地图”页面查看抓取情况。最常见的几种结果:
- “成功 - 已发现 N 个 URL”:正常状态。N 应当与你预期的文章数接近,差几条以内属正常波动。
- “无法获取站点地图”:通常是 sitemap.xml 返回 5xx 或 robots.txt (https://zhangwenbao.com/page-types-to-block-in-robots-txt-for-ecommerce.html) Disallow 了 sitemap 路径。
- “sitemap 中有 URL 错误”:点开错误详情,常见是 lastmod 格式不对、URL 中有空格、URL 跨域。
- “已发现但未编入索引”:sitemap 没问题,但 Google 评估这些 URL 的内容质量后选择不索引。这是内容质量问题,不是 sitemap 技术问题。
## 常见问题解答
## portal.php?mod=sitemap 与根目录的 sitemap.xml 文件冲突怎么办?
Web 服务器的优先级是先找静态文件再走 PHP 动态。如果根目录有同名 sitemap.xml 静态文件,nginx rewrite 不会生效。两个解决:直接删除根目录的旧静态文件;或者在 nginx 的 location ~ \\.xml$ 块里用 try_files 显式跳过文件检查。
## portal_sitemap.php 输出空白页是什么原因?
三个排查点:第一,portal.php 入口的白名单数组是否真的加了 'sitemap',没加会被默认改成 mod=index 走首页;第二,文件名是否严格匹配 portal_sitemap.php,差一个字母路由就找不到;第三,php-fpm 错误日志里看是否有 fatal error,常见的是 PHP 7+ 不识别老的 dhtmlspecialchars 调用方式。
## sitemap 里要不要包含 list 页(分类页)?
建议包含。分类页(portal.php?mod=list&catid=N)也是有价值的索引页,特别是分类内文章很多的情况下。改 portal_sitemap.php 在文章 URL 之外,再循环输出一遍分类 URL:
foreach ($_G['cache']['portalcategory'] as $cat) {
$listUrl = $_G['siteurl'].($rewriteflag ? rewriteoutput('portal_list', 1, '', $cat['catid']) : "portal.php?mod=list&catid={$cat['catid']}");
echo " \n {$listUrl}\n daily\n 0.6\n \n";
}
## sitemap 里要不要带文章配图作为 image sitemap?
对图文为主的门户站点,强烈建议加 image sitemap。在每个 url 块里增加:
https://example.com/article-cover.jpg
文章标题
同时在 urlset 标签里增加命名空间:xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"。这样 Google 会单独索引图片,给你带 image search 流量。
## 百度对 sitemap 的抓取慢怎么办?
百度抓取 sitemap 的频率确实比 Google 低,提交后可能两三天才开始动。三个加速手段:第一,配合百度推送 API 主动推(百度站长平台-资源提交-推送 API);第二,sitemap 在 robots.txt 里显式声明:Sitemap: https://example.com/sitemap.xml;第三,新文章发布时实时推送单 URL 给百度。
## 多语言站点 sitemap 怎么处理?
每个语言一份 sitemap,在 sitemapindex 里都列出来。每个 url 块里用 标记其它语言版本。这是 Google 推荐的多语言 sitemap 协议。
## sitemap 文件大小超过 50MB 怎么办?
分页 sitemap 是直接的解决方案。另一种做法是 gzip 压缩:把 sitemap.xml 用 gzip 压缩成 sitemap.xml.gz,搜索引擎都支持。Apache/Nginx 配置 mod_deflate / gzip 自动压缩 .xml 即可。
## sitemap 提交后多久能在 GSC 看到 URL 全部被抓?
“发现 N 个 URL”是 sitemap 提交成功的指标,通常 1-2 天内显示。“编入索引”需要 1-4 周,取决于站点权威度与内容质量。新站点 sitemap 提交后 1 个月才把所有 URL 索引完是正常状态。
## 原来用 dz_sitemap 插件,怎么平滑切到本文方案?
三步:第一,停用插件但暂时不卸载(保留旧 sitemap.xml 文件以防搜索引擎正在抓);第二,部署本文方案,访问 portal.php?mod=sitemap 验证内容正确;第三,把 nginx rewrite 加上让 /sitemap.xml 指向新方案,同时删掉旧静态 sitemap.xml 文件。这个切换对搜索引擎透明,URL 没变。
## 本方案对 Discuz 升级有影响吗?
影响极小。新增的 portal_sitemap.php 文件官方升级不会动它。portal.php 入口加的一行白名单是你需要在每次升级后手动合并的,但这一行改动很容易识别。如果想完全免维护,可以把这行白名单写成插件 hook(plugin/your_plugin/portal.inc.php),通过 Discuz 插件机制注入,升级完全无感。
## 权威参考资料
## Discuz图片SEO优化深挖:alt/title三级回退策略、模板编译细节与X3.4/X3.5版本差异
- URL:https://zhangwenbao.com/discuz-seo-title-alt.html
- 分类:Discuz教程
- 发布:2020-11-19 | 更新:2026-06-02
- 摘要:Discuz论坛默认上传到帖子的图片不带alt和title,搜索引擎抓不到图片语义。本文从它的模板编译引擎讲起,剖析编译缓存与上下文作用域,给出按附件描述、主题tag、帖子标题的三级回退alt调用代码,再讲alt长度区间、堆砌判定阈值和版本差异。
- 关键词:图片SEO,Discuz SEO,Discuz,alt属性,BBcode
> **TLDR**:摘要:Discuz论坛默认上传到帖子的图片不带alt和title,搜索引擎抓不到图片语义。本文从discuzcode.htm模板怎么编译成PHP讲起,给出按附件描述、主题tag、帖子标题的三级回退alt调用完整代码,再讲alt与title的真实权重差异、堆砌判定阈值、X3.2到X3.5的版本模板差异、改后的副作用清单和可回滚的版本化方案。
> 摘要:Discuz论坛默认上传到帖子的图片不带alt和title,搜索引擎抓不到图片语义。本文从discuzcode.htm模板怎么编译成PHP讲起,给出按附件描述、主题tag、帖子标题的三级回退alt调用完整代码,再讲alt与title的真实权重差异、堆砌判定阈值、X3.2到X3.5的版本模板差异、改后的副作用清单和可回滚的版本化方案。
Discuz X3.x 论坛系统默认从前台编辑器上传到帖子里的图片,HTML 输出时只有 src 和 onclick,没有 alt 也没有 title。对一个内容靠用户生成 (UGC) 的论坛站,图片往往占帖子篇幅的一半甚至更多——这些图片在 Google 图片搜索 / 百度图片里抓不到任何语义,相当于把整片图片流量主动让给了竞品。
这一篇把 Discuz 图片 SEO 这件事从模板引擎一直拆到最终上线效果:先讲清 discuzcode.htm 这个文件被 Discuz 自研编译器怎么处理、为什么后台要"刷新缓存"才生效,再给出按"附件描述 → 主题 tag → 帖子标题"的三级回退 alt 调用代码,最后讲透 alt 与 title 在搜索引擎眼里的真实权重差异、堆砌关键词的真实判定线、X3.2 / X3.4 / X3.5 三版本模板差异,以及与现代图片技术(srcset / lazy-load / Schema ImageObject)的协同。
## Discuz 模板引擎:discuzcode.htm 是怎么变成 PHP 的
很多 Discuz 站长改完 discuzcode.htm 重启了 Apache 也没生效,最后归结到"忘记后台刷新缓存"。这背后是 Discuz 不走 Smarty 也不走 Twig,用的是自研的极简模板引擎 template_compile。理解它的工作机制后,再处理任何模板修改都不会再迷路。
## 编译流程
Discuz 在程序启动时调用 function_core.php 的 template() 函数,传入模板路径(如 forum/discuzcode),函数内部依次:
- 检查 data/template/ 目录下是否已有对应的编译产物(命名规则是 style_id_模板路径.tpl.php);
- 如果产物不存在或源文件 mtime 比产物新,触发 parse_template() 重新编译;
- 编译过程:把 .htm 里的 {if ...}、{loop ...}、{eval ...} 等花括号语法翻译为标准 PHP 代码;
- 编译产物落到 data/template/ 下,下次直接 include 该 .tpl.php,跳过编译。
所以"刷新缓存"实际上是 清空 data/template/ 下的 .tpl.php 文件,强制下次访问重新编译。后台 → 工具 → 更新缓存 → 模板缓存,等价于命令行 rm data/template/*.tpl.php。
## 编译后的实际产物长什么样
原始 discuzcode.htm 里这一行(典型 BBCode 解析处):
编译后的 data/template/forum_discuzcode.tpl.php 里变成:
翻译成 PHP 之后就是普通的字符串拼接,运行时由 PHP 解释器跑出最终 HTML。所以模板里的所有变量都是 PHP 变量——$attach 是当前附件数组、$_G 是全局上下文、$post 是当前楼层数据等等。理解这点后,模板里写的所有 {if $foo['bar']} 你都能心里翻译成 if ($foo['bar']),不再是黑盒。
## BBCode 解析的特殊标记 inpost="1"
原帖里要修改的位置定位字符串是 inpost="1"。这个属性是 Discuz BBCode 解析器在帖子楼层渲染上下文时打上的标记——只有在解析帖子正文里的 [img]xxx[/img] 这种标签时,模板才会进这个分支。这就是为什么我们要在 inpost="1" 前插入 alt/title 自动注入逻辑:精确锁定"帖子正文中的图片",而不影响头像、表情、签名图片这些其它图片源。
## 三级回退 alt 调用:完整代码与逐段解析
核心思路:图片应该优先用最具体的描述作 alt——附件自带的 description 字段是用户最精确的描述,没有就退到主题 tag 标签(含语义关键词),再没有最后用帖子标题(一定有)。这种 fallback 链能保证每张图片都有合理 alt,没有漏网。
## 完整代码
alt="{if $attach['description']}$attach['description']{elseif $post['first'] && ($post[tags] || $relatedkeywords) && $_GET['from'] != 'preview'}{if $post[tags]}{eval $tagi = 0;}{loop $post[tags] $var}{if $tagi}, {/if}$var[1]{eval $tagi++;}{/loop}{/if}{if $relatedkeywords}{/if}{else}$_G['thread']['subject']{/if}" title="$_G['thread']['subject']"
把这段插到 template/default/forum/discuzcode.htm 中 inpost="1" 的前面(同一行内、属性级),保存 → 后台 → 工具 → 更新缓存 → 模板缓存。
## 逐段解析(让你下次改的时候不靠抄)
条件 | 取值 | 触发场景 |
$attach['description'] 不为空 | 用 attach.description | 用户在编辑器附件管理里给该图填了"描述" |
是首楼 + 有主题 tag + 不是预览态 | 用 tag 标签拼接(逗号分隔) | 主题正文图,回退到主题 tag |
以上都不满足 | 用帖子标题 | 兜底 |
title | 固定用帖子标题 | 所有图片一致 |
有几个细节是源代码读懂后才能解释的:
- $post['first']:标记当前楼层是否是首楼。Discuz 帖子里一个主题可以有多楼,只有首楼才会显示主题级别的 tag——所以 fallback 到 tag 这一步限定 first 楼,避免回复楼层也强行套主题 tag。
- $_GET['from'] != 'preview':发帖预览态下,$post 数组没有从数据库完整加载(tag 是 join 出来的),强行取会报 undefined index 警告。排除预览态是必要兜底。
- {loop $post[tags] $var}:Discuz 模板的循环语法。$post['tags'] 是二维数组,每个元素是 [tagid, tagname],所以 $var[1] 是 tag 名。
- 逗号分隔时的空格:原帖 alt 里多个 tag 用 , 直接拼无空格。我建议改成 , 加空格——alt 文本对搜索引擎来说还是"自然语言",逗号后跟空格更像人话。
## 加固版本:兼容 attach 描述里的 HTML 实体
真实生产环境踩过的坑:用户在附件描述里写了引号或尖括号("张文保的"产品图""),原样输出到 alt="..." 会破坏 HTML 结构。加 htmlspecialchars 编码:
alt="{if $attach['description']}{eval echo htmlspecialchars($attach['description'], ENT_QUOTES, 'UTF-8');}{elseif ...}"
这一步在原帖代码里没考虑,对中文站点几乎不踩坑(中文用户很少打英文双引号),但内容来自国际化用户时必加。
## alt 与 title 在搜索引擎里的真实权重差异
"alt 给搜索引擎看,title 给用户看"是简化说法。实际两边都看,只是优先级不同:
## Google 图片搜索的真实抓取顺序
根据 Google Search Central 文档与多年实测,Google 抓取图片时按以下优先级提取语义:
- alt 属性(首选,权重最高)
- 图片附近的文字(caption / figure / 上下 100 字符内的文字)
- 文件名(如 red-running-shoes.jpg 比 IMG_1234.JPG 强)
- 页面 title 与 H1(图片所在页面的语境)
- title 属性(次要补充,悬停提示)
- EXIF 数据中的描述(最次要,但有效)
所以你看,alt 在权重上确实高于 title,但 title 不是没用——它是图片的"鼠标悬停语义",对部分桌面用户体验有意义,也对 Google 的多模态相关性微调有贡献。两者都要写,且不要相同(避免触发"重复内容"启发式)。
## 百度图片对 alt 的依赖更高
百度图片由于历史上对图片本身视觉特征的抓取能力较弱,更依赖周边语义信号——alt 在百度图片里的权重比 Google 还要高。我手上有一个 Discuz 站做对比测试:
测试场景 | Google 图片收录率 | 百度图片收录率 |
无 alt、无 title | ≈ 35% | ≈ 8% |
有 alt(取自帖子标题,所有图相同) | ≈ 70% | ≈ 55% |
三级回退 alt(每图描述各异) | ≈ 90% | ≈ 80% |
百度图片在"无 alt vs 有 alt"之间的提升幅度(8% → 55%)比 Google 大得多——这就是为什么针对国内论坛用户的 Discuz 站做图片 SEO 时,alt 改造的 ROI 显著高于其它平台。
## 字符长度怎么把握
alt 没有官方上限,但实务上:
- Google:alt 超过 ~125 字符(约 60 中文字)后基本不再贡献语义权重。常用规则是 最多 80-125 字符,5-10 个有意义的词。
- 百度:阈值更宽,可达 ~200 字符(约 100 中文字)。
- title:建议比 alt 略长一点,可包含触动用户点击的修饰语。
Discuz 三级回退里第三档"取帖子标题"——大多数论坛标题在 20-40 字之间,正好落在合理 alt 区间。如果论坛规定标题超过 60 字,可能要在模板里做一次截断(用 cutstr() 函数)。
## 关键词堆砌的真实判定阈值
"alt 不要堆砌关键词"这句口号听了无数次,但具体什么程度算堆砌?基于多年观察归纳出几个经验值:
- 同一页面 alt 中的核心关键词重复率 > 50%:明显堆砌。比如一篇帖子有 10 张图,9 张 alt 都是同一个 tag 关键词,触发 Google Panda 算法对"低质 / 模板化内容"的判定线。
- 单个 alt 内同一关键词出现 > 2 次:明显堆砌。"红色运动鞋 红色运动鞋好看 红色运动鞋促销"这种都算。
- alt 与页面 title / H1 完全相同:可疑堆砌。搜索引擎会怀疑你是不是在用模板批量套 alt。
三级回退方案在这些方面的天然优势:
- 第一级 attach.description 是用户原创,每张图各异 ✓
- 第二级 tag 拼接,多个 tag 用逗号分隔产生足够多样性 ✓
- 第三级帖子标题作为兜底,但仅在前两级都没有时使用——同一帖子 10 张图里走到第三级的概率正好不高 ✓
所以这个方案在算法上对堆砌检测是相对友好的。如果你的论坛运营要求很严格,建议给运营/小编培训"附件上传时填描述"的习惯,把第一级覆盖率拉高,整体 SEO 效果更好。
## Discuz X3.2 / X3.4 / X3.5 三版本模板差异
Discuz 在不同版本里 discuzcode.htm 的具体路径和锚点字符串略有差异,迁移时要注意:
版本 | discuzcode.htm 路径 | inpost 锚点 | $attach 字段名 |
Discuz X3.2 | template/default/forum/discuzcode.htm | inpost="1" | $attach['description'] |
Discuz X3.3 | template/default/forum/discuzcode.htm | inpost="1" | $attach['description'] |
Discuz X3.4 | template/default/forum/discuzcode.htm | inpost="1" | $attach['description'] |
Discuz X3.5 | template/default/forum/discuzcode.htm | inpost="1" | $attach['description'] |
X3.5 默认开启 SSL 后 | 同上,模板内 src 改用 //协议无关 | 同上 | 同上 |
结论是:从 X3.2 开始到目前最新的 X3.5,模板路径和锚点没有变化,三级回退代码可以无修改通用。X3.5 的差异主要在 src 用了协议无关的 //host/path,对 alt/title 注入逻辑不影响。
## 用了应用中心模板(非 default)的怎么办
很多 Discuz 站装了第三方模板(比如蓝色风格、卡片风格、移动端模板),路径变成 template/<主题ID>/forum/discuzcode.htm。修改要在当前激活的模板下做,default 改了不生效。后台 → 界面 → 风格管理 看哪个风格有星标(默认)就改哪个。
## 移动端模板(mobile)有自己的 discuzcode_mobile.htm?
X3.4 之后引入移动端独立模板。移动端帖子图片渲染走 template/default/forum/discuzcode_mobile.htm,逻辑跟 PC 端类似但不共享代码。要改两份,PC 端和移动端各一份,否则手机访问帖子时还是没 alt。
## 修改后的副作用清单(生产环境踩坑)
## 与 BBCode 短代码的兼容性
Discuz 帖子里用户能写各种 [img] 短代码变体:[img=300,200]xxx[/img](指定尺寸)、[img]http://外链[/img](外部图片)、[free]xxx[/free](免认证图)等。修改 discuzcode.htm 后要全测一遍——尤其外链 (https://zhangwenbao.com/is-external-link-building-important-for-seo.html)图,$attach 数组在外链图场景下是空的,三级回退会跳到第三级(帖子标题),对外链图来说还合理。但如果用户写错了 BBCode(比如 [img][/img] 中间没内容),原本 Discuz 会渲染出空 img 标签,加了 alt 之后变成
——视觉上看起来更怪了,但不报错。
## 与图片 lazy-load 插件的协同
Discuz 后台开了"延迟加载图片"功能或装了 lazy-load 插件,会把 src 替换成 data-src,src 改用 1×1 透明 placeholder。这种情况下 alt 反而更重要——加载完成前用户看到的就是 alt 文字,不能写得太废。三级回退方案在 lazy-load 场景下表现更好。
## 与 srcset / picture 的兼容
X3.5 开始部分模板支持响应式 srcset。alt 是
自身的属性,不影响 srcset 行为。如果你给 srcset 加了 WebP / AVIF 兼容(用 包裹),alt 写在最里层
上即可,外层 不需要 alt。
## 与 Schema ImageObject 的协同
在帖子页加了 ImageObject Schema 的话(建议加),Schema 里的 name / description 与 HTML 的 alt / title 不要完全相同——Google 的 Rich Results 要求两者形成"互补"而非"重复"。简单做法:Schema name 用帖子标题,description 用附件描述(如有),alt 用三级回退后的值。
## 缓存刷新机制
修改 discuzcode.htm 后必须刷新缓存。三种刷新位置缺一不可:
- 后台 → 工具 → 更新缓存 → 模板缓存(最常忘记)
- 站长 → 服务器优化 → 内存缓存 / Memcache(如果开了)
- CDN 缓存(如果套了 CDN)
三层都刷过,再用浏览器无缓存模式(Ctrl+Shift+R)打开帖子,源代码里看到 alt 出现了才算改造完成。
## 配套优化:图片 SEO 不是只有 alt/title
alt/title 只是图片 SEO 链路里的一环,整体性思考要看:
## 文件名
Discuz 默认上传后图片文件名是 (随机)_(时间戳).jpg,没有任何语义。要改这个需要改上传逻辑(source/class/upload/upload.class.php),把文件名根据 attach.description 或 tag 自动生成 SEO 友好的英文 / 拼音串。改造工作量比 alt 大得多,建议优先级排在 alt/title 之后。
## 上下文文字
Google 抓取图片时会读图片上下文(前后 100 字符内的文字)。Discuz 编辑器默认会在图片前后产生
这种空段落,破坏上下文连续性——可在前台 JS 里做后处理把空段落去掉,或在后台改 htmleditor.js 让编辑器不插空段落。
## ImageObject Schema
给帖子页加 ImageObject Schema。在 discuzcode.htm 同一处插入 alt 的旁边,可以再加一个 JSON-LD 输出:
但 Schema 要求 contentUrl 是绝对 URL,如果 $attach[url] 已经是相对路径,需要拼上 $_G['siteurl']。
## EXIF 与 IPTC 元数据
用户上传的原图常带相机型号、拍摄地点等 EXIF 信息——保留这些对 Google 图片有微弱正向贡献,但也可能暴露用户隐私(GPS 坐标)。Discuz 默认会保留 EXIF,如果是隐私敏感站,可在上传逻辑里调用 imagecreatefromjpeg 重新另存以清空 EXIF。
## 图片格式与压缩
WebP 比 JPEG 体积小 25-35%,对 Core Web Vitals (https://zhangwenbao.com/mobile-seo-mistakes-2026.html) 的 LCP 贡献明显。Discuz X3.5 已支持 WebP 上传与展示,X3.4 及以下版本要装第三方 WebP 转换插件。Google 图片对 WebP 完全友好,不影响 SEO。
## 把改造做成一个可回滚的版本化方案
直接改 template/default/forum/discuzcode.htm 的最大风险是 Discuz 升级时被覆盖。建议做法:
## 复制模板到自有风格目录
到后台 → 界面 → 风格管理 → 添加新风格(基于 default 复制),叫 seo。改的是 template/seo/forum/discuzcode.htm,原 default 不动。这样升级时 default 被官方覆盖,自定义风格安全。
## 用 Git 管理模板目录
把 template/seo/ 目录纳入 Git 版本管理,每次改动 commit,意外覆盖时直接 git checkout 回滚。Discuz 升级前打个 tag,方便回退到老版本。
## 部署前做"diff 自动检测"
简单 shell 脚本:在 deploy 前比较 template/default/ 与 template/seo/ 的 discuzcode.htm 差异——如果 default 这个文件在升级中改了,提示需要把改动手动 merge 到 seo 风格里。
#!/bin/bash
diff template/default/forum/discuzcode.htm template/seo/forum/discuzcode.htm | head -50
## 2026 年 Discuz 现状速览
这一节是给做决策的站长看的——Discuz 目前是什么状态?
- 官方维护:Discuz 在 2021 年由腾讯转交给 Discuz 开源社区(discuz.com → discuz.net 主导),后续版本(X3.5 在 2022 年发布)由社区维护。腾讯不再直接负责。
- X3.5 是当前最新主版本,PHP 8.x 兼容、SSL 默认开、移动端模板增强。X3.4 仍在大量站点上跑,但社区建议升级。
- 安全:X3.x 历史上有多个高危漏洞(远程代码执行、SQL 注入),不打补丁的老 X3.2/X3.3 站点风险极高。本文这套 alt/title 改造与漏洞补丁正交,可以独立做。
- 新项目选型:2026 年再选 Discuz 上线新论坛要慎重——大体量论坛站建议看 Flarum(PHP / 现代化)、NodeBB(Node.js / 实时)、Discourse(Ruby / 全球流行)。Discuz 的优势在于"中文社区生态熟悉"和"插件库丰富",劣势是"移动端体验、API 现代化、PWA (https://zhangwenbao.com/pwa-seo-service-worker-crawl-indexing-impact-mechanism.html) 支持"都不如新型论坛框架。
## 常见问题解答
## 改完 discuzcode.htm 没生效,源代码看 img 还是没 alt 怎么办?
三个排查点:① 是否后台刷新了模板缓存(最常见原因,参见 6.5);② 改的是不是当前激活风格的 discuzcode.htm(不是 default 而是用户实际看到的风格);③ 浏览器是否有强缓存——Ctrl+Shift+R 无缓存重载。三步过完仍没效,到 data/template/ 目录直接 rm 所有 .tpl.php 文件再访问一次,强制重新编译。
## 多个 tag 用逗号还是空格分隔,对 SEO 影响有差别吗?
实测无显著差别。Google 对 alt 文本的解析会自动正则化空白与标点。建议用"逗号 + 空格"("运动鞋, 跑鞋, 篮球鞋")——读起来像人话,对屏幕阅读器友好(无障碍 (https://zhangwenbao.com/website-accessibility-seo-optimization-guide.html))也加分。
## $attach 数组里有哪些字段可以用?
常用字段:aid(附件 ID)、filename(原始文件名)、filesize、filetype(扩展名)、url(访问根)、attachment(相对路径)、description(用户描述)、thumb(是否有缩略图)、isimage(是否图片)、width、height。在模板里直接 $attach['字段名'] 即可访问。
## 用户上传时为什么很少有人填"附件描述"?
Discuz 编辑器的"附件描述"字段藏在"高级模式"里,普通用户看不到。三种解决思路:① 后台 → 界面 → 编辑器配置 → 默认开启高级模式;② 改前端 JS,把"添加描述"的字段从折叠里展开放在主流程;③ 给运营 / 版主设硬性 KPI:"凡置顶帖发图必填描述",从源头提高第一级覆盖率。第三种最有效。
## title 用帖子标题,如果帖子标题里有引号会破坏 HTML 吗?
会。如果帖子标题里包含 "(双引号),title="$_G['thread']['subject']" 会把 title 属性提前结束,后面所有内容跑出 HTML 结构。一定要加 htmlspecialchars 编码:title="{eval echo htmlspecialchars($_G['thread']['subject'], ENT_QUOTES, 'UTF-8');}"。alt 同理。
## 这个改造对 PageSpeed / Core Web Vitals 有影响吗?
无负面影响。alt/title 是属性字符串,不增加 HTTP 请求、不阻塞渲染。理论上 HTML 体积会增加几百字节(每图增加 ~50-100 字节),可忽略。Google 还把"图片有 alt"作为 Lighthouse Accessibility 评分项之一,加了反而正向加分。
## 除了 discuzcode.htm,还有哪些模板会渲染图片需要改?
头像渲染走 template/default/common/header.htm 里的
,签名图走 template/default/forum/viewthread_signaturebox.htm,表情走 template/default/forum/discuzcode.htm 中 {$smiley} 段。如果这几处的图片也想加 alt(推荐头像加上"用户名的头像"),要分别在各模板内独立改。
## 三级回退方案有没有"过度优化"的风险?
不会。三级回退是"补足缺失值"的逻辑——给原本完全没有 alt 的图片填上合理内容,不存在"加多了"的问题。真正的过度优化风险在第一级(attach.description)的填写——如果 100 张图的 description 都被运营批量填成同一个核心关键词,那叫人为堆砌,跟模板逻辑无关。
## 升级 Discuz 后改造代码丢了怎么办?
如果按本文 8.1 的方案做了独立 SEO 风格目录,default 被覆盖不影响 seo 风格。如果直接改的 default,每次升级前必须备份 template/default/forum/discuzcode.htm,升级后 diff 比较再 merge 自定义改动。Git 管理是最稳的做法。
## 对论坛站的"图片站长平台"提交(比如百度图片站长平台)有帮助吗?
有,但有限。百度图片站长平台主要看"图片到 HTML 的可被抓取性"和"图片本身质量分",alt 充实只是"可抓取性"的一部分。提交平台后还要保证:图片直链稳定不变(不要频繁改 CDN 路径)、图片清晰度(≥ 800px 长边)、图片不加水印(特别是百度图片)、页面整体内容质量过关——alt 改造只解决了第一道关。
## 权威参考资料
## Discuz$_G全局变量深度解析:初始化时机、性能开销、安全坑与现代框架对比
- URL:https://zhangwenbao.com/discuz-global-variables-details.html
- 分类:Discuz教程
- 发布:2020-07-22 | 更新:2026-06-02
- 摘要:$_G是Discuz自定义的超全局数组,每个请求由discuz_application初始化时装载配置、缓存和用户身份。本文拆解它的字段分组、装载耗时、直接var_dump暴露密码的安全坑、Redis加速,以及和现代PHP框架容器的对比与滥用避坑。
- 关键词:Discuz变量,Discuz X3,$_G,Discuz 二开,Discuz Redis
> **TLDR**:摘要:$_G是Discuz自定义的超全局数组,每个请求由discuz_application初始化时装载配置、缓存和用户身份。本文拆解它在框架里的位置和核心字段分组、直接打印$_G会泄漏数据库密码的安全坑、每次请求都要装一遍的性能开销,再讲各版本差异、和现代PHP框架的对比、扩展$_G的实战,以及减少装载开销和滥用反模式。
> 摘要:$_G是Discuz自定义的超全局数组,每个请求由discuz_application初始化时装载配置、缓存和用户身份。本文拆解它在框架里的位置和核心字段分组、直接打印$_G会泄漏数据库密码的安全坑、每次请求都要装一遍的性能开销,再讲各版本差异、和现代PHP框架的对比、扩展$_G的实战,以及减少装载开销和滥用反模式。
Discuz (https://zhangwenbao.com/discuz-batch-replacement-of-sql.html)! 全局变量 $_G 是论坛框架里最关键的"运行时上下文容器"——保存了当前用户、当前帖子、当前版块、所有缓存、所有设置项。Discuz 几乎所有插件、模板、二开代码都在围绕这一个数组转。但网传的"$_G 字段速查表"只列字段名,没讲清楚它的初始化时机、性能开销、安全坑(直接打印会泄漏数据库密码)、与现代 PHP 全局上下文模式的对比。这一篇把 Discuz $_G 拆透。
## $_G 在 Discuz 框架里的位置
Discuz! 在每个请求开始时(source/class/discuz/discuz_application.php 的 init 方法)做几件事:
- 读 config 配置:从 config/config_global.php 加载站点设置(数据库连接、UCenter URL 等);
- 装载缓存:从 Memcached / Redis / 文件缓存里取已编译好的设置 / 用户组 / 版块树等;
- 识别用户身份:从 cookie 解出 UID + 用户组,查数据库取当前用户信息;
- 解析当前请求参数:fid(版块)、tid(帖子)、mod(模式)等;
- 组装 $_G:把上面所有数据汇总到 $GLOBALS['_G'](PHP 超全局),代码里用 $_G 直接引用。
所以 $_G 不是 PHP 原生超全局($_GET、$_POST 是原生),是 Discuz 自定义的,但通过 global $_G 或在文件顶部 declare 后能像超全局一样在任何地方使用。
## 与 PHP 原生超全局的对比
变量 | 来源 | 含义 |
$_GET | PHP 原生 | URL 查询参数 |
$_POST | PHP 原生 | POST 表单数据 |
$_SERVER | PHP 原生 | 请求头 + 服务器信息 |
$_SESSION | PHP 原生 | 会话数据 |
$GLOBALS | PHP 原生 | 所有全局变量 |
$_G | Discuz 自定义 | Discuz 运行时上下文(用户/版块/帖子/设置/缓存全部) |
$_ENV | PHP 原生 | 环境变量 |
## $_G 的核心字段分组
$_G 里上百个字段按用途分四大类:
## 当前请求上下文
$_G['uid'] // 当前登录用户 ID(0 表示未登录)
$_G['username'] // 当前用户名
$_G['adminid'] // 管理组 ID(0=普通用户,1=超管)
$_G['groupid'] // 用户组 ID
$_G['fid'] // 当前版块 ID(帖子页/版块页才有)
$_G['tid'] // 当前帖子 ID(帖子页才有)
$_G['mod'] // 当前 mod 参数(forum.php?mod=viewthread)
$_G['inajax'] // 是否 ajax 请求(0/1)
$_G['page'] // 当前分页号
$_G['tpp'] // 每页显示数量
## 全局配置(来自 config_global.php)
$_G['config']['db'][1]['dbhost'] // 数据库主机
$_G['config']['db'][1]['dbname'] // 数据库名
$_G['config']['db'][1]['dbuser'] // 数据库用户名
$_G['config']['db'][1]['dbpw'] // 数据库密码 ⚠ 敏感
$_G['config']['db'][1]['tablepre'] // 表前缀(默认 pre_)
$_G['config']['security']['authkey'] // 认证密钥 ⚠ 敏感
$_G['config']['cookie']['cookiepre'] // Cookie 前缀
## 后台设置(来自 wp_common_setting 表)
$_G['setting']['sitename'] // 站点名
$_G['setting']['siteurl'] // 站点 URL
$_G['setting']['icp'] // ICP 备案号
$_G['setting']['attachurl'] // 附件 URL
$_G['setting']['attachdir'] // 附件目录
$_G['setting']['rewriterule'] // 伪静态规则
$_G['setting']['extcredits'] // 积分配置(数组)
$_G['setting']['creditsformula'] // 总积分计算公式
$_G['setting']['plugins'] // 已启用插件列表
## 缓存数据(来自 Memcached / Redis)
$_G['cache']['groupperms'] // 用户组权限缓存
$_G['cache']['forums'] // 版块树缓存
$_G['cache']['smiley'] // 表情缓存
$_G['cache']['plugin'] // 插件配置缓存
$_G['cache']['styles'] // 风格缓存
$_G['cache']['userstatus'] // 用户状态缓存
## 安全坑:直接打印 $_G 泄漏数据库密码
这是 Discuz 二开里最容易踩的坑——开发者调试时随手 var_dump($_G) 或 print_r($_G),结果整个数据库密码、authkey、所有缓存内容都打印到页面或日志。如果是测试环境也罢,万一在生产环境的某个调试页面忘了删,攻击者一发现就拿到所有敏感信息。
## 安全打印 $_G 的方式
// ❌ 危险:暴露所有敏感数据
print_r($_G);
// ✅ 安全:只打印当前关心的字段
var_dump([
'uid' => $_G['uid'],
'username' => $_G['username'],
'fid' => $_G['fid'],
'tid' => $_G['tid'],
'mod' => $_G['mod'],
]);
// ✅ 或者打印过滤敏感字段后的副本
function safe_dump_g() {
$copy = $GLOBALS['_G'];
unset($copy['config']['db']);
unset($copy['config']['security']);
unset($copy['cache']['plugin']); // 插件可能有 API key
return $copy;
}
print_r(safe_dump_g());
## 调试代码的标准防护
所有调试输出都包在 if (DEBUG) 里:
// 在 config 里加常量
define('DEBUG', false); // 上线必须 false
// 调试代码包起来
if (DEBUG) {
echo '';
print_r(safe_dump_g());
echo '
';
}
这样上线时只要 DEBUG=false,所有调试输出全静默。永远不要靠"我记得删了"——人类记不住。
## 性能开销:$_G 每次请求都要装一遍
$_G 在每个 PHP 请求开始时全量装载,开销不小:
装载步骤 | 典型耗时 |
读 config_global.php | ~ 1 ms |
装载用户身份(含查 user 表) | 5-15 ms |
装载缓存(Memcached/Redis) | 2-5 ms(命中)/ 50-200 ms(未命中要重建) |
装载版块树(forum 数 × 1ms) | 论坛 100 版块约 5-10 ms |
装载已启用插件配置 | 每插件 1-2 ms |
典型中型论坛每请求装 $_G 总耗时 30-80 ms。这是 Discuz 性能的主要瓶颈之一——所以缓存是否命中(Memcached/Redis 在线)极其关键。
## 缓存全部从 Redis 读取的优化
Discuz X3.5 起原生支持 Redis:
// config/config_global.php
$_config['memory']['redis']['server'] = '127.0.0.1';
$_config['memory']['redis']['port'] = 6379;
$_config['memory']['redis']['serializer'] = 1;
$_config['memory']['redis']['pconnect'] = 1;
$_config['memory']['redis']['timeout'] = 3;
开了之后所有 cache 字段从 Redis 拿,比文件缓存快 10 倍。Memcached 也类似,二选一。
## 什么时候用 $_G 什么时候直接查 SQL
开发 Discuz 插件时常见两难:从 $_G['cache'] 取还是直接查数据库?决策矩阵:
数据特征 | 从 $_G['cache'] 取 | 直接查 SQL |
变化频率低 | ✓(缓存命中率高) | 慢且没必要 |
需要最新(如订单状态) | 不行(缓存过期) | ✓(实时) |
大数据量(10 万+) | 不行(不能全装内存) | ✓(分页查) |
跨用户共享(如版块树) | ✓(一次缓存所有用户共用) | 慢 |
用户私有(如未读消息) | 不适合(缓存按用户分太多) | ✓ |
原则:"配置类、共享类、变化少"用缓存;"业务类、用户私有、变化多"直接查。
## $_G 与 Discuz X 各版本的差异
版本 | 主要变化 |
Discuz X 1.x | 引入 $_G 替代之前 X1 时代的零散全局变量 |
Discuz X 2.x | $_G 字段大幅扩展,加入 plugin / styles 缓存 |
Discuz X 3.0 | 支持 Redis / Memcached 加速 $_G 装载 |
Discuz X 3.2 | $_G['config']['security'] 引入 |
Discuz X 3.4 | 移动端字段 $_G['mobile'] 加入 |
Discuz X 3.5 | 原生 PHP 8 兼容、Redis 序列化器选项 |
跨版本写插件要注意——X3.5 移除了某些老字段,老插件直接用可能 NOTICE。建议用 isset() 守护:if (isset($_G['xxx'])) { ... }。
## $_G 与现代 PHP 框架的对比
$_G 这种"全局上下文容器"在 2026 年的现代 PHP 框架里被替换为更优雅的写法:
框架 | 等价机制 |
Discuz | $_G 全局数组 |
Laravel | Service Container(依赖注入) |
Symfony | Container + Request 对象 |
Yii | Yii::$app 单例 |
WordPress | $GLOBALS['post'] / $GLOBALS['wp_query'] |
ThinkPHP | app() 容器 |
$_G 的设计在 2010 年代是合理的——简单、直接。但在 2026 年看显然有几个问题:
- 无类型提示:所有字段都是字符串/数组混合,IDE 自动补全弱;
- 无懒加载:所有缓存字段都装载,即便当前请求用不到;
- 无可测性:单元测试时 mock $_G 困难;
- 无依赖注入:所有函数都隐式依赖全局,重构难。
但 Discuz 已经成熟稳定,这套架构不大可能重构。理解 $_G 是与 Discuz 共存的现实。
## 扩展 $_G 的实战
插件经常需要给 $_G 加自定义字段。标准做法:
// 在插件入口(plugin.xml 指定的 module 文件)
require_once libfile('function/myplugin', 'class/plugin');
// 给 $_G 加一个字段
global $_G;
$_G['myplugin']['version'] = '1.0';
$_G['myplugin']['settings'] = C::t('common_setting')->fetch('myplugin_settings');
// 之后任何文件可以直接用
echo $_G['myplugin']['version'];
注意:
- 用插件 ID 做命名空间($_G['myplugin'][...]),避免冲突;
- 不要写 $_G['username'] = 'attacker' 这种覆盖核心字段;
- 大数据不要塞 $_G——它是每个请求的内存,塞大了拖慢所有请求。
## 调试技巧:在线观察 $_G
开发新插件时实时观察 $_G 内容:
// 在管理员账号下显示一个调试浮层
if ($_G['adminid'] == 1 && isset($_GET['debug_g'])) {
echo '';
echo '
';
print_r(safe_dump_g());
echo '
';
echo '
';
}
访问 ?debug_g=1 弹出浮层只对管理员可见(adminid=1),不影响普通用户。
## 性能优化:减少 $_G 装载开销
大型论坛 $_G 装载是性能瓶颈,几个优化方向:
- 开 Redis / Memcached:所有 cache 字段从内存读,比文件缓存快 10x;
- 关掉不必要的插件:每个启用插件都给 $_G['cache']['plugin'] 加东西;
- 用 OPcache:让 PHP 缓存编译后的 bytecode,加快每次请求启动;
- 分离静态资源:CSS/JS/图片走 CDN,论坛 PHP 进程只处理动态请求;
- 升级 PHP 8.1+:Discuz X3.5 支持 PHP 8,性能比 PHP 7 好 10-30%。
## 与现代化部署的协同
把 Discuz 上 Docker (https://zhangwenbao.com/wordpress-docker-containerized-deployment-environment-consistency.html) / K8s 时,$_G 的几个相关注意点:
- Redis 必须用容器外 / 独立服务:$_G 缓存数据需要跨多个 PHP 容器共享,本地 Redis 不行;
- Session 走 Redis:Discuz 默认 Session 走文件,多容器场景必须走 Redis;
- 静态文件走 PVC 或 OSS:附件、头像不能本地存,否则容器重启丢;
- config_global.php 走 ConfigMap:数据库密码、authkey 走 K8s Secret,不写代码里。
## $_G 滥用的反模式
实战见过的反模式(不要学):
- 把 $_G 当临时变量传函数参数:function foo() { global $_G; ... } 函数里直接读 $_G 是 Discuz 风格但污染了全局耦合,单测难;
- 给 $_G 加大数据:把 100MB 的图片字节塞 $_G['img'] 然后整个请求都拖;
- 循环里反复修改 $_G:性能极差,且容易引起意外副作用;
- 用 $_G 当事件总线:插件 A 写 $_G['event_xxx'],插件 B 读,触发回调——比走正经的 hook 系统糟糕。
## 常见问题解答
## $_G 与 $GLOBALS['_G'] 是同一个东西吗?
是。$_G 是通过 global $_G 声明后从 $GLOBALS['_G'] 取出的引用。在文件顶部(Discuz 通过 declare 自动注入)声明后,整个文件作用域都能用 $_G。直接 $GLOBALS['_G'] 在所有位置都能用,不需要 global 声明。
## 插件给 $_G 加自定义字段会冲突吗?
会,如果命名不规范。建议每个插件用插件标识做前缀,比如 $_G['myplugin']['xxx'] 或 $_G['plugin_myplugin_xxx']。Discuz 自家保留字段(uid、username、fid、tid 等)禁止覆盖。命名空间规范了 100% 不会冲突。
## 为什么 $_G['fid'] / $_G['tid'] 有时为空?
fid/tid 只在帖子相关页面(forum.php?mod=viewthread / forum.php?mod=forumdisplay)才有值。在首页、个人中心、其它非论坛页面,这两个字段为空或不存在。代码里使用前要 isset($_G['fid']) && $_G['fid'] > 0 守护。
## $_G 是只读的吗?
不是。Discuz 没有强制锁定 $_G,任何代码都能写。但不应该修改核心字段(uid、username 等),否则会破坏后续逻辑(比如权限检查依赖 uid)。插件加自定义字段是 OK 的(用插件命名空间)。
## $_G['cache'] 的缓存什么时候失效?
取决于具体字段。① forums(版块树)在版块新增/修改/删除时;② groupperms(权限)在用户组改时;③ plugins(插件配置)在启用/禁用插件时。后台 → 工具 → 更新缓存可以手动触发清缓存。
## 能不能把 $_G 序列化保存?
能但不建议。$_G 体积大(含所有缓存),序列化后 100KB+ 很常见。要保存当前请求快照(用于复现 bug),建议只保存关键字段:serialize(['uid'=>$_G['uid'], 'fid'=>$_G['fid'], 'tid'=>$_G['tid'], 'mod'=>$_G['mod']])。
## 升级 Discuz 后老插件读 $_G 报错?
多数是字段名变了。X3.4 → X3.5 移除了几个老字段(如 $_G['member'] 部分子字段,要走 $_G['member'] = getuserbyuid($_G['uid']) 显式查)。修法:用 isset() 守护 + 给老字段写兜底逻辑。
## $_G 能跨请求保持吗?
不能。$_G 是请求级的——每个 PHP 请求开始时重新装载,请求结束就释放。要跨请求保持的状态用 Session($_SESSION)或 Cookie。混淆这点会导致"为什么我设的值下次请求就丢了"困惑。
## $_G 的内存占用大概多少?
典型中型论坛(10 万用户、100 个版块、20 个插件)单请求 $_G 内存约 5-15 MB。如果论坛规模大(千万级用户、万级版块),$_G 可能膨胀到 50MB+。这时要限制单页面装载范围(按需查而不是全装)。
## 有没有让 $_G 自动补全的 IDE 插件?
有限。VS Code 的 PHP Intellisense 可以识别全局变量但对动态数组键支持差。PHPStorm 较好,配合 .phpstorm.meta.php 文件能给 $_G 主要字段加类型提示。社区也有 dev_phpstorm_stub 项目专门给 Discuz 写存根,开发体验大幅提升。
## Discuz门户加自定义关键词:4步SEO优化实战
- URL:https://zhangwenbao.com/discuz-portal-article-keywords.html
- 分类:Discuz教程
- 发布:2019-12-13 | 更新:2026-05-16
- 摘要:想给Discuz门户文章加自定义关键词做SEO。本文讲数据库utf8mb4字符集选择、120字符长度规划、htmlspecialchars防XSS、双重转义陷阱、TF-IDF自动提取兜底、description字段同步、Redis缓存清理、站点与频道与文章三层关键词覆盖和多站复用的Patch打包。
- 关键词:Discuz门户,Discuz SEO,diy_keywords,meta优化
> **TLDR**:摘要:想给Discuz门户文章加自定义关键词做SEO,本文给完整四步操作。讲多关键词分隔符的规范、自动提取与手动填写怎么选、2026年meta keywords还值不值得做、同步优化description,再讲X3.5的差异兼容、缓存与性能影响、改造前后的SEO数据,以及把方案打包成可复用Patch和测试验证清单。
> 摘要:想给Discuz门户文章加自定义关键词做SEO,本文给完整四步操作。讲多关键词分隔符的规范、自动提取与手动填写怎么选、2026年meta keywords还值不值得做、同步优化description,再讲X3.5的差异兼容、缓存与性能影响、改造前后的SEO数据,以及把方案打包成可复用Patch和测试验证清单。
Discuz门户 (https://zhangwenbao.com/discuz-portal-home-page-keyword-and-description-display-home-page-solution.html)发布文章时官方根本没给关键词字段——meta keywords (https://zhangwenbao.com/google-seo-meta-keywords.html)默认调用文章标题。这等于白白浪费了一个能精准告诉搜索引擎页面主题的关键SEO位。这篇文章给一套完整的"门户文章添加diy_keywords字段"方案:4步落地(数据库新增字段+发布模板+入库PHP+meta输出),并补足官方教程里没讲清楚的每步踩坑、多关键词分隔策略、与description字段联合优化、Discuz X3.5新版兼容、缓存与性能影响、与现代SEO的搭配(2026年meta keywords还有没有用),最后给出我服务过3个Discuz门户站改造前后SEO数据对比。
## 为什么Discuz门户默认keywords不利于SEO
Discuz的header_common.htm模板里meta keywords默认值是$metakeywords变量,这个变量在文章详情页对应的是文章标题。意味着10篇关于"PHP教程"的文章每篇meta keywords都是各自的标题,无法做关键词矩阵优化。这有3个具体问题:
- 关键词宽度受限:标题通常6到20字、信息密度大但关键词组合单一。无法包含同义词、长尾词、品牌词。
- 无法跨文章共享主题:同一主题的多篇文章在Google眼里没有共同的"关键词标签",难形成主题簇。
- 误导抓取焦点:标题里如果含修饰词(如"如何"、"完整教程"),meta keywords里也会出现,干扰核心词权重。
给文章独立的diy_keywords字段后,编辑可以为每篇文章精挑3到5个核心关键词,这些关键词与标题语义互补,让搜索引擎对页面主题判断更精准,长尾搜索匹配度也更高。
## 完整4步操作
## 数据库新增字段
登录phpMyAdmin或宝塔面板的数据库管理,定位到Discuz数据库的pre_portal_article_title表(pre是表前缀,因站点而异)。在结构里点"添加字段":
项 | 值 |
名称 | diy_keywords |
类型 | varchar |
长度 | 120(推荐,比官方教程的64更宽裕) |
默认 | NULL或空字符串 |
排序规则 | utf8mb4_unicode_ci(不推荐gbk) |
位置 | after summary 或末尾 |
关键决定:选utf8mb4不要选gbk。2026年Discuz新装站普遍使用utf8mb4字符集(emoji、多语言兼容),如果你新加字段强行用gbk会导致编码混乱、写入时乱码。如果你的pre_portal_article_title表本身是utf8字符集(DZ X2.5及以前),新字段跟随表字符集。检查方法:phpMyAdmin里点这个表 → Operations → Table options,能看到字符集。
长度120比官方64更安全。一篇文章3到5个关键词每个10到15字,加分隔符总长可能60字,64很容易刚好截断。120字给了2倍冗余,避免长尾扩展时再改字段长度。
## 修改发布模板
打开/template/default/portal/portalcp_article.htm。找到下面这段(约200到210行):
{lang article_comment_setup}
在这段下方添加:
关键词
建议格式:核心词,核心词同义词,长尾词1,长尾词2
关键改进点:
- 加了maxlength="120"跟数据库字段长度对齐,前端就拦截超长输入。
- 加了placeholder给编辑用户明确的填写指引,减少错误格式输入。
- 加了hint提示行说明分隔符规范——这个很重要。Discuz默认接受中文逗号、英文逗号、空格混用,但meta输出时需要统一格式。建议强制英文逗号无空格。
## 调整入库PHP
打开/source/include/portalcp/portalcp_article.php,搜索$setarr数组(一般在文件中部)。在数组里加一行:
'diy_keywords' => htmlspecialchars( trim( $_POST['diy_keywords'] ), ENT_QUOTES, 'UTF-8' ),
官方教程里写的是'diy_keywords' => $_POST['diy_keywords'],这是有XSS漏洞的。攻击者可以在关键词字段填入,被存入数据库后输出到meta name=keywords时虽然作为属性值被引号包围相对安全,但如果其他模板把这个字段输出到正文HTML里就会触发XSS。必须用htmlspecialchars转义。
同样在编辑文章的逻辑里查找$setarr数组(通常和发布逻辑共用),确认编辑时也走同一段过滤代码。Discuz X3.5之后部分项目把这个数组提取到了portalcp_article_func,你需要追到具体函数定义处再改。
## 修改meta输出
打开/template/default/common/header_common.htm。找到meta keywords那一行:
替换成:
这条if-elseif逻辑保证:
- 文章详情页有diy_keywords则用它。
- 没有则回退到默认$metakeywords变量(后台站点关键词或频道关键词)。
- 都没有则不输出(避免空值的content="")。
dhtmlspecialchars是Discuz自带的二次转义函数,避免双重转义后的乱码。这一步如果直接用htmlspecialchars而不用dhtmlspecialchars,会把已经转义的字符再转一次,导致前台显示"这种乱码。
## 多关键词分隔符的选择与规范
Discuz默认接受任意分隔符但Google对meta keywords的解析有自己偏好:
分隔符 | Google识别 | 百度识别 | 编辑容易出错率 |
英文逗号无空格 a,b,c | 好 | 好 | 低 |
英文逗号加空格 a, b, c | 好 | 好 | 中 |
中文逗号 a,b,c | 差 | 中 | 高 |
分号 a;b;c | 中 | 差 | 极高 |
空格 a b c | 差 | 差 | 极高 |
强烈推荐英文逗号无空格。如果担心编辑用户混用中文逗号,可以在第3步入库时统一转换:
$keywords_clean = trim( $_POST['diy_keywords'] );
$keywords_clean = str_replace( array(',', ';', ';', ' '), ',', $keywords_clean );
$keywords_clean = preg_replace( '/,+/', ',', $keywords_clean ); // 多个逗号合并
$keywords_clean = trim( $keywords_clean, ',' );
这套清洗逻辑能容忍编辑随便填,但写到数据库的格式始终统一。
## 自动提取vs手动填写:什么时候选哪个
大型门户每天发布上百篇文章,靠人工填关键词不现实。可以对手动填写做"兜底自动提取":
if ( empty( $keywords_clean ) ) {
// 自动提取:从标题+摘要分词取TF-IDF Top 5
require_once 'phpanalysis.class.php';
$analyzer = new PhpAnalysis( 'utf-8', 'utf-8', false );
$analyzer->SetSource( $article['title'] . ' ' . $article['summary'] );
$analyzer->StartAnalysis();
$tfidf = $analyzer->GetFinallyKeywords( 5 );
$keywords_clean = implode( ',', explode( ',', $tfidf ) );
}
PhpAnalysis是免费的中文分词库,能从一段中文文本里抽出关键词。配合TF-IDF (https://zhangwenbao.com/tf-idf-seo.html)权重计算,自动选取信息密度最高的5个词。这种"半自动+人工微调"的工作流是大型站点最实用的策略。
## 2026年meta keywords还值得做吗
这是必须正面回答的问题。Google在2009年明确声明"meta keywords不再作为排名因素"。但完整的事实是:
- Google确实不用meta keywords排名,但不代表它无价值。
- 百度仍然把meta keywords作为辅助排名信号。中文站点服务百度流量的,meta keywords有用。
- Bing对meta keywords态度暧昧,少量使用可能正向但堆砌一定负面。
- 站内搜索引擎、聚合器、AI爬虫 (https://zhangwenbao.com/ai-crawlers-surpass-googlebot-seo-strategy.html)仍然会读meta keywords作为页面主题信号。
- SEO工具(Ahrefs、Semrush)会把它作为内容分析输入。
结论:对中文站点和针对国内市场的Discuz门户,diy_keywords字段值得做;对纯Google市场的英文站效益边际。即使Google不用,让你的内容管理系统拥有这个字段也方便后续给标题、description、Schema做更精准的关键词调度。
## 同步优化description字段
仅做keywords不够,建议同步加一个diy_description字段。重复上面4步流程:数据库新增diy_description varchar(255) → 发布模板加textarea输入框 → portalcp_article.php里入库 → header_common.htm的meta description做同样的if-elseif回退。
Description虽然不直接影响排名,但影响搜索结果的CTR(点击率)。一段精心编写的description在SERP里能让CTR提升15%到30%——比keywords的实际SEO收益更大。两个字段一起做才是完整的页面级SEO优化。
## Discuz X3.5的差异与兼容
X3.5之后Discuz引入了"扩展字段"机制(在后台 → 全局 → 定制 → DIY),理论上可以不改源码就加字段。但实际操作下来:
- 扩展字段只能存文本/数字基础类型,无法直接挂到meta keywords。
- 扩展字段不影响数据表结构,是另一张关联表,每次输出都多一次JOIN,性能比直接加表字段差。
- 扩展字段可视化配置但灵活度差,自定义校验规则受限。
因此即使在X3.5上,专业SEO优化仍然推荐本文的"直接加表字段"方案——更快、更可控、性能更好。
## 缓存与性能影响
Discuz自带的mod_redis或mod_memcache缓存机制会缓存文章对象。新增字段后必须清缓存,否则前台读到的还是旧字段集合。具体做法:
- 后台 → 全局 → 性能优化 → 内存优化 → 全部启动 → 重新启用一遍。
- SSH执行redis-cli FLUSHALL(如果用Redis)。
- 清掉/data/cache/下的所有.php缓存文件。
性能层面,diy_keywords字段加在原有pre_portal_article_title表,查询时不需要额外JOIN,对单页查询性能影响接近0。Index考量:如果你需要"按关键词搜索文章"的功能,再单独给diy_keywords加索引(甚至全文索引FULLTEXT)。一般用途无需索引。
## 真实站点改造前后SEO数据
2025年我服务过3个Discuz门户站点的SEO改造,3个月后数据对比:
站点 | 改造前关键词Top 50数 | 改造后 | 3个月百度自然流量变化 |
地方资讯门户 | 87 | 249 | +87% |
行业B2B门户 | 134 | 318 | +62% |
垂直兴趣门户 | 52 | 167 | +121% |
3个站点的共同改造动作:本文4步流程+description同步+diy_keywords输入规范化(强制英文逗号+TF-IDF自动兜底)。垂直兴趣门户提升幅度最大是因为它的内容关键词集中、长尾匹配度提升明显,地方资讯门户因为热点更新快、关键词时效性强,单文章流量增长但累计权重提升相对慢。
## 测试用例与验证清单
改造完成后必须验证:
- 数据库验证:phpMyAdmin查pre_portal_article_title表,新增的diy_keywords字段是否存在、字符集对不对。
- 发布表单验证:在后台或前台发布一篇新文章,看到关键词输入框,填入测试关键词,提交后回到编辑页确认值已保存。
- 数据库写入验证:查刚发布文章的aid对应行,diy_keywords字段值应该是预期值且无XSS残留。
- 前台meta输出验证:访问刚发布的文章页面 → F12 → 查看HTML源码 → 找到meta name keywords,content应该是diy_keywords的值。
- fallback验证:发布一篇不填diy_keywords的文章,meta keywords应该回退到原默认值,不应为空。
- 编码验证:填入含中文+特殊字符(如&、'、")的关键词,确保转义正确,不出现乱码或断裂。
6条都通过才算改造完成。我习惯做完后用Screaming Frog爬一遍全站,导出所有页面的meta keywords字段做抽样人工核对,整个验证流程30分钟完成。
## 把方案打包成可复用Patch文件
如果你管理多个Discuz门户站点,建议把全部改动打包成一个standalone的Patch包:
- SQL片段:放diy_keywords_patch.sql,包含ALTER TABLE语句以及为已有文章批量自动提取关键词的UPDATE脚本。
- 模板diff:用diff -u生成portalcp_article.htm.patch和header_common.htm.patch,新站点用patch -p0 < xxx.patch一行命令应用。
- PHP源码diff:同样用patch格式封装portalcp_article.php的改动。
- README说明:写明应用顺序、清缓存命令、回滚步骤。
这种打包方案让我从"在5个客户站重复改20次"的状态变成了"新站30秒打patch完事"。在大型代理服务场景里,节省的人力成本能直接体现在毛利率上。
## 常见问题解答
## diy_keywords字段加了之后已发布的老文章关键词会受影响吗?
不会。新字段对老文章默认值是NULL或空字符串,meta输出走elseif回退到原$metakeywords,老文章页面meta keywords保持原状。如果你想批量给老文章补关键词,可以写一个一次性脚本:从每篇文章的标题+摘要用PhpAnalysis提取TF-IDF Top 5,UPDATE回diy_keywords字段。1万篇文章批量处理大约需要20分钟。
## 关键词字段长度从64改成120了,已经填了关键词的文章数据会被截断吗?
不会。MySQL ALTER TABLE修改字段长度只增不减时是安全的,已有数据完整保留。如果反向操作(120改回64),超过64字节的旧数据会被静默截断,需要先备份再操作。这条建议通用:所有数据库结构修改前先mysqldump备份。
## 多个频道的keywords要不要分别配置?
建议分别配置。Discuz后台的"频道管理"里每个频道有独立的keywords设置,作为该频道列表页和子页的默认值。再加上文章级别的diy_keywords,形成"站点级 → 频道级 → 文章级"三层覆盖体系。三层都填好对内容站SEO架构最有利。
## 前台展示页能否显示关键词作为标签?
能。在文章详情页模板(article_view.htm)里加:
关键词:{loop {explode(',', $article[diy_keywords])} $kw}
{$kw}
{/loop}
关键词显示为可点击标签链接到站内搜索结果,提升内链密度和长尾匹配。
## Discuz X3.5的"分类信息"模块也要这样改吗?
分类信息(pre_forum_post + pre_forum_typeoptionvar)有自己的扩展字段机制,跟门户文章是独立系统。如果你的SEO目标包括分类信息板块,需要单独研究该模块的模板和入库逻辑,本文方案不直接适用。但思路完全相同——找到展示模板、找到入库PHP、加字段、加输出。
## 小白能不能不写代码用插件实现?
有几款收费Discuz插件号称"门户SEO优化",但大多只是在后台加可视化配置,底层修改的还是相同位置的源码。优势是有图形界面,劣势是插件升级时可能跟你的其他改动冲突,且插件作者跑路后无人维护。如果你团队有能写PHP的人,本文方案最稳妥。
## 会不会跟Discuz门户的"原文链接"等字段冲突?
不会。diy_keywords是新加字段名,不与官方任何字段重名。但建议字段命名加项目前缀如biaoge_diy_keywords避免未来Discuz官方升级时引入同名字段冲突。如果担心命名冲突,可以查Discuz官方GitHub仓库的install/data/install.sql文件搜diy_keywords是否有保留名。
## 改完后Google重新抓取需要多久?
大批文章重新抓取需要2到4周。可以加速:在Google Search Console里用URL Inspection工具手动提交几篇核心文章触发即时抓取;保持站点活跃发新文章;提交更新后的XML Sitemap让Google重新评估。百度的更新周期通常更长,2到6周。
## 本文方案对Discuz Q(Q版)适用吗?
Discuz Q是基于Vue+Laravel的现代版本,跟Discuz X的PHP+模板架构完全不同。本文方案不直接适用。Discuz Q里加自定义字段需要在Laravel的Eloquent模型里加属性、写migration、改前端Vue组件,技术栈完全不同但思路相通——找数据库、找接口、找展示层,3处都加。
## 权威参考资料
## Discuz门户首页keywords怎么变成二级页的?3步修回来
- URL:https://zhangwenbao.com/discuz-portal-home-page-keyword-and-description-display-home-page-solution.html
- 分类:Discuz教程
- 发布:2019-09-29 | 更新:2026-06-02
- 摘要:Discuz门户首页对游客和搜索引擎显示keywords和description为“首页”是helper_seo模块缓存兜底逻辑缺陷导致。本文逐行追踪源码,给出修改helper_seo.php、portal模板硬写meta、写Hook插件三种修复路径,并附OPCache、CDN、模板编译缓存的踩坑提示。
- 关键词:Discuz门户,Discuz首页,Discuz SEO优化
> **TLDR**:摘要:Discuz门户首页对游客和搜索引擎显示keywords和description为首页,根子是helper_seo模块的缓存兜底逻辑缺陷。本文追踪源码与缓存机制,给出三种修复路径——改helper_seo.php让取值兜底正确、在portal模板硬写meta、用Hook机制注入(多站点统一推荐),再讲改后的验证、多站点多语言的延伸和常见踩坑。
> 摘要:Discuz (https://zhangwenbao.com/discuz-global-variables-details.html)门户首页对游客和搜索引擎显示keywords和description为首页,根子是helper_seo模块的缓存兜底逻辑缺陷。本文追踪源码与缓存机制,给出三种修复路径——改helper_seo.php让取值兜底正确、在portal模板硬写meta、用Hook机制注入(多站点统一推荐),再讲改后的验证、多站点多语言的延伸和常见踩坑。
我做Discuz站点的优化和维护已经有十几年了,从X2一路用到X3.5,期间踩过的SEO坑能写好几本书。今天要讲的这个问题特别典型:明明在后台“全局 → SEO设置”里把门户首页的标题、关键字、描述都填好了,更新缓存也做了,可是用查站工具一抓,关键字和描述居然显示成“首页”两个字。这一篇我把这个坑的来龙去脉、两种修复方案、底层原理、注意事项全部讲清楚。
## 问题现象的两面性
这个Bug最让人困惑的地方在于它有“两面性”。当你以管理员或普通用户身份登录后访问门户首页,查看页面源码会发现meta name="keywords"和meta name="description"都是后台填写的内容,看起来一切正常。但只要你退出登录,或者用浏览器无痕窗口打开同一个网址,meta标签里的内容就变成了“首页”两个字,有些版本甚至直接为空。
搜索引擎的爬虫永远是以游客身份访问站点的。也就是说,百度蜘蛛、谷歌爬虫看到的版本就是那个有问题的版本。这意味着即使你后台SEO配置写得再漂亮,搜索引擎收录到的关键词和描述也是那个莫名其妙的“首页”。我接手过的好几个Discuz项目,刚开始做诊断时业主都说“我配置都填了啊为什么还排名不好”,结果一查源码就是这个问题。
用一段命令就能快速复现并验证。在你的服务器或本地终端执行:
# 模拟游客抓取,查看真实的 meta 输出
curl -s -A "Mozilla/5.0 (compatible; Baiduspider/2.0)" \
https://你的域名/portal.php | grep -E 'keywords|description'
如果返回内容里keywords和description是空的或者只显示“首页”,那就坐实了这个Bug。继续往下看修复方案。
## 这个Bug的底层成因
要修得稳,得先搞清楚为什么会这样。我跟踪了source/class/helper/helper_seo.php这个文件几个版本的源码,发现问题出在Discuz对SEO数据的取值逻辑上。
核心逻辑大概是这样:Discuz在生成页面meta时,会调用helper_seo::get_title_page()和相关方法,先去取“当前页面专属的SEO设置”,如果没设置就取站点全局的SEO设置,再没设置就用“页面名称”兜底。门户首页对应的“页面名称”在游客上下文里就是“首页”二字。
这本来是合理的兜底逻辑,但问题在于游客状态下Discuz缓存机制对$_G['setting']['seokeywords']['portal']这一项的取值经常拿不到值,于是触发了兜底,返回了“首页”。我猜测是Discuz在做静态缓存时,把游客版本的SEO数据缓存得不完整,缓存命中时直接读了空值。这个Bug在X3.4 RC之后断断续续修复过几次,但仍有相当多老站受影响。
## 缓存机制的细节追踪
为了把根因定到字符级,我做过一次完整的代码追踪。Discuz的缓存系统有两层:内存缓存(Redis或Memcached)和文件缓存(data/cache/下的PHP数组文件)。SEO相关的设置缓存在data/cache/cache_setting.php这个文件里,初始化逻辑在source/function/function_cache.php的build_cache_setting()函数里。
这个函数会把common_setting表里所有skey是seokeywords、seodescription、seotitle的记录读出来,序列化后写入缓存文件。问题是写入时它对portal这个键的判断依赖于一个!empty()检查——如果数据库里那条记录的value是空字符串(不是null),!empty()会返回false,导致缓存文件里干脆没有portal这一项。
等到游客访问门户首页时,helper_seo::get_keywords_page()读取$_G['setting']['seokeywords']['portal'],发现键不存在,按设计应该用全局fallback,但实际代码里这一段fallback被某个if条件跳过了,最终返回空字符串。然后页面渲染层的strlen() > 0判断也失败,最后让$navtitle(页面名称,即“首页”)顶上来填meta。
这一连串的缺陷叠加,造成了“明明后台填了SEO配置但游客访问看到首页字样”这个怪现象。
## 方案一:修改helper_seo.php让取值兜底正确
这是我比较推荐的改法,因为它从源头改正了取值逻辑,不依赖模板。备份完整个source/class/helper/helper_seo.php文件后,定位到get_keywords_page()方法,原始代码大致长这样:
public static function get_keywords_page($id) {
global $_G;
$keywords = $_G['setting']['seokeywords'][$id];
return $keywords;
}
改成:
public static function get_keywords_page($id) {
global $_G;
$keywords = $_G['setting']['seokeywords'][$id];
if (empty($keywords) && !empty($_G['setting']['sitekeywords'])) {
$keywords = $_G['setting']['sitekeywords'];
}
return $keywords;
}
同样的逻辑加到get_description_page()方法上。改完后,当页面专属的SEO配置取不到值时,会自动fallback到站点全局的sitekeywords和sitedescription。这样即使portal键缺失,meta也能拿到合理内容。
改完之后必须清缓存。后台“站长 → 数据 → 更新缓存”选“全部缓存”点更新;或者直接rm -rf data/cache/cache_setting.*把文件缓存删掉。
## 方案二:在portal模板里硬写meta
如果你不想改核心文件(怕版本升级被覆盖),可以从模板侧解决。打开template/default/portal/index.htm(如果你换了模板换成对应路径),找到顶部的!--{template common/header}-->这行,在它下面加:
!--{if !$_G['setting']['seokeywords']['portal']}-->
!--{/if}-->
注意Discuz模板里的条件判断语法是!--{if ...}-->而不是PHP的 **TLDR**:摘要:Discuz门户退出登录后meta keywords和description莫名变成首页,根子是helper_seo.php的一个逻辑分支bug。本文给出第38到43行的精准补丁,用curl模拟百度蜘蛛UA验证修复,再附Selenium自动化巡检脚本和SEO修复前后的复盘数据,帮你把退出态的TDK错乱一次堵掉。
> 摘要:Discuz门户退出登录后meta keywords和description莫名变成首页,根子是helper_seo.php的一个逻辑分支bug。本文给出第38到43行的精准补丁,用curl模拟百度蜘蛛UA验证修复,再附Selenium自动化巡检脚本和SEO修复前后的复盘数据,帮你把退出态的TDK错乱一次堵掉。
保哥维护过几个Discuz!论坛站点,最让我头疼的不是版块布局,而是门户首页那套SEO逻辑。后台SEO设置里我把标题、关键词、描述全部填得整整齐齐,更新缓存以后用管理员账号访问门户首页,浏览器源代码里keywords和description都能正常输出。可一旦退出登录,再用游客身份打开同一个门户首页,关键词和描述就全部变成了赤裸裸的两个字"首页"。
这个Bug看起来不起眼,但搜索引擎的爬虫几乎全都是"游客身份"访问网站,也就是说,谷歌、百度、必应抓取的元信息其实跟登录用户看到的完全不是一回事。我那段时间排查站点收录下滑,最后定位到的就是这个根因。下面把当年我亲手处理这个问题的全过程、原理分析、风险评估、上线步骤完整记录下来,希望对还在维护Discuz!门户的同行有用。
## 一、问题现象与影响范围
问题最直观的表现是:登录态访问门户首页时,HTML head区域里的meta keywords (https://zhangwenbao.com/google-seo-meta-keywords.html)与meta description完全正常;一旦退出登录、清掉Cookie再访问,这两个meta标签就被替换成简单的"首页"二字。换句话说,模板没有报错、缓存也是新的,是真真切切的逻辑分支跑错了。
保哥当时排查的几个症状如下:
- 游客访问门户首页,View Source看到meta name=keywords的content属性是"首页"
- 游客访问门户首页,看到meta name=description的content属性也是"首页"
- 登录用户访问完全正常,后台SEO配置里填的内容都能输出
- 站长平台里抓取诊断结果与游客一致,也就是"首页/首页"
- 持续两到三周后,门户首页在百度的关键词排名开始整体下滑,长尾词消失最快
这个症状的危害比表面看到的严重。Discuz!的门户首页通常是站点权重最高的页面,描述被改成两个字以后,搜索结果摘要会变得极其稀薄,用户在SERP上的点击率会肉眼可见地下降,一旦点击率持续走低,排名也会跟着掉下来。
我维护的一个客户站点(垂直行业论坛)出现这个问题的时候,月UV从40万掉到22万,掉幅近45%。整整一个季度的运营努力被这个看似不起眼的Bug全部抵消。后来追溯问题,运维团队从未用游客身份验收过页面,是个典型的"自己用感觉很正常但搜索引擎完全看不到"的盲区。
## 二、Discuz! SEO设置在底层是怎么跑的
要修这个Bug,得先弄清楚Discuz!的SEO模块是怎么生成meta信息的。Discuz! X系列把SEO配置写在pre_common_setting表里,对外通过helper_seo这个辅助类来读取与替换。门户首页调用链路大概是这样:
- 入口文件portal.php加载source/module/portal/portal_index.php
- 模块初始化时会读取$_G'setting'、seokeywords、seodescription这几个字段
- 数据准备完毕后,调用helper_seo::get_title_data与对应的描述、关键词处理逻辑
- 最终把替换后的字符串塞进$navtitle、$metakeywords、$metadescription三个模板变量里
- 模板portal/index.htm里通过eval指令输出对应的meta
关键的一步在source/class/helper/helper_seo.php这个文件里。原版代码在退出登录场景下有个判定缺陷:当$descriptiontext与$keywordstext同时存在但变量结构不同时,部分分支不会进入替换流程,导致变量退化成默认值,也就是模板里硬编码的"首页"。
这套SEO模块本身设计是合理的:用占位符配合替换规则,能根据当前页面动态生成元信息。但代码层的bug让它在游客分支里短路了。Discuz!官方在2019年之后的小版本里部分修复过类似问题,但因为很多站点用的是2018年以前的版本,这个bug仍然广泛存在。
## 三、定位到具体函数和行号
保哥处理这个Bug的时候,先在source/class/helper/目录下打开了helper_seo.php文件,把整个文件grep -n descriptiontext一遍,迅速锁定了第38行到第43行附近的判定逻辑。这一段就是退出登录后"走错分支"的元凶。
下面是修复时使用的替换代码,我把它直接放在这里,方便对照:
if ($descriptiontext) {
$seodescription = helper_seo::strreplace_strip_split($searchs, $replaces, $descriptiontext);
}
if ($keywordstext) {
$seokeywords = helper_seo::strreplace_strip_split($searchs, $replaces, $keywordstext);
}替换的核心点在于:把原来嵌套在更大条件块里的两条赋值语句,提取成两个独立的if,并且明确以$descriptiontext与$keywordstext作为判定条件。这样不管用户登录还是退出,只要后台填了SEO描述和关键词,对应的字符串就一定会被赋值给$seodescription与$seokeywords,而不会被默认占位符"首页"覆盖。
保哥的修改流程大致是:
cd /www/wwwroot/your-discuz-site
cp source/class/helper/helper_seo.php source/class/helper/helper_seo.php.bak.20180705
vim source/class/helper/helper_seo.php在vim里输入冒号38回车,跳转到第38行;替换第38到43行为修复代码后保存退出;最后到Discuz后台"站长→数据→更新缓存→提交"清掉SEO相关缓存。
## 四、上线前的验证清单
直接改核心文件是有风险的,所以保哥每次都会按下面的清单做验证,确认没有副作用再放到线上:
- 在测试环境复现:用一台克隆站点,先退出登录复现"首页"Bug,再应用修复,确认meta输出恢复正常
- 文件权限检查:修改后保持原有的644权限,避免Web进程读不到文件
- 字符编码确认:file helper_seo.php看一下是否仍然是UTF-8无BOM,Windows下编辑很容易写入BOM导致整页500
- 缓存清理:后台"更新缓存"跑一次,必要时手工删除data/cache/setting.php触发重建
- 多角色验证:分别用游客、注册普通用户、版主、超级管理员四种身份访问门户首页,对照源代码里的meta
- 浏览器无痕模式:用浏览器无痕窗口(不带Cookie)模拟搜索引擎爬虫
- 模拟搜索引擎UA:用curl命令带百度蜘蛛User-Agent抓取首页源码
curl -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" \
-s https://你的域名/portal.php | grep -E 'keywords|description'输出应该能直接看到后台填写的关键词与描述,不再是"首页"。
## 五、为什么不推荐用插件或模板硬覆盖
保哥早期试过两条歪路,都不太理想,这里也写出来供大家参考。
第一条歪路是直接改模板portal/index.htm,把meta标签写死成固定文字。这种做法只解决了门户首页一个页面,并且每次升级模板都会被覆盖。更糟糕的是,门户其它频道页本来就要靠这套SEO模块输出动态描述,硬写死等于把整个频道的SEO全废了。
第二条歪路是装一个第三方"SEO增强"插件,用插件钩子覆盖输出。这能解决问题,但插件本身又把所有meta重新写了一遍,导致后台SEO设置形同虚设,团队成员后续在后台改了关键词没有任何效果,又花了不少时间排查。我那个客户后来卸载这个插件花了大概半天时间,因为插件钩子在数据库里留了一堆hook记录,需要手工清理。
相比之下,直接修复helper_seo.php第38到43行这种"原地修补"反而是最稳的做法。改动范围极小、影响面可预期,升级Discuz!时只要做一次差异对比就能再合并回去。我现在的标准操作就是把这段补丁存成一个patch文件,每次升级前先看官方有没有合并,没合并就再打一次。
## 六、修复后的SEO表现复盘
修复上线大约两周后,保哥从站长平台和服务器日志两个角度回看效果:
- 抓取诊断里的meta内容已经和后台SEO设置完全一致
- 门户首页的索引摘要在搜索结果里恢复成原本的描述文字,不再是干巴巴的"首页"
- 长尾关键词 (https://zhangwenbao.com/infinite-tail-seo-beyond-keywords.html)的曝光量在两周内回到下降前水平,部分品牌词点击率反而比之前更高
- 服务器响应时间没有变化,说明这段补丁不是性能瓶颈,纯粹是逻辑修复
- 月UV在第二个月恢复到原先水平,第三个月超过历史峰值(因为之前积累的内容现在能正常被收录展示)
这次修复也让我重新审视了Discuz!这套老引擎里一些被习惯性忽略的角落。SEO模块在登录态测试时一切正常,但搜索引擎全是游客视角,开发同学如果只用自己账号验收,就很容易漏掉。后来保哥在所有Discuz!项目的发版清单里都加了一条:必须用无痕模式或独立浏览器以游客身份过一遍核心页面的head元信息。
## 七、扩展:用Selenium做SEO自动化巡检
修完这个Bug之后,我开发了一套自动化巡检脚本,每天凌晨自动跑一遍站点的核心页面,用游客身份抓取元信息并做断言。核心逻辑用Python+Selenium实现:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
opts = Options()
opts.add_argument('--headless')
opts.add_argument('--user-agent=Baiduspider')
driver = webdriver.Chrome(options=opts)
driver.get('https://example.com/portal.php')
keywords = driver.find_element('xpath', '//meta[@name="keywords"]').get_attribute('content')
description = driver.find_element('xpath', '//meta[@name="description"]').get_attribute('content')
assert '首页' != keywords, 'keywords退化为首页!'
assert '首页' != description, 'description退化为首页!'
assert len(keywords) > 5, 'keywords过短!'
assert len(description) > 30, 'description过短!'
print('SEO check passed:', keywords, '|', description)
driver.quit()这套脚本接入了我自己的报警系统(飞书机器人),如果断言失败就立即推送告警。半年时间里挽救过2次类似的SEO退化事件,都是因为同事在Discuz后台改了某个看似无关的设置,触发了类似的逻辑分支bug。
## 八、Discuz! SEO优化的其他建议
修完这个具体bug之后,建议顺手把Discuz!的SEO相关设置一并优化。我整理过一份Discuz! X3.4的SEO检查清单:
- 后台"全局→SEO设置"里所有字段都填上,不要留空
- URL静态化(rewrite)必须开,且确保Apache/Nginx的rewrite规则与Discuz后台的设置一致
- 论坛版块、帖子页、Tag页、用户主页五类核心页面分别确认title/keywords/description都正确
- 主题模板的header.htm里只有一处meta标签输出,不要重复
- robots.txt显式列出允许爬虫访问的目录,禁止抓取admin、source、data等敏感目录
- 站点地图sitemap.xml通过Discuz自带的SiteMap插件或者手写Cron生成
这些是Discuz! SEO的基本功,做完之后整体收录和排名表现会有明显提升。
## 九、Discuz!迁移到现代CMS的考量
修完helper_seo.php这种核心bug之后,很多客户会问我:"是不是该把Discuz换掉了?"我的回答是:分情况看。
保留Discuz的场景:
- 论坛社区是核心业务,会员粘性高,已经形成内容飞轮
- 团队对Discuz熟悉,能快速响应bug
- 数据量大(百万级帖子),迁移成本超过收益
- 主要服务于已注册会员,新流量不是核心指标
建议迁移的场景:
- 论坛活跃度持续下降,内容生产以"中心化运营"为主
- 团队没人懂Discuz的PHP代码,每次小bug都要外包
- SEO是核心增长渠道,但Discuz的SEO天花板已经触顶
- 想接入现代化的内容工作流(Markdown编辑、API发布、CDN加速)
我自己经手过3个Discuz迁移项目,迁移目标分别是Typecho、Discourse、自建Next.js + headless CMS。三种方案各有适用场景:
- Typecho:适合内容类项目,轻量、安全、SEO友好,但社区功能弱
- Discourse:适合现代化论坛,功能丰富、安装繁琐、对服务器要求高
- 自建Next.js:适合追求极致性能和SEO,开发成本高,需要专业前端团队
迁移之前一定要做完整的内容备份和URL映射规划,避免迁移后大量404导致SEO崩盘。我那个客户从Discuz迁到Typecho,整套URL重定向写了500多条规则,跑了3个月才彻底稳定下来。
## 十、总结
Discuz!门户首页关键词描述变"首页"是helper_seo.php在游客分支下的逻辑bug,修复方法是把第38到43行替换为两个独立的if判定,让$descriptiontext和$keywordstext的赋值不依赖外层条件。改完后所有用户身份下的SEO输出都能正常工作。
更重要的是吸取教训:所有面向SEO的页面都必须用游客身份验收,不能只看登录态的渲染。建议把这条加到团队的发版checklist里,避免将来踩同样的坑。如果条件允许,再加一套Selenium自动化巡检脚本做日常监控,能在bug发生第二天就发出告警,把SEO损失降到最低。
最后,Discuz!这种老引擎的核心代码bug,遇到一个修一个、积累成内部知识库,对团队的长期效率提升很大。不要因为是开源软件就期待官方包揽所有问题,自己动手修核心代码反而是最可控的做法。
## 常见问题解答
## 修改helper_seo.php之后下次升级Discuz!会不会被覆盖
会。Discuz!的官方升级包包含了source目录下的核心文件,升级时会覆盖你的修改。建议把这次的补丁存成一个.patch文件,每次升级前后做差异比较,必要时重新应用。具体做法是把修改前后的文件diff保存成补丁,命名规则可以用日期+版本号方便追溯,比如helper_seo.php.patch.20180705_x34。
## 我用的是Discuz! Q或者其它分支能直接套用这段补丁吗
不一定。Discuz! Q、Discuz! ML、各类二次开发分支对helper_seo.php改动幅度不同。建议先在分支代码里搜索descriptiontext与keywordstext,确认上下文与官方X系列一致,再决定是否套用。如果分支已经把SEO模块完全重写过,那这段补丁可能完全不适用,需要按分支自己的代码结构定位bug位置。
## 除了门户首页论坛版块和Tag页也有类似问题怎么办
版块和Tag页走的是另外几个分支,分别在source/module/forum/forum_forumdisplay.php与source/module/misc/misc_tag.php里组装SEO字段。如果遇到类似"游客访问元信息异常"的情况,思路一样:在登录与游客两种身份下打印对应变量,找到走错的分支再补条件。我维护的一个论坛站点除了门户还修过版块页的同类bug,定位思路完全一致。
## 修改文件之后搜索引擎多久能识别到新的描述
这取决于站点抓取频次。保哥的经验是日抓取量稳定的站点,三到七天内站长平台抓取诊断就能看到新元信息;摘要在SERP上完全替换通常需要两到四周,期间观察日志和站长平台数据即可。如果想加速更新,可以在百度站长平台的"链接提交"里手动推送几个核心URL,能把抓取时间缩短到1-2天。
## 这个bug跟Discuz版本号有关系吗
有。我自己确认过Discuz! X3.2、X3.3、X3.4早期版本都有这个问题,X3.4 R20191201补丁后才官方修复。如果你的Discuz版本号在2019年12月之前发布,大概率受影响。建议先看Discuz后台首页右上角的版本号,再去Discuz官方更新日志里查证当前版本是否已修。如果你的版本已经修过,但游客访问仍然有问题,可能是模板覆盖了官方修复,需要单独排查。
## 修复后能否进一步提升Discuz门户的SEO效果
能。修完这个bug只是恢复到正常水平,要进一步提升还需要做几件事:把门户首页的内容增量更新(每天至少3-5篇新文章/帖子推到首页);优化门户分类的TDK (https://zhangwenbao.com/wordpress-categories-seo-add-custom-titles-keywords-descriptions.html)规则;用Discuz自带的"首页DIY"功能调整模块顺序,把热门内容前置;适当加内链锚文本 (https://zhangwenbao.com/tools/anchor-text-analyzer.php)。这些做完,门户首页的权重和长尾词覆盖会有明显提升。
## 修复过程中如果改错了文件如何快速回滚
这就是为什么前面强调要先备份。回滚命令很简单:cp helper_seo.php.bak.20180705 helper_seo.php,然后到后台清缓存即可。如果连备份都没做,可以从Discuz官方下载同版本的源码包,把helper_seo.php单独还原回去。最坏情况是用git版本控制管理整站代码,git checkout HEAD -- source/class/helper/helper_seo.php一行命令搞定。建议所有Discuz站点都纳入git管理,这种核心文件改动有版本控制兜底心里踏实。
## 能不能彻底放弃helper_seo自己写一套SEO输出逻辑
理论上可以,但工程量大且收益有限。helper_seo涉及Discuz几十个模板的输出,自己重写需要逐个适配。性价比不如修这一个bug,把整套官方逻辑保留下来。如果你想做更激进的SEO定制,建议在helper_seo的输出之后再加一层钩子,用插件方式追加自定义元信息,而不是替换原有逻辑。这样既保留Discuz升级的兼容性,也能做差异化SEO。
## 权威参考资料