feat(frontend): 重构视频分析页面,支持多种搜索方式

主要更新:
- 前端改用 Ant Design 组件(Table、Modal、Select 等)
- 支持三种搜索方式:星图ID、达人unique_id、达人昵称模糊匹配
- 列表页实时调用云图 API 获取 A3 数据和成本指标
- 详情弹窗显示完整 6 大类指标,支持文字复制
- 品牌 API URL 格式修复为查询参数形式
- 优化云图 API 参数格式和会话池管理

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zfc
2026-01-28 22:01:55 +08:00
co-authored by Claude Opus 4.5
parent f123f68be3
commit 7cd29c5980
25 changed files with 2482 additions and 1324 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ async def fetch_brand_name(
timeout=settings.BRAND_API_TIMEOUT
) as client:
response = await client.get(
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands/{brand_id}",
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands",
params={"brand_id": brand_id},
headers=headers,
)
if response.status_code == 200:
+130 -45
View File
@@ -1,13 +1,18 @@
"""
SessionID池服务 (T-021)
SessionID池服务 (T-021, T-027)
从内部API获取Cookie列表,随机选取sessionid用于巨量云图API调用。
从内部API获取Cookie列表,随机选取 aadvid/auth_token 用于 API 调用。
T-027 修复:
- 改为随机选取任意一组配置,不按 brand_id 匹配
- auth_token 直接使用完整值 (如 "sessionid=xxx")
"""
import asyncio
import random
import logging
from typing import List, Optional
import random
from typing import Dict, Optional, Any, List
from dataclasses import dataclass
import httpx
@@ -16,16 +21,27 @@ from app.config import settings
logger = logging.getLogger(__name__)
@dataclass
class CookieConfig:
"""Cookie 配置"""
brand_id: str
aadvid: str
auth_token: str # 完整的 cookie 值,如 "sessionid=xxx"
industry_id: int
brand_name: str
class SessionPool:
"""SessionID池管理器"""
"""SessionID池管理器 - T-027: 改为随机选取"""
def __init__(self):
self._sessions: List[str] = []
# 存储所有配置的列表
self._configs: List[CookieConfig] = []
self._lock = asyncio.Lock()
async def refresh(self) -> bool:
"""
从内部API刷新SessionID列表。
从内部API刷新配置列表。
Returns:
bool: 刷新是否成功
@@ -47,19 +63,34 @@ class SessionPool:
if response.status_code == 200:
data = response.json()
# 响应格式: {"data": [{"sessionid": "xxx", ...}, ...]}
if isinstance(data, dict):
cookie_list = data.get("data", [])
if isinstance(cookie_list, list):
self._sessions = [
item.get("sessionid")
for item in cookie_list
if isinstance(item, dict) and item.get("sessionid")
]
self._configs = []
for item in cookie_list:
if not isinstance(item, dict):
continue
brand_id = str(item.get("brand_id", ""))
aadvid = str(item.get("aadvid", ""))
# T-027: 直接使用 auth_token 或 sessionid_cookie 完整值
auth_token = item.get("auth_token") or item.get("sessionid_cookie", "")
industry_id = item.get("industry_id", 0)
brand_name = item.get("brand_name", "")
if brand_id and aadvid and auth_token:
self._configs.append(CookieConfig(
brand_id=brand_id,
aadvid=aadvid,
auth_token=auth_token,
industry_id=int(industry_id) if industry_id else 0,
brand_name=brand_name,
))
logger.info(
f"SessionPool refreshed: {len(self._sessions)} sessions"
f"SessionPool refreshed: {len(self._configs)} configs"
)
return len(self._sessions) > 0
return len(self._configs) > 0
logger.warning(
f"Failed to refresh session pool: status={response.status_code}"
@@ -76,48 +107,101 @@ class SessionPool:
logger.error(f"SessionPool refresh unexpected error: {e}")
return False
def get_random(self) -> Optional[str]:
def get_random_config(self) -> Optional[Dict[str, Any]]:
"""
随机获取一个SessionID
T-027: 随机选取任意一组配置
Returns:
Optional[str]: SessionID,池为空时返回None
Dict or None: 包含 aadvid 和 auth_token 的字典
"""
if not self._sessions:
if not self._configs:
return None
return random.choice(self._sessions)
config = random.choice(self._configs)
return {
"brand_id": config.brand_id,
"aadvid": config.aadvid,
"auth_token": config.auth_token,
"industry_id": config.industry_id,
"brand_name": config.brand_name,
}
def remove(self, session_id: str) -> None:
def remove_by_auth_token(self, auth_token: str) -> None:
"""
从池中移除失效的SessionID
从池中移除失效的配置
Args:
session_id: 要移除的SessionID
auth_token: 要移除的 auth_token
"""
try:
self._sessions.remove(session_id)
logger.info(f"Removed invalid session: {session_id[:8]}...")
except ValueError:
pass # 已经被移除
self._configs = [c for c in self._configs if c.auth_token != auth_token]
logger.info(f"Removed invalid config: {auth_token[:20]}...")
# 兼容旧接口
def remove(self, session_id: str) -> None:
"""兼容旧接口:移除包含指定 session_id 的配置"""
self._configs = [c for c in self._configs if session_id not in c.auth_token]
@property
def size(self) -> int:
"""返回池中SessionID数量"""
return len(self._sessions)
"""返回池中配置数量"""
return len(self._configs)
@property
def is_empty(self) -> bool:
"""检查池是否为空"""
return len(self._sessions) == 0
return len(self._configs) == 0
# 兼容旧接口
def get_random(self) -> Optional[str]:
"""兼容旧接口:随机获取一个 SessionID"""
config = self.get_random_config()
if config:
# 从 auth_token 中提取 sessionid
auth_token = config["auth_token"]
if "=" in auth_token:
return auth_token.split("=", 1)[-1]
return auth_token
return None
# 兼容旧代码
@property
def _brand_configs(self) -> Dict[str, Any]:
"""兼容旧接口"""
return {c.brand_id: c for c in self._configs}
# 全局单例
session_pool = SessionPool()
async def get_random_config(max_retries: int = 3) -> Optional[Dict[str, Any]]:
"""
T-027: 随机获取一组配置,必要时刷新池。
Args:
max_retries: 最大重试次数
Returns:
Dict or None: 包含 aadvid 和 auth_token 的字典
"""
for attempt in range(max_retries):
if session_pool.is_empty:
success = await session_pool.refresh()
if not success:
logger.warning(f"Session pool refresh failed, attempt {attempt + 1}")
continue
config = session_pool.get_random_config()
if config:
return config
logger.error("Failed to get config after all retries")
return None
# 兼容旧接口
async def get_session_with_retry(max_retries: int = 3) -> Optional[str]:
"""
获取SessionID,必要时刷新池 (T-022 支持)。
获取SessionID,必要时刷新池 (兼容旧接口)。
Args:
max_retries: 最大重试次数
@@ -125,17 +209,18 @@ async def get_session_with_retry(max_retries: int = 3) -> Optional[str]:
Returns:
Optional[str]: SessionID,获取失败返回None
"""
for attempt in range(max_retries):
# 如果池为空,尝试刷新
if session_pool.is_empty:
success = await session_pool.refresh()
if not success:
logger.warning(f"Session pool refresh failed, attempt {attempt + 1}")
continue
session_id = session_pool.get_random()
if session_id:
return session_id
logger.error("Failed to get session after all retries")
config = await get_random_config(max_retries)
if config:
auth_token = config["auth_token"]
if "=" in auth_token:
return auth_token.split("=", 1)[-1]
return auth_token
return None
async def get_config_for_brand(brand_id: str, max_retries: int = 3) -> Optional[Any]:
"""
兼容旧接口:获取品牌对应的配置。
T-027: 实际上现在随机选取,不再按 brand_id 匹配。
"""
return await get_random_config(max_retries)
+111
View File
@@ -318,3 +318,114 @@ async def get_and_update_video_analysis(
)
return result
async def search_videos_by_star_id(
session: AsyncSession, star_id: str
) -> list[KolVideo]:
"""根据星图ID精准匹配搜索视频列表。"""
stmt = select(KolVideo).where(KolVideo.star_id == star_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def search_videos_by_unique_id(
session: AsyncSession, unique_id: str
) -> list[KolVideo]:
"""根据达人unique_id精准匹配搜索视频列表。"""
stmt = select(KolVideo).where(KolVideo.star_unique_id == unique_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def search_videos_by_nickname(
session: AsyncSession, nickname: str
) -> list[KolVideo]:
"""根据达人昵称模糊匹配搜索视频列表。"""
stmt = select(KolVideo).where(KolVideo.star_nickname.ilike(f"%{nickname}%"))
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_video_list_with_a3(
session: AsyncSession, videos: list[KolVideo]
) -> list[Dict[str, Any]]:
"""
获取视频列表的摘要数据(实时调用云图API获取A3数据)。
"""
from app.services.brand_api import get_brand_names
# 批量获取品牌名称
brand_ids = [video.brand_id for video in videos if video.brand_id]
brand_map = await get_brand_names(brand_ids) if brand_ids else {}
result = []
for video in videos:
# 实时调用云图 API 获取 A3 数据和 cost
a3_increase_cnt = 0
ad_a3_increase_cnt = 0
natural_a3_increase_cnt = 0
api_cost = 0.0
try:
publish_time = video.publish_time or datetime.now()
industry_id = video.industry_id or ""
api_response = await fetch_yuntu_analysis(
item_id=video.item_id,
publish_time=publish_time,
industry_id=industry_id,
)
api_data = parse_analysis_response(api_response)
a3_increase_cnt = api_data.get("a3_increase_cnt", 0)
ad_a3_increase_cnt = api_data.get("ad_a3_increase_cnt", 0)
natural_a3_increase_cnt = api_data.get("natural_a3_increase_cnt", 0)
api_cost = api_data.get("cost", 0)
except Exception as e:
logger.warning(f"API failed for {video.item_id}: {e}")
a3_increase_cnt = video.total_new_a3_cnt or 0
ad_a3_increase_cnt = video.heated_new_a3_cnt or 0
natural_a3_increase_cnt = video.natural_new_a3_cnt or 0
api_cost = video.total_cost or 0.0
# 数据库字段
estimated_video_cost = video.estimated_video_cost or 0.0
natural_play_cnt = video.natural_play_cnt or 0
total_play_cnt = video.total_play_cnt or 0
after_view_search_uv = video.after_view_search_uv or 0
# 计算成本指标
estimated_natural_search_uv = None
if total_play_cnt > 0 and after_view_search_uv > 0:
estimated_natural_search_uv = (natural_play_cnt / total_play_cnt) * after_view_search_uv
estimated_natural_cpm = round((estimated_video_cost / natural_play_cnt) * 1000, 2) if natural_play_cnt > 0 else None
estimated_cp_a3 = round(api_cost / a3_increase_cnt, 2) if a3_increase_cnt > 0 else None
estimated_natural_cp_a3 = round(estimated_video_cost / natural_a3_increase_cnt, 2) if natural_a3_increase_cnt > 0 else None
estimated_cp_search = round(api_cost / after_view_search_uv, 2) if after_view_search_uv > 0 else None
estimated_natural_cp_search = round(estimated_video_cost / estimated_natural_search_uv, 2) if estimated_natural_search_uv and estimated_natural_search_uv > 0 else None
brand_name = brand_map.get(video.brand_id, video.brand_id) if video.brand_id else ""
result.append({
"item_id": video.item_id,
"star_nickname": video.star_nickname or "",
"title": video.title or "",
"video_url": video.video_url or "",
"create_date": video.publish_time.isoformat() if video.publish_time else None,
"hot_type": video.viral_type or "",
"industry_id": video.industry_id or "",
"brand_id": video.brand_id or "",
"brand_name": brand_name,
"total_new_a3_cnt": a3_increase_cnt,
"heated_new_a3_cnt": ad_a3_increase_cnt,
"natural_new_a3_cnt": natural_a3_increase_cnt,
"estimated_natural_cpm": estimated_natural_cpm,
"estimated_cp_a3": estimated_cp_a3,
"estimated_natural_cp_a3": estimated_natural_cp_a3,
"estimated_cp_search": estimated_cp_search,
"estimated_natural_cp_search": estimated_natural_cp_search,
})
return result
+97 -58
View File
@@ -1,17 +1,26 @@
"""
巨量云图API封装 (T-023)
巨量云图API封装 (T-023, T-027)
封装GetContentMaterialAnalysisInfo接口调用,获取视频分析数据。
T-027 修复:
1. 日期格式: YYYYMMDD (不是 YYYY-MM-DD)
2. Cookie 头: 直接使用 auth_token 完整值
3. industry_id: 字符串格式 ["12"]
4. A3 指标: API 返回字符串,需转为整数
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from typing import Dict, Optional, Any, Union
import httpx
from app.config import settings
from app.services.session_pool import session_pool, get_session_with_retry
from app.services.session_pool import (
session_pool,
get_random_config,
)
logger = logging.getLogger(__name__)
@@ -38,11 +47,26 @@ class SessionInvalidError(YuntuAPIError):
pass
def _safe_int(value: Any, default: int = 0) -> int:
"""安全转换为整数,处理字符串类型的数字"""
if value is None:
return default
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return default
return default
async def call_yuntu_api(
item_id: str,
publish_time: datetime,
publish_time: Union[datetime, None],
industry_id: str,
session_id: Optional[str] = None,
aadvid: str,
auth_token: str,
) -> Dict[str, Any]:
"""
调用巨量云图GetContentMaterialAnalysisInfo接口。
@@ -50,8 +74,9 @@ async def call_yuntu_api(
Args:
item_id: 视频ID
publish_time: 发布时间
industry_id: 行业ID
session_id: 可选的sessionid,不提供则从池中获取
industry_id: 行业ID(字符串格式)
aadvid: 广告主IDURL参数)
auth_token: Cookie完整值(如 "sessionid=xxx"
Returns:
Dict: API响应数据
@@ -60,16 +85,16 @@ async def call_yuntu_api(
SessionInvalidError: SessionID失效时抛出
YuntuAPIError: API调用失败时抛出
"""
# 获取sessionid
if session_id is None:
session_id = await get_session_with_retry()
if session_id is None:
raise YuntuAPIError("Failed to get valid session")
# 处理 publish_time
if publish_time is None:
publish_time = datetime.now()
# 构造请求参数
# end_date = start_date + 30天
start_date = publish_time.strftime("%Y-%m-%d")
end_date = (publish_time + timedelta(days=30)).strftime("%Y-%m-%d")
# T-027: 日期格式必须为 YYYYMMDD
start_date = publish_time.strftime("%Y%m%d")
end_date = (publish_time + timedelta(days=30)).strftime("%Y%m%d")
# T-027: industry_id_list 为字符串数组
industry_id_list = [str(industry_id)] if industry_id else []
request_data = {
"is_my_video": "0",
@@ -79,27 +104,30 @@ async def call_yuntu_api(
"end_date": end_date,
"assist_type": 3,
"assist_video_type": 3,
"industry_id_list": [industry_id] if industry_id else [],
"industry_id_list": industry_id_list,
"trigger_point_id_list": TRIGGER_POINT_IDS,
}
# 构造请求头
# T-027: Cookie 直接使用 auth_token 完整值
headers = {
"Content-Type": "application/json",
"Cookie": f"sessionid={session_id}",
"Cookie": auth_token,
}
# URL 带 aadvid 参数
url = f"{YUNTU_BASE_URL}/yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo?aadvid={aadvid}"
try:
async with httpx.AsyncClient(timeout=settings.YUNTU_API_TIMEOUT) as client:
response = await client.post(
f"{YUNTU_BASE_URL}/yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo",
url,
json=request_data,
headers=headers,
)
# 检查SessionID是否失效
if response.status_code in (401, 403):
logger.warning(f"Session invalid: {session_id[:8]}...")
logger.warning(f"Session invalid: {auth_token[:20]}...")
raise SessionInvalidError(
f"Session invalid: {response.status_code}",
status_code=response.status_code,
@@ -114,9 +142,10 @@ async def call_yuntu_api(
data = response.json()
# 检查业务错误
if data.get("code") != 0:
error_msg = data.get("message", "Unknown error")
# 检查业务错误
status = data.get("status", data.get("code", 0))
if status != 0:
error_msg = data.get("msg", data.get("message", "Unknown error"))
raise YuntuAPIError(
f"API business error: {error_msg}",
status_code=response.status_code,
@@ -140,51 +169,59 @@ async def get_video_analysis(
max_retries: int = 3,
) -> Dict[str, Any]:
"""
获取视频分析数据,支持SessionID失效自动重试 (T-022)
获取视频分析数据(随机选取配置)
T-027: 改为随机选取任意一组 aadvid/auth_token,不按 brand_id 匹配。
Args:
item_id: 视频ID
publish_time: 发布时间
industry_id: 行业ID
industry_id: 行业ID(来自数据库中的视频)
max_retries: 最大重试次数
Returns:
Dict: 视频分析数据
Raises:
YuntuAPIError: 所有重试失败抛出
YuntuAPIError: API调用失败抛出
"""
last_error = None
for attempt in range(max_retries):
# 从池中获取sessionid
session_id = await get_session_with_retry()
if session_id is None:
last_error = YuntuAPIError("Failed to get valid session")
# T-027: 随机选取任意一组配置
config = await get_random_config()
if config is None:
last_error = YuntuAPIError("No config available in session pool")
logger.warning(f"No config available, attempt {attempt + 1}/{max_retries}")
continue
logger.info(
f"Using random config: aadvid={config['aadvid']}, attempt {attempt + 1}"
)
try:
result = await call_yuntu_api(
item_id=item_id,
publish_time=publish_time,
industry_id=industry_id,
session_id=session_id,
industry_id=industry_id, # T-027: 使用数据库中视频的 industry_id
aadvid=config["aadvid"],
auth_token=config["auth_token"],
)
return result
except SessionInvalidError:
# SessionID失效,从池中移除并重试
session_pool.remove(session_id)
# SessionID失效,从池中移除
session_pool.remove_by_auth_token(config["auth_token"])
logger.info(
f"Session invalid, retrying... attempt {attempt + 1}/{max_retries}"
f"Session invalid, attempt {attempt + 1}/{max_retries}"
)
last_error = SessionInvalidError("All sessions invalid")
last_error = SessionInvalidError("Session invalid after retries")
continue
except YuntuAPIError as e:
last_error = e
logger.error(f"Yuntu API error on attempt {attempt + 1}: {e.message}")
# 非SessionID问题,不再重试
# 非 session 错误不重试
break
raise last_error or YuntuAPIError("Unknown error after retries")
@@ -194,35 +231,37 @@ def parse_analysis_response(data: Dict[str, Any]) -> Dict[str, Any]:
"""
解析巨量云图API响应,提取关键指标。
T-027: A3 指标在 API 响应中是字符串类型,需要转为整数。
Args:
data: API原始响应数据
Returns:
Dict: 结构化的分析数据
"""
result_data = data.get("data", {})
result_data = data.get("data", {}) or {}
return {
# 触达指标
"total_show_cnt": result_data.get("total_show_cnt", 0), # 总曝光数
"natural_show_cnt": result_data.get("natural_show_cnt", 0), # 自然曝光数
"ad_show_cnt": result_data.get("ad_show_cnt", 0), # 加热曝光数
"total_play_cnt": result_data.get("total_play_cnt", 0), # 总播放数
"natural_play_cnt": result_data.get("natural_play_cnt", 0), # 自然播放数
"ad_play_cnt": result_data.get("ad_play_cnt", 0), # 加热播放数
"effective_play_cnt": result_data.get("effective_play_cnt", 0), # 有效播放数
# A3指标
"a3_increase_cnt": result_data.get("a3_increase_cnt", 0), # 新增A3
"ad_a3_increase_cnt": result_data.get("ad_a3_increase_cnt", 0), # 加热新增A3
"natural_a3_increase_cnt": result_data.get("natural_a3_increase_cnt", 0), # 自然新增A3
"total_show_cnt": _safe_int(result_data.get("total_show_cnt")),
"natural_show_cnt": _safe_int(result_data.get("natural_show_cnt")),
"ad_show_cnt": _safe_int(result_data.get("ad_show_cnt")),
"total_play_cnt": _safe_int(result_data.get("total_play_cnt")),
"natural_play_cnt": _safe_int(result_data.get("natural_play_cnt")),
"ad_play_cnt": _safe_int(result_data.get("ad_play_cnt")),
"effective_play_cnt": _safe_int(result_data.get("effective_play_cnt")),
# A3指标 - T-027: 转为整数
"a3_increase_cnt": _safe_int(result_data.get("a3_increase_cnt")),
"ad_a3_increase_cnt": _safe_int(result_data.get("ad_a3_increase_cnt")),
"natural_a3_increase_cnt": _safe_int(result_data.get("natural_a3_increase_cnt")),
# 搜索指标
"after_view_search_uv": result_data.get("after_view_search_uv", 0), # 看后搜人数
"after_view_search_pv": result_data.get("after_view_search_pv", 0), # 看后搜次数
"brand_search_uv": result_data.get("brand_search_uv", 0), # 品牌搜索人数
"product_search_uv": result_data.get("product_search_uv", 0), # 商品搜索人数
"return_search_cnt": result_data.get("return_search_cnt", 0), # 回搜次数
"after_view_search_uv": _safe_int(result_data.get("after_view_search_uv")),
"after_view_search_pv": _safe_int(result_data.get("after_view_search_pv")),
"brand_search_uv": _safe_int(result_data.get("brand_search_uv")),
"product_search_uv": _safe_int(result_data.get("product_search_uv")),
"return_search_cnt": _safe_int(result_data.get("return_search_cnt")),
# 费用指标
"cost": result_data.get("cost", 0), # 总花费
"natural_cost": result_data.get("natural_cost", 0), # 自然花费
"ad_cost": result_data.get("ad_cost", 0), # 加热花费
"cost": _safe_int(result_data.get("cost")),
"natural_cost": _safe_int(result_data.get("natural_cost")),
"ad_cost": _safe_int(result_data.get("ad_cost")),
}