1. 门户系统的骨架:先判断这套 PHP 程序值不值得投入
1.1 门户系统和普通建站程序到底差在哪
很多人第一次接触门户系统,会拿它和博客程序、企业站程序对比,结论往往是"功能太多、太重"。这个判断其实跑偏了。门户系统的设计目标是多频道、多栏目、多终端、上万级内容量的矩阵式运营,普通建站程序是单条内容流的展示工具,两者的数据模型从根上就不一样。
具体差在三个地方。第一是频道与栏目分层:一套门户里同时跑新闻、房产、招聘、分类信息、图库、专题、视频,每个频道有自己的字段体系、审核流程、模板目录,甚至自己的编辑权限。第二是主表加附表的存储结构:内容主表只放标题、栏目、时间、状态这类公共字段,频道特有的字段(比如房产的面积、朝向、总价)落在附表里,这样新增频道不用动主表。第三是模板的多级覆盖:全局模板、频道模板、栏目模板、单页模板一层层往下覆盖,改一个栏目的样式不需要动其他栏目。
这也是我在选型阶段最看重的一点——如果只是做个二十来页的宣传站,任何 CMS 都能干;但只要业务涉及"多个内容板块 + 多个编辑角色 + PC 和手机两套展现",门户类的程序在数据结构上省下来的返工量,远超它带来的学习成本。
1.2 8.1 版本在技术栈上的实际构成
标题里的"基于 PHP",落到具体实现上就是一套自研的 MVC 结构:请求地址里用m、c、a三个参数分别定位模块、控制器、动作,入口文件统一收口,模板用自定义标签语法而不是原生 PHP 混排。这套结构在门户类程序里非常典型,理解它之后你会发现大部分所谓"看不懂的框架"都是同一个套路。
几个关键设计需要提前知道背后的理由:
为什么模板引擎是自研的,而不是接入主流的 Twig 或 Blade?因为门户系统的后台需要让编辑人员在浏览器里直接编辑模板、插入数据调用标签。Swift 那类模板引擎的语法是为开发者设计的,标签写错直接抛异常;自研标签体系可以在解析阶段做容错,标签写错了页面顶多某一块不显示,不会整页崩掉。这个取舍对运营型站点是有意义的——编辑改错一个字把整站搞白屏,这种事发生一次就够受了。
为什么缓存主要靠文件而不是 Redis?门户站的内容是典型的读多写少,列表页、详情页、导航、碎片区块的缓存命中率极高。文件缓存不依赖任何外部服务,虚拟主机、低配云服务器都能跑起来,部署门槛低。代价也很明确:缓存一致性要靠程序主动清理,改完内容忘记清缓存就是最常见的"改了不生效"。
为什么用m/c/a这种参数式路由?它把"模块"这一层显式暴露在 URL 里,插件和模块之间调用彼此的控制器非常直接,不需要经过复杂的路由注册。副作用是 URL 不够优雅,得靠伪静态规则去包装成/article/list-12.html这种形式。
1.3 "跨平台"在这个语境下其实有两层含义
标题里的跨平台,很多人只想到 PC 端和手机端。实际落地时它会拆成两层,两层都会带来实打实的坑。
第一层是操作系统层:本地开发常用 Windows 环境,正式环境基本是 Linux。Windows 的文件系统不区分大小写,Template/Article/index.html和template/article/index.html在本地是同一个文件,扔到 Linux 上就是两个不存在的路径,页面直接报模板找不到。目录分隔符\和/混用、文件权限(Linux 上 644/755 的差别)、换行符差异,都属于这一类。
第二层是终端层:PC 模板、WAP 模板、APP 接口、小程序接口共用一套数据。这一层的设计重点是数据出口要统一,展现层要能自由切换,后面第 8 章会展开讲。
我一般建议在动手之前就把这两层分清楚:如果是前者,属于"环境配置"范畴,配一次就完事;如果是后者,属于"架构设计"范畴,一开始没规划好,后面每加一个终端都要改一遍业务代码。这两件事混在一起谈,很容易把简单问题复杂化。
2. 环境账要先算清楚:PHP 版本、扩展、数据库与伪静态
2.1 PHP 版本不是越新越好,7.4 是这套程序的安全区
这是我在部署阶段踩得最扎实的一个坑。门户类程序的代码里普遍存在一些历史写法,比如each()、create_function()、ereg()系列,这些函数在 PHP 7.x 被标记废弃,到 8.0 直接移除。现象很典型:环境检查页面一路绿灯,装完进了后台,点某个菜单直接 500,日志里写着Call to undefined function each()。
我的选择是PHP 7.4。7.4 对老代码的兼容性足够,同时又比 5.6 快出一大截,OPcache 的表现也稳定。5.6 虽然最保险,但已经拿不到官方维护,性能和安全性都说不过去。8.x 不是不能上,但要准备好改代码——这不是配置问题,是工作量问题。
扩展清单比版本更容易被忽略,我按用途列一下:
| 扩展 | 用途 | 缺了会怎样 |
|---|---|---|
| mysqli 或 pdo_mysql | 数据库连接 | 装不上,直接卡在环境检测 |
| gd | 缩略图、水印、验证码 | 上传图片成功但缩略图全是空白 |
| fileinfo | 上传文件的真实类型检测 | 上传校验失效,或者上传直接报错 |
| mbstring | 中文字符串截取 | 标题截断出现半个汉字乱码 |
| curl | 远程请求、接口调用 | 采集、短信、推送类插件全部失效 |
| openssl | HTTPS 相关、加密函数 | 后台登录验证码或加密环节报错 |
| exif | 读取图片方向信息 | 手机拍的竖图上传后变成横的 |
| zip | 打包下载、模板导入 | 模板导入功能报"格式不支持" |
gd和fileinfo这两个最容易被漏,因为很多环境默认装了一部分。装完建议在后台的环境检测页面或者自己写个phpinfo()页面核一遍,别等上线后才发现缩略图生成不了。
2.2 MySQL 的字符集、sql_mode 和时区三项必须提前定死
数据库这块我见过太多"装好之后才改"的返工,改起来比装之前设定麻烦得多,因为存量数据已经按错误字符集写进去了。
字符集统一 utf8mb4。老程序可能默认 utf8(三字节),这种字符集存不下 emoji 和部分生僻字,用户提交带 emoji 的评论会直接写库失败或者变成问号。建库时就把服务端、库、表、连接四处全设成 utf8mb4,别指望后面 ALTER 一劳永逸,索引长度、连接配置都是坑。
sql_mode 要去掉ONLY_FULL_GROUP_BY。老代码里的GROUP BY写法普遍不规范,SELECT 里带非聚合字段是常态。MySQL 5.7 之后默认开启严格模式,这类查询直接报错,页面就是一片空白。我的配置是:
[mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci default-time-zone = '+08:00' sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION max_allowed_packet = 64M时区单独强调一下。PHP 那边date.timezone设了Asia/Shanghai,MySQL 这边没设,程序又是用NOW()写时间,结果就是文章发布时间永远差 8 小时。这种 bug 特别隐蔽,因为前台显示的是 PHP 格式化后的时间,看着对,一到后台按时间筛选、按时间排序就全乱。
2.3 上传目录必须单独隔离,这是安全底线不是优化项
附件目录(通常叫uploadfile或uploads)是整站风险最高的一块。用户上传的文件如果能被当 PHP 执行,那就是最省事的一条提权路径。配置层面必须把它和程序执行隔开。
Nginx 下的做法是给上传目录单独加一条 location,命中脚本后缀直接拒绝:
location ~* ^/(uploadfile|uploads)/.*\.(php|php5|phtml|jsp|asp|aspx)$ { deny all; } location ~* ^/(uploadfile|uploads)/ { expires 30d; add_header Cache-Control "public"; }目录权限按最小可用原则给:uploadfile和data(缓存目录)需要写权限,755 加属主;config目录里的配置文件只给读权限;程序目录一律不给写。Windows 开发环境没有这个概念,部署到 Linux 时如果习惯性给 777,后面出问题基本查不出来。
2.4 伪静态规则要按"先匹配具体、再匹配泛化"的顺序写
门户系统的 URL 规则一般分三类:频道首页、列表页、详情页。伪静态的核心难点不是写规则,而是规则顺序——宽松规则写在前面,后面的具体规则永远不会被命中。
location / { if (!-e $request_filename) { rewrite ^/([a-z]+)/list-([0-9]+)\.html$ /index.php?m=$1&c=index&a=lists&catid=$2 last; rewrite ^/([a-z]+)/show-([0-9]+)\.html$ /index.php?m=$1&c=index&a=show&id=$2 last; rewrite ^/([a-z]+)/?$ /index.php?m=$1 last; rewrite ^/tag/(.+)\.html$ /index.php?m=tag&a=lists&tag=$1 last; } }if (!-e $request_filename)这一句先判断物理文件是否存在,存在的直接交给 Nginx 处理(静态资源走这里),不存在的才进重写。省掉这句,CSS、JS、图片全部会被当成路由参数送进 PHP,页面能打开但样式全丢,而且服务器负载莫名其妙升高。
Apache 环境对应的是.htaccess里的RewriteRule,逻辑一样,注意RewriteBase要和子目录部署保持一致。如果站点部署在二级目录下(比如/portal),所有规则前都要补上这个前缀,否则所有内页跳转都会回到根目录。
3. 装完之后别急着改代码:先把请求链路和目录边界摸清楚
3.1 一次请求从入口到页面输出的完整路径
这一步值得专门花一个小时。把链路画清楚之后,后面所有报错都能定位到具体环节,而不是漫无目的地翻代码。
一次请求大致走这么几步:Nginx 收到/article/show-123.html,伪静态规则把它改写成index.php?m=article&c=index&a=show&id=123;入口文件index.php加载公共引导文件,初始化配置、数据库连接、路由参数过滤;路由根据m、c、a定位到模块目录下的控制器文件和方法;控制器调用模型层取数据,取完把数据赋给模板变量;模板引擎检查编译缓存是否存在、是否过期,不新鲜就把标签语法编译成 PHP 文件存到data目录;最后 include 编译后的模板文件输出 HTML。
这条链路上每一步都可能出问题,而现象几乎都是白屏或 500。所以定位思路不是去猜,而是逐段验证:先确认 URL 是否被正确改写(看 Nginx 的 access log),再看入口文件是否报错(PHP 错误日志),再确认控制器是否被执行(临时打一行日志),最后看模板编译文件是否生成(去data目录看)。
3.2 模块、插件、模板三者的边界,决定了你该改哪里
很多二次开发的返工,根源是"改错层"。同一个需求放在不同层实现,成本能差好几倍。
模块层承载业务功能:文章、图库、下载、分类信息。它有独立的控制器、模型、数据表、后台管理页面。如果需求是"新增一种内容类型",比如"短视频",这是模块的活。
插件层承载横切能力:短信发送、支付对接、内容审核、消息推送。它挂在不特定模块的钩子上,不修改核心文件。如果需求是"文章审核通过后发条通知",这是插件的活。
模板层只管展示:布局、颜色、字段排列、交互。如果需求是"详情页正文下面加个相关推荐",先看这个数据是不是已经有了,有的话纯模板就能搞定。
判断方法很简单:问一句"这个需求会不会改变数据的存储结构"。会,往下走业务层;不会但要在多个模块触发,走插件层;纯粹是长得不一样,走模板层。我见过最典型的错误是拿模板标签硬拼业务逻辑,在模板里写了二十行条件判断去算价格,后来业务规则一改,全站模板都要跟着改。
3.3 Windows 开发环境到 Linux 生产环境的四个隐形差异
本地跑通、上线就崩,八成是这四个差异之一:
| 差异点 | Windows 表现 | Linux 表现 | 规避方式 |
|---|---|---|---|
| 大小写敏感 | 不敏感,Template能匹配template | 敏感,路径必须完全一致 | 目录名统一全小写 |
| 路径分隔符 | 反斜杠也能用 | 只认正斜杠 | 代码里一律用/或DIRECTORY_SEPARATOR |
| 文件权限 | 基本不生效 | 写权限不足直接失败 | 部署后按目录用途分别授权 |
| 换行符 | CRLF | LF | 配置文件、模板文件统一转 LF |
第三条最容易漏。用户在后台改模板、上传图片、清缓存,全都需要写文件。权限不对的表现是"操作提示成功但没有任何变化",因为file_put_contents失败只返回 false,很多老代码不检查返回值。
4. 模板层的二次开发:标签解析机制和实际写法
4.1 模板标签是怎么变成 PHP 的
自研模板引擎的核心是一套正则替换加编译缓存。你写的是{module ...}...{/module}这种标签,程序在第一次访问时把它替换成foreach循环加输出语句,编译结果存成一个 PHP 文件放在缓存目录。第二次访问直接 include 编译后的文件,不再解析。
理解这一点能解释两个高频现象:
第一,改模板不生效。因为编译缓存还在。大部分程序会在模板文件修改时间变化时自动重新编译,但如果服务器时间不对、或者文件是通过 FTP 上传的(修改时间被保留成源文件的时间),判断就会失效。手动清一次模板缓存最稳。
第二,标签里不能用原生 PHP 逻辑。编译阶段只认它自己定义的标签语法,你在标签属性里写{if $a && $b}这种混合表达式,解析器可能只识别一半。复杂逻辑要么在控制器里算好再传,要么用程序提供的条件标签。
一个典型的数据调用写法长这样:
{module module="article" catid="12" num="10" order="updatetime DESC"} <li> <a href="{$v.url}" title="{$v.title}">{$v.title|cutstr=30}</a> <span>{$v.updatetime|date="Y-m-d"}</span> </li> {/module}这里的catid、num、order是模块自定义的参数,$v是循环变量,|cutstr=30是一个过滤器调用。注意catid要写子栏目时通常需要额外参数控制是否包含子级,这个参数名各版本不统一,改之前一定去模板目录里翻现成的写法,照抄最保险。
4.2 列表页、详情页、单页的模板继承关系
模板目录一般按模块分开,每个模块里再分index(列表)、show(详情)、single(单页)。程序找模板的顺序是"栏目自定义模板 → 频道默认模板 → 全局默认模板",任何一层命中就停。
这个机制的实际价值在于:同一个频道下,某个栏目要做成图文卡片的样式,只需要在后台给这个栏目单独指定一个模板文件,其他栏目完全不受影响。做多频道站点的时候,这个特性省下来的工作量非常可观。
有一个坑要注意:自定义模板文件必须放在约定的目录里,文件名符合命名规则。放错目录不会报错,只会静默回退到上一级模板,你看着页面没变化,还以为是缓存问题,清了八百遍缓存也没用。
4.3 自定义字段和附表:新增频道字段的正确姿势
门户站最常见的需求就是加字段。比如房产频道要加"建筑面积""朝向""楼层"。正确做法是在后台的模型管理里给该频道添加自定义字段,程序会自动在附表里加列,并在发布页面渲染对应表单。
几个实操要点:
- 字段类型要选对。数字型字段选 int/decimal,别用 varchar,否则后面按价格区间筛选、排序会非常慢,而且排序结果是字符串顺序("100"排在"20"前面)。
- 需要筛选的字段要建索引。附表数据量上万之后,没有索引的筛选条件会拖垮列表页。
- 字段名避开保留字。
desc、order、group、key这些在 SQL 里是关键字,字段名叫这个,查询语句不加反引号就报语法错误。 - 前台展示时注意空值处理。自定义字段没填是空字符串,直接输出可能会把布局撑歪,模板里加个判断更稳妥。
4.4 模板调试的两个土办法,比装调试工具快
我不太喜欢在门户项目里装一整套调试工具,太重。两个土办法够用:
一是在模板任意位置插入一段输出当前所有可用变量的代码,看看数据到底传过来没有、字段名到底是什么。字段名拼错是模板层最高频的错误,拼错了不报错,只是空输出。
二是把编译缓存目录里的编译文件直接打开看。你想知道标签被编译成了什么,直接看那个 PHP 文件最直观,比查文档快。文件名一般和模板路径对应,缓存目录里按修改时间排序,最新的那个就是。
5. 业务层二次开发:新增一个模块要动哪些地方
5.1 数据表设计:主表加附表不是可选项
新增模块时最容易偷懒的做法是把所有字段塞一张表。内容量小的时候没问题,一旦某个频道有几十个自定义字段,主表宽度爆炸,列表页一次查询要读大量用不上的列,性能肉眼可见地掉。
标准做法是拆两张表:
CREATE TABLE `xycms_news` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `catid` smallint(5) unsigned NOT NULL DEFAULT '0', `title` varchar(200) NOT NULL DEFAULT '', `thumb` varchar(255) NOT NULL DEFAULT '', `status` tinyint(1) NOT NULL DEFAULT '0', `updatetime` int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `catid_status` (`catid`,`status`), KEY `updatetime` (`updatetime`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `xycms_news_data` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `content` mediumtext, `source` varchar(100) NOT NULL DEFAULT '', `author` varchar(50) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;主表只放列表页需要的字段(标题、缩略图、栏目、时间、状态),正文和大字段放附表。列表页查询只碰主表,详情页再 JOIN 一次附表。这个拆分看起来多写了一张表,实际是列表页性能的分水岭。
catid_status这个联合索引的顺序有讲究:栏目在前、状态在后,因为列表页的查询条件永远是"某栏目下已审核的内容",栏目区分度高,放在最左边能最大化利用索引。
5.2 控制器与模型的约定:跟着现有代码抄,别自创风格
程序对模块目录有约定俗成的结构:模块目录下放controller、model、template、config。新建模块最省事的办法不是从零写,而是找一个功能最接近的现有模块,整个目录复制过来,重命名,然后删掉不要的方法。
这么做有两个实际好处:一是继承关系、命名空间、基类初始化这些东西不用自己猜;二是后台菜单、权限节点、缓存 key 的命名规则会自动保持一致,不会出现"新模块的清缓存按钮清不掉自己"这种诡异问题。
模型层要注意一点:数据库操作统一走模型,不要在控制器里拼 SQL。门户系统的表前缀、字符集、分表逻辑都可能在配置文件里配,硬编码表名(尤其是写死xycms_前缀)在换环境部署时必然出事。
5.3 后台菜单和权限节点的挂载
新模块的前台能访问了,后台还没入口,这是新手的常见状态。后台菜单一般存在菜单表里,按"顶级菜单 → 分组 → 菜单项"三层组织。加一条记录,指向控制器的地址,配上图标和排序值。
权限节点是另一套东西,往往和菜单是分开维护的。如果只加了菜单没加权限节点,会出现"超级管理员能看见,普通编辑看不见"或者反过来"菜单能看见,点进去提示无权限"。权限节点的标识一般和m/c/a对应,加完之后记住给对应角色组授权,否则一切都白搭。
5.4 接口输出:业务层就把数据结构定死
如果这个模块以后要供 APP 或小程序调用,那从第一天起就按接口的思维写。建议在模块里单独开一个api控制器,返回统一结构:
public function lists() { $catid = intval($_GET['catid'] ?? 0); $page = max(1, intval($_GET['page'] ?? 1)); $size = min(50, max(1, intval($_GET['size'] ?? 20))); $list = model('news')->getList($catid, $page, $size); return json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'total' => $list['total'], 'items' => array_map(function ($row) { return [ 'id' => intval($row['id']), 'title' => $row['title'], 'thumb' => $row['thumb'], 'datetime' => date('Y-m-d H:i', $row['updatetime']), ]; }, $list['items']), ], ]); }三个细节值得注意:page和size一定要加上下限保护,否则size=99999一个请求就能把数据库拖死;返回的字段类型要统一,id用 int 不要用字符串,否则前端做比较和排序会出问题;时间字段直接返回格式化后的字符串,别让每个终端各自处理时间戳,否则 PC 显示北京时间、小程序显示 UTC 时间,这类问题排查起来非常烦。
6. 插件钩子机制:不动核心代码的扩展方式
6.1 钩子的注册、执行顺序与上下文参数
插件机制要解决的核心问题是:怎么在不修改核心文件的前提下,往核心流程里插入自己的逻辑。答案是在核心代码的关键位置预先埋好"钩子点",插件向这些点注册自己的函数,运行时按顺序执行。
插件目录的结构一般是这样的:
// plugin/notice_audit/hook.php return [ 'content_audit_pass' => 'notice_audit_pass', 'content_audit_fail' => 'notice_audit_fail', ]; function notice_audit_pass($params) { $uid = intval($params['uid'] ?? 0); $title = trim($params['title'] ?? ''); if ($uid <= 0 || $title === '') { return false; } model('message')->send($uid, '您的投稿已通过审核:' . $title); }return出来的数组就是注册表,键是钩子名,值是函数名。核心代码执行到那个位置时,读取所有插件的注册表,依次调用。
执行顺序是个容易被忽略的点。多个插件挂同一个钩子时,顺序由插件的安装顺序或排序权重决定。如果两个插件都对同一份数据做处理,顺序不同结果就不同。我的做法是:插件的函数尽量做成"无副作用"的,只读不写,需要写操作时用自己独立的数据表或队列,避免互相干扰。
上下文参数要防御性处理。钩子传过来的$params是核心代码拼的,字段名可能随版本变化。用??给默认值,取不到就提前 return,不要让一个插件的健壮性问题拖崩主流程。
6.2 一个完整插件的落地:投稿审核通过后发通知
拿这个需求走一遍全流程,比看文档直观。
第一步,找到钩子点。审核逻辑在哪个控制器里,审核通过之后执行了什么,那里有没有埋好的钩子。没有钩子的话,两个选择:找就近的、语义相近的钩子,或者接受一次核心文件的小改动(在最稳的那一行加一个hook('content_audit_pass', $params),改动量只有一行,升级时合并成本可控)。
第二步,建插件目录,写配置文件。配置文件里至少要有插件名、版本、作者、依赖的最低程序版本、钩子注册表。版本号别乱写,后面升级判断全靠它。
第三步,写业务函数。通知内容要考虑长度限制(很多短信和站内信都有字数上限),用户 ID 要做类型转换(前端传过来的都是字符串),发送失败要捕获异常并记日志——通知发不出去不能影响审核本身的成功。
第四步,在后台插件管理里安装启用。启用时会执行安装文件,一般是建表或者写初始配置。
第五步,验证。这条链路的验证不能只看"有没有收到通知",还要确认通知没发出去时审核是否仍然成功。我一般会临时把发送函数改成抛异常,跑一遍审核,确认主流程不受影响。
6.3 插件升级时被覆盖,是这类系统最疼的一件事
插件机制只保护核心代码不被插件修改,反过来不成立——程序升级会覆盖插件目录。每次升级前把plugin目录完整备份,升级后对比差异,把被覆盖的文件还原。这个动作听起来原始,但比事后找回丢失的定制逻辑省事得多。
更稳妥的做法是给所有自研插件加统一前缀(比如custom_),升级前用脚本把整个前缀的目录打包,升级后再解包回来。同时把插件的版本号和维护记录写在一个 Markdown 文件里放在项目根目录,谁改了什么一目了然。
7. 踩坑实录:白屏、404、缓存、上传的排查链路
7.1 500 白屏:三层定位法,别一上来就猜
白屏是最没有信息量的故障,但排查路径其实很固定。
第一层,看 PHP 错误日志。这是最快的一步,也是最多人跳过的一步。日志位置由php.ini的error_log决定,或者看虚拟主机的日志目录。里面往往直接写着哪个文件哪一行报了什么错。如果日志里什么都没有,说明错误没被记录,那就临时在入口文件顶部打开显示:
ini_set('display_errors', 1); ini_set('error_reporting', E_ALL); error_reporting(E_ALL);上线环境一定要关掉display_errors,但排查阶段它是必需品。
第二层,看 Nginx/Apache 的错误日志。如果 PHP 日志干干净净,说明请求可能根本没到 PHP。502 一般是 PHP-FPM 没起来或者 socket 路径配错;500 且 Nginx 日志里有upstream相关信息,多半是超时或者内存溢出。
第三层,二分法定位文件。上面两层都没结论,就在入口文件里逐段exit。加载配置之后exit('ok1'),连数据库之后exit('ok2'),路由解析之后exit('ok3'),一路往下推,很快就能锁定是哪一段出的问题。这个方法笨,但绝对有效。
按这个顺序走过几次之后,我发现 90% 的白屏集中在三个原因上:PHP 版本不兼容(函数被移除)、数据库连接失败(密码错、端口错、socket 路径错)、模板目录权限不足(编译写不进去)。
7.2 伪静态失效导致的 404 连锁反应
现象是首页能打开,点任何链接都 404。看到这个现象第一反应往往是"伪静态没配"。
排查顺序:先看 Nginx 配置里include伪静态文件的那一行有没有被注释掉;再确认伪静态文件确实存在且文件名对得上(很多教程给的文件名和实际下载的不一致);然后用nginx -t测试配置语法,重载;最后确认location /块里没有更靠前的规则把请求截走了。
还有一个隐蔽的情况:站点部署在二级目录下,伪静态规则里的重写目标写的是/index.php,少了子目录前缀。结果就是所有内页都跳到了根域名,看着像 404,实际是跳错了地方。
Apache 环境下多一层:.htaccess需要AllowOverride All才生效,默认是None。配置文件写了半天没反应,多半是这个。
7.3 改了模板不生效:四种原因按概率排
| 现象 | 原因 | 处理 |
|---|---|---|
| 改了任何模板都没变化 | 模板编译缓存没清 | 后台清缓存,或手动删缓存目录下编译文件 |
| 只改了某栏目没变化 | 该栏目指定了独立模板 | 去栏目设置里看模板绑定 |
| 改完一会儿又变回去 | 用了 CDN 或反向代理缓存 | 刷新 CDN 缓存,或者给 HTML 加短缓存 |
| 部分区域没变化 | 该区域是碎片/区块,走的是另一套模板 | 找到对应碎片单独改 |
第一种占绝大多数。我现在的习惯是:动模板之前先清一次缓存,改完再清一次,两次都清完再看效果。多花的这十秒钟,能省掉大量的自我怀疑。
7.4 附件上传失败:从 PHP 配置查到目录权限
上传失败的表现有好几种,对应原因完全不同,得分开看。
提示"文件过大":upload_max_filesize和post_max_size两个都要调,只调一个没用,取的是两者中较小的那个。post_max_size要比upload_max_filesize稍大,因为表单里还有其他字段。
提示"上传成功但文件不存在":目录权限问题。Linux 下uploadfile目录属主不是 PHP 运行用户,写不进去。注意file_put_contents失败是静默的,很多老代码不检查返回值,所以表现为"成功"。
图片上传成功但缩略图空白:gd扩展没装,或者临时目录不可写(sys_get_temp_dir()指向的位置)。
大文件上传超时:max_execution_time和max_input_time都要放开,Nginx 那边还有client_max_body_size限制,默认 1M,超过直接 413。
upload_max_filesize = 64M post_max_size = 64M max_execution_time = 300 max_input_time = 300 memory_limit = 256M date.timezone = Asia/Shanghai7.5 中文乱码:三个位置必须同时是 utf8mb4
乱码的根源永远是编码不一致,问题在于链路上有好几个位置各自独立设置。数据库服务端、数据库、表、PHP 连接、页面Content-Type、文件本身编码,六处任意一处不一致就可能出问题。
最常见的组合是:库是 utf8mb4,连接也是 utf8mb4,但模板文件本身用 GBK 存的。这种在浏览器里看不出异常(因为 HTML 头声明是 utf8),但一提交表单,中文就变问号或者方框。用编辑器的"另存为"把模板文件统一转成 UTF-8 无 BOM 能解决。注意是无 BOM,带 BOM 的话会在页面顶部多输出三个字节,可能让header()报"输出已开始"的错。
8. 多端与接口:一套数据怎么喂好四个终端
8.1 模板分组加 UA 识别,是最省事的多端方案
如果只需要 PC 和手机两套页面,最轻量的做法是模板分组。程序一般支持按目录区分模板包,比如template/pc/和template/mobile/,后台配置好域名或者按 UA 自动切换。
这套方案的优点是改造成本几乎为零,现有模板复制一份改样式就行,数据层完全不动。缺点是维护两套模板,加一个区块要改两遍。内容量不大、更新节奏慢的站点,这个代价可以接受。
判断要不要升级到接口方案的标准是:是否有第三个终端需要同样的数据。只有 PC 和 WAP 两个,模板分组够用;一旦要接小程序或者 APP,数据出口就必须独立成接口,否则每加一个终端就要在模板标签里再拼一遍数据。
8.2 把接口单独做成一个应用,别和页面路由混在一起
我倾向于在程序里单独开一个api模块,和前台页面模块平级。理由有三个:接口的鉴权方式和页面完全不同(页面靠 session,接口靠 token);接口的输出必须严格是 JSON,不能有任何 HTML 混入;接口的缓存策略和页面也不一样。
几个必须做的防护:
- 入参全部强制类型转换。
intval、trim、白名单校验,一个都不能省。接口是直接暴露在公网的,参数校验松一次就多一个入口。 - 分页参数封顶。
size最大 50,page最大按总页数截断。不加这个限制,一个size=100000的请求就能让数据库喘不过气。 - 敏感字段不返回。主表里的
status、审批人 ID、内部备注这些,接口里一律不输出,需要一个字段一个字段白名单式地拼,而不是SELECT *直接扔出去。 - 统一错误码。业务错误和系统错误分开,HTTP 状态码保持 200,body 里用
code区分。这样客户端处理逻辑统一,不用去判断状态码。
8.3 签名和限流,接口上线前必须有的两道闸
只要接口涉及写操作(提交评论、点赞、下单),就必须有防重放机制。最轻的做法是时间戳加签名:客户端带上timestamp和sign,sign = md5(参数按字典序拼接 + 密钥),服务端校验时间戳偏差不超过 5 分钟,签名一致才放行。
限流按接口维度做。读接口按 IP 限流,写接口按用户 ID 限流。文件缓存的计数器实现最省事:用文件路径 + 时间窗口作为 key,自增,超过阈值返回 429。这个方案在单机环境足够用,多机部署时换成共享存储就行。
还有一点容易被忘:接口的错误信息不要暴露内部细节。SQL 报错原文、文件路径、堆栈信息,一律不返回给客户端,日志里记全就行。
9. 上线前的性能与安全收尾
9.1 缓存分层:三层各管一段,不要互相打架
门户站的缓存一般分三层,各管各的:
页面级缓存(整页 HTML 静态化)管列表页和详情页,命中率最高,收益最大。缺点是内容更新后必须主动清理,否则用户看到的是旧内容。清理策略按栏目维度清,不要全站清,否则缓存等于没有。
数据级缓存管那些查询重、变化少的数据,比如栏目树、导航、配置项。这部分数据一天可能都不变一次,缓存时间长一点没关系。
模板编译缓存是引擎自己管的,不用干预,只需要在改模板后手动清一次。
三层叠加使用时有个细节:页面缓存里已经包含了数据缓存的结果,所以清数据缓存的时候,关联的页面缓存也要一起清,不然页面还是旧的。这个依赖关系在配置文件里写清楚,后面接手的人就不会踩。
9.2 安全加固清单,逐条对照
| 项目 | 做法 | 原因 |
|---|---|---|
| 安装目录 | 装完立即删除或改名 | 重装入口是最直接的攻击面 |
| 后台路径 | 改成不易猜的路径 | 减少扫描器的无效请求 |
| 数据库前缀 | 不用默认前缀 | 防批量 SQL 注入工具 |
| 上传目录 | 禁止执行脚本 | 防上传后门 |
| 配置文件 | 只读权限 | 防篡改数据库密码 |
| 错误显示 | 生产环境关闭 | 防路径和结构泄露 |
| 后台密码 | 强密码加登录失败锁定 | 防暴力破解 |
| 版本号 | 前台页面不显示 | 减少针对性探测 |
后台登录这块我一般还会加一条:限制后台入口的访问来源,能固定 IP 就固定,不能的话至少加一层二次验证。门户站的后台一旦被进,内容被批量篡改的恢复成本远高于加这层验证的成本。
9.3 备份和迁移,别等出事才想
数据库用mysqldump定时导,参数上注意两点:加--single-transaction保证 InnoDB 表的一致性快照,不加的话导出来的数据可能是半截状态;加--default-character-set=utf8mb4,不加的话中文在某些环境下导出来就是乱码。
附件目录单独备份,量大的话用增量同步。恢复演练至少做一次——备份文件能不能成功导入,只有真导一次才知道。我见过太多"备份了三年,出事那天发现文件是 0 字节"的情况。
迁移到新服务器时,顺序是:装环境(版本对齐,扩展装齐)→ 导数据库 → 传程序文件 → 传附件目录 → 改配置文件里的域名和数据库信息 → 配伪静态和权限 → 清缓存 → 逐页验证。缓存目录和临时目录不要从旧服务器复制,里面全是旧路径的绝对路径,复制过去反而出问题。
这套流程我走过十几次,每次出岔子的地方都集中在两处:一是数据库版本差异导致的字符集问题,二是配置文件里写死了绝对路径。迁移前把配置文件通读一遍,把所有绝对路径改成相对路径或者配置项,能省掉一大半排查时间。