
一套基于 PHP 的门户系统版本 8.1放到今天这个前后端分离、Serverless 满天飞的环境里很多人第一反应是“这玩意儿还有必要碰吗”。我最初也是这个态度直到去年接了个活儿对方要在一个月内上线一个同时覆盖 PC 站、手机站、微信端的内容门户预算有限、人手就我一个半还要能持续加栏目、加功能。绕了一圈之后我重新捡起了这类“老派”的整站系统——它不新但它把内容模型、栏目树、模板渲染、多端适配这套东西已经磨了很多年你要做的就是理解它的运行逻辑然后在它给的框架里做二次开发。这篇文章想聊的就是这个完整过程PHP 门户系统 8.1 的搭建、结构拆解、模板改造、模块开发、性能与安全加固以及我实际踩过的那些坑。这不是一份照着手册念的安装说明手册里有的我不重复我要写的是手册里不写、但你在真实项目里一定会撞上的部分。适合谁看三类人一是手里有个内容站需求、想快速落地的独立开发者二是接手了别人做的老站、需要读懂结构再改的人三是刚学完 PHP 基础、想找一个真实项目练手的同学。不需要你精通框架但至少得能看懂 PHP 语法、会敲几行 Linux 命令、知道 MySQL 基本操作剩下的我在文里补。1. 先想明白这类 PHP 门户系统到底靠什么活着1.1 多端同源的设计逻辑才是它真正的价值点很多人把这类系统理解成“一个 CMS”这个理解偏了。普通 CMS 是“一套后台 → 一个前台模板”而这套东西的核心设计是“一套数据 → 多套端模板”。它把内容、栏目、模型这些数据结构抽出来PC 模板、手机模板、小程序端接口各走各的渲染层但底层共用同一张内容表、同一套栏目树。这个设计带来的直接好处是内容维护成本极低编辑在后台发一篇稿子PC 站、手机站自动同步出现不用做任何额外操作。你不需要写一套 API 给前端、再写一套 SSR 给搜索引擎也不用维护两套数据库。对于一个日均几百到几千篇更新量的内容站来说这个效率优势是压倒性的。代价也很明显模板层和业务逻辑耦合较紧。它用的不是现代框架那种“控制器返回 JSON、视图层随便换”的模式而是把 PHP 变量直接丢给模板引擎去渲染模板里可以写标签、循环、判断。这种模式在快速改版时特别爽但如果你要接一个 Vue 前端做前后端分离就会觉得别扭——虽然 8.x 版本已经提供了 RESTful 风格的数据接口层但它的接口粒度是按“栏目/内容列表”设计的不是按业务域设计的你要做复杂交互还是得自己加接口。我的判断标准很简单如果这个项目 70% 以上是内容展示 少量交互用它非常划算如果是个重交互的业务系统别硬套。这个判断在选型阶段就该做完别等写了三周模块才发现方向不对。1.2 为什么选它而不是从零搭一个框架从零用某个现代框架搭一个门户技术上完全可行我也干过。但真实成本不在写代码在于那些“没人愿意写但必须有的东西”栏目无限极分类、内容审核流、附件管理、会员体系、评论、搜索、SEO 的 URL 规则、静态页生成、后台权限的按钮级控制……这些东西你从零写保守估计两个月起步还得踩一堆边角坑。这类系统把这些都做完了而且做了很多年边角坑基本被人踩平了。你要付出的成本是读懂它的目录结构大概两三天、理解它的模板标签语法半天、熟悉它的模块开发约定一周左右。投入产出比在这个场景下是合适的。还有个不太被提起但很实际的因素后续接手的成本。你从零搭的东西交付之后客户想换个开发者维护新来的人得从头读你的代码而这类有公开文档、有固定目录结构的系统任何一个有 PHP 经验的开发者接手两三天就能上手改。对甲方来说这是隐性价值对你自己来说这也意味着你不用被一个项目绑死。1.3 8.1 这个版本号背后你真正该关心的是什么版本号本身不重要重要的是它代表的兼容性状态。8.x 这条线对 PHP 版本的支持比老版本宽同时保留了对老模板标签语法的兼容——这一点很关键意味着你从老版本迁移过来的模板大部分能直接跑不用全量重写。但兼容不等于无痛。我实测下来从更早的版本迁到 8.1必须重点检查三块检查项老版本常见写法8.1 下的处理建议数据库连接写在公共配置文件里硬编码迁到统一配置层检查是否有残留硬编码模板标签部分标签参数顺序不一致逐页回归重点测带参数的循环标签上传目录相对路径拼接改成基于根目录常量的绝对路径伪静态规则Apache 的 .htaccess 为主Nginx 环境下需重写规则不能直接搬最后一条是最容易翻车的。很多人本地用集成环境默认 Apache测得好好的传到服务器Nginx直接全站 404然后开始怀疑人生。原因就是伪静态规则没跟着换这个后面第 3 章我会给完整的 Nginx 配置。2. 环境准备别急着传压缩包这几步错了后面全是坑2.1 PHP 与 MySQL 的版本选择我给一个明确区间这类系统对版本比较敏感太老跑不动太新可能有兼容告警。我的建议是PHP7.4 是最稳的区间8.0/8.1 也能跑但需要确认你用的版本对 8.x 做了适配否则某些老扩展函数会报 deprecated 警告虽然不影响运行但日志会被刷满出真问题的时候你根本找不到。MySQL5.7 或 8.0 都行。用 8.0 的话注意默认字符集变成了utf8mb4、默认认证插件是caching_sha2_password老版本的连接库可能连不上需要用mysql_native_password或者升级连接方式。PHP 扩展这块必须开的有pdo_mysql、mbstring、gd图片处理、curl、fileinfo、zip、opcache。gd最容易漏漏了之后后台能进但一上传图片就报错而且报错信息很不直观我第一次遇到时排查了快一小时。php.ini里几个关键参数装完立刻改upload_max_filesize 64M post_max_size 64M max_execution_time 300 memory_limit 256M date.timezone Asia/Shanghaidate.timezone不设的话后台发布时间的显示会差 8 小时很多人以为是程序 bug其实是 PHP 默认用的 UTC。这个坑我见过太多次了。2.2 目录规划从第一天就按“能迁移”的思路来新手最容易犯的错是把整站扔在/www/wwwroot/下面随便起个名然后附件、缓存、备份全在同一个目录里。等到要迁移服务器或者做灰度的时候你就知道痛苦了。我习惯的规划是这样/data/wwwroot/portal/ # 站点根目录只放代码 /data/attachments/ # 附件目录单独挂载方便备份和 CDN 回源 /data/backup/ # 数据库与站点备份 /data/cache/ # 文件缓存目录独立出来便于清理附件目录独立出来有两个实际好处一是备份的时候可以分开做代码用 Git 管、附件用增量同步二是接 CDN 的时候回源路径可以直接指向附件目录不用动代码。权限方面Linux 下别图省事直接chmod -R 777这是最常见的错误操作等于给整站开了写权限。正确做法是目录归 web 用户代码文件只读chown -R www:www /data/wwwroot/portal find /data/wwwroot/portal -type d -exec chmod 755 {} \; find /data/wwwroot/portal -type f -exec chmod 644 {} \; chmod -R 775 /data/wwwroot/portal/data chmod -R 775 /data/attachments只给data运行时产生的缓存、配置和附件目录写权限其余全只读。这样即使有人上传了木马也没有执行和写入的权限能挡掉一大半低级攻击。2.3 数据库这块字符集和前缀是两个容易忽视的细节建库的时候显式指定字符集别依赖默认值CREATE DATABASE portal_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;utf8mb4而不是utf8区别在于前者支持完整的 Unicode包括 emoji 和部分生僻字后者在 MySQL 里其实是utf8mb3的别名只支持三字节。内容站早晚会遇到用户在评论里打 emoji到时候报Incorrect string value改表改到你手软不如一开始就建对。表前缀这个事安装时有个默认值我建议改成有辨识度的比如pt_而不是默认的cdb_之类。原因很直白如果同一台服务器上装了两套系统或者将来要做数据迁移默认前缀会导致表名冲突和误操作。改前缀带来的唯一代价是安装时多打几个字符收益是长期的。另外如果预算允许我强烈建议把数据库放到独立实例上哪怕只是同机不同端口。内容站的读多写少特征明显将来加缓存或者做读写分离独立实例的操作空间大得多。3. 安装与初始化从解压到能访问的完整路径3.1 上传解压与伪静态配置Nginx 用户重点看这里上传方式我不多说SFTP 或者命令行scp都行注意别用 FTP 的二进制/ASCII 模式搞错压缩包传上去解压报错就是这个原因。解压命令cd /data/wwwroot/portal unzip portal_8.1.zip # 如果压缩包内还有一层目录把内容上移 mv portal_8.1/* . mv portal_8.1/.[!.]* . 2/dev/null rmdir portal_8.1伪静态是重灾区。Apache 下.htaccess一般随包提供不用管Nginx 必须自己写。下面这份配置我用了很久涵盖常见场景server { listen 80; server_name example.com; root /data/wwwroot/portal; index index.php index.html; # 静态资源长缓存 location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|woff2|ttf)$ { expires 30d; add_header Cache-Control public, max-age2592000; } # 禁止访问敏感目录 location ~* ^/(data|system|config)/ { deny all; } # 伪静态把所有非文件请求转到入口 location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php7.4-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 300; } }关键在try_files那一行它的意思是“先找真实文件找不到再交给入口脚本”。写错成rewrite全量转发会导致 CSS、图片全走 PHP服务器负载直接飙升。我见过一个站每条请求都过 PHPtop一看 CPU 常年 90%就是这个问题。重载配置前先测试nginx -t nginx -s reloadnginx -t一定要养成习惯配置语法错直接 reload 会导致 Nginx 拒绝重载还好不会挂但如果你直接 restart 就有短暂中断。3.2 安装向导里那几个参数分别意味着什么浏览器打开域名进安装向导。几个需要你主动填的数据库地址同机填127.0.0.1而不是localhost走 TCP 比走 socket 更可控出问题好排查。管理员账号别用admin这是被爆破最多的用户名。密码至少 16 位混合字符别用生日拼音。后台路径很多版本允许自定义后台目录名一定要改默认路径等于告诉别人门在哪。安装锁定装完之后安装目录通常会有个install.lock之类的文件生成确认它存在否则别人能重跑安装覆盖你的数据——这是真实发生过的攻击手法。安装过程大概三步环境检测 → 数据库配置 → 管理员设置。环境检测那一步如果提示某扩展未开启别跳过回去开。跳过之后功能会在某些操作时莫名其妙失败排查成本远高于现在重启一下 PHP-FPM。3.3 装完立刻要做的五件事别等出事再补装完能访问不等于能上线。我固定会做这几件事改后台目录名 加访问白名单。Nginx 里对后台目录加allow/deny只放行自己的固定 IP 或办公网段。关掉调试模式。调试模式开着报错信息会把服务器路径、SQL 语句全打在前台等于把家底摊开给人看。设置定时任务。这类系统通常需要定时执行的任务生成静态页、清理缓存、备份数据库、发送队列邮件。用crontab配# 每天凌晨 3 点备份数据库 0 3 * * * /usr/bin/mysqldump -u portal_user -pXXX portal_db | gzip /data/backup/db_$(date \%Y\%m\%d).sql.gz # 每 10 分钟处理一次任务队列 */10 * * * * cd /data/wwwroot/portal /usr/bin/php cron.php queue /dev/null 21注意date里的%在 crontab 里必须转义成\%不转义会被当成命令分隔符任务直接不执行。这个坑我踩过一次备份停了半个月才发现。配 301 跳转。裸域名跳www或者反过来统一一个。不统一的话搜索引擎会当成两个站权重分散。首份完整备份。代码 数据库 附件三个都备。装完就是最干净的基线将来出问题可以回滚到这个状态。4. 目录结构与模板机制二次开发的入口都在哪4.1 核心目录职责速查先建立心理地图刚拿到源码的时候别急着改先花两小时把目录结构摸清楚。这套系统的目录逻辑大同小异我按通用结构给你一张表目录职责你能不能改index.php统一入口路由分发尽量不动system/或core/框架核心、类库、基础函数绝对不改改了没法升级app/或module/业务模块按功能分目录新增自己的模块放这里template/模板文件按端和主题分主要改造区域static/CSS、JS、图片等前端资源可以随便改data/缓存、配置、运行时文件只清不手改api/数据接口层需要新增接口时改这里plugin/或hook/插件与钩子不侵入式扩展的主战场理解这张表的核心是理解一条边界system/是黑盒template/和app/是你的地盘。所有改造尽量往后面两个区域靠这样将来版本升级你能少受罪。4.2 模板标签体系它其实是套简易 DSL模板文件里你会看到类似{loop $list $item}...{/loop}、{if $a 1}...{/if}、{$item[title]}这样的写法。别被吓到这就是一套编译成 PHP 的简易模板语言本质是把foreach、if这些语法做了个更短的别名。一个典型的列表块长这样{loop $article_list $item} div classitem a href{$item[url]} title{$item[title]} img src{$item[thumb]} alt{$item[title]} h3{$item[title]}/h3 p{$item[description]}/p span{$item[pubdate]}/span /a /div {/loop}三个要点一是变量名必须和数据查询返回的字段对上字段名错了不会报错只会静默输出空这个排查起来很磨人二是$item[xxx]的引号不能省省了在某些 PHP 版本下会报 notice三是循环标签不要嵌套太深超过三层就该考虑在控制器里预处理数据了模板里写复杂逻辑后期没人维护得动。{$item[url]}这种写法要注意如果字段里存的是相对路径而你又做了伪静态跳转可能出问题。稳妥做法是用系统提供的 URL 生成函数而不是直接拼字段。4.3 多端模板的复用策略这里能省掉大量重复劳动PC 模板和手机模板通常是两套目录但里面的块级结构高度相似。我的做法是抽出公共片段用引入标签复用!-- 公共头部 -- {inc header} !-- 公共底部 -- {inc footer}抽的时候注意粒度头部、底部、侧栏、面包屑这些稳定的抽列表项这种经常要按端调整布局的别硬抽分开写反而改起来快。我见过有人追求极致复用把一个列表项抽成三层嵌套的引入结果改一次样式要跳五个文件得不偿失。还有个实用技巧是用同一套 CSS 变量控制端差异。PC 和手机模板的类名保持一致只在样式层用媒体查询区分这样 HTML 结构可以大量复用改一处两端都生效。这个策略在改版频繁的阶段特别省事。5. 二次开发实战从改模板到写模块的完整闭环5.1 模块开发的最小闭环一张表 一个后台 一个前台大部分二次开发需求本质都是“加一个能录数据、能展示数据的功能”。我以最典型的“案例展示”模块为例走一遍完整流程。第一步建表。字段设计遵循两个原则必须有id主键、必须有排序和状态字段方便后台管理和前台筛选。CREATE TABLE pt_case ( id int(10) unsigned NOT NULL AUTO_INCREMENT, catid int(10) unsigned NOT NULL DEFAULT 0 COMMENT 分类ID, title varchar(200) NOT NULL DEFAULT COMMENT 标题, thumb varchar(255) NOT NULL DEFAULT COMMENT 缩略图, content mediumtext COMMENT 正文, client varchar(100) NOT NULL DEFAULT COMMENT 客户名, sort int(10) NOT NULL DEFAULT 0 COMMENT 排序, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, createtime int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_cat_status_sort (catid,status,sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意最后一个索引idx_cat_status_sort这个是多字段联合索引顺序不能乱。因为前台查询几乎都是“某分类下、状态启用、按排序倒序”这个模式索引顺序和查询条件匹配才能用上。索引顺序写反比如status, catid, sort在分类筛选时就用不上得全表扫。这个细节后面第 6 章还会展开。第二步后台录入界面。可以复用系统现有的表单组件主要注意三点分类下拉要读分类表、富文本编辑器要正确初始化、图片上传要接系统的上传接口别自己写一套权限和路径容易出问题。第三步前台展示页。新建一个模板文件用标签取数据{loop $case_list $item} div classcase-item img src{$item[thumb]} alt{$item[title]} h4{$item[title]}/h4 span classclient{$item[client]}/span /div {/loop}数据从哪来在对应的控制器或模块入口文件里查$catid intval($_GET[catid] ?? 0); $where status 1; if ($catid) { $where . AND catid {$catid}; } $sql SELECT id,title,thumb,client FROM pt_case WHERE {$where} ORDER BY sort DESC, id DESC LIMIT 20; $case_list $db-fetchAll($sql);这段代码有两个细节值得说。一是intval强转SQL 拼接场景下任何来自外部的变量都必须强转或参数化这条是红线没有例外。二是ORDER BY sort DESC, id DESC加了个第二排序字段因为sort有大量默认值 0只按它排的话顺序不稳定翻页会出现重复或遗漏加上id就稳了。5.2 钩子机制不侵入核心代码的正确姿势直接改核心文件是最省事也最危险的做法。省事是因为想改哪改哪危险是因为下次升级一覆盖你的改动全没了而且没人记得当初改了什么。正规做法是走钩子也叫事件/插件。这类系统一般在关键位置埋了钩子点比如“内容发布后”“页面渲染前”“用户登录后”。你把自己要执行的逻辑挂到钩子上代码放在独立目录里核心文件一行不动。一个典型的内容发布后钩子// plugin/after_publish.php function onAfterPublish($content) { // 推送到搜索索引 $es new SearchClient(); $es-index([ id $content[id], title $content[title], content strip_tags($content[content]), url $content[url], ]); // 清理相关缓存 Cache::delete(home_list); Cache::delete(cat_ . $content[catid]); return true; }注意里面那个缓存清理这是最容易被忘掉的一步。内容更新了但前台迟迟不显示十次有八次是缓存没清。写钩子的时候顺手把相关缓存 key 一起删掉能省掉大量“为什么改了不生效”的困惑。如果系统没提供你需要的钩子点怎么办两个选择一是在自己的模块里绕开用定时任务或队列实现二是提一个最小的核心改动但必须记录下来——我会在项目根目录维护一个PATCHES.md逐条记录改了什么文件、第几行、为什么改、升级时怎么恢复。这个习惯救过我一次升级到新版本时我照着这个文件花了半小时就完成了迁移而同事的项目重写了三天。5.3 一次真实改造给列表页加上按自定义字段筛选去年有个需求案例列表要能按“行业”筛选而“行业”是个后加的字段不在原来的分类体系里。常规做法是新建一堆分类但那样栏目树会爆炸编辑也容易选错。我的做法是在pt_case表加一个industry字段存行业 ID然后在前台加筛选参数$industry intval($_GET[industry] ?? 0); $where status 1; if ($catid) { $where . AND catid . $catid; } if ($industry){ $where . AND industry . $industry; } // 分页 $page max(1, intval($_GET[page] ?? 1)); $size 12; $offset ($page - 1) * $size; $total $db-fetchOne(SELECT COUNT(*) AS c FROM pt_case WHERE {$where})[c]; $list $db-fetchAll(SELECT * FROM pt_case WHERE {$where} ORDER BY sort DESC, id DESC LIMIT {$offset}, {$size});这里有几个实战要点分页的LIMIT参数必须是整数因为它是拼进 SQL 的字符串拼进去就是注入漏洞。上面用了intval和max双重保险。总数查询和列表查询的WHERE必须完全一致不然分页数量和实际数据对不上会出现“显示 100 条但只能翻 8 页”这种诡异现象。筛选参数的保留在分页链接里容易漏。翻页时要把catid、industry这些参数带上否则翻到第二页筛选就失效了。我一般写个辅助函数拼 query stringfunction buildPageUrl($page) { $params $_GET; $params[page] $page; return /case/list? . http_build_query($params); }http_build_query会自动做 URL 编码比手动拼字符串安全得多。这套改完索引也要跟着调整。原来的联合索引(catid, status, sort)在加了industry之后不够用了我加了一个(industry, status, sort)。这里就有个取舍索引不是越多越好每个索引都会拖慢写入。我的判断依据是看哪个查询路径最高频——案例列表页是主要入口按行业筛选是用户最常用的操作那这个索引就值得加。如果是个一年用两次的筛选宁可让它慢点也不加索引。6. 性能与安全上线之后才是真正的战场6.1 缓存分层别一上来就上 Redis性能优化这事有个常见误区就是一上来就加 Redis、加各种中间件。实际测下来内容站 80% 的性能问题不在数据库在模板重复渲染和无谓的文件 IO。我的优化顺序是这样的从成本低到成本高层级手段预期收益实施成本1开启 OPcache减少 PHP 编译开销20%~40%极低改配置2页面静态化 / 文件缓存首屏响应降到 10ms 级低系统一般自带3数据库索引优化复杂查询提速明显中需要看慢查询4Redis 缓存热点数据高并发下扛压中高要改代码5CDN 动静分离带宽与地域延迟中要改域名解析OPcache 配置很简单opcache.enable1 opcache.memory_consumption128 opcache.max_accelerated_files10000 opcache.validate_timestamps1 opcache.revalidate_freq60validate_timestamps1加revalidate_freq60意味着最多 60 秒后才会检查文件变更也就是说你改了代码可能要等一分钟才生效。开发环境建议设成revalidate_freq0每次请求都检查生产环境设 60 减少开销。很多人改完代码刷新没变化第一反应是缓存没清其实是 OPcache 在作怪重启一下 PHP-FPM 立刻生效。页面静态化这块内容站的详情页特别适合。一篇发布后基本不改的稿子生成 HTML 直接由 Nginx 返回PHP 和数据库完全不参与单机扛几千并发轻松。但要处理好静态页失效内容更新时要删除对应的静态文件否则用户看到的是旧版本。这个逻辑放在发布钩子里最合适。6.2 慢查询实例一个索引带来的 40 倍差距说个真实案例。某个列表页加载要 3 秒多EXPLAIN一看是全表扫。表里 20 万条数据查询条件是catid 5 AND status 1 ORDER BY sort DESC。原来的索引是(status, sort)看着有索引但catid不在里面MySQL 只能用它过滤status几乎全表都符合然后回表再筛catid等于白用。改成(catid, status, sort)之后EXPLAIN显示typeref扫描行数从 20 万降到 300查询时间从 2.8 秒降到 0.07 秒。这个案例的教训是索引要按“等值条件在前、排序字段在后”的顺序建。catid和status是等值匹配放前面sort是排序放最后。如果顺序写成(sort, catid, status)排序字段在最前面等值条件用不上索引基本失效。还有个技巧如果列表页只需要几个字段用覆盖索引能进一步减少回表ALTER TABLE pt_case ADD INDEX idx_cover (catid, status, sort, id, title, thumb);这样查询id,title,thumb时直接从索引里拿不用回表。代价是索引体积变大、写入变慢只在读多写少的表上值得做。6.3 安全加固这几条不做等于裸奔内容站是攻击重灾区因为入口多、表单多、上传功能多。我固定的检查清单上传目录禁止执行。这条最重要没有之一。Nginx 里对上传目录单独配置location ^~ /data/attachments/ { location ~ \.(php|php5|phtml|jsp|asp)$ { deny all; } }光改后缀名没用攻击者会传.php.jpg再配合解析漏洞。上面这段配置是把上传目录里任何可能被解析为脚本的文件直接拒绝从根上堵死。SQL 注入。所有外部输入进 SQL 前必须处理。能用参数化就用参数化必须拼接的场景一律intval或addslashes。别信“这个参数用户改不了”抓包工具一改就改了。XSS。输出到 HTML 的内容一律转义htmlspecialchars($str, ENT_QUOTES, UTF-8)。富文本内容要单独处理用白名单过滤标签别图省事直接strip_tags那样连img都留不住。后台入口保护。三个措施叠加改路径、加 IP 白名单、加二次验证。单个措施都可能被绕过叠加起来成本就高了。日志与监控。至少保留 30 天的访问日志重点看两类短时间内大量 404在扫目录、大量 POST 到登录接口在爆破。发现后直接封 IP别犹豫。7. 常见问题速查这些坑我都替你踩过了7.1 白屏、500、404三板斧排查法白屏通常是 PHP 致命错误但错误显示关了。最快的定位方法是临时打开错误显示ini_set(display_errors, 1); ini_set(error_reporting, E_ALL); error_reporting(E_ALL);或者直接看 PHP 错误日志tail -f /var/log/php7.4-fpm.log。九成白屏是语法错误或者调用了不存在的函数。500 错误先看 Nginx 错误日志再看 PHP-FPM 日志。常见原因权限不对www 用户读不了文件、fastcgi_pass指向的 sock 路径不对、SCRIPT_FILENAME配错。404 错误如果是全站 404基本就是伪静态没配按第 3 章那份配置改。如果是部分页面 404看 URL 规则是否被 Nginx 的其他location抢先匹配了——location的匹配优先级是^~ 正则 前缀经常有人写了正则规则把静态文件也拦进去了。7.2 模板改了不生效按这个顺序查这个问题我被问过最多原因就那么几个按顺序排一遍基本能定位模板缓存。系统一般会编译模板到data/下的缓存目录清掉再试。OPcache。重启 PHP-FPMsystemctl reload php7.4-fpm。改错主题。多端多主题的系统PC 和手机是不同目录你可能改的是没在用的那套。CDN 缓存。接了 CDN 的话后台刷新缓存或者加版本号参数验证。浏览器缓存。无痕模式打开或者CtrlF5强刷。第 3 条特别隐蔽因为文件确实改了、时间戳也对就是不生效特别打击人。我现在养成习惯改之前先在前台按 F12 看实际加载的模板路径有的系统会在 HTML 注释里标出来确认改对了地方再动手。7.3 上传、编码、跨域三个高频杂症上传报错最常见的是权限和大小限制。先看php.ini的upload_max_filesize再看目录权限最后看是不是被 Nginx 的client_max_body_size拦了默认 1M传大图必挂。改完 Nginx 记得-s reload。中文乱码三个地方要对齐数据库字符集、连接字符集、页面meta charset。连接字符集最容易漏PDO 连接时显式指定$dsn mysql:host127.0.0.1;dbnameportal_db;charsetutf8mb4;漏了这个即使库是utf8mb4连接层还是用默认字符集插入中文就变问号。跨域问题多出现在 API 接口被前端单独调用时。在接口入口加响应头header(Access-Control-Allow-Origin: https://你的域名); header(Access-Control-Allow-Methods: GET, POST, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization); if ($_SERVER[REQUEST_METHOD] OPTIONS) { exit; }注意Allow-Origin别写成*特别是带登录态的接口写*浏览器会拒绝携带 Cookie而且安全上也说不过去。另外OPTIONS预检请求要提前返回否则 POST 会先被拦一次。7.4 问题速查表现象最可能原因快速验证处理全站 404伪静态未配访问index.php?xxx是否正常补try_files规则白屏PHP 致命错误看 FPM 错误日志开 error_reporting 定位图片不显示附件路径或权限直接访问图片 URL查目录权限与路径常量改模板无效模板缓存/OPcache/改错主题无痕模式 看 HTML 注释逐项排除后台上传失败大小限制或目录不可写传 100K 小文件试试改 php.ini 与权限中文变问号连接字符集未指定看表字段字符集DSN 加 charset分页翻页重复排序字段不唯一看 ORDER BY加 id 作为第二排序定时任务不执行cron 里%未转义手动跑命令是否成功改为\%前台数据不更新页面缓存未清清缓存后是否正常发布钩子里加清缓存后台登录被踢Session 目录不可写或超时看 session 配置改目录权限与有效期8. 关于升级与长期维护几句实在话二次开发做完只是开始真正的考验在半年后。我给自己定了几条规矩执行下来省了很多麻烦。第一所有改动留痕。前面提过的PATCHES.md格式固定日期、涉及文件、改动内容、升级时如何处理保留/合并/废弃。写的时候多花两分钟升级的时候能省两天。第二模板改动集中在自定义目录。能不碰系统自带的模板就不碰新建一套主题目录从默认主题复制再改。这样升级时直接把系统模板覆盖掉你的改动不受影响。第三模块开发遵循命名空间隔离。自己的模块统一用custom_前缀表名、类名、函数名都加。别用getList、checkUser这种大路货名字将来和系统的函数撞名报错信息会让你抓狂——Cannot redeclare function而且报错位置指向的是系统文件你根本猜不到是自己哪行代码闯的祸。第四定期做恢复演练。备份文件存在不等于能恢复。我每季度会挑一个备份在测试环境完整恢复一次确认流程走得通。这个动作花不了一小时但能在关键时刻救命。有次我发现备份脚本里mysqldump的路径写错备份文件全是 0 字节如果没演练等真出事才发现就晚了。第五别过度优化。我见过有人为了省几百毫秒上了一堆中间件结果运维复杂度翻了几倍出问题时排查链路长得吓人。性能优化的判断标准应该是“有没有用户能感知到的慢”而不是“有没有达到某个理论指标”。内容站只要首屏两秒内出来剩下的优化收益就很有限了。最后分享一个我常用的自检思路任何一处改动先问自己三个问题——这个改动会不会影响升级会不会引入新的外部输入风险半年后别人接手能不能看懂三个问题都过了再动手写。这三个问题帮我挡掉了不少当时觉得聪明、后来觉得蠢的方案。