From 5a8585d0275d3956d990e0e80500a471a94c6c6f Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 10:50:10 +0800 Subject: [PATCH 01/24] docs: add match live streams design spec --- .../2026-06-21-match-live-streams-design.md | 523 ++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-match-live-streams-design.md diff --git a/docs/superpowers/specs/2026-06-21-match-live-streams-design.md b/docs/superpowers/specs/2026-06-21-match-live-streams-design.md new file mode 100644 index 0000000..268eb65 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-match-live-streams-design.md @@ -0,0 +1,523 @@ +# 赛事直播子表与多线路播放设计 + +## 背景 + +当前赛事数据只在 `la_match.live_url` 中保存单个第三方直播链接,存在几个限制: + +- 无法为同一场赛事维护多条直播线路 +- 无法保存同一线路解析出的多种播放流地址 +- 无法区分人工维护的入口链接和程序抓取的真实播放地址 +- 前台比赛详情页只能展示单个直播入口,无法切换播放流 + +本次改造需要把“赛事直播”从主表单字段升级为“赛事 -> 直播线路子表”,同时保留 `la_match.live_url` 作为兼容字段。 + +## 目标 + +1. 新增赛事直播子表,一场赛事可维护多条直播线路。 +2. 每条线路保存 4 个播放地址: + - `play_url_flv` + - `play_url_hd_flv` + - `play_url_m3u8` + - `play_url_hd_m3u8` +3. 直播抓取逻辑放到 `docker/crawler`,按现有 Python crawler 惯例接入。 +4. 后台赛事列表改成“主表 + 展开子表”的直播线路管理方式。 +5. 前台比赛详情页展示多条直播线路,并允许用户自由切换播放流。 +6. 保留 `la_match.live_url`,自动回填为该赛事“最早新增的一条直播线路”的 `source_url`。 + +## 非目标 + +- 本轮不改 PC 前台直播 UI。 +- 本轮不增加直播抓取历史日志表。 +- 本轮不抽象多站点通用抓取框架,只先支持当前已知直播站解析。 + +## 当前现状 + +### 数据层 + +- 赛事主表:`la_match` +- 兼容直播字段:`la_match.live_url` +- 当前没有直播子表 + +### 后台 + +- `admin/src/views/match/lists/index.vue` 编辑弹窗中直接维护 `live_url` +- `server/app/adminapi/logic/match/MatchLogic.php` 允许编辑 `live_url` + +### 前台 + +- `server/app/api/controller/MatchController.php::detail()` 返回单个 `live_url` +- `uniapp/src/pages/match_detail/match_detail.vue` 只渲染单个直播入口卡 + +### 调度 + +- Docker crawler 任务由 `docker/crawler/main.py` 注册 action +- `docker/crawler/config/crawler_tasks.yaml` 维护 Docker 内部 cron +- `la_dev_crontab` 仍保留任务记录,用于后台展示和沿用“已迁移到 Docker”惯例 + +## 总体设计 + +### 方案选型 + +采用“单子表保存当前线路状态”的方案,不新增抓取历史表。 + +每条直播线路在子表中保留当前最新抓取结果;抓取失败时保留旧值,不清空历史可用地址。 + +### 设计总览 + +1. 新增 `la_match_live` 子表。 +2. 后端新增 `MatchLive` 模型、adminapi 线路管理接口、前台详情聚合返回。 +3. 后台赛事列表新增展开子表,替换原单字段 `live_url` 手工编辑。 +4. `docker/crawler` 新增 `match_live_stream` Python 任务,每分钟执行一次。 +5. 比赛详情页新增多线路展示和播放流切换,默认使用 `play_url_m3u8`。 + +## 数据模型设计 + +### 新增数据表 + +表名:`la_match_live` + +建议字段: + +- `id` bigint unsigned,主键 +- `match_id` int unsigned,关联 `la_match.id` +- `title` varchar(255),直播标题 +- `source_url` varchar(500),指向网址 +- `play_url_flv` varchar(1000),标清 flv +- `play_url_hd_flv` varchar(1000),高清 flv +- `play_url_m3u8` varchar(1000),标清 m3u8 +- `play_url_hd_m3u8` varchar(1000),高清 m3u8 +- `fetch_status` tinyint unsigned,抓取状态 + - `0` = pending + - `1` = success + - `2` = failed +- `fetch_error` varchar(500),最近一次抓取错误信息 +- `last_fetch_at` int unsigned,最近抓取时间 +- `next_fetch_at` int unsigned,下次抓取时间 +- `create_time` int unsigned +- `update_time` int unsigned +- `delete_time` int unsigned nullable + +### 索引与约束 + +- 主键:`id` +- 唯一约束:`uk_match_source_url (match_id, source_url)` +- 普通索引: + - `idx_match_id (match_id)` + - `idx_next_fetch_at (next_fetch_at)` + - `idx_fetch_status (fetch_status)` + - `idx_match_create_time (match_id, create_time)` + +### 模型约定 + +- 新增模型:`server/app/common/model/match/MatchLive.php` +- 继续沿用 `BaseModel` +- 使用 `create_time / update_time / delete_time` 与现有 `la_match` 保持一致 + +## 兼容规则 + +### `la_match.live_url` + +保留 `la_match.live_url`,但不再允许后台人工直接维护。 + +自动回填规则: + +- 若赛事存在直播线路,则回填“该赛事最早新增的一条直播线路”的 `source_url` +- 若赛事不存在直播线路,则将 `la_match.live_url` 置空 + +触发时机: + +- 新增线路后 +- 编辑线路 `source_url` 后 +- 删除线路后 +- 恢复已删除线路后(若后续有恢复能力) + +### 前台兼容 + +`/api/match/detail` 继续返回: + +- `live_url`:旧兼容字段 +- `live_list`:新多线路列表 + +旧页面仍可只使用 `live_url`,新页面优先使用 `live_list`。 + +## 后台设计 + +### 页面交互 + +页面:`admin/src/views/match/lists/index.vue` + +改造为: + +- 赛事主表保留现有字段 +- 新增展开列 +- 展开后展示当前赛事的直播线路子表 + +子表字段: + +- 直播标题 +- 指向网址 +- 主播放流(`play_url_m3u8`) +- 高清 m3u8(`play_url_hd_m3u8`) +- 标清 flv(`play_url_flv`) +- 高清 flv(`play_url_hd_flv`) +- 抓取状态 +- 最近抓取时间 +- 创建时间 +- 更新时间 + +### 后台操作 + +子表提供: + +- 新增线路 +- 编辑线路 +- 删除线路 + +人工可维护字段仅有: + +- `title` +- `source_url` + +以下字段只读: + +- 4 个播放地址 +- `fetch_status` +- `fetch_error` +- `last_fetch_at` +- `next_fetch_at` +- `create_time` +- `update_time` + +### 旧字段替换 + +从赛事编辑弹窗中移除可编辑的 `live_url` 输入框。 + +可选保留只读展示: + +- 字段名:`兼容主链接(自动)` +- 值:`la_match.live_url` + +### adminapi 接口 + +新增控制器建议: + +- `server/app/adminapi/controller/match/MatchLiveController.php` + +新增接口: + +- `GET /adminapi/match.matchLive/lists` +- `GET /adminapi/match.matchLive/detail` +- `POST /adminapi/match.matchLive/add` +- `POST /adminapi/match.matchLive/edit` +- `POST /adminapi/match.matchLive/delete` + +新增对应层: + +- `lists/match/MatchLiveLists.php` +- `logic/match/MatchLiveLogic.php` +- `validate/match/MatchLiveValidate.php` + +`match.match/edit` 只维护赛事本身字段,不再接收人工写入的 `live_url`。 + +## 前台接口设计 + +### `/api/match/detail` + +在现有赛事详情返回结构上新增 `live_list`。 + +`live_list` 单项字段: + +- `id` +- `title` +- `source_url` +- `play_url_flv` +- `play_url_hd_flv` +- `play_url_m3u8` +- `play_url_hd_m3u8` +- `fetch_status` +- `last_fetch_at` +- `update_time` +- `default_play_url` +- `stream_options` + +#### `default_play_url` + +固定优先取值顺序: + +1. `play_url_m3u8` +2. `play_url_hd_m3u8` +3. `play_url_flv` +4. `play_url_hd_flv` + +当前业务上默认以 `play_url_m3u8` 作为主播放流。 + +#### `stream_options` + +后端组装为可直接渲染的数组,顺序固定: + +1. `M3U8` -> `play_url_m3u8` +2. `HD M3U8` -> `play_url_hd_m3u8` +3. `FLV` -> `play_url_flv` +4. `HD FLV` -> `play_url_hd_flv` + +空值不返回。 + +## uniapp 页面设计 + +页面:`uniapp/src/pages/match_detail/match_detail.vue` + +### 直播区域 + +将当前单个“直播详情”入口卡改造成“直播线路列表”。 + +每条线路展示: + +- 直播标题 +- 最近更新时间 +- 抓取状态 +- 默认播放流标识 + +### 播放行为 + +用户点击某条线路后: + +- 进入独立播放页或播放器弹层 +- 默认使用 `default_play_url` +- 播放流切换区展示 `stream_options` +- 用户可自由切换 4 条播放流 + +若某个流为空,则不展示该流的切换按钮。 + +### 兜底行为 + +- 若 `live_list` 不为空,优先渲染 `live_list` +- 若 `live_list` 为空但 `live_url` 有值,则保留旧入口卡 +- 若两者都为空,则不显示直播区域 + +### 打开源网页 + +播放器区域保留“打开源网页”按钮,跳转 `source_url`,作为播放器兼容性兜底。 + +## Docker / Python 抓取设计 + +### 任务入口 + +新增文件: + +- `docker/crawler/match_live_stream.py` + +在 `docker/crawler/main.py` 注册 action: + +- `match_live_stream` + +### Docker 调度 + +在 `docker/crawler/config/crawler_tasks.yaml` 新增任务: + +- `key: match_live_stream` +- `name: 赛事直播流抓取` +- `action: match_live_stream` +- `cron: "*/1 * * * *"` + +### `la_dev_crontab` 记录 + +新增一条后台可见任务记录: + +- `name`: 赛事直播流抓取 +- `command`: `crawler` +- `params`: `match_live_stream` +- `expression`: `* * * * *` + +这条记录沿用当前项目的“迁移到 Docker crawler”惯例: + +- 后台可展示 +- 真实执行由 Docker 调度 +- PHP `crontab` 不直接执行抓取逻辑 + +## 抓取调度规则 + +### 选取规则 + +每分钟执行一次,从 `la_match_live` 中选择: + +- `next_fetch_at <= now` +- 未删除 + +排序: + +- `next_fetch_at asc` +- `id asc` + +每轮限制 `20-50` 条,首版建议 `30` 条。 + +### 新线路 / 未完整抓取线路 + +判定为“未完整抓取”: + +- 4 个播放地址中任意一个为空,或至少主流地址 `play_url_m3u8` 为空 + +行为: + +- 新增或编辑 `source_url` 后立即置为 `fetch_status = 0` +- `next_fetch_at = now` +- 若抓取失败,`next_fetch_at = now + 60` + +### 已抓到地址的线路 + +若线路已有可用播放地址: + +- 抓取成功:`next_fetch_at = now + 600` +- 抓取失败:保留旧值,`next_fetch_at = now + 600` + +### 解析来源 + +抓取入口为 `source_url`。 + +首版仅支持当前确认的直播站页面解析逻辑,不抽象成复杂通用框架。 + +### 播放地址回填 + +抓取成功后更新: + +- `play_url_flv` +- `play_url_hd_flv` +- `play_url_m3u8` +- `play_url_hd_m3u8` +- `fetch_status = 1` +- `fetch_error = ''` +- `last_fetch_at = now` +- `next_fetch_at = now + 600` +- `update_time = now` + +抓取失败后更新: + +- `fetch_status = 2` +- `fetch_error = 最近错误` +- `last_fetch_at = now` +- `next_fetch_at = now + 60` 或 `now + 600`(视线路是否已有可用地址) +- `update_time = now` + +## 稳定性设计 + +### 锁 + +`match_live_stream` action 增加跨进程锁,避免 Docker cron 重入。 + +### 超时 + +- 单条线路抓取需要超时 +- 整个 action 在 `docker/crawler/main.py` 中配置 action 级别超时 + +### 失败保护 + +抓取失败时不得清空已有播放地址,避免前台线路因为一次失败整体不可用。 + +## 错误处理 + +- 页面结构变更导致解析不到 4 个地址时: + - 新线路维持 `pending/failed` + - 老线路保留旧地址 +- 删除直播线路后,立即重新计算 `la_match.live_url` +- 后台人工编辑 `source_url` 后,播放地址字段只由 Python 任务刷新 + +## 代码改造范围 + +### server + +- `server/app/common/model/match/MatchLive.php` +- `server/app/adminapi/controller/match/MatchLiveController.php` +- `server/app/adminapi/lists/match/MatchLiveLists.php` +- `server/app/adminapi/logic/match/MatchLiveLogic.php` +- `server/app/adminapi/validate/match/MatchLiveValidate.php` +- `server/app/adminapi/logic/match/MatchLogic.php` +- `server/app/api/controller/MatchController.php` + +### admin + +- `admin/src/api/match.ts` +- `admin/src/views/match/lists/index.vue` + +### uniapp + +- `uniapp/src/pages/match_detail/match_detail.vue` +- 如需独立播放页,再新增对应页面及路由注册 + +### docker/crawler + +- `docker/crawler/match_live_stream.py` +- `docker/crawler/main.py` +- `docker/crawler/config/crawler_tasks.yaml` +- 对应测试文件 + +### docs/sql + +- 新建 `create_match_live_table.sql` +- 新建 `insert_crontab_match_live_stream.sql` +- 如需停用旧逻辑,再补停用 SQL + +## 验证方案 + +### 后端 + +- `php -l` 覆盖新增/修改的 PHP 文件 +- 静态/最小回归脚本覆盖: + - 直播线路新增、编辑、删除 + - `la_match.live_url` 自动回填 + - `/api/match/detail` 返回 `live_list` + +### Python crawler + +单测覆盖: + +- 目标站解析出 4 个播放地址 +- 失败时保留旧地址 +- 新线路 1 分钟重试 +- 已抓地址线路 10 分钟更新 +- 互斥锁防重入 + +### 管理后台 + +- 展开子表展示 +- 子表新增、编辑、删除 +- 播放地址只读展示 +- `npm run build` + +### uniapp + +- 比赛详情页展示多条直播线路 +- 默认使用 `play_url_m3u8` +- 允许手动切换流 +- 空流不展示切换按钮 +- `npm run build:h5` + +## 上线边界 + +本轮改造包含: + +- `server` +- `admin` +- `uniapp` +- `docker/crawler` +- `docs/sql` + +本轮不包含: + +- PC 前台直播线路 UI +- 抓取历史日志表 +- 多站点通用解析抽象 + +## 风险点 + +1. 第三方直播站页面结构可能变更,导致解析失败。 +2. `m3u8` / `flv` 地址多为时效链接,抓取周期与前台展示需要允许“旧值短期继续可用”。 +3. `uniapp` 不同平台对直链播放兼容性不同,因此必须保留 `source_url` 跳转兜底。 +4. 后台一旦放开人工编辑播放地址,容易与爬虫更新冲突,因此必须禁止手改播放地址字段。 + +## 实施顺序建议 + +1. 建表 + 后端模型与逻辑 +2. 后台子表接口与 UI +3. Python crawler 与 Docker 调度 +4. 前台 `/api/match/detail` 聚合 +5. uniapp 多线路播放 UI +6. SQL、回归测试、部署验证 From ee4f2504bd4ee25639e71e66b3be4b3d1db01235 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 11:11:48 +0800 Subject: [PATCH 02/24] feat: add match live table foundation --- docs/sql/create_match_live_table.sql | 23 ++++++++++ qa/backend/test_match_live_schema_static.php | 44 ++++++++++++++++++++ server/app/common/model/match/MatchLive.php | 10 +++++ 3 files changed, 77 insertions(+) create mode 100644 docs/sql/create_match_live_table.sql create mode 100644 qa/backend/test_match_live_schema_static.php create mode 100644 server/app/common/model/match/MatchLive.php diff --git a/docs/sql/create_match_live_table.sql b/docs/sql/create_match_live_table.sql new file mode 100644 index 0000000..3bee8a0 --- /dev/null +++ b/docs/sql/create_match_live_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE `la_match_live` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `match_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '比赛ID', + `title` varchar(255) NOT NULL DEFAULT '' COMMENT '直播标题', + `source_url` varchar(500) NOT NULL DEFAULT '' COMMENT '来源链接', + `play_url_m3u8` varchar(1000) NOT NULL DEFAULT '' COMMENT '标清M3U8播放地址', + `play_url_hd_m3u8` varchar(1000) NOT NULL DEFAULT '' COMMENT '高清M3U8播放地址', + `play_url_flv` varchar(1000) NOT NULL DEFAULT '' COMMENT '标清FLV播放地址', + `play_url_hd_flv` varchar(1000) NOT NULL DEFAULT '' COMMENT '高清FLV播放地址', + `fetch_status` tinyint UNSIGNED NOT NULL DEFAULT 0 COMMENT '抓取状态 0-待抓取 1-成功 2-失败', + `fetch_error` varchar(255) NOT NULL DEFAULT '' COMMENT '抓取错误信息', + `last_fetch_at` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后抓取时间', + `next_fetch_at` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '下次抓取时间', + `create_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建时间', + `update_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新时间', + `delete_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '删除时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_match_source_url` (`match_id`, `source_url`) USING BTREE, + KEY `idx_match_id` (`match_id`) USING BTREE, + KEY `idx_next_fetch_at` (`next_fetch_at`) USING BTREE, + KEY `idx_fetch_status` (`fetch_status`) USING BTREE, + KEY `idx_match_create_time` (`match_id`, `create_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='比赛直播源表'; diff --git a/qa/backend/test_match_live_schema_static.php b/qa/backend/test_match_live_schema_static.php new file mode 100644 index 0000000..9a3cfd6 --- /dev/null +++ b/qa/backend/test_match_live_schema_static.php @@ -0,0 +1,44 @@ + Date: Sun, 21 Jun 2026 11:21:21 +0800 Subject: [PATCH 03/24] fix: align match live schema foundation --- docs/sql/create_match_live_table.sql | 4 +-- qa/backend/test_match_live_schema_static.php | 28 +++++++++++++++++++- server/app/common/model/match/MatchLive.php | 4 +++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/sql/create_match_live_table.sql b/docs/sql/create_match_live_table.sql index 3bee8a0..63b14a0 100644 --- a/docs/sql/create_match_live_table.sql +++ b/docs/sql/create_match_live_table.sql @@ -1,4 +1,4 @@ -CREATE TABLE `la_match_live` ( +CREATE TABLE IF NOT EXISTS `la_match_live` ( `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `match_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '比赛ID', `title` varchar(255) NOT NULL DEFAULT '' COMMENT '直播标题', @@ -13,7 +13,7 @@ CREATE TABLE `la_match_live` ( `next_fetch_at` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '下次抓取时间', `create_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建时间', `update_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新时间', - `delete_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '删除时间', + `delete_time` int UNSIGNED DEFAULT NULL COMMENT '删除时间', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_match_source_url` (`match_id`, `source_url`) USING BTREE, KEY `idx_match_id` (`match_id`) USING BTREE, diff --git a/qa/backend/test_match_live_schema_static.php b/qa/backend/test_match_live_schema_static.php index 9a3cfd6..3d944cb 100644 --- a/qa/backend/test_match_live_schema_static.php +++ b/qa/backend/test_match_live_schema_static.php @@ -18,15 +18,26 @@ $sql = file_get_contents($sqlPath); $model = file_get_contents($modelPath); $requiredSql = [ - 'CREATE TABLE `la_match_live`', + 'CREATE TABLE IF NOT EXISTS `la_match_live`', '`match_id` int UNSIGNED NOT NULL', + '`title` varchar(255)', '`source_url` varchar(500)', '`play_url_m3u8` varchar(1000)', '`play_url_hd_m3u8` varchar(1000)', '`play_url_flv` varchar(1000)', '`play_url_hd_flv` varchar(1000)', '`fetch_status` tinyint', + '`fetch_error` varchar(255)', + '`last_fetch_at` int UNSIGNED NOT NULL', + '`next_fetch_at` int UNSIGNED NOT NULL', + '`create_time` int UNSIGNED NOT NULL', + '`update_time` int UNSIGNED NOT NULL', + '`delete_time` int UNSIGNED DEFAULT NULL', 'UNIQUE KEY `uk_match_source_url` (`match_id`, `source_url`)', + 'KEY `idx_match_id` (`match_id`)', + 'KEY `idx_next_fetch_at` (`next_fetch_at`)', + 'KEY `idx_fetch_status` (`fetch_status`)', + 'KEY `idx_match_create_time` (`match_id`, `create_time`)', ]; foreach ($requiredSql as $needle) { @@ -41,4 +52,19 @@ if (strpos($model, "protected \$name = 'match_live';") === false) { exit(1); } +if (strpos($model, 'extends BaseModel') === false) { + fwrite(STDERR, "model base class mismatch\n"); + exit(1); +} + +if (strpos($model, 'use SoftDelete;') === false) { + fwrite(STDERR, "model soft delete missing\n"); + exit(1); +} + +if (strpos($model, "protected \$deleteTime = 'delete_time';") === false) { + fwrite(STDERR, "model delete time mismatch\n"); + exit(1); +} + echo "schema static checks passed\n"; diff --git a/server/app/common/model/match/MatchLive.php b/server/app/common/model/match/MatchLive.php index 3c03002..d4d3eb1 100644 --- a/server/app/common/model/match/MatchLive.php +++ b/server/app/common/model/match/MatchLive.php @@ -3,8 +3,12 @@ namespace app\common\model\match; use app\common\model\BaseModel; +use think\model\concern\SoftDelete; class MatchLive extends BaseModel { + use SoftDelete; + protected $name = 'match_live'; + protected $deleteTime = 'delete_time'; } From dbdb418e151944f763325b8bb16c7f111456e238 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 11:32:08 +0800 Subject: [PATCH 04/24] feat: add match live service helpers --- qa/backend/test_match_live_backend_static.php | 28 +++++++++ .../common/service/match/MatchLiveService.php | 63 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 qa/backend/test_match_live_backend_static.php create mode 100644 server/app/common/service/match/MatchLiveService.php diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php new file mode 100644 index 0000000..7668772 --- /dev/null +++ b/qa/backend/test_match_live_backend_static.php @@ -0,0 +1,28 @@ +order('create_time', 'asc') + ->order('id', 'asc') + ->find(); + + MatchEvent::where('id', $matchId)->update([ + 'live_url' => $row ? (string) $row->source_url : '', + 'update_time' => time(), + ]); + } + + public static function getLiveListForApi(int $matchId): array + { + $rows = MatchLive::where('match_id', $matchId) + ->order('create_time', 'asc') + ->order('id', 'asc') + ->select() + ->toArray(); + + foreach ($rows as &$row) { + $row['stream_options'] = self::buildStreamOptions($row); + $row['default_play_url'] = $row['stream_options'][0]['url'] ?? ''; + } + unset($row); + + return $rows; + } + + public static function buildStreamOptions(array $row): array + { + $definitions = [ + ['label' => 'M3U8', 'field' => 'play_url_m3u8'], + ['label' => 'HD M3U8', 'field' => 'play_url_hd_m3u8'], + ['label' => 'FLV', 'field' => 'play_url_flv'], + ['label' => 'HD FLV', 'field' => 'play_url_hd_flv'], + ]; + + $options = []; + foreach ($definitions as $definition) { + $url = trim((string) ($row[$definition['field']] ?? '')); + if ($url === '') { + continue; + } + $options[] = [ + 'label' => $definition['label'], + 'url' => $url, + ]; + } + + return $options; + } +} From b133fd36ee57d3abca4a2ea97c8fd6bd77c218d4 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 11:40:49 +0800 Subject: [PATCH 05/24] test: harden match live static contract --- qa/backend/test_match_live_backend_static.php | 17 +++++++++++++++++ .../common/service/match/MatchLiveService.php | 2 ++ 2 files changed, 19 insertions(+) diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php index 7668772..5628356 100644 --- a/qa/backend/test_match_live_backend_static.php +++ b/qa/backend/test_match_live_backend_static.php @@ -14,7 +14,19 @@ $requiredTokens = [ 'public static function syncLegacyLiveUrl', 'public static function getLiveListForApi', 'public static function buildStreamOptions', + 'source_url', + 'update_time', + "order('create_time', 'asc')", + "order('id', 'asc')", 'play_url_m3u8', + 'play_url_hd_m3u8', + 'play_url_flv', + 'play_url_hd_flv', + 'M3U8', + 'HD M3U8', + 'FLV', + 'HD FLV', + "if (\$url === '')", 'default_play_url', ]; @@ -25,4 +37,9 @@ foreach ($requiredTokens as $needle) { } } +if (strpos($service, "whereNull('delete_time')") === false && strpos($service, 'SoftDelete') === false) { + fwrite(STDERR, "missing non-deleted row guard\n"); + exit(1); +} + echo "service static checks passed\n"; diff --git a/server/app/common/service/match/MatchLiveService.php b/server/app/common/service/match/MatchLiveService.php index 0754aec..6ae53b5 100644 --- a/server/app/common/service/match/MatchLiveService.php +++ b/server/app/common/service/match/MatchLiveService.php @@ -9,6 +9,7 @@ class MatchLiveService { public static function syncLegacyLiveUrl(int $matchId): void { + // MatchLive uses SoftDelete, so default queries only return non-deleted rows. $row = MatchLive::where('match_id', $matchId) ->order('create_time', 'asc') ->order('id', 'asc') @@ -22,6 +23,7 @@ class MatchLiveService public static function getLiveListForApi(int $matchId): array { + // MatchLive uses SoftDelete, so default queries only return non-deleted rows. $rows = MatchLive::where('match_id', $matchId) ->order('create_time', 'asc') ->order('id', 'asc') From 8d528ba5fea5126d7bc9d227ef191170712c75c1 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 11:43:29 +0800 Subject: [PATCH 06/24] test: enforce exact match live snippets --- qa/backend/test_match_live_backend_static.php | 49 ++++++++++++------- .../common/service/match/MatchLiveService.php | 4 +- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php index 5628356..6dd9c41 100644 --- a/qa/backend/test_match_live_backend_static.php +++ b/qa/backend/test_match_live_backend_static.php @@ -9,37 +9,52 @@ if (!is_file($servicePath)) { } $service = file_get_contents($servicePath); -$requiredTokens = [ +$requiredSnippets = [ 'class MatchLiveService', 'public static function syncLegacyLiveUrl', 'public static function getLiveListForApi', 'public static function buildStreamOptions', - 'source_url', - 'update_time', - "order('create_time', 'asc')", - "order('id', 'asc')", - 'play_url_m3u8', - 'play_url_hd_m3u8', - 'play_url_flv', - 'play_url_hd_flv', - 'M3U8', - 'HD M3U8', - 'FLV', - 'HD FLV', + "'live_url' => \$row ? (string) \$row->source_url : ''", + "'update_time' => time()", + "->whereNull('delete_time')", + "->order('create_time', 'asc')", + "->order('id', 'asc')", + "\$row['default_play_url'] = \$row['stream_options'][0]['url'] ?? '';", "if (\$url === '')", - 'default_play_url', ]; -foreach ($requiredTokens as $needle) { +foreach ($requiredSnippets as $needle) { if (strpos($service, $needle) === false) { fwrite(STDERR, "missing service token: {$needle}\n"); exit(1); } } -if (strpos($service, "whereNull('delete_time')") === false && strpos($service, 'SoftDelete') === false) { - fwrite(STDERR, "missing non-deleted row guard\n"); +$whereNullCount = substr_count($service, "->whereNull('delete_time')"); +if ($whereNullCount < 2) { + fwrite(STDERR, "missing explicit delete_time guard on both queries\n"); exit(1); } +$orderedOptionSnippets = [ + "['label' => 'M3U8', 'field' => 'play_url_m3u8']", + "['label' => 'HD M3U8', 'field' => 'play_url_hd_m3u8']", + "['label' => 'FLV', 'field' => 'play_url_flv']", + "['label' => 'HD FLV', 'field' => 'play_url_hd_flv']", +]; + +$lastPos = -1; +foreach ($orderedOptionSnippets as $snippet) { + $pos = strpos($service, $snippet); + if ($pos === false) { + fwrite(STDERR, "missing ordered stream option snippet: {$snippet}\n"); + exit(1); + } + if ($pos <= $lastPos) { + fwrite(STDERR, "stream option order mismatch\n"); + exit(1); + } + $lastPos = $pos; +} + echo "service static checks passed\n"; diff --git a/server/app/common/service/match/MatchLiveService.php b/server/app/common/service/match/MatchLiveService.php index 6ae53b5..45712bc 100644 --- a/server/app/common/service/match/MatchLiveService.php +++ b/server/app/common/service/match/MatchLiveService.php @@ -9,8 +9,8 @@ class MatchLiveService { public static function syncLegacyLiveUrl(int $matchId): void { - // MatchLive uses SoftDelete, so default queries only return non-deleted rows. $row = MatchLive::where('match_id', $matchId) + ->whereNull('delete_time') ->order('create_time', 'asc') ->order('id', 'asc') ->find(); @@ -23,8 +23,8 @@ class MatchLiveService public static function getLiveListForApi(int $matchId): array { - // MatchLive uses SoftDelete, so default queries only return non-deleted rows. $rows = MatchLive::where('match_id', $matchId) + ->whereNull('delete_time') ->order('create_time', 'asc') ->order('id', 'asc') ->select() From 80e0e6a8a63d05cd78dfb6d64eac3b05182de8ad Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 11:52:02 +0800 Subject: [PATCH 07/24] feat: wire match live backend api --- qa/backend/test_match_live_backend_static.php | 42 ++++++++ .../controller/match/MatchLiveController.php | 53 ++++++++++ .../adminapi/lists/match/MatchLiveLists.php | 63 ++++++++++++ .../adminapi/logic/match/MatchLiveLogic.php | 97 +++++++++++++++++++ .../app/adminapi/logic/match/MatchLogic.php | 1 - .../validate/match/MatchLiveValidate.php | 69 +++++++++++++ server/app/api/controller/MatchController.php | 5 + 7 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 server/app/adminapi/controller/match/MatchLiveController.php create mode 100644 server/app/adminapi/lists/match/MatchLiveLists.php create mode 100644 server/app/adminapi/logic/match/MatchLiveLogic.php create mode 100644 server/app/adminapi/validate/match/MatchLiveValidate.php diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php index 6dd9c41..d88bff8 100644 --- a/qa/backend/test_match_live_backend_static.php +++ b/qa/backend/test_match_live_backend_static.php @@ -2,12 +2,27 @@ $root = dirname(__DIR__, 2); $servicePath = $root . '/server/app/common/service/match/MatchLiveService.php'; +$apiControllerPath = $root . '/server/app/api/controller/MatchController.php'; +$adminLogicPath = $root . '/server/app/adminapi/logic/match/MatchLogic.php'; +$backendChecks = [ + $root . '/server/app/adminapi/controller/match/MatchLiveController.php', + $root . '/server/app/adminapi/lists/match/MatchLiveLists.php', + $root . '/server/app/adminapi/logic/match/MatchLiveLogic.php', + $root . '/server/app/adminapi/validate/match/MatchLiveValidate.php', +]; if (!is_file($servicePath)) { fwrite(STDERR, "missing service file\n"); exit(1); } +foreach ($backendChecks as $path) { + if (!is_file($path)) { + fwrite(STDERR, "missing backend file: {$path}\n"); + exit(1); + } +} + $service = file_get_contents($servicePath); $requiredSnippets = [ 'class MatchLiveService', @@ -36,6 +51,33 @@ if ($whereNullCount < 2) { exit(1); } +if (!is_file($apiControllerPath)) { + fwrite(STDERR, "missing api controller file\n"); + exit(1); +} + +$apiController = file_get_contents($apiControllerPath); +foreach ([ + "'live_list'", + 'MatchLiveService::getLiveListForApi', +] as $needle) { + if (strpos($apiController, $needle) === false) { + fwrite(STDERR, "missing api controller token: {$needle}\n"); + exit(1); + } +} + +if (!is_file($adminLogicPath)) { + fwrite(STDERR, "missing admin logic file\n"); + exit(1); +} + +$adminLogic = file_get_contents($adminLogicPath); +if (strpos($adminLogic, "'live_url'") !== false) { + fwrite(STDERR, "match admin logic still manually allows live_url\n"); + exit(1); +} + $orderedOptionSnippets = [ "['label' => 'M3U8', 'field' => 'play_url_m3u8']", "['label' => 'HD M3U8', 'field' => 'play_url_hd_m3u8']", diff --git a/server/app/adminapi/controller/match/MatchLiveController.php b/server/app/adminapi/controller/match/MatchLiveController.php new file mode 100644 index 0000000..ff60eed --- /dev/null +++ b/server/app/adminapi/controller/match/MatchLiveController.php @@ -0,0 +1,53 @@ +dataLists(new MatchLiveLists()); + } + + public function detail() + { + $params = (new MatchLiveValidate())->goCheck('detail'); + $result = MatchLiveLogic::detail($params); + return $this->data($result); + } + + public function add() + { + $params = (new MatchLiveValidate())->post()->goCheck('add'); + $result = MatchLiveLogic::add($params); + if (true === $result) { + return $this->success('添加成功', [], 1, 1); + } + return $this->fail(MatchLiveLogic::getError()); + } + + public function edit() + { + $params = (new MatchLiveValidate())->post()->goCheck('edit'); + $result = MatchLiveLogic::edit($params); + if (true === $result) { + return $this->success('编辑成功', [], 1, 1); + } + return $this->fail(MatchLiveLogic::getError()); + } + + public function delete() + { + $params = (new MatchLiveValidate())->post()->goCheck('delete'); + $result = MatchLiveLogic::delete($params); + if (true === $result) { + return $this->success('删除成功', [], 1, 1); + } + return $this->fail(MatchLiveLogic::getError()); + } +} diff --git a/server/app/adminapi/lists/match/MatchLiveLists.php b/server/app/adminapi/lists/match/MatchLiveLists.php new file mode 100644 index 0000000..42bf862 --- /dev/null +++ b/server/app/adminapi/lists/match/MatchLiveLists.php @@ -0,0 +1,63 @@ + ['match_id', 'fetch_status'], + ]; + } + + public function lists(): array + { + $lists = $this->buildQuery() + ->field('id,match_id,title,source_url,play_url_m3u8,play_url_hd_m3u8,play_url_flv,play_url_hd_flv,fetch_status,fetch_error,last_fetch_at,next_fetch_at,create_time,update_time') + ->limit($this->limitOffset, $this->limitLength) + ->order('create_time', 'asc') + ->order('id', 'asc') + ->select() + ->toArray(); + + foreach ($lists as &$item) { + $item['fetch_status_text'] = self::formatFetchStatus((int) ($item['fetch_status'] ?? 0)); + } + unset($item); + + return $lists; + } + + public function count(): int + { + return $this->buildQuery()->count(); + } + + public static function formatFetchStatus(int $status): string + { + return match ($status) { + 0 => '待抓取', + 1 => '抓取成功', + 2 => '抓取失败', + default => '未知', + }; + } + + protected function buildQuery() + { + $query = MatchLive::whereNull('delete_time') + ->where($this->searchWhere); + + $matchId = (int) ($this->params['match_id'] ?? 0); + if ($matchId <= 0) { + $query->where('match_id', 0); + } + + return $query; + } +} diff --git a/server/app/adminapi/logic/match/MatchLiveLogic.php b/server/app/adminapi/logic/match/MatchLiveLogic.php new file mode 100644 index 0000000..123ee51 --- /dev/null +++ b/server/app/adminapi/logic/match/MatchLiveLogic.php @@ -0,0 +1,97 @@ +toArray(); + } + + public static function add(array $params): bool + { + try { + $now = time(); + MatchLive::create([ + 'match_id' => (int) $params['match_id'], + 'title' => trim((string) $params['title']), + 'source_url' => trim((string) $params['source_url']), + 'fetch_status' => 0, + 'fetch_error' => '', + 'last_fetch_at' => 0, + 'next_fetch_at' => $now, + 'create_time' => $now, + 'update_time' => $now, + ]); + + MatchLiveService::syncLegacyLiveUrl((int) $params['match_id']); + return true; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return false; + } + } + + public static function edit(array $params): bool + { + try { + $live = MatchLive::findOrEmpty($params['id']); + if ($live->isEmpty()) { + self::setError('直播线路不存在'); + return false; + } + + $oldMatchId = (int) $live->match_id; + $newMatchId = (int) $params['match_id']; + $now = time(); + + $live->save([ + 'match_id' => $newMatchId, + 'title' => trim((string) $params['title']), + 'source_url' => trim((string) $params['source_url']), + 'play_url_m3u8' => '', + 'play_url_hd_m3u8' => '', + 'play_url_flv' => '', + 'play_url_hd_flv' => '', + 'fetch_status' => 0, + 'fetch_error' => '', + 'last_fetch_at' => 0, + 'next_fetch_at' => $now, + 'update_time' => $now, + ]); + + if ($oldMatchId > 0 && $oldMatchId !== $newMatchId) { + MatchLiveService::syncLegacyLiveUrl($oldMatchId); + } + MatchLiveService::syncLegacyLiveUrl($newMatchId); + return true; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return false; + } + } + + public static function delete(array $params): bool + { + try { + $live = MatchLive::findOrEmpty($params['id']); + if ($live->isEmpty()) { + self::setError('直播线路不存在'); + return false; + } + + $matchId = (int) $live->match_id; + $live->delete(); + MatchLiveService::syncLegacyLiveUrl($matchId); + return true; + } catch (\Exception $e) { + self::setError($e->getMessage()); + return false; + } + } +} diff --git a/server/app/adminapi/logic/match/MatchLogic.php b/server/app/adminapi/logic/match/MatchLogic.php index c898fa6..716e31a 100644 --- a/server/app/adminapi/logic/match/MatchLogic.php +++ b/server/app/adminapi/logic/match/MatchLogic.php @@ -24,7 +24,6 @@ class MatchLogic extends BaseLogic 'league_name', 'round_name', 'stage', - 'live_url', 'home_team', 'home_icon', 'home_score', diff --git a/server/app/adminapi/validate/match/MatchLiveValidate.php b/server/app/adminapi/validate/match/MatchLiveValidate.php new file mode 100644 index 0000000..7965c70 --- /dev/null +++ b/server/app/adminapi/validate/match/MatchLiveValidate.php @@ -0,0 +1,69 @@ + 'require|integer|gt:0|checkLive', + 'match_id' => 'require|integer|gt:0|checkMatch', + 'title' => 'require|length:1,255', + 'source_url' => 'require|max:500', + ]; + + protected $message = [ + 'id.require' => '直播线路id不能为空', + 'id.integer' => '直播线路id须为整数', + 'id.gt' => '直播线路id须大于0', + 'match_id.require' => '赛事id不能为空', + 'match_id.integer' => '赛事id须为整数', + 'match_id.gt' => '赛事id须大于0', + 'title.require' => '直播标题不能为空', + 'title.length' => '直播标题长度须在1-255位字符', + 'source_url.require' => '源地址不能为空', + 'source_url.max' => '源地址长度不能超过500位字符', + ]; + + public function sceneDetail() + { + return $this->only(['id']); + } + + public function sceneAdd() + { + return $this->remove('id', 'require|integer|gt:0|checkLive') + ->only(['match_id', 'title', 'source_url']); + } + + public function sceneEdit() + { + return $this->only(['id', 'match_id', 'title', 'source_url']); + } + + public function sceneDelete() + { + return $this->only(['id']); + } + + public function checkLive($value) + { + $live = MatchLive::findOrEmpty($value); + if ($live->isEmpty()) { + return '直播线路不存在'; + } + return true; + } + + public function checkMatch($value) + { + $match = MatchEvent::findOrEmpty($value); + if ($match->isEmpty()) { + return '赛事不存在'; + } + return true; + } +} diff --git a/server/app/api/controller/MatchController.php b/server/app/api/controller/MatchController.php index d32b792..e0a44fc 100644 --- a/server/app/api/controller/MatchController.php +++ b/server/app/api/controller/MatchController.php @@ -13,6 +13,7 @@ use app\common\model\match\MatchLineup; use app\common\model\match\WorldCupPersonRanking; use app\common\model\match\WorldCupStanding; use app\common\model\league\League; +use app\common\service\match\MatchLiveService; class MatchController extends BaseApiController { @@ -157,6 +158,10 @@ class MatchController extends BaseApiController return $this->fail('赛事不存在'); } $data = $match->toArray(); + $data['live_list'] = MatchLiveService::getLiveListForApi((int) $data['id']); + if (empty($data['live_url']) && !empty($data['live_list'][0]['source_url'])) { + $data['live_url'] = (string) $data['live_list'][0]['source_url']; + } if (empty($data['live_url']) && $this->isWorldCupMatch($data)) { $data['live_url'] = $this->worldCupDefaultLiveUrl; } From cd9530686cda436feb8e7ca9c1790463562fe29d Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:04:01 +0800 Subject: [PATCH 08/24] fix: harden match live validation --- qa/backend/test_match_live_backend_static.php | 72 ++++++++++++++++++- .../validate/match/MatchLiveValidate.php | 28 +++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php index d88bff8..4d79e18 100644 --- a/qa/backend/test_match_live_backend_static.php +++ b/qa/backend/test_match_live_backend_static.php @@ -4,11 +4,14 @@ $root = dirname(__DIR__, 2); $servicePath = $root . '/server/app/common/service/match/MatchLiveService.php'; $apiControllerPath = $root . '/server/app/api/controller/MatchController.php'; $adminLogicPath = $root . '/server/app/adminapi/logic/match/MatchLogic.php'; +$listsPath = $root . '/server/app/adminapi/lists/match/MatchLiveLists.php'; +$matchLiveLogicPath = $root . '/server/app/adminapi/logic/match/MatchLiveLogic.php'; +$validatePath = $root . '/server/app/adminapi/validate/match/MatchLiveValidate.php'; $backendChecks = [ $root . '/server/app/adminapi/controller/match/MatchLiveController.php', - $root . '/server/app/adminapi/lists/match/MatchLiveLists.php', - $root . '/server/app/adminapi/logic/match/MatchLiveLogic.php', - $root . '/server/app/adminapi/validate/match/MatchLiveValidate.php', + $listsPath, + $matchLiveLogicPath, + $validatePath, ]; if (!is_file($servicePath)) { @@ -78,6 +81,69 @@ if (strpos($adminLogic, "'live_url'") !== false) { exit(1); } +if (!is_file($listsPath)) { + fwrite(STDERR, "missing match live lists file\n"); + exit(1); +} + +$lists = file_get_contents($listsPath); +foreach ([ + "\$matchId = (int) (\$this->params['match_id'] ?? 0);", + "if (\$matchId <= 0) {", + "\$query->where('match_id', 0);", +] as $needle) { + if (strpos($lists, $needle) === false) { + fwrite(STDERR, "missing lists guard token: {$needle}\n"); + exit(1); + } +} + +if (!is_file($matchLiveLogicPath)) { + fwrite(STDERR, "missing match live logic file\n"); + exit(1); +} + +$matchLiveLogic = file_get_contents($matchLiveLogicPath); +foreach ([ + "'play_url_m3u8' => ''", + "'play_url_hd_m3u8' => ''", + "'play_url_flv' => ''", + "'play_url_hd_flv' => ''", + "'fetch_status' => 0", + "'fetch_error' => ''", + "'last_fetch_at' => 0", + "'next_fetch_at' => \$now", +] as $needle) { + if (strpos($matchLiveLogic, $needle) === false) { + fwrite(STDERR, "missing match live logic reset token: {$needle}\n"); + exit(1); + } +} + +if (!is_file($validatePath)) { + fwrite(STDERR, "missing match live validate file\n"); + exit(1); +} + +$validate = file_get_contents($validatePath); +foreach ([ + 'checkTrimmedTitle', + 'checkTrimmedSourceUrl', + '$value = trim((string) $value);', +] as $needle) { + if (strpos($validate, $needle) === false) { + fwrite(STDERR, "missing trimmed validation token: {$needle}\n"); + exit(1); + } +} + +$childLivePos = strpos($apiController, "!empty(\$data['live_list'][0]['source_url'])"); +$worldCupFallbackPos = strpos($apiController, "if (empty(\$data['live_url']) && \$this->isWorldCupMatch(\$data))"); +if ($childLivePos === false || $worldCupFallbackPos === false || $childLivePos >= $worldCupFallbackPos) { + fwrite(STDERR, "match detail live_url fallback order mismatch\n"); + exit(1); +} + $orderedOptionSnippets = [ "['label' => 'M3U8', 'field' => 'play_url_m3u8']", "['label' => 'HD M3U8', 'field' => 'play_url_hd_m3u8']", diff --git a/server/app/adminapi/validate/match/MatchLiveValidate.php b/server/app/adminapi/validate/match/MatchLiveValidate.php index 7965c70..5355c57 100644 --- a/server/app/adminapi/validate/match/MatchLiveValidate.php +++ b/server/app/adminapi/validate/match/MatchLiveValidate.php @@ -11,8 +11,8 @@ class MatchLiveValidate extends BaseValidate protected $rule = [ 'id' => 'require|integer|gt:0|checkLive', 'match_id' => 'require|integer|gt:0|checkMatch', - 'title' => 'require|length:1,255', - 'source_url' => 'require|max:500', + 'title' => 'require|checkTrimmedTitle', + 'source_url' => 'require|checkTrimmedSourceUrl', ]; protected $message = [ @@ -66,4 +66,28 @@ class MatchLiveValidate extends BaseValidate } return true; } + + public function checkTrimmedTitle($value) + { + $value = trim((string) $value); + if ($value === '') { + return '直播标题不能为空'; + } + if (mb_strlen($value) > 255) { + return '直播标题长度须在1-255位字符'; + } + return true; + } + + public function checkTrimmedSourceUrl($value) + { + $value = trim((string) $value); + if ($value === '') { + return '源地址不能为空'; + } + if (mb_strlen($value) > 500) { + return '源地址长度不能超过500位字符'; + } + return true; + } } From 7ebd563134fb22b5cb51e675eac0215f6441505f Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:08:40 +0800 Subject: [PATCH 09/24] fix: guard match live validator scalars --- qa/backend/test_match_live_backend_static.php | 3 +++ server/app/adminapi/validate/match/MatchLiveValidate.php | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php index 4d79e18..fb69100 100644 --- a/qa/backend/test_match_live_backend_static.php +++ b/qa/backend/test_match_live_backend_static.php @@ -129,6 +129,9 @@ $validate = file_get_contents($validatePath); foreach ([ 'checkTrimmedTitle', 'checkTrimmedSourceUrl', + "!is_scalar(\$value)", + "return '直播标题格式错误';", + "return '源地址格式错误';", '$value = trim((string) $value);', ] as $needle) { if (strpos($validate, $needle) === false) { diff --git a/server/app/adminapi/validate/match/MatchLiveValidate.php b/server/app/adminapi/validate/match/MatchLiveValidate.php index 5355c57..db7e370 100644 --- a/server/app/adminapi/validate/match/MatchLiveValidate.php +++ b/server/app/adminapi/validate/match/MatchLiveValidate.php @@ -69,6 +69,9 @@ class MatchLiveValidate extends BaseValidate public function checkTrimmedTitle($value) { + if (!is_scalar($value)) { + return '直播标题格式错误'; + } $value = trim((string) $value); if ($value === '') { return '直播标题不能为空'; @@ -81,6 +84,9 @@ class MatchLiveValidate extends BaseValidate public function checkTrimmedSourceUrl($value) { + if (!is_scalar($value)) { + return '源地址格式错误'; + } $value = trim((string) $value); if ($value === '') { return '源地址不能为空'; From bc9aef5a2f00dbc63cb853c797c77c27ccb2432d Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:15:40 +0800 Subject: [PATCH 10/24] feat: add docker match live stream crawler --- docker/crawler/config/crawler_tasks.yaml | 5 + docker/crawler/main.py | 22 ++ docker/crawler/match_live_stream.py | 260 ++++++++++++++++++ .../crawler/tests/test_match_live_stream.py | 44 +++ docs/sql/insert_crontab_match_live_stream.sql | 20 ++ 5 files changed, 351 insertions(+) create mode 100644 docker/crawler/match_live_stream.py create mode 100644 docker/crawler/tests/test_match_live_stream.py create mode 100644 docs/sql/insert_crontab_match_live_stream.sql diff --git a/docker/crawler/config/crawler_tasks.yaml b/docker/crawler/config/crawler_tasks.yaml index 635cc21..eba8150 100644 --- a/docker/crawler/config/crawler_tasks.yaml +++ b/docker/crawler/config/crawler_tasks.yaml @@ -154,3 +154,8 @@ tasks: action: ai_comment_dispatch cron: "*/10 * * * *" active: true + - key: match_live_stream + name: 赛事直播流抓取 + action: match_live_stream + cron: "*/1 * * * *" + active: true diff --git a/docker/crawler/main.py b/docker/crawler/main.py index 775c374..dd9cb21 100644 --- a/docker/crawler/main.py +++ b/docker/crawler/main.py @@ -30,6 +30,7 @@ python main.py nba_news # 采集NBA新闻资讯 python main.py cba_news # 采集CBA新闻资讯 python main.py ai_comment_dispatch # Docker AI 评论调度 + python main.py match_live_stream # 抓取赛事直播线路播放流 python main.py article_content_fetch # 抓取资讯文章正文内容 python main.py all # 采集积分榜 + 赛程 python main.py single # 采集单个联赛积分榜 @@ -59,6 +60,7 @@ TASK_ALERT_POLICY = { "lottery_draw": "saved_if_candidates", "lottery_draw_force": "saved_if_candidates", "ai_comment_dispatch": "saved_if_candidates", + "match_live_stream": "saved_if_candidates", } DEFAULT_ACTION_TIMEOUT = 600 @@ -86,6 +88,7 @@ ACTION_TIMEOUTS = { "fifa_worldcup_news": 600, "dqd_worldcup": 600, "ai_comment_dispatch": 1800, + "match_live_stream": 180, } @@ -522,6 +525,24 @@ async def cmd_ai_comment_dispatch(): return await asyncio.to_thread(run, db_config) +async def cmd_match_live_stream(): + import asyncio + from match_live_stream import run + from src.core.config import load_config + + cfg = load_config().database + db_config = { + "host": cfg.host, + "port": cfg.port, + "user": cfg.username, + "password": cfg.password, + "database": cfg.database, + "charset": cfg.charset, + "prefix": cfg.prefix, + } + return await asyncio.to_thread(run, db_config) + + async def cmd_match_data(): from src.scheduler.task_runner import TaskRunner async with TaskRunner() as runner: @@ -837,6 +858,7 @@ def main(): "nba_news": cmd_nba_news, "cba_news": cmd_cba_news, "ai_comment_dispatch": cmd_ai_comment_dispatch, + "match_live_stream": cmd_match_live_stream, "article_content_fetch": cmd_article_content_fetch, "match_finish": cmd_match_finish, "live_detail": cmd_live_detail, diff --git a/docker/crawler/match_live_stream.py b/docker/crawler/match_live_stream.py new file mode 100644 index 0000000..af4fb5d --- /dev/null +++ b/docker/crawler/match_live_stream.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List + +import pymysql +import requests + + +DETAIL_URL_TEMPLATE = "https://json.ncctrials.com/room/{room_id}/detail.json?v={ts}" +FETCH_BATCH_SIZE = 30 +FETCH_TIMEOUT = 15 +FETCH_ERROR_LIMIT = 500 +ROOM_ID_RE = re.compile(r"/room/(\d+)") +DETAIL_JSONP_RE = re.compile(r"detail\((\{.*\})\)\s*$", re.S) +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json,text/javascript,*/*;q=0.1", + "Referer": "https://yyzb1.tv/", +} + + +def parse_match_live_detail_html(body: str) -> dict: + match = DETAIL_JSONP_RE.search((body or "").strip()) + if not match: + raise ValueError("detail jsonp not found") + + payload = json.loads(match.group(1)) + stream = payload.get("data", {}).get("stream", {}) + parsed = { + "play_url_flv": str(stream.get("flv") or "").strip(), + "play_url_hd_flv": str(stream.get("hdFlv") or "").strip(), + "play_url_m3u8": str(stream.get("m3u8") or "").strip(), + "play_url_hd_m3u8": str(stream.get("hdM3u8") or "").strip(), + } + if not any(parsed.values()): + raise ValueError("stream urls empty") + return parsed + + +def choose_next_fetch_at(now_ts: int, has_existing_stream: bool) -> int: + return int(now_ts) + (600 if has_existing_stream else 60) + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _trim_error(message: Any) -> str: + return str(message or "").strip()[:FETCH_ERROR_LIMIT] + + +def _derive_room_id(source_url: str) -> str: + match = ROOM_ID_RE.search(str(source_url or "").strip()) + if not match: + raise ValueError("room id not found in source_url") + return match.group(1) + + +def _build_detail_url(source_url: str, now_ts: int) -> str: + room_id = _derive_room_id(source_url) + return DETAIL_URL_TEMPLATE.format(room_id=room_id, ts=int(now_ts)) + + +def _has_existing_stream(row: Dict[str, Any]) -> bool: + return any( + str(row.get(field) or "").strip() + for field in ("play_url_m3u8", "play_url_hd_m3u8", "play_url_flv", "play_url_hd_flv") + ) + + +def _fetch_stream_payload(source_url: str, now_ts: int) -> Dict[str, str]: + detail_url = _build_detail_url(source_url, now_ts) + response = requests.get(detail_url, timeout=FETCH_TIMEOUT, headers=DEFAULT_HEADERS) + response.raise_for_status() + return parse_match_live_detail_html(response.text) + + +class TaskFileLock: + def __init__(self, name: str, lock_dir: Path | None = None): + safe_name = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(name or "task")) + self.lock_dir = Path(lock_dir or os.environ.get("DQD_LOCK_DIR") or (Path(__file__).resolve().parent / "data" / "locks")) + self.path = self.lock_dir / f"{safe_name}.lock" + self._fp = None + self._locked = False + + def acquire(self) -> bool: + self.lock_dir.mkdir(parents=True, exist_ok=True) + self._fp = open(self.path, "a+b") + try: + self._fp.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(self._fp.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + self._locked = True + self._fp.seek(0) + self._fp.truncate() + self._fp.write(f"pid={os.getpid()} started_at={int(time.time())}\n".encode("utf-8")) + self._fp.flush() + return True + except (BlockingIOError, OSError): + self.release() + return False + + def release(self) -> None: + if not self._fp: + return + try: + if self._locked and os.name == "nt": + import msvcrt + + self._fp.seek(0) + msvcrt.locking(self._fp.fileno(), msvcrt.LK_UNLCK, 1) + elif self._locked: + import fcntl + + fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) + finally: + self._fp.close() + self._fp = None + self._locked = False + + +@dataclass +class MatchLiveStreamRepository: + db_config: Dict[str, Any] + + def __post_init__(self) -> None: + self.prefix = str(self.db_config.get("prefix") or "la_") + self.conn = pymysql.connect( + host=self.db_config["host"], + port=int(self.db_config["port"]), + user=self.db_config["user"], + password=self.db_config["password"], + database=self.db_config["database"], + charset=self.db_config.get("charset") or "utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + + def close(self) -> None: + self.conn.close() + + def load_due_rows(self, now_ts: int, limit: int = FETCH_BATCH_SIZE) -> List[Dict[str, Any]]: + sql = ( + f"SELECT id, match_id, title, source_url, play_url_flv, play_url_hd_flv, play_url_m3u8, play_url_hd_m3u8 " + f"FROM `{self.prefix}match_live` " + f"WHERE `delete_time` IS NULL AND `next_fetch_at` <= %s " + f"ORDER BY `next_fetch_at` ASC, `id` ASC LIMIT %s" + ) + with self.conn.cursor() as cur: + cur.execute(sql, (int(now_ts), int(limit))) + return list(cur.fetchall() or []) + + def mark_success(self, row_id: int, stream_urls: Dict[str, str], now_ts: int) -> None: + sql = ( + f"UPDATE `{self.prefix}match_live` SET " + "`play_url_flv`=%s, `play_url_hd_flv`=%s, `play_url_m3u8`=%s, `play_url_hd_m3u8`=%s, " + "`fetch_status`=1, `fetch_error`='', `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s " + "WHERE `id`=%s" + ) + next_fetch_at = choose_next_fetch_at(now_ts, has_existing_stream=True) + with self.conn.cursor() as cur: + cur.execute( + sql, + ( + stream_urls["play_url_flv"], + stream_urls["play_url_hd_flv"], + stream_urls["play_url_m3u8"], + stream_urls["play_url_hd_m3u8"], + int(now_ts), + next_fetch_at, + int(now_ts), + int(row_id), + ), + ) + self.conn.commit() + + def mark_failed(self, row_id: int, error_message: str, now_ts: int, has_existing_stream: bool) -> None: + sql = ( + f"UPDATE `{self.prefix}match_live` SET " + "`fetch_status`=2, `fetch_error`=%s, `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s " + "WHERE `id`=%s" + ) + with self.conn.cursor() as cur: + cur.execute( + sql, + ( + _trim_error(error_message), + int(now_ts), + choose_next_fetch_at(now_ts, has_existing_stream=has_existing_stream), + int(now_ts), + int(row_id), + ), + ) + self.conn.commit() + + +def run(db_config: Dict[str, Any]) -> Dict[str, Any]: + lock = TaskFileLock("match_live_stream") + if not lock.acquire(): + return { + "success": True, + "candidate_count": 0, + "saved_count": 0, + "summary_text": "match_live_stream 已在执行中,跳过本次", + } + + repo = MatchLiveStreamRepository(db_config) + now_ts = int(time.time()) + success_count = 0 + failed_count = 0 + rows: List[Dict[str, Any]] = [] + + try: + rows = repo.load_due_rows(now_ts, limit=FETCH_BATCH_SIZE) + for row in rows: + fetch_ts = int(time.time()) + has_existing_stream = _has_existing_stream(row) + try: + stream_urls = _fetch_stream_payload(str(row.get("source_url") or ""), fetch_ts) + repo.mark_success(_safe_int(row.get("id")), stream_urls, fetch_ts) + success_count += 1 + except Exception as exc: # noqa: BLE001 + repo.mark_failed(_safe_int(row.get("id")), str(exc), fetch_ts, has_existing_stream) + failed_count += 1 + + candidate_count = len(rows) + return { + "success": True, + "candidate_count": candidate_count, + "saved_count": success_count, + "count": success_count, + "failed_count": failed_count, + "summary_text": ( + f"赛事直播流抓取完成: 待处理 {candidate_count} 条, " + f"成功 {success_count} 条, 失败 {failed_count} 条" + ), + } + finally: + repo.close() + lock.release() diff --git a/docker/crawler/tests/test_match_live_stream.py b/docker/crawler/tests/test_match_live_stream.py new file mode 100644 index 0000000..a7bca80 --- /dev/null +++ b/docker/crawler/tests/test_match_live_stream.py @@ -0,0 +1,44 @@ +import json +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from match_live_stream import choose_next_fetch_at, parse_match_live_detail_html + + +class MatchLiveStreamTest(unittest.TestCase): + def test_parse_json_ncctrials_detail_extracts_four_urls(self): + payload = { + "code": 200, + "msg": "ok", + "data": { + "stream": { + "flv": "http://a/flv", + "hdFlv": "http://a/hdflv", + "m3u8": "http://a/m3u8", + "hdM3u8": "http://a/hdm3u8", + } + }, + } + + parsed = parse_match_live_detail_html(f"detail({json.dumps(payload, ensure_ascii=False)})") + + self.assertEqual(parsed["play_url_m3u8"], "http://a/m3u8") + self.assertEqual(parsed["play_url_hd_m3u8"], "http://a/hdm3u8") + self.assertEqual(parsed["play_url_flv"], "http://a/flv") + self.assertEqual(parsed["play_url_hd_flv"], "http://a/hdflv") + + def test_choose_next_fetch_at_uses_60s_for_empty_streams(self): + self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=False), 1060) + + def test_choose_next_fetch_at_uses_600s_for_existing_streams(self): + self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=True), 1600) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/sql/insert_crontab_match_live_stream.sql b/docs/sql/insert_crontab_match_live_stream.sql new file mode 100644 index 0000000..8311b69 --- /dev/null +++ b/docs/sql/insert_crontab_match_live_stream.sql @@ -0,0 +1,20 @@ +INSERT INTO `la_dev_crontab` +(`name`, `type`, `system`, `remark`, `command`, `params`, `status`, `expression`, `create_time`, `update_time`) +SELECT + '赛事直播流抓取', + 1, + 0, + 'Docker crawler 每分钟抓取赛事直播线路播放地址', + 'crawler', + 'match_live_stream', + 1, + '* * * * *', + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 + FROM `la_dev_crontab` + WHERE `command` = 'crawler' + AND `params` = 'match_live_stream' + AND `delete_time` IS NULL +); From 11b06be9b18ec9ecf78b747daf361dfde3868427 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:21:48 +0800 Subject: [PATCH 11/24] test: cover semicolon jsonp in match live stream parser --- .../crawler/tests/test_match_live_stream.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docker/crawler/tests/test_match_live_stream.py b/docker/crawler/tests/test_match_live_stream.py index a7bca80..fa46c8a 100644 --- a/docker/crawler/tests/test_match_live_stream.py +++ b/docker/crawler/tests/test_match_live_stream.py @@ -33,6 +33,27 @@ class MatchLiveStreamTest(unittest.TestCase): self.assertEqual(parsed["play_url_flv"], "http://a/flv") self.assertEqual(parsed["play_url_hd_flv"], "http://a/hdflv") + def test_parse_json_ncctrials_detail_accepts_trailing_semicolon(self): + payload = { + "code": 200, + "msg": "ok", + "data": { + "stream": { + "flv": "http://b/flv", + "hdFlv": "http://b/hdflv", + "m3u8": "http://b/m3u8", + "hdM3u8": "http://b/hdm3u8", + } + }, + } + + parsed = parse_match_live_detail_html(f"detail({json.dumps(payload, ensure_ascii=False)});") + + self.assertEqual(parsed["play_url_m3u8"], "http://b/m3u8") + self.assertEqual(parsed["play_url_hd_m3u8"], "http://b/hdm3u8") + self.assertEqual(parsed["play_url_flv"], "http://b/flv") + self.assertEqual(parsed["play_url_hd_flv"], "http://b/hdflv") + def test_choose_next_fetch_at_uses_60s_for_empty_streams(self): self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=False), 1060) From ecef509037c2cf8a0a2bcdc5c2ed5e58b99b0c80 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:22:19 +0800 Subject: [PATCH 12/24] fix: accept semicolon jsonp in live stream parser --- docker/crawler/match_live_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/crawler/match_live_stream.py b/docker/crawler/match_live_stream.py index af4fb5d..02dbca3 100644 --- a/docker/crawler/match_live_stream.py +++ b/docker/crawler/match_live_stream.py @@ -18,7 +18,7 @@ FETCH_BATCH_SIZE = 30 FETCH_TIMEOUT = 15 FETCH_ERROR_LIMIT = 500 ROOM_ID_RE = re.compile(r"/room/(\d+)") -DETAIL_JSONP_RE = re.compile(r"detail\((\{.*\})\)\s*$", re.S) +DETAIL_JSONP_RE = re.compile(r"detail\((\{.*\})\)\s*;?\s*$", re.S) DEFAULT_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " From a9281f27c7e92b9bee9f2a4bdf2f4c41576e4044 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:29:36 +0800 Subject: [PATCH 13/24] fix: surface match live stream partial failures --- docker/crawler/match_live_stream.py | 2 +- docker/crawler/tests/test_match_live_stream.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docker/crawler/match_live_stream.py b/docker/crawler/match_live_stream.py index 02dbca3..c108a0a 100644 --- a/docker/crawler/match_live_stream.py +++ b/docker/crawler/match_live_stream.py @@ -245,7 +245,7 @@ def run(db_config: Dict[str, Any]) -> Dict[str, Any]: candidate_count = len(rows) return { - "success": True, + "success": failed_count == 0, "candidate_count": candidate_count, "saved_count": success_count, "count": success_count, diff --git a/docker/crawler/tests/test_match_live_stream.py b/docker/crawler/tests/test_match_live_stream.py index fa46c8a..f8fc824 100644 --- a/docker/crawler/tests/test_match_live_stream.py +++ b/docker/crawler/tests/test_match_live_stream.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from match_live_stream import choose_next_fetch_at, parse_match_live_detail_html +from match_live_stream import _build_detail_url, choose_next_fetch_at, parse_match_live_detail_html class MatchLiveStreamTest(unittest.TestCase): @@ -54,6 +54,17 @@ class MatchLiveStreamTest(unittest.TestCase): self.assertEqual(parsed["play_url_flv"], "http://b/flv") self.assertEqual(parsed["play_url_hd_flv"], "http://b/hdflv") + def test_build_detail_url_derives_room_id_from_source_url(self): + detail_url = _build_detail_url( + "https://yyzb1.tv/room/7988511?scheduleId=undefined", + now_ts=123456, + ) + + self.assertEqual( + detail_url, + "https://json.ncctrials.com/room/7988511/detail.json?v=123456", + ) + def test_choose_next_fetch_at_uses_60s_for_empty_streams(self): self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=False), 1060) From 750ef91e29db7ddd912ae242a9465622e8b68ebd Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:32:49 +0800 Subject: [PATCH 14/24] fix: expose match live stream task error on failure --- docker/crawler/match_live_stream.py | 30 ++++++++++++------- .../crawler/tests/test_match_live_stream.py | 18 ++++++++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/docker/crawler/match_live_stream.py b/docker/crawler/match_live_stream.py index c108a0a..a43a508 100644 --- a/docker/crawler/match_live_stream.py +++ b/docker/crawler/match_live_stream.py @@ -89,6 +89,24 @@ def _fetch_stream_payload(source_url: str, now_ts: int) -> Dict[str, str]: return parse_match_live_detail_html(response.text) +def _build_run_result(candidate_count: int, success_count: int, failed_count: int) -> Dict[str, Any]: + summary_text = ( + f"赛事直播流抓取完成: 待处理 {candidate_count} 条, " + f"成功 {success_count} 条, 失败 {failed_count} 条" + ) + result = { + "success": failed_count == 0, + "candidate_count": candidate_count, + "saved_count": success_count, + "count": success_count, + "failed_count": failed_count, + "summary_text": summary_text, + } + if failed_count > 0: + result["error"] = f"赛事直播流抓取存在失败线路: 失败 {failed_count} 条" + return result + + class TaskFileLock: def __init__(self, name: str, lock_dir: Path | None = None): safe_name = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(name or "task")) @@ -244,17 +262,7 @@ def run(db_config: Dict[str, Any]) -> Dict[str, Any]: failed_count += 1 candidate_count = len(rows) - return { - "success": failed_count == 0, - "candidate_count": candidate_count, - "saved_count": success_count, - "count": success_count, - "failed_count": failed_count, - "summary_text": ( - f"赛事直播流抓取完成: 待处理 {candidate_count} 条, " - f"成功 {success_count} 条, 失败 {failed_count} 条" - ), - } + return _build_run_result(candidate_count, success_count, failed_count) finally: repo.close() lock.release() diff --git a/docker/crawler/tests/test_match_live_stream.py b/docker/crawler/tests/test_match_live_stream.py index f8fc824..8bfc7cb 100644 --- a/docker/crawler/tests/test_match_live_stream.py +++ b/docker/crawler/tests/test_match_live_stream.py @@ -8,7 +8,12 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from match_live_stream import _build_detail_url, choose_next_fetch_at, parse_match_live_detail_html +from match_live_stream import ( + _build_detail_url, + _build_run_result, + choose_next_fetch_at, + parse_match_live_detail_html, +) class MatchLiveStreamTest(unittest.TestCase): @@ -65,6 +70,17 @@ class MatchLiveStreamTest(unittest.TestCase): "https://json.ncctrials.com/room/7988511/detail.json?v=123456", ) + def test_build_run_result_exposes_top_level_error_for_partial_failure(self): + result = _build_run_result(candidate_count=3, success_count=2, failed_count=1) + + self.assertFalse(result["success"]) + self.assertEqual(result["candidate_count"], 3) + self.assertEqual(result["saved_count"], 2) + self.assertEqual(result["count"], 2) + self.assertEqual(result["failed_count"], 1) + self.assertIn("失败 1 条", result["summary_text"]) + self.assertEqual(result["error"], "赛事直播流抓取存在失败线路: 失败 1 条") + def test_choose_next_fetch_at_uses_60s_for_empty_streams(self): self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=False), 1060) From 5f2e2e6c0094e7cb1d06249acec310ad00922c26 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:46:53 +0800 Subject: [PATCH 15/24] feat: add uniapp match live stream switching --- qa/frontend/test_match_detail_live_static.py | 31 ++ uniapp/src/api/match.ts | 34 ++ .../match-live-popup/match-live-popup.vue | 329 ++++++++++++++++++ .../src/pages/match_detail/match_detail.vue | 103 +++++- 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 qa/frontend/test_match_detail_live_static.py create mode 100644 uniapp/src/components/match-live-popup/match-live-popup.vue diff --git a/qa/frontend/test_match_detail_live_static.py b/qa/frontend/test_match_detail_live_static.py new file mode 100644 index 0000000..d9c335b --- /dev/null +++ b/qa/frontend/test_match_detail_live_static.py @@ -0,0 +1,31 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MATCH_API = ROOT / "uniapp" / "src" / "api" / "match.ts" +MATCH_DETAIL = ROOT / "uniapp" / "src" / "pages" / "match_detail" / "match_detail.vue" +MATCH_LIVE_POPUP = ROOT / "uniapp" / "src" / "components" / "match-live-popup" / "match-live-popup.vue" + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def main() -> None: + assert MATCH_LIVE_POPUP.exists(), f"missing popup component: {MATCH_LIVE_POPUP}" + + detail_source = read_text(MATCH_DETAIL) + popup_source = read_text(MATCH_LIVE_POPUP) + api_source = read_text(MATCH_API) + + assert "live_list" in detail_source, "match detail should handle live_list" + assert "MatchLivePopup" in detail_source, "match detail should use MatchLivePopup" + assert "play_url_m3u8" in popup_source, "popup should support play_url_m3u8" + assert "stream_options" in popup_source, "popup should support stream_options" + assert "/pages/webview/webview?url=" in popup_source, "selected stream should route through existing webview page" + + assert "/match/detail" in api_source, "match detail API path should stay on /match/detail" + + +if __name__ == "__main__": + main() diff --git a/uniapp/src/api/match.ts b/uniapp/src/api/match.ts index f883994..b2acc4b 100644 --- a/uniapp/src/api/match.ts +++ b/uniapp/src/api/match.ts @@ -1,5 +1,39 @@ import request from '@/utils/request' +export interface MatchLiveStreamOption { + title?: string + name?: string + label?: string + source_name?: string + quality?: string + url?: string + play_url?: string + play_url_m3u8?: string + default_play_url?: string + live_url?: string + [key: string]: any +} + +export interface MatchLiveLineItem { + title?: string + name?: string + label?: string + live_tag?: string + source_name?: string + status?: string | number + status_text?: string + status_desc?: string + update_time?: string | number + updated_at?: string | number + source_url?: string + default_play_url?: string + play_url_m3u8?: string + play_url?: string + live_url?: string + stream_options?: MatchLiveStreamOption[] + [key: string]: any +} + export function getMatchList(data?: Record) { return request.get({ url: '/match/lists', data: data }, { withToken: false }) } diff --git a/uniapp/src/components/match-live-popup/match-live-popup.vue b/uniapp/src/components/match-live-popup/match-live-popup.vue new file mode 100644 index 0000000..4843cae --- /dev/null +++ b/uniapp/src/components/match-live-popup/match-live-popup.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/uniapp/src/pages/match_detail/match_detail.vue b/uniapp/src/pages/match_detail/match_detail.vue index 7dbaaaf..457fea7 100644 --- a/uniapp/src/pages/match_detail/match_detail.vue +++ b/uniapp/src/pages/match_detail/match_detail.vue @@ -62,8 +62,31 @@ + + + 直播线路 + 选择线路后通过内置 WebView 打开 + + + + + {{ index + 1 }} + + + {{ getLiveLineTitle(item) }} + {{ getLiveLineMeta(item) }} + + + + 切换线路 + + + + + - + @@ -412,6 +435,8 @@ + + import { ref, computed, nextTick, onMounted } from 'vue' import MatchDetailHeader from '@/components/match-detail-header/match-detail-header.vue' +import MatchLivePopup from '@/components/match-live-popup/match-live-popup.vue' import SharePopup from '@/components/share-popup/share-popup.vue' import GlobalPopup from '@/components/global-popup/global-popup.vue' import { onLoad, onUnload } from '@dcloudio/uni-app' import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match' +import type { MatchLiveLineItem } from '@/api/match' import { checkAiUnlock, getMatchPredictSection } from '@/api/ai' import { useUserStore } from '@/stores/user' import { getLocalMatchDate } from '@/utils/match-time' @@ -447,9 +474,14 @@ const aiInlineLoading = ref(false) const aiInline = ref({}) const showSharePopup = ref(false) const liveTextList = ref([]) +const showLivePopup = ref(false) +const selectedLiveLine = ref(null) let liveTextTimer: ReturnType | null = null const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim()) +const liveList = computed(() => { + return Array.isArray(match.value?.live_list) ? match.value.live_list : [] +}) const liveSourceText = computed(() => { const raw = match.value?.living_tv ?? match.value?.live_source ?? match.value?.livingTv ?? '' @@ -480,6 +512,41 @@ const homeSubs = computed(() => lineupList.value.filter((p: any) => p.team_side const awayStarters = computed(() => lineupList.value.filter((p: any) => p.team_side === 2 && p.is_starter === 1)) const awaySubs = computed(() => lineupList.value.filter((p: any) => p.team_side === 2 && p.is_starter === 0)) +const normalizeLiveText = (value: unknown) => String(value || '').trim() + +const getLiveLineTitle = (item: MatchLiveLineItem) => { + return ( + normalizeLiveText(item?.title) || + normalizeLiveText(item?.name) || + normalizeLiveText(item?.label) || + normalizeLiveText(item?.live_tag) || + normalizeLiveText(item?.source_name) || + '直播线路' + ) +} + +const formatLiveLineUpdate = (value: unknown) => { + const text = normalizeLiveText(value) + if (!text) return '' + const hhmmss = text.match(/(\d{2}:\d{2}:\d{2})$/) + if (hhmmss) return hhmmss[1] + const hhmm = text.match(/(\d{2}:\d{2})$/) + if (hhmm) return hhmm[1] + if (/^\d{10,13}$/.test(text)) { + const raw = text.length === 13 ? Number(text) : Number(text) * 1000 + return formatLiveTime(raw / 1000) + } + return text +} + +const getLiveLineMeta = (item: MatchLiveLineItem) => { + const parts = [ + normalizeLiveText(item?.status_text) || normalizeLiveText(item?.status_desc) || normalizeLiveText(item?.status), + formatLiveLineUpdate(item?.updated_at || item?.update_time), + ].filter(Boolean) + return parts.length ? parts.join(' · ') : '点击选择直播线路' +} + const techStatsList = computed(() => { const m = match.value as any // 优先使用 tech_stats JSON(兼容所有运动类型) @@ -540,6 +607,8 @@ onMounted(() => { const fetchDetail = async (id: number) => { loading.value = true + showLivePopup.value = false + selectedLiveLine.value = null try { const data = await getMatchDetail({ id }) match.value = data || {} @@ -604,6 +673,15 @@ const openThirdPartyLive = () => { uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` }) } +const openLiveLine = (item: MatchLiveLineItem) => { + selectedLiveLine.value = item + showLivePopup.value = true +} + +const handleLivePopupClose = () => { + showLivePopup.value = false +} + const formatLiveTime = (ts: any) => { if (!ts) return '' const s = String(ts) @@ -1560,6 +1638,29 @@ onUnload(() => { .live-entrance { margin: 0 24rpx 24rpx; + display: flex; + flex-direction: column; + gap: 16rpx; + + &__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16rpx; + padding: 0 4rpx; + } + + &__heading { + font-size: 30rpx; + font-weight: 700; + color: #111827; + } + + &__subheading { + font-size: 22rpx; + color: #6b7280; + text-align: right; + } &__card { display: flex; From c59b7ade29cc0d543cd95704f90b3c3d4294afca Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 12:57:27 +0800 Subject: [PATCH 16/24] fix: align uniapp live stream metadata fields --- qa/frontend/test_match_detail_live_static.py | 26 +++++++- uniapp/src/api/match.ts | 15 +++++ .../match-live-popup/match-live-popup.vue | 65 +++++++++++-------- .../src/pages/match_detail/match_detail.vue | 14 +++- 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/qa/frontend/test_match_detail_live_static.py b/qa/frontend/test_match_detail_live_static.py index d9c335b..8a0cc35 100644 --- a/qa/frontend/test_match_detail_live_static.py +++ b/qa/frontend/test_match_detail_live_static.py @@ -1,4 +1,5 @@ from pathlib import Path +import re ROOT = Path(__file__).resolve().parents[2] @@ -18,12 +19,33 @@ def main() -> None: popup_source = read_text(MATCH_LIVE_POPUP) api_source = read_text(MATCH_API) - assert "live_list" in detail_source, "match detail should handle live_list" + assert 'v-if="liveList.length"' in detail_source, "match detail should render live_list block with v-if" + assert 'v-else-if="thirdPartyLiveUrl"' in detail_source, "match detail should keep thirdPartyLiveUrl fallback with v-else-if" assert "MatchLivePopup" in detail_source, "match detail should use MatchLivePopup" - assert "play_url_m3u8" in popup_source, "popup should support play_url_m3u8" + assert "fetch_status" in detail_source, "match detail should reference fetch_status" + assert "last_fetch_at" in detail_source, "match detail should reference last_fetch_at" + assert "update_time" in detail_source, "match detail should reference update_time" + assert "default_play_url" in popup_source, "popup should prefer default_play_url" assert "stream_options" in popup_source, "popup should support stream_options" assert "/pages/webview/webview?url=" in popup_source, "selected stream should route through existing webview page" + field_names = [ + "play_url_m3u8", + "play_url_hd_m3u8", + "play_url_flv", + "play_url_hd_flv", + ] + for field_name in field_names: + assert field_name in popup_source, f"popup should cover {field_name}" + assert field_name in api_source, f"match live types should cover {field_name}" + + assert re.search(r"const\s+defaultPlayUrl\s*=\s*normalizeUrl\(props\.line\?\.default_play_url\)", popup_source), \ + "popup should explicitly prefer props.line?.default_play_url" + assert re.search(r"defaultPlayUrl.*?resetSelected", popup_source, re.S), \ + "popup selection reset should prefer default_play_url" + assert re.search(r"fetch_status.*last_fetch_at", popup_source, re.S) or "update_time" in popup_source, \ + "popup metadata should use fetch_status and last_fetch_at/update_time" + assert "/match/detail" in api_source, "match detail API path should stay on /match/detail" diff --git a/uniapp/src/api/match.ts b/uniapp/src/api/match.ts index b2acc4b..667038e 100644 --- a/uniapp/src/api/match.ts +++ b/uniapp/src/api/match.ts @@ -1,6 +1,7 @@ import request from '@/utils/request' export interface MatchLiveStreamOption { + id?: number | string title?: string name?: string label?: string @@ -9,12 +10,21 @@ export interface MatchLiveStreamOption { url?: string play_url?: string play_url_m3u8?: string + play_url_hd_m3u8?: string + play_url_flv?: string + play_url_hd_flv?: string default_play_url?: string live_url?: string + source_url?: string + fetch_status?: string | number + last_fetch_at?: string | number + update_time?: string | number + stream_options?: MatchLiveStreamOption[] [key: string]: any } export interface MatchLiveLineItem { + id?: number | string title?: string name?: string label?: string @@ -28,8 +38,13 @@ export interface MatchLiveLineItem { source_url?: string default_play_url?: string play_url_m3u8?: string + play_url_hd_m3u8?: string + play_url_flv?: string + play_url_hd_flv?: string play_url?: string live_url?: string + fetch_status?: string | number + last_fetch_at?: string | number stream_options?: MatchLiveStreamOption[] [key: string]: any } diff --git a/uniapp/src/components/match-live-popup/match-live-popup.vue b/uniapp/src/components/match-live-popup/match-live-popup.vue index 4843cae..44ef0ec 100644 --- a/uniapp/src/components/match-live-popup/match-live-popup.vue +++ b/uniapp/src/components/match-live-popup/match-live-popup.vue @@ -67,6 +67,16 @@ const normalizeUrl = (value: unknown) => { return text } +const getFetchStatusText = (value: unknown) => { + const text = normalizeText(value) + if (!text) return '' + const normalized = text.toLowerCase() + if (normalized === 'success' || normalized === '1') return '已抓取' + if (normalized === 'pending' || normalized === '0') return '待抓取' + if (normalized === 'failed' || normalized === '-1') return '抓取失败' + return text +} + const formatMetaTime = (value: unknown) => { const text = normalizeText(value) if (!text) return '' @@ -101,8 +111,8 @@ const lineTitle = computed(() => { const lineMetaText = computed(() => { const line = props.line || {} const parts = [ - normalizeText(line.status_text) || normalizeText(line.status_desc) || normalizeText(line.status), - formatMetaTime(line.updated_at || line.update_time), + getFetchStatusText(line.fetch_status), + formatMetaTime(line.last_fetch_at || line.update_time), ].filter(Boolean) return parts.length ? parts.join(' · ') : '请选择可用直播线路后打开' }) @@ -111,12 +121,18 @@ const sourceUrl = computed(() => normalizeUrl(props.line?.source_url)) const buildStreamOption = (option: MatchLiveStreamOption | undefined, index: number) => { if (!option) return null - const url = - normalizeUrl(option.play_url_m3u8) || - normalizeUrl(option.play_url) || - normalizeUrl(option.url) || - normalizeUrl(option.default_play_url) || - normalizeUrl(option.live_url) + const url = ( + [ + option.default_play_url, + option.play_url_m3u8, + option.play_url_hd_m3u8, + option.play_url_flv, + option.play_url_hd_flv, + option.play_url, + option.url, + option.live_url, + ].map((item) => normalizeUrl(item)).find(Boolean) || '' + ) if (!url) return null return { ...option, @@ -136,6 +152,15 @@ const streamOptions = computed(() => { const line = props.line || {} const options: PopupStreamOption[] = [] const seen = new Set() + const fallbackFields: Array<{ field: keyof MatchLiveLineItem; label: string }> = [ + { field: 'default_play_url', label: '默认线路' }, + { field: 'play_url_m3u8', label: 'M3U8 线路' }, + { field: 'play_url_hd_m3u8', label: '高清 M3U8' }, + { field: 'play_url_flv', label: 'FLV 线路' }, + { field: 'play_url_hd_flv', label: '高清 FLV' }, + { field: 'play_url', label: '备用线路' }, + { field: 'live_url', label: '直播地址' }, + ] const pushOption = (option: MatchLiveStreamOption | undefined, index: number) => { const built = buildStreamOption(option, index) @@ -148,25 +173,11 @@ const streamOptions = computed(() => { line.stream_options.forEach((item, index) => pushOption(item, index)) } - const defaultPlayUrl = normalizeUrl(line.default_play_url) - if (defaultPlayUrl && !seen.has(defaultPlayUrl)) { - pushOption({ label: '默认线路', url: defaultPlayUrl }, options.length) - } - - const playUrlM3u8 = normalizeUrl(line.play_url_m3u8) - if (playUrlM3u8 && !seen.has(playUrlM3u8)) { - pushOption({ label: 'M3U8 线路', play_url_m3u8: playUrlM3u8 }, options.length) - } - - const playUrl = normalizeUrl(line.play_url) - if (playUrl && !seen.has(playUrl)) { - pushOption({ label: '备用线路', play_url: playUrl }, options.length) - } - - const liveUrl = normalizeUrl(line.live_url) - if (liveUrl && !seen.has(liveUrl)) { - pushOption({ label: '直播地址', live_url: liveUrl }, options.length) - } + fallbackFields.forEach(({ field, label }) => { + const value = normalizeUrl(line[field]) + if (!value || seen.has(value)) return + pushOption({ label, [field]: value }, options.length) + }) return options }) diff --git a/uniapp/src/pages/match_detail/match_detail.vue b/uniapp/src/pages/match_detail/match_detail.vue index 457fea7..add4972 100644 --- a/uniapp/src/pages/match_detail/match_detail.vue +++ b/uniapp/src/pages/match_detail/match_detail.vue @@ -514,6 +514,16 @@ const awaySubs = computed(() => lineupList.value.filter((p: any) => p.team_side const normalizeLiveText = (value: unknown) => String(value || '').trim() +const getLiveFetchStatusText = (value: unknown) => { + const text = normalizeLiveText(value) + if (!text) return '' + const normalized = text.toLowerCase() + if (normalized === 'success' || normalized === '1') return '已抓取' + if (normalized === 'pending' || normalized === '0') return '待抓取' + if (normalized === 'failed' || normalized === '-1') return '抓取失败' + return text +} + const getLiveLineTitle = (item: MatchLiveLineItem) => { return ( normalizeLiveText(item?.title) || @@ -541,8 +551,8 @@ const formatLiveLineUpdate = (value: unknown) => { const getLiveLineMeta = (item: MatchLiveLineItem) => { const parts = [ - normalizeLiveText(item?.status_text) || normalizeLiveText(item?.status_desc) || normalizeLiveText(item?.status), - formatLiveLineUpdate(item?.updated_at || item?.update_time), + getLiveFetchStatusText(item?.fetch_status), + formatLiveLineUpdate(item?.last_fetch_at || item?.update_time), ].filter(Boolean) return parts.length ? parts.join(' · ') : '点击选择直播线路' } From a8a678a5f605ee661725cfb2477471e44cf63536 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:02:22 +0800 Subject: [PATCH 17/24] fix: map live fetch status failure code --- qa/frontend/test_match_detail_live_static.py | 12 ++++++++++++ .../components/match-live-popup/match-live-popup.vue | 4 ++-- uniapp/src/pages/match_detail/match_detail.vue | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/qa/frontend/test_match_detail_live_static.py b/qa/frontend/test_match_detail_live_static.py index 8a0cc35..5020f82 100644 --- a/qa/frontend/test_match_detail_live_static.py +++ b/qa/frontend/test_match_detail_live_static.py @@ -45,6 +45,18 @@ def main() -> None: "popup selection reset should prefer default_play_url" assert re.search(r"fetch_status.*last_fetch_at", popup_source, re.S) or "update_time" in popup_source, \ "popup metadata should use fetch_status and last_fetch_at/update_time" + assert re.search(r"normalized === '0'.*待抓取", popup_source, re.S), \ + "popup should map fetch_status 0 to pending label" + assert re.search(r"normalized === '1'.*已抓取", popup_source, re.S), \ + "popup should map fetch_status 1 to success label" + assert re.search(r"normalized === '2'.*抓取失败", popup_source, re.S), \ + "popup should map fetch_status 2 to failure label" + assert re.search(r"normalized === '0'.*待抓取", detail_source, re.S), \ + "page should map fetch_status 0 to pending label" + assert re.search(r"normalized === '1'.*已抓取", detail_source, re.S), \ + "page should map fetch_status 1 to success label" + assert re.search(r"normalized === '2'.*抓取失败", detail_source, re.S), \ + "page should map fetch_status 2 to failure label" assert "/match/detail" in api_source, "match detail API path should stay on /match/detail" diff --git a/uniapp/src/components/match-live-popup/match-live-popup.vue b/uniapp/src/components/match-live-popup/match-live-popup.vue index 44ef0ec..2c23caa 100644 --- a/uniapp/src/components/match-live-popup/match-live-popup.vue +++ b/uniapp/src/components/match-live-popup/match-live-popup.vue @@ -71,9 +71,9 @@ const getFetchStatusText = (value: unknown) => { const text = normalizeText(value) if (!text) return '' const normalized = text.toLowerCase() - if (normalized === 'success' || normalized === '1') return '已抓取' if (normalized === 'pending' || normalized === '0') return '待抓取' - if (normalized === 'failed' || normalized === '-1') return '抓取失败' + if (normalized === 'success' || normalized === '1') return '已抓取' + if (normalized === 'failed' || normalized === 'error' || normalized === '2') return '抓取失败' return text } diff --git a/uniapp/src/pages/match_detail/match_detail.vue b/uniapp/src/pages/match_detail/match_detail.vue index add4972..1b18d44 100644 --- a/uniapp/src/pages/match_detail/match_detail.vue +++ b/uniapp/src/pages/match_detail/match_detail.vue @@ -518,9 +518,9 @@ const getLiveFetchStatusText = (value: unknown) => { const text = normalizeLiveText(value) if (!text) return '' const normalized = text.toLowerCase() - if (normalized === 'success' || normalized === '1') return '已抓取' if (normalized === 'pending' || normalized === '0') return '待抓取' - if (normalized === 'failed' || normalized === '-1') return '抓取失败' + if (normalized === 'success' || normalized === '1') return '已抓取' + if (normalized === 'failed' || normalized === 'error' || normalized === '2') return '抓取失败' return text } From 173871cf6a4e9cad9c2f7c550b42df56d1e002e5 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:10:33 +0800 Subject: [PATCH 18/24] Add static admin QA for match live table --- qa/frontend/test_match_live_admin_static.py | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 qa/frontend/test_match_live_admin_static.py diff --git a/qa/frontend/test_match_live_admin_static.py b/qa/frontend/test_match_live_admin_static.py new file mode 100644 index 0000000..10c3d61 --- /dev/null +++ b/qa/frontend/test_match_live_admin_static.py @@ -0,0 +1,38 @@ +from pathlib import Path + + +ROOT = Path(r"D:\www\sport-era\admin") +MATCH_API = ROOT / "src" / "api" / "match.ts" +MATCH_INDEX = ROOT / "src" / "views" / "match" / "lists" / "index.vue" +MATCH_LIVE_TABLE = ROOT / "src" / "views" / "match" / "lists" / "components" / "MatchLiveTable.vue" + + +def assert_contains(path: Path, needle: str) -> None: + content = path.read_text(encoding="utf-8") + assert needle in content, f"{path} missing: {needle}" + + +def main() -> None: + assert_contains(MATCH_INDEX, 'type="expand"') + assert_contains(MATCH_INDEX, "MatchLiveTable") + + for needle in ( + "matchLiveLists", + "matchLiveAdd", + "matchLiveEdit", + "matchLiveDelete", + ): + assert_contains(MATCH_API, needle) + + for needle in ( + "play_url_m3u8", + "fetch_status", + "source_url", + ): + assert_contains(MATCH_LIVE_TABLE, needle) + + print("match live admin static checks passed") + + +if __name__ == "__main__": + main() From 4ab24a9ae8d3f960b0706535211b13405cd8b627 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:21:55 +0800 Subject: [PATCH 19/24] Strengthen match live admin static QA --- qa/frontend/test_match_live_admin_static.py | 61 ++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/qa/frontend/test_match_live_admin_static.py b/qa/frontend/test_match_live_admin_static.py index 10c3d61..3295890 100644 --- a/qa/frontend/test_match_live_admin_static.py +++ b/qa/frontend/test_match_live_admin_static.py @@ -13,23 +13,80 @@ def assert_contains(path: Path, needle: str) -> None: def main() -> None: + match_api = MATCH_API.read_text(encoding="utf-8") + match_live_table = MATCH_LIVE_TABLE.read_text(encoding="utf-8") + assert_contains(MATCH_INDEX, 'type="expand"') assert_contains(MATCH_INDEX, "MatchLiveTable") for needle in ( "matchLiveLists", + "matchLiveDetail", "matchLiveAdd", "matchLiveEdit", "matchLiveDelete", ): - assert_contains(MATCH_API, needle) + assert needle in match_api, f"{MATCH_API} missing: {needle}" for needle in ( "play_url_m3u8", "fetch_status", "source_url", ): - assert_contains(MATCH_LIVE_TABLE, needle) + assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing: {needle}" + + readonly_fields = ( + 'play_url_m3u8" readonly', + 'play_url_hd_m3u8" readonly', + 'play_url_flv" readonly', + 'play_url_hd_flv" readonly', + 'formatFetchStatus(editData.fetch_status)" readonly', + 'editData.last_fetch_at" readonly', + ) + for needle in readonly_fields: + assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing readonly guard: {needle}" + + handle_edit_block = match_live_table.split("const handleEdit = async (id: number) => {", 1)[1].split( + "const handleSubmit = async () => {", 1 + )[0] + assert "resetEditData()" in handle_edit_block, f"{MATCH_LIVE_TABLE} missing resetEditData usage in handleEdit" + assert "const res = await matchLiveDetail" in handle_edit_block, f"{MATCH_LIVE_TABLE} missing detail request" + assert ( + handle_edit_block.index("resetEditData()") + < handle_edit_block.index("const res = await matchLiveDetail") + ), "handleEdit must reset editData before matchLiveDetail response is applied" + + assert "const params = {" in match_live_table, f"{MATCH_LIVE_TABLE} missing submit payload" + for needle in ( + "id: editData.id", + "match_id: props.matchId", + "title: editData.title", + "source_url: editData.source_url", + ): + assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing submit field: {needle}" + forbidden_submit_fields = ( + "play_url_m3u8:", + "play_url_hd_m3u8:", + "play_url_flv:", + "play_url_hd_flv:", + "fetch_status:", + "last_fetch_at:", + "create_time:", + "update_time:", + ) + params_slice = match_live_table.split("const params = {", 1)[1].split("\n }", 1)[0] + for needle in forbidden_submit_fields: + assert needle not in params_slice, f"submit payload must not include readonly field: {needle}" + + for needle in ( + "status === 0", + "status === 1", + "status === 2", + "status === '0'", + "status === '1'", + "status === '2'", + ): + assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing fetch_status mapping: {needle}" print("match live admin static checks passed") From 08c1affb1440665be5e224f1df12c24cf3287093 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:41:56 +0800 Subject: [PATCH 20/24] test: cover match live stream repository updates --- .../crawler/tests/test_match_live_stream.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/docker/crawler/tests/test_match_live_stream.py b/docker/crawler/tests/test_match_live_stream.py index 8bfc7cb..f05544f 100644 --- a/docker/crawler/tests/test_match_live_stream.py +++ b/docker/crawler/tests/test_match_live_stream.py @@ -13,10 +13,47 @@ from match_live_stream import ( _build_run_result, choose_next_fetch_at, parse_match_live_detail_html, + MatchLiveStreamRepository, ) +class FakeCursor: + def __init__(self, fetchall_result=None): + self.fetchall_result = list(fetchall_result or []) + self.executed = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params): + self.executed.append((sql, params)) + + def fetchall(self): + return list(self.fetchall_result) + + +class FakeConnection: + def __init__(self, fetchall_result=None): + self.cursor_instance = FakeCursor(fetchall_result=fetchall_result) + self.commit_count = 0 + + def cursor(self): + return self.cursor_instance + + def commit(self): + self.commit_count += 1 + + class MatchLiveStreamTest(unittest.TestCase): + def _build_repo(self, *, prefix="la_", fetchall_result=None): + repo = object.__new__(MatchLiveStreamRepository) + repo.prefix = prefix + repo.conn = FakeConnection(fetchall_result=fetchall_result) + return repo + def test_parse_json_ncctrials_detail_extracts_four_urls(self): payload = { "code": 200, @@ -87,6 +124,70 @@ class MatchLiveStreamTest(unittest.TestCase): def test_choose_next_fetch_at_uses_600s_for_existing_streams(self): self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=True), 1600) + def test_load_due_rows_orders_by_due_time_and_honors_limit(self): + expected_rows = [{"id": 12, "source_url": "https://yyzb1.tv/room/7988511"}] + repo = self._build_repo(fetchall_result=expected_rows) + + rows = repo.load_due_rows(now_ts=1711111111, limit=7) + + self.assertEqual(rows, expected_rows) + self.assertEqual(repo.conn.commit_count, 0) + self.assertEqual(len(repo.conn.cursor_instance.executed), 1) + sql, params = repo.conn.cursor_instance.executed[0] + self.assertIn("FROM `la_match_live`", sql) + self.assertIn("`next_fetch_at` <= %s", sql) + self.assertIn("ORDER BY `next_fetch_at` ASC, `id` ASC LIMIT %s", sql) + self.assertEqual(params, (1711111111, 7)) + + def test_mark_failed_only_updates_fetch_fields_and_preserves_stream_urls(self): + repo = self._build_repo() + error_message = "x" * 600 + + repo.mark_failed(row_id=88, error_message=error_message, now_ts=2000, has_existing_stream=True) + + self.assertEqual(repo.conn.commit_count, 1) + self.assertEqual(len(repo.conn.cursor_instance.executed), 1) + sql, params = repo.conn.cursor_instance.executed[0] + self.assertIn("UPDATE `la_match_live` SET", sql) + self.assertIn("`fetch_status`=2", sql) + self.assertIn("`fetch_error`=%s, `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s", sql) + self.assertNotIn("`play_url_flv`=%s", sql) + self.assertNotIn("`play_url_hd_flv`=%s", sql) + self.assertNotIn("`play_url_m3u8`=%s", sql) + self.assertNotIn("`play_url_hd_m3u8`=%s", sql) + self.assertEqual(params, ("x" * 500, 2000, 2600, 2000, 88)) + + def test_mark_success_updates_all_stream_urls_and_resets_fetch_fields(self): + repo = self._build_repo() + stream_urls = { + "play_url_flv": "http://stream/flv", + "play_url_hd_flv": "http://stream/hdflv", + "play_url_m3u8": "http://stream/m3u8", + "play_url_hd_m3u8": "http://stream/hdm3u8", + } + + repo.mark_success(row_id=66, stream_urls=stream_urls, now_ts=3000) + + self.assertEqual(repo.conn.commit_count, 1) + self.assertEqual(len(repo.conn.cursor_instance.executed), 1) + sql, params = repo.conn.cursor_instance.executed[0] + self.assertIn("UPDATE `la_match_live` SET", sql) + self.assertIn("`play_url_flv`=%s, `play_url_hd_flv`=%s, `play_url_m3u8`=%s, `play_url_hd_m3u8`=%s", sql) + self.assertIn("`fetch_status`=1, `fetch_error`='', `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s", sql) + self.assertEqual( + params, + ( + "http://stream/flv", + "http://stream/hdflv", + "http://stream/m3u8", + "http://stream/hdm3u8", + 3000, + 3600, + 3000, + 66, + ), + ) + if __name__ == "__main__": unittest.main() From 75a529be359b8f7995d44e08dbfd0df4191657bf Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:44:05 +0800 Subject: [PATCH 21/24] fix: preserve live streams on title edits --- docs/sql/create_match_live_table.sql | 2 +- docs/sql/insert_menu_match_live.sql | 51 +++++++++++++++ qa/backend/test_match_live_backend_static.php | 65 ++++++++++++++++--- qa/backend/test_match_live_schema_static.php | 2 +- qa/frontend/test_match_live_admin_static.py | 13 ++++ .../adminapi/logic/match/MatchLiveLogic.php | 29 +++++---- 6 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 docs/sql/insert_menu_match_live.sql diff --git a/docs/sql/create_match_live_table.sql b/docs/sql/create_match_live_table.sql index 63b14a0..089022d 100644 --- a/docs/sql/create_match_live_table.sql +++ b/docs/sql/create_match_live_table.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS `la_match_live` ( `play_url_flv` varchar(1000) NOT NULL DEFAULT '' COMMENT '标清FLV播放地址', `play_url_hd_flv` varchar(1000) NOT NULL DEFAULT '' COMMENT '高清FLV播放地址', `fetch_status` tinyint UNSIGNED NOT NULL DEFAULT 0 COMMENT '抓取状态 0-待抓取 1-成功 2-失败', - `fetch_error` varchar(255) NOT NULL DEFAULT '' COMMENT '抓取错误信息', + `fetch_error` varchar(500) NOT NULL DEFAULT '' COMMENT '抓取错误信息', `last_fetch_at` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后抓取时间', `next_fetch_at` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '下次抓取时间', `create_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建时间', diff --git a/docs/sql/insert_menu_match_live.sql b/docs/sql/insert_menu_match_live.sql new file mode 100644 index 0000000..a8cfd2c --- /dev/null +++ b/docs/sql/insert_menu_match_live.sql @@ -0,0 +1,51 @@ +INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT COALESCE((SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.match/lists' LIMIT 1), 0), 'C', '直播线路管理', '', 0, 'match.matchLive/lists', 'match_live', 'match/lists/index', 'match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchLive/lists' +); + +INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路详情', '', 0, 'match.matchLive/detail', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchLive/detail' +); + +INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路新增', '', 0, 'match.matchLive/add', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchLive/add' +); + +INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路编辑', '', 0, 'match.matchLive/edit', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchLive/edit' +); + +INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路删除', '', 0, 'match.matchLive/delete', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchLive/delete' +); + +INSERT INTO `la_system_role_menu` (`role_id`, `menu_id`) +SELECT 1, m.`id` +FROM `la_system_menu` m +WHERE m.`perms` IN ( + 'match.matchLive/lists', + 'match.matchLive/detail', + 'match.matchLive/add', + 'match.matchLive/edit', + 'match.matchLive/delete' +) +AND NOT EXISTS ( + SELECT 1 + FROM `la_system_role_menu` rm + WHERE rm.`role_id` = 1 + AND rm.`menu_id` = m.`id` +); diff --git a/qa/backend/test_match_live_backend_static.php b/qa/backend/test_match_live_backend_static.php index fb69100..e830f91 100644 --- a/qa/backend/test_match_live_backend_static.php +++ b/qa/backend/test_match_live_backend_static.php @@ -105,17 +105,64 @@ if (!is_file($matchLiveLogicPath)) { $matchLiveLogic = file_get_contents($matchLiveLogicPath); foreach ([ - "'play_url_m3u8' => ''", - "'play_url_hd_m3u8' => ''", - "'play_url_flv' => ''", - "'play_url_hd_flv' => ''", - "'fetch_status' => 0", - "'fetch_error' => ''", - "'last_fetch_at' => 0", - "'next_fetch_at' => \$now", + '$sourceUrl = trim((string) $params[\'source_url\']);', + '$sourceUrlChanged = $sourceUrl !== trim((string) $live->source_url);', + "'source_url' => \$sourceUrl", + 'if ($sourceUrlChanged) {', + "\$saveData['play_url_m3u8'] = '';", + "\$saveData['play_url_hd_m3u8'] = '';", + "\$saveData['play_url_flv'] = '';", + "\$saveData['play_url_hd_flv'] = '';", + "\$saveData['fetch_status'] = 0;", + "\$saveData['fetch_error'] = '';", + "\$saveData['last_fetch_at'] = 0;", + "\$saveData['next_fetch_at'] = \$now;", + '$live->save($saveData);', ] as $needle) { if (strpos($matchLiveLogic, $needle) === false) { - fwrite(STDERR, "missing match live logic reset token: {$needle}\n"); + fwrite(STDERR, "missing match live logic source-url reset guard token: {$needle}\n"); + exit(1); + } +} + +$savePos = strpos($matchLiveLogic, '$live->save($saveData);'); +$guardPos = strpos($matchLiveLogic, 'if ($sourceUrlChanged) {'); +if ($savePos === false || $guardPos === false || $guardPos > $savePos) { + fwrite(STDERR, "match live logic must guard crawler resets inside source_url change branch\n"); + exit(1); +} + +$guardBody = substr($matchLiveLogic, $guardPos, $savePos - $guardPos); + +foreach ([ + "\$saveData['play_url_m3u8'] = '';", + "\$saveData['play_url_hd_m3u8'] = '';", + "\$saveData['play_url_flv'] = '';", + "\$saveData['play_url_hd_flv'] = '';", + "\$saveData['fetch_status'] = 0;", + "\$saveData['fetch_error'] = '';", + "\$saveData['last_fetch_at'] = 0;", + "\$saveData['next_fetch_at'] = \$now;", +] as $needle) { + if (strpos($guardBody, $needle) === false) { + fwrite(STDERR, "match live logic reset token not scoped to source_url change guard: {$needle}\n"); + exit(1); + } +} + +$unconditionalSaveBody = preg_replace('/if \\(\\$sourceUrlChanged\\) \\{.*?\\}/s', '', $matchLiveLogic, 1); +foreach ([ + "\$saveData['play_url_m3u8'] = '';", + "\$saveData['play_url_hd_m3u8'] = '';", + "\$saveData['play_url_flv'] = '';", + "\$saveData['play_url_hd_flv'] = '';", + "\$saveData['fetch_status'] = 0;", + "\$saveData['fetch_error'] = '';", + "\$saveData['last_fetch_at'] = 0;", + "\$saveData['next_fetch_at'] = \$now;", +] as $needle) { + if (strpos($unconditionalSaveBody, $needle) !== false) { + fwrite(STDERR, "match live logic still unconditionally resets crawler state: {$needle}\n"); exit(1); } } diff --git a/qa/backend/test_match_live_schema_static.php b/qa/backend/test_match_live_schema_static.php index 3d944cb..8402b59 100644 --- a/qa/backend/test_match_live_schema_static.php +++ b/qa/backend/test_match_live_schema_static.php @@ -27,7 +27,7 @@ $requiredSql = [ '`play_url_flv` varchar(1000)', '`play_url_hd_flv` varchar(1000)', '`fetch_status` tinyint', - '`fetch_error` varchar(255)', + '`fetch_error` varchar(500)', '`last_fetch_at` int UNSIGNED NOT NULL', '`next_fetch_at` int UNSIGNED NOT NULL', '`create_time` int UNSIGNED NOT NULL', diff --git a/qa/frontend/test_match_live_admin_static.py b/qa/frontend/test_match_live_admin_static.py index 3295890..fb06797 100644 --- a/qa/frontend/test_match_live_admin_static.py +++ b/qa/frontend/test_match_live_admin_static.py @@ -2,9 +2,11 @@ from pathlib import Path ROOT = Path(r"D:\www\sport-era\admin") +REPO_ROOT = Path(__file__).resolve().parents[2] MATCH_API = ROOT / "src" / "api" / "match.ts" MATCH_INDEX = ROOT / "src" / "views" / "match" / "lists" / "index.vue" MATCH_LIVE_TABLE = ROOT / "src" / "views" / "match" / "lists" / "components" / "MatchLiveTable.vue" +MENU_SQL = REPO_ROOT / "docs" / "sql" / "insert_menu_match_live.sql" def assert_contains(path: Path, needle: str) -> None: @@ -15,6 +17,8 @@ def assert_contains(path: Path, needle: str) -> None: def main() -> None: match_api = MATCH_API.read_text(encoding="utf-8") match_live_table = MATCH_LIVE_TABLE.read_text(encoding="utf-8") + assert MENU_SQL.is_file(), f"missing menu seed file: {MENU_SQL}" + menu_sql = MENU_SQL.read_text(encoding="utf-8") assert_contains(MATCH_INDEX, 'type="expand"') assert_contains(MATCH_INDEX, "MatchLiveTable") @@ -88,6 +92,15 @@ def main() -> None: ): assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing fetch_status mapping: {needle}" + for needle in ( + "match.matchLive/lists", + "match.matchLive/detail", + "match.matchLive/add", + "match.matchLive/edit", + "match.matchLive/delete", + ): + assert needle in menu_sql, f"{MENU_SQL} missing permission token: {needle}" + print("match live admin static checks passed") diff --git a/server/app/adminapi/logic/match/MatchLiveLogic.php b/server/app/adminapi/logic/match/MatchLiveLogic.php index 123ee51..d844e51 100644 --- a/server/app/adminapi/logic/match/MatchLiveLogic.php +++ b/server/app/adminapi/logic/match/MatchLiveLogic.php @@ -49,21 +49,28 @@ class MatchLiveLogic extends BaseLogic $oldMatchId = (int) $live->match_id; $newMatchId = (int) $params['match_id']; $now = time(); + $sourceUrl = trim((string) $params['source_url']); + $sourceUrlChanged = $sourceUrl !== trim((string) $live->source_url); - $live->save([ + $saveData = [ 'match_id' => $newMatchId, 'title' => trim((string) $params['title']), - 'source_url' => trim((string) $params['source_url']), - 'play_url_m3u8' => '', - 'play_url_hd_m3u8' => '', - 'play_url_flv' => '', - 'play_url_hd_flv' => '', - 'fetch_status' => 0, - 'fetch_error' => '', - 'last_fetch_at' => 0, - 'next_fetch_at' => $now, + 'source_url' => $sourceUrl, 'update_time' => $now, - ]); + ]; + + if ($sourceUrlChanged) { + $saveData['play_url_m3u8'] = ''; + $saveData['play_url_hd_m3u8'] = ''; + $saveData['play_url_flv'] = ''; + $saveData['play_url_hd_flv'] = ''; + $saveData['fetch_status'] = 0; + $saveData['fetch_error'] = ''; + $saveData['last_fetch_at'] = 0; + $saveData['next_fetch_at'] = $now; + } + + $live->save($saveData); if ($oldMatchId > 0 && $oldMatchId !== $newMatchId) { MatchLiveService::syncLegacyLiveUrl($oldMatchId); From 229b70c1be457767ae4d60327f6568b4588f7829 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:47:07 +0800 Subject: [PATCH 22/24] fix: require match live menu parent --- docs/sql/insert_menu_match_live.sql | 4 +++- qa/frontend/test_match_live_admin_static.py | 23 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/sql/insert_menu_match_live.sql b/docs/sql/insert_menu_match_live.sql index a8cfd2c..005de39 100644 --- a/docs/sql/insert_menu_match_live.sql +++ b/docs/sql/insert_menu_match_live.sql @@ -1,5 +1,7 @@ INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) -SELECT COALESCE((SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.match/lists' LIMIT 1), 0), 'C', '直播线路管理', '', 0, 'match.matchLive/lists', 'match_live', 'match/lists/index', 'match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT parent.`id`, 'C', '直播线路管理', '', 0, 'match.matchLive/lists', 'match_live', 'match/lists/index', 'match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM `la_system_menu` parent +WHERE parent.`perms` = 'match.match/lists' WHERE NOT EXISTS ( SELECT 1 FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' diff --git a/qa/frontend/test_match_live_admin_static.py b/qa/frontend/test_match_live_admin_static.py index fb06797..31efa91 100644 --- a/qa/frontend/test_match_live_admin_static.py +++ b/qa/frontend/test_match_live_admin_static.py @@ -19,6 +19,7 @@ def main() -> None: match_live_table = MATCH_LIVE_TABLE.read_text(encoding="utf-8") assert MENU_SQL.is_file(), f"missing menu seed file: {MENU_SQL}" menu_sql = MENU_SQL.read_text(encoding="utf-8") + normalized_menu_sql = " ".join(menu_sql.split()) assert_contains(MATCH_INDEX, 'type="expand"') assert_contains(MATCH_INDEX, "MatchLiveTable") @@ -97,10 +98,32 @@ def main() -> None: "match.matchLive/detail", "match.matchLive/add", "match.matchLive/edit", + "match.match/lists", "match.matchLive/delete", ): assert needle in menu_sql, f"{MENU_SQL} missing permission token: {needle}" + forbidden_menu_fallbacks = ( + "COALESCE(", + ", 0), 'C', '直播线路管理'", + "SELECT 0, 'C', '直播线路管理'", + "VALUES (0, 'C', '直播线路管理'", + ) + for needle in forbidden_menu_fallbacks: + assert needle not in menu_sql, f"{MENU_SQL} must not contain top-level fallback: {needle}" + + assert ( + "FROM `la_system_menu` parent" in menu_sql + and "parent.`perms` = 'match.match/lists'" in menu_sql + ), f"{MENU_SQL} must bind the match live menu to parent match.match/lists explicitly" + assert ( + "SELECT parent.`id`, 'C', '直播线路管理'" in menu_sql + and "WHERE NOT EXISTS (" in menu_sql + ), f"{MENU_SQL} must insert the list menu only when the parent match.match/lists record exists" + assert ( + "SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1)" in normalized_menu_sql + ), f"{MENU_SQL} child permissions must bind to the inserted match.matchLive/lists menu" + print("match live admin static checks passed") From 875a770c5a8df611371a750f4b9d8bfc43c21dfb Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 13:49:35 +0800 Subject: [PATCH 23/24] fix: correct match live menu seed sql --- docs/sql/insert_menu_match_live.sql | 2 +- qa/frontend/test_match_live_admin_static.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/sql/insert_menu_match_live.sql b/docs/sql/insert_menu_match_live.sql index 005de39..f04c0e5 100644 --- a/docs/sql/insert_menu_match_live.sql +++ b/docs/sql/insert_menu_match_live.sql @@ -2,7 +2,7 @@ INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `p SELECT parent.`id`, 'C', '直播线路管理', '', 0, 'match.matchLive/lists', 'match_live', 'match/lists/index', 'match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() FROM `la_system_menu` parent WHERE parent.`perms` = 'match.match/lists' -WHERE NOT EXISTS ( + AND NOT EXISTS ( SELECT 1 FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' ); diff --git a/qa/frontend/test_match_live_admin_static.py b/qa/frontend/test_match_live_admin_static.py index 31efa91..f78f8c5 100644 --- a/qa/frontend/test_match_live_admin_static.py +++ b/qa/frontend/test_match_live_admin_static.py @@ -20,6 +20,8 @@ def main() -> None: assert MENU_SQL.is_file(), f"missing menu seed file: {MENU_SQL}" menu_sql = MENU_SQL.read_text(encoding="utf-8") normalized_menu_sql = " ".join(menu_sql.split()) + first_insert_block = menu_sql.split(";", 1)[0] + ";" + normalized_first_insert = " ".join(first_insert_block.split()) assert_contains(MATCH_INDEX, 'type="expand"') assert_contains(MATCH_INDEX, "MatchLiveTable") @@ -118,8 +120,16 @@ def main() -> None: ), f"{MENU_SQL} must bind the match live menu to parent match.match/lists explicitly" assert ( "SELECT parent.`id`, 'C', '直播线路管理'" in menu_sql - and "WHERE NOT EXISTS (" in menu_sql ), f"{MENU_SQL} must insert the list menu only when the parent match.match/lists record exists" + assert ( + "WHERE parent.`perms` = 'match.match/lists' AND NOT EXISTS (" in normalized_first_insert + ), f"{MENU_SQL} first insert must combine parent binding with AND NOT EXISTS in a single WHERE predicate" + assert ( + "WHERE parent.`perms` = 'match.match/lists' WHERE NOT EXISTS (" not in normalized_first_insert + ), f"{MENU_SQL} first insert must not contain a second standalone WHERE NOT EXISTS after the parent WHERE" + assert normalized_first_insert.count("WHERE") == 2, ( + f"{MENU_SQL} first insert should only have the outer WHERE plus the nested WHERE inside NOT EXISTS" + ) assert ( "SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1)" in normalized_menu_sql ), f"{MENU_SQL} child permissions must bind to the inserted match.matchLive/lists menu" From f654081e8f1c711b8425b8649b5f28606ec07086 Mon Sep 17 00:00:00 2001 From: hajimi Date: Sun, 21 Jun 2026 16:30:28 +0800 Subject: [PATCH 24/24] refactor: move match live admin ui to dedicated page --- docs/sql/insert_menu_match_live.sql | 14 ++++++------- qa/frontend/test_match_live_admin_static.py | 23 +++++++++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/sql/insert_menu_match_live.sql b/docs/sql/insert_menu_match_live.sql index f04c0e5..7dbe9a5 100644 --- a/docs/sql/insert_menu_match_live.sql +++ b/docs/sql/insert_menu_match_live.sql @@ -1,35 +1,35 @@ INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) -SELECT parent.`id`, 'C', '直播线路管理', '', 0, 'match.matchLive/lists', 'match_live', 'match/lists/index', 'match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT parent.`id`, 'C', '直播线路管理', '', 0, 'match.matchLive/index', 'match_live', 'match/live/index', '/match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() FROM `la_system_menu` parent WHERE parent.`perms` = 'match.match/lists' AND NOT EXISTS ( SELECT 1 FROM `la_system_menu` - WHERE `perms` = 'match.matchLive/lists' + WHERE `perms` = 'match.matchLive/index' ); INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) -SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路详情', '', 0, 'match.matchLive/detail', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/index' LIMIT 1), 'A', '直播线路详情', '', 0, 'match.matchLive/detail', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() WHERE NOT EXISTS ( SELECT 1 FROM `la_system_menu` WHERE `perms` = 'match.matchLive/detail' ); INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) -SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路新增', '', 0, 'match.matchLive/add', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/index' LIMIT 1), 'A', '直播线路新增', '', 0, 'match.matchLive/add', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() WHERE NOT EXISTS ( SELECT 1 FROM `la_system_menu` WHERE `perms` = 'match.matchLive/add' ); INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) -SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路编辑', '', 0, 'match.matchLive/edit', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/index' LIMIT 1), 'A', '直播线路编辑', '', 0, 'match.matchLive/edit', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() WHERE NOT EXISTS ( SELECT 1 FROM `la_system_menu` WHERE `perms` = 'match.matchLive/edit' ); INSERT INTO `la_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) -SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1), 'A', '直播线路删除', '', 0, 'match.matchLive/delete', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/index' LIMIT 1), 'A', '直播线路删除', '', 0, 'match.matchLive/delete', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() WHERE NOT EXISTS ( SELECT 1 FROM `la_system_menu` WHERE `perms` = 'match.matchLive/delete' @@ -39,7 +39,7 @@ INSERT INTO `la_system_role_menu` (`role_id`, `menu_id`) SELECT 1, m.`id` FROM `la_system_menu` m WHERE m.`perms` IN ( - 'match.matchLive/lists', + 'match.matchLive/index', 'match.matchLive/detail', 'match.matchLive/add', 'match.matchLive/edit', diff --git a/qa/frontend/test_match_live_admin_static.py b/qa/frontend/test_match_live_admin_static.py index f78f8c5..fcfaaeb 100644 --- a/qa/frontend/test_match_live_admin_static.py +++ b/qa/frontend/test_match_live_admin_static.py @@ -6,6 +6,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] MATCH_API = ROOT / "src" / "api" / "match.ts" MATCH_INDEX = ROOT / "src" / "views" / "match" / "lists" / "index.vue" MATCH_LIVE_TABLE = ROOT / "src" / "views" / "match" / "lists" / "components" / "MatchLiveTable.vue" +MATCH_LIVE_PAGE = ROOT / "src" / "views" / "match" / "live" / "index.vue" MENU_SQL = REPO_ROOT / "docs" / "sql" / "insert_menu_match_live.sql" @@ -16,15 +17,20 @@ def assert_contains(path: Path, needle: str) -> None: def main() -> None: match_api = MATCH_API.read_text(encoding="utf-8") + match_index = MATCH_INDEX.read_text(encoding="utf-8") match_live_table = MATCH_LIVE_TABLE.read_text(encoding="utf-8") + match_live_page = MATCH_LIVE_PAGE.read_text(encoding="utf-8") assert MENU_SQL.is_file(), f"missing menu seed file: {MENU_SQL}" menu_sql = MENU_SQL.read_text(encoding="utf-8") normalized_menu_sql = " ".join(menu_sql.split()) first_insert_block = menu_sql.split(";", 1)[0] + ";" normalized_first_insert = " ".join(first_insert_block.split()) - assert_contains(MATCH_INDEX, 'type="expand"') - assert_contains(MATCH_INDEX, "MatchLiveTable") + assert MATCH_LIVE_PAGE.is_file(), f"missing page file: {MATCH_LIVE_PAGE}" + assert 'type="expand"' not in match_index, f"{MATCH_INDEX} should not use expand child table anymore" + assert "MatchLiveTable" not in match_index, f"{MATCH_INDEX} should not render MatchLiveTable inline anymore" + assert "match.matchLive/index" in match_index, f"{MATCH_INDEX} missing route perms match.matchLive/index" + assert "getRoutePath('match.matchLive/index')" in match_index, f"{MATCH_INDEX} must route to match.matchLive/index" for needle in ( "matchLiveLists", @@ -42,6 +48,11 @@ def main() -> None: ): assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing: {needle}" + assert "MatchLiveTable" in match_live_page, f"{MATCH_LIVE_PAGE} should reuse MatchLiveTable" + assert "matchDetail" in match_live_page, f"{MATCH_LIVE_PAGE} should load match detail" + assert "useRoute" in match_live_page, f"{MATCH_LIVE_PAGE} should read route query" + assert "router.back()" in match_live_page, f"{MATCH_LIVE_PAGE} should provide back navigation" + readonly_fields = ( 'play_url_m3u8" readonly', 'play_url_hd_m3u8" readonly', @@ -96,7 +107,7 @@ def main() -> None: assert needle in match_live_table, f"{MATCH_LIVE_TABLE} missing fetch_status mapping: {needle}" for needle in ( - "match.matchLive/lists", + "match.matchLive/index", "match.matchLive/detail", "match.matchLive/add", "match.matchLive/edit", @@ -120,7 +131,7 @@ def main() -> None: ), f"{MENU_SQL} must bind the match live menu to parent match.match/lists explicitly" assert ( "SELECT parent.`id`, 'C', '直播线路管理'" in menu_sql - ), f"{MENU_SQL} must insert the list menu only when the parent match.match/lists record exists" + ), f"{MENU_SQL} must insert the page menu only when the parent match.match/lists record exists" assert ( "WHERE parent.`perms` = 'match.match/lists' AND NOT EXISTS (" in normalized_first_insert ), f"{MENU_SQL} first insert must combine parent binding with AND NOT EXISTS in a single WHERE predicate" @@ -131,8 +142,8 @@ def main() -> None: f"{MENU_SQL} first insert should only have the outer WHERE plus the nested WHERE inside NOT EXISTS" ) assert ( - "SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/lists' LIMIT 1)" in normalized_menu_sql - ), f"{MENU_SQL} child permissions must bind to the inserted match.matchLive/lists menu" + "SELECT (SELECT `id` FROM `la_system_menu` WHERE `perms` = 'match.matchLive/index' LIMIT 1)" in normalized_menu_sql + ), f"{MENU_SQL} child permissions must bind to the inserted match.matchLive/index menu" print("match live admin static checks passed")