feat(video-analysis): 完成视频分析模块迭代任务

Bug 修复:
- T-019: 修复品牌API响应解析,正确解析 data[0].brand_name
- T-020: 添加品牌API Bearer Token认证

视频分析功能:
- T-021: SessionID池服务,从内部API获取Cookie列表
- T-022: SessionID自动重试,失效时自动切换重试
- T-023: 巨量云图API封装,支持超时和错误处理
- T-024: 视频分析数据接口 GET /api/v1/videos/{item_id}/analysis
- T-025: 数据库A3指标更新
- T-026: 视频分析前端页面,展示6大类25+指标

测试覆盖率:
- brand_api.py: 100%
- session_pool.py: 100%
- yuntu_api.py: 100%
- video_analysis.py: 99%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zfc 2026-01-28 17:51:35 +08:00
parent cdc364cb2a
commit f123f68be3
17 changed files with 2259 additions and 23 deletions

View File

@ -6,3 +6,7 @@ CORS_ORIGINS=["http://localhost:3000"]
# 品牌 API 配置 # 品牌 API 配置
BRAND_API_BASE_URL=https://api.internal.intelligrow.cn BRAND_API_BASE_URL=https://api.internal.intelligrow.cn
BRAND_API_TOKEN=your_brand_api_token_here
# 云图 API 配置 (SessionID池服务)
YUNTU_API_TOKEN=your_yuntu_api_token_here

View File

@ -0,0 +1,55 @@
"""
视频分析API路由 (T-024)
GET /api/v1/videos/{item_id}/analysis
"""
from fastapi import APIRouter, Depends, HTTPException
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.yuntu_api import YuntuAPIError
router = APIRouter(prefix="/videos", tags=["视频分析"])
@router.get("/{item_id}/analysis")
async def get_video_analysis(
item_id: str,
db: AsyncSession = Depends(get_db),
):
"""
获取视频分析数据
返回6大类指标
- 基础信息 (8字段)
- 触达指标 (7字段)
- A3指标 (3字段)
- 搜索指标 (5字段)
- 费用指标 (3字段)
- 成本指标 (6字段计算得出)
Args:
item_id: 视频ID
Returns:
视频分析数据
Raises:
404: 视频不存在
500: API调用失败
"""
try:
result = await get_video_analysis_data(db, item_id)
return {
"success": True,
"data": result,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except YuntuAPIError as e:
# API失败但有降级数据时不抛错
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)}")

View File

@ -8,6 +8,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", # 忽略额外的环境变量
) )
# Database # Database
@ -18,11 +19,16 @@ class Settings(BaseSettings):
# Brand API # Brand API
BRAND_API_BASE_URL: str = "https://api.internal.intelligrow.cn" BRAND_API_BASE_URL: str = "https://api.internal.intelligrow.cn"
BRAND_API_TOKEN: str = "" # Bearer Token for Brand API authentication
# Yuntu API (for SessionID pool)
YUNTU_API_TOKEN: str = "" # Bearer Token for Yuntu Cookie API
# API Settings # API Settings
MAX_QUERY_LIMIT: int = 1000 MAX_QUERY_LIMIT: int = 1000
BRAND_API_TIMEOUT: float = 3.0 BRAND_API_TIMEOUT: float = 3.0
BRAND_API_CONCURRENCY: int = 10 BRAND_API_CONCURRENCY: int = 10
YUNTU_API_TIMEOUT: float = 10.0 # 巨量云图API超时
settings = Settings() settings = Settings()

View File

