From 3a5d571e5340c8cefacfcdd84222191af8a7e48b Mon Sep 17 00:00:00 2001 From: hajimi Date: Sat, 11 Jul 2026 11:19:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B5=94=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + AGENTS.md | 23 +- README.md | 22 +- docker/.env.docker.example | 11 + docker/crawler/docker/entrypoint.sh | 2 +- docker/crawler/match_odds.py | 186 ++- docker/crawler/tests/test_match_odds.py | 4 +- docker/crawler/tests/test_titan_odds.py | 81 ++ docker/crawler/titan_odds.py | 448 ++++++ docs/sql/create_ai_quota_unlock.sql | 13 + docs/sql/create_match_odds_table.sql | 43 + docs/sql/insert_crontab_match_odds.sql | 31 + docs/sql/insert_menu_match_live.sql | 10 +- docs/sql/insert_menu_match_odds.sql | 43 + qa/screenshots/community-page.txt | 4 +- sbnews.sql | 66 + scripts/deploy-server.sh | 6 +- scripts/release-server.sh | 2 +- .../adminapi/lists/match/MatchOddsLists.php | 1 + server/app/api/controller/MatchController.php | 129 +- .../common/service/match/MatchLiveService.php | 35 +- sport-era-msg/pages/index/index.vue | 2 +- uniapp/src/api/match.ts | 22 + .../components/MatchCenterPanel.vue | 446 +----- .../components/MatchCenterTabsPanel.vue | 1243 +++++++++++++++++ 业务进度管理.md | 272 +++- 设计稿映射.md | 2 +- 赛事中心赛程直播赔率组件实施计划.md | 301 ++++ 赛事中心赛程直播赔率组件设计.md | 96 ++ 29 files changed, 3006 insertions(+), 542 deletions(-) create mode 100644 docker/crawler/tests/test_titan_odds.py create mode 100644 docker/crawler/titan_odds.py create mode 100644 docs/sql/create_ai_quota_unlock.sql create mode 100644 docs/sql/create_match_odds_table.sql create mode 100644 docs/sql/insert_crontab_match_odds.sql create mode 100644 docs/sql/insert_menu_match_odds.sql create mode 100644 uniapp/src/packages_match/components/MatchCenterTabsPanel.vue create mode 100644 赛事中心赛程直播赔率组件实施计划.md create mode 100644 赛事中心赛程直播赔率组件设计.md diff --git a/.gitignore b/.gitignore index 10557ef..7eaa209 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ bin-release/ **/.hbuilderx/ **/unpackage/ qa/ +.code-graph/ +./vscode/ +.tmp* +.codex/ diff --git a/AGENTS.md b/AGENTS.md index c1f8403..5ac059f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,13 @@ | uniapp | `npm run dev:h5` | `npm run build:h5` | | pc | `npm run dev` | `npm run generate` / `npm run build` | +## 自动化执行边界 + +- 除非用户在当前任务中明确要求,否则不要自动执行构建、测试、提交代码或部署。 +- 完成代码修改后,可以说明建议执行的验证命令或部署命令,但不得自行运行。 +- 用户明确要求“构建”“测试”“提交”“部署”“上线”“发布”等操作时,才执行对应命令。 +- 对已经由用户给出的命令,可按用户原命令执行;不要额外扩展到未要求的构建、测试、提交或部署步骤。 + ## 数据库结构基准 - 数据库表前缀:`la_`。 @@ -77,7 +84,7 @@ ## 代码修改后验证与评审 -除非用户特别说明不需要验证,代码修改完成后按改动类型选择验证方式: +仅在用户明确要求验证、测试或构建时,代码修改完成后按改动类型选择验证方式;未明确要求时只列出建议命令,不自动执行: ### 后端接口修改 @@ -108,14 +115,14 @@ ## 代码变更后部署 -- 只要本项目有代码变更,完成必要验证后,必须执行以下命令,将最新代码更新到测试服运行环境: +- 仅在用户明确要求部署、上线、发布或同步到测试服时,执行以下命令,将最新代码更新到测试服运行环境: ```powershell -cd D:\www\sport-era +cd D:\www\gs-sport-era powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net ``` -- 默认将这条部署命令视为 Sport Era 项目代码变更后的必做步骤,除非用户在当前任务中明确说明“不需要部署”。 +- 不要把部署视为代码变更后的默认必做步骤;未明确要求部署时,只提示可执行的部署命令。 ## 设计稿映射 @@ -133,6 +140,14 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -Auto 开发或接手任务前,先查看该文档确认已完成事项、进行中事项、待办事项与避坑记录。 +## 近期交接总结 + +- 当前真实开发目录以 `D:\www\gs-sport-era` 为准;Codex worktree(如 `C:\Users\Administrator\.codex\worktrees\...\gs-sport-era`)中的改动不会自动进入真实仓库、`git log` 或服务器运行目录。涉及可部署代码时,先确认改动已经同步到真实目录并形成清晰提交。 +- 正确的服务器运行目录是 `/www/wwwroot/test-server.sbnews.net`;`/www/wwwroot/api.sbnews.net` 不是当前项目的有效同步目录,不要再部署到该路径。排查“代码已更新但服务器没生效”时,本地真实仓库看 `git log`,服务器侧看文件内容、修改时间、容器内 `/app` 文件与定时任务日志。 +- Docker crawler 任务真实执行入口是 `sport-era-crawler` 容器。更新 `docker/crawler` 源码或 `config/crawler_tasks.yaml` 后,必须在服务器 `docker` 目录执行 `sudo docker compose -f docker-compose.crawler.yml build crawler` 和 `sudo docker compose -f docker-compose.crawler.yml up -d --no-deps crawler`;仅重启旧容器不能保证镜像内代码更新,必要时使用 `--no-cache` 重建。 +- 重启 crawler 后必须核验容器内代码和 cron,而不是只看宿主机源码:例如检查 `/app/match_live_stream.py`、`/app/config/crawler_tasks.yaml`,以及 `cat /etc/cron.d/* | grep match_live_stream`。 +- 雨燕直播间任务 `match_live_stream` 当前应直接同步 `all_live_rooms.json`,写入主播 `anchor_id`、`anchor_name`、`anchor_avatar`、`anchor_level` 以及直播间观看/关注/公告/简介字段;对应 SQL 为 `docs/sql/alter_match_live_anchor_fields.sql` 和 `docs/sql/insert_crontab_match_live_stream.sql`。上线后用 `la_match_live` 主播字段统计和 `la_crawler_task_log` 最近任务状态共同确认是否生效。 + ## 近期踩坑 - `Validate::only()` 只限制校验字段范围,不会自动过滤请求参数;新增字段落库前,要同时检查 validate、logic、model、保存白名单和数据库列是否同步。 diff --git a/README.md b/README.md index cad1d75..e643d5f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Docker 只负责运行 Python 爬虫和容器内 cron。MySQL、Redis、Nginx ```bash ssh sbnews -cd /www/wwwroot/test-server.sbnews.net/docker +cd /www/wwwroot/api.sbnews.net/docker ``` 查看容器状态: @@ -127,7 +127,7 @@ sudo docker exec sport-era-crawler python -c "import scrapling, browserforge; pr ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/api.sbnews.net ``` 如果 `sbnews` 配置在 `C:\Users\Administrator\.ssh\config` 中,默认会使用 Windows 的 `ssh.exe` 读取该配置。 @@ -138,7 +138,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -Remo ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/api.sbnews.net ``` 全量模式默认以仓库根目录为源,只打包以下四个子目录: @@ -177,14 +177,14 @@ docker/ ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From -To -RemoteDir /www/wwwroot/test-server.sbnews.net +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From -To -RemoteDir /www/wwwroot/api.sbnews.net ``` 示例: ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From abc1234 -To def5678 -RemoteDir /www/wwwroot/test-server.sbnews.net +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From abc1234 -To def5678 -RemoteDir /www/wwwroot/api.sbnews.net ``` ### 自动增量发布 @@ -193,21 +193,21 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -RemoteDir /www/wwwroot/api.sbnews.net ``` 如果需要一次发布最近 N 个提交的后端改动,可以追加 `-AutoIncrementalCommits `,例如发布最近 3 个提交会对比 `HEAD~3` 和 `HEAD`: ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/api.sbnews.net ``` 先演练不上传: ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/api.sbnews.net -DryRun ``` 脚本会在打包和上传前先检查默认打包范围(`server` + `uniapp` + `admin` + `docker`);如果这些目录内存在未提交、删除或未跟踪文件,会先自动提交这些变更,再继续计算增量范围。可以通过 `-AutoCommitMessage "提交说明"` 自定义这次自动提交的说明。若存在合并冲突,需要先手动解决。 @@ -222,7 +222,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncrem ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir /www/wwwroot/api.sbnews.net -DryRun ``` 脚本会在打包后打印 `update log`:增量模式包含 commit 范围、commit 摘要、变更文件和删除文件;全量模式包含当前最新 commit 和默认打包目录。 @@ -235,7 +235,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir ```powershell cd D:\www\sport-era -powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net -PostInstall "php think optimize:clear" +powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/api.sbnews.net -PostInstall "php think optimize:clear" ``` ### 发布 uniapp 源码到服务器并在服务器构建 @@ -246,7 +246,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -Remo cd D:\www\sport-era powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 ` -Sudo ` - -RemoteDir /www/wwwroot/test-server.sbnews.net ` + -RemoteDir /www/wwwroot/api.sbnews.net ` -UniappReleasePath ../server/public/mobile ` -PostInstall "cd uniapp && npm install && npm run build:h5" ``` diff --git a/docker/.env.docker.example b/docker/.env.docker.example index b1a4e15..3f07282 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -29,6 +29,17 @@ ODDS_SPORT_TYPES=FT,BK,ES,TN,VB,BM,TT,BS,SK,OP ODDS_SHOW_TYPES=live,today,early ODDS_VERIFY_TLS=0 +# Titan007 public cup odds. Multiple cup URLs can be comma-separated. +TITAN_ODDS_ENABLED=1 +TITAN_ODDS_URLS=https://zq.titan007.com/cn/CupMatch/75.html +TITAN_ODDS_COMPANY_ID=3 +TITAN_ODDS_EURO_COMPANY_ID=545 +TITAN_ODDS_COMPANY_NAME=Crown +TITAN_ODDS_SHOW_TYPE=early +TITAN_ODDS_VERIFY_TLS=1 +TITAN_ODDS_TIMEOUT=20 +TITAN_ODDS_REQUEST_DELAY_SECONDS=0.2 + # AI KB worker embedding config. Keep real key in docker/.env.docker only. EMBEDDING_API_KEY=change-me EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode diff --git a/docker/crawler/docker/entrypoint.sh b/docker/crawler/docker/entrypoint.sh index b8a186d..8e58d3d 100644 --- a/docker/crawler/docker/entrypoint.sh +++ b/docker/crawler/docker/entrypoint.sh @@ -19,7 +19,7 @@ from pathlib import Path env_file = Path("/etc/sport-era-crawler/env.sh") with env_file.open("w", encoding="utf-8") as fp: for key, value in sorted(os.environ.items()): - if key.startswith(("DQD_", "ODDS_", "HG_ODDS_")) or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}: + if key.startswith(("DQD_", "ODDS_", "HG_ODDS_", "TITAN_ODDS_")) or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}: fp.write(f"export {key}={shlex.quote(value)}\n") env_file.chmod(0o600) PY diff --git a/docker/crawler/match_odds.py b/docker/crawler/match_odds.py index d64e38f..29bb1de 100644 --- a/docker/crawler/match_odds.py +++ b/docker/crawler/match_odds.py @@ -15,6 +15,14 @@ import pymysql import requests import urllib3 +from titan_odds import ( + DEFAULT_TITAN_COMPANY_ID, + DEFAULT_TITAN_COMPANY_NAME, + DEFAULT_TITAN_CUP_URLS, + DEFAULT_TITAN_EURO_COMPANY_ID, + TitanOddsClient, +) + DEFAULT_BASE_URLS = [ "https://205.201.0.120/", @@ -70,6 +78,13 @@ def _safe_int(value: Any, default: int = 0) -> int: return default +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return default + + def _text(raw: Dict[str, str], key: str) -> str: return str(raw.get(key) or "").strip() @@ -538,62 +553,129 @@ def _build_result( def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict[str, Any]: env = env if env is not None else os.environ - username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip() - password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip() - if not username or not password: - return _build_result( - success=False, - candidate_count=0, - saved_count=0, - errors=["ODDS_USERNAME/ODDS_PASSWORD 未配置"], - ) - - base_urls = [_normalize_base_url(url) for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS)] - sport_types = [item.upper() for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES)] - show_types = [item.lower() for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_SHOW_TYPES)] - timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20) - verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False) - langx = (env.get("ODDS_LANGX") or "zh-cn").strip() - odds_type = (env.get("ODDS_TYPE") or "H").strip().upper() - request_delay = float(env.get("ODDS_REQUEST_DELAY_SECONDS") or REQUEST_DELAY_SECONDS) - - client: Optional[HgOddsClient] = None - login_errors: List[str] = [] - for base_url in base_urls: - try: - candidate = HgOddsClient( - base_url=base_url, - username=username, - password=password, - timeout=timeout, - verify_tls=verify_tls, - langx=langx, - odds_type=odds_type, - ) - candidate.login() - client = candidate - break - except Exception as exc: # noqa: BLE001 - login_errors.append(f"{base_url}: {exc}") - - if client is None: - return _build_result(success=False, candidate_count=0, saved_count=0, errors=login_errors) - items: List[Dict[str, Any]] = [] fetch_errors: List[str] = [] + source_urls: List[str] = [] + successful_sources = 0 skipped_count = 0 - for sport_type in sport_types: - for show_type in show_types: + + username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip() + password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip() + if username and password: + base_urls = [ + _normalize_base_url(url) + for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS) + ] + sport_types = [ + item.upper() + for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES) + ] + show_types = [ + item.lower() + for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_SHOW_TYPES) + ] + timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20) + verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False) + langx = (env.get("ODDS_LANGX") or "zh-cn").strip() + odds_type = (env.get("ODDS_TYPE") or "H").strip().upper() + request_delay = _safe_float( + env.get("ODDS_REQUEST_DELAY_SECONDS"), + REQUEST_DELAY_SECONDS, + ) + + client: Optional[HgOddsClient] = None + login_errors: List[str] = [] + for base_url in base_urls: try: - items.extend(client.fetch_game_list(sport_type, show_type)) - except OddsGameListUnavailable: - skipped_count += 1 + candidate = HgOddsClient( + base_url=base_url, + username=username, + password=password, + timeout=timeout, + verify_tls=verify_tls, + langx=langx, + odds_type=odds_type, + ) + candidate.login() + client = candidate + break except Exception as exc: # noqa: BLE001 - fetch_errors.append(f"{sport_type}/{show_type}: {exc}") - if request_delay > 0: - time.sleep(request_delay) + login_errors.append(f"hg {base_url}: {exc}") + + if client is None: + fetch_errors.extend(login_errors) + else: + successful_sources += 1 + source_urls.append(client.base_url) + for sport_type in sport_types: + for show_type in show_types: + try: + items.extend(client.fetch_game_list(sport_type, show_type)) + except OddsGameListUnavailable: + skipped_count += 1 + except Exception as exc: # noqa: BLE001 + fetch_errors.append(f"hg {sport_type}/{show_type}: {exc}") + if request_delay > 0: + time.sleep(request_delay) + + titan_enabled = _bool_env(env.get("TITAN_ODDS_ENABLED"), default=True) + if titan_enabled: + titan_urls = [ + str(url).strip() + for url in _split_env_list( + env.get("TITAN_ODDS_URLS", ""), + DEFAULT_TITAN_CUP_URLS, + ) + ] + titan_client = TitanOddsClient( + timeout=_safe_int(env.get("TITAN_ODDS_TIMEOUT"), 20), + verify_tls=_bool_env(env.get("TITAN_ODDS_VERIFY_TLS"), default=True), + company_id=_safe_int( + env.get("TITAN_ODDS_COMPANY_ID"), + DEFAULT_TITAN_COMPANY_ID, + ), + euro_company_id=_safe_int( + env.get("TITAN_ODDS_EURO_COMPANY_ID"), + DEFAULT_TITAN_EURO_COMPANY_ID, + ), + company_name=( + env.get("TITAN_ODDS_COMPANY_NAME") + or DEFAULT_TITAN_COMPANY_NAME + ).strip(), + show_type=(env.get("TITAN_ODDS_SHOW_TYPE") or "early").strip().lower(), + request_delay=_safe_float( + env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"), + 0.2, + ), + ) + for titan_url in titan_urls: + try: + items.extend(titan_client.fetch_cup(titan_url)) + successful_sources += 1 + source_urls.append(titan_url) + except Exception as exc: # noqa: BLE001 + fetch_errors.append(f"titan007 {titan_url}: {exc}") + titan_request_delay = _safe_float( + env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"), + 0.2, + ) + if titan_request_delay > 0: + time.sleep(titan_request_delay) + + if successful_sources == 0: + if not username or not password: + fetch_errors.append("ODDS_USERNAME/ODDS_PASSWORD 未配置") + return _build_result( + success=False, + candidate_count=len(items), + saved_count=0, + source_url=",".join(dict.fromkeys(source_urls)), + skipped_count=skipped_count, + errors=fetch_errors or ["没有可用的赔率来源"], + ) repo = None + source_url = ",".join(dict.fromkeys(source_urls)) try: repo = MatchOddsRepository(db_config) repo.ensure_table() @@ -603,7 +685,7 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict success=False, candidate_count=len(items), saved_count=0, - source_url=client.base_url, + source_url=source_url, skipped_count=skipped_count, errors=fetch_errors + [f"database: {exc}"], ) @@ -612,10 +694,10 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict repo.close() return _build_result( - success=len(fetch_errors) == 0, + success=True, candidate_count=len(items), saved_count=saved_count, - source_url=client.base_url, + source_url=source_url, skipped_count=skipped_count, errors=fetch_errors, ) diff --git a/docker/crawler/tests/test_match_odds.py b/docker/crawler/tests/test_match_odds.py index 5b998f6..9c715c5 100644 --- a/docker/crawler/tests/test_match_odds.py +++ b/docker/crawler/tests/test_match_odds.py @@ -146,7 +146,7 @@ class MatchOddsTest(unittest.TestCase): self.assertEqual(json.loads(params[-2])["moneyline"]["draw"], "3.90") def test_run_skips_without_credentials(self): - result = run({"prefix": "la_"}, env={}) + result = run({"prefix": "la_"}, env={"TITAN_ODDS_ENABLED": "0"}) self.assertFalse(result["success"]) self.assertEqual(result["candidate_count"], 0) @@ -182,6 +182,7 @@ class MatchOddsTest(unittest.TestCase): "ODDS_SPORT_TYPES": "FT", "ODDS_SHOW_TYPES": "today", "ODDS_REQUEST_DELAY_SECONDS": "0", + "TITAN_ODDS_ENABLED": "0", }, ) @@ -234,6 +235,7 @@ class MatchOddsTest(unittest.TestCase): "ODDS_SPORT_TYPES": "FT", "ODDS_SHOW_TYPES": "today,early", "ODDS_REQUEST_DELAY_SECONDS": "0", + "TITAN_ODDS_ENABLED": "0", }, ) diff --git a/docker/crawler/tests/test_titan_odds.py b/docker/crawler/tests/test_titan_odds.py new file mode 100644 index 0000000..496091a --- /dev/null +++ b/docker/crawler/tests/test_titan_odds.py @@ -0,0 +1,81 @@ +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 titan_odds import ( + build_titan_odds_items, + parse_titan_odds_script, + parse_titan_schedule_script, +) + + +SCHEDULE_SCRIPT = r""" +var arrCup = [75,'世界杯','世界盃','World Cup','世界杯','世界盃','World Cup','2026']; +jh["G27976"] = [[2907402,75,0,'2026-07-12 05:00',640,744,'','','19','4',-0.5,-0.25,'2.5','1']]; +var arrTeam = [ + [640,'挪威','挪威','Norway','','images/norway.png'], + [744,'英格兰','英格蘭','England','','images/england.png'] +]; +var lastUpdateTime = '2026-07-11 10:00:12'; +""" + +ODDS_SCRIPT = """ +oddsData["O_2907402"]=[[545,3.5,3.6,2.01],[16,3.3,3.55,2.13]]; +oddsData["L_2907402"]=[[3,0.88,-0.5,0.94],[16,0.86,-0.5,0.98]]; +oddsData["T_2907402"]=[[3,0.92,2.5,0.88],[16,0.93,2.5,0.87]]; +""" + + +class TitanOddsTest(unittest.TestCase): + def test_parse_schedule_and_odds_builds_normalized_item(self): + schedule = parse_titan_schedule_script(SCHEDULE_SCRIPT) + odds = parse_titan_odds_script(ODDS_SCRIPT) + + items = build_titan_odds_items( + schedule, + odds, + source_url="https://zq.titan007.com/cn/CupMatch/75.html", + company_id=3, + euro_company_id=545, + company_name="Crown", + ) + + self.assertEqual(len(items), 1) + item = items[0] + self.assertEqual(item["source_site"], "titan007") + self.assertEqual(item["source_match_id"], "2907402") + self.assertEqual(item["league_name"], "世界杯") + self.assertEqual(item["home_team"], "挪威") + self.assertEqual(item["away_team"], "英格兰") + self.assertEqual(item["show_type"], "early") + self.assertEqual(item["strong_side"], "C") + self.assertEqual(item["handicap_line"], "0.5") + self.assertEqual(item["handicap_home_odds"], "0.88") + self.assertEqual(item["handicap_away_odds"], "0.94") + self.assertEqual(item["total_line"], "2.5") + self.assertEqual(item["home_win_odds"], "3.5") + self.assertEqual(item["draw_odds"], "3.6") + self.assertEqual(item["away_win_odds"], "2.01") + self.assertEqual(item["raw_data"]["selected_company_id"], 3) + self.assertEqual(item["raw_data"]["selected_euro_company_id"], 545) + + def test_sparse_schedule_rows_are_parsed_without_eval(self): + script = r""" +var arrCup = [75,'世界杯','','','','','','2026']; +jh["G27977"] = [[2907405,75,0,'2026-07-16 03:00',77068,77069,'','','','',,,'','']]; +var arrTeam = [[77068,'99胜者'],[77069,'100胜者']]; +""" + + parsed = parse_titan_schedule_script(script) + + self.assertIn("2907405", parsed["matches"]) + self.assertEqual(parsed["matches"]["2907405"]["home_team"], "99胜者") + + +if __name__ == "__main__": + unittest.main() diff --git a/docker/crawler/titan_odds.py b/docker/crawler/titan_odds.py new file mode 100644 index 0000000..bfec98e --- /dev/null +++ b/docker/crawler/titan_odds.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import ast +import html +import json +import re +import time +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Iterable, List, Optional, Tuple +from urllib.parse import urljoin + +import requests + + +DEFAULT_TITAN_CUP_URLS = [ + "https://zq.titan007.com/cn/CupMatch/75.html", +] +DEFAULT_TITAN_COMPANY_ID = 3 +DEFAULT_TITAN_EURO_COMPANY_ID = 545 +DEFAULT_TITAN_COMPANY_NAME = "Crown" +DEFAULT_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" +) + + +def _limit(value: Any, size: int) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip()[:size] + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def _number_text(value: Any, *, absolute: bool = False) -> str: + if value is None or value == "": + return "" + try: + number = Decimal(str(value)) + if absolute: + number = abs(number) + text = format(number.normalize(), "f") + return "0" if text in {"-0", ""} else text + except (InvalidOperation, ValueError): + return _limit(value, 32) + + +def _extract_array_expression(script: str, start_at: int) -> Tuple[str, int]: + start = script.find("[", start_at) + if start < 0: + raise ValueError("JavaScript array start not found") + + depth = 0 + quote = "" + escaped = False + for index in range(start, len(script)): + char = script[index] + if quote: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = "" + continue + if char in {"'", '"'}: + quote = char + elif char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if depth == 0: + return script[start : index + 1], index + 1 + raise ValueError("JavaScript array end not found") + + +def _fill_sparse_array_holes(expression: str) -> str: + output: List[str] = [] + quote = "" + escaped = False + length = len(expression) + for index, char in enumerate(expression): + output.append(char) + if quote: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = "" + continue + if char in {"'", '"'}: + quote = char + continue + if char not in {"[", ","}: + continue + next_index = index + 1 + while next_index < length and expression[next_index].isspace(): + next_index += 1 + if next_index < length and expression[next_index] == ",": + output.append("None") + return "".join(output) + + +def _parse_js_array(expression: str) -> List[Any]: + normalized = _fill_sparse_array_holes(expression) + value = ast.literal_eval(normalized) + if not isinstance(value, list): + raise ValueError("JavaScript assignment is not an array") + return value + + +def _parse_named_array(script: str, marker: str) -> List[Any]: + marker_index = script.find(marker) + if marker_index < 0: + raise ValueError(f"JavaScript marker not found: {marker}") + expression, _ = _extract_array_expression(script, marker_index + len(marker)) + return _parse_js_array(expression) + + +def _is_match_row(value: Any) -> bool: + if not isinstance(value, list) or len(value) < 6: + return False + if _safe_int(value[0]) <= 0: + return False + return isinstance(value[3], str) and bool(re.match(r"^\d{4}-\d{2}-\d{2}\s", value[3])) + + +def _flatten_match_rows(value: Any) -> Iterable[List[Any]]: + if _is_match_row(value): + yield value + return + if isinstance(value, list): + for item in value: + yield from _flatten_match_rows(item) + + +def parse_titan_schedule_script(script: str) -> Dict[str, Any]: + script = (script or "").lstrip("\ufeff") + cup = _parse_named_array(script, "var arrCup =") + teams = _parse_named_array(script, "var arrTeam =") + team_map = { + str(row[0]): str(row[1]).strip() + for row in teams + if isinstance(row, list) and len(row) >= 2 + } + + matches: Dict[str, Dict[str, Any]] = {} + assignment_pattern = re.compile(r'jh\["(G[^"]+)"\]\s*=') + for assignment in assignment_pattern.finditer(script): + expression, _ = _extract_array_expression(script, assignment.end()) + event_key = assignment.group(1) + for row in _flatten_match_rows(_parse_js_array(expression)): + match_id = str(row[0]) + home_team_id = str(row[4] or "") + away_team_id = str(row[5] or "") + matches[match_id] = { + "source_match_id": match_id, + "source_event_id": event_key, + "match_time_text": str(row[3] or "").strip(), + "home_team_id": home_team_id, + "away_team_id": away_team_id, + "home_team": team_map.get(home_team_id, home_team_id), + "away_team": team_map.get(away_team_id, away_team_id), + "state": row[2] if len(row) > 2 else 0, + "schedule": row, + } + + last_update_match = re.search(r"var\s+lastUpdateTime\s*=\s*'([^']*)'", script) + return { + "source_league_id": str(cup[0] if cup else ""), + "league_name": str(cup[1] if len(cup) > 1 else "球探赛事").strip(), + "season": str(cup[7] if len(cup) > 7 else "").strip(), + "last_update_time": last_update_match.group(1) if last_update_match else "", + "matches": matches, + } + + +def parse_titan_odds_script(script: str) -> Dict[str, List[List[Any]]]: + script = (script or "").lstrip("\ufeff") + odds: Dict[str, List[List[Any]]] = {} + assignment_pattern = re.compile(r'oddsData\["([OLT]_\d+)"\]\s*=') + for assignment in assignment_pattern.finditer(script): + expression, _ = _extract_array_expression(script, assignment.end()) + value = json.loads(expression) + if isinstance(value, list): + odds[assignment.group(1)] = value + return odds + + +def _find_company_row(rows: List[List[Any]], company_id: int) -> Optional[List[Any]]: + for row in rows: + if isinstance(row, list) and row and _safe_int(row[0], -1) == company_id: + return row + return None + + +def _select_company_id(market_rows: Dict[str, List[List[Any]]], preferred_id: int) -> int: + coverage: Dict[int, int] = {} + for rows in market_rows.values(): + seen = set() + for row in rows: + if not isinstance(row, list) or not row: + continue + company_id = _safe_int(row[0], -1) + if company_id < 0 or company_id in seen: + continue + seen.add(company_id) + coverage[company_id] = coverage.get(company_id, 0) + 1 + if preferred_id in coverage: + return preferred_id + if not coverage: + return preferred_id + return sorted(coverage, key=lambda company_id: (-coverage[company_id], company_id))[0] + + +def build_titan_odds_items( + schedule_data: Dict[str, Any], + odds_data: Dict[str, List[List[Any]]], + *, + source_url: str, + company_id: int = DEFAULT_TITAN_COMPANY_ID, + euro_company_id: int = DEFAULT_TITAN_EURO_COMPANY_ID, + company_name: str = DEFAULT_TITAN_COMPANY_NAME, + show_type: str = "early", +) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + matches = schedule_data.get("matches") or {} + for match_id, match in matches.items(): + market_rows = { + "moneyline": odds_data.get(f"O_{match_id}", []), + "handicap": odds_data.get(f"L_{match_id}", []), + "total": odds_data.get(f"T_{match_id}", []), + } + selected_company_id = _select_company_id( + { + "handicap": market_rows["handicap"], + "total": market_rows["total"], + }, + company_id, + ) + selected_euro_company_id = _select_company_id( + {"moneyline": market_rows["moneyline"]}, + euro_company_id, + ) + moneyline = _find_company_row( + market_rows["moneyline"], + selected_euro_company_id, + ) + handicap = _find_company_row(market_rows["handicap"], selected_company_id) + total = _find_company_row(market_rows["total"], selected_company_id) + if not any((moneyline, handicap, total)): + continue + + handicap_value = handicap[2] if handicap and len(handicap) > 2 else "" + handicap_number: Optional[Decimal] = None + try: + handicap_number = Decimal(str(handicap_value)) + except (InvalidOperation, ValueError): + pass + strong_side = "" + if handicap_number is not None: + if handicap_number > 0: + strong_side = "H" + elif handicap_number < 0: + strong_side = "C" + + company_ids = { + _safe_int(row[0], -1) + for rows in market_rows.values() + for row in rows + if isinstance(row, list) and row + } + company_ids.discard(-1) + markets = { + "handicap": { + "line": _number_text(handicap_value, absolute=True), + "home": _number_text(handicap[1]) if handicap and len(handicap) > 1 else "", + "away": _number_text(handicap[3]) if handicap and len(handicap) > 3 else "", + "strong": strong_side, + }, + "total": { + "over_line": _number_text(total[2]) if total and len(total) > 2 else "", + "under_line": _number_text(total[2]) if total and len(total) > 2 else "", + "over": _number_text(total[1]) if total and len(total) > 1 else "", + "under": _number_text(total[3]) if total and len(total) > 3 else "", + }, + "moneyline": { + "home": _number_text(moneyline[1]) if moneyline and len(moneyline) > 1 else "", + "draw": _number_text(moneyline[2]) if moneyline and len(moneyline) > 2 else "", + "away": _number_text(moneyline[3]) if moneyline and len(moneyline) > 3 else "", + }, + "first_half": { + "handicap_line": "", + "handicap_home": "", + "handicap_away": "", + "total_over_line": "", + "total_under_line": "", + "total_over": "", + "total_under": "", + "home_win": "", + "away_win": "", + "draw": "", + }, + "both_teams_to_score": {"yes": "", "no": ""}, + "kickoff": {"home": "", "away": ""}, + } + selected_company_name = company_name if selected_company_id == company_id else str(selected_company_id) + raw_data = { + "schedule": match.get("schedule") or [], + "odds": { + "moneyline": market_rows["moneyline"], + "handicap": market_rows["handicap"], + "total": market_rows["total"], + }, + "selected_company_id": selected_company_id, + "selected_euro_company_id": selected_euro_company_id, + "selected_company_name": selected_company_name, + "last_update_time": schedule_data.get("last_update_time") or "", + } + items.append( + { + "source_site": "titan007", + "source_url": _limit(source_url, 255), + "source_match_id": _limit(match_id, 64), + "source_parent_id": "", + "source_league_id": _limit(schedule_data.get("source_league_id"), 64), + "source_event_id": _limit(match.get("source_event_id"), 64), + "sport_type": "FT", + "show_type": _limit(show_type, 20), + "odds_type": "H", + "league_name": _limit(schedule_data.get("league_name"), 150), + "match_time_text": _limit(match.get("match_time_text"), 50), + "home_team_id": _limit(match.get("home_team_id"), 64), + "away_team_id": _limit(match.get("away_team_id"), 64), + "home_team": _limit(match.get("home_team"), 150), + "away_team": _limit(match.get("away_team"), 150), + "strong_side": strong_side, + "more_count": len(company_ids), + "is_running": 0, + "handicap_line": _limit(markets["handicap"]["line"], 32), + "handicap_home_odds": _limit(markets["handicap"]["home"], 32), + "handicap_away_odds": _limit(markets["handicap"]["away"], 32), + "total_line": _limit(markets["total"]["over_line"], 32), + "total_over_odds": _limit(markets["total"]["over"], 32), + "total_under_odds": _limit(markets["total"]["under"], 32), + "home_win_odds": _limit(markets["moneyline"]["home"], 32), + "draw_odds": _limit(markets["moneyline"]["draw"], 32), + "away_win_odds": _limit(markets["moneyline"]["away"], 32), + "half_handicap_line": "", + "half_home_win_odds": "", + "half_draw_odds": "", + "half_away_win_odds": "", + "raw_data": raw_data, + "markets": markets, + } + ) + return items + + +class TitanOddsClient: + def __init__( + self, + *, + user_agent: str = DEFAULT_USER_AGENT, + timeout: int = 20, + verify_tls: bool = True, + company_id: int = DEFAULT_TITAN_COMPANY_ID, + euro_company_id: int = DEFAULT_TITAN_EURO_COMPANY_ID, + company_name: str = DEFAULT_TITAN_COMPANY_NAME, + show_type: str = "early", + request_delay: float = 0.2, + ) -> None: + self.timeout = int(timeout) + self.verify_tls = bool(verify_tls) + self.company_id = int(company_id) + self.euro_company_id = int(euro_company_id) + self.company_name = company_name + self.show_type = show_type + self.request_delay = float(request_delay) + self.session = requests.Session() + self.session.verify = self.verify_tls + self.session.headers.update( + { + "User-Agent": user_agent, + "Accept": "text/html,application/xhtml+xml,application/javascript,*/*;q=0.8", + } + ) + + def fetch_cup(self, cup_url: str) -> List[Dict[str, Any]]: + page_response = self.session.get(cup_url, timeout=self.timeout) + page_response.raise_for_status() + page_html = page_response.text or "" + season_match = re.search(r"selectSeason\s*=\s*'([^']+)'", page_html) + league_match = re.search(r"SclassID\s*=\s*(\d+)", page_html) + script_match = re.search( + r"""src=["']([^"']*/jsData/matchResult/[^"']+\.js(?:\?[^"']*)?)["']""", + page_html, + re.IGNORECASE, + ) + if not season_match or not league_match or not script_match: + raise ValueError("球探杯赛页面缺少赛季、联赛或赛程脚本") + + season = season_match.group(1) + league_id = league_match.group(1) + schedule_url = urljoin(page_response.url, html.unescape(script_match.group(1))) + schedule_response = self.session.get( + schedule_url, + headers={"Referer": page_response.url}, + timeout=self.timeout, + ) + schedule_response.raise_for_status() + schedule_data = parse_titan_schedule_script(schedule_response.text) + + if self.request_delay > 0: + time.sleep(self.request_delay) + + odds_url = urljoin(page_response.url, "/League/LeagueOddsAjax") + odds_response = self.session.get( + odds_url, + params={ + "sclassId": league_id, + "subSclassId": "", + "matchSeason": season, + "round": 1, + }, + headers={ + "Referer": page_response.url, + "X-Requested-With": "XMLHttpRequest", + }, + timeout=self.timeout, + ) + odds_response.raise_for_status() + odds_data = parse_titan_odds_script(odds_response.text) + return build_titan_odds_items( + schedule_data, + odds_data, + source_url=page_response.url, + company_id=self.company_id, + euro_company_id=self.euro_company_id, + company_name=self.company_name, + show_type=self.show_type, + ) diff --git a/docs/sql/create_ai_quota_unlock.sql b/docs/sql/create_ai_quota_unlock.sql new file mode 100644 index 0000000..7f41b84 --- /dev/null +++ b/docs/sql/create_ai_quota_unlock.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `la_ai_quota_unlock` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户ID', + `analysis_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '分析类型', + `analysis_key` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '分析唯一Key', + `ref_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联ID或哈希ID', + `create_time` int UNSIGNED NULL DEFAULT NULL COMMENT '解锁时间', + `update_time` int UNSIGNED NULL DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_analysis` (`user_id` ASC, `analysis_key` ASC) USING BTREE, + INDEX `idx_user_type` (`user_id` ASC, `analysis_type` ASC) USING BTREE, + INDEX `idx_analysis_key` (`analysis_key` ASC) USING BTREE +) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI分析通用解锁记录表' ROW_FORMAT = Dynamic; diff --git a/docs/sql/create_match_odds_table.sql b/docs/sql/create_match_odds_table.sql new file mode 100644 index 0000000..afd56b5 --- /dev/null +++ b/docs/sql/create_match_odds_table.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS `la_match_odds` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `source_site` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '赔率来源', + `source_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '来源线路', + `source_match_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源比赛ID', + `source_parent_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源主比赛ID', + `source_league_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源联赛ID', + `source_event_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源事件ID', + `sport_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '运动类型', + `show_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '盘口类型 live/today/early', + `odds_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '赔率格式', + `league_name` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '联赛名称', + `match_time_text` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '来源比赛时间文本', + `home_team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主队来源ID', + `away_team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客队来源ID', + `home_team` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '主队名称', + `away_team` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '客队名称', + `strong_side` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '让球强弱方', + `more_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更多玩法数量', + `is_running` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否滚球', + `handicap_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '让球盘口', + `handicap_home_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主队让球赔率', + `handicap_away_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客队让球赔率', + `total_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '大小球盘口', + `total_over_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '大球赔率', + `total_under_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '小球赔率', + `home_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主胜赔率', + `draw_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平局赔率', + `away_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客胜赔率', + `half_handicap_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场让球盘口', + `half_home_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场主胜赔率', + `half_draw_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场平局赔率', + `half_away_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场客胜赔率', + `create_time` INT UNSIGNED NOT NULL DEFAULT 0, + `raw_data` JSON NULL COMMENT '来源原始game节点', + `markets_json` JSON NULL COMMENT '规范化盘口JSON', + `update_time` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_source_match` (`source_site`, `source_match_id`), + KEY `idx_sport_show` (`sport_type`, `show_type`), + KEY `idx_league` (`source_league_id`), + KEY `idx_update_time` (`update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方赛事赔率表'; diff --git a/docs/sql/insert_crontab_match_odds.sql b/docs/sql/insert_crontab_match_odds.sql new file mode 100644 index 0000000..c31ab2e --- /dev/null +++ b/docs/sql/insert_crontab_match_odds.sql @@ -0,0 +1,31 @@ +UPDATE `la_dev_crontab` +SET + `name` = '第三方赛事赔率抓取', + `remark` = 'Docker crawler 每5分钟登录第三方线路并同步赛事赔率到 la_match_odds', + `status` = 1, + `expression` = '*/5 * * * *', + `update_time` = UNIX_TIMESTAMP() +WHERE `command` = 'crawler' + AND `params` = 'match_odds' + AND `delete_time` IS NULL; + +INSERT INTO `la_dev_crontab` +(`name`, `type`, `system`, `remark`, `command`, `params`, `status`, `expression`, `create_time`, `update_time`) +SELECT + '第三方赛事赔率抓取', + 1, + 0, + 'Docker crawler 每5分钟登录第三方线路并同步赛事赔率到 la_match_odds', + 'crawler', + 'match_odds', + 1, + '*/5 * * * *', + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 + FROM `la_dev_crontab` + WHERE `command` = 'crawler' + AND `params` = 'match_odds' + AND `delete_time` IS NULL +); diff --git a/docs/sql/insert_menu_match_live.sql b/docs/sql/insert_menu_match_live.sql index e3a7764..12defbe 100644 --- a/docs/sql/insert_menu_match_live.sql +++ b/docs/sql/insert_menu_match_live.sql @@ -3,16 +3,16 @@ JOIN `la_system_menu` parent ON parent.`type` = 'M' AND parent.`paths` = 'match' SET child.`pid` = parent.`id`, + child.`name` = '直播管理', child.`paths` = 'match_live', child.`component` = 'match/live/index', - child.`selected` = '/match/lists', - child.`is_show` = 0, + child.`selected` = '', + child.`is_show` = 1, child.`update_time` = UNIX_TIMESTAMP() -WHERE child.`perms` = 'match.matchLive/index' - AND child.`pid` <> parent.`id`; +WHERE child.`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 parent.`id`, 'C', '直播线路管理', '', 0, 'match.matchLive/index', 'match_live', 'match/live/index', '/match/lists', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +SELECT parent.`id`, 'C', '直播管理', '', 0, 'match.matchLive/index', 'match_live', 'match/live/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() FROM `la_system_menu` parent WHERE parent.`type` = 'M' AND parent.`paths` = 'match' diff --git a/docs/sql/insert_menu_match_odds.sql b/docs/sql/insert_menu_match_odds.sql new file mode 100644 index 0000000..54b45b5 --- /dev/null +++ b/docs/sql/insert_menu_match_odds.sql @@ -0,0 +1,43 @@ +UPDATE `la_system_menu` child +JOIN `la_system_menu` parent + ON parent.`type` = 'M' + AND parent.`paths` = 'match' +SET child.`pid` = parent.`id`, + child.`name` = '赔率列表', + child.`paths` = 'match_odds', + child.`component` = 'match/odds/index', + child.`selected` = '', + child.`is_show` = 1, + child.`update_time` = UNIX_TIMESTAMP() +WHERE child.`perms` = 'match.matchOdds/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 parent.`id`, 'C', '赔率列表', '', 0, 'match.matchOdds/index', 'match_odds', 'match/odds/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM `la_system_menu` parent +WHERE parent.`type` = 'M' + AND parent.`paths` = 'match' + AND NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchOdds/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.matchOdds/index' LIMIT 1), 'A', '赔率列表查询', '', 0, 'match.matchOdds/lists', '', '', '', '', 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_system_menu` + WHERE `perms` = 'match.matchOdds/lists' +); + +INSERT INTO `la_system_role_menu` (`role_id`, `menu_id`) +SELECT 1, m.`id` +FROM `la_system_menu` m +WHERE m.`perms` IN ( + 'match.matchOdds/index', + 'match.matchOdds/lists' +) +AND NOT EXISTS ( + SELECT 1 + FROM `la_system_role_menu` rm + WHERE rm.`role_id` = 1 + AND rm.`menu_id` = m.`id` +); diff --git a/qa/screenshots/community-page.txt b/qa/screenshots/community-page.txt index 7241e66..99c653e 100644 --- a/qa/screenshots/community-page.txt +++ b/qa/screenshots/community-page.txt @@ -1,6 +1,6 @@ -uid=3_0 RootWebArea "乐博头条" url="http://localhost:8993/" +uid=3_0 RootWebArea "世博头条" url="http://localhost:8993/" uid=3_1 StaticText "" - uid=3_2 StaticText "搜索乐博头条" + uid=3_2 StaticText "搜索世博头条" uid=3_3 StaticText "推荐" uid=3_4 StaticText "世界杯" uid=3_5 StaticText "中超" diff --git a/sbnews.sql b/sbnews.sql index d383edd..1062ffb 100644 --- a/sbnews.sql +++ b/sbnews.sql @@ -153,6 +153,24 @@ CREATE TABLE `la_ai_unlock` ( INDEX `idx_user_id`(`user_id` ASC) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI分析解锁记录' ROW_FORMAT = Dynamic; +-- ---------------------------- +-- Table structure for la_ai_quota_unlock +-- ---------------------------- +DROP TABLE IF EXISTS `la_ai_quota_unlock`; +CREATE TABLE `la_ai_quota_unlock` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户ID', + `analysis_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '分析类型', + `analysis_key` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '分析唯一Key', + `ref_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联ID或哈希ID', + `create_time` int UNSIGNED NULL DEFAULT NULL COMMENT '解锁时间', + `update_time` int UNSIGNED NULL DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_analysis`(`user_id` ASC, `analysis_key` ASC) USING BTREE, + INDEX `idx_user_type`(`user_id` ASC, `analysis_type` ASC) USING BTREE, + INDEX `idx_analysis_key`(`analysis_key` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI分析通用解锁记录表' ROW_FORMAT = Dynamic; + -- ---------------------------- -- Table structure for la_article -- ---------------------------- @@ -1042,6 +1060,54 @@ CREATE TABLE `la_match_data` ( INDEX `idx_crawl_time`(`crawl_time` ASC) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2964 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '比赛数据表' ROW_FORMAT = Dynamic; +-- ---------------------------- +-- Table structure for la_match_odds +-- ---------------------------- +DROP TABLE IF EXISTS `la_match_odds`; +CREATE TABLE `la_match_odds` ( + `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT, + `source_site` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '赔率来源', + `source_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '来源线路', + `source_match_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '来源比赛ID', + `source_parent_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '来源主比赛ID', + `source_league_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '来源联赛ID', + `source_event_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '来源事件ID', + `sport_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '运动类型', + `show_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '盘口类型 live/today/early', + `odds_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '赔率格式', + `league_name` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '联赛名称', + `match_time_text` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '来源比赛时间文本', + `home_team_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主队来源ID', + `away_team_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '客队来源ID', + `home_team` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主队名称', + `away_team` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '客队名称', + `strong_side` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '让球强弱方', + `more_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '更多玩法数量', + `is_running` tinyint UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否滚球', + `handicap_line` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '让球盘口', + `handicap_home_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主队让球赔率', + `handicap_away_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '客队让球赔率', + `total_line` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '大小球盘口', + `total_over_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '大球赔率', + `total_under_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '小球赔率', + `home_win_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主胜赔率', + `draw_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '平局赔率', + `away_win_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '客胜赔率', + `half_handicap_line` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '上半场让球盘口', + `half_home_win_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '上半场主胜赔率', + `half_draw_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '上半场平局赔率', + `half_away_win_odds` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '上半场客胜赔率', + `create_time` int UNSIGNED NOT NULL DEFAULT 0, + `raw_data` json NULL COMMENT '来源原始game节点', + `markets_json` json NULL COMMENT '规范化盘口JSON', + `update_time` int UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_source_match`(`source_site` ASC, `source_match_id` ASC) USING BTREE, + INDEX `idx_sport_show`(`sport_type` ASC, `show_type` ASC) USING BTREE, + INDEX `idx_league`(`source_league_id` ASC) USING BTREE, + INDEX `idx_update_time`(`update_time` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '第三方赛事赔率表' ROW_FORMAT = Dynamic; + -- ---------------------------- -- Table structure for la_match_event -- ---------------------------- diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh index 1761952..ac324fa 100644 --- a/scripts/deploy-server.sh +++ b/scripts/deploy-server.sh @@ -9,18 +9,18 @@ Usage: Modes: 1. Full deploy (no commit ids) - ./scripts/deploy-server.sh --remote-dir /www/wwwroot/test-server.sbnews.net + ./scripts/deploy-server.sh --remote-dir /www/wwwroot/api.sbnews.net 2. Incremental deploy (compare two commits) ./scripts/deploy-server.sh \ --from \ --to \ - --remote-dir /www/wwwroot/test-server.sbnews.net + --remote-dir /www/wwwroot/api.sbnews.net 3. Auto incremental deploy (compare latest two commits) ./scripts/deploy-server.sh \ --auto-incremental \ - --remote-dir /www/wwwroot/test-server.sbnews.net + --remote-dir /www/wwwroot/api.sbnews.net Options: --source-dir Local source directory, default: server diff --git a/scripts/release-server.sh b/scripts/release-server.sh index 96c1e25..cdb088c 100644 --- a/scripts/release-server.sh +++ b/scripts/release-server.sh @@ -225,7 +225,7 @@ ${ZIP_NAME} 使用方法: 1. 把 zip 上传到服务器 server 项目根 - 例: /www/wwwroot/test-server.sbnews.net/ + 例: /www/wwwroot/api.sbnews.net/ 2. 解压: unzip -o ${ZIP_NAME} 3. 应用: bash apply.sh 4. apply.sh 会自动删除废弃文件、清理空目录,并自我清理 diff --git a/server/app/adminapi/lists/match/MatchOddsLists.php b/server/app/adminapi/lists/match/MatchOddsLists.php index 3452104..bff359e 100644 --- a/server/app/adminapi/lists/match/MatchOddsLists.php +++ b/server/app/adminapi/lists/match/MatchOddsLists.php @@ -185,6 +185,7 @@ class MatchOddsLists extends BaseAdminDataLists implements ListsExtendInterface { $map = [ 'hg' => '滚球', + 'titan007' => '球探', ]; return $map[$sourceSite] ?? ($sourceSite !== '' ? strtoupper($sourceSite) : '未知平台'); } diff --git a/server/app/api/controller/MatchController.php b/server/app/api/controller/MatchController.php index da93490..f44e97a 100644 --- a/server/app/api/controller/MatchController.php +++ b/server/app/api/controller/MatchController.php @@ -18,7 +18,7 @@ use app\common\service\match\MatchLiveService; class MatchController extends BaseApiController { - public array $notNeedLogin = ['lists', 'detail', 'leagues', 'sportTypes', 'liveText', 'lineup', 'worldCupStandings', 'worldCupRankings', 'worldCupSchedule', 'worldCupLiveCards', 'worldCupOdds']; + public array $notNeedLogin = ['lists', 'detail', 'leagues', 'sportTypes', 'liveText', 'lineup', 'liveCards', 'odds', 'worldCupStandings', 'worldCupRankings', 'worldCupSchedule', 'worldCupLiveCards', 'worldCupOdds']; protected int $worldCupSeasonId = 26123; protected string $worldCupLeagueName = '世界杯'; @@ -513,16 +513,71 @@ class MatchController extends BaseApiController ]); } + public function liveCards() + { + $sportType = $this->request->get('sport_type/d', 0); + $leagueName = trim($this->request->get('league_name/s', '')); + + return $this->data([ + 'list' => MatchLiveService::getLiveCards($sportType, $leagueName), + ]); + } + + public function odds() + { + return $this->data([ + 'list' => $this->getOddsList( + $this->request->get('show_type/s', ''), + strtolower(trim($this->request->get('source_site/s', ''))), + trim($this->request->get('keyword/s', '')), + $this->request->get('include_special/d', 0), + min(max($this->request->get('limit/d', 120), 1), 300), + $this->request->get('sport_type/d', 0), + trim($this->request->get('league_name/s', '')) + ), + ]); + } + public function worldCupOdds() { - $showType = $this->request->get('show_type/s', ''); - $sourceSite = strtolower(trim($this->request->get('source_site/s', ''))); - $keyword = trim($this->request->get('keyword/s', '')); - $includeSpecial = $this->request->get('include_special/d', 0); - $limit = min(max($this->request->get('limit/d', 120), 1), 300); + return $this->data([ + 'season_id' => $this->worldCupSeasonId, + 'list' => $this->getOddsList( + $this->request->get('show_type/s', ''), + strtolower(trim($this->request->get('source_site/s', ''))), + trim($this->request->get('keyword/s', '')), + $this->request->get('include_special/d', 0), + min(max($this->request->get('limit/d', 120), 1), 300), + 1, + '', + true + ), + ]); + } + + private function getOddsList( + string $showType, + string $sourceSite, + string $keyword, + int $includeSpecial, + int $limit, + int $sportType = 0, + string $leagueName = '', + bool $worldCupEventOnly = false + ): array { $validShowTypes = ['live', 'today', 'early']; - $query = MatchOdds::where('sport_type', 'FT'); + $query = MatchOdds::where([]); + if ($sportType > 0) { + $oddsSportType = $this->resolveOddsSportType($sportType); + if ($oddsSportType === '') { + return []; + } + $query->where('sport_type', $oddsSportType); + } + if ($leagueName !== '') { + $query->where('league_name', $leagueName); + } if (!$includeSpecial) { $query->where('league_name', 'not like', '%特定球员%'); } @@ -553,7 +608,7 @@ class MatchController extends BaseApiController ->select() ->toArray(); - $eventMap = $this->buildWorldCupEventMap(); + $eventMap = $this->buildOddsEventMap($sportType, $leagueName, $worldCupEventOnly); $matches = []; foreach ($rows as $row) { $platform = $this->formatOddsPlatform($row); @@ -604,17 +659,30 @@ class MatchController extends BaseApiController return (int)$right['update_time'] <=> (int)$left['update_time']; }); - return $this->data([ - 'season_id' => $this->worldCupSeasonId, - 'list' => $list, - ]); + return $list; } - private function buildWorldCupEventMap(): array + private function buildOddsEventMap( + int $sportType = 0, + string $leagueName = '', + bool $worldCupOnly = false + ): array { - $rows = MatchEvent::where('league_name', $this->worldCupLeagueName) - ->where('sport_type', 1) - ->where('is_show', 1) + $query = MatchEvent::where('is_show', 1); + if ($sportType > 0) { + $query->where('sport_type', $sportType); + } + if ($leagueName !== '') { + $query->where('league_name', $leagueName); + } + if ($worldCupOnly) { + $query->where(function ($query) { + $query->where('league_name', $this->worldCupLeagueName) + ->whereOr('competition_id', 61); + }); + } + + $rows = $query ->field('id,match_id,home_team,home_icon,home_score,away_team,away_icon,away_score,status,match_time,current_minute') ->select() ->toArray(); @@ -629,15 +697,41 @@ class MatchController extends BaseApiController return $map; } + private function resolveOddsSportType(int $sportType): string + { + $map = [ + 1 => 'FT', + ]; + return $map[$sportType] ?? ''; + } + private function buildOddsMatchKey(array $row): string { + $teamPairKey = $this->buildTeamPairKey( + $row['home_team'] ?? '', + $row['away_team'] ?? '' + ); + $dateKey = $this->buildOddsMatchDateKey( + (string)($row['match_time_text'] ?? '') + ); + if ($teamPairKey !== '') { + return $teamPairKey . '|' . $dateKey; + } return implode('|', [ trim((string)($row['league_name'] ?? '')), trim((string)($row['match_time_text'] ?? '')), - $this->buildTeamPairKey($row['home_team'] ?? '', $row['away_team'] ?? ''), + (string)($row['source_match_id'] ?? ''), ]); } + private function buildOddsMatchDateKey(string $matchTimeText): string + { + if (preg_match('/(?:\d{4}[-\/])?(\d{1,2})[-\/](\d{1,2})/', $matchTimeText, $matches)) { + return sprintf('%02d-%02d', (int)$matches[1], (int)$matches[2]); + } + return trim($matchTimeText); + } + private function buildTeamPairKey(string $homeTeam, string $awayTeam): string { $homeTeam = preg_replace('/\s+/u', '', trim($homeTeam)); @@ -703,6 +797,7 @@ class MatchController extends BaseApiController { $map = [ 'hg' => '滚球', + 'titan007' => '球探', ]; return $map[$sourceSite] ?? ($sourceSite !== '' ? strtoupper($sourceSite) : '平台'); } diff --git a/server/app/common/service/match/MatchLiveService.php b/server/app/common/service/match/MatchLiveService.php index 6493791..3e160b7 100644 --- a/server/app/common/service/match/MatchLiveService.php +++ b/server/app/common/service/match/MatchLiveService.php @@ -97,16 +97,32 @@ class MatchLiveService return ''; } - public static function getWorldCupLiveCards(): array + public static function getLiveCards( + int $sportType = 0, + string $leagueName = '', + bool $worldCupOnly = false + ): array { - $rows = MatchLive::alias('live') + $query = MatchLive::alias('live') ->join('match m', 'm.id = live.match_id') ->where(['m.is_show' => 1]) - ->where([['m.status', '<>', 2]]) - ->where(function ($query) { - $query->where('m.league_name', '世界杯') - ->whereOr('m.competition_id', 61); - }) + ->where([['m.status', '<>', 2]]); + + if ($sportType > 0) { + $query->where('m.sport_type', $sportType); + } + if ($leagueName !== '') { + $query->where('m.league_name', $leagueName); + } + if ($worldCupOnly) { + $query + ->where(function ($query) { + $query->where('m.league_name', '世界杯') + ->whereOr('m.competition_id', 61); + }); + } + + $rows = $query ->field([ 'live.id as live_id', 'live.match_id', @@ -191,4 +207,9 @@ class MatchLiveService return $cards; } + + public static function getWorldCupLiveCards(): array + { + return self::getLiveCards(0, '', true); + } } diff --git a/sport-era-msg/pages/index/index.vue b/sport-era-msg/pages/index/index.vue index cc28b76..a4d3d30 100644 --- a/sport-era-msg/pages/index/index.vue +++ b/sport-era-msg/pages/index/index.vue @@ -364,7 +364,7 @@ export default { smsList: [], callList: [], activeRecordType: 'sms', - apiUrl: 'https://test-server.sbnews.net/api/sms/uploadSms', + apiUrl: 'https://api.sbnews.net/api/sms/uploadSms', intervalIndex: 2, polling: false, countdown: 0, diff --git a/uniapp/src/api/match.ts b/uniapp/src/api/match.ts index 63768b8..14fda00 100644 --- a/uniapp/src/api/match.ts +++ b/uniapp/src/api/match.ts @@ -125,6 +125,9 @@ export interface WorldCupOddsMatchItem { [key: string]: any } +export type MatchLiveCardItem = WorldCupLiveCardItem +export type MatchOddsMatchItem = WorldCupOddsMatchItem + export function getMatchList(data?: Record) { return request.get({ url: '/match/lists', data: data }, { withToken: false }) } @@ -165,6 +168,25 @@ export function getWorldCupLiveCards() { return request.get({ url: '/match/worldCupLiveCards' }, { withToken: false }) } +export function getMatchLiveCards(data?: { + sport_type?: number + league_name?: string +}) { + return request.get({ url: '/match/liveCards', data }, { withToken: false }) +} + +export function getMatchOdds(data?: { + sport_type?: number + league_name?: string + show_type?: 'live' | 'today' | 'early' + source_site?: string + keyword?: string + include_special?: number + limit?: number +}) { + return request.get({ url: '/match/odds', data }, { withToken: false }) +} + export function getWorldCupOdds(data?: { show_type?: 'live' | 'today' | 'early' source_site?: string diff --git a/uniapp/src/packages_match/components/MatchCenterPanel.vue b/uniapp/src/packages_match/components/MatchCenterPanel.vue index 0c601a0..592bac3 100644 --- a/uniapp/src/packages_match/components/MatchCenterPanel.vue +++ b/uniapp/src/packages_match/components/MatchCenterPanel.vue @@ -5,6 +5,7 @@ 赛事中心 + 2026 @@ -18,146 +19,24 @@ - - - {{ tab.label }} - - - - - - {{ tab.icon }} - {{ tab.label }} - - - - - - - 全部·{{ leagueTotalCount - }} - - - {{ lg.league_name }}·{{ lg.count - }} - - - - - - - - {{ group.title }} - {{ group.items.length }}场 - - - - - - - - - - - - 加载中... - - - 暂无赛事 - - - 没有更多赛事了 - - + @@ -302,40 +97,12 @@ onMounted(() => { align-items: center; justify-content: space-between; gap: 16rpx; - - &-text { - font-size: 36rpx; - font-weight: 700; - color: #222; - } } - &__tabs { - white-space: nowrap; - height: 80rpx; - border-bottom: 1rpx solid #f0f0f0; - } - - &__tabs-inner { - display: inline-flex; - align-items: center; - height: 60rpx; - padding: 0 12rpx; - gap: 4rpx; - } - - &__leagues { - white-space: nowrap; - background: #fff; - } - - &__leagues-inner { - display: inline-flex; - flex-wrap: nowrap; - align-items: center; - padding: 8rpx 16rpx 0; - gap: 4rpx; - white-space: nowrap; + &__title-text { + font-size: 36rpx; + font-weight: 700; + color: #222; } } @@ -406,185 +173,4 @@ onMounted(() => { flex-shrink: 0; } } - -.topic-tabs { - margin: 0 24rpx 16rpx; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12rpx; - - &__item { - height: 58rpx; - border-radius: 16rpx; - background: #f1f5ff; - color: #185dff; - font-size: 26rpx; - font-weight: 700; - line-height: 58rpx; - text-align: center; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.league-tab { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 8rpx 18rpx; - border-radius: 18rpx; - font-size: 24rpx; - color: #808080; - background: #fff; - white-space: nowrap; - flex-shrink: 0; - - &--active { - background: #185dff; - color: #fff; - } - - &__count { - font-size: 24rpx; - } -} - -.match-tab { - display: inline-flex; - align-items: center; - gap: 0rpx; - padding: 6rpx 16rpx; - border-radius: 18rpx; - background: #f4f5f7; - white-space: nowrap; - flex-shrink: 0; - - &__icon { - font-size: 32rpx; - line-height: 1; - } - - &__label { - font-size: 28rpx; - color: #666; - line-height: 1.4; - white-space: nowrap; - } - - &--active { - background: #e5edff; - - .match-tab__label { - color: #185dff; - font-weight: 500; - } - } -} - -.match-list { - flex: 1; - height: 0; - min-height: 0; - display: block; - background: #fff; -} - -.match-group { - margin-bottom: 2rpx; - - &__header { - display: flex; - align-items: center; - padding: 18rpx 18rpx 8rpx; - gap: 6rpx; - background: #fff; - } - - &__dot { - width: 14rpx; - height: 14rpx; - border-radius: 50%; - - &--live { - background: #10b981; - } - - &--upcoming { - background: #185dff; - } - - &--postponed { - background: #ff9900; - } - - &--ended { - background: #999; - } - } - - &__title { - font-size: 30rpx; - font-weight: 600; - color: #333; - } - - &__count { - font-size: 20rpx; - color: #999; - } - - &__arrow { - margin-left: auto; - font-size: 20rpx; - color: #999; - transition: transform 0.25s ease; - - &--collapsed { - transform: rotate(-90deg); - } - } - - &--ended { - background: #f3f4f6; - - .match-group__header { - background: #f3f4f6; - } - } -} - -.match-group:first-child { - margin-top: 0; -} - -.match-card-wrap { - position: relative; - margin: 0 18rpx 8rpx; - - &::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -4rpx; - height: 1rpx; - } - - &:last-child::after { - display: none; - } -} - -.match-loading, -.match-empty, -.match-nomore { - display: flex; - align-items: center; - justify-content: center; - padding: 30rpx 0; - gap: 8rpx; - font-size: 24rpx; - color: #999; -} diff --git a/uniapp/src/packages_match/components/MatchCenterTabsPanel.vue b/uniapp/src/packages_match/components/MatchCenterTabsPanel.vue new file mode 100644 index 0000000..2b4275f --- /dev/null +++ b/uniapp/src/packages_match/components/MatchCenterTabsPanel.vue @@ -0,0 +1,1243 @@ + + + + + diff --git a/业务进度管理.md b/业务进度管理.md index d533186..c6ab230 100644 --- a/业务进度管理.md +++ b/业务进度管理.md @@ -13,6 +13,266 @@ ## 一、已完成事项 +### 86. 赛事中心赛程 / 直播 / 赔率统一组件 + +- 状态:代码已修改,待构建与页面验证 - 时间:2026-07-11 + +完成内容: +- 用户端赛事中心新增 `MatchCenterTabsPanel.vue`,在当前页面内容区域统一切换“赛程 / 直播 / 赔率”,不再因点击三个 Tab 显示世界杯主题组件。 +- 赛程 Tab 迁移并保留原赛事列表、运动分类、联赛筛选、进行中/未开始/已结束分组和已结束赛事分页;运动与联赛分类条仅在赛程显示。 +- 直播 Tab 新增 `/api/match/liveCards` 通用接口,基于 `la_match_live` 关联可见且未结束的赛事,前端不显示运动或联赛分类筛选。 +- 赔率 Tab 新增 `/api/match/odds` 通用接口,只保留滚球盘/今日盘/早盘以及联赛/球队关键词搜索,并将赔率与普通站内赛事进行详情匹配。 +- 世界杯专题卡仍进入原世界杯专题页;`worldCupLiveCards`、`worldCupOdds` 原接口与专题功能保持兼容。 + +涉及模块: +- `server/app/api/controller/MatchController.php` +- `server/app/common/service/match/MatchLiveService.php` +- `uniapp/src/api/match.ts` +- `uniapp/src/packages_match/components/MatchCenterPanel.vue` +- `uniapp/src/packages_match/components/MatchCenterTabsPanel.vue` + +验证说明: +- 已完成静态改动范围和模板标签完整性检查。 +- 按当前项目规范和用户要求,本次未执行 PHP 语法检查、前端构建、浏览器验证、提交或部署。 + +### 85. 球探世界杯赔率线路 + +- 状态:已完成 - 时间:2026-07-11 + +完成内容: +- `match_odds` 多平台抓取任务新增球探线路,默认读取 `https://zq.titan007.com/cn/CupMatch/75.html`。 +- 新增 `TitanOddsClient`,从杯赛页面动态识别赛季、联赛 ID 和赛程脚本,再请求公开的 `LeagueOddsAjax` 赔率数据接口。 +- 球探盘口按页面默认 Crown 公司整理:亚让/大小球公司 ID 为 `3`,胜平负公司 ID 为 `545`;全部公司原始赔率同时保存在 `raw_data`。 +- 球探数据使用 `source_site=titan007` 写入 `la_match_odds`,平台名称在用户端和管理端统一显示为“球探”。 +- HG 与球探改为独立抓取来源,单个来源失败不会阻断另一个来源入库;球探相关参数支持通过 `TITAN_ODDS_*` 环境变量覆盖。 +- 多平台赔率聚合键调整为“主客队 + 比赛月日”,兼容 HG 与球探不同的联赛名和时间文本格式,使同场比赛可以展示多个平台。 + +涉及模块: +- `docker/crawler/titan_odds.py` +- `docker/crawler/match_odds.py` +- `docker/crawler/docker/entrypoint.sh` +- `docker/.env.docker.example` +- `docker/crawler/tests/test_titan_odds.py` +- `docker/crawler/tests/test_match_odds.py` +- `server/app/api/controller/MatchController.php` +- `server/app/adminapi/lists/match/MatchOddsLists.php` + +验证说明: +- 已分析球探页面、赛程脚本和公开赔率接口的实时响应格式。 +- 按当前项目规范,本次未执行测试、提交或部署命令。 + +### 84. 赛事中心世界杯快捷 Tab + +- 状态:已完成 - 时间:2026-07-10 + +完成内容: +- 用户端赛事中心 `topic-entry` 下方新增“赛程 / 直播 / 赔率”三项世界杯快捷入口。 +- 快捷入口复用世界杯专题页现有 Tab 状态,分别打开 `schedule`、`live`、`odds`,不新增接口和页面路由。 +- 原世界杯入口卡片继续默认进入赛程页,保持现有点击行为。 + +验证记录: +- 已执行 `npm run build:h5`,H5 构建通过。 +- 已在本地 `http://localhost:5177/packages_match/pages/worldcup` 点击“其他赛事”后验证快捷 Tab 展示,并点击“赔率”确认可进入世界杯赔率列表。 +- 已执行项目部署脚本,同步到测试服运行目录,自动提交 `c9ac46f`。 + +### 83. 管理端赛事赔率列表 + +- 状态:已完成 - 时间:2026-07-10 + +完成内容: +- 管理端在“赛事管理”菜单下新增“赔率列表”,对应组件 `match/odds/index`,菜单权限为 `match.matchOdds/index`。 +- 后端新增 `/adminapi/match.matchOdds/lists` 只读列表接口,基于 `la_match_odds` 展示第三方赔率记录,默认过滤没有任何盘口值的空赔率记录。 +- 赔率列表支持按平台、盘口类型、运动类型、联赛名称、球队/时间/来源ID 关键词筛选,并动态返回平台选项,便于后续接入多个赔率平台。 +- 管理端页面展示平台、盘口、运动、联赛、比赛双方、独赢、让球、大小球、半场独赢、玩法数量、来源ID、更新时间和来源线路。 +- 新增幂等菜单 SQL `docs/sql/insert_menu_match_odds.sql`,用于将“赔率列表”和隐藏查询权限挂到“赛事管理”下。 + +验证记录: +- 已执行 `php -l server\app\adminapi\controller\match\MatchOddsController.php`、`php -l server\app\adminapi\lists\match\MatchOddsLists.php`,语法检查通过。 +- 已在 `admin` 目录执行 `npm run build`,管理端构建通过;构建产生的 `server/public/admin` 哈希产物已恢复,避免混入源码改动。 +- 已通过部署脚本将新增后端接口同步到测试服运行目录,并通过后台 API 写入菜单,确认“赔率列表”位于“赛事管理”下。 +- 已请求 `https://test-server.sbnews.net/adminapi/match.matchOdds/lists?page_no=1&page_size=5&sport_type=FT&has_odds=1`,接口返回 200,当前有赔率记录 48 条。 +- 已在本地管理端 `http://localhost:5176/match/match_odds` 验证页面渲染,菜单显示“赔率列表”,表格展示“滚球”平台赔率数据。 + +### 82. 世界杯专题赔率 Tab + +- 状态:已完成 - 时间:2026-07-09 + +完成内容: +- 后端新增 `/api/match/worldCupOdds` 接口,以 `la_match_odds` 赔率抓取结果为主数据源按比赛聚合返回,不强制绑定世界杯赛程;赛程匹配仅用于补充图标、状态和详情跳转。 +- 新增 `MatchOdds` 模型读取第三方赔率表,当前 `source_site=hg` 前端展示为“滚球”,盘口类型展示为滚球盘/今日盘/早盘。 +- 用户端世界杯专题页在“比分和赛程”和“排名”之间新增“赔率”Tab,按赔率比赛卡片展示联赛、时间、主客队、平台、更新时间、独赢、让球、大小球赔率。 +- 赔率 Tab 支持按盘口类型筛选,并可通过关键词搜索联赛、主队、客队;后端接口同步支持 `show_type`、`source_site`、`keyword` 参数。 +- 后端会剔除没有任何盘口值的平台和比赛,避免前台展示只有赛程信息但没有赔率的空卡片。 +- 赔率比赛可匹配站内世界杯赛事时保留比赛详情跳转;未匹配站内赛事时仅展示第三方赔率。 + +验证记录: +- 已执行 `php -l server\app\api\controller\MatchController.php`、`php -l server\app\common\model\match\MatchOdds.php`,语法检查通过。 +- 已执行 `npm run build:h5`,H5 构建通过。 +- 已请求 `https://test-server.sbnews.net/api/match/worldCupOdds?limit=300`,接口返回 200,当前可取到 45 场有盘口值的足球赔率,空赔率比赛为 0。 +- 已在本地 H5 `http://localhost:8991/packages_match/pages/worldcup?tab=odds` 验证“赔率”Tab 渲染,页面展示 45 张有盘口值的赔率比赛卡,包含“滚球”平台及独赢/让球/大小球赔率。 + +### 81. 第三方赛事赔率定时抓取 + +- 状态:已完成 - 时间:2026-07-09 + +完成内容: +- Docker crawler 新增 `match_odds` 任务,支持依次尝试多条第三方线路,登录后拉取赛事赔率 XML,并同步到 `la_match_odds`。 +- 新增 `la_match_odds` 表,按 `source_site + source_match_id` 唯一更新,保存联赛、主客队、让球、大小球、独赢、半场赔率、来源原始节点和规范化盘口 JSON。 +- 赔率账号、密码、线路、运动类型、盘口范围均通过 `ODDS_*` / `HG_ODDS_*` 环境变量配置,未硬编码到代码或文档。 +- `docker/crawler/config/crawler_tasks.yaml` 注册 `match_odds`,默认每 5 分钟执行;`entrypoint.sh` 已把 `ODDS_` / `HG_ODDS_` 环境变量写入 cron 环境文件。 +- `docs/sql/create_match_odds_table.sql`、`docs/sql/insert_crontab_match_odds.sql` 已补齐幂等初始化脚本,`sbnews.sql` 已同步表结构。 + +验证记录: +- 已执行 `python -m unittest docker.crawler.tests.test_match_odds`,5 个用例通过。 +- 已执行 `python -m unittest docker.crawler.tests.test_docker_task_runner`,7 个用例通过。 +- 已执行 `python -m py_compile docker/crawler/match_odds.py docker/crawler/main.py`,语法检查通过。 +- 已在测试库执行建表和定时任务 SQL,确认 `la_match_odds` 存在,`la_dev_crontab` 中 `crawler match_odds` 任务 ID 为 47。 +- 已进行真实线路抓取验证:来源 `https://205.201.0.120/`,最近一次抓取成功入库 65 场,跳过当前账号不可用/无日期组合 28 项,失败 0 项;测试库累计足球赔率 70 场(滚球 8、今日 62)。 + +### 80. AI分析统一请求接口与免费次数扣减 + +- 状态:已完成 - 时间:2026-07-05 + +完成内容: +- 后端新增 `AiAnalysisRequestService`,统一承接赛事分段/完整预测、资讯分析、社区帖子分析、彩票总分析、彩票模块分析、用户可信度分析,并由 `/api/ai/analyze` 统一分发。 +- 后端新增 `la_ai_quota_unlock` 通用解锁表和 `AiQuotaUnlock` 模型,按 `analysis_type + analysis_key` 记录用户已解锁内容;同一比赛、文章、帖子、同一彩种最新期彩票分析重复查看不再重复扣减。 +- `AiQuotaService` 扩展为统一扣次服务:普通用户每日免费 3 次,VIP 展示 999 次;通用入口扣次成功后返回 `remain_count`、`charged`、`unlocked`。 +- 兼容旧接口:`/api/ai/*`、`/api/lottery/ai*`、`/api/community/aiAnalysis` 均改为代理到统一服务,避免旧页面绕过扣次。 +- 用户端 `uniapp/src/api/ai.ts` 新增 `requestAiAnalysis`,彩票、社区、赛事、资讯等 API 封装已统一走 `/api/ai/analyze`;页面接收 `remain_count` 后同步刷新免费剩余次数展示。 + +涉及模块: +- `server/app/api/controller/AiController.php` +- `server/app/api/controller/LotteryController.php` +- `server/app/api/controller/CommunityController.php` +- `server/app/common/service/ai/AiAnalysisRequestService.php` +- `server/app/common/service/ai/AiQuotaService.php` +- `server/app/common/model/ai/AiQuotaUnlock.php` +- `server/app/common/model/ai/AiUnlock.php` +- `uniapp/src/api/ai.ts` +- `uniapp/src/api/lottery.ts` +- `uniapp/src/api/community.ts` +- `uniapp/src/pages/ai_analysis/ai_analysis.vue` +- `uniapp/src/pages/match_detail/match_detail.vue` +- `uniapp/src/pages/lottery_analysis/lottery_analysis.vue` +- `uniapp/src/packages_lottery/pages/index.vue` +- `uniapp/src/packages_lottery/pages/draw_list.vue` +- `docs/sql/create_ai_quota_unlock.sql` +- `sbnews.sql` + +验证记录: +- 已执行 `php -l` 检查 `AiController.php`、`LotteryController.php`、`CommunityController.php`、`AiQuotaService.php`、`AiAnalysisRequestService.php`、`AiQuotaUnlock.php`、`AiUnlock.php`,均通过。 +- 已在测试库执行幂等建表,确认 `la_ai_quota_unlock` 已存在。 +- 已执行 `npm run build:h5`,H5 构建通过并复制产物到 `server/public/mobile`。 +- 已执行源码搜索,确认用户端 AI 分析 API 封装不再请求旧的 `/ai/matchPredict`、`/ai/articleAnalysis`、`/lottery/aiPredict`、`/community/aiAnalysis` 等分析入口。 + +### 79. AI分析入口与每日免费额度统一整理 + +- 状态:已完成 - 时间:2026-07-05 + +完成内容: +- 梳理系统内 AI 分析入口:用户端赛事分段预测、资讯深度解读、社区帖子分析、彩票总分析/模块分析/独立预测页;管理端包含 AI 配置、AI 分析记录、AI 日志、知识库相关配置与查询记录。 +- 后端新增 `AiQuotaService`,统一维护 AI 每日免费次数为 3 次、VIP 剩余额度展示为 999,并复用到用户 VIP 信息和赛事 AI 解锁扣次逻辑,避免后续多处硬编码。 +- 用户端新增 `uniapp/src/config/ai.ts`,统一维护“每日免费3次AI分析”文案和默认剩余额度;赛事、资讯、AI 分析页、彩票分析页、彩票首页、开奖列表的免费额度展示已统一读取该配置。 +- 已将测试库/运行库未删除用户的 `ai_free_count` 恢复为 3,`ai_free_date` 恢复为 2026-07-05。 + +涉及模块: +- `server/app/common/service/ai/AiQuotaService.php` +- `server/app/common/model/user/User.php` +- `server/app/common/model/ai/AiUnlock.php` +- `uniapp/src/config/ai.ts` +- `uniapp/src/pages/ai_analysis/ai_analysis.vue` +- `uniapp/src/pages/match_detail/match_detail.vue` +- `uniapp/src/pages/news_detail/news_detail.vue` +- `uniapp/src/pages/lottery_analysis/lottery_analysis.vue` +- `uniapp/src/packages_lottery/pages/index.vue` +- `uniapp/src/packages_lottery/pages/draw_list.vue` + +验证记录: +- 已执行源码搜索,确认 AI 免费次数默认值集中在后端 `AiQuotaService::DAILY_FREE_COUNT`、前端 `AI_DAILY_FREE_COUNT` 和 SQL 表结构默认值 3。 +- 已执行数据库恢复脚本,确认未删除用户 `224/224` 均恢复为今日剩余 3 次。 + +### 78. 安卓端新闻详情正文渲染修复 + +- 状态:已完成 - 时间:2026-07-05 + +完成内容: +- 排查 `/pages/news_detail/news_detail` 安卓端详情正文不显示,确认接口 `/api/article/detail` 正常返回 `content`,问题集中在 App 端富文本渲染链路。 +- 修复正文图片 HTML 归一化:自闭合 `` 追加 `referrerpolicy` 和样式前会先移除末尾 `/`,避免生成 `` 这类畸形标签。 +- 非 H5 端正文块改为使用项目已有 `u-parse` 解析 HTML,并补充移动端正文标签样式,降低 App `rich-text` 直接消费 HTML 字符串导致的空白风险;H5 端保留原 `v-html` 渲染。 + +涉及模块: +- `uniapp/src/pages/news_detail/news_detail.vue` + +验证记录: +- 已用测试服文章 `id=2430879` 确认详情接口返回正文 `content`。 +- 已执行一次性静态检查,确认 App 端使用 `u-parse` 且图片归一化不再保留错误斜杠。 +- 已执行 `npm run build:app`,App 目标构建通过。 +- 已执行 `npm run build:h5`,H5 构建通过;构建生成的 `server/public/mobile` 哈希产物已清理回构建前状态,避免混入源码修复。 +- 已执行项目约定部署命令,部署脚本自动提交 `074aa7a` 并同步到 `/www/wwwroot/api.sbnews.net`。 + +### 77. 新闻详情底部评论操作区优化 + +- 状态:已完成 - 时间:2026-07-04 + +完成内容: +- 用户端新闻详情页 `/pages/news_detail/news_detail` 底部操作区已移除“不喜欢”入口,不再展示点踩按钮和点踩数量。 +- 评论输入入口改为带图标、主提示和讨论数副提示的胶囊布局,右侧仅保留点赞、收藏、分享三个固定宽度操作,降低底栏拥挤和文字溢出风险。 +- 点赞数增加万级与 `999+` 展示兜底,无点赞时显示“点赞”,保持操作含义清晰。 + +涉及模块: +- `uniapp/src/pages/news_detail/news_detail.vue` + +验证记录: +- 已执行 `npm run build:h5`,H5 构建通过,构建产物已复制到 `server/public/mobile`。 + +### 76. 世界杯直播卡展示主播与房间信息 + +- 状态:已完成 - 时间:2026-07-03 + +完成内容: +- 前台 `/api/match/worldCupLiveCards` 增加透出 `la_match_live` 中的主播与房间字段:`anchor_name`、`anchor_avatar`、`room_view_count`、`room_focus_count`、`room_notice`。 +- 用户端世界杯直播卡片 `worldcup-live-card__meta` 改为展示主播头像/昵称、观看数、关注数和直播间公告,人数支持万级格式化并提供空值兜底。 +- `WorldCupLiveCardItem` 类型已同步补齐上述字段,避免后续调用方继续依赖索引签名。 + +涉及模块: +- `server/app/common/service/match/MatchLiveService.php` +- `uniapp/src/api/match.ts` +- `uniapp/src/packages_match/components/WorldCupPanel.vue` + +验证记录: +- 已执行 `php -l server/app/common/service/match/MatchLiveService.php`,PHP 语法检查通过。 +- 已执行 `npm run build:h5`,H5 构建通过;构建生成的 `server/public/mobile` 哈希产物已恢复,避免混入源码改动。 +- 已执行项目约定部署命令,部署脚本自动提交 `b1539d9` 并同步到 `/www/wwwroot/api.sbnews.net`。 + +### 75. 管理端直播管理接入 la_match_live + +- 状态:已完成 - 时间:2026-07-01 + +完成内容: +- 管理端新增独立“直播管理”页面,放在“赛事管理”一级菜单下,数据以 `la_match_live` 为主表展示。 +- 后台列表支持按赛事ID、线路标题、播放类型、抓取状态、来源域名、热门标记筛选,并展示 `play_type`、`iframe_url`、`source_domain`、`source_is_hot`、`fetch_status_text` 等 HTML/视频流线路字段。 +- `MatchLiveLists` 不再在未传 `match_id` 时强制追加 `match_id=0`,因此菜单入口可以直接分页查看全部直播线路;从赛事列表点“直播线路”仍可带 `match_id` 进入单场筛选。 +- `insert_menu_match_live.sql` 已调整为将 `match.matchLive/index` 挂到 `paths='match'` 的赛事管理目录下,菜单名为“直播管理”,并设为显示。 +- 已构建管理端并覆盖测试服后台静态产物,旧后台产物备份为 `/www/wwwroot/test-server.sbnews.net/server/public/admin.bak-20260701151143`。 + +涉及模块: +- `admin/src/views/match/live/index.vue` +- `admin/src/views/match/lists/components/MatchLiveTable.vue` +- `admin/src/api/match.ts` +- `server/app/adminapi/lists/match/MatchLiveLists.php` +- `server/app/adminapi/logic/match/MatchLiveLogic.php` +- `docs/sql/insert_menu_match_live.sql` +- `qa/frontend/test_admin_match_live_management_static.py` + +验证记录: +- 已先新增 `qa/frontend/test_admin_match_live_management_static.py` 红灯检查,修改前因直播页仍依赖单场赛事上下文失败。 +- 已执行 `C:\Python311\python.exe qa/frontend/test_admin_match_live_management_static.py`,静态检查通过。 +- 已执行 `php -l server/app/adminapi/lists/match/MatchLiveLists.php`、`php -l server/app/adminapi/logic/match/MatchLiveLogic.php`,PHP 语法检查通过。 +- 已在 `admin` 目录执行 `npm run build`,管理端构建通过,产物复制到 `server/public/admin`。 +- 已在测试服执行菜单 SQL,确认 `直播管理` 的父级为 `赛事管理`,`is_show=1`,超级管理员角色已拥有 `index/detail/add/edit/delete` 5 个权限节点。 +- 已验证 `https://test-admin.sbnews.net/` 返回 200,构建产物中可检索到“直播管理”。 +- 本次未执行自动部署脚本:当前工作区存在任务前已有的无关未提交改动,部署脚本会自动暂存/提交发布范围内容;本次按用户要求直接将相关源码、SQL 与构建产物同步到测试服。 + ### 74. 赛事直播流定时抓取恢复与 Docker 调度优化 - 状态:已完成 - 时间:2026-06-21 @@ -127,7 +387,7 @@ - 根目录部署默认范围继续包含 `server/`、`uniapp/`、`admin/` 与 `docker/`,并默认排除 `docker/.env`、`docker/.env.docker`、`docker/crawler/venv`、`docker/crawler/logs`、`docker/crawler/data`、`test_output.txt` 与临时脚本,避免密钥、日志、缓存和样例输出进入发布包。 - 默认发布范围内如仍存在 `admin` 这类 root gitlink,部署脚本会跳过该 gitlink 的自动提交 pathspec,但仍从当前工作区把源码补进发布包,避免自动提交阶段被 gitlink 卡住。 - `docker/crawler/Dockerfile` 在镜像构建时会把 `/app/docker/entrypoint.sh` 的 CRLF 换行归一为 LF,避免 Windows 上传后入口脚本报 `/usr/bin/env: 'bash\r': No such file or directory`。 -- `.vscode/sftp.json` 新增 `docker` 上传配置,远端目录为 `/www/wwwroot/test-server.sbnews.net/docker`,并忽略 Docker 密钥与 crawler 运行态目录。 +- `.vscode/sftp.json` 新增 `docker` 上传配置,远端目录为 `/www/wwwroot/api.sbnews.net/docker`,并忽略 Docker 密钥与 crawler 运行态目录。 - `README.md` 已同步 Docker 爬虫源码目录、任务清单路径、默认发布目录结构和服务器 `CRAWLER_DIR` 注意事项。 涉及模块: @@ -144,8 +404,8 @@ 验证记录: - 已执行 `git diff --check -- docker/docker-compose.crawler.yml docker/.env.example docker/.gitignore docker/crawler/.dockerignore docker/crawler/.gitignore scripts/deploy-server.ps1 README.md 业务进度管理.md`,未发现空白错误。 - 已执行 `deploy-server.ps1` PowerShell 语法解析检查,通过。 -- 已在临时 Git 克隆中执行 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun`,确认全量包默认包含 `server`、`uniapp`、`admin`、`docker`,且未上传到服务器。 -- 已在临时 Git 克隆中执行 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun`,确认迁移提交范围会上传 `docker/crawler` 源码并跳过 `docker/crawler/data`、`logs`、`venv`、`test_output.txt` 等运行态/输出文件。 +- 已在临时 Git 克隆中执行 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir /www/wwwroot/api.sbnews.net -DryRun`,确认全量包默认包含 `server`、`uniapp`、`admin`、`docker`,且未上传到服务器。 +- 已在临时 Git 克隆中执行 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/api.sbnews.net -DryRun`,确认迁移提交范围会上传 `docker/crawler` 源码并跳过 `docker/crawler/data`、`logs`、`venv`、`test_output.txt` 等运行态/输出文件。 - 本机未安装 `docker` 命令,因此未在本机执行 `docker compose -f docker-compose.crawler.yml config`;该项需要在服务器或已安装 Docker 的环境验证。 ### 68. 默认部署切换为根目录同时上传 server、uniapp 与 admin @@ -168,7 +428,7 @@ - 已在临时最小 Git 仓库执行默认 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -DryRun -RemoteDir /tmp/test`,修改前日志只出现 `server` 打包,确认红灯。 - 修改后已在临时最小 Git 仓库执行默认 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -DryRun -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /tmp/test`,日志已同时出现 `server/app/ok.txt`、`uniapp/src/App.vue` 与 `admin/src/main.ts` 的增量变更,确认根目录模式能同时覆盖三套源码。 - 已补跑 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -SourceDir server -DryRun -RemoteDir /tmp/test`,确认显式单独发布 `server` 的老用法仍可继续使用。 -- 已在真实仓库执行 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -DryRun -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net`,日志出现 `root gitlink include paths will be copied from working tree: admin` 与 `+ admin/`,确认 `admin` 仍是 gitlink 记录时也会被补进增量包。 +- 已在真实仓库执行 `powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -DryRun -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/api.sbnews.net`,日志出现 `root gitlink include paths will be copied from working tree: admin` 与 `+ admin/`,确认 `admin` 仍是 gitlink 记录时也会被补进增量包。 - 已在真实仓库执行默认全量 DryRun,日志显示 `working tree include paths: server uniapp admin`,确认全量包也会同时包含三套源码。 ### 67. 部署脚本支持发布 uniapp 源码到服务器 @@ -254,7 +514,7 @@ - 已执行 `C:\Python311\python.exe -m py_compile server/public/dongqiudi-crawler/ai_comment_dispatch.py server/public/dongqiudi-crawler/main.py server/public/dongqiudi-crawler/scripts/docker_task_runner.py qa/backend/test_ai_comment_dispatch_static.py`,Python 语法检查通过。 - 已在测试库执行 `docs/sql/create_ai_comment_task.sql`、`docs/sql/insert_ai_comment_config.sql`、`docs/sql/disable_article_ai_comment_crontab.sql`,确认 `la_ai_comment_task` 建表成功、`auto_comment_*` 配置写入成功,旧 `article_ai_comment` 定时任务已停用。 - 已用远端测试库配置手动执行一轮 `ai_comment_dispatch` 最小联调,临时将单轮条数压到 2,实际成功创建 2 条任务:`article_id=2044144`、`post_id=5924`,均已写入对应评论表并将文章/帖子 `comment_count` 回写为 `1`。 -- 已在测试服 `/www/wwwroot/test-server.sbnews.net/docker` 执行 `sudo docker compose -f docker-compose.crawler.yml build crawler` 并强制删除旧 `sport-era-crawler` 容器后重新 `up -d --no-deps crawler`,当前 `sport-era-crawler` 已切到新容器并正常运行。 +- 已在测试服 `/www/wwwroot/api.sbnews.net/docker` 执行 `sudo docker compose -f docker-compose.crawler.yml build crawler` 并强制删除旧 `sport-era-crawler` 容器后重新 `up -d --no-deps crawler`,当前 `sport-era-crawler` 已切到新容器并正常运行。 - 已确认运行中容器内 `/etc/cron.d/sport-era-crawler` 已渲染 `ai_comment_dispatch` 的 `*/10 * * * *` 任务,且容器内可直接 `import ai_comment_dispatch`。 ### 64. AI助手资讯类联网检索接入 @@ -683,7 +943,7 @@ - `README.md` 验证记录: -- 已先执行 `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -AutoIncrementalCommits 2 -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun`,修改前因参数不存在失败,确认覆盖新行为。 +- 已先执行 `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -AutoIncrementalCommits 2 -RemoteDir /www/wwwroot/api.sbnews.net -DryRun`,修改前因参数不存在失败,确认覆盖新行为。 - 修改后已执行自定义 2 个提交 dry-run,日志显示 `auto incremental commits: 2`,只打包不上传。 - 修改后已执行默认自动增量 dry-run,日志显示 `auto incremental commits: 1`,原命令兼容。 - 已验证单独传 `-AutoIncrementalCommits` 且不带 `-AutoIncremental` 会报错阻止误用。 diff --git a/设计稿映射.md b/设计稿映射.md index 5475faa..22ba0d1 100644 --- a/设计稿映射.md +++ b/设计稿映射.md @@ -22,7 +22,7 @@ | 设计稿名称 | uniapp 页面路径 | 状态 | 备注 | | --- | --- | --- | --- | -| 赛事中心(含世界杯专题入口) | `uniapp/src/pages/match/match.vue` | ✅ 已完成 | 2026-05-20 按世界杯专题稿补充顶部蓝色专题入口,入口位于标题与运动分类之间 | +| 赛事中心(含世界杯专题入口) | `uniapp/src/pages/match/match.vue` | ✅ 已完成 | 2026-07-11 赛程、直播、赔率改为在赛事中心当前内容区域切换;运动和联赛分类仅在赛程显示,赔率只保留盘口与联赛/球队搜索,顶部蓝色世界杯专题卡仍进入专题页 | | 赛事详情页 | `uniapp/src/pages/match_detail/match_detail.vue` | 🚧 部分完成 | 2026-05-29 优化顶部赛事摘要头部,补充联赛、对阵、状态与直播入口信息 | | 世界杯专题页(排名导航) | `uniapp/src/packages_match/pages/worldcup.vue` | 🚧 部分完成 | 2026-05-20 已按设计稿拆分排名导航、积分榜、球员榜组件,后续可继续细化表格视觉与新闻页样式 | | 首页 | `uniapp/src/pages/index/index.vue` | 待核对 | 需结合实际设计稿补充 | diff --git a/赛事中心赛程直播赔率组件实施计划.md b/赛事中心赛程直播赔率组件实施计划.md new file mode 100644 index 0000000..224596e --- /dev/null +++ b/赛事中心赛程直播赔率组件实施计划.md @@ -0,0 +1,301 @@ +# 赛事中心赛程、直播、赔率组件实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. 本项目未授权子代理、自动测试、构建、提交或部署。 + +**Goal:** 在赛事中心当前页面内用一个通用组件切换赛程、直播和赔率内容,不再因点击三个 Tab 切换到世界杯主题组件。 + +**Architecture:** `MatchCenterPanel.vue` 只保留赛事中心外壳和世界杯专题入口,新建 `MatchCenterTabsPanel.vue` 管理通用筛选、三个 Tab、数据加载和内容渲染。后端新增通用直播与赔率接口,并复用现有直播地址解析、赔率盘口格式化和赛事匹配逻辑;世界杯专题接口保持兼容。 + +**Tech Stack:** PHP 8 / ThinkPHP、Vue 3 `