Merge branch 'codex/match-live-streams' into dev.1.0.0

This commit is contained in:
hajimi
2026-06-21 16:50:16 +08:00
23 changed files with 2516 additions and 2 deletions
+5
View File
@@ -154,3 +154,8 @@ tasks:
action: ai_comment_dispatch action: ai_comment_dispatch
cron: "*/10 * * * *" cron: "*/10 * * * *"
active: true active: true
- key: match_live_stream
name: 赛事直播流抓取
action: match_live_stream
cron: "*/1 * * * *"
active: true
+22
View File
@@ -30,6 +30,7 @@
python main.py nba_news # 采集NBA新闻资讯 python main.py nba_news # 采集NBA新闻资讯
python main.py cba_news # 采集CBA新闻资讯 python main.py cba_news # 采集CBA新闻资讯
python main.py ai_comment_dispatch # Docker AI 评论调度 python main.py ai_comment_dispatch # Docker AI 评论调度
python main.py match_live_stream # 抓取赛事直播线路播放流
python main.py article_content_fetch # 抓取资讯文章正文内容 python main.py article_content_fetch # 抓取资讯文章正文内容
python main.py all # 采集积分榜 + 赛程 python main.py all # 采集积分榜 + 赛程
python main.py single <season_id> # 采集单个联赛积分榜 python main.py single <season_id> # 采集单个联赛积分榜
@@ -59,6 +60,7 @@ TASK_ALERT_POLICY = {
"lottery_draw": "saved_if_candidates", "lottery_draw": "saved_if_candidates",
"lottery_draw_force": "saved_if_candidates", "lottery_draw_force": "saved_if_candidates",
"ai_comment_dispatch": "saved_if_candidates", "ai_comment_dispatch": "saved_if_candidates",
"match_live_stream": "saved_if_candidates",
} }
DEFAULT_ACTION_TIMEOUT = 600 DEFAULT_ACTION_TIMEOUT = 600
@@ -86,6 +88,7 @@ ACTION_TIMEOUTS = {
"fifa_worldcup_news": 600, "fifa_worldcup_news": 600,
"dqd_worldcup": 600, "dqd_worldcup": 600,
"ai_comment_dispatch": 1800, "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) 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(): async def cmd_match_data():
from src.scheduler.task_runner import TaskRunner from src.scheduler.task_runner import TaskRunner
async with TaskRunner() as runner: async with TaskRunner() as runner:
@@ -837,6 +858,7 @@ def main():
"nba_news": cmd_nba_news, "nba_news": cmd_nba_news,
"cba_news": cmd_cba_news, "cba_news": cmd_cba_news,
"ai_comment_dispatch": cmd_ai_comment_dispatch, "ai_comment_dispatch": cmd_ai_comment_dispatch,
"match_live_stream": cmd_match_live_stream,
"article_content_fetch": cmd_article_content_fetch, "article_content_fetch": cmd_article_content_fetch,
"match_finish": cmd_match_finish, "match_finish": cmd_match_finish,
"live_detail": cmd_live_detail, "live_detail": cmd_live_detail,
+268
View File
@@ -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()
@@ -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()
+23
View File
@@ -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='比赛直播源表';
@@ -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
);
+53
View File
@@ -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`
);
@@ -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、回归测试、部署验证
@@ -0,0 +1,218 @@
<?php
$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',
$listsPath,
$matchLiveLogicPath,
$validatePath,
];
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',
'public static function syncLegacyLiveUrl',
'public static function getLiveListForApi',
'public static function buildStreamOptions',
"'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 === '')",
];
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";
@@ -0,0 +1,70 @@
<?php
$root = dirname(__DIR__, 2);
$sqlPath = $root . '/docs/sql/create_match_live_table.sql';
$modelPath = $root . '/server/app/common/model/match/MatchLive.php';
if (!is_file($sqlPath)) {
fwrite(STDERR, "missing sql file\n");
exit(1);
}
if (!is_file($modelPath)) {
fwrite(STDERR, "missing model file\n");
exit(1);
}
$sql = file_get_contents($sqlPath);
$model = file_get_contents($modelPath);
$requiredSql = [
'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(500)',
'`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) {
if (strpos($sql, $needle) === false) {
fwrite(STDERR, "missing sql token: {$needle}\n");
exit(1);
}
}
if (strpos($model, "protected \$name = 'match_live';") === false) {
fwrite(STDERR, "model table name mismatch\n");
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";
@@ -0,0 +1,65 @@
from pathlib import Path
import re
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 '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()
+152
View File
@@ -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()
@@ -0,0 +1,53 @@
<?php
namespace app\adminapi\controller\match;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\match\MatchLiveLists;
use app\adminapi\logic\match\MatchLiveLogic;
use app\adminapi\validate\match\MatchLiveValidate;
class MatchLiveController extends BaseAdminController
{
public function lists()
{
return $this->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());
}
}
@@ -0,0 +1,63 @@
<?php
namespace app\adminapi\lists\match;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use app\common\model\match\MatchLive;
class MatchLiveLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [
'=' => ['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;
}
}
@@ -0,0 +1,104 @@
<?php
namespace app\adminapi\logic\match;
use app\common\logic\BaseLogic;
use app\common\model\match\MatchLive;
use app\common\service\match\MatchLiveService;
class MatchLiveLogic extends BaseLogic
{
public static function detail(array $params): array
{
return MatchLive::findOrEmpty($params['id'])->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;
}
}
}
@@ -24,7 +24,6 @@ class MatchLogic extends BaseLogic
'league_name', 'league_name',
'round_name', 'round_name',
'stage', 'stage',
'live_url',
'home_team', 'home_team',
'home_icon', 'home_icon',
'home_score', 'home_score',
@@ -0,0 +1,99 @@
<?php
namespace app\adminapi\validate\match;
use app\common\model\match\MatchEvent;
use app\common\model\match\MatchLive;
use app\common\validate\BaseValidate;
class MatchLiveValidate extends BaseValidate
{
protected $rule = [
'id' => '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;
}
}
@@ -13,6 +13,7 @@ use app\common\model\match\MatchLineup;
use app\common\model\match\WorldCupPersonRanking; use app\common\model\match\WorldCupPersonRanking;
use app\common\model\match\WorldCupStanding; use app\common\model\match\WorldCupStanding;
use app\common\model\league\League; use app\common\model\league\League;
use app\common\service\match\MatchLiveService;
class MatchController extends BaseApiController class MatchController extends BaseApiController
{ {
@@ -157,6 +158,10 @@ class MatchController extends BaseApiController
return $this->fail('赛事不存在'); return $this->fail('赛事不存在');
} }
$data = $match->toArray(); $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)) { if (empty($data['live_url']) && $this->isWorldCupMatch($data)) {
$data['live_url'] = $this->worldCupDefaultLiveUrl; $data['live_url'] = $this->worldCupDefaultLiveUrl;
} }
@@ -0,0 +1,14 @@
<?php
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';
}
@@ -0,0 +1,65 @@
<?php
namespace app\common\service\match;
use app\common\model\match\MatchEvent;
use app\common\model\match\MatchLive;
class MatchLiveService
{
public static function syncLegacyLiveUrl(int $matchId): void
{
$row = MatchLive::where('match_id', $matchId)
->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;
}
}
+49
View File
@@ -1,5 +1,54 @@
import request from '@/utils/request' 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<string, any>) { export function getMatchList(data?: Record<string, any>) {
return request.get({ url: '/match/lists', data: data }, { withToken: false }) return request.get({ url: '/match/lists', data: data }, { withToken: false })
} }
@@ -0,0 +1,340 @@
<template>
<u-popup v-model="showPopup" mode="bottom" safe-area-inset-bottom border-radius="24" closeable @close="handleClose">
<view class="match-live-popup">
<view class="match-live-popup__header">
<text class="match-live-popup__title">{{ lineTitle }}</text>
<text class="match-live-popup__desc">{{ lineMetaText }}</text>
</view>
<view class="match-live-popup__section">
<text class="match-live-popup__section-title">直播线路</text>
<view v-if="streamOptions.length" class="match-live-popup__options">
<view
v-for="item in streamOptions"
:key="item.key"
class="match-live-popup__option"
:class="{ 'match-live-popup__option--active': item.key === selectedStreamKey }"
@tap="selectOption(item)"
>
<view class="match-live-popup__option-main">
<text class="match-live-popup__option-title">{{ item.label }}</text>
<text class="match-live-popup__option-url">{{ item.url }}</text>
</view>
<text v-if="item.key === selectedStreamKey" class="match-live-popup__option-tag">当前</text>
</view>
</view>
<view v-else class="match-live-popup__empty">
<text>暂无可用直播线路</text>
</view>
</view>
<view class="match-live-popup__actions">
<u-button type="primary" shape="circle" @click="openSelected">打开当前线路</u-button>
<u-button shape="circle" plain @click="openSource">打开源站</u-button>
</view>
</view>
</u-popup>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import type { MatchLiveLineItem, MatchLiveStreamOption } from '@/api/match'
type PopupStreamOption = MatchLiveStreamOption & {
key: string
label: string
url: string
}
const props = defineProps<{
show: boolean
line: MatchLiveLineItem | null
}>()
const emit = defineEmits(['update:show', 'close'])
const showPopup = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const selectedStreamKey = ref('')
const normalizeText = (value: unknown) => String(value || '').trim()
const normalizeUrl = (value: unknown) => {
const text = normalizeText(value)
return text
}
const getFetchStatusText = (value: unknown) => {
const text = normalizeText(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 formatMetaTime = (value: unknown) => {
const text = normalizeText(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
const date = new Date(raw)
if (!Number.isNaN(date.getTime())) {
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${hour}:${minute}`
}
}
return text
}
const lineTitle = computed(() => {
const line = props.line || {}
return (
normalizeText(line.title) ||
normalizeText(line.name) ||
normalizeText(line.label) ||
normalizeText(line.live_tag) ||
normalizeText(line.source_name) ||
'直播线路'
)
})
const lineMetaText = computed(() => {
const line = props.line || {}
const parts = [
getFetchStatusText(line.fetch_status),
formatMetaTime(line.last_fetch_at || line.update_time),
].filter(Boolean)
return parts.length ? parts.join(' · ') : '请选择可用直播线路后打开'
})
const sourceUrl = computed(() => normalizeUrl(props.line?.source_url))
const buildStreamOption = (option: MatchLiveStreamOption | undefined, index: number) => {
if (!option) return null
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,
key: `${index}-${url}`,
label:
normalizeText(option.title) ||
normalizeText(option.name) ||
normalizeText(option.label) ||
normalizeText(option.source_name) ||
normalizeText(option.quality) ||
`线路${index + 1}`,
url,
} satisfies PopupStreamOption
}
const streamOptions = computed(() => {
const line = props.line || {}
const options: PopupStreamOption[] = []
const seen = new Set<string>()
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)
if (!built || seen.has(built.url)) return
seen.add(built.url)
options.push(built)
}
if (Array.isArray(line.stream_options)) {
line.stream_options.forEach((item, index) => pushOption(item, index))
}
fallbackFields.forEach(({ field, label }) => {
const value = normalizeUrl(line[field])
if (!value || seen.has(value)) return
pushOption({ label, [field]: value }, options.length)
})
return options
})
const selectedStream = computed(() => {
return streamOptions.value.find((item) => item.key === selectedStreamKey.value) || streamOptions.value[0] || null
})
const resetSelected = () => {
const defaultPlayUrl = normalizeUrl(props.line?.default_play_url)
if (defaultPlayUrl) {
const matched = streamOptions.value.find((item) => item.url === defaultPlayUrl)
if (matched) {
selectedStreamKey.value = matched.key
return
}
}
selectedStreamKey.value = streamOptions.value[0]?.key || ''
}
watch(
() => [props.line, props.show],
() => {
resetSelected()
},
{ immediate: true, deep: true }
)
const navigateToWebview = (url: string) => {
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
}
const selectOption = (item: PopupStreamOption) => {
selectedStreamKey.value = item.key
}
const openSelected = () => {
const url = selectedStream.value?.url || ''
if (!url) {
uni.showToast({ title: '暂无可用直播链接', icon: 'none' })
return
}
navigateToWebview(url)
}
const openSource = () => {
if (!sourceUrl.value) {
uni.showToast({ title: '暂无源站链接', icon: 'none' })
return
}
navigateToWebview(sourceUrl.value)
}
const handleClose = () => {
emit('close')
}
</script>
<style lang="scss" scoped>
.match-live-popup {
padding: 40rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
background: #fff;
&__header {
display: flex;
flex-direction: column;
gap: 10rpx;
margin-bottom: 28rpx;
}
&__title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
&__desc {
font-size: 24rpx;
color: #6b7280;
line-height: 1.5;
}
&__section {
margin-bottom: 32rpx;
}
&__section-title {
display: block;
margin-bottom: 18rpx;
font-size: 26rpx;
font-weight: 600;
color: #1f2937;
}
&__options {
display: flex;
flex-direction: column;
gap: 16rpx;
}
&__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx 24rpx;
border: 2rpx solid #e5e7eb;
border-radius: 20rpx;
background: #f9fafb;
&--active {
border-color: #185dff;
background: rgba(24, 93, 255, 0.08);
}
}
&__option-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__option-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
&__option-url {
font-size: 22rpx;
color: #6b7280;
line-height: 1.4;
word-break: break-all;
}
&__option-tag {
flex-shrink: 0;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: #185dff;
color: #fff;
font-size: 22rpx;
font-weight: 600;
}
&__empty {
padding: 40rpx 0;
text-align: center;
font-size: 24rpx;
color: #9ca3af;
}
&__actions {
display: flex;
flex-direction: column;
gap: 18rpx;
}
}
</style>
+112 -1
View File
@@ -62,8 +62,31 @@
</view> </view>
</view> </view>
<view v-if="liveList.length" class="live-entrance">
<view class="live-entrance__header">
<text class="live-entrance__heading">直播线路</text>
<text class="live-entrance__subheading">选择线路后通过内置 WebView 打开</text>
</view>
<view v-for="(item, index) in liveList" :key="item.id || item.title || item.name || index"
class="live-entrance__card" @tap="openLiveLine(item)">
<view class="live-entrance__left">
<view class="live-entrance__badge">
<text>{{ index + 1 }}</text>
</view>
<view class="live-entrance__content">
<text class="live-entrance__title">{{ getLiveLineTitle(item) }}</text>
<text class="live-entrance__desc">{{ getLiveLineMeta(item) }}</text>
</view>
</view>
<view class="live-entrance__action">
<text>切换线路</text>
<u-icon name="arrow-right" size="26" color="#c7d2fe"></u-icon>
</view>
</view>
</view>
<!-- Tab 导航栏 --> <!-- Tab 导航栏 -->
<view v-if="thirdPartyLiveUrl" class="live-entrance"> <view v-else-if="thirdPartyLiveUrl" class="live-entrance">
<view class="live-entrance__card" @tap="openThirdPartyLive"> <view class="live-entrance__card" @tap="openThirdPartyLive">
<view class="live-entrance__left"> <view class="live-entrance__left">
<view class="live-entrance__badge"> <view class="live-entrance__badge">
@@ -412,6 +435,8 @@
</view> </view>
</scroll-view> </scroll-view>
<MatchLivePopup v-model:show="showLivePopup" :line="selectedLiveLine" @close="handleLivePopupClose" />
<!-- 分享弹窗 --> <!-- 分享弹窗 -->
<SharePopup v-if="showSharePopup" v-model:show="showSharePopup" page-type="match" <SharePopup v-if="showSharePopup" v-model:show="showSharePopup" page-type="match"
:path="`/pages/match_detail/match_detail?id=${match.id || ''}`" :path="`/pages/match_detail/match_detail?id=${match.id || ''}`"
@@ -423,10 +448,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, nextTick, onMounted } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import MatchDetailHeader from '@/components/match-detail-header/match-detail-header.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 SharePopup from '@/components/share-popup/share-popup.vue'
import GlobalPopup from '@/components/global-popup/global-popup.vue' import GlobalPopup from '@/components/global-popup/global-popup.vue'
import { onLoad, onUnload } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match' import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match'
import type { MatchLiveLineItem } from '@/api/match'
import { checkAiUnlock, getMatchPredictSection } from '@/api/ai' import { checkAiUnlock, getMatchPredictSection } from '@/api/ai'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { getLocalMatchDate } from '@/utils/match-time' import { getLocalMatchDate } from '@/utils/match-time'
@@ -447,9 +474,14 @@ const aiInlineLoading = ref(false)
const aiInline = ref<any>({}) const aiInline = ref<any>({})
const showSharePopup = ref(false) const showSharePopup = ref(false)
const liveTextList = ref<any[]>([]) const liveTextList = ref<any[]>([])
const showLivePopup = ref(false)
const selectedLiveLine = ref<MatchLiveLineItem | null>(null)
let liveTextTimer: ReturnType<typeof setInterval> | null = null let liveTextTimer: ReturnType<typeof setInterval> | null = null
const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim()) const thirdPartyLiveUrl = computed(() => String(match.value?.live_url || '').trim())
const liveList = computed<MatchLiveLineItem[]>(() => {
return Array.isArray(match.value?.live_list) ? match.value.live_list : []
})
const liveSourceText = computed(() => { const liveSourceText = computed(() => {
const raw = match.value?.living_tv ?? match.value?.live_source ?? match.value?.livingTv ?? '' 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 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 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 techStatsList = computed(() => {
const m = match.value as any const m = match.value as any
// 优先使用 tech_stats JSON(兼容所有运动类型) // 优先使用 tech_stats JSON(兼容所有运动类型)
@@ -540,6 +617,8 @@ onMounted(() => {
const fetchDetail = async (id: number) => { const fetchDetail = async (id: number) => {
loading.value = true loading.value = true
showLivePopup.value = false
selectedLiveLine.value = null
try { try {
const data = await getMatchDetail({ id }) const data = await getMatchDetail({ id })
match.value = data || {} match.value = data || {}
@@ -604,6 +683,15 @@ const openThirdPartyLive = () => {
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` }) 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) => { const formatLiveTime = (ts: any) => {
if (!ts) return '' if (!ts) return ''
const s = String(ts) const s = String(ts)
@@ -1560,6 +1648,29 @@ onUnload(() => {
.live-entrance { .live-entrance {
margin: 0 24rpx 24rpx; 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 { &__card {
display: flex; display: flex;