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:
parent
f123f68be3
commit
7cd29c5980
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||||
@ -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)}")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: 广告主ID(URL参数)
|
||||||
|
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")),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
103
doc/PRD.md
103
doc/PRD.md
@ -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 架构说明**:
|
||||||
|
|||||||
@ -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` 进行评审)
|
||||||
|
|||||||
@ -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品牌认知
|
||||||
- ✅ 设计规范详细,便于开发实现
|
- ✅ 设计规范详细,便于开发实现
|
||||||
- ✅ 状态覆盖全面,用户体验考虑周到
|
- ✅ 状态覆盖全面,用户体验考虑周到
|
||||||
- ✅ 与开发计划高度契合
|
- ✅ 与开发计划高度契合
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
42
doc/tasks.md
42
doc/tasks.md
@ -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` 对任务列表进行评审
|
||||||
|
|||||||
@ -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
867
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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({
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
width={900}
|
||||||
|
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text' } }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 50 }}>
|
||||||
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
</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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleViewDetail = async (itemId: string) => {
|
||||||
<div className="max-w-6xl mx-auto p-4">
|
setDetailVisible(true);
|
||||||
{/* 搜索框 */}
|
setDetailLoading(true);
|
||||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
setDetailData(null);
|
||||||
<h2 className="text-lg font-semibold mb-4">视频分析</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={itemId}
|
|
||||||
onChange={(e) => setItemId(e.target.value)}
|
|
||||||
placeholder="请输入视频ID (item_id)"
|
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? '加载中...' : '查询'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="mt-3 text-red-600 text-sm">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分析结果 */}
|
try {
|
||||||
{data && (
|
const response = await getVideoAnalysis(itemId);
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
if (response.success) {
|
||||||
{/* 基础信息 */}
|
setDetailData(response.data);
|
||||||
<MetricGroup title="基础信息">
|
} else {
|
||||||
<MetricCard label="视频ID" value={data.base_info.item_id} />
|
message.error(response.error || '获取详情失败');
|
||||||
<MetricCard label="达人昵称" value={data.base_info.star_nickname} />
|
setDetailVisible(false);
|
||||||
<MetricCard label="达人ID" value={data.base_info.star_unique_id} />
|
}
|
||||||
<MetricCard label="星图ID" value={data.base_info.star_id} />
|
} catch (err) {
|
||||||
<MetricCard label="发布时间" value={data.base_info.publish_time || '-'} />
|
message.error(err instanceof Error ? err.message : '获取详情失败');
|
||||||
<MetricCard label="行业" value={data.base_info.industry_name || '-'} />
|
setDetailVisible(false);
|
||||||
<div className="col-span-2">
|
} finally {
|
||||||
<MetricCard label="视频标题" value={data.base_info.title || '-'} />
|
setDetailLoading(false);
|
||||||
</div>
|
}
|
||||||
</MetricGroup>
|
};
|
||||||
|
|
||||||
{/* 触达指标 */}
|
// 表格列定义
|
||||||
<MetricGroup title="触达指标">
|
const columns: ColumnsType<VideoListItem> = [
|
||||||
<MetricCard label="总曝光数" value={formatNumber(data.reach_metrics.total_show_cnt)} />
|
{
|
||||||
<MetricCard label="自然曝光数" value={formatNumber(data.reach_metrics.natural_show_cnt)} />
|
title: '达人昵称',
|
||||||
<MetricCard label="加热曝光数" value={formatNumber(data.reach_metrics.ad_show_cnt)} />
|
dataIndex: 'star_nickname',
|
||||||
<MetricCard label="总播放数" value={formatNumber(data.reach_metrics.total_play_cnt)} />
|
key: 'star_nickname',
|
||||||
<MetricCard label="自然播放数" value={formatNumber(data.reach_metrics.natural_play_cnt)} />
|
width: 120,
|
||||||
<MetricCard label="加热播放数" value={formatNumber(data.reach_metrics.ad_play_cnt)} />
|
fixed: 'left',
|
||||||
<MetricCard label="有效播放数" value={formatNumber(data.reach_metrics.effective_play_cnt)} />
|
render: (text) => text || '-',
|
||||||
</MetricGroup>
|
},
|
||||||
|
{
|
||||||
{/* A3指标 */}
|
title: '视频标题',
|
||||||
<MetricGroup title="A3指标">
|
dataIndex: 'title',
|
||||||
<MetricCard label="新增A3" value={formatNumber(data.a3_metrics.a3_increase_cnt)} />
|
key: 'title',
|
||||||
<MetricCard label="加热新增A3" value={formatNumber(data.a3_metrics.ad_a3_increase_cnt)} />
|
width: 200,
|
||||||
<MetricCard label="自然新增A3" value={formatNumber(data.a3_metrics.natural_a3_increase_cnt)} />
|
ellipsis: true,
|
||||||
</MetricGroup>
|
render: (text, record) =>
|
||||||
|
record.video_url ? (
|
||||||
{/* 搜索指标 */}
|
<a href={record.video_url} target="_blank" rel="noopener noreferrer">
|
||||||
<MetricGroup title="搜索指标">
|
{text || '查看视频'}
|
||||||
<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>
|
</a>
|
||||||
</div>
|
) : (
|
||||||
)}
|
text || '-'
|
||||||
</div>
|
),
|
||||||
)}
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 24, maxWidth: 1600, margin: '0 auto' }}>
|
||||||
|
{/* 搜索区域 */}
|
||||||
|
<Card style={{ marginBottom: 24 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 16 }}>KOL 视频分析</h1>
|
||||||
|
<Space.Compact style={{ width: '100%', maxWidth: 600 }}>
|
||||||
|
<Select
|
||||||
|
value={searchType}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSearchType(val);
|
||||||
|
setSearchValue('');
|
||||||
|
}}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={SEARCH_TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 结果表格 */}
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={listData}
|
||||||
|
rowKey="item_id"
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 1800 }}
|
||||||
|
pagination={{
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无数据,请输入搜索条件' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 详情弹窗 */}
|
||||||
|
<DetailModal
|
||||||
|
visible={detailVisible}
|
||||||
|
data={detailData}
|
||||||
|
loading={detailLoading}
|
||||||
|
onClose={() => setDetailVisible(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user