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:
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 广告主ID(URL参数)
|
||||
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")),
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user