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
+124 -4
View File
@@ -1,26 +1,55 @@
"""
视频分析API路由 (T-024)
GET /api/v1/videos/{item_id}/analysis
GET /api/v1/videos/{item_id}/analysis - 单个视频分析
POST /api/v1/videos/search - 搜索视频列表(支持 star_id / nickname
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.video_analysis import get_video_analysis_data
from app.services.video_analysis import (
get_video_analysis_data,
get_video_base_info,
search_videos_by_star_id,
search_videos_by_unique_id,
search_videos_by_nickname,
get_video_list_with_a3,
)
from app.services.yuntu_api import YuntuAPIError
router = APIRouter(prefix="/videos", tags=["视频分析"])
class SearchRequest(BaseModel):
"""搜索请求"""
type: str # "star_id" | "unique_id" | "nickname"
value: str
class VideoListItem(BaseModel):
"""视频列表项"""
item_id: str
title: str
star_nickname: str
star_unique_id: str
create_date: Optional[str]
hot_type: str
total_play_cnt: int
total_new_a3_cnt: int
total_cost: float
@router.get("/{item_id}/analysis")
async def get_video_analysis(
item_id: str,
db: AsyncSession = Depends(get_db),
):
"""
获取视频分析数据。
获取单个视频分析数据。
返回6大类指标:
- 基础信息 (8字段)
@@ -53,3 +82,94 @@ async def get_video_analysis(
raise HTTPException(status_code=500, detail=f"API Error: {e.message}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
@router.post("/search")
async def search_videos(
request: SearchRequest,
db: AsyncSession = Depends(get_db),
):
"""
搜索视频列表。
支持三种搜索方式(均返回列表,点击详情查看完整数据):
- star_id: 星图ID精准匹配
- unique_id: 达人unique_id精准匹配
- nickname: 达人昵称模糊匹配
Args:
request: 搜索请求,包含 type 和 value
Returns:
视频列表(含A3数据和成本指标)
"""
try:
if request.type == "star_id":
# 星图ID查询,返回视频列表
videos = await search_videos_by_star_id(db, request.value)
if not videos:
return {
"success": True,
"type": "list",
"data": [],
"total": 0,
}
# 获取 A3 数据
result = await get_video_list_with_a3(db, videos)
return {
"success": True,
"type": "list",
"data": result,
"total": len(result),
}
elif request.type == "unique_id":
# 达人unique_id查询,返回视频列表
videos = await search_videos_by_unique_id(db, request.value)
if not videos:
return {
"success": True,
"type": "list",
"data": [],
"total": 0,
}
# 获取 A3 数据
result = await get_video_list_with_a3(db, videos)
return {
"success": True,
"type": "list",
"data": result,
"total": len(result),
}
elif request.type == "nickname":
# 昵称模糊查询,返回视频列表
videos = await search_videos_by_nickname(db, request.value)
if not videos:
return {
"success": True,
"type": "list",
"data": [],
"total": 0,
}
# 获取 A3 数据
result = await get_video_list_with_a3(db, videos)
return {
"success": True,
"type": "list",
"data": result,
"total": len(result),
}
else:
raise HTTPException(status_code=400, detail=f"Invalid search type: {request.type}")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except YuntuAPIError as e:
raise HTTPException(status_code=500, detail=f"API Error: {e.message}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
+1
View File
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
# Yuntu API (for SessionID pool)
YUNTU_API_TOKEN: str = "" # Bearer Token for Yuntu Cookie API
YUNTU_AADVID: str = "1648829117232140" # 广告主ID,用于巨量云图API调用
# API Settings
MAX_QUERY_LIMIT: int = 1000
+90 -23
View File
@@ -1,52 +1,119 @@
from sqlalchemy import Column, String, Integer, Float, DateTime, Index
from sqlalchemy import Column, String, Integer, Float, DateTime, BigInteger, Boolean, Date, Text
from sqlalchemy.dialects.postgresql import JSONB
from app.database import Base
class KolVideo(Base):
"""KOL 视频数据模型."""
"""KOL 视频数据模型 - 映射真实数据库表 yuntu_industry_kol_records."""
__tablename__ = "kol_videos"
__tablename__ = "yuntu_industry_kol_records"
# 主键
item_id = Column(String, primary_key=True)
# 基础信息
title = Column(String, nullable=True)
viral_type = Column(String, nullable=True)
video_url = Column(String, nullable=True)
video_url = Column(Text, nullable=True)
vid = Column(String, nullable=True)
video_duration = Column(Float, nullable=True)
create_date = Column(Date, nullable=True)
data_date = Column(Date, nullable=True)
# 达人信息
star_id = Column(String, nullable=False)
star_unique_id = Column(String, nullable=False)
star_nickname = Column(String, nullable=False)
publish_time = Column(DateTime, nullable=True)
star_uid = Column(String, nullable=True)
star_fans_cnt = Column(BigInteger, nullable=True)
star_mcn = Column(String, nullable=True)
# 热度类型
hot_type = Column(String, nullable=True) # 映射为 viral_type
is_hot = Column(Boolean, nullable=True)
has_cart = Column(Boolean, nullable=True)
# 曝光指标
natural_play_cnt = Column(Integer, default=0)
heated_play_cnt = Column(Integer, default=0)
total_play_cnt = Column(Integer, default=0)
natural_play_cnt = Column(BigInteger, default=0)
heated_play_cnt = Column(BigInteger, default=0)
total_play_cnt = Column(BigInteger, default=0)
# 互动指标
total_interact = Column(Integer, default=0)
like_cnt = Column(Integer, default=0)
share_cnt = Column(Integer, default=0)
comment_cnt = Column(Integer, default=0)
total_interaction_cnt = Column(BigInteger, default=0) # 映射为 total_interact
natural_interaction_cnt = Column(BigInteger, default=0)
heated_interaction_cnt = Column(BigInteger, default=0)
digg_cnt = Column(BigInteger, default=0) # 映射为 like_cnt
share_cnt = Column(BigInteger, default=0)
comment_cnt = Column(BigInteger, default=0)
play_over_cnt = Column(BigInteger, default=0)
play_over_rate = Column(Float, nullable=True)
# 效果指标
# 搜索效果指标
back_search_cnt = Column(BigInteger, default=0) # 映射为 return_search_cnt
back_search_uv = Column(BigInteger, default=0)
after_view_search_cnt = Column(BigInteger, default=0)
after_view_search_uv = Column(BigInteger, default=0)
after_view_search_rate = Column(Float, nullable=True)
# A3 指标
new_a3_rate = Column(Float, nullable=True)
after_view_search_uv = Column(Integer, default=0)
return_search_cnt = Column(Integer, default=0)
total_new_a3_cnt = Column(BigInteger, default=0)
natural_new_a3_cnt = Column(BigInteger, default=0)
heated_new_a3_cnt = Column(BigInteger, default=0)
# 成本指标
total_cost = Column(Float, nullable=True)
heated_cost = Column(Float, nullable=True)
star_task_cost = Column(Float, nullable=True)
search_cost = Column(Float, nullable=True)
ad_hot_roi = Column(Float, nullable=True)
estimated_video_cost = Column(Float, default=0)
price_under_20s = Column(BigInteger, nullable=True)
price_20_60s = Column(BigInteger, nullable=True)
price_over_60s = Column(BigInteger, nullable=True)
# 商业信息
industry_id = Column(String, nullable=True)
industry_name = Column(String, nullable=True)
brand_id = Column(String, nullable=True)
estimated_video_cost = Column(Float, default=0)
order_id = Column(String, nullable=True)
# 索引定义
__table_args__ = (
Index("idx_star_id", "star_id"),
Index("idx_star_unique_id", "star_unique_id"),
Index("idx_star_nickname", "star_nickname"),
)
# JSON 字段
content_type = Column(JSONB, nullable=True)
industry_tags = Column(JSONB, nullable=True)
ad_hot_type = Column(JSONB, nullable=True)
trend = Column(JSONB, nullable=True)
trend_daily = Column(JSONB, nullable=True)
trend_total = Column(JSONB, nullable=True)
component_metric_list = Column(JSONB, nullable=True)
key_word_after_search_infos = Column(JSONB, nullable=True)
index_map = Column(JSONB, nullable=True)
search_keywords = Column(JSONB, nullable=True)
keywords = Column(JSONB, nullable=True)
# 时间戳
created_at = Column(DateTime, nullable=True)
updated_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<KolVideo(item_id={self.item_id}, title={self.title})>"
# 兼容属性 - 映射旧字段名到新字段名
@property
def viral_type(self):
return self.hot_type
@property
def total_interact(self):
return self.total_interaction_cnt
@property
def like_cnt(self):
return self.digg_cnt
@property
def return_search_cnt(self):
return self.back_search_cnt
@property
def publish_time(self):
return self.create_date
+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")),
}
+311 -52
View File
@@ -1,5 +1,10 @@
"""
Tests for SessionID Pool Service (T-021, T-022)
Tests for SessionID Pool Service (T-021, T-022, T-027)
T-027 更新:
- 改为 CookieConfig 数据结构
- get_random_config() 随机选取配置
- remove_by_auth_token() 移除失效配置
"""
import pytest
@@ -8,8 +13,10 @@ import httpx
from app.services.session_pool import (
SessionPool,
CookieConfig,
session_pool,
get_session_with_retry,
get_random_config,
)
@@ -17,16 +24,27 @@ class TestSessionPool:
"""Tests for SessionPool class."""
async def test_refresh_success(self):
"""Test successful session pool refresh."""
"""Test successful session pool refresh (T-027 format)."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"sessionid": "session_001", "user": "test1"},
{"sessionid": "session_002", "user": "test2"},
{"sessionid": "session_003", "user": "test3"},
{
"brand_id": "533661",
"aadvid": "1648829117232140",
"auth_token": "sessionid=session_001",
"industry_id": 20,
"brand_name": "Brand1",
},
{
"brand_id": "10186612",
"aadvid": "9876543210",
"auth_token": "sessionid=session_002",
"industry_id": 30,
"brand_name": "Brand2",
},
]
}
@@ -39,9 +57,38 @@ class TestSessionPool:
result = await pool.refresh()
assert result is True
assert pool.size == 3
assert pool.size == 2
assert not pool.is_empty
async def test_refresh_with_sessionid_cookie_field(self):
"""Test refresh using sessionid_cookie field (fallback)."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{
"brand_id": "533661",
"aadvid": "1648829117232140",
"sessionid_cookie": "sessionid=session_001",
"industry_id": 20,
"brand_name": "Brand1",
},
]
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
with patch("httpx.AsyncClient", return_value=mock_client):
result = await pool.refresh()
assert result is True
assert pool.size == 1
async def test_refresh_empty_data(self):
"""Test refresh with empty data array."""
pool = SessionPool()
@@ -126,7 +173,17 @@ class TestSessionPool:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"sessionid": "test"}]}
mock_response.json.return_value = {
"data": [
{
"brand_id": "123",
"aadvid": "456",
"auth_token": "sessionid=test",
"industry_id": 20,
"brand_name": "Test",
}
]
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
@@ -146,40 +203,131 @@ class TestSessionPool:
assert "headers" in call_args.kwargs
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token"
def test_get_random_from_pool(self):
"""Test getting random session from pool."""
def test_get_random_config_from_pool(self):
"""Test getting random config from pool (T-027)."""
pool = SessionPool()
pool._sessions = ["session_1", "session_2", "session_3"]
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
CookieConfig(
brand_id="10186612",
aadvid="9876543210",
auth_token="sessionid=session_2",
industry_id=30,
brand_name="Brand2",
),
]
config = pool.get_random_config()
assert config is not None
assert "aadvid" in config
assert "auth_token" in config
assert config["auth_token"] in ["sessionid=session_1", "sessionid=session_2"]
def test_get_random_config_from_empty_pool(self):
"""Test getting random config from empty pool."""
pool = SessionPool()
config = pool.get_random_config()
assert config is None
def test_get_random_from_pool_compat(self):
"""Test get_random compatibility method."""
pool = SessionPool()
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
]
session = pool.get_random()
assert session in pool._sessions
assert session == "session_1"
def test_get_random_from_empty_pool(self):
"""Test getting random session from empty pool."""
def test_get_random_from_empty_pool_compat(self):
"""Test get_random from empty pool."""
pool = SessionPool()
session = pool.get_random()
assert session is None
def test_remove_session(self):
"""Test removing a session from pool."""
def test_remove_by_auth_token(self):
"""Test removing config by auth_token (T-027)."""
pool = SessionPool()
pool._sessions = ["session_1", "session_2", "session_3"]
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
CookieConfig(
brand_id="10186612",
aadvid="9876543210",
auth_token="sessionid=session_2",
industry_id=30,
brand_name="Brand2",
),
]
pool.remove("session_2")
pool.remove_by_auth_token("sessionid=session_1")
assert pool.size == 2
assert "session_2" not in pool._sessions
assert pool.size == 1
config = pool.get_random_config()
assert config["auth_token"] == "sessionid=session_2"
def test_remove_session_compat(self):
"""Test remove compatibility method."""
pool = SessionPool()
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
CookieConfig(
brand_id="10186612",
aadvid="9876543210",
auth_token="sessionid=session_2",
industry_id=30,
brand_name="Brand2",
),
]
pool.remove("session_1")
assert pool.size == 1
def test_remove_nonexistent_session(self):
"""Test removing a session that doesn't exist."""
pool = SessionPool()
pool._sessions = ["session_1"]
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
]
# Should not raise
pool.remove("nonexistent")
pool.remove_by_auth_token("nonexistent")
assert pool.size == 1
@@ -188,7 +336,22 @@ class TestSessionPool:
pool = SessionPool()
assert pool.size == 0
pool._sessions = ["a", "b"]
pool._configs = [
CookieConfig(
brand_id="123",
aadvid="456",
auth_token="sessionid=a",
industry_id=20,
brand_name="A",
),
CookieConfig(
brand_id="789",
aadvid="012",
auth_token="sessionid=b",
industry_id=30,
brand_name="B",
),
]
assert pool.size == 2
def test_is_empty_property(self):
@@ -196,29 +359,117 @@ class TestSessionPool:
pool = SessionPool()
assert pool.is_empty is True
pool._sessions = ["a"]
pool._configs = [
CookieConfig(
brand_id="123",
aadvid="456",
auth_token="sessionid=a",
industry_id=20,
brand_name="A",
),
]
assert pool.is_empty is False
class TestGetRandomConfig:
"""Tests for get_random_config function (T-027)."""
async def test_get_config_success(self):
"""Test successful config retrieval."""
pool = SessionPool()
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
]
with patch("app.services.session_pool.session_pool", pool):
result = await get_random_config()
assert result is not None
assert result["aadvid"] == "1648829117232140"
assert result["auth_token"] == "sessionid=session_1"
async def test_get_config_refresh_on_empty(self):
"""Test that pool is refreshed when empty."""
pool = SessionPool()
with patch("app.services.session_pool.session_pool", pool):
with patch.object(pool, "refresh") as mock_refresh:
async def refresh_side_effect():
pool._configs = [
CookieConfig(
brand_id="123",
aadvid="456",
auth_token="sessionid=new_session",
industry_id=20,
brand_name="New",
),
]
return True
mock_refresh.side_effect = refresh_side_effect
result = await get_random_config()
assert mock_refresh.called
assert result["auth_token"] == "sessionid=new_session"
async def test_get_config_retry_on_refresh_failure(self):
"""Test retry behavior when refresh fails."""
pool = SessionPool()
with patch("app.services.session_pool.session_pool", pool):
with patch.object(pool, "refresh") as mock_refresh:
mock_refresh.return_value = False
result = await get_random_config(max_retries=3)
assert result is None
assert mock_refresh.call_count == 3
class TestGetSessionWithRetry:
"""Tests for get_session_with_retry function (T-022)."""
"""Tests for get_session_with_retry function (T-022 compat)."""
async def test_get_session_success(self):
"""Test successful session retrieval."""
with patch.object(session_pool, "_sessions", ["session_1", "session_2"]):
pool = SessionPool()
pool._configs = [
CookieConfig(
brand_id="533661",
aadvid="1648829117232140",
auth_token="sessionid=session_1",
industry_id=20,
brand_name="Brand1",
),
]
with patch("app.services.session_pool.session_pool", pool):
result = await get_session_with_retry()
assert result in ["session_1", "session_2"]
assert result == "session_1"
async def test_get_session_refresh_on_empty(self):
"""Test that pool is refreshed when empty."""
with patch.object(session_pool, "_sessions", []):
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = True
pool = SessionPool()
# After refresh, pool should have sessions
with patch("app.services.session_pool.session_pool", pool):
with patch.object(pool, "refresh") as mock_refresh:
async def refresh_side_effect():
session_pool._sessions.append("new_session")
pool._configs = [
CookieConfig(
brand_id="123",
aadvid="456",
auth_token="sessionid=new_session",
industry_id=20,
brand_name="New",
),
]
return True
mock_refresh.side_effect = refresh_side_effect
@@ -230,55 +481,65 @@ class TestGetSessionWithRetry:
async def test_get_session_retry_on_refresh_failure(self):
"""Test retry behavior when refresh fails."""
original_sessions = session_pool._sessions.copy()
pool = SessionPool()
try:
session_pool._sessions = []
with patch.object(session_pool, "refresh") as mock_refresh:
with patch("app.services.session_pool.session_pool", pool):
with patch.object(pool, "refresh") as mock_refresh:
mock_refresh.return_value = False
result = await get_session_with_retry(max_retries=3)
assert result is None
assert mock_refresh.call_count == 3
finally:
session_pool._sessions = original_sessions
async def test_get_session_max_retries(self):
"""Test max retries limit."""
original_sessions = session_pool._sessions.copy()
pool = SessionPool()
try:
session_pool._sessions = []
with patch.object(session_pool, "refresh") as mock_refresh:
with patch("app.services.session_pool.session_pool", pool):
with patch.object(pool, "refresh") as mock_refresh:
mock_refresh.return_value = False
result = await get_session_with_retry(max_retries=5)
assert result is None
assert mock_refresh.call_count == 5
finally:
session_pool._sessions = original_sessions
class TestSessionPoolIntegration:
"""Integration tests for session pool."""
async def test_refresh_filters_invalid_items(self):
"""Test that refresh filters out invalid items."""
"""Test that refresh filters out invalid items (T-027 format)."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"sessionid": "valid_session"},
{"no_sessionid": "missing"},
{
"brand_id": "533661",
"aadvid": "1648829117232140",
"auth_token": "sessionid=valid_session",
"industry_id": 20,
"brand_name": "Valid1",
},
{"no_auth_token": "missing"},
None,
{"sessionid": ""}, # Empty string should be filtered
{"sessionid": "another_valid"},
{
"brand_id": "10186612",
"aadvid": "", # Empty aadvid should be filtered
"auth_token": "sessionid=xxx",
"industry_id": 30,
"brand_name": "Invalid",
},
{
"brand_id": "789012",
"aadvid": "9876543210",
"auth_token": "sessionid=another_valid",
"industry_id": 40,
"brand_name": "Valid2",
},
]
}
@@ -292,8 +553,6 @@ class TestSessionPoolIntegration:
assert result is True
assert pool.size == 2
assert "valid_session" in pool._sessions
assert "another_valid" in pool._sessions
async def test_refresh_handles_non_dict_data(self):
"""Test refresh with non-dict response."""
+11
View File
@@ -195,6 +195,13 @@ class TestGetVideoAnalysisData:
result = await get_video_analysis_data(mock_session, "video_123")
# T-027: 验证使用 industry_id 而不是 brand_id 调用 API
mock_api.assert_called_once_with(
item_id="video_123",
publish_time=datetime(2025, 1, 15),
industry_id="20",
)
# 验证基础信息
assert result["base_info"]["item_id"] == "video_123"
assert result["base_info"]["title"] == "测试视频"
@@ -249,6 +256,10 @@ class TestGetVideoAnalysisData:
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
mock_video.total_new_a3_cnt = 500
mock_video.heated_new_a3_cnt = 100
mock_video.natural_new_a3_cnt = 400
mock_video.total_cost = 10000
# Mock session
mock_session = AsyncMock()
+93 -95
View File
@@ -1,5 +1,10 @@
"""
Tests for Yuntu API Service (T-023)
Tests for Yuntu API Service (T-023, T-027)
T-027 更新:
- call_yuntu_api 参数改为 auth_token(完整 cookie 值)
- 日期格式改为 YYYYMMDD
- industry_id 改为字符串
"""
import pytest
@@ -24,11 +29,11 @@ class TestCallYuntuAPI:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "success",
"status": 0,
"msg": "ok",
"data": {
"total_show_cnt": 100000,
"a3_increase_cnt": 500,
"a3_increase_cnt": "500",
},
}
@@ -42,17 +47,18 @@ class TestCallYuntuAPI:
item_id="test_item_123",
publish_time=datetime(2025, 1, 1),
industry_id="20",
session_id="test_session",
aadvid="1648829117232140",
auth_token="sessionid=test_session",
)
assert result["code"] == 0
assert result["status"] == 0
assert result["data"]["total_show_cnt"] == 100000
async def test_call_with_correct_parameters(self):
"""Test that API is called with correct parameters."""
"""Test that API is called with correct parameters (T-027 format)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "data": {}}
mock_response.json.return_value = {"status": 0, "data": {}}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
@@ -64,26 +70,27 @@ class TestCallYuntuAPI:
item_id="video_001",
publish_time=datetime(2025, 1, 15),
industry_id="30",
session_id="session_abc",
aadvid="1648829117232140",
auth_token="sessionid=session_abc",
)
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# 验证URL
# 验证URL包含aadvid
assert "GetContentMaterialAnalysisInfo" in call_args.args[0]
assert "aadvid=1648829117232140" in call_args.args[0]
# 验证请求体
# 验证请求体 - T-027: 日期格式 YYYYMMDD
json_data = call_args.kwargs["json"]
assert json_data["object_id"] == "video_001"
assert json_data["start_date"] == "2025-01-15"
assert json_data["end_date"] == "2025-02-14" # +30天
assert json_data["industry_id_list"] == ["30"]
assert json_data["start_date"] == "20250115" # YYYYMMDD
assert json_data["end_date"] == "20250214" # +30天
assert json_data["industry_id_list"] == ["30"] # 字符串数组
# 验证headers包含sessionid
# 验证headers - T-027: 直接使用 auth_token
headers = call_args.kwargs["headers"]
assert "Cookie" in headers
assert "sessionid=session_abc" in headers["Cookie"]
assert headers["Cookie"] == "sessionid=session_abc"
async def test_call_session_invalid_401(self):
"""Test handling of 401 response (session invalid)."""
@@ -101,7 +108,8 @@ class TestCallYuntuAPI:
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="invalid_session",
aadvid="123",
auth_token="sessionid=invalid_session",
)
assert exc_info.value.status_code == 401
@@ -122,7 +130,8 @@ class TestCallYuntuAPI:
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="invalid_session",
aadvid="123",
auth_token="sessionid=invalid_session",
)
async def test_call_api_error_500(self):
@@ -142,18 +151,19 @@ class TestCallYuntuAPI:
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
aadvid="123",
auth_token="sessionid=session",
)
assert exc_info.value.status_code == 500
async def test_call_business_error(self):
"""Test handling of business error (code != 0)."""
"""Test handling of business error (status != 0)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 1001,
"message": "Invalid parameter",
"status": 1001,
"msg": "Invalid parameter",
}
mock_client = AsyncMock()
@@ -167,7 +177,8 @@ class TestCallYuntuAPI:
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
aadvid="123",
auth_token="sessionid=session",
)
assert "Invalid parameter" in exc_info.value.message
@@ -185,7 +196,8 @@ class TestCallYuntuAPI:
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
aadvid="123",
auth_token="sessionid=session",
)
assert "timeout" in exc_info.value.message.lower()
@@ -203,62 +215,24 @@ class TestCallYuntuAPI:
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
aadvid="123",
auth_token="sessionid=session",
)
async def test_call_without_session_id(self):
"""Test API call without providing session_id (gets from pool)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "data": {}}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
with patch("httpx.AsyncClient", return_value=mock_client):
with patch(
"app.services.yuntu_api.get_session_with_retry"
) as mock_get_session:
mock_get_session.return_value = "pool_session"
result = await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
)
assert result["code"] == 0
mock_get_session.assert_called_once()
async def test_call_no_session_available(self):
"""Test API call when no session is available."""
with patch(
"app.services.yuntu_api.get_session_with_retry"
) as mock_get_session:
mock_get_session.return_value = None
with pytest.raises(YuntuAPIError) as exc_info:
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
)
assert "session" in exc_info.value.message.lower()
class TestGetVideoAnalysis:
"""Tests for get_video_analysis function with retry logic (T-022)."""
"""Tests for get_video_analysis function with retry logic (T-022, T-027)."""
async def test_success_first_try(self):
"""Test successful call on first attempt."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session:
mock_session.return_value = "valid_session"
with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_config.return_value = {
"aadvid": "123",
"auth_token": "sessionid=valid_session",
}
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
mock_call.return_value = {"code": 0, "data": {"a3_increase_cnt": 100}}
mock_call.return_value = {"status": 0, "data": {"a3_increase_cnt": "100"}}
result = await get_video_analysis(
item_id="test",
@@ -266,20 +240,24 @@ class TestGetVideoAnalysis:
industry_id="20",
)
assert result["data"]["a3_increase_cnt"] == 100
assert result["data"]["a3_increase_cnt"] == "100"
assert mock_call.call_count == 1
async def test_retry_on_session_invalid(self):
"""Test retry when session is invalid."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session:
mock_session.side_effect = ["session_1", "session_2", "session_3"]
with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_config.side_effect = [
{"aadvid": "123", "auth_token": "sessionid=session_1"},
{"aadvid": "456", "auth_token": "sessionid=session_2"},
{"aadvid": "789", "auth_token": "sessionid=session_3"},
]
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
# 前两次失败,第三次成功
mock_call.side_effect = [
SessionInvalidError("Invalid"),
SessionInvalidError("Invalid"),
{"code": 0, "data": {}},
{"status": 0, "data": {}},
]
with patch("app.services.yuntu_api.session_pool") as mock_pool:
@@ -290,15 +268,15 @@ class TestGetVideoAnalysis:
max_retries=3,
)
assert result["code"] == 0
assert result["status"] == 0
assert mock_call.call_count == 3
# 验证失效的session被移除
assert mock_pool.remove.call_count == 2
assert mock_pool.remove_by_auth_token.call_count == 2
async def test_max_retries_exceeded(self):
"""Test that error is raised after max retries."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session:
mock_session.return_value = "session"
with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=session"}
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
mock_call.side_effect = SessionInvalidError("Invalid")
@@ -316,8 +294,8 @@ class TestGetVideoAnalysis:
async def test_no_retry_on_api_error(self):
"""Test that non-session errors don't trigger retry."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session:
mock_session.return_value = "session"
with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=session"}
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
mock_call.side_effect = YuntuAPIError("Server error", status_code=500)
@@ -332,10 +310,10 @@ class TestGetVideoAnalysis:
assert mock_call.call_count == 1
assert exc_info.value.status_code == 500
async def test_no_session_available(self):
"""Test error when no session is available."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session:
mock_session.return_value = None
async def test_no_config_available(self):
"""Test error when no config is available."""
with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_config.return_value = None
with pytest.raises(YuntuAPIError):
await get_video_analysis(
@@ -349,7 +327,7 @@ class TestParseAnalysisResponse:
"""Tests for parse_analysis_response function."""
def test_parse_complete_response(self):
"""Test parsing complete response data."""
"""Test parsing complete response data (T-027: handles string values)."""
response = {
"data": {
"total_show_cnt": 100000,
@@ -359,17 +337,17 @@ class TestParseAnalysisResponse:
"natural_play_cnt": 40000,
"ad_play_cnt": 10000,
"effective_play_cnt": 30000,
"a3_increase_cnt": 500,
"ad_a3_increase_cnt": 100,
"natural_a3_increase_cnt": 400,
"a3_increase_cnt": "500", # 字符串
"ad_a3_increase_cnt": "100",
"natural_a3_increase_cnt": "400",
"after_view_search_uv": 1000,
"after_view_search_pv": 1500,
"brand_search_uv": 200,
"product_search_uv": 300,
"return_search_cnt": 50,
"cost": 10000.5,
"cost": 10000,
"natural_cost": 0,
"ad_cost": 10000.5,
"ad_cost": 10000,
}
}
@@ -377,9 +355,11 @@ class TestParseAnalysisResponse:
assert result["total_show_cnt"] == 100000
assert result["natural_show_cnt"] == 80000
assert result["a3_increase_cnt"] == 500
assert result["a3_increase_cnt"] == 500 # 转为整数
assert result["ad_a3_increase_cnt"] == 100
assert result["natural_a3_increase_cnt"] == 400
assert result["after_view_search_uv"] == 1000
assert result["cost"] == 10000.5
assert result["cost"] == 10000
def test_parse_empty_response(self):
"""Test parsing empty response."""
@@ -404,7 +384,7 @@ class TestParseAnalysisResponse:
response = {
"data": {
"total_show_cnt": 50000,
"a3_increase_cnt": 100,
"a3_increase_cnt": "100",
}
}
@@ -414,3 +394,21 @@ class TestParseAnalysisResponse:
assert result["a3_increase_cnt"] == 100
assert result["natural_show_cnt"] == 0 # Default value
assert result["cost"] == 0 # Default value
def test_parse_string_numbers(self):
"""Test parsing string numbers to int (T-027)."""
response = {
"data": {
"a3_increase_cnt": "1689071",
"ad_a3_increase_cnt": "36902",
"natural_a3_increase_cnt": "1652169",
"cost": 785000,
}
}
result = parse_analysis_response(response)
assert result["a3_increase_cnt"] == 1689071
assert result["ad_a3_increase_cnt"] == 36902
assert result["natural_a3_increase_cnt"] == 1652169
assert result["cost"] == 785000