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..a43a508 --- /dev/null +++ b/docker/crawler/match_live_stream.py @@ -0,0 +1,268 @@ +#!/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*;?\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) + + +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")) + 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 _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 new file mode 100644 index 0000000..f05544f --- /dev/null +++ b/docker/crawler/tests/test_match_live_stream.py @@ -0,0 +1,193 @@ +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 ( + _build_detail_url, + _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, + "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_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_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_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) + + 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() diff --git a/docs/sql/create_match_live_table.sql b/docs/sql/create_match_live_table.sql new file mode 100644 index 0000000..089022d --- /dev/null +++ b/docs/sql/create_match_live_table.sql @@ -0,0 +1,23 @@ +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 '直播标题', + `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(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 '创建时间', + `update_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, + 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/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 +); diff --git a/docs/sql/insert_menu_match_live.sql b/docs/sql/insert_menu_match_live.sql new file mode 100644 index 0000000..7dbe9a5 --- /dev/null +++ b/docs/sql/insert_menu_match_live.sql @@ -0,0 +1,53 @@ +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/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/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/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/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/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/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' +); + +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/index', + '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/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、回归测试、部署验证 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..e830f91 --- /dev/null +++ b/qa/backend/test_match_live_backend_static.php @@ -0,0 +1,218 @@ + \$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 === '')", +]; + +foreach ($requiredSnippets as $needle) { + if (strpos($service, $needle) === false) { + fwrite(STDERR, "missing service token: {$needle}\n"); + exit(1); + } +} + +$whereNullCount = substr_count($service, "->whereNull('delete_time')"); +if ($whereNullCount < 2) { + fwrite(STDERR, "missing explicit delete_time guard on both queries\n"); + 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); +} + +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 ([ + '$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 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); + } +} + +if (!is_file($validatePath)) { + fwrite(STDERR, "missing match live validate file\n"); + exit(1); +} + +$validate = file_get_contents($validatePath); +foreach ([ + 'checkTrimmedTitle', + 'checkTrimmedSourceUrl', + "!is_scalar(\$value)", + "return '直播标题格式错误';", + "return '源地址格式错误';", + '$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']", + "['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/qa/backend/test_match_live_schema_static.php b/qa/backend/test_match_live_schema_static.php new file mode 100644 index 0000000..8402b59 --- /dev/null +++ b/qa/backend/test_match_live_schema_static.php @@ -0,0 +1,70 @@ + 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 '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 "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 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" + + +if __name__ == "__main__": + main() 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..fcfaaeb --- /dev/null +++ b/qa/frontend/test_match_live_admin_static.py @@ -0,0 +1,152 @@ +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" +MATCH_LIVE_PAGE = ROOT / "src" / "views" / "match" / "live" / "index.vue" +MENU_SQL = REPO_ROOT / "docs" / "sql" / "insert_menu_match_live.sql" + + +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: + 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 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", + "matchLiveDetail", + "matchLiveAdd", + "matchLiveEdit", + "matchLiveDelete", + ): + assert needle in match_api, f"{MATCH_API} missing: {needle}" + + for needle in ( + "play_url_m3u8", + "fetch_status", + "source_url", + ): + 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', + '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}" + + for needle in ( + "match.matchLive/index", + "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 + ), 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" + 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/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") + + +if __name__ == "__main__": + main() 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..d844e51 --- /dev/null +++ b/server/app/adminapi/logic/match/MatchLiveLogic.php @@ -0,0 +1,104 @@ +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(); + $sourceUrl = trim((string) $params['source_url']); + $sourceUrlChanged = $sourceUrl !== trim((string) $live->source_url); + + $saveData = [ + 'match_id' => $newMatchId, + 'title' => trim((string) $params['title']), + '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); + } + 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..db7e370 --- /dev/null +++ b/server/app/adminapi/validate/match/MatchLiveValidate.php @@ -0,0 +1,99 @@ + 'require|integer|gt:0|checkLive', + 'match_id' => 'require|integer|gt:0|checkMatch', + 'title' => 'require|checkTrimmedTitle', + 'source_url' => 'require|checkTrimmedSourceUrl', + ]; + + 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; + } + + public function checkTrimmedTitle($value) + { + if (!is_scalar($value)) { + return '直播标题格式错误'; + } + $value = trim((string) $value); + if ($value === '') { + return '直播标题不能为空'; + } + if (mb_strlen($value) > 255) { + return '直播标题长度须在1-255位字符'; + } + return true; + } + + public function checkTrimmedSourceUrl($value) + { + if (!is_scalar($value)) { + return '源地址格式错误'; + } + $value = trim((string) $value); + if ($value === '') { + return '源地址不能为空'; + } + if (mb_strlen($value) > 500) { + return '源地址长度不能超过500位字符'; + } + 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; } diff --git a/server/app/common/model/match/MatchLive.php b/server/app/common/model/match/MatchLive.php new file mode 100644 index 0000000..d4d3eb1 --- /dev/null +++ b/server/app/common/model/match/MatchLive.php @@ -0,0 +1,14 @@ +whereNull('delete_time') + ->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) + ->whereNull('delete_time') + ->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; + } +} diff --git a/uniapp/src/api/match.ts b/uniapp/src/api/match.ts index f883994..667038e 100644 --- a/uniapp/src/api/match.ts +++ b/uniapp/src/api/match.ts @@ -1,5 +1,54 @@ import request from '@/utils/request' +export interface MatchLiveStreamOption { + id?: number | string + title?: string + name?: string + label?: string + source_name?: string + quality?: string + 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 + 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_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 +} + 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..2c23caa --- /dev/null +++ b/uniapp/src/components/match-live-popup/match-live-popup.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/uniapp/src/pages/match_detail/match_detail.vue b/uniapp/src/pages/match_detail/match_detail.vue index 7dbaaaf..1b18d44 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,51 @@ 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 getLiveFetchStatusText = (value: unknown) => { + const text = normalizeLiveText(value) + if (!text) return '' + const normalized = text.toLowerCase() + if (normalized === 'pending' || normalized === '0') return '待抓取' + if (normalized === 'success' || normalized === '1') return '已抓取' + if (normalized === 'failed' || normalized === 'error' || normalized === '2') return '抓取失败' + return text +} + +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 = [ + getLiveFetchStatusText(item?.fetch_status), + formatLiveLineUpdate(item?.last_fetch_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 +617,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 +683,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 +1648,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;