☰
PHP文库系统源码:多站点部署与Sphinx全文检索实践
2026/11/3 21:57:28 网站建设 项目流程

简介:面向计划搭建或运营文库网站的开发者与站长,知沃文库系统基于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运维登录是,但建议限制来源 IP
3306MySQL否,只在内网或本机
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-port=80/tcp firewall-cmd --permanent --add-port=443/tcp firewall-cmd --reload

ss -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:host=127.0.0.1;dbname=wenku;port=3306', '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.php
vendor/Composer 依赖不提交业务改动

这个结构的价值在于:栏目、文档、订单、用户这些数据模型放common/models,PC 站和手机站只是换了视图和部分控制器逻辑。二次开发时最怕的就是「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 或矢量分页图的路子。

完整链路我梳理成四段:

  1. 用户上传,原件落到对象存储或本地uploads,写document表,status置为待转码。
  2. 定时任务每分钟扫一次待转码队列,按扩展名分发给对应的转换器。
  3. 转换器内部通常是两级:Office 系先调文档转换工具转成 PDF,再把 PDF 转成 H5 或分页图;PDF 直接进第二级;图片和音视频走各自的处理分支。
  4. 转换完成回写 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 2>&1 # 每天凌晨 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.6,cron 一跑就报语法错误,日志里全是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 = U+4E00..U+9FFF charset_table = 0..9, A..Z->a..z, a..z, U+4E00..U+9FFF }

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 支付链路:从下单到回调发货的完整闭环

支付这块最容易出事。下单简单,难的是回调——网络抖动、网关重试、用户重复点支付,任何一个环节没处理好,就是重复发货或者付了钱不给权限,两种都是投诉。

链路我拆成四步:

  1. 用户在文档详情页点购买,服务端创建订单,order_no用「时间戳 + 用户 ID + 随机数」生成,状态置为待支付。
  2. 调支付网关的统一下单接口,拿到支付参数返回前端唤起。
  3. 网关异步回调notify地址,验签通过后置订单为已支付,写下载授权。
  4. 返回网关约定的成功标识,否则网关会按自己的策略重试。

回调处理的代码:

<?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)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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默认 1m;502 是 PHP-FPM 处理时间太长被打断,或者 PHP 把文件大小限制卡住了。文库站动不动就是几十兆的 PPT 和 PDF,这几处必须一起调:Nginx 的client_max_body_size、fastcgi_read_timeout,PHP 的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 --interactive=0

第二步改模型,加验证规则和筛选场景:

// 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('1=0'); } // 年份精确筛选,0 表示不限 $query->andFilterWhere(['pub_year' => $this->pub_year]); $query->andFilterWhere(['like', 'title', $this->title]); return $query; }

andFilterWhere的好处是值为空时自动忽略条件,不用手写一堆if。validate()失败时返回where('1=0')是个小技巧,参数非法时返回空结果而不是全表,避免用户乱传参把库拖垮。

第三步是检索侧。Sphinx 配置里加一行sql_attr_uint = pub_year,重建索引,查询时按 3.3 里的方式拼@pub_year 2024。注意属性字段必须先重建索引才生效,改完配置不重建,筛选永远是空。

第四步验证,我一般按这个顺序走一遍:

验证项操作预期
迁移./yii migrate表结构多出pub_year和索引
后台录入编辑文档填年份保存成功,列表显示
前台筛选按年份筛结果只含该年份文档
检索关键词 + 年份组合Sphinx 返回交集,不超时
回滚./yii migrate/down 1字段和索引都消失

这张表走完,一个字段才算真正落地,而不是「后台能填了但前台用不上」。

最后说个习惯。早些年我给一个文库站加「下载次数」字段,图省事直接在生产库ALTER TABLE,模型里也加了字段但忘了加验证规则,结果后台批量导入时传了个空值进去,整个列表页按下载次数排序时全乱,用户看到的是随机顺序。从那以后我每次加字段都强制走一遍「迁移 + 模型规则 + 索引 + 检索配置 + 回滚验证」,一步都不跳。这套流程看着慢,但比事后捞数据快太多了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询