@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.api.v1 import query, export from app.api.v1 import query, export, video_analysis
app = FastAPI( app = FastAPI(
title="KOL Insight API", title="KOL Insight API",
@ -22,6 +22,7 @@ app.add_middleware(
# 注册 API 路由 # 注册 API 路由
app.include_router(query.router, prefix="/api/v1", tags=["Query"]) app.include_router(query.router, prefix="/api/v1", tags=["Query"])
app.include_router(export.router, prefix="/api/v1", tags=["Export"]) app.include_router(export.router, prefix="/api/v1", tags=["Export"])
app.include_router(video_analysis.router, prefix="/api/v1", tags=["VideoAnalysis"])
@app.get("/") @app.get("/")

View File

@ -24,17 +24,28 @@ async def fetch_brand_name(
""" """
async with semaphore: async with semaphore:
try: try:
# 构建请求头,包含 Bearer Token 认证 (T-020)
headers = {}
if settings.BRAND_API_TOKEN:
headers["Authorization"] = f"Bearer {settings.BRAND_API_TOKEN}"
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=settings.BRAND_API_TIMEOUT timeout=settings.BRAND_API_TIMEOUT
) as client: ) as client:
response = await client.get( response = await client.get(
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands/{brand_id}" f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands/{brand_id}",
headers=headers,
) )
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
# 尝试从响应中获取品牌名称 # T-019: 正确解析品牌API响应
# 响应格式: {"total": 1, "data": [{"brand_id": xxx, "brand_name": "xxx"}]}
if isinstance(data, dict): if isinstance(data, dict):
name = data.get("data", {}).get("name") or data.get("name") data_list = data.get("data", [])
if isinstance(data_list, list) and len(data_list) > 0:
first_item = data_list[0]
if isinstance(first_item, dict):
name = first_item.get("brand_name")
if name: if name:
return brand_id, name return brand_id, name
except httpx.TimeoutException: except httpx.TimeoutException:

View File

@ -0,0 +1,141 @@
"""
SessionID池服务 (T-021)
从内部API获取Cookie列表随机选取sessionid用于巨量云图API调用
"""
import asyncio
import random
import logging
from typing import List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class SessionPool:
"""SessionID池管理器"""
def __init__(self):
self._sessions: List[str] = []
self._lock = asyncio.Lock()
async def refresh(self) -> bool:
"""
从内部API刷新SessionID列表
Returns:
bool: 刷新是否成功
"""
async with self._lock:
try:
headers = {}
if settings.YUNTU_API_TOKEN:
headers["Authorization"] = f"Bearer {settings.YUNTU_API_TOKEN}"
async with httpx.AsyncClient(
timeout=settings.YUNTU_API_TIMEOUT
) as client:
response = await client.get(
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/get_cookie",
params={"page": 1, "page_size": 100},
headers=headers,
)
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")
]
logger.info(
f"SessionPool refreshed: {len(self._sessions)} sessions"
)
return len(self._sessions) > 0
logger.warning(
f"Failed to refresh session pool: status={response.status_code}"
)
return False
except httpx.TimeoutException:
logger.error("SessionPool refresh timeout")
return False
except httpx.RequestError as e:
logger.error(f"SessionPool refresh request error: {e}")
return False
except Exception as e:
logger.error(f"SessionPool refresh unexpected error: {e}")
return False
def get_random(self) -> Optional[str]:
"""
随机获取一个SessionID
Returns:
Optional[str]: SessionID池为空时返回None
"""
if not self._sessions:
return None
return random.choice(self._sessions)
def remove(self, session_id: str) -> None:
"""
从池中移除失效的SessionID
Args:
session_id: 要移除的SessionID
"""
try:
self._sessions.remove(session_id)
logger.info(f"Removed invalid session: {session_id[:8]}...")
except ValueError:
pass # 已经被移除
@property
def size(self) -> int:
"""返回池中SessionID数量"""
return len(self._sessions)
@property
def is_empty(self) -> bool:
"""检查池是否为空"""
return len(self._sessions) == 0
# 全局单例
session_pool = SessionPool()
async def get_session_with_retry(max_retries: int = 3) -> Optional[str]:
"""
获取SessionID必要时刷新池 (T-022 支持)
Args:
max_retries: 最大重试次数
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")
return None

View File

@ -0,0 +1,320 @@
"""
视频分析服务 (T-024)
实现视频分析数据获取和成本指标计算
"""
import logging
from datetime import datetime
from typing import Dict, Optional, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import update
from app.models.kol_video import KolVideo
from app.services.yuntu_api import (
get_video_analysis as fetch_yuntu_analysis,
parse_analysis_response,
YuntuAPIError,
)
logger = logging.getLogger(__name__)
def calculate_cost_metrics(
cost: float,
natural_play_cnt: int,
a3_increase_cnt: int,
natural_a3_increase_cnt: int,
after_view_search_uv: int,
total_play_cnt: int,
) -> Dict[str, Optional[float]]:
"""
计算成本指标
Args:
cost: 总花费
natural_play_cnt: 自然播放数
a3_increase_cnt: 新增A3
natural_a3_increase_cnt: 自然新增A3
after_view_search_uv: 看后搜人数
total_play_cnt: 总播放数
Returns:
Dict: 成本指标字典
"""
metrics = {}
# CPM = cost / total_play_cnt * 1000
if total_play_cnt and total_play_cnt > 0:
metrics["cpm"] = round(cost / total_play_cnt * 1000, 2)
else:
metrics["cpm"] = None
# 自然CPM = cost / natural_play_cnt * 1000
if natural_play_cnt and natural_play_cnt > 0:
metrics["natural_cpm"] = round(cost / natural_play_cnt * 1000, 2)
else:
metrics["natural_cpm"] = None
# CPA3 = cost / a3_increase_cnt
if a3_increase_cnt and a3_increase_cnt > 0:
metrics["cpa3"] = round(cost / a3_increase_cnt, 2)
else:
metrics["cpa3"] = None
# 自然CPA3 = cost / natural_a3_increase_cnt
if natural_a3_increase_cnt and natural_a3_increase_cnt > 0:
metrics["natural_cpa3"] = round(cost / natural_a3_increase_cnt, 2)
else:
metrics["natural_cpa3"] = None
# CPsearch = cost / after_view_search_uv
if after_view_search_uv and after_view_search_uv > 0:
metrics["cp_search"] = round(cost / after_view_search_uv, 2)
else:
metrics["cp_search"] = None
# 预估自然看后搜人数 = natural_play_cnt / total_play_cnt * after_view_search_uv
if total_play_cnt and total_play_cnt > 0 and after_view_search_uv:
estimated_natural_search_uv = (
natural_play_cnt / total_play_cnt * after_view_search_uv
)
metrics["estimated_natural_search_uv"] = round(estimated_natural_search_uv, 2)
# 自然CPsearch = cost / estimated_natural_search_uv
if estimated_natural_search_uv > 0:
metrics["natural_cp_search"] = round(cost / estimated_natural_search_uv, 2)
else:
metrics["natural_cp_search"] = None
else:
metrics["estimated_natural_search_uv"] = None
metrics["natural_cp_search"] = None
return metrics
async def get_video_base_info(
session: AsyncSession, item_id: str
) -> Optional[KolVideo]:
"""
从数据库获取视频基础信息
Args:
session: 数据库会话
item_id: 视频ID
Returns:
KolVideo or None
"""
stmt = select(KolVideo).where(KolVideo.item_id == item_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_video_analysis_data(
session: AsyncSession, item_id: str
) -> Dict[str, Any]:
"""
获取视频分析数据T-024主接口
包含
- 基础信息从数据库
- 触达指标从巨量云图API
- A3指标
- 搜索指标
- 费用指标
- 成本指标计算得出
Args:
session: 数据库会话
item_id: 视频ID
Returns:
Dict: 完整的视频分析数据
Raises:
ValueError: 视频不存在时抛出
YuntuAPIError: API调用失败时抛出
"""
# 1. 从数据库获取基础信息
video = await get_video_base_info(session, item_id)
if video is None:
raise ValueError(f"Video not found: {item_id}")
# 2. 构建基础信息
base_info = {
"item_id": video.item_id,
"title": video.title,
"video_url": video.video_url,
"star_id": video.star_id,
"star_unique_id": video.star_unique_id,
"star_nickname": video.star_nickname,
"publish_time": video.publish_time.isoformat() if video.publish_time else None,
"industry_name": video.industry_name,
}
# 3. 调用巨量云图API获取实时数据
try:
publish_time = video.publish_time or datetime.now()
industry_id = video.industry_id or ""
api_response = await fetch_yuntu_analysis(
item_id=item_id,
publish_time=publish_time,
industry_id=industry_id,
)
# 4. 解析API响应
analysis_data = parse_analysis_response(api_response)
except YuntuAPIError as e:
logger.error(f"Failed to get yuntu analysis for {item_id}: {e.message}")
# API失败时使用数据库中的数据
analysis_data = {
"total_show_cnt": video.total_play_cnt or 0,
"natural_show_cnt": video.natural_play_cnt or 0,
"ad_show_cnt": video.heated_play_cnt or 0,
"total_play_cnt": video.total_play_cnt or 0,
"natural_play_cnt": video.natural_play_cnt or 0,
"ad_play_cnt": video.heated_play_cnt or 0,
"effective_play_cnt": 0,
"a3_increase_cnt": 0,
"ad_a3_increase_cnt": 0,
"natural_a3_increase_cnt": 0,
"after_view_search_uv": video.after_view_search_uv or 0,
"after_view_search_pv": 0,
"brand_search_uv": 0,
"product_search_uv": 0,
"return_search_cnt": video.return_search_cnt or 0,
"cost": video.estimated_video_cost or 0,
"natural_cost": 0,
"ad_cost": 0,
}
# 5. 计算成本指标
cost = analysis_data.get("cost", 0) or (video.estimated_video_cost or 0)
cost_metrics = calculate_cost_metrics(
cost=cost,
natural_play_cnt=analysis_data.get("natural_play_cnt", 0),
a3_increase_cnt=analysis_data.get("a3_increase_cnt", 0),
natural_a3_increase_cnt=analysis_data.get("natural_a3_increase_cnt", 0),
after_view_search_uv=analysis_data.get("after_view_search_uv", 0),
total_play_cnt=analysis_data.get("total_play_cnt", 0),
)
# 6. 组装返回数据
return {
"base_info": base_info,
"reach_metrics": {
"total_show_cnt": analysis_data.get("total_show_cnt", 0),
"natural_show_cnt": analysis_data.get("natural_show_cnt", 0),
"ad_show_cnt": analysis_data.get("ad_show_cnt", 0),
"total_play_cnt": analysis_data.get("total_play_cnt", 0),
"natural_play_cnt": analysis_data.get("natural_play_cnt", 0),
"ad_play_cnt": analysis_data.get("ad_play_cnt", 0),
"effective_play_cnt": analysis_data.get("effective_play_cnt", 0),
},
"a3_metrics": {
"a3_increase_cnt": analysis_data.get("a3_increase_cnt", 0),
"ad_a3_increase_cnt": analysis_data.get("ad_a3_increase_cnt", 0),
"natural_a3_increase_cnt": analysis_data.get("natural_a3_increase_cnt", 0),
},
"search_metrics": {
"after_view_search_uv": analysis_data.get("after_view_search_uv", 0),
"after_view_search_pv": analysis_data.get("after_view_search_pv", 0),
"brand_search_uv": analysis_data.get("brand_search_uv", 0),
"product_search_uv": analysis_data.get("product_search_uv", 0),
"return_search_cnt": analysis_data.get("return_search_cnt", 0),
},
"cost_metrics_raw": {
"cost": analysis_data.get("cost", 0),
"natural_cost": analysis_data.get("natural_cost", 0),
"ad_cost": analysis_data.get("ad_cost", 0),
},
"cost_metrics_calculated": cost_metrics,
}
async def update_video_a3_metrics(
session: AsyncSession,
item_id: str,
total_new_a3_cnt: int,
heated_new_a3_cnt: int,
natural_new_a3_cnt: int,
total_cost: float,
) -> bool:
"""
更新数据库中的A3指标 (T-025)
Args:
session: 数据库会话
item_id: 视频ID
total_new_a3_cnt: 总新增A3
heated_new_a3_cnt: 加热新增A3
natural_new_a3_cnt: 自然新增A3
total_cost: 总花费
Returns:
bool: 更新是否成功
"""
try:
stmt = (
update(KolVideo)
.where(KolVideo.item_id == item_id)
.values(
total_new_a3_cnt=total_new_a3_cnt,
heated_new_a3_cnt=heated_new_a3_cnt,
natural_new_a3_cnt=natural_new_a3_cnt,
total_cost=total_cost,
)
)
result = await session.execute(stmt)
await session.commit()
if result.rowcount > 0:
logger.info(f"Updated A3 metrics for video {item_id}")
return True
else:
logger.warning(f"No video found to update: {item_id}")
return False
except Exception as e:
logger.error(f"Failed to update A3 metrics for {item_id}: {e}")
await session.rollback()
return False
async def get_and_update_video_analysis(
session: AsyncSession, item_id: str
) -> Dict[str, Any]:
"""
获取视频分析数据并更新数据库中的A3指标 (T-024 + T-025 组合)
Args:
session: 数据库会话
item_id: 视频ID
Returns:
Dict: 完整的视频分析数据
"""
# 获取分析数据
result = await get_video_analysis_data(session, item_id)
# 提取A3指标
a3_metrics = result.get("a3_metrics", {})
cost_raw = result.get("cost_metrics_raw", {})
# 更新数据库
await update_video_a3_metrics(
session=session,
item_id=item_id,
total_new_a3_cnt=a3_metrics.get("a3_increase_cnt", 0),
heated_new_a3_cnt=a3_metrics.get("ad_a3_increase_cnt", 0),
natural_new_a3_cnt=a3_metrics.get("natural_a3_increase_cnt", 0),
total_cost=cost_raw.get("cost", 0),
)
return result

View File

@ -0,0 +1,228 @@
"""
巨量云图API封装 (T-023)
封装GetContentMaterialAnalysisInfo接口调用获取视频分析数据
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
import httpx
from app.config import settings
from app.services.session_pool import session_pool, get_session_with_retry
logger = logging.getLogger(__name__)
# 巨量云图API基础URL
YUNTU_BASE_URL = "https://yuntu.oceanengine.com"
# 触发点ID列表固定值
TRIGGER_POINT_IDS = ["610000", "610300", "610301"]
class YuntuAPIError(Exception):
"""巨量云图API错误"""
def __init__(self, message: str, status_code: int = 0, response_data: Any = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class SessionInvalidError(YuntuAPIError):
"""SessionID失效错误"""
pass
async def call_yuntu_api(
item_id: str,
publish_time: datetime,
industry_id: str,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
调用巨量云图GetContentMaterialAnalysisInfo接口
Args:
item_id: 视频ID
publish_time: 发布时间
industry_id: 行业ID
session_id: 可选的sessionid不提供则从池中获取
Returns:
Dict: API响应数据
Raises:
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")
# 构造请求参数
# end_date = start_date + 30天
start_date = publish_time.strftime("%Y-%m-%d")
end_date = (publish_time + timedelta(days=30)).strftime("%Y-%m-%d")
request_data = {
"is_my_video": "0",
"object_id": item_id,
"object_type": 2,
"start_date": start_date,
"end_date": end_date,
"assist_type": 3,
"assist_video_type": 3,
"industry_id_list": [industry_id] if industry_id else [],
"trigger_point_id_list": TRIGGER_POINT_IDS,
}
# 构造请求头
headers = {
"Content-Type": "application/json",
"Cookie": f"sessionid={session_id}",
}
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",
json=request_data,
headers=headers,
)
# 检查SessionID是否失效
if response.status_code in (401, 403):
logger.warning(f"Session invalid: {session_id[:8]}...")
raise SessionInvalidError(
f"Session invalid: {response.status_code}",
status_code=response.status_code,
)
if response.status_code != 200:
raise YuntuAPIError(
f"API returned {response.status_code}",
status_code=response.status_code,
response_data=response.text,
)
data = response.json()
# 检查业务错误码
if data.get("code") != 0:
error_msg = data.get("message", "Unknown error")
raise YuntuAPIError(
f"API business error: {error_msg}",
status_code=response.status_code,
response_data=data,
)
return data
except httpx.TimeoutException:
logger.error(f"Yuntu API timeout for item_id: {item_id}")
raise YuntuAPIError("API request timeout")
except httpx.RequestError as e:
logger.error(f"Yuntu API request error: {e}")
raise YuntuAPIError(f"API request error: {e}")
async def get_video_analysis(
item_id: str,
publish_time: datetime,
industry_id: str,
max_retries: int = 3,
) -> Dict[str, Any]:
"""
获取视频分析数据支持SessionID失效自动重试 (T-022)
Args:
item_id: 视频ID
publish_time: 发布时间
industry_id: 行业ID
max_retries: 最大重试次数
Returns:
Dict: 视频分析数据
Raises:
YuntuAPIError: 所有重试失败后抛出
"""
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")
continue
try:
result = await call_yuntu_api(
item_id=item_id,
publish_time=publish_time,
industry_id=industry_id,
session_id=session_id,
)
return result
except SessionInvalidError:
# SessionID失效从池中移除并重试
session_pool.remove(session_id)
logger.info(
f"Session invalid, retrying... attempt {attempt + 1}/{max_retries}"
)
last_error = SessionInvalidError("All sessions invalid")
continue
except YuntuAPIError as e:
last_error = e
logger.error(f"Yuntu API error on attempt {attempt + 1}: {e.message}")
# 非SessionID问题不再重试
break
raise last_error or YuntuAPIError("Unknown error after retries")
def parse_analysis_response(data: Dict[str, Any]) -> Dict[str, Any]:
"""
解析巨量云图API响应提取关键指标
Args:
data: API原始响应数据
Returns:
Dict: 结构化的分析数据
"""
result_data = data.get("data", {})
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
# 搜索指标
"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), # 回搜次数
# 费用指标
"cost": result_data.get("cost", 0), # 总花费
"natural_cost": result_data.get("natural_cost", 0), # 自然花费
"ad_cost": result_data.get("ad_cost", 0), # 加热花费
}

View File

@ -116,11 +116,24 @@ class TestBrandAPI:
# 验证所有调用都完成了 # 验证所有调用都完成了
assert mock_fetch.call_count == 15 assert mock_fetch.call_count == 15
async def test_fetch_brand_name_200_with_nested_data(self): async def test_fetch_brand_name_200_with_array_data(self):
"""Test successful brand fetch with nested data structure.""" """Test successful brand fetch with array data structure (T-019 fix)."""
# 正确的API响应格式: data是数组从data[0].brand_name获取品牌名称
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = {"data": {"name": "嵌套品牌名"}} mock_response.json.return_value = {
"total": 1,
"last_updated": "2025-12-30T11:28:40.738185",
"has_more": 0,
"data": [
{
"industry_id": 20,
"industry_name": "母婴",
"brand_id": 533661,
"brand_name": "Giving/启初"
}
]
}
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.get.return_value = mock_response mock_client.get.return_value = mock_response
@ -129,16 +142,19 @@ class TestBrandAPI:
with patch("httpx.AsyncClient", return_value=mock_client): with patch("httpx.AsyncClient", return_value=mock_client):
semaphore = asyncio.Semaphore(10) semaphore = asyncio.Semaphore(10)
brand_id, brand_name = await fetch_brand_name("brand_nested", semaphore) brand_id, brand_name = await fetch_brand_name("533661", semaphore)
assert brand_id == "brand_nested" assert brand_id == "533661"
assert brand_name == "嵌套品牌名" assert brand_name == "Giving/启初"
async def test_fetch_brand_name_200_with_flat_data(self): async def test_fetch_brand_name_200_with_empty_data_array(self):
"""Test successful brand fetch with flat data structure.""" """Test brand fetch with 200 but empty data array (T-019 edge case)."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = {"name": "扁平品牌名"} mock_response.json.return_value = {
"total": 0,
"data": []
}
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.get.return_value = mock_response mock_client.get.return_value = mock_response
@ -147,16 +163,19 @@ class TestBrandAPI:
with patch("httpx.AsyncClient", return_value=mock_client): with patch("httpx.AsyncClient", return_value=mock_client):
semaphore = asyncio.Semaphore(10) semaphore = asyncio.Semaphore(10)
brand_id, brand_name = await fetch_brand_name("brand_flat", semaphore) brand_id, brand_name = await fetch_brand_name("unknown_brand", semaphore)
assert brand_id == "brand_flat" assert brand_id == "unknown_brand"
assert brand_name == "扁平品牌名" assert brand_name == "unknown_brand" # Fallback
async def test_fetch_brand_name_200_no_name(self): async def test_fetch_brand_name_200_no_brand_name_field(self):
"""Test brand fetch with 200 but no name in response.""" """Test brand fetch with 200 but no brand_name in data item."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = {"data": {"id": "123"}} # No name field mock_response.json.return_value = {
"total": 1,
"data": [{"brand_id": 123}] # No brand_name field
}
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.get.return_value = mock_response mock_client.get.return_value = mock_response
@ -170,6 +189,35 @@ class TestBrandAPI:
assert brand_id == "brand_no_name" assert brand_id == "brand_no_name"
assert brand_name == "brand_no_name" # Fallback assert brand_name == "brand_no_name" # Fallback
async def test_fetch_brand_name_with_auth_header(self):
"""Test that Authorization header is sent (T-020)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"total": 1,
"data": [{"brand_id": 123, "brand_name": "测试品牌"}]
}
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):
with patch("app.services.brand_api.settings") as mock_settings:
mock_settings.BRAND_API_TIMEOUT = 3.0
mock_settings.BRAND_API_BASE_URL = "https://api.test.com"
mock_settings.BRAND_API_TOKEN = "test_token_123"
semaphore = asyncio.Semaphore(10)
await fetch_brand_name("123", semaphore)
# 验证请求包含 Authorization header
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert "headers" in call_args.kwargs
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token_123"
async def test_fetch_brand_name_request_error(self): async def test_fetch_brand_name_request_error(self):
"""Test brand fetch with request error.""" """Test brand fetch with request error."""
mock_client = AsyncMock() mock_client = AsyncMock()

View File

@ -0,0 +1,314 @@
"""
Tests for SessionID Pool Service (T-021, T-022)
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from app.services.session_pool import (
SessionPool,
session_pool,
get_session_with_retry,
)
class TestSessionPool:
"""Tests for SessionPool class."""
async def test_refresh_success(self):
"""Test successful session pool refresh."""
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"},
]
}
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 == 3
assert not pool.is_empty
async def test_refresh_empty_data(self):
"""Test refresh with empty data array."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": []}
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 False
assert pool.size == 0
async def test_refresh_api_error(self):
"""Test refresh with API error."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 500
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 False
async def test_refresh_timeout(self):
"""Test refresh with timeout."""
pool = SessionPool()
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.TimeoutException("Timeout")
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 False
async def test_refresh_request_error(self):
"""Test refresh with request error."""
pool = SessionPool()
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.RequestError("Connection failed")
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 False
async def test_refresh_unexpected_error(self):
"""Test refresh with unexpected error."""
pool = SessionPool()
mock_client = AsyncMock()
mock_client.get.side_effect = ValueError("Unexpected")
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 False
async def test_refresh_with_auth_header(self):
"""Test that refresh includes Authorization header."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"sessionid": "test"}]}
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):
with patch("app.services.session_pool.settings") as mock_settings:
mock_settings.YUNTU_API_TOKEN = "test_token"
mock_settings.YUNTU_API_TIMEOUT = 10.0
mock_settings.BRAND_API_BASE_URL = "https://api.test.com"
await pool.refresh()
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
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."""
pool = SessionPool()
pool._sessions = ["session_1", "session_2", "session_3"]
session = pool.get_random()
assert session in pool._sessions
def test_get_random_from_empty_pool(self):
"""Test getting random session from empty pool."""
pool = SessionPool()
session = pool.get_random()
assert session is None
def test_remove_session(self):
"""Test removing a session from pool."""
pool = SessionPool()
pool._sessions = ["session_1", "session_2", "session_3"]
pool.remove("session_2")
assert pool.size == 2
assert "session_2" not in pool._sessions
def test_remove_nonexistent_session(self):
"""Test removing a session that doesn't exist."""
pool = SessionPool()
pool._sessions = ["session_1"]
# Should not raise
pool.remove("nonexistent")
assert pool.size == 1
def test_size_property(self):
"""Test size property."""
pool = SessionPool()
assert pool.size == 0
pool._sessions = ["a", "b"]
assert pool.size == 2
def test_is_empty_property(self):
"""Test is_empty property."""
pool = SessionPool()
assert pool.is_empty is True
pool._sessions = ["a"]
assert pool.is_empty is False
class TestGetSessionWithRetry:
"""Tests for get_session_with_retry function (T-022)."""
async def test_get_session_success(self):
"""Test successful session retrieval."""
with patch.object(session_pool, "_sessions", ["session_1", "session_2"]):
result = await get_session_with_retry()
assert result in ["session_1", "session_2"]
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
# After refresh, pool should have sessions
async def refresh_side_effect():
session_pool._sessions.append("new_session")
return True
mock_refresh.side_effect = refresh_side_effect
result = await get_session_with_retry()
assert mock_refresh.called
assert result == "new_session"
async def test_get_session_retry_on_refresh_failure(self):
"""Test retry behavior when refresh fails."""
original_sessions = session_pool._sessions.copy()
try:
session_pool._sessions = []
with patch.object(session_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()
try:
session_pool._sessions = []
with patch.object(session_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."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"sessionid": "valid_session"},
{"no_sessionid": "missing"},
None,
{"sessionid": ""}, # Empty string should be filtered
{"sessionid": "another_valid"},
]
}
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 == 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."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = ["not", "a", "dict"]
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 False

