ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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 超时后台首页能开,点进列表页 404
Redis5.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 'tvapp'@'127.0.0.1' IDENTIFIED BY '换成自己的强密码';" mysql -uroot -p -e "GRANT SELECT,INSERT,UPDATE,DELETE ON tv_admin.* TO 'tvapp'@'127.0.0.1';" mysql -uroot -p -e "FLUSH PRIVILEGES;" # 4) 写配置:数据库、缓存、接口密钥 # 改完执行一次语法检查,比在浏览器里看白屏快得多 php -l config/database.php

Nginx 侧的重写规则是第二个高频翻车点。后台能打开首页但点任何菜单都 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, encoding='utf-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='', encoding='utf-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)

几个参数上的细节值得说清楚。encoding='utf-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 id="xxx">标识,节目用<programme channel="xxx">引用,而后台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(tzinfo=timezone(timedelta(minutes=offset))).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 out

parse_xmltv_time里那个tzinfo替换不能省。直接datetime.strptime出来的是「无时区」的裸时间,后面一旦有代码对它做astimezone,Python 会按服务器本地时区猜,猜错就是 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), n=1, cutoff=0.85) if hit: m[cid] = hit[0] # 兜底匹配,命中率看命名规范程度 return m

cutoff=0.85是试出来的经验值。放到 0.7,会把「综合频道」和「综合频道高清」配成一对,节目单张冠李戴;收到 0.95,稍微带个空格或全半角差异就匹配不上,命中率掉一大截。0.85 到 0.9 这个区间在中文频道名上比较贴合实际。兜底匹配出来的结果一定要人工抽检,别全信——自动匹配错位比没有节目单更让人困惑,用户看到的是「节目单有,但内容和频道对不上」,这种问题比空白难排查得多。

抓取任务本身建议放到后台的定时任务里,别用浏览器点。抓一次全量 XMLTV 动辄几十 MB,走 Web 请求既容易被超时掐断,也没法断点续跑:

# 放到 crontab,每 6 小时抓一次,日志留 7 天 0 */6 * * * cd /www/tv && php think epg:fetch --days=3 >> runtime/log/epg.log 2>&1 # 日志按天切,不然一个月后 log 目录能撑到几个 G 0 3 * * * find /www/tv/runtime/log -name 'epg.log.*' -mtime +7 -delete

--days=3这类参数的意思是只抓未来三天的节目,够客户端展示用,也避免一次写几十万行。抓完记得看一下落库行数和「无节目频道数」,后者持续上涨通常意味着源站的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必须和客户端一字不差,中间那个|不能省——省了之后uid=12, ts=3和uid=1, ts=23会算出同一个串,是可以构造的碰撞。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?cat=1&uid=1&ts=$(date +%s)&sign=xxx"

看结果时关注三件事:P95 延迟、非 2xx 响应数、以及压测期间 MySQL 的Threads_running。如果 P95 在 200ms 以内但Threads_running一直贴着连接池上限,说明缓存命中率不够,得回头看 key 设计是不是切得太细。压测前记得把签名有效期临时放宽,否则跑到一半全部 403,你会以为是接口挂了。

5. 踩坑排查:五条从日志里翻出来的记录

下面这几条都是「表面症状和真实原因隔了三层」的类型。写下来是因为它们各自都花了不少时间,而每一条的排查路径都可以复用。

5.1 后台能登录,所有列表页 404

现象:登录成功跳转到首页正常,但点「频道管理」「用户管理」全部返回 404,Nginx 的 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:*,但实际请求带的是ver=2,而删除脚本里的通配写成了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,但错误输出被2>&1重定向到了日志,而日志路径又是相对的,结果错误信息写到了别的地方,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 2>&1

改完用crontab -l确认落盘,再手动cd /执行一遍那条完整命令——如果从根目录执行不报错,crontab 里基本也不会错。这个「换个目录手动跑一遍」的习惯,能省掉很多深夜排查。

6. 进阶:给源做一轮定时巡检,把坏源挡在用户之前

多源的价值只有在「坏源能被自动降权」时才体现出来。我见过太多系统挂着三四个源,但全是人工维护,一个源死了半年没人发现,因为客户端总是拿第一条,第一条恰好还活着。等第一条也死了,用户才会报上来。

巡检脚本的思路很简单:定时从source表捞一批待检查的源,发一个只读前几百字节的请求,判断返回里有没有 m3u8 的播放列表标记,然后回写status和last_check。不要真去下载整个流,那样带宽和时间都受不了。

#!/usr/bin/env bash # 只取前 512 字节 + 6 秒超时,判断是不是合法播放列表 set -u UA='Mozilla/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 state=1 # 可用 else state=0 # 不可用,先标记不删,留人工复核 fi mysql -h127.0.0.1 -utvapp -p"$DBPASS" tv_admin \ -e "UPDATE source SET status=$state, last_check=NOW() 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版本,把要动的行数打印出来,确认数量和预期量级一致才换写账号执行。这个习惯看着笨,但它拦住的每一次都是不可逆的操作。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进