添加赔率
This commit is contained in:
@@ -28,3 +28,7 @@ bin-release/
|
|||||||
**/.hbuilderx/
|
**/.hbuilderx/
|
||||||
**/unpackage/
|
**/unpackage/
|
||||||
qa/
|
qa/
|
||||||
|
.code-graph/
|
||||||
|
./vscode/
|
||||||
|
.tmp*
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -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、保存白名单和数据库列是否同步。
|
||||||
|
|||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+134
-52
@@ -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,62 +553,129 @@ 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
|
||||||
username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip()
|
|
||||||
password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip()
|
|
||||||
if not username or not password:
|
|
||||||
return _build_result(
|
|
||||||
success=False,
|
|
||||||
candidate_count=0,
|
|
||||||
saved_count=0,
|
|
||||||
errors=["ODDS_USERNAME/ODDS_PASSWORD 未配置"],
|
|
||||||
)
|
|
||||||
|
|
||||||
base_urls = [_normalize_base_url(url) for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS)]
|
|
||||||
sport_types = [item.upper() for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES)]
|
|
||||||
show_types = [item.lower() for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_SHOW_TYPES)]
|
|
||||||
timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20)
|
|
||||||
verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False)
|
|
||||||
langx = (env.get("ODDS_LANGX") or "zh-cn").strip()
|
|
||||||
odds_type = (env.get("ODDS_TYPE") or "H").strip().upper()
|
|
||||||
request_delay = float(env.get("ODDS_REQUEST_DELAY_SECONDS") or REQUEST_DELAY_SECONDS)
|
|
||||||
|
|
||||||
client: Optional[HgOddsClient] = None
|
|
||||||
login_errors: List[str] = []
|
|
||||||
for base_url in base_urls:
|
|
||||||
try:
|
|
||||||
candidate = HgOddsClient(
|
|
||||||
base_url=base_url,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
timeout=timeout,
|
|
||||||
verify_tls=verify_tls,
|
|
||||||
langx=langx,
|
|
||||||
odds_type=odds_type,
|
|
||||||
)
|
|
||||||
candidate.login()
|
|
||||||
client = candidate
|
|
||||||
break
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
login_errors.append(f"{base_url}: {exc}")
|
|
||||||
|
|
||||||
if client is None:
|
|
||||||
return _build_result(success=False, candidate_count=0, saved_count=0, errors=login_errors)
|
|
||||||
|
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
fetch_errors: List[str] = []
|
fetch_errors: List[str] = []
|
||||||
|
source_urls: List[str] = []
|
||||||
|
successful_sources = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
for sport_type in sport_types:
|
|
||||||
for show_type in show_types:
|
username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip()
|
||||||
|
password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip()
|
||||||
|
if username and password:
|
||||||
|
base_urls = [
|
||||||
|
_normalize_base_url(url)
|
||||||
|
for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS)
|
||||||
|
]
|
||||||
|
sport_types = [
|
||||||
|
item.upper()
|
||||||
|
for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES)
|
||||||
|
]
|
||||||
|
show_types = [
|
||||||
|
item.lower()
|
||||||
|
for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_SHOW_TYPES)
|
||||||
|
]
|
||||||
|
timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20)
|
||||||
|
verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False)
|
||||||
|
langx = (env.get("ODDS_LANGX") or "zh-cn").strip()
|
||||||
|
odds_type = (env.get("ODDS_TYPE") or "H").strip().upper()
|
||||||
|
request_delay = _safe_float(
|
||||||
|
env.get("ODDS_REQUEST_DELAY_SECONDS"),
|
||||||
|
REQUEST_DELAY_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: Optional[HgOddsClient] = None
|
||||||
|
login_errors: List[str] = []
|
||||||
|
for base_url in base_urls:
|
||||||
try:
|
try:
|
||||||
items.extend(client.fetch_game_list(sport_type, show_type))
|
candidate = HgOddsClient(
|
||||||
except OddsGameListUnavailable:
|
base_url=base_url,
|
||||||
skipped_count += 1
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=timeout,
|
||||||
|
verify_tls=verify_tls,
|
||||||
|
langx=langx,
|
||||||
|
odds_type=odds_type,
|
||||||
|
)
|
||||||
|
candidate.login()
|
||||||
|
client = candidate
|
||||||
|
break
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
fetch_errors.append(f"{sport_type}/{show_type}: {exc}")
|
login_errors.append(f"hg {base_url}: {exc}")
|
||||||
if request_delay > 0:
|
|
||||||
time.sleep(request_delay)
|
if client is None:
|
||||||
|
fetch_errors.extend(login_errors)
|
||||||
|
else:
|
||||||
|
successful_sources += 1
|
||||||
|
source_urls.append(client.base_url)
|
||||||
|
for sport_type in sport_types:
|
||||||
|
for show_type in show_types:
|
||||||
|
try:
|
||||||
|
items.extend(client.fetch_game_list(sport_type, show_type))
|
||||||
|
except OddsGameListUnavailable:
|
||||||
|
skipped_count += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
fetch_errors.append(f"hg {sport_type}/{show_type}: {exc}")
|
||||||
|
if request_delay > 0:
|
||||||
|
time.sleep(request_delay)
|
||||||
|
|
||||||
|
titan_enabled = _bool_env(env.get("TITAN_ODDS_ENABLED"), default=True)
|
||||||
|
if titan_enabled:
|
||||||
|
titan_urls = [
|
||||||
|
str(url).strip()
|
||||||
|
for url in _split_env_list(
|
||||||
|
env.get("TITAN_ODDS_URLS", ""),
|
||||||
|
DEFAULT_TITAN_CUP_URLS,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
titan_client = TitanOddsClient(
|
||||||
|
timeout=_safe_int(env.get("TITAN_ODDS_TIMEOUT"), 20),
|
||||||
|
verify_tls=_bool_env(env.get("TITAN_ODDS_VERIFY_TLS"), default=True),
|
||||||
|
company_id=_safe_int(
|
||||||
|
env.get("TITAN_ODDS_COMPANY_ID"),
|
||||||
|
DEFAULT_TITAN_COMPANY_ID,
|
||||||
|
),
|
||||||
|
euro_company_id=_safe_int(
|
||||||
|
env.get("TITAN_ODDS_EURO_COMPANY_ID"),
|
||||||
|
DEFAULT_TITAN_EURO_COMPANY_ID,
|
||||||
|
),
|
||||||
|
company_name=(
|
||||||
|
env.get("TITAN_ODDS_COMPANY_NAME")
|
||||||
|
or DEFAULT_TITAN_COMPANY_NAME
|
||||||
|
).strip(),
|
||||||
|
show_type=(env.get("TITAN_ODDS_SHOW_TYPE") or "early").strip().lower(),
|
||||||
|
request_delay=_safe_float(
|
||||||
|
env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"),
|
||||||
|
0.2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for titan_url in titan_urls:
|
||||||
|
try:
|
||||||
|
items.extend(titan_client.fetch_cup(titan_url))
|
||||||
|
successful_sources += 1
|
||||||
|
source_urls.append(titan_url)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
fetch_errors.append(f"titan007 {titan_url}: {exc}")
|
||||||
|
titan_request_delay = _safe_float(
|
||||||
|
env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"),
|
||||||
|
0.2,
|
||||||
|
)
|
||||||
|
if titan_request_delay > 0:
|
||||||
|
time.sleep(titan_request_delay)
|
||||||
|
|
||||||
|
if successful_sources == 0:
|
||||||
|
if not username or not password:
|
||||||
|
fetch_errors.append("ODDS_USERNAME/ODDS_PASSWORD 未配置")
|
||||||
|
return _build_result(
|
||||||
|
success=False,
|
||||||
|
candidate_count=len(items),
|
||||||
|
saved_count=0,
|
||||||
|
source_url=",".join(dict.fromkeys(source_urls)),
|
||||||
|
skipped_count=skipped_count,
|
||||||
|
errors=fetch_errors or ["没有可用的赔率来源"],
|
||||||
|
)
|
||||||
|
|
||||||
repo = None
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
@@ -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='第三方赛事赔率表';
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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`
|
||||||
|
);
|
||||||
@@ -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
@@ -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
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) : '未知平台');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]]);
|
||||||
->where(function ($query) {
|
|
||||||
$query->where('m.league_name', '世界杯')
|
if ($sportType > 0) {
|
||||||
->whereOr('m.competition_id', 61);
|
$query->where('m.sport_type', $sportType);
|
||||||
})
|
}
|
||||||
|
if ($leagueName !== '') {
|
||||||
|
$query->where('m.league_name', $leagueName);
|
||||||
|
}
|
||||||
|
if ($worldCupOnly) {
|
||||||
|
$query
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('m.league_name', '世界杯')
|
||||||
|
->whereOr('m.competition_id', 61);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query
|
||||||
->field([
|
->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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,40 +97,12 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
|
|
||||||
&-text {
|
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tabs {
|
&__title-text {
|
||||||
white-space: nowrap;
|
font-size: 36rpx;
|
||||||
height: 80rpx;
|
font-weight: 700;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
color: #222;
|
||||||
}
|
|
||||||
|
|
||||||
&__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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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` 会报错阻止误用。
|
||||||
|
|||||||
@@ -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. 原世界杯专题页的赛程、直播、赔率等功能不受影响。
|
||||||
Reference in New Issue
Block a user