
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远程请求、接口调用采集、短信、推送类插件全部失效opensslHTTPS 相关、加密函数后台登录验证码或加密环节报错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/ShanghaiMySQL 这边没设程序又是用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$1cindexalistscatid$2 last; rewrite ^/([a-z])/show-([0-9])\.html$ /index.php?m$1cindexashowid$2 last; rewrite ^/([a-z])/?$ /index.php?m$1 last; rewrite ^/tag/(.)\.html$ /index.php?mtagaliststag$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?marticlecindexashowid123入口文件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文件权限基本不生效写权限不足直接失败部署后按目录用途分别授权换行符CRLFLF配置文件、模板文件统一转 LF第三条最容易漏。用户在后台改模板、上传图片、清缓存全都需要写文件。权限不对的表现是操作提示成功但没有任何变化因为file_put_contents失败只返回 false很多老代码不检查返回值。4. 模板层的二次开发标签解析机制和实际写法4.1 模板标签是怎么变成 PHP 的自研模板引擎的核心是一套正则替换加编译缓存。你写的是{module ...}...{/module}这种标签程序在第一次访问时把它替换成foreach循环加输出语句编译结果存成一个 PHP 文件放在缓存目录。第二次访问直接 include 编译后的文件不再解析。理解这一点能解释两个高频现象第一改模板不生效。因为编译缓存还在。大部分程序会在模板文件修改时间变化时自动重新编译但如果服务器时间不对、或者文件是通过 FTP 上传的修改时间被保留成源文件的时间判断就会失效。手动清一次模板缓存最稳。第二标签里不能用原生 PHP 逻辑。编译阶段只认它自己定义的标签语法你在标签属性里写{if $a $b}这种混合表达式解析器可能只识别一半。复杂逻辑要么在控制器里算好再传要么用程序提供的条件标签。一个典型的数据调用写法长这样{module modulearticle catid12 num10 orderupdatetime DESC} li a href{$v.url} title{$v.title}{$v.title|cutstr30}/a span{$v.updatetime|dateY-m-d}/span /li {/module}这里的catid、num、order是模块自定义的参数$v是循环变量|cutstr30是一个过滤器调用。注意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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;主表只放列表页需要的字段标题、缩略图、栏目、时间、状态正文和大字段放附表。列表页查询只碰主表详情页再 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一定要加上下限保护否则size99999一个请求就能把数据库拖死返回的字段类型要统一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最大 50page最大按总页数截断。不加这个限制一个size100000的请求就能让数据库喘不过气。敏感字段不返回。主表里的status、审批人 ID、内部备注这些接口里一律不输出需要一个字段一个字段白名单式地拼而不是SELECT *直接扔出去。统一错误码。业务错误和系统错误分开HTTP 状态码保持 200body 里用code区分。这样客户端处理逻辑统一不用去判断状态码。8.3 签名和限流接口上线前必须有的两道闸只要接口涉及写操作提交评论、点赞、下单就必须有防重放机制。最轻的做法是时间戳加签名客户端带上timestamp和signsign md5(参数按字典序拼接 密钥)服务端校验时间戳偏差不超过 5 分钟签名一致才放行。限流按接口维度做。读接口按 IP 限流写接口按用户 ID 限流。文件缓存的计数器实现最省事用文件路径 时间窗口作为 key自增超过阈值返回 429。这个方案在单机环境足够用多机部署时换成共享存储就行。还有一点容易被忘接口的错误信息不要暴露内部细节。SQL 报错原文、文件路径、堆栈信息一律不返回给客户端日志里记全就行。9. 上线前的性能与安全收尾9.1 缓存分层三层各管一段不要互相打架门户站的缓存一般分三层各管各的页面级缓存整页 HTML 静态化管列表页和详情页命中率最高收益最大。缺点是内容更新后必须主动清理否则用户看到的是旧内容。清理策略按栏目维度清不要全站清否则缓存等于没有。数据级缓存管那些查询重、变化少的数据比如栏目树、导航、配置项。这部分数据一天可能都不变一次缓存时间长一点没关系。模板编译缓存是引擎自己管的不用干预只需要在改模板后手动清一次。三层叠加使用时有个细节页面缓存里已经包含了数据缓存的结果所以清数据缓存的时候关联的页面缓存也要一起清不然页面还是旧的。这个依赖关系在配置文件里写清楚后面接手的人就不会踩。9.2 安全加固清单逐条对照项目做法原因安装目录装完立即删除或改名重装入口是最直接的攻击面后台路径改成不易猜的路径减少扫描器的无效请求数据库前缀不用默认前缀防批量 SQL 注入工具上传目录禁止执行脚本防上传后门配置文件只读权限防篡改数据库密码错误显示生产环境关闭防路径和结构泄露后台密码强密码加登录失败锁定防暴力破解版本号前台页面不显示减少针对性探测后台登录这块我一般还会加一条限制后台入口的访问来源能固定 IP 就固定不能的话至少加一层二次验证。门户站的后台一旦被进内容被批量篡改的恢复成本远高于加这层验证的成本。9.3 备份和迁移别等出事才想数据库用mysqldump定时导参数上注意两点加--single-transaction保证 InnoDB 表的一致性快照不加的话导出来的数据可能是半截状态加--default-character-setutf8mb4不加的话中文在某些环境下导出来就是乱码。附件目录单独备份量大的话用增量同步。恢复演练至少做一次——备份文件能不能成功导入只有真导一次才知道。我见过太多备份了三年出事那天发现文件是 0 字节的情况。迁移到新服务器时顺序是装环境版本对齐扩展装齐→ 导数据库 → 传程序文件 → 传附件目录 → 改配置文件里的域名和数据库信息 → 配伪静态和权限 → 清缓存 → 逐页验证。缓存目录和临时目录不要从旧服务器复制里面全是旧路径的绝对路径复制过去反而出问题。这套流程我走过十几次每次出岔子的地方都集中在两处一是数据库版本差异导致的字符集问题二是配置文件里写死了绝对路径。迁移前把配置文件通读一遍把所有绝对路径改成相对路径或者配置项能省掉一大半排查时间。