View File

@ -0,0 +1,423 @@
"""
Tests for Video Analysis Service (T-024)
"""
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, patch, MagicMock
from app.services.video_analysis import (
calculate_cost_metrics,
get_video_base_info,
get_video_analysis_data,
update_video_a3_metrics,
get_and_update_video_analysis,
)
from app.services.yuntu_api import YuntuAPIError
class TestCalculateCostMetrics:
"""Tests for calculate_cost_metrics function."""
def test_all_metrics_calculated(self):
"""Test calculation of all cost metrics."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=40000,
a3_increase_cnt=500,
natural_a3_increase_cnt=400,
after_view_search_uv=1000,
total_play_cnt=50000,
)
# CPM = 10000 / 50000 * 1000 = 200
assert result["cpm"] == 200.0
# 自然CPM = 10000 / 40000 * 1000 = 250
assert result["natural_cpm"] == 250.0
# CPA3 = 10000 / 500 = 20
assert result["cpa3"] == 20.0
# 自然CPA3 = 10000 / 400 = 25
assert result["natural_cpa3"] == 25.0
# CPsearch = 10000 / 1000 = 10
assert result["cp_search"] == 10.0
# 预估自然看后搜人数 = 40000 / 50000 * 1000 = 800
assert result["estimated_natural_search_uv"] == 800.0
# 自然CPsearch = 10000 / 800 = 12.5
assert result["natural_cp_search"] == 12.5
def test_zero_total_play_cnt(self):
"""Test with zero total_play_cnt (division by zero)."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=0,
a3_increase_cnt=500,
natural_a3_increase_cnt=400,
after_view_search_uv=1000,
total_play_cnt=0,
)
assert result["cpm"] is None
assert result["natural_cpm"] is None
assert result["estimated_natural_search_uv"] is None
assert result["natural_cp_search"] is None
def test_zero_a3_counts(self):
"""Test with zero A3 counts."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=40000,
a3_increase_cnt=0,
natural_a3_increase_cnt=0,
after_view_search_uv=1000,
total_play_cnt=50000,
)
assert result["cpa3"] is None
assert result["natural_cpa3"] is None
# 其他指标应该正常计算
assert result["cpm"] == 200.0
def test_zero_search_uv(self):
"""Test with zero after_view_search_uv."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=40000,
a3_increase_cnt=500,
natural_a3_increase_cnt=400,
after_view_search_uv=0,
total_play_cnt=50000,
)
assert result["cp_search"] is None
# 当 after_view_search_uv=0 时,预估自然看后搜人数也应为 None无意义
assert result["estimated_natural_search_uv"] is None
assert result["natural_cp_search"] is None
def test_all_zeros(self):
"""Test with all zero values."""
result = calculate_cost_metrics(
cost=0,
natural_play_cnt=0,
a3_increase_cnt=0,
natural_a3_increase_cnt=0,
after_view_search_uv=0,
total_play_cnt=0,
)
assert result["cpm"] is None
assert result["natural_cpm"] is None
assert result["cpa3"] is None
assert result["natural_cpa3"] is None
assert result["cp_search"] is None
assert result["estimated_natural_search_uv"] is None
assert result["natural_cp_search"] is None
def test_decimal_precision(self):
"""Test that results are rounded to 2 decimal places."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=30000,
a3_increase_cnt=333,
natural_a3_increase_cnt=111,
after_view_search_uv=777,
total_play_cnt=70000,
)
# 验证都是2位小数
assert isinstance(result["cpm"], float)
assert len(str(result["cpm"]).split(".")[-1]) <= 2
class TestGetVideoAnalysisData:
"""Tests for get_video_analysis_data function."""
async def test_success_with_api_data(self):
"""Test successful data retrieval with API data."""
# Mock database video
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = "https://example.com/video"
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = datetime(2025, 1, 15)
mock_video.industry_name = "母婴"
mock_video.industry_id = "20"
mock_video.total_play_cnt = 50000
mock_video.natural_play_cnt = 40000
mock_video.heated_play_cnt = 10000
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
# Mock session
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_video
mock_session.execute.return_value = mock_result
# Mock API response
api_response = {
"code": 0,
"data": {
"total_show_cnt": 100000,
"natural_show_cnt": 80000,
"ad_show_cnt": 20000,
"total_play_cnt": 50000,
"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,
"after_view_search_uv": 1000,
"after_view_search_pv": 1500,
"brand_search_uv": 200,
"product_search_uv": 300,
"return_search_cnt": 50,
"cost": 10000,
"natural_cost": 0,
"ad_cost": 10000,
},
}
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.return_value = api_response
result = await get_video_analysis_data(mock_session, "video_123")
# 验证基础信息
assert result["base_info"]["item_id"] == "video_123"
assert result["base_info"]["title"] == "测试视频"
assert result["base_info"]["star_nickname"] == "测试达人"
# 验证触达指标
assert result["reach_metrics"]["total_show_cnt"] == 100000
assert result["reach_metrics"]["natural_play_cnt"] == 40000
# 验证A3指标
assert result["a3_metrics"]["a3_increase_cnt"] == 500
assert result["a3_metrics"]["natural_a3_increase_cnt"] == 400
# 验证搜索指标
assert result["search_metrics"]["after_view_search_uv"] == 1000
# 验证费用指标
assert result["cost_metrics_raw"]["cost"] == 10000
# 验证计算指标
assert result["cost_metrics_calculated"]["cpm"] is not None
assert result["cost_metrics_calculated"]["cpa3"] is not None
async def test_video_not_found(self):
"""Test error when video is not found."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_session.execute.return_value = mock_result
with pytest.raises(ValueError) as exc_info:
await get_video_analysis_data(mock_session, "nonexistent")
assert "not found" in str(exc_info.value).lower()
async def test_fallback_on_api_failure(self):
"""Test fallback to database data when API fails."""
# Mock database video
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = None
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = datetime(2025, 1, 15)
mock_video.industry_name = "母婴"
mock_video.industry_id = "20"
mock_video.total_play_cnt = 50000
mock_video.natural_play_cnt = 40000
mock_video.heated_play_cnt = 10000
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
# Mock session
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_video
mock_session.execute.return_value = mock_result
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.side_effect = YuntuAPIError("API Error")
result = await get_video_analysis_data(mock_session, "video_123")
# 应该使用数据库数据
assert result["reach_metrics"]["total_play_cnt"] == 50000
assert result["reach_metrics"]["natural_play_cnt"] == 40000
assert result["search_metrics"]["after_view_search_uv"] == 1000
async def test_null_publish_time(self):
"""Test handling of null publish_time."""
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = None
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = None # NULL
mock_video.industry_name = None
mock_video.industry_id = None
mock_video.total_play_cnt = 0
mock_video.natural_play_cnt = 0
mock_video.heated_play_cnt = 0
mock_video.after_view_search_uv = 0
mock_video.return_search_cnt = 0
mock_video.estimated_video_cost = 0
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_video
mock_session.execute.return_value = mock_result
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.return_value = {"code": 0, "data": {}}
result = await get_video_analysis_data(mock_session, "video_123")
assert result["base_info"]["publish_time"] is None
class TestUpdateVideoA3Metrics:
"""Tests for update_video_a3_metrics function (T-025)."""
async def test_update_success(self):
"""Test successful A3 metrics update."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.rowcount = 1
mock_session.execute.return_value = mock_result
result = await update_video_a3_metrics(
session=mock_session,
item_id="video_123",
total_new_a3_cnt=500,
heated_new_a3_cnt=100,
natural_new_a3_cnt=400,
total_cost=10000.0,
)
assert result is True
mock_session.commit.assert_called_once()
async def test_update_video_not_found(self):
"""Test update when video not found."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.rowcount = 0
mock_session.execute.return_value = mock_result
result = await update_video_a3_metrics(
session=mock_session,
item_id="nonexistent",
total_new_a3_cnt=500,
heated_new_a3_cnt=100,
natural_new_a3_cnt=400,
total_cost=10000.0,
)
assert result is False
async def test_update_database_error(self):
"""Test update with database error."""
mock_session = AsyncMock()
mock_session.execute.side_effect = Exception("Database error")
result = await update_video_a3_metrics(
session=mock_session,
item_id="video_123",
total_new_a3_cnt=500,
heated_new_a3_cnt=100,
natural_new_a3_cnt=400,
total_cost=10000.0,
)
assert result is False
mock_session.rollback.assert_called_once()
class TestGetAndUpdateVideoAnalysis:
"""Tests for get_and_update_video_analysis function (T-024 + T-025)."""
async def test_get_and_update_success(self):
"""Test successful get and update."""
# Mock database video
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = None
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = datetime(2025, 1, 15)
mock_video.industry_name = "母婴"
mock_video.industry_id = "20"
mock_video.total_play_cnt = 50000
mock_video.natural_play_cnt = 40000
mock_video.heated_play_cnt = 10000
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
# Mock session
mock_session = AsyncMock()
mock_select_result = MagicMock()
mock_select_result.scalar_one_or_none.return_value = mock_video
mock_update_result = MagicMock()
mock_update_result.rowcount = 1
# 根据不同的SQL语句返回不同的结果
async def mock_execute(stmt):
# 简单判断:如果是 SELECT 返回视频,如果是 UPDATE 返回更新结果
stmt_str = str(stmt)
if "SELECT" in stmt_str.upper():
return mock_select_result
return mock_update_result
mock_session.execute.side_effect = mock_execute
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.return_value = {
"code": 0,
"data": {
"a3_increase_cnt": 500,
"ad_a3_increase_cnt": 100,
"natural_a3_increase_cnt": 400,
"cost": 10000,
},
}
result = await get_and_update_video_analysis(mock_session, "video_123")
# 验证返回数据
assert result["a3_metrics"]["a3_increase_cnt"] == 500
# 验证数据库更新被调用
mock_session.commit.assert_called()

