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>
142 lines
4.3 KiB
Python
142 lines
4.3 KiB
Python
"""
|
||
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
|