PHP文库系统源码:多站点部署与Sphinx全文检索实践
简介面向计划搭建或运营文库网站的开发者与站长知沃文库系统基于Yii2框架开发融合百度文库、原创力、豆丁文库等平台思路提供文档转换预览、全文检索、支付、私域流量、小程序公众号及商城资讯分销等运营模块。资源包共1个PDF文件约7.54MB为系统安装部署与配置手册目录覆盖ECS安全组设置、PC/手机/接口三站建立、对象存储OSS、短信与支付配置、第三方登录、公众号对接、导航栏目层级、用户等级、Banner与内容管理、文档发布流程、Sphinx检索启用及定时转码任务等关键环节。已有334人学习适合需要快速完成服务器环境搭建、站点配置和运营功能联调的技术人员参考也可作为文库类文档管理系统的部署检查清单。1. 文库系统落地一套 PHP 代码撑起三个站点的运营盘子去年有朋友拿着一堆攒了五六年的行业报告和标准文档找我说想做成一个能搜、能预览、能付费下载的站。我第一反应是别从头写——这套基于 PHP 的文库系统文库网站源码里该有的东西基本齐了多格式文档转 H5 预览、全文检索、微信生态对接、支付分销一条龙。它更像一个文档管理系统的运营底座而不是一个只能点开首页的演示壳子。适合两类人想快速把资料盘活成付费站点的运营方和想拿一套成熟 MVC 工程练手的 PHP 开发者。下面按我实际部署、改代码的顺序拆。2. 环境与三站点部署从安全组端口到 www、m、api 的域名分流拿到源码包之后最容易犯的错是先往服务器上一传然后打开浏览器干等。这套系统是「一套代码、三个入口」的结构PC 站、手机站、接口站各绑一个子域名共用同一份根目录配置没对齐的话装完就是一堆 404 和白屏。2.1 安全组与端口先放行再谈安装服务器第一步不是传代码是开端口。文库系统跟普通企业站不一样它除了 80/443还要连数据库、连检索引擎、连对象存储安全组少开一个端口后面排查能耗掉半天。我用过的端口清单大致是这么一张表端口用途是否对公网开放80 / 443Web 访问是22运维登录是但建议限制来源 IP3306MySQL否只在内网或本机9312Sphinx searchd 检索服务否9306SphinxQL可选想用 SQL 语法查索引时开否6379Redis做缓存和队列时否云主机控制台里的操作路径各家略有差别逻辑都是「安全组 → 入方向规则 → 添加规则」把上表里对外的那几个放出去其余全部收在内网网段。上服务器之后先确认监听状态别凭感觉# 看当前对外监听的端口确认 80/443 起来了3306/9312 没暴露在 0.0.0.0 ss -lntp | grep -E :(80|443|3306|9312|6379) # 系统防火墙如果也开着同样要放行CentOS 系 firewall-cmd --permanent --add-port80/tcp firewall-cmd --permanent --add-port443/tcp firewall-cmd --reloadss -lntp里的-l是只看监听态-n不做域名解析、直接显示数字端口-t是 TCP-p带进程名。这一步的意义在于如果 MySQL 监听在0.0.0.0:3306说明它对外可达就算安全组挡住了也多了一层不该有的暴露面我一般会在my.cnf里加bind-address 127.0.0.1收掉。提示端口是否真的通用本机telnet 内网IP 9312验证最直接比翻安全组规则快。2.2 三个子域名一套代码运行目录与伪静态这套系统的站点结构是 PC 站www、手机站m、接口站api。原文里写得很清楚——手机站的运行目录和根目录都与 PC 站相同接口站「此处不用修改配置文件」。翻译成人话就是三份站点配置都指向同一个public目录PHP 入口是同一个index.php靠域名和请求路径做分流。服务器面板里建站的顺序是先建 PC 站绑定www.xxx.com运行目录选public上传程序再建手机站绑定m.xxx.com根目录填成跟 PC 站一模一样的路径最后建接口站绑定api.xxx.com根目录同样复用只是伪静态规则不放行全部交给入口文件路由。Nginx 的站点配置我一般写成这样server { listen 80; server_name www.example.com; root /www/wwwroot/wenku/public; index index.php; # 大文档上传默认 1m 完全不够用 client_max_body_size 512m; location / { # 文件不存在就交给 index.php 做路由 try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { fastcgi_pass unix:/tmp/php-cgi-74.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # 转码触发、大文件上传都容易超时读超时给到 600 秒 fastcgi_read_timeout 600; } # 预览图片和静态资源走长缓存文库站图片量大这个头省很多回源 location ~* \.(jpg|jpeg|png|gif|webp|css|js|woff2)$ { expires 7d; access_log off; } # 转码中间产物和日志绝对不能对外 location ~ ^/(runtime|web\/uploads\/temp)/ { deny all; } }手机站配置只改两处server_name换成m.example.com其余照抄根目录保持一致。接口站api.example.com的location /里不加try_files直接rewrite ^ /index.php?$args last;因为接口不做静态资源全部走路由更干净。try_files $uri $uri/ /index.php?$args这一行是伪静态的核心先按真实文件找再按目录找都没有就把原参数丢给 PHP 入口。文库站的详情页、检索页、下载页都是伪静态 URL不配这行翻页和筛选参数会全部丢失。注意改完配置一定要nginx -t再 reload。我见过直接 reload 之后整个站挂掉、还得回滚配置的情况。2.3 安装向导与配置落点数据库、对象存储、缓存浏览器打开www域名走完安装向导会要求填数据库、站点地址、管理员账号。装完之后真正的配置在common/config/和config/目录下Yii2 的常见做法是拆成params.php和main.php敏感信息放params-local.php不进版本库。配置项按模块分我整理成这张表装完照着对一遍配置块关键项常见落点数据库host、库名、用户、密码、表前缀components.db缓存FileCache 或 Rediscomponents.cache对象存储driver、bucket、endpoint、CDN 域名components.storage短信签名、模板 ID、AccessKeyparams.sms支付商户号、密钥、证书路径、回调地址params.pay第三方登录AppID、AppSecret、回调域名params.oauth公众号/小程序AppID、Secret、Token、EncodingAESKeyparams.wechat配置文件的写法大致长这样?php // common/config/main-local.php示意字段名以安装向导生成的实际文件为准 return [ components [ db [ class yii\db\Connection, dsn mysql:host127.0.0.1;dbnamewenku;port3306, username wenku, password 换成自己的密码, charset utf8mb4, tablePrefix wk_, enableSchemaCache true, // 表结构缓存减少每次请求的元数据查询 ], cache [ class yii\redis\Cache, redis [hostname 127.0.0.1, port 6379, database 0], ], storage [ // 文档原件和转码产物本地存还是对象存储二选一 driver oss, bucket wenku-doc, endpoint oss-cn-xxx.example.com, cdnDomain https://cdn.example.com, localPath webroot/uploads, ], ], ];enableSchemaCache是 Yii2 里很容易被忽略的一项开了之后表结构不再每次请求都查information_schema文库站这种列表页频繁查询的场景提升很明显。tablePrefix一定要跟安装向导建表时填的前缀一致不一致的表现是首页正常、后台一进列表就报「表不存在」。存储这块要注意如果选了对象存储转码产物上传之后本地要留一份或者干脆走 CDN 域名否则预览页会出现「部分用户能看、部分 404」的玄学问题本质是本地缓存没清、CDN 没刷新。目录权限也得顺手处理cd /www/wwwroot/wenku # 运行目录和上传目录归 web 用户所有 chown -R www:www runtime web/uploads # 目录可写文件不可执行 find runtime web/uploads -type d -exec chmod 755 {} \; find runtime web/uploads -type f -exec chmod 644 {} \;find ... -exec chmod这两行的区别是目录要x位才能进去文件不需要执行权限。上传目录给到 777 是血泪教训式的错误做法一旦有文件上传漏洞直接就是可执行脚本落地。3. 核心架构拆解Yii2 分层、文档转码链路与 Sphinx 检索部署只是把人送进门真正决定这套文库系统能不能撑住运营的是文档从上传到可预览的那条链路以及检索能不能在毫秒级给出结果。这两块是整份源码里最值得读的部分。3.1 目录结构一套代码三个应用的 Yii2 组织方式Yii2 高级模板天然是「多应用共享公共层」的结构这套系统在此基础上加了手机站和接口站。我读源码时按应用的边界画了一张表目录职责说明common/公共模型、服务、配置三个站共用改模型只需要改这里frontend/PC 站控制器与视图绑定www域名mobile/手机站控制器与视图绑定m域名视图层独立api/接口模块按v1分版本绑定api域名给小程序和公众号调console/定时任务、转码命令命令行入口cron 调它web/静态资源、上传目录、入口文件三个站共用同一个index.phpvendor/Composer 依赖不提交业务改动这个结构的价值在于栏目、文档、订单、用户这些数据模型放common/modelsPC 站和手机站只是换了视图和部分控制器逻辑。二次开发时最怕的就是「PC 改一遍、手机改一遍」这套分层至少在模型层避免了重复。api应用的设计上我一般会按modules/v1/controllers/组织每个控制器对应一组资源DocumentController管文档详情和下载CategoryController管栏目树PayController管下单和回调。版本号放在模块层将来出v2时老接口不动小程序可以不强制升级。控制器的典型写法是继承yii\rest\ActiveController或自定义基类统一处理鉴权、限流和返回格式。这里有个细节值得注意接口站的鉴权跟 PC 站不一样PC 走 Session接口走 Token。同一份模型代码、两套认证方式靠components.user里换identityClass和enableSession解决不是复制一份模型。3.2 文档转码从上传到可预览的完整链路文库站最核心的一环就是把 doc、docx、ppt、pptx、pdf、txt、zip、mp3、mp4、jpg、png、bmp、gif 这些格式统一成浏览器能直接看的东西。这套系统的做法是转成 H5 或分页图片——原文强调「文字放大无失真、对 SEO 更友好」这基本排除了纯截图方案走的是 PDF 转 HTML 或矢量分页图的路子。完整链路我梳理成四段用户上传原件落到对象存储或本地uploads写document表status置为待转码。定时任务每分钟扫一次待转码队列按扩展名分发给对应的转换器。转换器内部通常是两级Office 系先调文档转换工具转成 PDF再把 PDF 转成 H5 或分页图PDF 直接进第二级图片和音视频走各自的处理分支。转换完成回写 H5 路径、页数、封面图status改成已完成前端才能预览。队列消费的代码我一般这么写?php // console/controllers/ConvertController.php namespace console\controllers; use common\models\Document; use common\services\converter\ConverterFactory; use yii\console\Controller; class ConvertController extends Controller { // 单次最多处理 50 条防止一个大文件把整轮任务卡死 public $limit 50; public function actionRun() { $docs Document::find() -where([status Document::STATUS_WAIT]) -orderBy([id SORT_ASC]) -limit($this-limit) -all(); foreach ($docs as $doc) { // 条件更新当乐观锁用只有还是待转码才抢得到避免多进程重复处理 $affected Document::updateAll( [status Document::STATUS_RUNNING, updated_at time()], [id $doc-id, status Document::STATUS_WAIT] ); if ($affected 0) { continue; } try { // 按扩展名拿转换器office 系 - pdf - h5/图片pdf 直接进二级 $converter ConverterFactory::make($doc-ext); $result $converter-convert($doc-source_path, $doc-id); $doc-updateAttributes([ h5_path $result[h5] ?? null, page_count $result[pages] ?? 0, cover $result[cover] ?? null, status Document::STATUS_DONE, ]); } catch (\Throwable $e) { // 失败计数超过 3 次标记为失败留日志人工排查 $doc-retry_times $doc-retry_times 1; $doc-status $doc-retry_times 3 ? Document::STATUS_FAIL : Document::STATUS_WAIT; $doc-error_msg mb_substr($e-getMessage(), 0, 500); $doc-save(false); \Yii::error($e-getMessage(), convert); } } } }逐段说下逻辑。status是个状态机WAIT待转码、RUNNING处理中、DONE已完成、FAIL失败。updateAll带上status WAIT这个条件等于用数据库做了一次原子的「抢占」两个 cron 进程同时跑也不会重复转同一个文件——这是我踩过重复转码导致存储翻倍的坑之后加上的。limit参数的作用不是省资源是控制单轮时长。一个 200 页的 PPT 转码可能要跑几分钟不设上限的话这一轮跑完下一轮又开始了进程越堆越多。50 条是个经验值机器配置好可以调大。retry_times和error_msg这两个字段很多人建表时会省掉等线上出现「某个文件永远转不出来」时就知道后悔药多少钱了。save(false)里的false是跳过模型验证异常分支里只关心落库不需要再跑一遍 rules。定时任务的配置# 每分钟消费一次转码队列输出重定向到日志 * * * * * /usr/bin/php /www/wwwroot/wenku/yii convert/run /www/wwwlogs/convert.log 21 # 每天凌晨 3 点合并检索增量索引避免新文档搜不到 0 3 * * * /usr/bin/indexer --config /etc/sphinx/sphinx.conf --merge doc_main doc_delta --rotate第一行的/usr/bin/php要写绝对路径而且必须是 CLI 版本。我见过最典型的翻车现场是面板里装的是 PHP 7.4命令行默认的php是系统自带的 5.6cron 一跑就报语法错误日志里全是Parse error而页面上完全看不出问题。提示yii是 Yii2 的命令行入口脚本等价于php yii。所有 console 控制器都能用./yii 控制器/动作的方式手动触发调试转码时先手动跑一遍再交给 cron。3.3 Sphinx 全文检索索引配置与条件组合查询文档量上到几万份之后数据库LIKE %关键词%就会变成整站最慢的查询。这套系统选择 Sphinx 做检索引擎原文说的是「毫秒级、全文检索、条件组合检索」落到实现上就是主索引 增量索引属性字段做过滤全文字段做匹配。索引配置的核心部分# /etc/sphinx/sphinx.conf source doc_main { type mysql sql_host 127.0.0.1 sql_user wenku sql_pass 换成自己的密码 sql_db wenku sql_port 3306 # 只索引已完成转码的文档status 2 对应 DONE sql_query SELECT id, title, description, category_id, pub_year, \ UNIX_TIMESTAMP(created_at) AS created_ts \ FROM wk_document WHERE status 2 # 属性字段可以过滤、可以排序但不参与全文匹配 sql_attr_uint category_id sql_attr_uint pub_year sql_attr_timestamp created_ts # 字符串属性既能过滤也能取回原值 sql_field_string title } index doc_main { source doc_main path /var/lib/sphinx/doc_main min_word_len 1 # 中文没有天然空格靠单字 ngram 切分开索引 ngram_len 1 ngram_chars U4E00..U9FFF charset_table 0..9, A..Z-a..z, a..z, U4E00..U9FFF }sql_attr_uint声明的字段在 Sphinx 里以属性形式存储查询时可以category_id 12这样过滤不用回 MySQL 再筛一遍这是「条件组合检索」快的根本原因。sql_field_string则把标题原文也存进索引命中后直接展示省一次回表。中文处理是 Sphinx 的经典难点。ngram_len 1配合ngram_chars指定中文区间等于把中文按单字建索引查询时短语再按同样规则切分。这种方案的召回率高但索引体积会明显变大——十万份文档的主索引跑到几个 G 很正常磁盘要提前规划。PHP 侧的查询封装?php // common/services/SearchService.php namespace common\services; use yii\base\Component; class SearchService extends Component { public $host 127.0.0.1; public $port 9312; public $index doc_main; public function search($keyword, $categoryId 0, $year 0, $page 1, $size 20) { $cl new \SphinxClient(); $cl-SetServer($this-host, $this-port); $cl-SetMatchMode(SPH_MATCH_EXTENDED2); // 分页offset、limit、max_matches超过 1000 条结果一般也没人翻 $cl-SetLimits(($page - 1) * $size, $size, 1000); // 标题命中的权重远高于描述 $cl-SetFieldWeights([title 10, description 1]); $cl-SetSortMode(SPH_SORT_EXTENDED, weight DESC, created_ts DESC); $query $cl-EscapeString($keyword); if ($categoryId 0) { // 栏目过滤放在检索引擎侧完成 $query . category_id {$categoryId}; } if ($year 0) { $query . pub_year {$year}; } $res $cl-Query($query, $this-index); if ($res false) { // 检索引擎挂了不能白屏降级走数据库 LIKE return $this-fallbackLike($keyword, $categoryId, $page, $size); } return [ total $res[total_found] ?? 0, ids array_keys($res[matches] ?? []), time $res[time] ?? 0, ]; } }SetMatchMode(SPH_MATCH_EXTENDED2)是扩展匹配模式支持字段 值这种语法上面拼category_id就靠它。SetFieldWeights让标题命中排在前面文库站用户搜的基本都是标题里的关键词权重不调的话正文里提一句的文档会挤掉真正想要的。EscapeString不能省用户输入里带、-、|这些扩展语法符号不转义会直接解析成查询条件轻则结果为空重则报错。最下面那个fallbackLike是被逼出来的检索引擎进程一挂整个搜索页就 500运营那边电话能打爆。降级查 MySQL 慢是慢但至少页面还在。增量索引的机制也补一句。主索引全量重建很慢新文档进不来。标准做法是建一个doc_delta源条件带WHERE id $max_doc_id定时重建增量索引并合并进主索引——上面 cron 里那条--merge就是干这个的。合并完还要--rotate让 searchd 平滑重载不中断服务。4. 运营侧模块支付回调、微信生态与栏目数据模型文库系统跟普通 CMS 最大的差别是它得真金白银地跑起来。支付能不能正确发货、公众号能不能把用户沉淀下来、栏目和用户等级能不能撑住运营策略这三块决定了这套系统是「能看」还是「能赚」。4.1 支付链路从下单到回调发货的完整闭环支付这块最容易出事。下单简单难的是回调——网络抖动、网关重试、用户重复点支付任何一个环节没处理好就是重复发货或者付了钱不给权限两种都是投诉。链路我拆成四步用户在文档详情页点购买服务端创建订单order_no用「时间戳 用户 ID 随机数」生成状态置为待支付。调支付网关的统一下单接口拿到支付参数返回前端唤起。网关异步回调notify地址验签通过后置订单为已支付写下载授权。返回网关约定的成功标识否则网关会按自己的策略重试。回调处理的代码?php // api/modules/v1/controllers/PayController.php public function actionNotify($channel) { $raw Yii::$app-request-getRawBody(); $data $channel wxpay ? $this-parseWxNotify($raw) : $this-parseAliNotify(Yii::$app-request-post()); // 1. 验签不过直接拒别往下走 if (!$this-verify($channel, $data)) { Yii::warning(sign fail: . $raw, pay); return fail; } $tx Yii::$app-db-beginTransaction(); try { // 2. forUpdate 加行锁同一订单的并发回调排队进来 $order Order::find() -where([order_no $data[out_trade_no]]) -forUpdate() -one(); if (!$order) { throw new \Exception(order not found: . $data[out_trade_no]); } // 3. 幂等已支付就直接返回成功不重复发货 if ($order-status Order::STATUS_PAID) { $tx-commit(); return success; } $order-status Order::STATUS_PAID; $order-pay_channel $channel; $order-trade_no $data[transaction_id]; $order-paid_at time(); $order-save(false); // 4. 发货写下载授权、加积分、触发分销佣金 (new DeliveryService())-deliver($order); $tx-commit(); return success; } catch (\Throwable $e) { $tx-rollBack(); Yii::error($e-getMessage(), pay); // 返回 fail 让网关重试别吞掉 return fail; } }forUpdate()生成的是SELECT ... FOR UPDATE在事务里锁住这一行两个回调同时进来会串行处理。这是防重复发货最可靠的一层光靠if ($order-status PAID)判断挡不住并发——两个请求可能同时读到「待支付」。return success和return fail的语义要分清返回 success网关认为处理完成不再重试返回 fail网关会按自己的节奏重推。所以「订单不存在」这种可重试的情况返回 fail而「已支付」这种幂等情况必须返回 success否则网关一直重推日志被刷屏。DeliveryService单独抽出来是有意为之PC 站和接口站下单都走同一套发货逻辑后面要加「买一送一」「限时折扣」这类运营规则只改这一个类。注意支付证书和密钥不要写在代码仓库里。我一般放runtime/cert/目录权限 700配置里写相对路径。4.2 微信生态接入公众号、小程序与第三方登录的配置项原文里有一长串配置入口公众号设置、微信开放平台设置、第三方登录配置、短信配置、导航设置。这些在后台都是以键值对形式存的落到代码里就是一张config表或者一组params。我梳理出的配置项清单配置项用途容易填错的地方公众号 AppID / AppSecret网页授权、模板消息、菜单Secret 重置过要同步更新公众号 Token / EncodingAESKey消息回调校验与加解密Token 必须与后台填写完全一致开放平台 AppID网站扫码登录、UnionID 打通跟公众号 AppID 不是一个小程序 AppID / Secret小程序登录、内容跳转小程序不能直接复用公众号 Secret服务器地址 URL微信回调入口必须 80/443且能通过校验JS 接口安全域名H5 内唤起微信能力不带协议头改完要重新授权公众号网页授权的流程是用户点「微信登录」→ 跳授权页 → 拿code→ 服务端用code换access_token和openid→ 如果绑定了开放平台还能拿到unionid→ 查本地是否已有绑定没有就自动注册。关键点在于unionid。只有公众号和小程序绑在同一个开放平台账号下同一个用户才会有一致的unionid。不绑开放平台公众号一个openid、小程序另一个openid同一用户在两边会被认成两个人私域流量池就成了两个漏水的桶。这是我在做用户体系打通时踩过的坑配置阶段就得想清楚。用户表的设计上我一般会加openid_mp、openid_mini、unionid三个字段登录时优先按unionid匹配回退到各端的openid最后才按手机号。4.3 栏目、导航与用户等级数据模型怎么设计栏目层级、导航、Banner、用户等级这几块是运营配置最频繁的地方模型设计直接影响后台好不好用。栏目表我倾向用「路径枚举」而不是纯parent_id递归CREATE TABLE wk_category ( id int unsigned NOT NULL AUTO_INCREMENT, pid int unsigned NOT NULL DEFAULT 0 COMMENT 父级ID, name varchar(60) NOT NULL COMMENT 栏目名, path varchar(255) NOT NULL DEFAULT 0 COMMENT 祖先链如 0-1-5, level tinyint unsigned NOT NULL DEFAULT 1 COMMENT 层级, doc_count int unsigned NOT NULL DEFAULT 0 COMMENT 文档数冗余字段, sort int NOT NULL DEFAULT 100 COMMENT 排序越小越前, status tinyint NOT NULL DEFAULT 1 COMMENT 1显示 0隐藏, PRIMARY KEY (id), KEY idx_pid_sort (pid,sort), KEY idx_path (path(64)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文档栏目;path这个字段是关键。新增栏目时path 父级.path - 自己的 id。查某个栏目下所有子孙栏目-- 取行业报告及其所有下级栏目下的已转码文档 SELECT d.id, d.title, d.cover FROM wk_document d JOIN wk_category c ON c.id d.category_id WHERE c.path LIKE 0-1-% AND d.status 2 ORDER BY d.created_at DESC LIMIT 20;一条 SQL 搞定不用递归也不用多次查询。LIKE 0-1-%前面的部分有索引可用前缀匹配比%1%全表扫好太多。缺点是移动栏目时要批量更新子孙的path但栏目迁移是低频操作用一次批量UPDATE换掉高频查询的递归划算。用户等级表则是运营的抓手CREATE TABLE wk_user_level ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(30) NOT NULL COMMENT 等级名, min_points int unsigned NOT NULL DEFAULT 0 COMMENT 积分下限, download_per_day int unsigned NOT NULL DEFAULT 3 COMMENT 每日免费下载数, discount decimal(4,2) NOT NULL DEFAULT 1.00 COMMENT 购买折扣, PRIMARY KEY (id), KEY idx_min_points (min_points) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户等级;等级判断只查一次SELECT * FROM wk_user_level WHERE min_points :points ORDER BY min_points DESC LIMIT 1取到的就是当前等级。下载限额则要在doc_download_log里按天统计配合缓存不然每次下载都 count 一次日志表量大之后很慢。5. 避坑与排查上线后最容易翻车的五个点前面几章讲的都是「照着做应该能跑」实际环境里没有一次是顺利的。下面这几条是我和同行交流时反复被提到的基本覆盖了上线首周 80% 的故障。5.1 部署与转码类问题现象一文档一直显示「转码中」队列不动。原因通常是 cron 没配或者配了但 PHP 版本不对。面板里装的是新版 PHP命令行默认的php还是系统老版本yii脚本一执行就报语法错误而且因为输出重定向到日志面板上看不出任何提示。解决分三步which php和php -v确认 CLI 版本用绝对路径/usr/bin/php /path/to/yii convert/run手动跑一遍看报错确认runtime目录对执行用户可写。手动能跑、cron 不跑就去看crontab -l是不是配在了别的用户下。现象二上传大文件报 413 或 502。413 是 Nginx 拦的client_max_body_size默认 1m502 是 PHP-FPM 处理时间太长被打断或者 PHP 把文件大小限制卡住了。文库站动不动就是几十兆的 PPT 和 PDF这几处必须一起调Nginx 的client_max_body_size、fastcgi_read_timeoutPHP 的upload_max_filesize、post_max_size、max_execution_time。四处缺一处都会失败而且报错位置不一样很容易只改了一处就以为解决了。现象三新发的文档搜不到老的能搜到。这是增量索引没合并。Sphinx 的主索引是静态的新数据必须走增量索引再 merge 进去。检查sphinx.conf里是否配了doc_delta源cron 里是否有--merge ... --rotate。还有个更隐蔽的情况Delta 源的sql_query_pre里记录的max_doc_id没更新导致增量永远是空的。5.2 业务与配置类问题现象四用户付了钱下载权限没到账或者同一笔订单被发了两次。根因基本都是回调没做幂等。表现是用户投诉「付了两次钱」或者「付了钱还得再付」。查日志会发现同一个out_trade_no进来好几次每次都跑了发货逻辑。解决办法就是 4.1 里那套forUpdate加行锁、状态判断、事务包裹、已支付直接返回 success。另外把回调的原始报文完整落日志出了问题能拿着transaction_id去支付平台后台对账。现象五PC 站登录了手机站还是未登录或者 API 站一直 401。三个子域名各自为政Session Cookie 的域没统一成.example.com浏览器认为它们是三个不同的站。另外接口站默认走 Token 鉴权、不开 Session用 PC 站的登录态去调接口当然过不去。解决把session.cookieDomain配成带前导点的顶级域Session 存到 Redis 共享接口站单独走 Token登录接口返回 Token前端存起来带在 Header 里。这两种认证方式并存没问题但要让前端清楚哪个页面用哪种。提示排查这类问题时浏览器开发者工具里看一眼 Cookie 的 Domain 字段比翻配置文件快得多。6. 二次开发给文档加一个自定义字段的完整走法二次开发最常见的需求不是改页面是加字段。运营跑一段时间后总会提「我想按资料年份筛选」「我想给文档打个密级标签」。拿「资料年份」这个需求走一遍完整流程后面加任何字段都是同一套动作。第一步走迁移别直接改数据库?php // console/migrations/m250101_000001_add_pub_year_to_document.php use yii\db\Migration; class m250101_000001_add_pub_year_to_document extends Migration { public function safeUp() { $this-addColumn({{%document}}, pub_year, $this-smallInteger()-defaultValue(0)-comment(资料年份)); // 列表页和筛选都按这个字段走加索引 $this-createIndex(idx_pub_year, {{%document}}, pub_year); } public function safeDown() { $this-dropIndex(idx_pub_year, {{%document}}); $this-dropColumn({{%document}}, pub_year); } }safeUp/safeDown是 Yii2 迁移的约定后者让回滚可用。{{%document}}里的%会被自动替换成配置的表前缀写死wk_document的项目换前缀时会全线崩。执行cd /www/wwwroot/wenku ./yii migrate --interactive0第二步改模型加验证规则和筛选场景// common/models/DocumentSearch.php 片段 public function rules() { return [ [[title, category_id], safe], [pub_year, integer, min 1990, max 2100], [pub_year, default, value 0], ]; } public function search($params) { $query Document::find()-where([status Document::STATUS_DONE]); $this-load($params); if (!$this-validate()) { return $query-where(10); } // 年份精确筛选0 表示不限 $query-andFilterWhere([pub_year $this-pub_year]); $query-andFilterWhere([like, title, $this-title]); return $query; }andFilterWhere的好处是值为空时自动忽略条件不用手写一堆if。validate()失败时返回where(10)是个小技巧参数非法时返回空结果而不是全表避免用户乱传参把库拖垮。第三步是检索侧。Sphinx 配置里加一行sql_attr_uint pub_year重建索引查询时按 3.3 里的方式拼pub_year 2024。注意属性字段必须先重建索引才生效改完配置不重建筛选永远是空。第四步验证我一般按这个顺序走一遍验证项操作预期迁移./yii migrate表结构多出pub_year和索引后台录入编辑文档填年份保存成功列表显示前台筛选按年份筛结果只含该年份文档检索关键词 年份组合Sphinx 返回交集不超时回滚./yii migrate/down 1字段和索引都消失这张表走完一个字段才算真正落地而不是「后台能填了但前台用不上」。最后说个习惯。早些年我给一个文库站加「下载次数」字段图省事直接在生产库ALTER TABLE模型里也加了字段但忘了加验证规则结果后台批量导入时传了个空值进去整个列表页按下载次数排序时全乱用户看到的是随机顺序。从那以后我每次加字段都强制走一遍「迁移 模型规则 索引 检索配置 回滚验证」一步都不跳。这套流程看着慢但比事后捞数据快太多了。希望帮到你。本文还有配套的精品资源点击获取