2026/11/5 8:06:44

IPTV后台管理系统部署与优化:EPG、m3u导入、接口签名缓存与源巡检

IPTV后台管理系统部署与优化:EPG、m3u导入、接口签名缓存与源巡检 简介面向IPTV后台开发与二次开发学习者的双子星IPTV后台管理系统PHP源码包适合研究后台系统代码组织、数据库测试数据以及PHP运行环境搭配的初中级开发者参考。压缩包约31.82MB主体包含PHP源码、数据库测试文件并附带VC9-32位、VC11-32位运行库压缩包运行需自备phpStudy等PHP环境资源页未标注有效文件总数文件类型明细亦未展开。已有2083人学习或下载说明该套后台源码在相关学习中具有一定关注度。读者可从中获得后台系统源码、数据库测试文件与依赖组件便于对照PHP版本和VC运行库兼容性为后续代码阅读、二次开发、功能梳理及环境排错提供基础材料。1. 拿到「双子星」IPTV 后台管理系统压缩包先别急着上传后台管理系统的价值全在数据结构上不在界面上。一个能跑的 IPTV 后台本质上要同时回答三个问题频道、分类、源地址怎么组织EPG 节目单怎么和频道对齐客户端拉播放列表时怎么鉴权、怎么不给数据库压力。这套「双子星」IPTV 后台管理系统就是围绕这三件事搭的一套 PHP 后台负责增删改查一套接口层负责下发 m3u8 地址与节目单中间夹着用户、授权码和源状态标记。适合谁手里已经有一批 m3u 源、想从「记事本维护 txt」升级到「多人协同 接口下发」的运维或全栈以及想拆一套完整 CRUD 接口 定时任务练手的人。不适合指望开箱即播的人——这类系统从来只管「管」不管「源从哪来」。2. 环境与目录把包跑起来前的四道检查解压之后第一件事不是找 install 页面而是先扫一遍目录把「这是个什么年代的系统」判断清楚。我一般会先看有没有 composer.json、有没有 .env、public 是不是唯一入口这三个信息基本决定了后面要走多少弯路。2.1 运行环境与扩展清单这类系统的版本敏感度比想象中高。PHP 7.2 到 7.4 这一档是最常见的落点跳到 8.x 之后很多老代码里的each()、动态属性、以及老版本框架的容器写法会集体报错。判断方法很直接看包里的 composer.json 里的require段以及有没有一个public/install.php之类的环境检测页。组件常见可用版本为什么是这个装错的典型现象PHP7.2 / 7.3 / 7.4老框架的容器与 ORM 写法在这一档最兼容8.x 下白屏日志里是Cannot use parent之类的语法错MySQL5.7 / 8.0utf8mb4 与 JSON 字段都够用5.6 下建表报索引长度超限Nginx1.18 以上只需要 pathinfo 重写与 fastcgi 超时后台首页能开点进列表页 404Redis5.0 以上可选播放列表接口缓存没它也能跑但压测差距明显—PHP 扩展pdo_mysql、curl、mbstring、json、openssl、simplexml、fileinfo、redisXMLTV 解析要 simplexml抓源要 curl中文频道名要 mbstringEPG 导入报「无法解析 XML」多半是缺 simplexml先把扩展这件事做实别等到导入 EPG 才回头查# 检查关键扩展是否齐全缺哪个补哪个 php -v php -m | grep -Ei pdo_mysql|curl|mbstring|simplexml|fileinfo|redis|openssl输出里没有simplexml就去装php7.4-xml多数发行版把它和 dom、xmlreader 打在一个包里没有mbstring会导致频道名截断成乱码——这个坑非常隐蔽因为列表页看起来正常只有导出 CSV 时才炸。提示如果包里带.env.example先复制成.env再改别直接编辑示例文件。后面想回滚配置时示例文件是唯一的后悔药。2.2 目录结构与唯一对外入口把这包解开结构大致长这样不同分支会有出入但职责划分基本一致/www/tv/ ├── app/ # 控制器、模型、服务层业务逻辑都在这 ├── config/ # 数据库、缓存、接口密钥配置 ├── public/ # Web 根目录唯一应该暴露给 Nginx 的目录 │ ├── index.php # 入口文件 │ ├── static/ # 后台前端资源 │ └── uploads/ # 台标、海报等上传文件 ├── runtime/ # 日志、模板编译缓存必须可写 ├── data/ # 导入导出文件、EPG 原始 XML 落盘 └── .env两个判断要点Web 根目录必须指到public/指到项目根目录等于把.env、data/里的备份 SQL 一起送出去runtime/和public/uploads/必须给 PHP-FPM 的运行用户写权限不给的话表现是首页 500 且runtime/log里空空如也——因为它连日志都写不进去。# 权限一次做对省掉后面反复 chmod 的来回 cd /www/tv cp .env.example .env chown -R www:www runtime public/uploads data chmod -R 775 runtime public/uploads data chmod 640 .env # 配置文件不给其他用户读chmod 640 .env这一步很多人跳过。配置文件里有数据库密码和接口签名密钥一旦哪天目录被误配成可列目录整个后台就等于裸奔。2.3 从建库到登录后台六步落地整个安装流程最容易被低估的是数据库字符集。频道名里带生僻字、台标文件名带中文、EPG 里有书名号——这些在 latin1 或 utf8三字节下会变成问号而且是在写入阶段就损坏事后改字符集救不回来。# 1) 建库字符集一次到位 mysql -uroot -p -e CREATE DATABASE tv_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 2) 导入结构如果是 install.sql data.sql 两个文件注意顺序 mysql -uroot -p tv_admin data/install.sql # 3) 建一个只对 tv_admin 有权限的账号别用 root 跑业务 mysql -uroot -p -e CREATE USER tvapp127.0.0.1 IDENTIFIED BY 换成自己的强密码; mysql -uroot -p -e GRANT SELECT,INSERT,UPDATE,DELETE ON tv_admin.* TO tvapp127.0.0.1; mysql -uroot -p -e FLUSH PRIVILEGES; # 4) 写配置数据库、缓存、接口密钥 # 改完执行一次语法检查比在浏览器里看白屏快得多 php -l config/database.phpNginx 侧的重写规则是第二个高频翻车点。后台能打开首页但点任何菜单都 404八成是 pathinfo 没配对server { listen 80; server_name tv.example.internal; # 先挂内网域名验证完再考虑对外 root /www/tv/public; index index.php; location / { # 后台多数路由走 index.php?s/xxx/yyy 这种形式两条都留着更保险 try_files $uri $uri/ /index.php?s$uri$args; } location ~ \.php$ { fastcgi_pass unix:/run/php/php7.4-fpm.sock; # 和 php -v 的版本对齐 fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_read_timeout 120s; # EPG 抓取是长耗时任务默认 60s 会被掐断 } # 这些后缀绝对不能被直接下载 location ~* \.(sql|log|env|bak|zip)$ { deny all; } client_max_body_size 32m; # 台标包、EPG XML 上传会超默认 1M }fastcgi_read_timeout和client_max_body_size这两行是踩出来的前者不对点「立即抓取 EPG」会转圈到 60 秒然后报 504但后台其实还在跑导致你以为失败了又点一次最后同一份数据被写三遍后者不对上传 20MB 的 XMLTV 直接 413报错信息还很不明显。最后验证登录。别在浏览器里盲试先用 curl 确认接口层通了# 200 且返回 JSON 说明重写、PHP、数据库三关都过了 curl -i -s http://tv.example.internal/admin/login/index | head -20 # 顺手确认 install 入口已经被挡掉 curl -o /dev/null -s -w %{http_code}\n http://tv.example.internal/install.php第二条返回 404 或 403 才是对的。如果返回 200说明安装页还在任何人访问都能重装一遍数据库——这是最该在上线前处理掉的问题。3. 频道与 EPG把散装 m3u 变成可查询的结构化数据后台真正的资产是表结构界面只是壳。搞清楚「频道、分类、源、节目单」这四者怎么关联后面无论是换前端、接第三方客户端还是自己写巡检脚本都不会被卡住。3.1 四张核心表的分工按这类系统的通行设计核心表大致是这么分的表职责关键字段设计上的取舍channel频道主档一个频道一行id、name、category_id、epg_id、logo、statusepg_id是与节目单对齐的唯一纽带必须允许为空channel_category分类决定客户端侧边栏顺序id、name、sort、statussort用整数递增别用时间排序否则运营一改顺序就乱source源地址一个频道可挂多条id、channel_id、url、weight、status、last_check一频道多源是这个系统的核心价值单源设计等于没有容灾epg_program节目单按频道 时间存id、epg_id、title、start_at、end_at时间统一存 UTC 或统一存本地时间二选一绝不能混source表里weight和last_check这两个字段值得单独说。weight决定客户端拿到的源顺序通常权重高的排前面last_check是巡检脚本回写的时间戳接口层拿它做一次过滤——超过 N 小时没被巡检过的源权重自动降一档。没有这两个字段多源就是摆设用户照样会撞上死源。epg_program的时间字段是最容易埋雷的地方。如果抓回来的 XMLTV 是0800而服务器时区是 UTC入库时不转换节目单会整体错 8 小时表现为「现在播的」和实际完全对不上。我一般的做法是入库统一转成 UTC 存datetime展示层再按用户时区渲染这样换服务器时区也不会出事。3.2 m3u 批量导入先转 CSV 再进库后台一般都有「导入 m3u」的入口但我不建议直接把几百 K 的 m3u 丢进去。原因很实际导入失败时你看不到是哪一行坏的只能整批重来。先转成 CSV人能看、能改、能只导一部分。# m3u - csv把 #EXTINF 上的属性和下一行的地址抽出来 import re, csv ATTR re.compile(r([\w-])([^]*)) def parse_m3u(path): rows, cur [], None with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue if line.startswith(#EXTINF): meta dict(ATTR.findall(line)) # 逗号后面才是显示名前面的属性区不能一起切 name line.split(,, 1)[1] if , in line else cur { name: name.strip(), group: meta.get(group-title, 未分组), tvg_id: meta.get(tvg-id, ), logo: meta.get(tvg-logo, ), } elif not line.startswith(#) and cur: cur[url] line # 地址单独一列方便后面批量探活 rows.append(cur) cur None # 置空防止一行 #EXTINF 吃掉多个地址 return rows rows parse_m3u(raw.m3u) with open(channels.csv, w, newline, encodingutf-8-sig) as f: w csv.DictWriter(f, fieldnames[name, group, tvg_id, logo, url]) w.writeheader() w.writerows(rows) print(f解析出 {len(rows)} 条去重前) # 按 name url 去重重复导入同一份 m3u 时不会翻倍 seen, uniq set(), [] for r in rows: k (r[name], r[url]) if k not in seen: seen.add(k) uniq.append(r)几个参数上的细节值得说清楚。encodingutf-8-sig是为了让 Excel 打开不乱码——带 BOM 的 CSV 在 Windows 上双击就能看纯 utf-8 会被识别成 ANSI。cur None那行看着多余实际很关键如果 m3u 里混了注释行或者空地址不置空的话下一个地址会覆盖上一条导致频道数凭空少一截。去重键用name url而不是只用name因为同一个频道挂多条源是常态只用名字去重会把多源直接砍成单源。CSV 出来后导入前再跑一遍字符检查把控制字符和多余空格清掉# 找出含不可见控制字符的行这些行导入后频道名会显示成方块 grep -nP [\x00-\x08\x0B\x0C\x0E-\x1F] channels.csv | head # 去掉字段首尾空格后台一般不做 trim会把 体育 当成新分类 sed -i s/ *, */,/g; s/^ *//; s/ *$// channels.csv导入顺序也有讲究先导分类再导频道最后导源地址。反过来的话源表会因为channel_id不存在而被外键挡掉或者更糟——外键没建脏数据静默写进去了等到客户端拉列表时才发现一堆空频道。导完立刻做一次数量核对三个数对不上就别往下走-- 导入后的三方核对分类数、频道数、源数 SELECT (SELECT COUNT(*) FROM channel_category) AS cats, (SELECT COUNT(*) FROM channel) AS chans, (SELECT COUNT(*) FROM source) AS srcs, -- 没有源的频道就是黑洞用户点进去必然失败 (SELECT COUNT(*) FROM channel c WHERE NOT EXISTS (SELECT 1 FROM source s WHERE s.channel_id c.id)) AS orphan;orphan大于 0 说明有频道根本没有源。这类频道留在库里是有害的——接口会照常下发客户端照常显示用户点开就是黑屏然后来投诉「你的系统坏了」。3.3 EPG 抓取与 tvg-id 对齐EPG 这一步的难点不在抓在对齐。XMLTV 里的频道用channel idxxx标识节目用programme channelxxx引用而后台channel表的epg_id字段就是接这个xxx的。三者对不上节目单就是空的。先看解析逻辑注意时间格式import xml.etree.ElementTree as ET from datetime import datetime, timezone, timedelta def parse_xmltv_time(s): # XMLTV 时间形如 20240512193000 0800空格前是时间空格后是时区偏移 t, tz s.split( ) dt datetime.strptime(t, %Y%m%d%H%M%S) sign 1 if tz[0] else -1 offset sign * (int(tz[1:3]) * 60 int(tz[3:5])) # 统一折算成 UTC 再入库展示层再转本地避免服务器换时区后整体错位 return dt.replace(tzinfotimezone(timedelta(minutesoffset))).astimezone(timezone.utc) def load_programmes(path): tree ET.parse(path) root tree.getroot() out [] for p in root.findall(programme): out.append({ epg_id: p.get(channel), title: (p.findtext(title) or ).strip(), start_at: parse_xmltv_time(p.get(start)), end_at: parse_xmltv_time(p.get(stop)), }) return outparse_xmltv_time里那个tzinfo替换不能省。直接datetime.strptime出来的是「无时区」的裸时间后面一旦有代码对它做astimezonePython 会按服务器本地时区猜猜错就是 8 小时偏移。这个 bug 特别阴因为白天测不出来只有跨零点看节目单才发现「昨天的不见了」。对齐则要分两步走先按epg_id精确匹配匹配不上的再按频道名做相似度兜底。import difflib def build_map(channels, epg_ids): # channels: [(channel_id, name, epg_id)]epg_ids: XMLTV 里出现的全部 id m {} for cid, name, eid in channels: if eid and eid in epg_ids: m[cid] eid # 精确匹配优先最可靠 continue hit difflib.get_close_matches(name, list(epg_ids), n1, cutoff0.85) if hit: m[cid] hit[0] # 兜底匹配命中率看命名规范程度 return mcutoff0.85是试出来的经验值。放到 0.7会把「综合频道」和「综合频道高清」配成一对节目单张冠李戴收到 0.95稍微带个空格或全半角差异就匹配不上命中率掉一大截。0.85 到 0.9 这个区间在中文频道名上比较贴合实际。兜底匹配出来的结果一定要人工抽检别全信——自动匹配错位比没有节目单更让人困惑用户看到的是「节目单有但内容和频道对不上」这种问题比空白难排查得多。抓取任务本身建议放到后台的定时任务里别用浏览器点。抓一次全量 XMLTV 动辄几十 MB走 Web 请求既容易被超时掐断也没法断点续跑# 放到 crontab每 6 小时抓一次日志留 7 天 0 */6 * * * cd /www/tv php think epg:fetch --days3 runtime/log/epg.log 21 # 日志按天切不然一个月后 log 目录能撑到几个 G 0 3 * * * find /www/tv/runtime/log -name epg.log.* -mtime 7 -delete--days3这类参数的意思是只抓未来三天的节目够客户端展示用也避免一次写几十万行。抓完记得看一下落库行数和「无节目频道数」后者持续上涨通常意味着源站的tvg-id规范变了。4. 播放列表接口返回结构、签名校验与缓存压测后台是给自己人用的接口是给客户端用的。这两部分的设计目标完全不同后台要功能全接口要快、要小、要抗住并发。很多人的系统在后台点着很流畅一上量就崩问题基本都出在这一层。4.1 接口返回长什么样先定结构再写代码。这类系统给客户端的播放列表接口返回大体是「分类 → 频道 → 源列表」三层中间塞上 EPG 的当前节目和下一个节目让客户端不用二次请求{ code: 0, msg: ok, data: { server_time: 1715500800, categories: [ { id: 1, name: 综合频道, channels: [ { id: 101, name: 综合频道01, logo: https://tv.example.internal/uploads/logo/101.png, epg: { now: 晚间新闻, next: 天气预报 }, sources: [ { url: https://源站地址/101/index.m3u8, weight: 100 } ] } ] } ] } }几个设计取舍值得说明。server_time一定要带客户端本地时间可能差几分钟甚至几小时靠它校正签名有效期和节目单高亮否则用户手机上「正在播」会一直标错。sources返回数组而不是单个字符串是为了让客户端能做失败切换——第一条连不上自动试第二条这个能力比什么都重要。epg只给当前和下一档别把整天的节目单塞进列表接口那是另一个接口的事混在一起会让响应体从几十 KB 涨到几 MB。接口的另一个常见误区是「一次返回全部频道」。频道数上千时首屏要等好几秒。更合理的做法是分类列表单独一个接口进某个分类才拉该分类的频道。列表接口只返回分类名和频道数量体积能压到几 KB。4.2 签名校验把 token 从明文换成 HMAC如果包里的接口用的是「URL 里带一个固定 token」第一件事就是换成带时间戳的签名。固定 token 一旦泄露等于永久授权而且日志里全是明文谁翻日志谁都能用。?php // 客户端每次请求带 uid、ts、sign 三个参数 // sign hash_hmac(sha256, uid . | . ts, secret) function checkSign(array $q, string $secret, int $window 300): bool { if (empty($q[uid]) || empty($q[ts]) || empty($q[sign])) { return false; } // 1) 时间窗超过 5 分钟的请求直接拒绝抓包拿到也没法长期重放 if (abs(time() - (int)$q[ts]) $window) { return false; } // 2) 服务端用同样的拼接顺序和密钥重算一遍 $expect hash_hmac(sha256, $q[uid] . | . $q[ts], $secret); // 3) 必须用 hash_equals不能写 // 定长比较可以避免逐字节比较带来的时序差异 return hash_equals($expect, $q[sign]); }三个参数都有讲究。$window 300是容忍度太小会误杀时钟偏快的设备太大会放大重放窗口五分钟是个常见折中。拼接顺序uid|ts必须和客户端一字不差中间那个|不能省——省了之后uid12, ts3和uid1, ts23会算出同一个串是可以构造的碰撞。hash_equals不是洁癖在 PHP 里遇到0e123开头的哈希还会走数字比较这是历史上真实出过事的一类问题。密钥放在.env里别写进代码也别和数据库密码用同一个。更换密钥时要做双密钥过渡新旧同时有效一段时间让客户端逐步升级直接一刀切会让所有在线设备同时掉线。4.3 缓存分层与压测基线接口的性能瓶颈几乎永远在「每次请求都查一遍频道和源」。这个查询本身不慢但乘上并发就不行了。频道列表这种「几分钟才变一次」的数据天然适合缓存。?php // 缓存键按「分类」和「客户端版本」两个维度切粒度太粗会导致改一个频道就得全量失效 function getPlaylist(int $catId, string $ver, PDO $db, Redis $r): array { $key pl:{$catId}:{$ver}; $cached $r-get($key); if ($cached ! false) { return json_decode($cached, true); // 命中直接返回不碰数据库 } // 只取启用状态的源且按权重倒序让权重高的排前面 $stmt $db-prepare( SELECT c.id, c.name, c.logo, s.url, s.weight FROM channel c JOIN source s ON s.channel_id c.id AND s.status 1 WHERE c.category_id ? AND c.status 1 ORDER BY s.weight DESC ); $stmt-execute([$catId]); $data $stmt-fetchAll(PDO::FETCH_ASSOC); // TTL 设 180 秒运营改完频道最多等三分钟生效比手动清缓存省事 $r-setex($key, 180, json_encode($data, JSON_UNESCAPED_UNICODE)); return $data; }JSON_UNESCAPED_UNICODE这个选项不能漏。不加的话中文频道名会被转成\u7efc\u5408这种形式响应体体积能涨将近一倍移动端解析也更费劲。TTL 定 180 秒是权衡定 60 秒缓存效果有限定 600 秒运营改完配置看不到变化会以为系统坏了然后反复点保存。后台的保存动作里再挂一个主动删缓存的钩子就能做到「改完立刻生效」TTL 只作为兜底。压测别用浏览器刷新用 wrk 打真实接口# 4 线程、20 并发、持续 30 秒重点看 P95 和超时数 wrk -t4 -c20 -d30s --latency \ http://tv.example.internal/api/v1/playlist?cat1uid1ts$(date %s)signxxx看结果时关注三件事P95 延迟、非 2xx 响应数、以及压测期间 MySQL 的Threads_running。如果 P95 在 200ms 以内但Threads_running一直贴着连接池上限说明缓存命中率不够得回头看 key 设计是不是切得太细。压测前记得把签名有效期临时放宽否则跑到一半全部 403你会以为是接口挂了。5. 踩坑排查五条从日志里翻出来的记录下面这几条都是「表面症状和真实原因隔了三层」的类型。写下来是因为它们各自都花了不少时间而每一条的排查路径都可以复用。5.1 后台能登录所有列表页 404现象登录成功跳转到首页正常但点「频道管理」「用户管理」全部返回 404Nginx 的 access log 里根本没有对应记录。原因路由是index.php?s/admin/channel/index这种形式Nginx 只配了try_files $uri $uri/ /index.php没把原始 URI 透传给s参数PHP 收到的$_GET[s]是空的路由解析不到控制器。解决把location /改成try_files $uri $uri/ /index.php?s$uri$args;。改完nginx -t再 reload。验证方法是拿 curl 直接请求带参数的路径看是否返回 JSON 而不是 404 页面——如果返回的是 Nginx 的默认 404说明请求压根没进 PHP问题一定在 Nginx 配置而不在代码。5.2 EPG 导入报「XML 解析失败」文件本身没坏现象同一个 XMLTV 文件用xmllint校验能过用浏览器能打开但后台导入一直失败日志里只有一行「解析错误」。原因两个可能。一是没装 simplexml 扩展simplexml_load_file直接返回 false错误信息被吞掉了二是文件太大PHP 的memory_limit是 128M几十 MB 的 XML 用 DOM 方式加载会瞬间吃满内存然后被 kill。解决先php -m | grep simplexml确认扩展再php -r echo ini_get(memory_limit);看内存上限。文件大的话不要改memory_limit硬扛改用流式解析——XMLReader 逐节点读内存占用跟文件大小基本无关。另一个更省事的办法是提前把 XMLTV 拆成按天的小文件抓取脚本里加--split-day这类参数。5.3 频道数明明是 800客户端只显示 200 多现象后台列表页分页显示 800 条但接口返回的分类里频道明显少一截而且少的那些没有规律。原因接口的 SQL 用了JOIN source而一个频道挂多个源时会产生多行如果外层再套了GROUP BY或者用了LIMIT很容易把频道切掉。另一种情况是source.status 0的源被过滤后那些「所有源都被标记为不可用」的频道整体消失了但后台列表页不带这个过滤条件。解决先在数据库里跑一次对比找出后台有、接口没有的那批频道-- 后台可见但接口取不到的频道通常是全部源都被标了不可用 SELECT c.id, c.name, (SELECT COUNT(*) FROM source s WHERE s.channel_id c.id) AS total_src, (SELECT COUNT(*) FROM source s WHERE s.channel_id c.id AND s.status 1) AS ok_src FROM channel c WHERE c.status 1 HAVING ok_src 0;定位到之后接口层的处理策略要改没有可用源的频道不要直接丢弃而是照常下发但标记available: false让客户端显示成灰色。直接消失对用户来说就是「频道没了」而灰显至少说明系统知道这个频道存在、只是暂时不可用。5.4 加了 Redis 之后后台改了频道但客户端半天不变现象运营在后台把某个频道的源地址换了刷新客户端还是老地址短则几分钟长则半小时。原因缓存 key 设计成pl:{$catId}:{$ver}后台保存时只删了pl:1:*但实际请求带的是ver2而删除脚本里的通配写成了pl:1:*Redis 的KEYS模式匹配在命名空间有冒号嵌套时很容易写错一层。解决不要用KEYS删缓存改用一个版本号键。每次后台保存就INCR pl:ver缓存 key 变成pl:{ver}:{$catId}版本号一变旧 key 自然失效过期时间到了自动回收。这个做法的好处是删除操作从「扫全库」变成「一次自增」Redis 压力几乎为零也不会因为通配写错而漏删。顺带把KEYS命令从生产环境的脚本里彻底去掉——它在数据量大时是会把 Redis 卡住的。5.5 定时巡检跑了但last_check一直是旧时间现象巡检脚本手动执行有输出crontab 里也有记录但源表里的检查时间戳几天没变。原因crontab 的执行环境和登录 shell 不同php可能不在 PATH 里另外脚本里用了相对路径require vendor/autoload.php从/root目录执行时直接 fatal但错误输出被21重定向到了日志而日志路径又是相对的结果错误信息写到了别的地方crontab的邮件也没人看。解决crontab 里一律写绝对路径并且在命令最前面加cd# 错误示范依赖当前目录和 PATH */10 * * * * php think source:check # 正确写法绝对路径 先 cd 日志也写绝对路径 */10 * * * * cd /www/tv /usr/bin/php /www/tv/think source:check /www/tv/runtime/log/check.log 21改完用crontab -l确认落盘再手动cd /执行一遍那条完整命令——如果从根目录执行不报错crontab 里基本也不会错。这个「换个目录手动跑一遍」的习惯能省掉很多深夜排查。6. 进阶给源做一轮定时巡检把坏源挡在用户之前多源的价值只有在「坏源能被自动降权」时才体现出来。我见过太多系统挂着三四个源但全是人工维护一个源死了半年没人发现因为客户端总是拿第一条第一条恰好还活着。等第一条也死了用户才会报上来。巡检脚本的思路很简单定时从source表捞一批待检查的源发一个只读前几百字节的请求判断返回里有没有 m3u8 的播放列表标记然后回写status和last_check。不要真去下载整个流那样带宽和时间都受不了。#!/usr/bin/env bash # 只取前 512 字节 6 秒超时判断是不是合法播放列表 set -u UAMozilla/5.0 (compatible; SourceCheck/1.0) while IFS$\t read -r sid url; do body$(curl -s -m 6 -A $UA -r 0-511 $url 2/dev/null) if printf %s $body | grep -q #EXTM3U; then state1 # 可用 else state0 # 不可用先标记不删留人工复核 fi mysql -h127.0.0.1 -utvapp -p$DBPASS tv_admin \ -e UPDATE source SET status$state, last_checkNOW() WHERE id$sid; done (mysql -h127.0.0.1 -utvapp -p$DBPASS tv_admin -N -B \ -e SELECT id, url FROM source WHERE last_check IS NULL OR last_check NOW() - INTERVAL 2 HOUR LIMIT 200;)几个参数决定了这个脚本靠不靠谱。-r 0-511是范围请求只拿前 512 字节源站支持的话开销极小不支持范围请求的源站会返回整个文件所以必须有-m 6兜底。grep -q #EXTM3U只验头部标记不验流是否真能播——真播一遍成本太高头部标记能过滤掉 80% 的死链剩下 20% 靠用户反馈。LIMIT 200是控制单次检查量源多的时候分多批跑避免一次几百个并发把自己出口带宽打满。巡检的判定策略我一般用「三次连续失败才标记不可用」连续失败次数动作理由1 次只更新last_check不改状态单次失败可能是网络抖动或源站临时限流2 次权重降一档开始影响下发顺序但还能用3 次status置 0接口不再下发大概率真死了藏起来比给用户看黑屏好单次失败就置 0 是最常见的过度反应源站半夜扩容重启两分钟你的巡检刚好撞上一批好源被集体标死第二天用户说「频道少了一半」你还得挨个手动恢复。加个计数器字段比如fail_count就能避免。还有一点是永远不删源。巡检只改标记不DELETE。源地址是人工一条条找来的资产删了就得重找标成不可用只是不下发随时能恢复。我吃过一次亏——早期的脚本里写的是「连续失败五次直接删除」某天 DNS 解析出了点问题半小时内清掉了两百多条源记录备份还是两天前的。从那以后我每次写带UPDATE或DELETE的定时任务都强制先在只读账号下跑一遍SELECT版本把要动的行数打印出来确认数量和预期量级一致才换写账号执行。这个习惯看着笨但它拦住的每一次都是不可逆的操作。希望帮到你。本文还有配套的精品资源点击获取