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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zfc 2026-01-28 22:01:55 +08:00
parent f123f68be3
commit 7cd29c5980
25 changed files with 2482 additions and 1324 deletions

4
.gitignore vendored
View File

@ -47,3 +47,7 @@ Thumbs.db
.eggs/ .eggs/
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
temp/

View File

@ -1,26 +1,55 @@
""" """
视频分析API路由 (T-024) 视频分析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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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 from app.services.yuntu_api import YuntuAPIError
router = APIRouter(prefix="/videos", tags=["视频分析"]) 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") @router.get("/{item_id}/analysis")
async def get_video_analysis( async def get_video_analysis(
item_id: str, item_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取视频分析数据 获取单个视频分析数据
返回6大类指标 返回6大类指标
- 基础信息 (8字段) - 基础信息 (8字段)
@ -53,3 +82,94 @@ async def get_video_analysis(
raise HTTPException(status_code=500, detail=f"API Error: {e.message}") raise HTTPException(status_code=500, detail=f"API Error: {e.message}")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Internal error: {str(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)}")

View File

@ -23,6 +23,7 @@ class Settings(BaseSettings):
# Yuntu API (for SessionID pool) # Yuntu API (for SessionID pool)
YUNTU_API_TOKEN: str = "" # Bearer Token for Yuntu Cookie API YUNTU_API_TOKEN: str = "" # Bearer Token for Yuntu Cookie API
YUNTU_AADVID: str = "1648829117232140" # 广告主ID用于巨量云图API调用
# API Settings # API Settings
MAX_QUERY_LIMIT: int = 1000 MAX_QUERY_LIMIT: int = 1000

View File

@ -1,52 +1,119 @@
from sqlalchemy import Column, String, Integer, Float, DateTime, Index from sqlalchemy import Column, String, Integer, Float, DateTime, BigInteger, Boolean, Date, Text
from sqlalchemy.dialects.postgresql import JSONB
from app.database import Base from app.database import Base
class KolVideo(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) item_id = Column(String, primary_key=True)
# 基础信息 # 基础信息
title = Column(String, nullable=True) title = Column(String, nullable=True)
viral_type = Column(String, nullable=True) video_url = Column(Text, nullable=True)
video_url = Column(String, 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_id = Column(String, nullable=False)
star_unique_id = Column(String, nullable=False) star_unique_id = Column(String, nullable=False)
star_nickname = 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) natural_play_cnt = Column(BigInteger, default=0)
heated_play_cnt = Column(Integer, default=0) heated_play_cnt = Column(BigInteger, default=0)
total_play_cnt = Column(Integer, default=0) total_play_cnt = Column(BigInteger, default=0)
# 互动指标 # 互动指标
total_interact = Column(Integer, default=0) total_interaction_cnt = Column(BigInteger, default=0) # 映射为 total_interact
like_cnt = Column(Integer, default=0) natural_interaction_cnt = Column(BigInteger, default=0)
share_cnt = Column(Integer, default=0) heated_interaction_cnt = Column(BigInteger, default=0)
comment_cnt = Column(Integer, 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) new_a3_rate = Column(Float, nullable=True)
after_view_search_uv = Column(Integer, default=0) total_new_a3_cnt = Column(BigInteger, default=0)
return_search_cnt = Column(Integer, 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_id = Column(String, nullable=True)
industry_name = Column(String, nullable=True) industry_name = Column(String, nullable=True)
brand_id = Column(String, nullable=True) brand_id = Column(String, nullable=True)
estimated_video_cost = Column(Float, default=0) order_id = Column(String, nullable=True)
# 索引定义 # JSON 字段
__table_args__ = ( content_type = Column(JSONB, nullable=True)
Index("idx_star_id", "star_id"), industry_tags = Column(JSONB, nullable=True)
Index("idx_star_unique_id", "star_unique_id"), ad_hot_type = Column(JSONB, nullable=True)
Index("idx_star_nickname", "star_nickname"), 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): def __repr__(self):
return f"<KolVideo(item_id={self.item_id}, title={self.title})>" 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

View File

@ -33,7 +33,8 @@ async def fetch_brand_name(
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",
params={"brand_id": brand_id},
headers=headers, headers=headers,
) )
if response.status_code == 200: if response.status_code == 200:

View File

@ -1,13 +1,18 @@
""" """
SessionID池服务 (T-021) SessionID池服务 (T-021, T-027)
从内部API获取Cookie列表随机选取sessionid用于巨量云图API调用 从内部API获取Cookie列表随机选取 aadvid/auth_token 用于 API 调用
T-027 修复:
- 改为随机选取任意一组配置不按 brand_id 匹配
- auth_token 直接使用完整值 ( "sessionid=xxx")
""" """
import asyncio import asyncio
import random
import logging import logging
from typing import List, Optional import random
from typing import Dict, Optional, Any, List
from dataclasses import dataclass
import httpx import httpx
@ -16,16 +21,27 @@ from app.config import settings
logger = logging.getLogger(__name__) 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: class SessionPool:
"""SessionID池管理器""" """SessionID池管理器 - T-027: 改为随机选取"""
def __init__(self): def __init__(self):
self._sessions: List[str] = [] # 存储所有配置的列表
self._configs: List[CookieConfig] = []
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def refresh(self) -> bool: async def refresh(self) -> bool:
""" """
从内部API刷新SessionID列表 从内部API刷新配置列表
Returns: Returns:
bool: 刷新是否成功 bool: 刷新是否成功
@ -47,19 +63,34 @@ class SessionPool:
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
# 响应格式: {"data": [{"sessionid": "xxx", ...}, ...]}
if isinstance(data, dict): if isinstance(data, dict):
cookie_list = data.get("data", []) cookie_list = data.get("data", [])
if isinstance(cookie_list, list): if isinstance(cookie_list, list):
self._sessions = [ self._configs = []
item.get("sessionid") for item in cookie_list:
for item in cookie_list if not isinstance(item, dict):
if isinstance(item, dict) and item.get("sessionid") 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( 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( logger.warning(
f"Failed to refresh session pool: status={response.status_code}" f"Failed to refresh session pool: status={response.status_code}"
@ -76,48 +107,101 @@ class SessionPool:
logger.error(f"SessionPool refresh unexpected error: {e}") logger.error(f"SessionPool refresh unexpected error: {e}")
return False return False
def get_random(self) -> Optional[str]: def get_random_config(self) -> Optional[Dict[str, Any]]:
""" """
随机获取一个SessionID T-027: 随机选取任意一组配置
Returns: Returns:
Optional[str]: SessionID池为空时返回None Dict or None: 包含 aadvid auth_token 的字典
""" """
if not self._sessions: if not self._configs:
return None 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: Args:
session_id: 要移除的SessionID auth_token: 要移除的 auth_token
""" """
try: self._configs = [c for c in self._configs if c.auth_token != auth_token]
self._sessions.remove(session_id) logger.info(f"Removed invalid config: {auth_token[:20]}...")
logger.info(f"Removed invalid session: {session_id[:8]}...")
except ValueError: # 兼容旧接口
pass # 已经被移除 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 @property
def size(self) -> int: def size(self) -> int:
"""返回池中SessionID数量""" """返回池中配置数量"""
return len(self._sessions) return len(self._configs)
@property @property
def is_empty(self) -> bool: 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() 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]: async def get_session_with_retry(max_retries: int = 3) -> Optional[str]:
""" """
获取SessionID必要时刷新池 (T-022 支持) 获取SessionID必要时刷新池 (兼容旧接口)
Args: Args:
max_retries: 最大重试次数 max_retries: 最大重试次数
@ -125,17 +209,18 @@ async def get_session_with_retry(max_retries: int = 3) -> Optional[str]:
Returns: Returns:
Optional[str]: SessionID获取失败返回None Optional[str]: SessionID获取失败返回None
""" """
for attempt in range(max_retries): config = await get_random_config(max_retries)
# 如果池为空,尝试刷新 if config:
if session_pool.is_empty: auth_token = config["auth_token"]
success = await session_pool.refresh() if "=" in auth_token:
if not success: return auth_token.split("=", 1)[-1]
logger.warning(f"Session pool refresh failed, attempt {attempt + 1}") return auth_token
continue
session_id = session_pool.get_random()
if session_id:
return session_id
logger.error("Failed to get session after all retries")
return None 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)

View File

@ -318,3 +318,114 @@ async def get_and_update_video_analysis(
) )
return result 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

View File

@ -1,17 +1,26 @@
""" """
巨量云图API封装 (T-023) 巨量云图API封装 (T-023, T-027)
封装GetContentMaterialAnalysisInfo接口调用获取视频分析数据 封装GetContentMaterialAnalysisInfo接口调用获取视频分析数据
T-027 修复
1. 日期格式: YYYYMMDD (不是 YYYY-MM-DD)
2. Cookie : 直接使用 auth_token 完整值
3. industry_id: 字符串格式 ["12"]
4. A3 指标: API 返回字符串需转为整数
""" """
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any from typing import Dict, Optional, Any, Union
import httpx import httpx
from app.config import settings 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__) logger = logging.getLogger(__name__)
@ -38,11 +47,26 @@ class SessionInvalidError(YuntuAPIError):
pass 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( async def call_yuntu_api(
item_id: str, item_id: str,
publish_time: datetime, publish_time: Union[datetime, None],
industry_id: str, industry_id: str,
session_id: Optional[str] = None, aadvid: str,
auth_token: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
调用巨量云图GetContentMaterialAnalysisInfo接口 调用巨量云图GetContentMaterialAnalysisInfo接口
@ -50,8 +74,9 @@ async def call_yuntu_api(
Args: Args:
item_id: 视频ID item_id: 视频ID
publish_time: 发布时间 publish_time: 发布时间
industry_id: 行业ID industry_id: 行业ID字符串格式
session_id: 可选的sessionid不提供则从池中获取 aadvid: 广告主IDURL参数
auth_token: Cookie完整值 "sessionid=xxx"
Returns: Returns:
Dict: API响应数据 Dict: API响应数据
@ -60,16 +85,16 @@ async def call_yuntu_api(
SessionInvalidError: SessionID失效时抛出 SessionInvalidError: SessionID失效时抛出
YuntuAPIError: API调用失败时抛出 YuntuAPIError: API调用失败时抛出
""" """
# 获取sessionid # 处理 publish_time
if session_id is None: if publish_time is None:
session_id = await get_session_with_retry() publish_time = datetime.now()
if session_id is None:
raise YuntuAPIError("Failed to get valid session")
# 构造请求参数 # T-027: 日期格式必须为 YYYYMMDD
# end_date = start_date + 30天 start_date = publish_time.strftime("%Y%m%d")
start_date = publish_time.strftime("%Y-%m-%d") end_date = (publish_time + timedelta(days=30)).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 = { request_data = {
"is_my_video": "0", "is_my_video": "0",
@ -79,27 +104,30 @@ async def call_yuntu_api(
"end_date": end_date, "end_date": end_date,
"assist_type": 3, "assist_type": 3,
"assist_video_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, "trigger_point_id_list": TRIGGER_POINT_IDS,
} }
# 构造请求头 # T-027: Cookie 直接使用 auth_token 完整值
headers = { headers = {
"Content-Type": "application/json", "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: try:
async with httpx.AsyncClient(timeout=settings.YUNTU_API_TIMEOUT) as client: async with httpx.AsyncClient(timeout=settings.YUNTU_API_TIMEOUT) as client:
response = await client.post( response = await client.post(
f"{YUNTU_BASE_URL}/yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo", url,
json=request_data, json=request_data,
headers=headers, headers=headers,
) )
# 检查SessionID是否失效 # 检查SessionID是否失效
if response.status_code in (401, 403): 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( raise SessionInvalidError(
f"Session invalid: {response.status_code}", f"Session invalid: {response.status_code}",
status_code=response.status_code, status_code=response.status_code,
@ -114,9 +142,10 @@ async def call_yuntu_api(
data = response.json() data = response.json()
# 检查业务错误码 # 检查业务错误
if data.get("code") != 0: status = data.get("status", data.get("code", 0))
error_msg = data.get("message", "Unknown error") if status != 0:
error_msg = data.get("msg", data.get("message", "Unknown error"))
raise YuntuAPIError( raise YuntuAPIError(
f"API business error: {error_msg}", f"API business error: {error_msg}",
status_code=response.status_code, status_code=response.status_code,
@ -140,51 +169,59 @@ async def get_video_analysis(
max_retries: int = 3, max_retries: int = 3,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
获取视频分析数据支持SessionID失效自动重试 (T-022) 获取视频分析数据随机选取配置
T-027: 改为随机选取任意一组 aadvid/auth_token不按 brand_id 匹配
Args: Args:
item_id: 视频ID item_id: 视频ID
publish_time: 发布时间 publish_time: 发布时间
industry_id: 行业ID industry_id: 行业ID来自数据库中的视频
max_retries: 最大重试次数 max_retries: 最大重试次数
Returns: Returns:
Dict: 视频分析数据 Dict: 视频分析数据
Raises: Raises:
YuntuAPIError: 所有重试失败后抛出 YuntuAPIError: API调用失败时抛出
""" """
last_error = None last_error = None
for attempt in range(max_retries): for attempt in range(max_retries):
# 从池中获取sessionid # T-027: 随机选取任意一组配置
session_id = await get_session_with_retry() config = await get_random_config()
if session_id is None: if config is None:
last_error = YuntuAPIError("Failed to get valid session") last_error = YuntuAPIError("No config available in session pool")
logger.warning(f"No config available, attempt {attempt + 1}/{max_retries}")
continue continue
logger.info(
f"Using random config: aadvid={config['aadvid']}, attempt {attempt + 1}"
)
try: try:
result = await call_yuntu_api( result = await call_yuntu_api(
item_id=item_id, item_id=item_id,
publish_time=publish_time, publish_time=publish_time,
industry_id=industry_id, industry_id=industry_id, # T-027: 使用数据库中视频的 industry_id
session_id=session_id, aadvid=config["aadvid"],
auth_token=config["auth_token"],
) )
return result return result
except SessionInvalidError: except SessionInvalidError:
# SessionID失效从池中移除并重试 # SessionID失效从池中移除
session_pool.remove(session_id) session_pool.remove_by_auth_token(config["auth_token"])
logger.info( 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 continue
except YuntuAPIError as e: except YuntuAPIError as e:
last_error = e last_error = e
logger.error(f"Yuntu API error on attempt {attempt + 1}: {e.message}") logger.error(f"Yuntu API error on attempt {attempt + 1}: {e.message}")
# 非SessionID问题不再重试 # 非 session 错误不重试
break break
raise last_error or YuntuAPIError("Unknown error after retries") 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响应提取关键指标 解析巨量云图API响应提取关键指标
T-027: A3 指标在 API 响应中是字符串类型需要转为整数
Args: Args:
data: API原始响应数据 data: API原始响应数据
Returns: Returns:
Dict: 结构化的分析数据 Dict: 结构化的分析数据
""" """
result_data = data.get("data", {}) result_data = data.get("data", {}) or {}
return { return {
# 触达指标 # 触达指标
"total_show_cnt": result_data.get("total_show_cnt", 0), # 总曝光数 "total_show_cnt": _safe_int(result_data.get("total_show_cnt")),
"natural_show_cnt": result_data.get("natural_show_cnt", 0), # 自然曝光数 "natural_show_cnt": _safe_int(result_data.get("natural_show_cnt")),
"ad_show_cnt": result_data.get("ad_show_cnt", 0), # 加热曝光数 "ad_show_cnt": _safe_int(result_data.get("ad_show_cnt")),
"total_play_cnt": result_data.get("total_play_cnt", 0), # 总播放数 "total_play_cnt": _safe_int(result_data.get("total_play_cnt")),
"natural_play_cnt": result_data.get("natural_play_cnt", 0), # 自然播放数 "natural_play_cnt": _safe_int(result_data.get("natural_play_cnt")),
"ad_play_cnt": result_data.get("ad_play_cnt", 0), # 加热播放数 "ad_play_cnt": _safe_int(result_data.get("ad_play_cnt")),
"effective_play_cnt": result_data.get("effective_play_cnt", 0), # 有效播放数 "effective_play_cnt": _safe_int(result_data.get("effective_play_cnt")),
# A3指标 # A3指标 - T-027: 转为整数
"a3_increase_cnt": result_data.get("a3_increase_cnt", 0), # 新增A3 "a3_increase_cnt": _safe_int(result_data.get("a3_increase_cnt")),
"ad_a3_increase_cnt": result_data.get("ad_a3_increase_cnt", 0), # 加热新增A3 "ad_a3_increase_cnt": _safe_int(result_data.get("ad_a3_increase_cnt")),
"natural_a3_increase_cnt": result_data.get("natural_a3_increase_cnt", 0), # 自然新增A3 "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_uv": _safe_int(result_data.get("after_view_search_uv")),
"after_view_search_pv": result_data.get("after_view_search_pv", 0), # 看后搜次数 "after_view_search_pv": _safe_int(result_data.get("after_view_search_pv")),
"brand_search_uv": result_data.get("brand_search_uv", 0), # 品牌搜索人数 "brand_search_uv": _safe_int(result_data.get("brand_search_uv")),
"product_search_uv": result_data.get("product_search_uv", 0), # 商品搜索人数 "product_search_uv": _safe_int(result_data.get("product_search_uv")),
"return_search_cnt": result_data.get("return_search_cnt", 0), # 回搜次数 "return_search_cnt": _safe_int(result_data.get("return_search_cnt")),
# 费用指标 # 费用指标
"cost": result_data.get("cost", 0), # 总花费 "cost": _safe_int(result_data.get("cost")),
"natural_cost": result_data.get("natural_cost", 0), # 自然花费 "natural_cost": _safe_int(result_data.get("natural_cost")),
"ad_cost": result_data.get("ad_cost", 0), # 加热花费 "ad_cost": _safe_int(result_data.get("ad_cost")),
} }

View File

@ -1,5 +1,10 @@
""" """
Tests for SessionID Pool Service (T-021, T-022) Tests for SessionID Pool Service (T-021, T-022, T-027)
T-027 更新
- 改为 CookieConfig 数据结构
- get_random_config() 随机选取配置
- remove_by_auth_token() 移除失效配置
""" """
import pytest import pytest
@ -8,8 +13,10 @@ import httpx
from app.services.session_pool import ( from app.services.session_pool import (
SessionPool, SessionPool,
CookieConfig,
session_pool, session_pool,
get_session_with_retry, get_session_with_retry,
get_random_config,
) )
@ -17,16 +24,27 @@ class TestSessionPool:
"""Tests for SessionPool class.""" """Tests for SessionPool class."""
async def test_refresh_success(self): async def test_refresh_success(self):
"""Test successful session pool refresh.""" """Test successful session pool refresh (T-027 format)."""
pool = SessionPool() pool = SessionPool()
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = { mock_response.json.return_value = {
"data": [ "data": [
{"sessionid": "session_001", "user": "test1"}, {
{"sessionid": "session_002", "user": "test2"}, "brand_id": "533661",
{"sessionid": "session_003", "user": "test3"}, "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() result = await pool.refresh()
assert result is True assert result is True
assert pool.size == 3 assert pool.size == 2
assert not pool.is_empty 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): async def test_refresh_empty_data(self):
"""Test refresh with empty data array.""" """Test refresh with empty data array."""
pool = SessionPool() pool = SessionPool()
@ -126,7 +173,17 @@ class TestSessionPool:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 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 = AsyncMock()
mock_client.get.return_value = mock_response mock_client.get.return_value = mock_response
@ -146,40 +203,131 @@ class TestSessionPool:
assert "headers" in call_args.kwargs assert "headers" in call_args.kwargs
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token" assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token"
def test_get_random_from_pool(self): def test_get_random_config_from_pool(self):
"""Test getting random session from pool.""" """Test getting random config from pool (T-027)."""
pool = SessionPool() 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() session = pool.get_random()
assert session in pool._sessions assert session == "session_1"
def test_get_random_from_empty_pool(self): def test_get_random_from_empty_pool_compat(self):
"""Test getting random session from empty pool.""" """Test get_random from empty pool."""
pool = SessionPool() pool = SessionPool()
session = pool.get_random() session = pool.get_random()
assert session is None assert session is None
def test_remove_session(self): def test_remove_by_auth_token(self):
"""Test removing a session from pool.""" """Test removing config by auth_token (T-027)."""
pool = SessionPool() 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 pool.size == 1
assert "session_2" not in pool._sessions 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): def test_remove_nonexistent_session(self):
"""Test removing a session that doesn't exist.""" """Test removing a session that doesn't exist."""
pool = SessionPool() 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 # Should not raise
pool.remove("nonexistent") pool.remove_by_auth_token("nonexistent")
assert pool.size == 1 assert pool.size == 1
@ -188,7 +336,22 @@ class TestSessionPool:
pool = SessionPool() pool = SessionPool()
assert pool.size == 0 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 assert pool.size == 2
def test_is_empty_property(self): def test_is_empty_property(self):
@ -196,29 +359,117 @@ class TestSessionPool:
pool = SessionPool() pool = SessionPool()
assert pool.is_empty is True 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 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: 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): async def test_get_session_success(self):
"""Test successful session retrieval.""" """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() 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): async def test_get_session_refresh_on_empty(self):
"""Test that pool is refreshed when empty.""" """Test that pool is refreshed when empty."""
with patch.object(session_pool, "_sessions", []): pool = SessionPool()
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = True
# 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(): 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 return True
mock_refresh.side_effect = refresh_side_effect mock_refresh.side_effect = refresh_side_effect
@ -230,55 +481,65 @@ class TestGetSessionWithRetry:
async def test_get_session_retry_on_refresh_failure(self): async def test_get_session_retry_on_refresh_failure(self):
"""Test retry behavior when refresh fails.""" """Test retry behavior when refresh fails."""
original_sessions = session_pool._sessions.copy() pool = SessionPool()
try: with patch("app.services.session_pool.session_pool", pool):
session_pool._sessions = [] with patch.object(pool, "refresh") as mock_refresh:
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = False mock_refresh.return_value = False
result = await get_session_with_retry(max_retries=3) result = await get_session_with_retry(max_retries=3)
assert result is None assert result is None
assert mock_refresh.call_count == 3 assert mock_refresh.call_count == 3
finally:
session_pool._sessions = original_sessions
async def test_get_session_max_retries(self): async def test_get_session_max_retries(self):
"""Test max retries limit.""" """Test max retries limit."""
original_sessions = session_pool._sessions.copy() pool = SessionPool()
try: with patch("app.services.session_pool.session_pool", pool):
session_pool._sessions = [] with patch.object(pool, "refresh") as mock_refresh:
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = False mock_refresh.return_value = False
result = await get_session_with_retry(max_retries=5) result = await get_session_with_retry(max_retries=5)
assert result is None assert result is None
assert mock_refresh.call_count == 5 assert mock_refresh.call_count == 5
finally:
session_pool._sessions = original_sessions
class TestSessionPoolIntegration: class TestSessionPoolIntegration:
"""Integration tests for session pool.""" """Integration tests for session pool."""
async def test_refresh_filters_invalid_items(self): 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() pool = SessionPool()
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = { mock_response.json.return_value = {
"data": [ "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, 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 result is True
assert pool.size == 2 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): async def test_refresh_handles_non_dict_data(self):
"""Test refresh with non-dict response.""" """Test refresh with non-dict response."""

View File

@ -195,6 +195,13 @@ class TestGetVideoAnalysisData:
result = await get_video_analysis_data(mock_session, "video_123") 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"]["item_id"] == "video_123"
assert result["base_info"]["title"] == "测试视频" assert result["base_info"]["title"] == "测试视频"
@ -249,6 +256,10 @@ class TestGetVideoAnalysisData:
mock_video.after_view_search_uv = 1000 mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50 mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000 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
mock_session = AsyncMock() mock_session = AsyncMock()

View File

@ -1,5 +1,10 @@
""" """
Tests for Yuntu API Service (T-023) Tests for Yuntu API Service (T-023, T-027)
T-027 更新
- call_yuntu_api 参数改为 auth_token完整 cookie
- 日期格式改为 YYYYMMDD
- industry_id 改为字符串
""" """
import pytest import pytest
@ -24,11 +29,11 @@ class TestCallYuntuAPI:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = { mock_response.json.return_value = {
"code": 0, "status": 0,
"message": "success", "msg": "ok",
"data": { "data": {
"total_show_cnt": 100000, "total_show_cnt": 100000,
"a3_increase_cnt": 500, "a3_increase_cnt": "500",
}, },
} }
@ -42,17 +47,18 @@ class TestCallYuntuAPI:
item_id="test_item_123", item_id="test_item_123",
publish_time=datetime(2025, 1, 1), publish_time=datetime(2025, 1, 1),
industry_id="20", 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 assert result["data"]["total_show_cnt"] == 100000
async def test_call_with_correct_parameters(self): 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 = MagicMock()
mock_response.status_code = 200 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 = AsyncMock()
mock_client.post.return_value = mock_response mock_client.post.return_value = mock_response
@ -64,26 +70,27 @@ class TestCallYuntuAPI:
item_id="video_001", item_id="video_001",
publish_time=datetime(2025, 1, 15), publish_time=datetime(2025, 1, 15),
industry_id="30", industry_id="30",
session_id="session_abc", aadvid="1648829117232140",
auth_token="sessionid=session_abc",
) )
mock_client.post.assert_called_once() mock_client.post.assert_called_once()
call_args = mock_client.post.call_args call_args = mock_client.post.call_args
# 验证URL # 验证URL包含aadvid
assert "GetContentMaterialAnalysisInfo" in call_args.args[0] assert "GetContentMaterialAnalysisInfo" in call_args.args[0]
assert "aadvid=1648829117232140" in call_args.args[0]
# 验证请求体 # 验证请求体 - T-027: 日期格式 YYYYMMDD
json_data = call_args.kwargs["json"] json_data = call_args.kwargs["json"]
assert json_data["object_id"] == "video_001" assert json_data["object_id"] == "video_001"
assert json_data["start_date"] == "2025-01-15" assert json_data["start_date"] == "20250115" # YYYYMMDD
assert json_data["end_date"] == "2025-02-14" # +30天 assert json_data["end_date"] == "20250214" # +30天
assert json_data["industry_id_list"] == ["30"] assert json_data["industry_id_list"] == ["30"] # 字符串数组
# 验证headers包含sessionid # 验证headers - T-027: 直接使用 auth_token
headers = call_args.kwargs["headers"] headers = call_args.kwargs["headers"]
assert "Cookie" in headers assert headers["Cookie"] == "sessionid=session_abc"
assert "sessionid=session_abc" in headers["Cookie"]
async def test_call_session_invalid_401(self): async def test_call_session_invalid_401(self):
"""Test handling of 401 response (session invalid).""" """Test handling of 401 response (session invalid)."""
@ -101,7 +108,8 @@ class TestCallYuntuAPI:
item_id="test", item_id="test",
publish_time=datetime.now(), publish_time=datetime.now(),
industry_id="20", industry_id="20",
session_id="invalid_session", aadvid="123",
auth_token="sessionid=invalid_session",
) )
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
@ -122,7 +130,8 @@ class TestCallYuntuAPI:
item_id="test", item_id="test",
publish_time=datetime.now(), publish_time=datetime.now(),
industry_id="20", industry_id="20",
session_id="invalid_session", aadvid="123",
auth_token="sessionid=invalid_session",
) )
async def test_call_api_error_500(self): async def test_call_api_error_500(self):
@ -142,18 +151,19 @@ class TestCallYuntuAPI:
item_id="test", item_id="test",
publish_time=datetime.now(), publish_time=datetime.now(),
industry_id="20", industry_id="20",
session_id="session", aadvid="123",
auth_token="sessionid=session",
) )
assert exc_info.value.status_code == 500 assert exc_info.value.status_code == 500
async def test_call_business_error(self): 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 = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = { mock_response.json.return_value = {
"code": 1001, "status": 1001,
"message": "Invalid parameter", "msg": "Invalid parameter",
} }
mock_client = AsyncMock() mock_client = AsyncMock()
@ -167,7 +177,8 @@ class TestCallYuntuAPI:
item_id="test", item_id="test",
publish_time=datetime.now(), publish_time=datetime.now(),
industry_id="20", industry_id="20",
session_id="session", aadvid="123",
auth_token="sessionid=session",
) )
assert "Invalid parameter" in exc_info.value.message assert "Invalid parameter" in exc_info.value.message
@ -185,7 +196,8 @@ class TestCallYuntuAPI:
item_id="test", item_id="test",
publish_time=datetime.now(), publish_time=datetime.now(),
industry_id="20", industry_id="20",
session_id="session", aadvid="123",
auth_token="sessionid=session",
) )
assert "timeout" in exc_info.value.message.lower() assert "timeout" in exc_info.value.message.lower()
@ -203,62 +215,24 @@ class TestCallYuntuAPI:
item_id="test", item_id="test",
publish_time=datetime.now(), publish_time=datetime.now(),
industry_id="20", 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: 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): async def test_success_first_try(self):
"""Test successful call on first attempt.""" """Test successful call on first attempt."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_session.return_value = "valid_session" mock_config.return_value = {
"aadvid": "123",
"auth_token": "sessionid=valid_session",
}
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: 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( result = await get_video_analysis(
item_id="test", item_id="test",
@ -266,20 +240,24 @@ class TestGetVideoAnalysis:
industry_id="20", industry_id="20",
) )
assert result["data"]["a3_increase_cnt"] == 100 assert result["data"]["a3_increase_cnt"] == "100"
assert mock_call.call_count == 1 assert mock_call.call_count == 1
async def test_retry_on_session_invalid(self): async def test_retry_on_session_invalid(self):
"""Test retry when session is invalid.""" """Test retry when session is invalid."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_session.side_effect = ["session_1", "session_2", "session_3"] 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: with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
# 前两次失败,第三次成功 # 前两次失败,第三次成功
mock_call.side_effect = [ mock_call.side_effect = [
SessionInvalidError("Invalid"), SessionInvalidError("Invalid"),
SessionInvalidError("Invalid"), SessionInvalidError("Invalid"),
{"code": 0, "data": {}}, {"status": 0, "data": {}},
] ]
with patch("app.services.yuntu_api.session_pool") as mock_pool: with patch("app.services.yuntu_api.session_pool") as mock_pool:
@ -290,15 +268,15 @@ class TestGetVideoAnalysis:
max_retries=3, max_retries=3,
) )
assert result["code"] == 0 assert result["status"] == 0
assert mock_call.call_count == 3 assert mock_call.call_count == 3
# 验证失效的session被移除 # 验证失效的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): async def test_max_retries_exceeded(self):
"""Test that error is raised after max retries.""" """Test that error is raised after max retries."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_session.return_value = "session" mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=session"}
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
mock_call.side_effect = SessionInvalidError("Invalid") mock_call.side_effect = SessionInvalidError("Invalid")
@ -316,8 +294,8 @@ class TestGetVideoAnalysis:
async def test_no_retry_on_api_error(self): async def test_no_retry_on_api_error(self):
"""Test that non-session errors don't trigger retry.""" """Test that non-session errors don't trigger retry."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_session.return_value = "session" mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=session"}
with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: with patch("app.services.yuntu_api.call_yuntu_api") as mock_call:
mock_call.side_effect = YuntuAPIError("Server error", status_code=500) mock_call.side_effect = YuntuAPIError("Server error", status_code=500)
@ -332,10 +310,10 @@ class TestGetVideoAnalysis:
assert mock_call.call_count == 1 assert mock_call.call_count == 1
assert exc_info.value.status_code == 500 assert exc_info.value.status_code == 500
async def test_no_session_available(self): async def test_no_config_available(self):
"""Test error when no session is available.""" """Test error when no config is available."""
with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: with patch("app.services.yuntu_api.get_random_config") as mock_config:
mock_session.return_value = None mock_config.return_value = None
with pytest.raises(YuntuAPIError): with pytest.raises(YuntuAPIError):
await get_video_analysis( await get_video_analysis(
@ -349,7 +327,7 @@ class TestParseAnalysisResponse:
"""Tests for parse_analysis_response function.""" """Tests for parse_analysis_response function."""
def test_parse_complete_response(self): def test_parse_complete_response(self):
"""Test parsing complete response data.""" """Test parsing complete response data (T-027: handles string values)."""
response = { response = {
"data": { "data": {
"total_show_cnt": 100000, "total_show_cnt": 100000,
@ -359,17 +337,17 @@ class TestParseAnalysisResponse:
"natural_play_cnt": 40000, "natural_play_cnt": 40000,
"ad_play_cnt": 10000, "ad_play_cnt": 10000,
"effective_play_cnt": 30000, "effective_play_cnt": 30000,
"a3_increase_cnt": 500, "a3_increase_cnt": "500", # 字符串
"ad_a3_increase_cnt": 100, "ad_a3_increase_cnt": "100",
"natural_a3_increase_cnt": 400, "natural_a3_increase_cnt": "400",
"after_view_search_uv": 1000, "after_view_search_uv": 1000,
"after_view_search_pv": 1500, "after_view_search_pv": 1500,
"brand_search_uv": 200, "brand_search_uv": 200,
"product_search_uv": 300, "product_search_uv": 300,
"return_search_cnt": 50, "return_search_cnt": 50,
"cost": 10000.5, "cost": 10000,
"natural_cost": 0, "natural_cost": 0,
"ad_cost": 10000.5, "ad_cost": 10000,
} }
} }
@ -377,9 +355,11 @@ class TestParseAnalysisResponse:
assert result["total_show_cnt"] == 100000 assert result["total_show_cnt"] == 100000
assert result["natural_show_cnt"] == 80000 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["after_view_search_uv"] == 1000
assert result["cost"] == 10000.5 assert result["cost"] == 10000
def test_parse_empty_response(self): def test_parse_empty_response(self):
"""Test parsing empty response.""" """Test parsing empty response."""
@ -404,7 +384,7 @@ class TestParseAnalysisResponse:
response = { response = {
"data": { "data": {
"total_show_cnt": 50000, "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["a3_increase_cnt"] == 100
assert result["natural_show_cnt"] == 0 # Default value assert result["natural_show_cnt"] == 0 # Default value
assert result["cost"] == 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

View File

@ -64,6 +64,15 @@ KOL Insight 旨在解决这一痛点,提供批量数据查询和智能成本
|----|----------|----------| |----|----------|----------|
| US-007 | 作为运营人员,我想要点击视频链接直接跳转,以便快速查看原视频 | 1. 视频链接可点击<br>2. 新窗口打开视频页面 | | US-007 | 作为运营人员,我想要点击视频链接直接跳转,以便快速查看原视频 | 1. 视频链接可点击<br>2. 新窗口打开视频页面 |
<!-- ITER: 2026-01-28 - 新增视频分析用户故事 -->
<!-- NEW START -->
#### P0 - 视频分析增强
| ID | 用户故事 | 验收标准 |
|----|----------|----------|
| US-008 | 作为运营人员我想要查看视频的详细分析数据触达、A3、搜索、费用、成本指标以便全面评估视频投放效果 | 1. 调用巨量云图API获取实时数据<br>2. 展示6大类25+指标<br>3. 成本指标自动计算<br>4. A3指标更新到数据库 |
<!-- NEW END -->
### 2.3 用户旅程 ### 2.3 用户旅程
**核心用户旅程:批量查询 KOL 数据** **核心用户旅程:批量查询 KOL 数据**
@ -86,6 +95,7 @@ KOL Insight 旨在解决这一痛点,提供批量数据查询和智能成本
### 3.1 功能架构 ### 3.1 功能架构
<!-- MODIFIED: 统一术语为"预估自然看后搜人数" --> <!-- MODIFIED: 统一术语为"预估自然看后搜人数" -->
<!-- ITER: 2026-01-28 - 新增巨量云图视频分析模块 -->
``` ```
KOL Insight KOL Insight
├── 数据查询模块 ├── 数据查询模块
@ -99,8 +109,13 @@ KOL Insight
├── 数据展示模块 ├── 数据展示模块
│ ├── 结果列表展示 │ ├── 结果列表展示
│ └── 视频链接跳转 │ └── 视频链接跳转
└── 数据导出模块 ├── 数据导出模块
└── Excel/CSV导出 │ └── Excel/CSV导出
└── 视频分析模块 (NEW)
├── SessionID池管理
├── 巨量云图API集成
├── 实时数据获取与更新
└── 视频分析报表展示
``` ```
### 3.2 功能详情 ### 3.2 功能详情
@ -135,6 +150,19 @@ KOL Insight
|--------|------|--------------|--------|----------| |--------|------|--------------|--------|----------|
| 数据导出 | 将查询结果导出为 Excel/CSV 格式 | US-005 | P1 | 文件可下载,数据完整,中文列名 | | 数据导出 | 将查询结果导出为 Excel/CSV 格式 | US-005 | P1 | 文件可下载,数据完整,中文列名 |
<!-- ITER: 2026-01-28 - 新增巨量云图视频分析模块 -->
<!-- NEW START -->
#### 3.2.5 视频分析模块
| 功能点 | 描述 | 关联用户故事 | 优先级 | 验收标准 |
|--------|------|--------------|--------|----------|
| SessionID池管理 | 从内部API获取Cookie列表随机选取sessionid用于请求 | US-008 | P0 | 1. 调用内部API获取100个sessionid<br>2. 随机选取机制实现<br>3. 失败自动切换重试最多3次 |
| 巨量云图API封装 | 调用GetContentMaterialAnalysisInfo获取视频分析数据 | US-008 | P0 | 1. 正确构造请求参数<br>2. 超时设置10秒<br>3. 错误处理和日志记录 |
| 视频分析接口 | GET /api/v1/videos/{item_id}/analysis | US-008 | P0 | 1. 返回6大类指标<br>2. 计算指标准确<br>3. 除零返回null |
| 数据库A3指标更新 | 从API获取数据后更新数据库对应字段 | US-008 | P1 | 1. 更新total_new_a3_cnt<br>2. 更新heated_new_a3_cnt<br>3. 更新natural_new_a3_cnt<br>4. 更新total_cost |
| 视频分析报表 | 前端展示6大类25+指标 | US-008 | P1 | 1. 基础信息展示<br>2. 触达/A3/搜索/费用/成本指标展示<br>3. 数值格式化 |
<!-- NEW END -->
## 4. 非功能需求 ## 4. 非功能需求
### 4.1 性能需求 ### 4.1 性能需求
@ -231,6 +259,8 @@ KOL Insight
|------|------|--------| |------|------|--------|
| PostgreSQL | 数据存储与查询 | 自建数据库 | | PostgreSQL | 数据存储与查询 | 自建数据库 |
| 品牌API | 根据品牌ID获取品牌名称 | 内部API (api.internal.intelligrow.cn) | | 品牌API | 根据品牌ID获取品牌名称 | 内部API (api.internal.intelligrow.cn) |
| Cookie池API | 获取巨量云图SessionID列表 | 内部API (api.internal.intelligrow.cn) |
| 巨量云图API | 获取视频分析数据 | 巨量云图 (yuntu.oceanengine.com) |
<!-- NEW START --> <!-- NEW START -->
**品牌API详情** **品牌API详情**
@ -240,6 +270,74 @@ KOL Insight
- 文档https://api.internal.intelligrow.cn/docs#/云图 - 文档https://api.internal.intelligrow.cn/docs#/云图
<!-- NEW END --> <!-- NEW END -->
<!-- ITER: 2026-01-28 - 修复品牌API响应解析+添加认证 -->
<!-- NEW START -->
**品牌API认证与响应格式**
- 认证方式Bearer Token`Authorization: Bearer {token}`
- Token配置通过环境变量 `BRAND_API_TOKEN` 配置
- 响应格式:
```json
{
"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/启初"}
]
}
```
- 解析方式:从 `data[0].brand_name` 获取品牌名称
<!-- NEW END -->
<!-- ITER: 2026-01-28 - 新增巨量云图API和Cookie池API -->
<!-- ITER: 2026-01-28 - 修复API参数格式问题 -->
<!-- NEW START -->
**Cookie池API详情**
- 接口地址:`/v1/yuntu/get_cookie`
- 请求方式GET
- 认证方式Bearer Token`Authorization: Bearer {YUNTU_API_TOKEN}`
- 用途获取巨量云图认证信息列表aadvid + auth_token
- **使用方式**:随机选取任意一组 aadvid/auth_token避免限流
- 示例:
```bash
curl -X 'GET' \
'https://api.internal.intelligrow.cn/v1/yuntu/get_cookie?page=1&page_size=100' \
-H 'Authorization: Bearer {YUNTU_API_TOKEN}'
```
- 响应关键字段:
- `data[].aadvid` - 云图API的URL参数
- `data[].auth_token` - Cookie头完整值格式`sessionid=xxx`
**巨量云图API详情**
- 接口地址:`POST /yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo?aadvid={AADVID}`
- 基础URL`https://yuntu.oceanengine.com`
- 认证方式Cookie头直接使用 `auth_token` 完整值
- 用途获取视频触达、A3、搜索、费用等分析数据
- 请求参数:
```json
{
"is_my_video": "0",
"object_id": "{item_id}",
"object_type": 2,
"start_date": "{YYYYMMDD格式}",
"end_date": "{start_date+30天,YYYYMMDD格式}",
"assist_type": 3,
"assist_video_type": 3,
"industry_id_list": ["{数据库中视频的industry_id,字符串格式}"],
"trigger_point_id_list": ["610000", "610300", "610301"]
}
```
- **⚠️ 参数格式要求**
- 日期格式必须为 `YYYYMMDD`(如 `20251014`),不是 `YYYY-MM-DD`
- `industry_id_list` 使用数据库中视频的 industry_id传字符串数组
- Cookie 头直接使用 `auth_token` 值(已包含 `sessionid=xxx`
- 关键响应字段:
- `data.a3_increase_cnt` - 新增A3字符串类型
- `data.ad_a3_increase_cnt` - 加热新增A3字符串类型
- `data.natural_a3_increase_cnt` - 自然新增A3字符串类型
- `data.cost` - 总花费(单位可能是分)
<!-- NEW END -->
### 6.2 内部接口 ### 6.2 内部接口
<!-- MODIFIED: 补充核心API端点改用 FastAPI RESTful 风格 --> <!-- MODIFIED: 补充核心API端点改用 FastAPI RESTful 风格 -->
@ -247,6 +345,7 @@ KOL Insight
|------|------|------|------| |------|------|------|------|
| /api/v1/query | POST | 批量查询KOL视频数据 | FastAPI 后端服务提供 | | /api/v1/query | POST | 批量查询KOL视频数据 | FastAPI 后端服务提供 |
| /api/v1/export | GET | 导出查询结果为Excel/CSV | FastAPI 后端服务提供 | | /api/v1/export | GET | 导出查询结果为Excel/CSV | FastAPI 后端服务提供 |
| /api/v1/videos/{item_id}/analysis | GET | 获取单个视频分析数据 | FastAPI 后端服务提供 (NEW) |
<!-- NEW START --> <!-- NEW START -->
**API 架构说明** **API 架构说明**

View File

@ -7,25 +7,25 @@
| 版本 | v1.0 | | 版本 | v1.0 |
| 创建日期 | 2026-01-28 | | 创建日期 | 2026-01-28 |
| 来源文档 | DevelopmentPlan.md, PRD.md, FeatureSummary.md | | 来源文档 | DevelopmentPlan.md, PRD.md, FeatureSummary.md |
| 品牌主体 | 秒思AI制作 | | 品牌主体 | 秒思AI制作 |
## 1. 设计概述 ## 1. 设计概述
### 1.1 设计原则 ### 1.1 设计原则
**秒思AI设计语言** **秒思AI设计语言**
| 原则 | 说明 | 应用 | | 原则 | 说明 | 应用 |
|------|------|------| |------|------|------|
| 优雅简洁 | 去除冗余元素,聚焦核心功能 | 单页应用,扁平化设计 | | 优雅简洁 | 去除冗余元素,聚焦核心功能 | 单页应用,扁平化设计 |
| 专业可信 | 体现数据分析的专业性 | 稳重色系,清晰的信息层级 | | 专业可信 | 体现数据分析的专业性 | 稳重色系,清晰的信息层级 |
| 高效直观 | 减少用户学习成本 | 明确的操作流程,即时反馈 | | 高效直观 | 减少用户学习成本 | 明确的操作流程,即时反馈 |
| 品牌一致 | 强化秒思AI品牌形象 | 统一使用品牌标识和色彩 | | 品牌一致 | 强化秒思AI品牌形象 | 统一使用品牌标识和色彩 |
**品牌元素** **品牌元素**
- **Logo**: doc/ui/muse.svg (秒思AI品牌标识) - **Logo**: doc/ui/muse.svg (秒思AI品牌标识)
- **Slogan**: "秒思AI制作" (展示在关键位置) - **Slogan**: "秒思AI制作" (展示在关键位置)
- **色调**: 专业、现代、科技感 - **色调**: 专业、现代、科技感
### 1.2 页面总览 ### 1.2 页面总览
@ -76,7 +76,7 @@
┌────────────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────────────┐
│ ┌──────────────────────────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Header (品牌头部) │ │ │ │ Header (品牌头部) │ │
│ │ ┌──────┐ [秒思AI制作] │ │ │ │ ┌──────┐ [秒思AI制作] │ │
│ │ │ MUSE │ KOL Insight - 云图数据查询分析 │ │ │ │ │ MUSE │ KOL Insight - 云图数据查询分析 │ │
│ │ │ Logo │ (品牌标识 + 产品名称) │ │ │ │ │ Logo │ (品牌标识 + 产品名称) │ │
│ │ └──────┘ │ │ │ │ └──────┘ │ │
@ -123,7 +123,7 @@
├────────────────────────────────────────────────────────────────────────────┤ ├────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Footer │ │ │ │ Footer │ │
│ │ © 2026 秒思AI制作 | KOL Insight v1.0 │ │ │ │ © 2026 秒思AI制作 | KOL Insight v1.0 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────────┘
``` ```
@ -132,7 +132,7 @@
| 组件ID | 组件名称 | 类型 | 说明 | 交互 | | 组件ID | 组件名称 | 类型 | 说明 | 交互 |
|--------|----------|------|------|------| |--------|----------|------|------|------|
| C-001 | 品牌头部 | Header | 展示秒思AI品牌Logo和产品名称 | 静态展示 | | C-001 | 品牌头部 | Header | 展示秒思AI品牌Logo和产品名称 | 静态展示 |
| C-002 | 查询方式选择器 | Radio Group | 三种查询方式单选 | 点击切换查询方式 | | C-002 | 查询方式选择器 | Radio Group | 三种查询方式单选 | 点击切换查询方式 |
| C-003 | 查询输入框 | Textarea | 批量输入或昵称输入 | 文本输入 | | C-003 | 查询输入框 | Textarea | 批量输入或昵称输入 | 文本输入 |
| C-004 | 查询按钮组 | Button Group | 清空、开始查询 | 点击执行操作 | | C-004 | 查询按钮组 | Button Group | 清空、开始查询 | 点击执行操作 |
@ -523,7 +523,7 @@
### 5.1 色彩规范 ### 5.1 色彩规范
**秒思AI品牌色系** **秒思AI品牌色系**
| 用途 | 色值 | 示例 | 说明 | | 用途 | 色值 | 示例 | 说明 |
|------|------|------|------| |------|------|------|------|
@ -665,21 +665,21 @@ Mobile (< 768px):
| 位置 | 尺寸 | 说明 | | 位置 | 尺寸 | 说明 |
|------|------|------| |------|------|------|
| Header 左侧 | 高度 40px | 秒思AI Logo (doc/ui/muse.svg) | | Header 左侧 | 高度 40px | 秒思AI Logo (doc/ui/muse.svg) |
| Favicon | 32x32px | 简化版 Logo 图标 | | Favicon | 32x32px | 简化版 Logo 图标 |
| 加载动画 | - | 可选:Logo 动效 | | 加载动画 | - | 可选:Logo 动效 |
**品牌声明位置** **品牌声明位置**
- Header 右上角:"秒思AI制作" - Header 右上角:"秒思AI制作"
- Footer 中央:"© 2026 秒思AI制作 | KOL Insight v1.0" - Footer 中央:"© 2026 秒思AI制作 | KOL Insight v1.0"
**Header 品牌区域详细设计** **Header 品牌区域详细设计**
``` ```
┌────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────┐
│ ┌──────┐ │ │ ┌──────┐ │
│ │ │ KOL Insight 秒思AI制作 │ │ │ │ KOL Insight 秒思AI制作 │
│ │ MUSE │ 云图数据查询分析 │ │ │ MUSE │ 云图数据查询分析 │
│ │ Logo │ (产品名称 + Slogan) (品牌声明) │ │ │ Logo │ (产品名称 + Slogan) (品牌声明) │
│ │ │ │ │ │ │ │
@ -838,7 +838,7 @@ Mobile (< 768px):
**关键设计决策** **关键设计决策**
- **单页应用**: 简化交互流程,提升用户体验 - **单页应用**: 简化交互流程,提升用户体验
- **品牌强化**: 多处展示"秒思AI制作",建立品牌认知 - **品牌强化**: 多处展示"秒思AI制作",建立品牌认知
- **数据优先**: 核心是数据展示,UI 简洁不干扰 - **数据优先**: 核心是数据展示,UI 简洁不干扰
- **响应式**: 支持桌面/平板/移动端访问 - **响应式**: 支持桌面/平板/移动端访问
@ -846,5 +846,5 @@ Mobile (< 768px):
**文档版本**: v1.0 **文档版本**: v1.0
**最后更新**: 2026-01-28 **最后更新**: 2026-01-28
**设计团队**: 秒思AI **设计团队**: 秒思AI
**审核状态**: 待审核 (建议运行 `/ru` 进行评审) **审核状态**: 待审核 (建议运行 `/ru` 进行评审)

View File

@ -57,7 +57,7 @@
| 布局风格统一 | ✅ | 垂直布局从上到下Header → 查询区 → 结果区 → Footer | | 布局风格统一 | ✅ | 垂直布局从上到下Header → 查询区 → 结果区 → Footer |
| 交互模式一致 | ✅ | 查询 → 展示 → 导出流程清晰 | | 交互模式一致 | ✅ | 查询 → 展示 → 导出流程清晰 |
| 状态覆盖完整 | ✅ | 默认态、输入态、查询中、结果态、空结果态、错误态 | | 状态覆盖完整 | ✅ | 默认态、输入态、查询中、结果态、空结果态、错误态 |
| 品牌元素应用 | ✅ | 秒思AI Logo、Slogan、品牌色系统一应用 | | 品牌元素应用 | ✅ | 秒思AI Logo、Slogan、品牌色系统一应用 |
| 设计规范完整 | ✅ | 色彩、字体、间距、圆角、阴影规范完整 | | 设计规范完整 | ✅ | 色彩、字体、间距、圆角、阴影规范完整 |
| 响应式设计 | ✅ | 考虑了 Mobile/Tablet/Desktop 三种断点 | | 响应式设计 | ✅ | 考虑了 Mobile/Tablet/Desktop 三种断点 |
@ -143,7 +143,7 @@
| 交互说明清晰 | ✅ | 8种交互场景全部说明 | | 交互说明清晰 | ✅ | 8种交互场景全部说明 |
| 用户流程图 | ✅ | 核心流程、辅助流程、异常流程全部包含 | | 用户流程图 | ✅ | 核心流程、辅助流程、异常流程全部包含 |
| 设计规范统一 | ✅ | 色彩、字体、间距、圆角、阴影规范完整 | | 设计规范统一 | ✅ | 色彩、字体、间距、圆角、阴影规范完整 |
| 品牌元素应用 | ✅ | 秒思AI Logo、Slogan、品牌色完整应用 | | 品牌元素应用 | ✅ | 秒思AI Logo、Slogan、品牌色完整应用 |
| 数据展示规范 | ✅ | 26个字段完整列出格式化规则明确 | | 数据展示规范 | ✅ | 26个字段完整列出格式化规则明确 |
| 响应式设计 | ✅ | Mobile/Tablet/Desktop 三种断点考虑 | | 响应式设计 | ✅ | Mobile/Tablet/Desktop 三种断点考虑 |
@ -155,7 +155,7 @@
- 符合开发计划的技术架构Next.js App Router - 符合开发计划的技术架构Next.js App Router
2. **品牌一致性强** ⭐⭐⭐ 2. **品牌一致性强** ⭐⭐⭐
- 秒思AI品牌元素贯穿整个设计 - 秒思AI品牌元素贯穿整个设计
- Logo、Slogan、品牌色系统一应用 - Logo、Slogan、品牌色系统一应用
- Header 和 Footer 强化品牌认知 - Header 和 Footer 强化品牌认知
@ -193,7 +193,7 @@
| 操作效率 | ⭐⭐⭐⭐⭐ | 批量查询、一键导出,效率高 | | 操作效率 | ⭐⭐⭐⭐⭐ | 批量查询、一键导出,效率高 |
| 错误提示 | ⭐⭐⭐⭐ | 错误态有明确提示和重试引导 | | 错误提示 | ⭐⭐⭐⭐ | 错误态有明确提示和重试引导 |
| 视觉层次 | ⭐⭐⭐⭐⭐ | 查询区 → 结果区层次清晰 | | 视觉层次 | ⭐⭐⭐⭐⭐ | 查询区 → 结果区层次清晰 |
| 品牌认知 | ⭐⭐⭐⭐⭐ | 多处展示秒思AI品牌元素 | | 品牌认知 | ⭐⭐⭐⭐⭐ | 多处展示秒思AI品牌元素 |
| 响应式体验 | ⭐⭐⭐⭐ | 考虑了移动端适配 | | 响应式体验 | ⭐⭐⭐⭐ | 考虑了移动端适配 |
## 评审结论 ## 评审结论
@ -211,7 +211,7 @@ UIDesign 文档整体质量优秀,设计完整、规范统一、品牌一致
**优点总结** **优点总结**
- ✅ 单页应用设计合理,操作流程简洁高效 - ✅ 单页应用设计合理,操作流程简洁高效
- ✅ 品牌元素应用完整,强化秒思AI品牌认知 - ✅ 品牌元素应用完整强化秒思AI品牌认知
- ✅ 设计规范详细,便于开发实现 - ✅ 设计规范详细,便于开发实现
- ✅ 状态覆盖全面,用户体验考虑周到 - ✅ 状态覆盖全面,用户体验考虑周到
- ✅ 与开发计划高度契合 - ✅ 与开发计划高度契合

View File

@ -4,751 +4,72 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 评审时间 | 2026-01-28 15:30 | | 评审时间 | 2026-01-28 17:35 |
| 目标文档 | [doc/tasks.md](doc/tasks.md) | | 目标文档 | doc/tasks.md |
| 参照文档 | [doc/UIDesign.md](doc/UIDesign.md), [doc/DevelopmentPlan.md](doc/DevelopmentPlan.md) | | 参照文档 | doc/UIDesign.md, doc/DevelopmentPlan.md |
| 问题统计 | **4 个严重 / 6 个一般 / 5 个建议** | | 问题统计 | 0 个严重 / 4 个一般 / 2 个建议 |
| 评审结论 | 🟡 **需修改后通过** |
## 覆盖度分析 ## 覆盖度分析
### DevelopmentPlan 覆盖 ### DevelopmentPlan 覆盖
#### Phase 1: 基础架构搭建 | 开发项 | 对应任务 | 状态 |
|--------|----------|------|
| 开发项 (DevelopmentPlan) | 对应任务 (tasks.md) | 状态 | 说明 | | T-001 前端项目初始化 | T-001A | ✅ |
|---------------------------|---------------------|------|------| | T-002 后端项目初始化 | T-001B | ✅ |
| T-001 前端项目初始化 + T-002 后端项目初始化 | **T-001 项目初始化** | ⚠️ | **合并为一个任务,粒度过大** | | T-003 数据库配置 | T-002 | ✅ |
| T-003 数据库配置 | T-002 数据库配置 | ✅ | 完全覆盖,含TDD要求 | | T-004 基础 UI 框架 | T-003 | ✅ |
| T-004 基础 UI 框架 | T-003 基础 UI 框架 | ✅ | 完全覆盖,含品牌元素 | | T-005 环境变量配置 | T-004 | ✅ |
| T-005 环境变量配置 | T-004 环境变量配置 | ✅ | 完全覆盖 | | T-006 查询 API 开发 | T-005 | ✅ |
| T-007 计算逻辑实现 | T-006 | ✅ |
#### Phase 2: 核心功能开发 | T-008 品牌 API 批量集成 | T-007 | ✅ |
| T-009 导出 API 开发 | T-010 | ✅ |
| 开发项 (DevelopmentPlan) | 对应任务 (tasks.md) | 状态 | 说明 | | T-010 查询表单组件 | T-008 | ✅ |
|---------------------------|---------------------|------|------| | T-011 结果表格组件 | T-009 | ✅ |
| T-006 查询 API 开发 (后端) | **T-005 查询 API 开发** | ✅ | 含TDD要求和100%覆盖率 | | T-012 导出按钮组件 | T-011 | ✅ |
| T-007 计算逻辑实现 (后端) | **T-006 计算逻辑实现** | ✅ | 含TDD要求和100%覆盖率 | | T-013 错误处理 | T-013 | ✅ |
| T-008 品牌 API 批量集成 (后端) | **T-007 品牌 API 批量集成** | ✅ | 含TDD要求和100%覆盖率 | | T-014 性能优化 | T-014 | ✅ |
| T-009 导出 API 开发 (后端) | **T-010 导出 API 开发** | ⚠️ | **依赖T-009前端组件,不合理** | | T-015 视频链接跳转 | T-015 | ✅ |
| T-010 查询表单组件 (前端) | T-008 查询表单组件 | ✅ | 标注"粗略实现" | | T-016 部署配置 | T-016 | ✅ |
| T-011 结果表格组件 (前端) | T-009 结果表格组件 | ✅ | 标注"粗略实现" | | T-017 集成测试 | T-017 | ✅ |
| T-012 导出按钮组件 (前端) | T-011 导出按钮组件 | ✅ | 标注"粗略实现" |
| **(未在 DevelopmentPlan 中)** | **T-012 主页面集成** | ⚠️ | **新增任务,导致编号错位** |
#### Phase 3: 优化与测试
| 开发项 (DevelopmentPlan) | 对应任务 (tasks.md) | 状态 | 说明 |
|---------------------------|---------------------|------|------|
| T-013 错误处理 (前后端) | **T-013 错误处理** | ❌ | **编号错位** |
| T-014 性能优化 (后端) | **T-014 性能优化** | ❌ | **编号错位** |
| T-015 视频链接跳转 (前端) | **T-015 视频链接跳转** | ❌ | **编号错位** |
| T-016 部署配置 (前后端) | **T-016 部署配置** | ❌ | **编号错位** |
| T-017 集成测试 | **T-017 集成测试** | ❌ | **编号错位** |
**总覆盖率**: 17/16 (tasks.md 新增1个任务)
**关键问题**:
1. ❌ **任务编号不一致**: Phase 3 的5个任务编号都向后偏移一位
2. ⚠️ **T-001 粒度过大**: 前后端初始化合并为一个任务
3. ⚠️ **T-010 依赖错误**: 后端 API 不应依赖前端组件 T-009
4. ⚠️ **T-012 新增任务**: DevelopmentPlan 中没有对应项
---
### UIDesign 覆盖 ### UIDesign 覆盖
| UI 页面/组件 | 对应任务 | 状态 | 说明 | | UI 页面 | 对应任务 | 状态 |
|-------------|----------|------|------| |---------|----------|------|
| **P-001: 数据查询主页** | T-012 主页面集成 | ✅ | 单页应用集成 | | P-001 数据查询主页 | T-011A (集成), T-008/009/011/015 | ✅ |
| **组件覆盖** | | | |
| C-001: 品牌头部 | T-003 基础 UI 框架 | ✅ | 包含 Logo 和品牌声明 |
| C-002: 查询方式选择器 | T-008 查询表单组件 | ✅ | Radio Group |
| C-003: 查询输入框 | T-008 查询表单组件 | ✅ | Textarea |
| C-004: 查询按钮组 | T-008 查询表单组件 | ✅ | 清空/开始查询 |
| C-005: 结果表格 | T-009 结果表格组件 | ✅ | 26字段表格 |
| C-006: 导出按钮组 | T-011 导出按钮组件 | ✅ | Excel/CSV 导出 |
| C-007: 分页器 | T-009 结果表格组件 | ✅ | 验收标准第9条 |
| C-008: 视频链接 | T-015 视频链接跳转 | ✅ | 新窗口打开 |
| C-009: Footer | T-003 基础 UI 框架 | ✅ | 版权信息 |
| **页面状态** | | | |
| 6种状态 | T-012 主页面集成 | ✅ | 验收标准第6-8条 |
**总覆盖率**: 10/10 (100%) **总覆盖率**: 1/1
**UI覆盖评价**: ✅ 所有 UI 页面、组件、状态都有对应任务
---
## 任务质量分析 ## 任务质量分析
| 检查项 | 通过数 | 总数 | 通过率 | | 检查项 | 通过数 | 总数 |
|--------|--------|------|--------| |--------|--------|------|
| 有明确描述 | 17 | 17 | 100% | | 有明确描述 | 27 | 27 |
| 有验收标准 | 17 | 17 | 100% | | 有验收标准 | 27 | 27 |
| 验收标准清晰 | 17 | 17 | 100% | | 粒度合适 | 25 | 27 |
| 依赖关系明确 | 16 | 17 | 94% |
| 粒度合适 | 16 | 17 | 94% |
| TDD 要求明确 | 7 | 12 | 58% |
| 测试覆盖率要求 | 7 | 12 | 58% |
**质量问题**:
- ⚠️ **T-001 粒度过大**: 前后端初始化合并,无法并行开发
- ⚠️ **后端任务 TDD 覆盖不全**: 仅 7/12 的后端任务有明确 TDD 要求
- ❌ **缺少测试独立任务**: 100% 覆盖率嵌入开发任务,难以单独验收
---
## 问题清单 ## 问题清单
### 严重问题 (Critical) ### 严重问题 (Critical)
无。
#### C-1: T-001 任务粒度过大,前后端无法并行
**位置**: [doc/tasks.md:43](doc/tasks.md:43)
**问题描述**:
```markdown
| T-001 | 项目初始化 | 前后端分离架构:前端 Next.js,后端 FastAPI,配置 TypeScript、ESLint、Prettier | P0 | - |
```
T-001 包含:
1. 前端 Next.js 14.x 项目创建
2. 后端 FastAPI 0.104+ 项目创建
3. 前端 TypeScript、ESLint、Prettier 配置
4. 后端 Python 依赖管理配置
5. 验收标准6条(前端3条+后端3条)
**影响**:
- 🚫 **无法并行开发**: 前端和后端开发者可能是不同人员,合并为一个任务导致无法同时开工
- 🚫 **验收标准过多**: 6条验收标准涉及不同技术栈,验收时需要同时检查前后端
- 🚫 **依赖关系不清晰**: T-002 数据库配置依赖 T-001,但实际只依赖后端部分
**建议修复**:
拆分为两个独立任务:
- **T-001A: 前端项目初始化** (依赖: 无)
- 创建 Next.js 14.x 项目
- 配置 TypeScript、ESLint、Prettier
- 验收: 可运行 `pnpm dev`
- **T-001B: 后端项目初始化** (依赖: 无)
- 创建 FastAPI 0.104+ 项目
- 配置 Poetry/pip
- 验收: 可运行 `uvicorn main:app --reload`
**优点**:
- ✅ 前后端可并行开发,节省时间
- ✅ 验收标准更聚焦
- ✅ 依赖关系更清晰(T-002 只依赖 T-001B)
---
#### C-2: T-010 依赖关系错误
**位置**: [doc/tasks.md:67](doc/tasks.md:67)
**问题描述**:
```markdown
| T-010 | 导出 API 开发 | ... | P1 | T-006, T-007, T-009 | ...
```
T-010 (后端导出 API) 依赖 T-009 (前端结果表格组件),这是**逻辑错误**。
**分析**:
- T-010 是**后端 FastAPI** 接口,负责生成 Excel/CSV 文件
- T-009 是**前端 React** 组件,负责展示表格
- 后端 API 不应该依赖前端组件的实现
**实际依赖**:
- T-010 应该依赖 **T-006 (计算逻辑实现)** 和 **T-007 (品牌API集成)**
- 因为导出的数据需要包含计算后的指标和品牌名称
**验收标准第5条**:
```
5. 使用中文列名作为表头 **(与 T-009 ResultTable 字段一致)**
```
这说明是要求"字段一致性",而不是"依赖关系"。
**影响**:
- 🚫 **执行顺序混乱**: 开发者可能误以为要先完成前端表格才能开发后端导出API
- 🚫 **前后端耦合**: 后端依赖前端,违反分离架构原则
**建议修复**:
1. 修改依赖: `T-010 依赖: T-006, T-007` (移除 T-009)
2. 修改验收标准第5条: "使用中文列名作为表头 **(字段顺序和命名与前端 ResultTable 保持一致,参考共享的字段定义)**"
3. 建议: 创建共享的字段定义文件(如 `types/fields.ts`),前后端都引用
---
#### C-3: 缺少单元测试独立任务
**位置**: 整个 tasks.md
**问题描述**:
tasks.md 中有 **7个任务** 要求 TDD 和 100% 测试覆盖率:
- T-002: 数据库配置 (验收标准 7-8 条)
- T-005: 查询 API 开发 (验收标准 9-10 条)
- T-006: 计算逻辑实现 (验收标准 7-8 条)
- T-007: 品牌 API 批量集成 (验收标准 8-9 条)
- T-010: 导出 API 开发 (验收标准 10-11 条)
- T-013: 错误处理 (验收标准 8-9 条)
- T-017: 集成测试 (验收标准 9-11 条)
但**没有单独的测试任务**,所有测试要求都嵌入在开发任务中。
**影响**:
- 🚫 **测试容易被忽略**: 开发进度紧张时,测试可能被压缩或跳过
- 🚫 **无法单独追踪测试进度**: 测试覆盖率没有独立的验收里程碑
- 🚫 **100% 覆盖率难以保证**: 嵌入在开发任务中,验收时可能只检查功能,不检查覆盖率
- 🚫 **测试报告缺失**: T-017 要求生成覆盖率报告,但其他任务没有明确要求
**建议修复**:
在 Phase 3 增加测试里程碑任务:
**方案A: 增加独立测试任务**
```markdown
| T-018 | 测试覆盖率验收 | 验证所有后端代码测试覆盖率 ≥ 100% | P1 | T-002, T-005~007, T-010, T-013 |
验收标准:
1. 数据库操作测试覆盖率 100% (T-002)
2. API集成测试覆盖率 100% (T-005)
3. 计算逻辑单元测试覆盖率 100% (T-006)
4. 品牌API单元测试覆盖率 100% (T-007)
5. 导出功能单元测试覆盖率 100% (T-010)
6. 错误处理分支覆盖率 100% (T-013)
7. 使用 pytest-cov 生成覆盖率报告
8. 覆盖率报告上传到 CI/CD
```
**方案B: 在每个 Phase 结束增加测试验收点**
```markdown
## 3. Phase 2 任务 - 核心功能开发
### 3.3 测试验收
| ID | 任务 | 描述 | 优先级 | 依赖 | 验收标准 |
|----|------|------|--------|------|----------|
| T-012A | Phase 2 测试验收 | 验证 Phase 2 所有后端任务测试覆盖率 | P0 | T-005~007, T-010 | 1. 所有后端代码覆盖率 ≥ 100%<br>2. 生成覆盖率报告 |
```
---
#### C-4: 任务编号与 DevelopmentPlan 不一致
**位置**: Phase 3 所有任务 ([doc/tasks.md:88-101](doc/tasks.md))
**问题描述**:
tasks.md 新增了 T-012 (主页面集成),导致 Phase 3 的所有任务编号向后偏移一位:
| DevelopmentPlan | tasks.md | 差异 |
|-----------------|----------|------|
| T-013 错误处理 | **T-013 错误处理** | ❌ 编号错位 |
| T-014 性能优化 | **T-014 性能优化** | ❌ 编号错位 |
| T-015 视频链接跳转 | **T-015 视频链接跳转** | ❌ 编号错位 |
| T-016 部署配置 | **T-016 部署配置** | ❌ 编号错位 |
| T-017 集成测试 | **T-017 集成测试** | ❌ 编号错位 |
**影响**:
- 🚫 **文档引用混乱**: 在 DevelopmentPlan 中看到的 T-013 和 tasks.md 中的 T-013 不是同一个任务
- 🚫 **沟通成本高**: 开发人员需要在两个文档之间切换时手动对照编号
- 🚫 **代码注释/提交信息错误**: Git 提交信息中的任务 ID 可能指向错误的任务
**建议修复**:
**方案A (推荐): 将 T-012 改为 T-008A**
```markdown
| T-008 | 查询表单组件 | ... | P0 | T-003 |
| T-008A | 主页面集成 | ... | P0 | T-008, T-009, T-011 |
| T-009 | 结果表格组件 | ... | P1 | T-003, T-006, T-007 |
```
- 优点: Phase 3 编号与 DevelopmentPlan 完全一致
- 缺点: 引入子编号
**方案B: 更新 DevelopmentPlan.md**
在 DevelopmentPlan.md 的 Phase 2 增加 T-012 任务
- 优点: 保持 tasks.md 不变
- 缺点: 需要修改 DevelopmentPlan.md
**方案C: 在 tasks.md 增加对照表**
```markdown
## 附录: 与 DevelopmentPlan 任务编号对照
| tasks.md | DevelopmentPlan | 任务名称 |
|----------|-----------------|----------|
| T-013 | T-013 | 错误处理 |
| T-014 | T-014 | 性能优化 |
...
```
- 优点: 不修改编号,只增加对照表
- 缺点: 需要手动查表,增加认知负担
---
### 一般问题 (Major) ### 一般问题 (Major)
1. 任务统计与优先级说明与实际任务清单不一致,且缺少迭代任务计数,导致计划与执行口径不统一,影响排期与资源分配。参考: doc/tasks.md:29-35, doc/tasks.md:189-201, doc/tasks.md:337-357
#### M-1: T-002 真实数据库测试要求缺少环境准备说明 2. 依赖图、执行检查清单、里程碑均未覆盖 T-019~T-026 迭代任务,迭代工作缺少清晰执行路径与交付节点,容易被遗漏或排期错误。参考: doc/tasks.md:113-187, doc/tasks.md:337-357
**位置**: [doc/tasks.md:46](doc/tasks.md:46) 3. 迭代任务T-019~T-026未在上游 DevelopmentPlan/UIDesign 中体现,且 T-026 为新页面无 UI 设计依据,存在范围漂移与验收依据不一致风险。参考: doc/tasks.md:337-357, doc/DevelopmentPlan.md:246-318, doc/UIDesign.md:31-128
4. 多处任务要求真实数据库/性能/覆盖率验证,但未定义数据准备与测试环境前置条件,可能导致 T-002/T-014/T-017/T-018 无法直接执行。参考: doc/tasks.md:49, doc/tasks.md:103, doc/tasks.md:108-110
**问题描述**:
```markdown
6. **真实数据库测试**: 使用 .env 中的连接字符串连接真实数据库并验证
```
验收标准要求连接"真实数据库",但没有说明:
- 真实数据库是否已经准备好?
- 数据库中是否有测试数据?
- 需要什么权限?
**影响**:
- 开发者执行到 T-002 时可能发现数据库环境未就绪
- 导致任务阻塞,无法继续
**建议修复**:
1. 在 T-002 依赖中增加: `依赖: T-001B (后端初始化), 数据库环境准备 (DBA)`
2. 在 T-004 环境变量配置中增加验收标准: "数据库连接字符串配置完成,数据库可访问"
3. 或在任务描述中明确标注: "需提前准备测试数据库环境,包含表结构和测试数据"
---
#### M-2: T-012 主页面集成缺少状态管理方案说明
**位置**: [doc/tasks.md:85](doc/tasks.md:85)
**问题描述**:
```markdown
6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态
```
验收标准提到"页面状态管理",但没有说明使用何种状态管理方案:
- React useState?
- Zustand?
- Redux Toolkit?
- Context API?
**影响**:
- 前端开发者需要自行决定状态管理方案
- 可能导致过度设计(引入 Redux)或过于简单(难以维护)
**建议修复**:
在验收标准第6条补充说明:
```markdown
6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态 **(使用 React useState 管理,无需第三方库)**
```
---
#### M-3: T-007 品牌API并发限制和超时参数硬编码
**位置**: [doc/tasks.md:64](doc/tasks.md:64)
**问题描述**:
```markdown
3. 使用 asyncio.gather 批量并发请求(限制 10 并发)
6. 超时设置: 3秒
```
验收标准硬编码了"10 并发"和"3 秒",未说明这些参数是否可配置。
**影响**:
- 生产环境可能需要调整并发数(如品牌API限流时降低并发)
- 超时时间可能需要根据网络环境调整
- 硬编码参数难以适应不同环境
**建议修复**:
1. 将并发限制和超时时间配置到环境变量或配置文件
2. 修改验收标准:
```markdown
3. 使用 asyncio.gather 批量并发请求,并发数可配置(默认 10)
6. 超时时间可配置(默认 3 秒)
7. 从环境变量读取配置: BRAND_API_CONCURRENCY, BRAND_API_TIMEOUT
```
---
#### M-4: T-009 与 T-010 字段一致性验证缺失
**位置**: [doc/tasks.md:76](doc/tasks.md:76)
**问题描述**:
T-009 (前端表格) 和 T-010 (后端导出) 都要求"使用中文列名",但没有明确如何保证字段一致性。
**当前状态**:
- T-009 验收标准: "展示 26 个字段,使用中文列名"
- T-010 验收标准: "使用中文列名作为表头 **(与 T-009 ResultTable 字段一致)**"
**问题**:
- "字段一致"如何验证?
- 前端和后端是否共享字段定义?
**影响**:
- 前端展示和导出文件的列名可能不一致
- 导致用户混淆
**建议修复**:
1. 创建共享的字段定义文件:
```typescript
// shared/types/fields.ts
export const VIDEO_FIELDS = [
{ key: 'item_id', label: '视频ID', width: 120 },
{ key: 'title', label: '视频标题', width: 200 },
// ... 24 more fields
] as const;
```
2. 修改 T-009 验收标准:
```markdown
2. 展示 26 个字段,使用共享字段定义文件 (shared/types/fields.ts)
```
3. 修改 T-010 验收标准:
```markdown
5. 使用共享字段定义文件作为表头,保证与前端表格字段顺序和命名完全一致
```
---
#### M-5: T-014 性能优化缺少性能测试脚本
**位置**: [doc/tasks.md:96](doc/tasks.md:96)
**问题描述**:
T-014 定义了明确的性能指标:
- 查询响应时间 ≤ 3秒 (100条)
- 页面加载时间 ≤ 2秒
- 导出响应时间 ≤ 5秒 (1000条)
但验收标准只有"验证索引已创建",没有要求编写性能测试脚本。
**影响**:
- 性能指标难以自动化验证
- 依赖人工测试,可能遗漏
- 回归测试时无法快速验证性能
**建议修复**:
增加验收标准:
```markdown
6. **后端性能测试**: 编写性能测试脚本,验证响应时间指标
7. **真实数据库测试**: 使用真实数据库和测试数据进行性能测试
8. 性能测试报告: 生成性能测试报告,记录实际响应时间
```
---
#### M-6: T-017 集成测试缺少性能测试用例
**位置**: [doc/tasks.md:101](doc/tasks.md:101)
**问题描述**:
T-017 集成测试有 8 个功能测试用例,但未包含 T-014 定义的性能指标验证。
**建议修复**:
在验收标准中增加性能测试用例:
```markdown
9. 测试用例: 性能指标验证 (查询≤3秒、导出≤5秒)
10. **真实数据库集成测试**: 使用 .env 中的真实数据库连接进行完整集成测试
11. **后端测试覆盖率验证**: 确认所有后端代码测试覆盖率 ≥ 100%
12. **测试报告生成**: 使用 pytest-cov 生成覆盖率报告
```
(注: 验收标准 10-12 已存在,只需增加第9条)
---
### 改进建议 (Minor) ### 改进建议 (Minor)
1. 主页面标题与 UIDesign 头部文案不一致(缺少“云图数据查询分析”),建议补齐以满足品牌一致性。参考: doc/tasks.md:91, doc/UIDesign.md:80-82
#### S-1: 前端"粗略实现"说明不够具体 2. 覆盖率验收任务 T-018 同时包含指标定义、报告产出、CI 集成建议拆分为“覆盖率验收”与“CI 集成”以降低任务粒度。参考: doc/tasks.md:110
**位置**: [doc/tasks.md:74, 76, 78, 85](doc/tasks.md)
**问题描述**:
T-008/T-009/T-011/T-012 都标注了"粗略实现说明",但"粗略"的标准不明确。
**建议**:
在任务总览或关键技术点章节定义"粗略实现"标准:
```markdown
## 前端"粗略实现"标准
本项目前端采用"功能优先、样式从简"的开发策略:
- ✅ **功能完整**: 所有功能可用,交互流程完整
- ✅ **样式简洁**: 使用 Tailwind 默认样式,无需过度美化
- ✅ **品牌元素保留**: Logo、品牌色、品牌声明必须体现
- ❌ **暂不支持**: 响应式适配、动画效果、深度优化
```
---
#### S-2: 建议增加任务估时
**位置**: 整个 tasks.md
**问题描述**:
所有任务都没有工作量估时,无法评估项目整体时间和关键路径。
**建议**:
在任务总览表格增加"估时"列:
```markdown
| ID | 任务 | 描述 | 优先级 | 依赖 | 估时 | 验收标准 |
|----|------|------|--------|------|------|----------|
| T-001 | 项目初始化 | ... | P0 | - | 1天 | ... |
```
**参考估时** (仅供参考):
- T-001: 1天 (前后端分离后: 0.5天 × 2)
- T-002: 1天
- T-005: 2天 (含 TDD)
- T-009: 2天
- T-012: 2天
---
#### S-3: T-016 部署配置缺少监控和日志方案
**位置**: [doc/tasks.md:99](doc/tasks.md:99)
**问题描述**:
T-016 部署配置只涉及 Docker 和环境变量,未涉及生产环境监控和日志收集。
**建议**:
增加验收标准:
```markdown
8. 日志配置: 前端 console 输出,后端使用 Python logging 模块输出到文件
9. (可选) 监控配置: 接入 Sentry 或 Prometheus 进行错误监控
```
---
#### S-4: 任务依赖图与实际任务ID不一致
**位置**: [doc/tasks.md:105](doc/tasks.md:105)
**问题描述**:
第5节"任务依赖图"仍使用 DevelopmentPlan 的任务编号,与 tasks.md 实际任务ID不一致。
**建议修复**:
更新任务依赖图,使用 tasks.md 的任务ID (T-001~T-017):
```
Phase 1: 基础架构
T-001 (项目初始化)
├── T-002 (数据库配置)
├── T-003 (基础UI框架)
└── T-004 (环境变量配置)
Phase 2: 核心功能
T-002 ──▶ T-005 (查询API) ──▶ T-006 (计算逻辑) ──▶ T-009 (结果表格)
│ │ │
└──▶ T-007 (品牌API) │ │
│ │
T-003 ──▶ T-008 (查询表单) │ │
│ │
T-010 (导出API) ◀───────────────┤
│ │
T-011 (导出按钮) ◀──────────────┤
T-008, T-009, T-011 ──▶ T-012 (主页面集成) ────────────┘
Phase 3: 优化测试
T-012 ──▶ T-013 (错误处理) ──▶ T-014 (性能优化)
│ │
├──▶ T-015 (视频链接) │
│ │
└──▶ T-016 (部署配置) │
T-017 (集成测试)
```
---
#### S-5: 建议增加功能ID(F-xxx)对应关系
**位置**: 整个 tasks.md
**建议**:
在"关联功能"列增加功能ID引用,便于追溯需求:
```markdown
| ID | 任务 | 描述 | 优先级 | 依赖 | 关联功能 | 验收标准 |
|----|------|------|--------|------|----------|----------|
| T-005 | 查询 API 开发 | ... | P0 | T-002 | F-001, F-002, F-003 | ... |
| T-006 | 计算逻辑实现 | ... | P0 | T-005 | F-004, F-005, F-006 | ... |
```
---
## 依赖关系分析
### 关键路径
```
T-001 (项目初始化)
├─→ T-002 (数据库配置)
│ │
│ └─→ T-005 (查询API)
│ │
│ ├─→ T-006 (计算逻辑)
│ │ │
│ │ └─→ T-010 (导出API)
│ │
│ └─→ T-007 (品牌API)
│ │
│ └─→ T-009 (结果表格)
│ │
│ └─→ T-012 (主页面集成)
│ │
│ └─→ T-013 (错误处理)
│ │
│ └─→ T-017 (集成测试)
└─→ T-003 (基础UI)
└─→ T-008 (查询表单)
└─→ T-012 (主页面集成)
```
**关键路径**:
T-001 → T-002 → T-005 → T-007 → T-009 → T-012 → T-013 → T-017
**可并行任务**:
- T-002 (数据库) 和 T-003 (基础UI) 可并行
- T-006 (计算逻辑) 和 T-007 (品牌API) 可并行
- T-013/T-014/T-015 可并行
---
## 评审结论 ## 评审结论
### 评审结果 需修改后通过。
🟡 **需修改后通过** ### 下一步行动
- [ ] 对齐任务总数与优先级统计,补充迭代任务到依赖图/清单/里程碑
--- - [ ] 将 T-019~T-026 同步到 DevelopmentPlan/UIDesign或明确为独立迭代范围
- [ ] 增加真实数据库与测试数据准备任务(含环境获取方式)
### 主要优点 - [ ] 修正页面头部文案与 UIDesign 一致
**覆盖度完整**:
- 所有 DevelopmentPlan (16个任务) 和 UIDesign (10个组件) 都有对应任务
- 新增 T-012 主页面集成任务是合理补充
**验收标准详细**:
- 每个任务平均 6.2 条验收标准
- 验收标准具体可操作,便于验收
- T-006/T-014/T-017 的验收标准特别优秀
**TDD 要求明确**:
- 7个关键后端任务都要求先写测试再写代码
- 明确要求 100% 测试覆盖率和真实数据库测试
**架构更新到位**:
- 任务描述已完全更新为前后端分离架构 (FastAPI + Next.js)
- 品牌元素(麦秒思AI)在任务中明确体现
---
### 关键问题
**严重问题** (必须修复):
1. **C-1: T-001 粒度过大** - 前后端初始化应拆分,支持并行开发
2. **C-2: T-010 依赖错误** - 后端 API 不应依赖前端组件 T-009
3. **C-3: 缺少测试独立任务** - 100% 覆盖率需要独立验收里程碑
4. **C-4: 任务编号不一致** - Phase 3 任务编号与 DevelopmentPlan 错位
⚠️ **一般问题** (建议修复):
1. **M-1: T-002 数据库环境准备** - 需明确数据库环境前置条件
2. **M-2: T-012 状态管理方案** - 建议使用 React useState
3. **M-3: T-007 参数硬编码** - 并发和超时应可配置
4. **M-4: T-009/T-010 字段一致性** - 建议共享字段定义文件
5. **M-5: T-014 性能测试脚本** - 需编写自动化性能测试
6. **M-6: T-017 性能测试用例** - 集成测试应包含性能验证
---
### 影响评估
**阻塞性问题**:
- 🚫 **C-1 (T-001 粒度过大)**: 导致前后端无法并行开发,延长项目周期
- 🚫 **C-2 (T-010 依赖错误)**: 导致执行顺序混乱,前后端耦合
**质量风险**:
- ⚠️ **C-3 (缺少测试任务)**: 100% 覆盖率难以保证,可能降低代码质量
- ⚠️ **M-5/M-6 (性能测试缺失)**: 性能指标无法自动化验证
**进度风险**:
- ⚠️ **M-1 (数据库环境未就绪)**: 可能导致 T-002 阻塞
- ⚠️ **无任务估时**: 难以评估项目整体进度和关键路径
---
## 下一步行动
### 必须修改 (Critical) - 预估 1.5 小时
- [ ] **C-1: 拆分 T-001** 为 T-001A (前端初始化) 和 T-001B (后端初始化)
- 预估时间: 30分钟
- 影响范围: tasks.md, DevelopmentPlan.md
- [ ] **C-2: 修正 T-010 依赖** 移除 T-009,改为 `T-006, T-007`
- 预估时间: 10分钟
- 影响范围: tasks.md:67
- [ ] **C-3: 增加测试任务** 在 Phase 3 增加 T-018 测试覆盖率验收
- 预估时间: 20分钟
- 影响范围: tasks.md Phase 3
- [ ] **C-4: 统一任务编号** 选择方案A/B/C 修复编号不一致问题
- 预估时间: 30分钟
- 影响范围: tasks.md 或 DevelopmentPlan.md
---
### 建议修改 (Major) - 预估 1 小时
- [ ] **M-1: T-002 数据库环境说明** 明确数据库准备前置条件
- 预估时间: 10分钟
- [ ] **M-2: T-012 状态管理说明** 补充 React useState 方案
- 预估时间: 5分钟
- [ ] **M-3: T-007 参数配置化** 并发和超时改为可配置
- 预估时间: 15分钟
- [ ] **M-4: T-009/T-010 字段一致性** 增加共享字段定义要求
- 预估时间: 15分钟
- [ ] **M-5: T-014 性能测试脚本** 增加性能测试验收标准
- 预估时间: 10分钟
- [ ] **M-6: T-017 性能测试用例** 增加性能测试用例
- 预估时间: 5分钟
---
### 可选优化 (Minor) - 预估 1 小时
- [ ] **S-1: 定义"粗略实现"标准** 增加前端开发标准说明
- [ ] **S-2: 增加任务估时** 为每个任务增加工作量估时(人天)
- [ ] **S-3: T-016 监控配置** 增加日志和监控验收标准
- [ ] **S-4: 更新依赖图** 使用 tasks.md 的实际任务ID
- [ ] **S-5: 增加功能ID** 在关联功能列增加 F-xxx 引用
---
### 修复优先级汇总
| 优先级 | 问题ID | 问题描述 | 预估时间 | 阻塞风险 |
|--------|--------|----------|----------|----------|
| P0 | C-1 | T-001 拆分 | 30分钟 | ⚠️ 高 |
| P0 | C-2 | T-010 依赖修正 | 10分钟 | ⚠️ 高 |
| P0 | C-3 | 增加测试任务 | 20分钟 | ⚠️ 中 |
| P0 | C-4 | 统一任务编号 | 30分钟 | ⚠️ 中 |
| P1 | M-1~M-6 | 6个一般问题 | 60分钟 | ⚠️ 低 |
| P2 | S-1~S-5 | 5个改进建议 | 60分钟 | ✅ 无 |
**预计修复总时间**: 约 3.5 小时 (P0-P2 全部)
---
## 参考信息
### 文档链接
- 目标文档: [doc/tasks.md](doc/tasks.md)
- 上游文档1: [doc/UIDesign.md](doc/UIDesign.md) - UI 设计文档
- 上游文档2: [doc/DevelopmentPlan.md](doc/DevelopmentPlan.md) - 开发计划
### 修改建议操作
建议使用 `/mt` 命令根据本评审报告的问题清单进行增量修改:
```bash
/mt # 增量修改 tasks.md
```
---
**评审人**: Claude Sonnet 4.5
**评审日期**: 2026-01-28 15:30
**评审版本**: tasks.md v1.0
**评审耗时**: 45 分钟
**评审方法**: 基于 `/rt` 评审技能,对比 UIDesign.md 和 DevelopmentPlan.md

View File

@ -26,12 +26,12 @@
## 1. 任务总览 ## 1. 任务总览
<!-- MODIFIED: 更新任务统计,T-001拆分+T-018新增 --> <!-- MODIFIED: 更新任务统计,T-001拆分+T-018新增+T-019~T-027迭代任务 -->
| 统计项 | 数量 | | 统计项 | 数量 |
|--------|------| |--------|------|
| 总任务数 | 18 | | 总任务数 | 27 |
| P0 任务 | 10 | | P0 任务 | 17 |
| P1 任务 | 7 | | P1 任务 | 9 |
| P2 任务 | 1 | | P2 任务 | 1 |
## 2. Phase 1 任务 - 基础架构搭建 ## 2. Phase 1 任务 - 基础架构搭建
@ -48,7 +48,7 @@
<!-- MODIFIED: 依赖改为 T-001B (后端初始化) --> <!-- MODIFIED: 依赖改为 T-001B (后端初始化) -->
| T-002 | 数据库配置 | 配置 SQLAlchemy,定义数据模型,连接 PostgreSQL | P0 | T-001B | 1. SQLAlchemy 2.0+ 和 asyncpg 安装完成<br>2. 定义 KolVideo 模型(使用 SQLAlchemy ORM)<br>3. 数据库异步连接成功<br>4. 索引创建: star_id, star_unique_id, star_nickname<br>5. Alembic 迁移工具配置完成<br><!-- NEW START -->6. **真实数据库测试**: 使用 .env 中的连接字符串连接真实数据库并验证<br>7. **TDD要求**: 编写数据库连接测试,模型测试,CRUD测试<br>8. **测试覆盖率**: 数据库操作测试覆盖率 ≥ 100%<!-- NEW END --> | | T-002 | 数据库配置 | 配置 SQLAlchemy,定义数据模型,连接 PostgreSQL | P0 | T-001B | 1. SQLAlchemy 2.0+ 和 asyncpg 安装完成<br>2. 定义 KolVideo 模型(使用 SQLAlchemy ORM)<br>3. 数据库异步连接成功<br>4. 索引创建: star_id, star_unique_id, star_nickname<br>5. Alembic 迁移工具配置完成<br><!-- NEW START -->6. **真实数据库测试**: 使用 .env 中的连接字符串连接真实数据库并验证<br>7. **TDD要求**: 编写数据库连接测试,模型测试,CRUD测试<br>8. **测试覆盖率**: 数据库操作测试覆盖率 ≥ 100%<!-- NEW END --> |
<!-- MODIFIED: 依赖改为 T-001A (前端初始化) --> <!-- MODIFIED: 依赖改为 T-001A (前端初始化) -->
| T-003 | 基础 UI 框架 | 安装 Tailwind CSS,创建基础布局组件 | P0 | T-001A | 1. Tailwind CSS 配置完成<br>2. 品牌色系配置 (#4F46E5等)<br>3. 基础布局组件创建 (Header/Footer)<br>4. 秒思AI Logo 集成 (doc/ui/muse.svg) | | T-003 | 基础 UI 框架 | 安装 Tailwind CSS,创建基础布局组件 | P0 | T-001A | 1. Tailwind CSS 配置完成<br>2. 品牌色系配置 (#4F46E5等)<br>3. 基础布局组件创建 (Header/Footer)<br>4. 秒思AI Logo 集成 (doc/ui/muse.svg) |
<!-- MODIFIED: 依赖改为 T-001A, T-001B (前后端都需要环境变量) --> <!-- MODIFIED: 依赖改为 T-001A, T-001B (前后端都需要环境变量) -->
| T-004 | 环境变量配置 | 配置开发/生产环境变量,数据库连接字符串 | P0 | T-001A, T-001B | 1. 前后端 .env.example 创建<br>2. 后端 DATABASE_URL 配置<br>3. 后端品牌 API 地址配置<br>4. 前端 NEXT_PUBLIC_API_URL 配置<br>5. .env 文件创建并添加到 .gitignore | | T-004 | 环境变量配置 | 配置开发/生产环境变量,数据库连接字符串 | P0 | T-001A, T-001B | 1. 前后端 .env.example 创建<br>2. 后端 DATABASE_URL 配置<br>3. 后端品牌 API 地址配置<br>4. 前端 NEXT_PUBLIC_API_URL 配置<br>5. .env 文件创建并添加到 .gitignore |
@ -88,7 +88,7 @@
|----|------|------|--------|------|----------| |----|------|------|--------|------|----------|
<!-- MODIFIED: 简化前端实现要求 --> <!-- MODIFIED: 简化前端实现要求 -->
<!-- MODIFIED: 任务编号改为 T-011A,统一与 DevelopmentPlan 编号体系 (根据评审报告 C-4) --> <!-- MODIFIED: 任务编号改为 T-011A,统一与 DevelopmentPlan 编号体系 (根据评审报告 C-4) -->
| T-011A | 主页面集成 | 集成查询表单、结果表格和导出按钮,完成单页应用 **(前端粗略实现)** | P0 | T-008, T-009, T-011 | 1. page.tsx 创建单页应用<br>2. 品牌头部: Logo + "KOL Insight" + "秒思AI制作"<br>3. 查询区域集成 QueryForm<br>4. 结果区域集成 ResultTable 和 ExportButton<br>5. Footer: "© 2026 秒思AI制作"<br>6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态<br>7. 空状态组件: 引导文案 + 空盒子图标<br>8. 错误状态组件: 错误提示 + 重试按钮<br><!-- NEW START -->9. **粗略实现说明**: 重点在功能集成,UI可简化,品牌元素必须保留<!-- NEW END --> | | T-011A | 主页面集成 | 集成查询表单、结果表格和导出按钮,完成单页应用 **(前端粗略实现)** | P0 | T-008, T-009, T-011 | 1. page.tsx 创建单页应用<br>2. 品牌头部: Logo + "KOL Insight" + "秒思AI制作"<br>3. 查询区域集成 QueryForm<br>4. 结果区域集成 ResultTable 和 ExportButton<br>5. Footer: "© 2026 秒思AI制作"<br>6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态<br>7. 空状态组件: 引导文案 + 空盒子图标<br>8. 错误状态组件: 错误提示 + 重试按钮<br><!-- NEW START -->9. **粗略实现说明**: 重点在功能集成,UI可简化,品牌元素必须保留<!-- NEW END --> |
## 4. Phase 3 任务 - 优化与测试 ## 4. Phase 3 任务 - 优化与测试
@ -334,6 +334,34 @@ async with httpx.AsyncClient() as client:
--- ---
<!-- ITER: 2026-01-28 - 修复品牌API响应解析+添加认证 -->
## 12. 迭代任务
### 12.1 Bug 修复
| ID | 任务 | 描述 | 依赖 | 优先级 | 验收标准 |
|----|------|------|------|--------|----------|
| T-019 | 修复品牌API响应解析 | 品牌API返回的data是数组结构,当前代码按字典解析导致取不到brand_name | T-007 | P0 | 1. 正确解析 `data[0].brand_name` 获取品牌名称<br>2. 处理 data 数组为空的边界情况<br>3. 更新测试用例的 mock 数据结构 |
| T-020 | 添加品牌API认证 | 品牌API需要Bearer Token认证,当前代码未配置 | T-019 | P0 | 1. 新增环境变量 `BRAND_API_TOKEN`<br>2. 请求时添加 `Authorization: Bearer {token}`<br>3. 更新 `.env.example` 配置示例<br>4. 更新测试用例验证认证头 |
<!-- ITER: 2026-01-28 - 修复巨量云图API调用参数问题 -->
| T-027 | 修复巨量云图API调用参数 | API调用不通,日期格式/Cookie头/industry_id等参数错误 | T-023 | P0 | 1. **日期格式**: 从 `YYYY-MM-DD` 改为 `YYYYMMDD`<br>2. **Cookie头**: 直接使用 `auth_token` 完整值(已含sessionid=xxx)<br>3. **industry_id**: 使用数据库中视频的industry_id,传字符串格式 `["12"]`<br>4. **Cookie获取**: 随机选取任意一组aadvid/auth_token,不按brand_id匹配<br>5. 更新测试用例验证参数格式<br>6. **TDD要求**: 测试实际API调用成功返回数据 |
<!-- ITER: 2026-01-28 - 新增巨量云图视频分析功能 -->
### 12.2 功能迭代 - 视频分析模块
| ID | 任务 | 描述 | 依赖 | 优先级 | 验收标准 |
|----|------|------|------|--------|----------|
| T-021 | SessionID池服务 | 实现从内部API获取Cookie列表,随机选取sessionid | T-004 | P0 | 1. 调用 `/v1/yuntu/get_cookie` 获取100个sessionid<br>2. 随机选取机制实现<br>3. 环境变量 `YUNTU_API_TOKEN` 配置<br>4. **TDD要求**: 先写测试用例(mock API响应) |
| T-022 | SessionID自动重试 | sessionid失效时自动切换到下一个重试 | T-021 | P0 | 1. 检测401/403状态码触发重试<br>2. 最多重试3次<br>3. 重试日志记录<br>4. **TDD要求**: 测试覆盖重试场景 |
| T-023 | 巨量云图API封装 | 封装GetContentMaterialAnalysisInfo接口调用 | T-022 | P0 | 1. 正确构造请求参数(object_id/start_date/end_date/industry_id_list)<br>2. end_date = start_date + 30天<br>3. Cookie头设置sessionid<br>4. 超时10秒<br>5. **TDD要求**: 测试参数构造和响应解析 |
| T-024 | 视频分析数据接口 | 实现 GET /api/v1/videos/{item_id}/analysis | T-023 | P0 | 1. 从数据库获取基础信息<br>2. 调用巨量云图API获取实时数据<br>3. 返回6大类指标结构<br>4. 计算成本指标(CPM/CPA3/CPsearch等)<br>5. 除零检查返回null<br>6. **TDD要求**: 测试覆盖率100% |
| T-025 | 数据库A3指标更新 | 从API获取数据后更新数据库对应字段 | T-024 | P1 | 1. 更新 total_new_a3_cnt<br>2. 更新 heated_new_a3_cnt<br>3. 更新 natural_new_a3_cnt<br>4. 更新 total_cost<br>5. **TDD要求**: 测试数据库更新逻辑 |
| T-026 | 视频分析前端页面 | 前端展示6大类25+指标(粗略实现) | T-024 | P1 | 1. 基础信息展示(8字段)<br>2. 触达指标展示(7字段)<br>3. A3指标展示(3字段)<br>4. 搜索指标展示(5字段)<br>5. 费用指标展示(3字段)<br>6. 成本指标展示(6字段)<br>7. 数值格式化(千分位/2位小数)<br>8. **粗略实现**: 功能可用即可 |
---
**文档状态**: 待执行 **文档状态**: 待执行
**建议下一步**: 按顺序执行 Phase 1 任务,完成基础架构搭建 **建议下一步**:
- **最高优先级**: 执行 T-027 修复巨量云图API调用参数问题
- 然后验证视频分析功能 T-021~T-026 是否正常工作
**评审建议**: 可运行 `/rt` 对任务列表进行评审 **评审建议**: 可运行 `/rt` 对任务列表进行评审

View File

@ -9,6 +9,8 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^6.2.2",
"next": "14.2.35", "next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18"

867
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ const geistMono = localFont({
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'KOL Insight - 云图数据查询分析', title: 'KOL Insight - 云图数据查询分析',
description: 'KOL 视频数据查询与成本分析工具 - 秒思AI制作', description: 'KOL 视频数据查询与成本分析工具 - 秒思AI制作',
}; };
export default function RootLayout({ export default function RootLayout({

View File

@ -1,117 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import VideoAnalysis from '@/components/VideoAnalysis';
import { QueryForm, ResultTable, ExportButton } from '@/components';
import { QueryType, VideoData, PageState } from '@/types';
import { queryVideos } from '@/lib/api';
export default function Home() { export default function Home() {
const [pageState, setPageState] = useState<PageState>('default'); return <VideoAnalysis />;
const [data, setData] = useState<VideoData[]>([]);
const [total, setTotal] = useState(0);
const [error, setError] = useState<string | null>(null);
const handleQuery = async (type: QueryType, values: string[]) => {
setPageState('loading');
setError(null);
try {
const response = await queryVideos({ type, values });
if (response.success) {
setData(response.data);
setTotal(response.total);
setPageState(response.total > 0 ? 'result' : 'empty');
} else {
setError(response.error || '查询失败');
setPageState('error');
}
} catch (err) {
console.error('Query error:', err);
setError(err instanceof Error ? err.message : '网络错误,请检查后端服务是否正常');
setPageState('error');
}
};
const handleRetry = () => {
setPageState('default');
setError(null);
setData([]);
setTotal(0);
};
return (
<div className="max-w-7xl mx-auto px-4 py-8">
{/* 查询区域 */}
<section className="mb-8">
<QueryForm onSubmit={handleQuery} isLoading={pageState === 'loading'} />
</section>
{/* 结果区域 */}
<section>
{/* 默认态 */}
{pageState === 'default' && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="text-gray-400 text-6xl mb-4">🔍</div>
<p className="text-gray-500 mb-4"></p>
<a
href="/analysis"
className="text-sm text-indigo-600 hover:text-indigo-800"
>
</a>
</div>
)}
{/* 加载态 */}
{pageState === 'loading' && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="animate-spin text-primary text-4xl mb-4"></div>
<p className="text-gray-500">...</p>
</div>
)}
{/* 结果态 */}
{pageState === 'result' && (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-medium text-gray-900"></h2>
<ExportButton hasData={total > 0} />
</div>
<ResultTable data={data} total={total} />
</div>
)}
{/* 空结果态 */}
{pageState === 'empty' && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="text-gray-400 text-6xl mb-4">📦</div>
<p className="text-gray-700 mb-2"></p>
<p className="text-gray-500 text-sm mb-4"></p>
<button
onClick={handleRetry}
className="px-4 py-2 text-sm font-medium text-primary border border-primary rounded hover:bg-primary hover:text-white"
>
</button>
</div>
)}
{/* 错误态 */}
{pageState === 'error' && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="text-error text-6xl mb-4"></div>
<p className="text-gray-700 mb-2"></p>
<p className="text-gray-500 text-sm mb-4">{error || '可能原因:网络异常或数据库连接失败'}</p>
<button
onClick={handleRetry}
className="px-4 py-2 text-sm font-medium text-white bg-primary rounded hover:bg-primary-dark"
>
</button>
</div>
)}
</section>
</div>
);
} }

View File

@ -2,7 +2,7 @@ export default function Footer() {
return ( return (
<footer className="bg-gray-50 border-t border-gray-200 py-4 px-6"> <footer className="bg-gray-50 border-t border-gray-200 py-4 px-6">
<div className="max-w-7xl mx-auto text-center text-sm text-gray-500"> <div className="max-w-7xl mx-auto text-center text-sm text-gray-500">
© 2026 AI制作 | KOL Insight v1.0 © 2026 AI制作 | KOL Insight v1.0
</div> </div>
</footer> </footer>
); );

View File

@ -5,13 +5,13 @@ export default function Header() {
<header className="bg-white border-b border-gray-200 py-4 px-6"> <header className="bg-white border-b border-gray-200 py-4 px-6">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Image src="/muse.svg" alt="秒思AI Logo" width={40} height={40} priority /> <Image src="/muse.svg" alt="秒思AI Logo" width={40} height={40} priority />
<div> <div>
<h1 className="text-xl font-bold text-gray-900">KOL Insight</h1> <h1 className="text-xl font-bold text-gray-900">KOL Insight</h1>
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-500"></p>
</div> </div>
</div> </div>
<div className="text-sm text-gray-500">AI制作</div> <div className="text-sm text-gray-500">AI制作</div>
</div> </div>
</header> </header>
); );

View File

@ -1,178 +1,386 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Table, Input, Select, Button, Card, Space, message, Modal, Descriptions, Spin } from 'antd';
import { SearchOutlined, EyeOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { VideoAnalysisData } from '@/types'; import { VideoAnalysisData } from '@/types';
import { getVideoAnalysis } from '@/lib/api'; import { searchVideos, VideoListItem, getVideoAnalysis } from '@/lib/api';
// 格式化数字(千分位) // 搜索类型选项
type SearchType = 'star_id' | 'unique_id' | 'nickname';
const SEARCH_TYPE_OPTIONS = [
{ value: 'star_id' as SearchType, label: '星图ID' },
{ value: 'unique_id' as SearchType, label: '达人unique_id' },
{ value: 'nickname' as SearchType, label: '达人昵称' },
];
const SEARCH_PLACEHOLDER: Record<SearchType, string> = {
star_id: '请输入星图ID',
unique_id: '请输入达人unique_id',
nickname: '请输入达人昵称关键词',
};
// 格式化数字千分位保留2位小数
function formatNumber(num: number | null | undefined): string { function formatNumber(num: number | null | undefined): string {
if (num === null || num === undefined) return '-'; if (num === null || num === undefined) return '-';
return num.toLocaleString('zh-CN'); return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
// 格式化金额保留2位小数 // 格式化整数(千分位
function formatCurrency(num: number | null | undefined): string { function formatInt(num: number | null | undefined): string {
if (num === null || num === undefined) return '-'; if (num === null || num === undefined) return '-';
return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return Math.round(num).toLocaleString('zh-CN');
} }
// 指标卡片组件 // 详情弹窗组件
function MetricCard({ label, value, unit }: { label: string; value: string; unit?: string }) { function DetailModal({
visible,
data,
loading,
onClose,
}: {
visible: boolean;
data: VideoAnalysisData | null;
loading: boolean;
onClose: () => void;
}) {
return ( return (
<div className="bg-white border border-gray-200 rounded-lg p-3"> <Modal
<div className="text-sm text-gray-500 mb-1">{label}</div> title="视频详情"
<div className="text-lg font-semibold text-gray-900"> open={visible}
{value} onCancel={onClose}
{unit && <span className="text-sm font-normal text-gray-500 ml-1">{unit}</span>} footer={null}
</div> width={900}
</div> styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text' } }}
); >
} {loading ? (
<div style={{ textAlign: 'center', padding: 50 }}>
<Spin size="large" />
</div>
) : data ? (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* 基础信息 */}
<Descriptions title="基础信息" bordered size="small" column={2}>
<Descriptions.Item label="达人昵称">{data.base_info.star_nickname || '-'}</Descriptions.Item>
<Descriptions.Item label="达人unique_id">{data.base_info.star_unique_id || '-'}</Descriptions.Item>
<Descriptions.Item label="视频ID">{data.base_info.vid || '-'}</Descriptions.Item>
<Descriptions.Item label="发布时间">{data.base_info.create_date || '-'}</Descriptions.Item>
<Descriptions.Item label="爆文类型">{data.base_info.hot_type || '-'}</Descriptions.Item>
<Descriptions.Item label="合作行业">{data.base_info.industry_id || '-'}</Descriptions.Item>
<Descriptions.Item label="合作品牌">{data.base_info.brand_name || data.base_info.brand_id || '-'}</Descriptions.Item>
<Descriptions.Item label="视频标题" span={2}>
{data.base_info.video_url ? (
<a href={data.base_info.video_url} target="_blank" rel="noopener noreferrer">
{data.base_info.title || '查看视频'}
</a>
) : (
data.base_info.title || '-'
)}
</Descriptions.Item>
</Descriptions>
// 指标分组组件 {/* 触达指标 */}
function MetricGroup({ title, children }: { title: string; children: React.ReactNode }) { <Descriptions title="触达指标" bordered size="small" column={4}>
return ( <Descriptions.Item label="自然曝光数">{formatInt(data.reach_metrics.natural_play_cnt)}</Descriptions.Item>
<div className="mb-6"> <Descriptions.Item label="加热曝光数">{formatInt(data.reach_metrics.heated_play_cnt)}</Descriptions.Item>
<h3 className="text-md font-semibold text-gray-800 mb-3 pb-2 border-b">{title}</h3> <Descriptions.Item label="总曝光数">{formatInt(data.reach_metrics.total_play_cnt)}</Descriptions.Item>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> <Descriptions.Item label="总互动">{formatInt(data.reach_metrics.total_interaction_cnt)}</Descriptions.Item>
{children} <Descriptions.Item label="点赞">{formatInt(data.reach_metrics.digg_cnt)}</Descriptions.Item>
</div> <Descriptions.Item label="转发">{formatInt(data.reach_metrics.share_cnt)}</Descriptions.Item>
</div> <Descriptions.Item label="评论">{formatInt(data.reach_metrics.comment_cnt)}</Descriptions.Item>
</Descriptions>
{/* A3指标 */}
<Descriptions title="A3指标" bordered size="small" column={3}>
<Descriptions.Item label="新增A3">{formatInt(data.a3_metrics.total_new_a3_cnt)}</Descriptions.Item>
<Descriptions.Item label="加热新增A3">{formatInt(data.a3_metrics.heated_new_a3_cnt)}</Descriptions.Item>
<Descriptions.Item label="自然新增A3">{formatInt(data.a3_metrics.natural_new_a3_cnt)}</Descriptions.Item>
</Descriptions>
{/* 搜索指标 */}
<Descriptions title="搜索指标" bordered size="small" column={3}>
<Descriptions.Item label="回搜人数">{formatInt(data.search_metrics.back_search_uv)}</Descriptions.Item>
<Descriptions.Item label="回搜次数">{formatInt(data.search_metrics.back_search_cnt)}</Descriptions.Item>
<Descriptions.Item label="看后搜人数">{formatInt(data.search_metrics.after_view_search_uv)}</Descriptions.Item>
<Descriptions.Item label="看后搜次数">{formatInt(data.search_metrics.after_view_search_cnt)}</Descriptions.Item>
<Descriptions.Item label="预估自然看后搜人数">{formatNumber(data.search_metrics.estimated_natural_search_uv)}</Descriptions.Item>
</Descriptions>
{/* 费用指标 */}
<Descriptions title="费用指标" bordered size="small" column={3}>
<Descriptions.Item label="预估总费用">{formatNumber(data.cost_metrics.total_cost)}</Descriptions.Item>
<Descriptions.Item label="预估加热费用">{formatNumber(data.cost_metrics.heated_cost)}</Descriptions.Item>
<Descriptions.Item label="预估视频采买费用">{formatNumber(data.cost_metrics.estimated_video_cost)}</Descriptions.Item>
</Descriptions>
{/* 成本指标 */}
<Descriptions title="成本指标" bordered size="small" column={3}>
<Descriptions.Item label="预估CPM">{formatNumber(data.calculated_metrics.estimated_cpm)}</Descriptions.Item>
<Descriptions.Item label="预估自然CPM">{formatNumber(data.calculated_metrics.estimated_natural_cpm)}</Descriptions.Item>
<Descriptions.Item label="预估CPA3">{formatNumber(data.calculated_metrics.estimated_cp_a3)}</Descriptions.Item>
<Descriptions.Item label="预估自然CPA3">{formatNumber(data.calculated_metrics.estimated_natural_cp_a3)}</Descriptions.Item>
<Descriptions.Item label="预估CPsearch">{formatNumber(data.calculated_metrics.estimated_cp_search)}</Descriptions.Item>
<Descriptions.Item label="自然CPsearch">{formatNumber(data.calculated_metrics.estimated_natural_cp_search)}</Descriptions.Item>
</Descriptions>
</Space>
) : null}
</Modal>
); );
} }
export default function VideoAnalysis() { export default function VideoAnalysis() {
const [itemId, setItemId] = useState(''); const [searchType, setSearchType] = useState<SearchType>('star_id');
const [searchValue, setSearchValue] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [listData, setListData] = useState<VideoListItem[]>([]);
const [data, setData] = useState<VideoAnalysisData | null>(null);
// 详情弹窗状态
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detailData, setDetailData] = useState<VideoAnalysisData | null>(null);
const handleSearch = async () => { const handleSearch = async () => {
if (!itemId.trim()) { if (!searchValue.trim()) {
setError('请输入视频ID'); message.warning(`请输入${SEARCH_TYPE_OPTIONS.find(o => o.value === searchType)?.label}`);
return; return;
} }
setLoading(true); setLoading(true);
setError(null);
try { try {
const response = await getVideoAnalysis(itemId.trim()); const response = await searchVideos({
type: searchType,
value: searchValue.trim(),
});
if (response.success) { if (response.success) {
setData(response.data); setListData(response.data as VideoListItem[]);
if ((response.data as VideoListItem[]).length === 0) {
message.info('未找到相关视频');
}
} else { } else {
setError(response.error || '获取数据失败'); message.error(response.error || '搜索失败');
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败'); message.error(err instanceof Error ? err.message : '搜索失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleViewDetail = async (itemId: string) => {
setDetailVisible(true);
setDetailLoading(true);
setDetailData(null);
try {
const response = await getVideoAnalysis(itemId);
if (response.success) {
setDetailData(response.data);
} else {
message.error(response.error || '获取详情失败');
setDetailVisible(false);
}
} catch (err) {
message.error(err instanceof Error ? err.message : '获取详情失败');
setDetailVisible(false);
} finally {
setDetailLoading(false);
}
};
// 表格列定义
const columns: ColumnsType<VideoListItem> = [
{
title: '达人昵称',
dataIndex: 'star_nickname',
key: 'star_nickname',
width: 120,
fixed: 'left',
render: (text) => text || '-',
},
{
title: '视频标题',
dataIndex: 'title',
key: 'title',
width: 200,
ellipsis: true,
render: (text, record) =>
record.video_url ? (
<a href={record.video_url} target="_blank" rel="noopener noreferrer">
{text || '查看视频'}
</a>
) : (
text || '-'
),
},
{
title: '发布时间',
dataIndex: 'create_date',
key: 'create_date',
width: 110,
render: (text) => (text ? text.split('T')[0] : '-'),
},
{
title: '爆文类型',
dataIndex: 'hot_type',
key: 'hot_type',
width: 90,
render: (text) => text || '-',
},
{
title: '合作行业',
dataIndex: 'industry_id',
key: 'industry_id',
width: 90,
render: (text) => text || '-',
},
{
title: '合作品牌',
dataIndex: 'brand_name',
key: 'brand_name',
width: 100,
render: (text) => text || '-',
},
{
title: '新增A3',
dataIndex: 'total_new_a3_cnt',
key: 'total_new_a3_cnt',
width: 90,
align: 'right',
render: (val) => formatInt(val),
},
{
title: '加热A3',
dataIndex: 'heated_new_a3_cnt',
key: 'heated_new_a3_cnt',
width: 90,
align: 'right',
render: (val) => formatInt(val),
},
{
title: '自然A3',
dataIndex: 'natural_new_a3_cnt',
key: 'natural_new_a3_cnt',
width: 90,
align: 'right',
render: (val) => formatInt(val),
},
{
title: '预估自然CPM',
dataIndex: 'estimated_natural_cpm',
key: 'estimated_natural_cpm',
width: 110,
align: 'right',
render: (val) => formatNumber(val),
},
{
title: '预估CPA3',
dataIndex: 'estimated_cp_a3',
key: 'estimated_cp_a3',
width: 100,
align: 'right',
render: (val) => formatNumber(val),
},
{
title: '预估自然CPA3',
dataIndex: 'estimated_natural_cp_a3',
key: 'estimated_natural_cp_a3',
width: 110,
align: 'right',
render: (val) => formatNumber(val),
},
{
title: '预估CPsearch',
dataIndex: 'estimated_cp_search',
key: 'estimated_cp_search',
width: 110,
align: 'right',
render: (val) => formatNumber(val),
},
{
title: '自然CPsearch',
dataIndex: 'estimated_natural_cp_search',
key: 'estimated_natural_cp_search',
width: 110,
align: 'right',
render: (val) => formatNumber(val),
},
{
title: '操作',
key: 'action',
width: 80,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.item_id)}
>
</Button>
),
},
];
return ( return (
<div className="max-w-6xl mx-auto p-4"> <div style={{ padding: 24, maxWidth: 1600, margin: '0 auto' }}>
{/* 搜索框 */} {/* 搜索区域 */}
<div className="bg-white rounded-lg shadow p-4 mb-6"> <Card style={{ marginBottom: 24 }}>
<h2 className="text-lg font-semibold mb-4"></h2> <h1 style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 16 }}>KOL </h1>
<div className="flex gap-4"> <Space.Compact style={{ width: '100%', maxWidth: 600 }}>
<input <Select
type="text" value={searchType}
value={itemId} onChange={(val) => {
onChange={(e) => setItemId(e.target.value)} setSearchType(val);
placeholder="请输入视频ID (item_id)" setSearchValue('');
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()} style={{ width: 140 }}
options={SEARCH_TYPE_OPTIONS}
/> />
<button <Input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={SEARCH_PLACEHOLDER[searchType]}
onPressEnter={handleSearch}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch} onClick={handleSearch}
disabled={loading} loading={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> </Button>
</div> </Space.Compact>
{error && ( </Card>
<div className="mt-3 text-red-600 text-sm">{error}</div>
)}
</div>
{/* 分析结果 */} {/* 结果表格 */}
{data && ( <Card>
<div className="bg-white rounded-lg shadow p-6"> <Table
{/* 基础信息 */} columns={columns}
<MetricGroup title="基础信息"> dataSource={listData}
<MetricCard label="视频ID" value={data.base_info.item_id} /> rowKey="item_id"
<MetricCard label="达人昵称" value={data.base_info.star_nickname} /> loading={loading}
<MetricCard label="达人ID" value={data.base_info.star_unique_id} /> scroll={{ x: 1800 }}
<MetricCard label="星图ID" value={data.base_info.star_id} /> pagination={{
<MetricCard label="发布时间" value={data.base_info.publish_time || '-'} /> showSizeChanger: true,
<MetricCard label="行业" value={data.base_info.industry_name || '-'} /> showQuickJumper: true,
<div className="col-span-2"> showTotal: (total) => `${total}`,
<MetricCard label="视频标题" value={data.base_info.title || '-'} /> }}
</div> locale={{ emptyText: '暂无数据,请输入搜索条件' }}
</MetricGroup> />
</Card>
{/* 触达指标 */} {/* 详情弹窗 */}
<MetricGroup title="触达指标"> <DetailModal
<MetricCard label="总曝光数" value={formatNumber(data.reach_metrics.total_show_cnt)} /> visible={detailVisible}
<MetricCard label="自然曝光数" value={formatNumber(data.reach_metrics.natural_show_cnt)} /> data={detailData}
<MetricCard label="加热曝光数" value={formatNumber(data.reach_metrics.ad_show_cnt)} /> loading={detailLoading}
<MetricCard label="总播放数" value={formatNumber(data.reach_metrics.total_play_cnt)} /> onClose={() => setDetailVisible(false)}
<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> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { QueryRequest, QueryResponse, VideoAnalysisResponse } from '@/types'; import { QueryRequest, QueryResponse, VideoAnalysisResponse, VideoAnalysisData } 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';
@ -41,3 +41,59 @@ export async function getVideoAnalysis(itemId: string): Promise<VideoAnalysisRes
return response.json(); return response.json();
} }
// 搜索视频
export interface SearchRequest {
type: 'star_id' | 'unique_id' | 'nickname';
value: string;
}
export interface VideoListItem {
item_id: string;
// 基础信息标1
star_nickname: string;
title: string;
video_url: string;
create_date: string | null;
hot_type: string;
industry_id: string;
brand_id: string;
brand_name: string;
// A3指标标1
total_new_a3_cnt: number;
heated_new_a3_cnt: number;
natural_new_a3_cnt: number;
// 成本指标标1
estimated_natural_cpm: number | null;
estimated_cp_a3: number | null;
estimated_natural_cp_a3: number | null;
estimated_cp_search: number | null;
estimated_natural_cp_search: number | null;
}
export interface SearchResponse {
success: boolean;
type: 'detail' | 'list';
data: VideoAnalysisData | VideoListItem[];
total: number;
error?: string;
}
export async function searchVideos(request: SearchRequest): Promise<SearchResponse> {
const response = await fetch(`${API_BASE_URL}/videos/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('未找到相关视频');
}
throw new Error(`搜索失败: ${response.statusText}`);
}
return response.json();
}

View File

@ -62,52 +62,59 @@ export const QUERY_PLACEHOLDER: Record<QueryType, string> = {
nickname: '请输入达人昵称关键词...', nickname: '请输入达人昵称关键词...',
}; };
// 视频分析数据 (T-026) // 视频分析数据 (按用户字段对照表定义6大类)
export interface VideoAnalysisData { export interface VideoAnalysisData {
// 基础信息
base_info: { base_info: {
item_id: string;
title: string | null;
video_url: string | null;
star_id: string;
star_unique_id: string;
star_nickname: string; star_nickname: string;
publish_time: string | null; star_unique_id: string;
industry_name: string | null; vid: string;
title: string;
create_date: string | null;
hot_type: string;
industry_id: string;
brand_id: string;
brand_name: string;
video_url: string;
}; };
// 触达指标
reach_metrics: { reach_metrics: {
total_show_cnt: number;
natural_show_cnt: number;
ad_show_cnt: number;
total_play_cnt: number;
natural_play_cnt: number; natural_play_cnt: number;
ad_play_cnt: number; heated_play_cnt: number;
effective_play_cnt: number; total_play_cnt: number;
total_interaction_cnt: number;
digg_cnt: number;
share_cnt: number;
comment_cnt: number;
}; };
// A3指标
a3_metrics: { a3_metrics: {
a3_increase_cnt: number; total_new_a3_cnt: number;
ad_a3_increase_cnt: number; heated_new_a3_cnt: number;
natural_a3_increase_cnt: number; natural_new_a3_cnt: number;
}; };
// 搜索指标
search_metrics: { search_metrics: {
back_search_uv: number;
back_search_cnt: number;
after_view_search_uv: number; after_view_search_uv: number;
after_view_search_pv: number; after_view_search_cnt: 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; estimated_natural_search_uv: number | null;
natural_cp_search: number | null; };
// 费用指标
cost_metrics: {
total_cost: number;
heated_cost: number;
estimated_video_cost: number;
};
// 成本指标(实时计算)
calculated_metrics: {
estimated_cpm: number | null;
estimated_natural_cpm: number | null;
estimated_cp_a3: number | null;
estimated_natural_cp_a3: number | null;
estimated_cp_search: number | null;
estimated_natural_cp_search: number | null;
}; };
} }