添加赔率

This commit is contained in:
hajimi
2026-07-11 11:19:43 +08:00
parent c9ac46faa7
commit 3a5d571e53
29 changed files with 3006 additions and 542 deletions
+4
View File
@@ -28,3 +28,7 @@ bin-release/
**/.hbuilderx/ **/.hbuilderx/
**/unpackage/ **/unpackage/
qa/ qa/
.code-graph/
./vscode/
.tmp*
.codex/
+19 -4
View File
@@ -48,6 +48,13 @@
| uniapp | `npm run dev:h5` | `npm run build:h5` | | uniapp | `npm run dev:h5` | `npm run build:h5` |
| pc | `npm run dev` | `npm run generate` / `npm run build` | | pc | `npm run dev` | `npm run generate` / `npm run build` |
## 自动化执行边界
- 除非用户在当前任务中明确要求,否则不要自动执行构建、测试、提交代码或部署。
- 完成代码修改后,可以说明建议执行的验证命令或部署命令,但不得自行运行。
- 用户明确要求“构建”“测试”“提交”“部署”“上线”“发布”等操作时,才执行对应命令。
- 对已经由用户给出的命令,可按用户原命令执行;不要额外扩展到未要求的构建、测试、提交或部署步骤。
## 数据库结构基准 ## 数据库结构基准
- 数据库表前缀:`la_` - 数据库表前缀:`la_`
@@ -77,7 +84,7 @@
## 代码修改后验证与评审 ## 代码修改后验证与评审
除非用户特别说明不需要验证,代码修改完成后按改动类型选择验证方式: 仅在用户明确要求验证、测试或构建时,代码修改完成后按改动类型选择验证方式;未明确要求时只列出建议命令,不自动执行
### 后端接口修改 ### 后端接口修改
@@ -108,14 +115,14 @@
## 代码变更后部署 ## 代码变更后部署
- 只要本项目有代码变更,完成必要验证后,必须执行以下命令,将最新代码更新到测试服运行环境: - 仅在用户明确要求部署、上线、发布或同步到测试服时,执行以下命令,将最新代码更新到测试服运行环境:
```powershell ```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 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、保存白名单和数据库列是否同步。 - `Validate::only()` 只限制校验字段范围,不会自动过滤请求参数;新增字段落库前,要同时检查 validate、logic、model、保存白名单和数据库列是否同步。
+11 -11
View File
@@ -66,7 +66,7 @@ Docker 只负责运行 Python 爬虫和容器内 cron。MySQL、Redis、Nginx
```bash ```bash
ssh sbnews 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 ```powershell
cd D:\www\sport-era 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` 读取该配置。 如果 `sbnews` 配置在 `C:\Users\Administrator\.ssh\config` 中,默认会使用 Windows 的 `ssh.exe` 读取该配置。
@@ -138,7 +138,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -Remo
```powershell ```powershell
cd D:\www\sport-era 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 ```powershell
cd D:\www\sport-era cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From <old_commit> -To <new_commit> -RemoteDir /www/wwwroot/test-server.sbnews.net powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From <old_commit> -To <new_commit> -RemoteDir /www/wwwroot/api.sbnews.net
``` ```
示例: 示例:
```powershell ```powershell
cd D:\www\sport-era 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 ```powershell
cd D:\www\sport-era 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 <n>`,例如发布最近 3 个提交会对比 `HEAD~3``HEAD` 如果需要一次发布最近 N 个提交的后端改动,可以追加 `-AutoIncrementalCommits <n>`,例如发布最近 3 个提交会对比 `HEAD~3``HEAD`
```powershell ```powershell
cd D:\www\sport-era 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 ```powershell
cd D:\www\sport-era 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 "提交说明"` 自定义这次自动提交的说明。若存在合并冲突,需要先手动解决。 脚本会在打包和上传前先检查默认打包范围(`server` + `uniapp` + `admin` + `docker`);如果这些目录内存在未提交、删除或未跟踪文件,会先自动提交这些变更,再继续计算增量范围。可以通过 `-AutoCommitMessage "提交说明"` 自定义这次自动提交的说明。若存在合并冲突,需要先手动解决。
@@ -222,7 +222,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncrem
```powershell ```powershell
cd D:\www\sport-era 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 和默认打包目录。 脚本会在打包后打印 `update log`:增量模式包含 commit 范围、commit 摘要、变更文件和删除文件;全量模式包含当前最新 commit 和默认打包目录。
@@ -235,7 +235,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir
```powershell ```powershell
cd D:\www\sport-era 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 源码到服务器并在服务器构建 ### 发布 uniapp 源码到服务器并在服务器构建
@@ -246,7 +246,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -Remo
cd D:\www\sport-era cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 ` powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 `
-Sudo ` -Sudo `
-RemoteDir /www/wwwroot/test-server.sbnews.net ` -RemoteDir /www/wwwroot/api.sbnews.net `
-UniappReleasePath ../server/public/mobile ` -UniappReleasePath ../server/public/mobile `
-PostInstall "cd uniapp && npm install && npm run build:h5" -PostInstall "cd uniapp && npm install && npm run build:h5"
``` ```
+11
View File
@@ -29,6 +29,17 @@ ODDS_SPORT_TYPES=FT,BK,ES,TN,VB,BM,TT,BS,SK,OP
ODDS_SHOW_TYPES=live,today,early ODDS_SHOW_TYPES=live,today,early
ODDS_VERIFY_TLS=0 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. # AI KB worker embedding config. Keep real key in docker/.env.docker only.
EMBEDDING_API_KEY=change-me EMBEDDING_API_KEY=change-me
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
+1 -1
View File
@@ -19,7 +19,7 @@ from pathlib import Path
env_file = Path("/etc/sport-era-crawler/env.sh") env_file = Path("/etc/sport-era-crawler/env.sh")
with env_file.open("w", encoding="utf-8") as fp: with env_file.open("w", encoding="utf-8") as fp:
for key, value in sorted(os.environ.items()): 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") fp.write(f"export {key}={shlex.quote(value)}\n")
env_file.chmod(0o600) env_file.chmod(0o600)
PY PY
+104 -22
View File
@@ -15,6 +15,14 @@ import pymysql
import requests import requests
import urllib3 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 = [ DEFAULT_BASE_URLS = [
"https://205.201.0.120/", "https://205.201.0.120/",
@@ -70,6 +78,13 @@ def _safe_int(value: Any, default: int = 0) -> int:
return default 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: def _text(raw: Dict[str, str], key: str) -> str:
return str(raw.get(key) or "").strip() return str(raw.get(key) or "").strip()
@@ -538,24 +553,35 @@ def _build_result(
def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict[str, Any]: 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 env = env if env is not None else os.environ
items: List[Dict[str, Any]] = []
fetch_errors: List[str] = []
source_urls: List[str] = []
successful_sources = 0
skipped_count = 0
username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip() 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() password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip()
if not username or not password: if username and password:
return _build_result( base_urls = [
success=False, _normalize_base_url(url)
candidate_count=0, for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS)
saved_count=0, ]
errors=["ODDS_USERNAME/ODDS_PASSWORD 未配置"], sport_types = [
) item.upper()
for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES)
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 = [
show_types = [item.lower() for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_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) timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20)
verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False) verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False)
langx = (env.get("ODDS_LANGX") or "zh-cn").strip() langx = (env.get("ODDS_LANGX") or "zh-cn").strip()
odds_type = (env.get("ODDS_TYPE") or "H").strip().upper() odds_type = (env.get("ODDS_TYPE") or "H").strip().upper()
request_delay = float(env.get("ODDS_REQUEST_DELAY_SECONDS") or REQUEST_DELAY_SECONDS) request_delay = _safe_float(
env.get("ODDS_REQUEST_DELAY_SECONDS"),
REQUEST_DELAY_SECONDS,
)
client: Optional[HgOddsClient] = None client: Optional[HgOddsClient] = None
login_errors: List[str] = [] login_errors: List[str] = []
@@ -574,14 +600,13 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict
client = candidate client = candidate
break break
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
login_errors.append(f"{base_url}: {exc}") login_errors.append(f"hg {base_url}: {exc}")
if client is None: if client is None:
return _build_result(success=False, candidate_count=0, saved_count=0, errors=login_errors) fetch_errors.extend(login_errors)
else:
items: List[Dict[str, Any]] = [] successful_sources += 1
fetch_errors: List[str] = [] source_urls.append(client.base_url)
skipped_count = 0
for sport_type in sport_types: for sport_type in sport_types:
for show_type in show_types: for show_type in show_types:
try: try:
@@ -589,11 +614,68 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict
except OddsGameListUnavailable: except OddsGameListUnavailable:
skipped_count += 1 skipped_count += 1
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
fetch_errors.append(f"{sport_type}/{show_type}: {exc}") fetch_errors.append(f"hg {sport_type}/{show_type}: {exc}")
if request_delay > 0: if request_delay > 0:
time.sleep(request_delay) 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 repo = None
source_url = ",".join(dict.fromkeys(source_urls))
try: try:
repo = MatchOddsRepository(db_config) repo = MatchOddsRepository(db_config)
repo.ensure_table() repo.ensure_table()
@@ -603,7 +685,7 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict
success=False, success=False,
candidate_count=len(items), candidate_count=len(items),
saved_count=0, saved_count=0,
source_url=client.base_url, source_url=source_url,
skipped_count=skipped_count, skipped_count=skipped_count,
errors=fetch_errors + [f"database: {exc}"], 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() repo.close()
return _build_result( return _build_result(
success=len(fetch_errors) == 0, success=True,
candidate_count=len(items), candidate_count=len(items),
saved_count=saved_count, saved_count=saved_count,
source_url=client.base_url, source_url=source_url,
skipped_count=skipped_count, skipped_count=skipped_count,
errors=fetch_errors, errors=fetch_errors,
) )
+3 -1
View File
@@ -146,7 +146,7 @@ class MatchOddsTest(unittest.TestCase):
self.assertEqual(json.loads(params[-2])["moneyline"]["draw"], "3.90") self.assertEqual(json.loads(params[-2])["moneyline"]["draw"], "3.90")
def test_run_skips_without_credentials(self): 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.assertFalse(result["success"])
self.assertEqual(result["candidate_count"], 0) self.assertEqual(result["candidate_count"], 0)
@@ -182,6 +182,7 @@ class MatchOddsTest(unittest.TestCase):
"ODDS_SPORT_TYPES": "FT", "ODDS_SPORT_TYPES": "FT",
"ODDS_SHOW_TYPES": "today", "ODDS_SHOW_TYPES": "today",
"ODDS_REQUEST_DELAY_SECONDS": "0", "ODDS_REQUEST_DELAY_SECONDS": "0",
"TITAN_ODDS_ENABLED": "0",
}, },
) )
@@ -234,6 +235,7 @@ class MatchOddsTest(unittest.TestCase):
"ODDS_SPORT_TYPES": "FT", "ODDS_SPORT_TYPES": "FT",
"ODDS_SHOW_TYPES": "today,early", "ODDS_SHOW_TYPES": "today,early",
"ODDS_REQUEST_DELAY_SECONDS": "0", "ODDS_REQUEST_DELAY_SECONDS": "0",
"TITAN_ODDS_ENABLED": "0",
}, },
) )
+81
View File
@@ -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()
+448
View File
@@ -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,
)
+13
View File
@@ -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;
+43
View File
@@ -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='第三方赛事赔率表';
+31
View File
@@ -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
);
+5 -5
View File
@@ -3,16 +3,16 @@ JOIN `la_system_menu` parent
ON parent.`type` = 'M' ON parent.`type` = 'M'
AND parent.`paths` = 'match' AND parent.`paths` = 'match'
SET child.`pid` = parent.`id`, SET child.`pid` = parent.`id`,
child.`name` = '直播管理',
child.`paths` = 'match_live', child.`paths` = 'match_live',
child.`component` = 'match/live/index', child.`component` = 'match/live/index',
child.`selected` = '/match/lists', child.`selected` = '',
child.`is_show` = 0, child.`is_show` = 1,
child.`update_time` = UNIX_TIMESTAMP() child.`update_time` = UNIX_TIMESTAMP()
WHERE child.`perms` = 'match.matchLive/index' WHERE child.`perms` = 'match.matchLive/index';
AND child.`pid` <> parent.`id`;
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`) 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 FROM `la_system_menu` parent
WHERE parent.`type` = 'M' WHERE parent.`type` = 'M'
AND parent.`paths` = 'match' AND parent.`paths` = 'match'
+43
View File
@@ -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`
);
+2 -2
View File
@@ -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_1 StaticText ""
uid=3_2 StaticText "搜索博头条" uid=3_2 StaticText "搜索博头条"
uid=3_3 StaticText "推荐" uid=3_3 StaticText "推荐"
uid=3_4 StaticText "世界杯" uid=3_4 StaticText "世界杯"
uid=3_5 StaticText "中超" uid=3_5 StaticText "中超"
+66
View File
@@ -153,6 +153,24 @@ CREATE TABLE `la_ai_unlock` (
INDEX `idx_user_id`(`user_id` ASC) USING BTREE 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; ) 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 -- Table structure for la_article
-- ---------------------------- -- ----------------------------
@@ -1042,6 +1060,54 @@ CREATE TABLE `la_match_data` (
INDEX `idx_crawl_time`(`crawl_time` ASC) USING BTREE 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; ) 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 -- Table structure for la_match_event
-- ---------------------------- -- ----------------------------
+3 -3
View File
@@ -9,18 +9,18 @@ Usage:
Modes: Modes:
1. Full deploy (no commit ids) 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) 2. Incremental deploy (compare two commits)
./scripts/deploy-server.sh \ ./scripts/deploy-server.sh \
--from <old_commit> \ --from <old_commit> \
--to <new_commit> \ --to <new_commit> \
--remote-dir /www/wwwroot/test-server.sbnews.net --remote-dir /www/wwwroot/api.sbnews.net
3. Auto incremental deploy (compare latest two commits) 3. Auto incremental deploy (compare latest two commits)
./scripts/deploy-server.sh \ ./scripts/deploy-server.sh \
--auto-incremental \ --auto-incremental \
--remote-dir /www/wwwroot/test-server.sbnews.net --remote-dir /www/wwwroot/api.sbnews.net
Options: Options:
--source-dir <dir> Local source directory, default: server --source-dir <dir> Local source directory, default: server
+1 -1
View File
@@ -225,7 +225,7 @@ ${ZIP_NAME}
使用方法: 使用方法:
1. 把 zip 上传到服务器 server 项目根 1. 把 zip 上传到服务器 server 项目根
例: /www/wwwroot/test-server.sbnews.net/ 例: /www/wwwroot/api.sbnews.net/
2. 解压: unzip -o ${ZIP_NAME} 2. 解压: unzip -o ${ZIP_NAME}
3. 应用: bash apply.sh 3. 应用: bash apply.sh
4. apply.sh 会自动删除废弃文件、清理空目录,并自我清理 4. apply.sh 会自动删除废弃文件、清理空目录,并自我清理
@@ -185,6 +185,7 @@ class MatchOddsLists extends BaseAdminDataLists implements ListsExtendInterface
{ {
$map = [ $map = [
'hg' => '滚球', 'hg' => '滚球',
'titan007' => '球探',
]; ];
return $map[$sourceSite] ?? ($sourceSite !== '' ? strtoupper($sourceSite) : '未知平台'); return $map[$sourceSite] ?? ($sourceSite !== '' ? strtoupper($sourceSite) : '未知平台');
} }
+112 -17
View File
@@ -18,7 +18,7 @@ use app\common\service\match\MatchLiveService;
class MatchController extends BaseApiController 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 int $worldCupSeasonId = 26123;
protected string $worldCupLeagueName = '世界杯'; 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() public function worldCupOdds()
{ {
$showType = $this->request->get('show_type/s', ''); return $this->data([
$sourceSite = strtolower(trim($this->request->get('source_site/s', ''))); 'season_id' => $this->worldCupSeasonId,
$keyword = trim($this->request->get('keyword/s', '')); 'list' => $this->getOddsList(
$includeSpecial = $this->request->get('include_special/d', 0); $this->request->get('show_type/s', ''),
$limit = min(max($this->request->get('limit/d', 120), 1), 300); 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']; $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) { if (!$includeSpecial) {
$query->where('league_name', 'not like', '%特定球员%'); $query->where('league_name', 'not like', '%特定球员%');
} }
@@ -553,7 +608,7 @@ class MatchController extends BaseApiController
->select() ->select()
->toArray(); ->toArray();
$eventMap = $this->buildWorldCupEventMap(); $eventMap = $this->buildOddsEventMap($sportType, $leagueName, $worldCupEventOnly);
$matches = []; $matches = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$platform = $this->formatOddsPlatform($row); $platform = $this->formatOddsPlatform($row);
@@ -604,17 +659,30 @@ class MatchController extends BaseApiController
return (int)$right['update_time'] <=> (int)$left['update_time']; return (int)$right['update_time'] <=> (int)$left['update_time'];
}); });
return $this->data([ return $list;
'season_id' => $this->worldCupSeasonId,
'list' => $list,
]);
} }
private function buildWorldCupEventMap(): array private function buildOddsEventMap(
int $sportType = 0,
string $leagueName = '',
bool $worldCupOnly = false
): array
{ {
$rows = MatchEvent::where('league_name', $this->worldCupLeagueName) $query = MatchEvent::where('is_show', 1);
->where('sport_type', 1) if ($sportType > 0) {
->where('is_show', 1) $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') ->field('id,match_id,home_team,home_icon,home_score,away_team,away_icon,away_score,status,match_time,current_minute')
->select() ->select()
->toArray(); ->toArray();
@@ -629,15 +697,41 @@ class MatchController extends BaseApiController
return $map; return $map;
} }
private function resolveOddsSportType(int $sportType): string
{
$map = [
1 => 'FT',
];
return $map[$sportType] ?? '';
}
private function buildOddsMatchKey(array $row): string 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('|', [ return implode('|', [
trim((string)($row['league_name'] ?? '')), trim((string)($row['league_name'] ?? '')),
trim((string)($row['match_time_text'] ?? '')), 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 private function buildTeamPairKey(string $homeTeam, string $awayTeam): string
{ {
$homeTeam = preg_replace('/\s+/u', '', trim($homeTeam)); $homeTeam = preg_replace('/\s+/u', '', trim($homeTeam));
@@ -703,6 +797,7 @@ class MatchController extends BaseApiController
{ {
$map = [ $map = [
'hg' => '滚球', 'hg' => '滚球',
'titan007' => '球探',
]; ];
return $map[$sourceSite] ?? ($sourceSite !== '' ? strtoupper($sourceSite) : '平台'); return $map[$sourceSite] ?? ($sourceSite !== '' ? strtoupper($sourceSite) : '平台');
} }
@@ -97,16 +97,32 @@ class MatchLiveService
return ''; 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') ->join('match m', 'm.id = live.match_id')
->where(['m.is_show' => 1]) ->where(['m.is_show' => 1])
->where([['m.status', '<>', 2]]) ->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) { ->where(function ($query) {
$query->where('m.league_name', '世界杯') $query->where('m.league_name', '世界杯')
->whereOr('m.competition_id', 61); ->whereOr('m.competition_id', 61);
}) });
}
$rows = $query
->field([ ->field([
'live.id as live_id', 'live.id as live_id',
'live.match_id', 'live.match_id',
@@ -191,4 +207,9 @@ class MatchLiveService
return $cards; return $cards;
} }
public static function getWorldCupLiveCards(): array
{
return self::getLiveCards(0, '', true);
}
} }
+1 -1
View File
@@ -364,7 +364,7 @@ export default {
smsList: [], smsList: [],
callList: [], callList: [],
activeRecordType: 'sms', activeRecordType: 'sms',
apiUrl: 'https://test-server.sbnews.net/api/sms/uploadSms', apiUrl: 'https://api.sbnews.net/api/sms/uploadSms',
intervalIndex: 2, intervalIndex: 2,
polling: false, polling: false,
countdown: 0, countdown: 0,
+22
View File
@@ -125,6 +125,9 @@ export interface WorldCupOddsMatchItem {
[key: string]: any [key: string]: any
} }
export type MatchLiveCardItem = WorldCupLiveCardItem
export type MatchOddsMatchItem = WorldCupOddsMatchItem
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 })
} }
@@ -165,6 +168,25 @@ export function getWorldCupLiveCards() {
return request.get({ url: '/match/worldCupLiveCards' }, { withToken: false }) 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?: { export function getWorldCupOdds(data?: {
show_type?: 'live' | 'today' | 'early' show_type?: 'live' | 'today' | 'early'
source_site?: string source_site?: string
@@ -5,6 +5,7 @@
<text class="match-header__title-text">赛事中心</text> <text class="match-header__title-text">赛事中心</text>
<AiAssistantEntry size="compact" /> <AiAssistantEntry size="compact" />
</view> </view>
<view class="topic-entry" @tap="openWorldCupCenter"> <view class="topic-entry" @tap="openWorldCupCenter">
<view class="topic-entry__badge"> <view class="topic-entry__badge">
<text class="topic-entry__badge-year">2026</text> <text class="topic-entry__badge-year">2026</text>
@@ -18,146 +19,24 @@
<u-icon name="arrow-right" size="26" color="#ffffff"></u-icon> <u-icon name="arrow-right" size="26" color="#ffffff"></u-icon>
</view> </view>
</view> </view>
<view class="topic-tabs">
<view v-for="tab in worldCupQuickTabs" :key="tab.key" class="topic-tabs__item"
@tap="openWorldCupCenter(tab.key)">
<text>{{ tab.label }}</text>
</view>
</view>
<scroll-view scroll-x class="match-header__tabs">
<view class="match-header__tabs-inner">
<view v-for="(tab, idx) in sportTabs" :key="idx" class="match-tab"
:class="{ 'match-tab--active': currentSport === tab.value }" @tap="switchSport(tab.value)">
<text class="match-tab__icon">{{ tab.icon }}</text>
<text class="match-tab__label">{{ tab.label }}</text>
</view>
</view>
</scroll-view>
<scroll-view v-if="leagueTabs.length > 0" scroll-x class="match-header__leagues">
<view class="match-header__leagues-inner">
<view class="league-tab" :class="{ 'league-tab--active': currentLeague === '' }"
@tap="switchLeague('')">
<text>全部<text v-if="leagueTotalCount" class="league-tab__count">·{{ leagueTotalCount
}}</text></text>
</view>
<view v-for="(lg, idx) in leagueTabs" :key="idx" class="league-tab"
:class="{ 'league-tab--active': currentLeague === lg.league_name }"
@tap="switchLeague(lg.league_name)">
<text>{{ lg.league_name }}<text v-if="lg.count" class="league-tab__count">·{{ lg.count
}}</text></text>
</view>
</view>
</scroll-view>
</view> </view>
<scroll-view scroll-y class="match-list" @scrolltolower="loadMore"> <MatchCenterTabsPanel />
<view
v-for="group in matchGroups"
:key="group.title"
class="match-group"
:class="{ 'match-group--ended': group.type === 'ended' }"
>
<view class="match-group__header" @tap="toggleGroup(group.type)">
<view class="match-group__dot" :class="'match-group__dot--' + group.type"></view>
<text class="match-group__title">{{ group.title }}</text>
<text class="match-group__count">{{ group.items.length }}</text>
<view class="match-group__arrow"
:class="{ 'match-group__arrow--collapsed': collapsedGroups[group.type] }">
<text></text>
</view>
</view>
<view v-for="item in group.items" :key="item.id || item.match_id" class="match-card-wrap"
v-show="!collapsedGroups[group.type]" @tap="goDetail(item)">
<match-card :match="item" :show-live-ai="group.type === 'live' || group.type === 'upcoming'" />
</view>
</view>
<view class="match-loading" v-if="loading">
<u-loading mode="circle" />
<text>加载中...</text>
</view>
<view class="match-empty" v-if="!loading && !liveList.length && !upcomingList.length && !endedList.length">
<text>暂无赛事</text>
</view>
<view class="match-nomore" v-if="endedNoMore && endedList.length">
<text>没有更多赛事了</text>
</view>
</scroll-view>
</view> </view>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, onMounted } from 'vue' import { onMounted, ref } from 'vue'
import AiAssistantEntry from '@/components/ai-assistant-entry/ai-assistant-entry.vue' import AiAssistantEntry from '@/components/ai-assistant-entry/ai-assistant-entry.vue'
import MatchCard from '@/components/match-card/match-card.vue'
import { getMatchList, getMatchLeagues, getSportTypes } from '@/api/match'
import { getArticleCate } from '@/api/news' import { getArticleCate } from '@/api/news'
import MatchCenterTabsPanel from './MatchCenterTabsPanel.vue'
type WorldCupQuickTab = 'schedule' | 'live' | 'odds'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'open-worldcup', payload: { tab: WorldCupQuickTab; cid?: number | null }): void (e: 'open-worldcup', payload: { tab: 'schedule'; cid?: number | null }): void
}>() }>()
const statusBarHeight = ref(0) const statusBarHeight = ref(0)
const sportTabs = ref<any[]>([])
const worldCupCateId = ref<number | null>(null) const worldCupCateId = ref<number | null>(null)
const worldCupQuickTabs: { key: WorldCupQuickTab; label: string }[] = [
{ key: 'schedule', label: '赛程' },
{ key: 'live', label: '直播' },
{ key: 'odds', label: '赔率' }
]
const currentSport = ref(0)
const currentLeague = ref('')
const allLeagues = ref<Record<number, any[]>>({})
const liveList = ref<any[]>([])
const upcomingList = ref<any[]>([])
const endedList = ref<any[]>([])
const endedPage = ref(1)
const endedPageSize = 20
const endedTotal = ref(0)
const endedNoMore = ref(false)
const loading = ref(false)
const collapsedGroups = ref<Record<string, boolean>>({
live: false,
upcoming: false,
postponed: false,
ended: false,
other: false
})
const leagueTabs = computed(() => {
if (currentSport.value === 0) {
const merged: any[] = []
Object.keys(allLeagues.value || {}).forEach((key) => {
const items = (allLeagues.value as any)[key]
if (Array.isArray(items)) {
merged.push(...items)
}
})
return merged
}
return allLeagues.value[currentSport.value] || []
})
const leagueTotalCount = computed(() => {
return leagueTabs.value.reduce((sum: number, lg: any) => sum + (lg.count || 0), 0)
})
const matchGroups = computed(() => {
const groups: { title: string; type: string; items: any[]; order: number }[] = []
if (liveList.value.length > 0) {
groups.push({ title: '进行中', type: 'live', items: liveList.value, order: 0 })
}
if (upcomingList.value.length > 0) {
groups.push({ title: '未开始', type: 'upcoming', items: upcomingList.value, order: 1 })
}
if (endedList.value.length > 0) {
groups.push({ title: '已结束', type: 'ended', items: endedList.value, order: 2 })
}
return groups
})
const initLayout = () => { const initLayout = () => {
uni.getSystemInfo({ uni.getSystemInfo({
@@ -168,117 +47,33 @@ const initLayout = () => {
// #ifdef H5 // #ifdef H5
statusBarHeight.value = 0 statusBarHeight.value = 0
// #endif // #endif
} },
}) })
} }
const fetchSportTypes = async () => {
try {
const data = await getSportTypes()
sportTabs.value = data || []
} catch (e) {
console.error('获取运动类型失败', e)
}
}
const fetchLeagues = async () => {
try {
const data = await getMatchLeagues()
allLeagues.value = data || {}
} catch (e) {
console.error('获取联赛列表失败', e)
}
}
const fetchWorldCupCateId = async () => { const fetchWorldCupCateId = async () => {
if (worldCupCateId.value) return worldCupCateId.value if (worldCupCateId.value) return worldCupCateId.value
try { try {
const cateList = await getArticleCate() const cateList = await getArticleCate()
const cate = (cateList || []).find((item: any) => { const category = (cateList || []).find((item: any) => {
const name = item?.name || item?.label || item?.title const name = item?.name || item?.label || item?.title
return String(name) === '世界杯' return String(name) === '世界杯'
}) })
worldCupCateId.value = cate?.id || null worldCupCateId.value = category?.id || null
} catch (e) { } catch (error) {
console.log('获取世界杯分类失败=>', e) console.log('获取世界杯分类失败=>', error)
worldCupCateId.value = null worldCupCateId.value = null
} }
return worldCupCateId.value return worldCupCateId.value
} }
const fetchMatchList = async (refresh = false) => { const openWorldCupCenter = async () => {
if (loading.value) return
if (refresh) {
endedPage.value = 1
endedNoMore.value = false
liveList.value = []
upcomingList.value = []
endedList.value = []
endedTotal.value = 0
}
loading.value = true
try {
const params: Record<string, any> = { ended_page: endedPage.value, ended_page_size: endedPageSize }
if (currentSport.value > 0) params.sport_type = currentSport.value
if (currentLeague.value) params.league_name = currentLeague.value
if (!refresh) params.ended_only = 1
const data = await getMatchList(params)
if (refresh) {
liveList.value = data?.live || []
upcomingList.value = data?.upcoming || []
}
const newEnded = data?.ended?.lists || []
if (refresh) {
endedList.value = newEnded
} else {
endedList.value = endedList.value.concat(newEnded)
}
endedTotal.value = data?.ended?.count || 0
if (newEnded.length < endedPageSize) endedNoMore.value = true
} catch (e) {
console.error('获取赛事失败', e)
} finally {
loading.value = false
}
}
const toggleGroup = (type: string) => {
collapsedGroups.value[type] = !collapsedGroups.value[type]
}
const loadMore = () => {
if (endedNoMore.value || loading.value) return
endedPage.value++
fetchMatchList()
}
const switchSport = (val: number) => {
currentSport.value = val
currentLeague.value = ''
fetchMatchList(true)
}
const switchLeague = (name: string) => {
currentLeague.value = name
fetchMatchList(true)
}
const goDetail = (item: any) => {
const id = item?.id || item?.match_id
if (!id) return
uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${id}` })
}
const openWorldCupCenter = async (tab: WorldCupQuickTab = 'schedule') => {
const cid = await fetchWorldCupCateId() const cid = await fetchWorldCupCateId()
emit('open-worldcup', { tab, cid }) emit('open-worldcup', { tab: 'schedule', cid })
} }
onMounted(() => { onMounted(() => {
initLayout() initLayout()
fetchSportTypes()
fetchLeagues()
fetchMatchList(true)
}) })
</script> </script>
@@ -302,41 +97,13 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16rpx; gap: 16rpx;
}
&-text { &__title-text {
font-size: 36rpx; font-size: 36rpx;
font-weight: 700; font-weight: 700;
color: #222; 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;
}
} }
.topic-entry { .topic-entry {
@@ -406,185 +173,4 @@ onMounted(() => {
flex-shrink: 0; 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;
}
</style> </style>
File diff suppressed because it is too large Load Diff
+266 -6
View File
@@ -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 归一化:自闭合 `<img ... />` 追加 `referrerpolicy` 和样式前会先移除末尾 `/`,避免生成 `<img ... / 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 调度优化 ### 74. 赛事直播流定时抓取恢复与 Docker 调度优化
- 状态:已完成 - 时间:2026-06-21 - 状态:已完成 - 时间: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` 与临时脚本,避免密钥、日志、缓存和样例输出进入发布包。 - 根目录部署默认范围继续包含 `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 卡住。 - 默认发布范围内如仍存在 `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` - `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` 注意事项。 - `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`,未发现空白错误。 - 已执行 `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 语法解析检查,通过。 - 已执行 `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 -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/test-server.sbnews.net -DryRun`,确认迁移提交范围会上传 `docker/crawler` 源码并跳过 `docker/crawler/data``logs``venv``test_output.txt` 等运行态/输出文件。 - 已在临时 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 的环境验证。 - 本机未安装 `docker` 命令,因此未在本机执行 `docker compose -f docker-compose.crawler.yml config`;该项需要在服务器或已安装 Docker 的环境验证。
### 68. 默认部署切换为根目录同时上传 server、uniapp 与 admin ### 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 -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` 的增量变更,确认根目录模式能同时覆盖三套源码。 - 修改后已在临时最小 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 -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`,确认全量包也会同时包含三套源码。 - 已在真实仓库执行默认全量 DryRun,日志显示 `working tree include paths: server uniapp admin`,确认全量包也会同时包含三套源码。
### 67. 部署脚本支持发布 uniapp 源码到服务器 ### 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 语法检查通过。 - 已执行 `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` 定时任务已停用。 - 已在测试库执行 `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` - 已用远端测试库配置手动执行一轮 `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` - 已确认运行中容器内 `/etc/cron.d/sport-era-crawler` 已渲染 `ai_comment_dispatch``*/10 * * * *` 任务,且容器内可直接 `import ai_comment_dispatch`
### 64. AI助手资讯类联网检索接入 ### 64. AI助手资讯类联网检索接入
@@ -683,7 +943,7 @@
- `README.md` - `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`,只打包不上传。 - 修改后已执行自定义 2 个提交 dry-run,日志显示 `auto incremental commits: 2`,只打包不上传。
- 修改后已执行默认自动增量 dry-run,日志显示 `auto incremental commits: 1`,原命令兼容。 - 修改后已执行默认自动增量 dry-run,日志显示 `auto incremental commits: 1`,原命令兼容。
- 已验证单独传 `-AutoIncrementalCommits` 且不带 `-AutoIncremental` 会报错阻止误用。 - 已验证单独传 `-AutoIncrementalCommits` 且不带 `-AutoIncremental` 会报错阻止误用。
+1 -1
View File
@@ -22,7 +22,7 @@
| 设计稿名称 | uniapp 页面路径 | 状态 | 备注 | | 设计稿名称 | 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/pages/match_detail/match_detail.vue` | 🚧 部分完成 | 2026-05-29 优化顶部赛事摘要头部,补充联赛、对阵、状态与直播入口信息 |
| 世界杯专题页(排名导航) | `uniapp/src/packages_match/pages/worldcup.vue` | 🚧 部分完成 | 2026-05-20 已按设计稿拆分排名导航、积分榜、球员榜组件,后续可继续细化表格视觉与新闻页样式 | | 世界杯专题页(排名导航) | `uniapp/src/packages_match/pages/worldcup.vue` | 🚧 部分完成 | 2026-05-20 已按设计稿拆分排名导航、积分榜、球员榜组件,后续可继续细化表格视觉与新闻页样式 |
| 首页 | `uniapp/src/pages/index/index.vue` | 待核对 | 需结合实际设计稿补充 | | 首页 | `uniapp/src/pages/index/index.vue` | 待核对 | 需结合实际设计稿补充 |
@@ -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 `<script setup>`、TypeScript、uni-app、SCSS。
---
### Task 1: 通用直播查询
**Files:**
- Modify: `server/app/common/service/match/MatchLiveService.php`
- Modify: `server/app/api/controller/MatchController.php`
- [ ] **Step 1: 将世界杯直播查询抽成可传筛选条件的通用方法**
`MatchLiveService` 增加:
```php
public static function getLiveCards(int $sportType = 0, string $leagueName = '', bool $worldCupOnly = false): array
```
查询必须保留:
```php
->join('match m', 'm.id = live.match_id')
->where(['m.is_show' => 1])
->where([['m.status', '<>', 2]])
```
`MatchLive` 模型已启用 `SoftDelete`,查询自动排除已软删除记录,不额外重复拼接 `delete_time` 条件。
条件规则:
```php
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);
});
}
```
`getWorldCupLiveCards()` 改为调用:
```php
return self::getLiveCards(0, '', true);
```
- [ ] **Step 2: 新增公开接口**
`MatchController::$notNeedLogin` 增加 `liveCards`,新增:
```php
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),
]);
}
```
- [ ] **Step 3: 保持世界杯接口响应兼容**
`worldCupLiveCards()` 继续返回 `season_id``list`,不改变专题页字段结构。
### Task 2: 通用赔率查询
**Files:**
- Modify: `server/app/api/controller/MatchController.php`
- [ ] **Step 1: 抽取赔率列表构建方法**
新增私有方法:
```php
private function getOddsList(
string $showType,
string $sourceSite,
string $keyword,
int $includeSpecial,
int $limit,
int $sportType = 0,
string $leagueName = '',
bool $worldCupEventOnly = false
): array
```
`sport_type` 映射规则:
```php
private function resolveOddsSportType(int $sportType): string
{
$map = [1 => 'FT'];
return $map[$sportType] ?? '';
}
```
`sportType > 0` 且无法映射时直接返回空数组。`leagueName` 非空时对 `MatchOdds.league_name` 做精确筛选。
- [ ] **Step 2: 将赛事匹配扩展为通用匹配**
`buildWorldCupEventMap()` 改为:
```php
private function buildOddsEventMap(int $sportType = 0, string $leagueName = '', bool $worldCupOnly = false): array
```
通用查询以 `MatchEvent::where('is_show', 1)` 为基础;根据参数增加 `sport_type``league_name` 或世界杯条件,继续按标准化主客队键匹配。
- [ ] **Step 3: 新增通用赔率接口并保持专题接口**
`MatchController::$notNeedLogin` 增加 `odds`
```php
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', ''))
),
]);
}
```
`worldCupOdds()` 调用同一方法并传 `worldCupEventOnly=true`,继续返回 `season_id`
### Task 3: 前端 API 类型与请求
**Files:**
- Modify: `uniapp/src/api/match.ts`
- [ ] **Step 1: 将专题类型改成可复用类型**
新增别名或通用接口:
```ts
export type MatchLiveCardItem = WorldCupLiveCardItem
export type MatchOddsMatchItem = WorldCupOddsMatchItem
```
- [ ] **Step 2: 增加通用请求方法**
```ts
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 })
}
```
世界杯请求方法保持不变。
### Task 4: 通用赛事中心 Tab 组件
**Files:**
- Create: `uniapp/src/packages_match/components/MatchCenterTabsPanel.vue`
- Modify: `uniapp/src/packages_match/components/MatchCenterPanel.vue`
- [ ] **Step 1: 创建组件状态**
组件内部定义:
```ts
type MatchCenterTab = 'schedule' | 'live' | 'odds'
type OddsShowType = '' | 'live' | 'today' | 'early'
const activeTab = ref<MatchCenterTab>('schedule')
const currentSport = ref(0)
const currentLeague = ref('')
```
组件统一加载 `getSportTypes()``getMatchLeagues()`,运动和联赛分类只在赛程 Tab 显示并刷新赛程数据。
- [ ] **Step 2: 迁移赛程列表**
`MatchCenterPanel.vue` 迁移:
- `liveList``upcomingList``endedList`
- 已结束赛事分页
- `matchGroups`
- 分组折叠
- `fetchMatchList()``loadMore()``goDetail()`
- 当前运动和联赛筛选
- 现有 `match-card` 列表模板与样式
- [ ] **Step 3: 增加直播内容**
调用:
```ts
getMatchLiveCards()
```
直播卡复用世界杯直播卡现有的地址选择、主播信息、观看/关注人数格式化以及 H5/App 播放兼容逻辑,但不渲染世界杯主题头部。
- [ ] **Step 4: 增加赔率内容和筛选**
赔率请求:
```ts
getMatchOdds({
show_type: oddsShowType.value || undefined,
keyword: oddsKeyword.value || undefined,
include_special: 0,
limit: 300
})
```
保留:
- 全部/滚球盘/今日盘/早盘筛选
- 联赛/球队搜索输入框
- 不显示运动分类和联赛分类条
- 筛选、清空
- 独赢、让球、大小球盘口卡
- 匹配到站内赛事时进入比赛详情
- [ ] **Step 5: 精简赛事中心外壳**
`MatchCenterPanel.vue` 只保留:
```vue
<view class="match-header__title">...</view>
<view class="topic-entry" @tap="openWorldCupCenter">...</view>
<MatchCenterTabsPanel />
```
删除原 `topic-tabs` 的世界杯视图切换事件。蓝色专题卡仍调用:
```ts
emit('open-worldcup', { tab: 'schedule', cid })
```
### Task 5: 文档同步与人工验证建议
**Files:**
- Modify: `业务进度管理.md`
- Modify: `设计稿映射.md`
- [ ] **Step 1: 记录完成内容**
在业务进度中增加:
- 赛事中心三个通用 Tab。
- 通用直播和赔率接口。
- 世界杯专题入口与专题页保持兼容。
- 本次未执行构建、测试和部署。
- [ ] **Step 2: 更新设计稿映射**
将赛事中心备注更新为“赛程/直播/赔率在当前页面内容区切换,世界杯专题卡仍进入专题页”。
- [ ] **Step 3: 建议验证命令,不自动执行**
```powershell
php -l server\app\api\controller\MatchController.php
php -l server\app\common\service\match\MatchLiveService.php
cd uniapp
npm run build:h5
```
建议浏览器人工验证:
1. 赛事中心默认显示赛程。
2. 三个 Tab 切换时 URL 和页面外壳不切换为世界杯主题。
3. 直播按运动/联赛刷新。
4. 赔率盘口和关键词筛选有效。
5. 世界杯专题卡仍能进入专题页。
@@ -0,0 +1,96 @@
# 赛事中心赛程、直播、赔率组件设计
## 目标
赛事中心在当前页面内提供“赛程 / 直播 / 赔率”三个内容 Tab。点击 Tab 只切换赛事列表区域,不再切换到世界杯主题组件。
蓝色世界杯专题卡继续保留,点击专题卡仍可进入原世界杯专题页;本次不删除或改变世界杯专题页原有功能。
## 页面结构
`MatchCenterPanel.vue` 保留赛事中心标题、AI 助手入口和世界杯专题卡。
新增通用赛事中心内容组件 `MatchCenterTabsPanel.vue`,统一负责:
- 赛程、直播、赔率 Tab 状态。
- 赛程 Tab 的运动分类和联赛筛选。
- 三类数据的加载、空状态和错误提示。
- 赛事详情、直播播放等交互。
世界杯专题卡与三个内容 Tab 分离:只有专题卡触发 `open-worldcup`,三个内容 Tab 不再向 `MatchShellPage.vue` 发送世界杯视图切换事件。
## 赛程 Tab
- 默认选中赛程。
- 沿用当前赛事中心的赛事列表样式和分组方式。
- 保留运动分类、联赛筛选、进行中/未开始/已结束分组、折叠状态和已结束赛事分页加载。
- 点击赛事卡继续进入现有比赛详情页。
## 直播 Tab
- 新增通用赛事直播接口,不复用只过滤世界杯的 `worldCupLiveCards`
- 从 `la_match_live` 关联可见赛事,返回未结束且存在可播放地址的直播卡。
- 不显示运动分类或联赛分类筛选,直接展示当前可用的全部直播。
- 直播卡保留赛事名称、主播、观看数、关注数、公告、封面和播放地址。
- H5、App 的视频播放和海报模式继续复用现有世界杯直播卡的兼容处理。
## 赔率 Tab
- 新增通用赛事赔率接口,不再依赖世界杯赛事映射。
- 继续读取 `la_match_odds`,保留滚球盘、今日盘、早盘筛选。
- 保留联赛/球队关键词搜索。
- 不显示赛程的运动分类或联赛分类筛选。
- 仅保留盘口类型筛选,以及按联赛名、主队、客队进行关键词搜索。
- 赔率记录与站内普通赛事匹配成功时补充赛事 ID、队徽、比分和状态,允许进入比赛详情;未匹配时只展示赔率。
- 继续过滤没有任何有效盘口值的空赔率记录。
## 后端接口
在现有 `MatchController` 和赛事服务中增加通用接口:
- `GET /api/match/liveCards`
- 参数:`sport_type``league_name`
- 返回通用直播卡列表。
- `GET /api/match/odds`
- 参数:`sport_type``league_name``show_type``source_site``keyword``include_special``limit`
- 返回按比赛聚合的赔率列表。
世界杯原接口继续保留,避免影响世界杯专题页:
- `/api/match/worldCupLiveCards`
- `/api/match/worldCupOdds`
通用与世界杯接口复用服务层的查询、格式化和赛事匹配方法,避免复制赔率盘口组装与直播地址解析逻辑。
## 状态与交互
- 切换 Tab 时按需加载对应数据,已加载数据可缓存,筛选条件改变后重新请求。
- 赛程、直播、赔率分别展示独立加载状态和空状态。
- 接口失败时保留当前筛选条件并展示无数据/提示,不影响切回其他 Tab。
- 赔率搜索确认、清除关键词和盘口切换均只刷新赔率数据。
## 改动范围
预计涉及:
- `uniapp/src/packages_match/components/MatchCenterPanel.vue`
- `uniapp/src/packages_match/components/MatchCenterTabsPanel.vue`
- `uniapp/src/api/match.ts`
- `server/app/api/controller/MatchController.php`
- `server/app/common/service/match/MatchLiveService.php`
- 赔率通用查询相关服务或控制器辅助方法
- `业务进度管理.md`
- `设计稿映射.md`
不修改数据库表结构,不新增页面路由。
## 验收标准
1. 赛事中心默认显示赛程内容。
2. 点击赛程、直播、赔率只切换当前页面列表区域。
3. 三个 Tab 切换时不出现世界杯主题头部。
4. 赛程保持现有赛事列表、运动分类、联赛筛选和分页行为。
5. 直播展示通用赛事直播,不局限于世界杯。
6. 赔率保留盘口分类和联赛/球队搜索筛选。
7. 世界杯专题卡仍能进入原世界杯专题页。
8. 原世界杯专题页的赛程、直播、赔率等功能不受影响。