View File

@ -0,0 +1,416 @@
"""
Tests for Yuntu API Service (T-023)
"""
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from app.services.yuntu_api import (
call_yuntu_api,
get_video_analysis,
parse_analysis_response,
YuntuAPIError,
SessionInvalidError,
)
class TestCallYuntuAPI:
"""Tests for call_yuntu_api function."""
async def test_call_success(self):
"""Test successful API call."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "success",
"data": {
"total_show_cnt": 100000,
"a3_increase_cnt": 500,
},
}
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):
result = await call_yuntu_api(
item_id="test_item_123",
publish_time=datetime(2025, 1, 1),
industry_id="20",
session_id="test_session",
)
assert result["code"] == 0
assert result["data"]["total_show_cnt"] == 100000
async def test_call_with_correct_parameters(self):
"""Test that API is called with correct parameters."""
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):
await call_yuntu_api(
item_id="video_001",
publish_time=datetime(2025, 1, 15),
industry_id="30",
session_id="session_abc",
)
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# 验证URL
assert "GetContentMaterialAnalysisInfo" in call_args.args[0]
# 验证请求体
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"]
# 验证headers包含sessionid
headers = call_args.kwargs["headers"]
assert "Cookie" in headers
assert "sessionid=session_abc" in headers["Cookie"]
async def test_call_session_invalid_401(self):
"""Test handling of 401 response (session invalid)."""
mock_response = MagicMock()
mock_response.status_code = 401
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 pytest.raises(SessionInvalidError) as exc_info:
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="invalid_session",
)
assert exc_info.value.status_code == 401
async def test_call_session_invalid_403(self):
"""Test handling of 403 response (session invalid)."""
mock_response = MagicMock()
mock_response.status_code = 403
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 pytest.raises(SessionInvalidError):
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="invalid_session",
)
async def test_call_api_error_500(self):
"""Test handling of 500 response."""
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
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 pytest.raises(YuntuAPIError) as exc_info:
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
)
assert exc_info.value.status_code == 500
async def test_call_business_error(self):
"""Test handling of business error (code != 0)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 1001,
"message": "Invalid parameter",
}
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 pytest.raises(YuntuAPIError) as exc_info:
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
)
assert "Invalid parameter" in exc_info.value.message
async def test_call_timeout(self):
"""Test handling of timeout."""
mock_client = AsyncMock()
mock_client.post.side_effect = httpx.TimeoutException("Timeout")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
with patch("httpx.AsyncClient", return_value=mock_client):
with pytest.raises(YuntuAPIError) as exc_info:
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="session",
)
assert "timeout" in exc_info.value.message.lower()
async def test_call_request_error(self):
"""Test handling of request error."""
mock_client = AsyncMock()
mock_client.post.side_effect = httpx.RequestError("Connection failed")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
with patch("httpx.AsyncClient", return_value=mock_client):
with pytest.raises(YuntuAPIError):
await call_yuntu_api(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
session_id="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)."""
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.call_yuntu_api") as mock_call:
mock_call.return_value = {"code": 0, "data": {"a3_increase_cnt": 100}}
result = await get_video_analysis(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
)
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.call_yuntu_api") as mock_call:
# 前两次失败,第三次成功
mock_call.side_effect = [
SessionInvalidError("Invalid"),
SessionInvalidError("Invalid"),
{"code": 0, "data": {}},
]
with patch("app.services.yuntu_api.session_pool") as mock_pool:
result = await get_video_analysis(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
max_retries=3,
)
assert result["code"] == 0
assert mock_call.call_count == 3
# 验证失效的session被移除
assert mock_pool.remove.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.call_yuntu_api") as mock_call:
mock_call.side_effect = SessionInvalidError("Invalid")
with patch("app.services.yuntu_api.session_pool"):
with pytest.raises(SessionInvalidError):
await get_video_analysis(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
max_retries=3,
)
assert mock_call.call_count == 3
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.call_yuntu_api") as mock_call:
mock_call.side_effect = YuntuAPIError("Server error", status_code=500)
with pytest.raises(YuntuAPIError) as exc_info:
await get_video_analysis(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
)
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
with pytest.raises(YuntuAPIError):
await get_video_analysis(
item_id="test",
publish_time=datetime.now(),
industry_id="20",
)
class TestParseAnalysisResponse:
"""Tests for parse_analysis_response function."""
def test_parse_complete_response(self):
"""Test parsing complete response data."""
response = {
"data": {
"total_show_cnt": 100000,
"natural_show_cnt": 80000,
"ad_show_cnt": 20000,
"total_play_cnt": 50000,
"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,
"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,
"natural_cost": 0,
"ad_cost": 10000.5,
}
}
result = parse_analysis_response(response)
assert result["total_show_cnt"] == 100000
assert result["natural_show_cnt"] == 80000
assert result["a3_increase_cnt"] == 500
assert result["after_view_search_uv"] == 1000
assert result["cost"] == 10000.5
def test_parse_empty_response(self):
"""Test parsing empty response."""
response = {"data": {}}
result = parse_analysis_response(response)
assert result["total_show_cnt"] == 0
assert result["a3_increase_cnt"] == 0
assert result["cost"] == 0
def test_parse_missing_data_key(self):
"""Test parsing response without data key."""
response = {}
result = parse_analysis_response(response)
assert result["total_show_cnt"] == 0
def test_parse_partial_response(self):
"""Test parsing partial response."""
response = {
"data": {
"total_show_cnt": 50000,
"a3_increase_cnt": 100,
}
}
result = parse_analysis_response(response)
assert result["total_show_cnt"] == 50000
assert result["a3_increase_cnt"] == 100
assert result["natural_show_cnt"] == 0 # Default value
assert result["cost"] == 0 # Default value

View File

@ -0,0 +1,16 @@
import VideoAnalysis from '@/components/VideoAnalysis';
export default function AnalysisPage() {
return (
<main className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-6">
<a href="/" className="text-indigo-600 hover:text-indigo-800">
</a>
</div>
<VideoAnalysis />
</div>
</main>
);
}

View File

@ -53,7 +53,13 @@ export default function Home() {
{pageState === 'default' && ( {pageState === 'default' && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center"> <div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="text-gray-400 text-6xl mb-4">🔍</div> <div className="text-gray-400 text-6xl mb-4">🔍</div>
<p className="text-gray-500"></p> <p className="text-gray-500 mb-4"></p>
<a
href="/analysis"
className="text-sm text-indigo-600 hover:text-indigo-800"
>
</a>
</div> </div>
)} )}

View File

@ -0,0 +1,178 @@
'use client';
import { useState } from 'react';
import { VideoAnalysisData } from '@/types';
import { getVideoAnalysis } from '@/lib/api';
// 格式化数字(千分位)
function formatNumber(num: number | null | undefined): string {
if (num === null || num === undefined) return '-';
return num.toLocaleString('zh-CN');
}
// 格式化金额保留2位小数
function formatCurrency(num: number | null | undefined): string {
if (num === null || num === undefined) return '-';
return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// 指标卡片组件
function MetricCard({ label, value, unit }: { label: string; value: string; unit?: string }) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="text-sm text-gray-500 mb-1">{label}</div>
<div className="text-lg font-semibold text-gray-900">
{value}
{unit && <span className="text-sm font-normal text-gray-500 ml-1">{unit}</span>}
</div>
</div>
);
}
// 指标分组组件
function MetricGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<h3 className="text-md font-semibold text-gray-800 mb-3 pb-2 border-b">{title}</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{children}
</div>
</div>
);
}
export default function VideoAnalysis() {
const [itemId, setItemId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<VideoAnalysisData | null>(null);
const handleSearch = async () => {
if (!itemId.trim()) {
setError('请输入视频ID');
return;
}
setLoading(true);
setError(null);
try {
const response = await getVideoAnalysis(itemId.trim());
if (response.success) {
setData(response.data);
} else {
setError(response.error || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-6xl mx-auto p-4">
{/* 搜索框 */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="flex gap-4">
<input
type="text"
value={itemId}
onChange={(e) => setItemId(e.target.value)}
placeholder="请输入视频ID (item_id)"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={handleSearch}
disabled={loading}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '加载中...' : '查询'}
</button>
</div>
{error && (
<div className="mt-3 text-red-600 text-sm">{error}</div>
)}
</div>
{/* 分析结果 */}
{data && (
<div className="bg-white rounded-lg shadow p-6">
{/* 基础信息 */}
<MetricGroup title="基础信息">
<MetricCard label="视频ID" value={data.base_info.item_id} />
<MetricCard label="达人昵称" value={data.base_info.star_nickname} />
<MetricCard label="达人ID" value={data.base_info.star_unique_id} />
<MetricCard label="星图ID" value={data.base_info.star_id} />
<MetricCard label="发布时间" value={data.base_info.publish_time || '-'} />
<MetricCard label="行业" value={data.base_info.industry_name || '-'} />
<div className="col-span-2">
<MetricCard label="视频标题" value={data.base_info.title || '-'} />
</div>
</MetricGroup>
{/* 触达指标 */}
<MetricGroup title="触达指标">
<MetricCard label="总曝光数" value={formatNumber(data.reach_metrics.total_show_cnt)} />
<MetricCard label="自然曝光数" value={formatNumber(data.reach_metrics.natural_show_cnt)} />
<MetricCard label="加热曝光数" value={formatNumber(data.reach_metrics.ad_show_cnt)} />
<MetricCard label="总播放数" value={formatNumber(data.reach_metrics.total_play_cnt)} />
<MetricCard label="自然播放数" value={formatNumber(data.reach_metrics.natural_play_cnt)} />
<MetricCard label="加热播放数" value={formatNumber(data.reach_metrics.ad_play_cnt)} />
<MetricCard label="有效播放数" value={formatNumber(data.reach_metrics.effective_play_cnt)} />
</MetricGroup>
{/* A3指标 */}
<MetricGroup title="A3指标">
<MetricCard label="新增A3" value={formatNumber(data.a3_metrics.a3_increase_cnt)} />
<MetricCard label="加热新增A3" value={formatNumber(data.a3_metrics.ad_a3_increase_cnt)} />
<MetricCard label="自然新增A3" value={formatNumber(data.a3_metrics.natural_a3_increase_cnt)} />
</MetricGroup>
{/* 搜索指标 */}
<MetricGroup title="搜索指标">
<MetricCard label="看后搜人数" value={formatNumber(data.search_metrics.after_view_search_uv)} />
<MetricCard label="看后搜次数" value={formatNumber(data.search_metrics.after_view_search_pv)} />
<MetricCard label="品牌搜索人数" value={formatNumber(data.search_metrics.brand_search_uv)} />
<MetricCard label="商品搜索人数" value={formatNumber(data.search_metrics.product_search_uv)} />
<MetricCard label="回搜次数" value={formatNumber(data.search_metrics.return_search_cnt)} />
</MetricGroup>
{/* 费用指标 */}
<MetricGroup title="费用指标">
<MetricCard label="总花费" value={formatCurrency(data.cost_metrics_raw.cost)} />
<MetricCard label="自然花费" value={formatCurrency(data.cost_metrics_raw.natural_cost)} />
<MetricCard label="加热花费" value={formatCurrency(data.cost_metrics_raw.ad_cost)} />
</MetricGroup>
{/* 成本指标 */}
<MetricGroup title="成本指标(计算)">
<MetricCard label="CPM" value={formatCurrency(data.cost_metrics_calculated.cpm)} />
<MetricCard label="自然CPM" value={formatCurrency(data.cost_metrics_calculated.natural_cpm)} />
<MetricCard label="CPA3" value={formatCurrency(data.cost_metrics_calculated.cpa3)} />
<MetricCard label="自然CPA3" value={formatCurrency(data.cost_metrics_calculated.natural_cpa3)} />
<MetricCard label="CP搜索" value={formatCurrency(data.cost_metrics_calculated.cp_search)} />
<MetricCard label="预估自然看后搜人数" value={formatNumber(data.cost_metrics_calculated.estimated_natural_search_uv)} />
<MetricCard label="自然CP搜索" value={formatCurrency(data.cost_metrics_calculated.natural_cp_search)} />
</MetricGroup>
{/* 视频链接 */}
{data.base_info.video_url && (
<div className="mt-4 pt-4 border-t">
<a
href={data.base_info.video_url}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800"
>
</a>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { QueryRequest, QueryResponse } from '@/types'; import { QueryRequest, QueryResponse, VideoAnalysisResponse } from '@/types';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
@ -27,3 +27,17 @@ export async function exportData(format: 'xlsx' | 'csv'): Promise<Blob> {
return response.blob(); return response.blob();
} }
// 获取视频分析数据 (T-026)
export async function getVideoAnalysis(itemId: string): Promise<VideoAnalysisResponse> {
const response = await fetch(`${API_BASE_URL}/videos/${itemId}/analysis`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('视频不存在');
}
throw new Error(`获取分析数据失败: ${response.statusText}`);
}
return response.json();
}

View File

@ -61,3 +61,58 @@ export const QUERY_PLACEHOLDER: Record<QueryType, string> = {
unique_id: '请输入达人unique_id每行一个...', unique_id: '请输入达人unique_id每行一个...',
nickname: '请输入达人昵称关键词...', nickname: '请输入达人昵称关键词...',
}; };
// 视频分析数据 (T-026)
export interface VideoAnalysisData {
base_info: {
item_id: string;
title: string | null;
video_url: string | null;
star_id: string;
star_unique_id: string;
star_nickname: string;
publish_time: string | null;
industry_name: string | null;
};
reach_metrics: {
total_show_cnt: number;
natural_show_cnt: number;
ad_show_cnt: number;
total_play_cnt: number;
natural_play_cnt: number;
ad_play_cnt: number;
effective_play_cnt: number;
};
a3_metrics: {
a3_increase_cnt: number;
ad_a3_increase_cnt: number;
natural_a3_increase_cnt: number;
};
search_metrics: {
after_view_search_uv: number;
after_view_search_pv: number;
brand_search_uv: number;
product_search_uv: number;
return_search_cnt: number;
};
cost_metrics_raw: {
cost: number;
natural_cost: number;
ad_cost: number;
};
cost_metrics_calculated: {
cpm: number | null;
natural_cpm: number | null;
cpa3: number | null;
natural_cpa3: number | null;
cp_search: number | null;
estimated_natural_search_uv: number | null;
natural_cp_search: number | null;
};
}
export interface VideoAnalysisResponse {
success: boolean;
data: VideoAnalysisData;
error?: string;
}