Merge branch 'codex/match-live-streams' into dev.1.0.0
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user