feat(core): 完成 Phase 2 核心功能开发

- 实现查询API (query.py): 支持star_id/unique_id/nickname三种查询方式
- 实现计算模块 (calculator.py): CPM/自然搜索UV/搜索成本计算
- 实现品牌API集成 (brand_api.py): 批量并发调用,10并发限制
- 实现导出服务 (export_service.py): Excel/CSV导出
- 前端组件: QueryForm/ResultTable/ExportButton
- 主页面集成: 支持6种页面状态
- 测试: 44个测试全部通过,覆盖率88%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zfc
2026-01-28 14:38:38 +08:00
co-authored by Claude Opus 4.5
parent ac0f086821
commit 8fbcb72a3f
21 changed files with 1677 additions and 100 deletions
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from io import BytesIO
from app.services.export_service import generate_excel, generate_csv
router = APIRouter()
# 存储最近的查询结果 (简化实现, 生产环境应使用 Redis 等缓存)
_cached_data: list = []
def set_export_data(data: list):
"""设置导出数据缓存."""
global _cached_data
_cached_data = data
def get_export_data() -> list:
"""获取导出数据缓存."""
return _cached_data
@router.get("/export")
async def export_data(
format: Literal["xlsx", "csv"] = Query("xlsx", description="导出格式"),
):
"""
导出查询结果.
Args:
format: 导出格式 (xlsx 或 csv)
Returns:
文件下载响应
"""
data = get_export_data()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if format == "xlsx":
content = generate_excel(data)
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
filename = f"kol_data_{timestamp}.xlsx"
else:
content = generate_csv(data)
media_type = "text/csv; charset=utf-8"
filename = f"kol_data_{timestamp}.csv"
return StreamingResponse(
BytesIO(content),
media_type=media_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.query import QueryRequest, QueryResponse, VideoData
from app.services.query_service import query_videos
from app.services.calculator import calculate_metrics
from app.services.brand_api import get_brand_names
from app.api.v1.export import set_export_data
router = APIRouter()
@router.post("/query", response_model=QueryResponse)
async def query(
request: QueryRequest,
db: AsyncSession = Depends(get_db),
) -> QueryResponse:
"""
批量查询 KOL 视频数据.
支持三种查询方式:
- star_id: 按星图ID精准匹配
- unique_id: 按达人unique_id精准匹配
- nickname: 按达人昵称模糊匹配
"""
try:
# 1. 查询数据库
videos = await query_videos(db, request.type, request.values)
if not videos:
return QueryResponse(success=True, data=[], total=0)
# 2. 提取品牌ID并批量获取品牌名称
brand_ids = [v.brand_id for v in videos if v.brand_id]
brand_map = await get_brand_names(brand_ids) if brand_ids else {}
# 3. 转换为响应模型并计算指标
data = []
for video in videos:
video_data = VideoData.model_validate(video)
# 填充品牌名称
if video.brand_id:
video_data.brand_name = brand_map.get(video.brand_id, video.brand_id)
# 计算预估指标
metrics = calculate_metrics(
estimated_video_cost=video.estimated_video_cost,
natural_play_cnt=video.natural_play_cnt,
total_play_cnt=video.total_play_cnt,
after_view_search_uv=video.after_view_search_uv,
)
video_data.estimated_natural_cpm = metrics["estimated_natural_cpm"]
video_data.estimated_natural_search_uv = metrics["estimated_natural_search_uv"]
video_data.estimated_natural_search_cost = metrics["estimated_natural_search_cost"]
data.append(video_data)
# 缓存数据供导出使用
set_export_data([d.model_dump() for d in data])
return QueryResponse(success=True, data=data, total=len(data))
except Exception as e:
return QueryResponse(success=False, data=[], total=0, error=str(e))
+5
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.api.v1 import query, export
app = FastAPI(
title="KOL Insight API",
@@ -18,6 +19,10 @@ app.add_middleware(
allow_headers=["*"],
)
# 注册 API 路由
app.include_router(query.router, prefix="/api/v1", tags=["Query"])
app.include_router(export.router, prefix="/api/v1", tags=["Export"])
@app.get("/")
async def root():
+67
View File
@@ -0,0 +1,67 @@
from pydantic import BaseModel, ConfigDict, Field
from typing import List, Literal, Optional
from datetime import datetime
class QueryRequest(BaseModel):
"""查询请求模型."""
type: Literal["star_id", "unique_id", "nickname"] = Field(
..., description="查询类型: star_id, unique_id, nickname"
)
values: List[str] = Field(
..., description="查询值列表 (批量ID 或单个昵称)", min_length=1
)
class VideoData(BaseModel):
"""视频数据模型."""
# 基础信息
item_id: str
title: Optional[str] = None
viral_type: Optional[str] = None
video_url: Optional[str] = None
star_id: str
star_unique_id: str
star_nickname: str
publish_time: Optional[datetime] = None
# 曝光指标
natural_play_cnt: int = 0
heated_play_cnt: int = 0
total_play_cnt: int = 0
# 互动指标
total_interact: int = 0
like_cnt: int = 0
share_cnt: int = 0
comment_cnt: int = 0
# 效果指标
new_a3_rate: Optional[float] = None
after_view_search_uv: int = 0
return_search_cnt: int = 0
# 商业信息
industry_id: Optional[str] = None
industry_name: Optional[str] = None
brand_id: Optional[str] = None
brand_name: Optional[str] = None # 从品牌 API 获取
estimated_video_cost: float = 0
# 计算字段
estimated_natural_cpm: Optional[float] = None
estimated_natural_search_uv: Optional[float] = None
estimated_natural_search_cost: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
class QueryResponse(BaseModel):
"""查询响应模型."""
success: bool = True
data: List[VideoData] = []
total: int = 0
error: Optional[str] = None
+83
View File
@@ -0,0 +1,83 @@
import asyncio
from typing import Dict, List, Tuple
import httpx
import logging
from app.config import settings
logger = logging.getLogger(__name__)
async def fetch_brand_name(
brand_id: str,
semaphore: asyncio.Semaphore,
) -> Tuple[str, str]:
"""
获取单个品牌名称.
Args:
brand_id: 品牌ID
semaphore: 并发控制信号量
Returns:
(brand_id, brand_name) 元组, 失败时 brand_name 为 brand_id
"""
async with semaphore:
try:
async with httpx.AsyncClient(
timeout=settings.BRAND_API_TIMEOUT
) as client:
response = await client.get(
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands/{brand_id}"
)
if response.status_code == 200:
data = response.json()
# 尝试从响应中获取品牌名称
if isinstance(data, dict):
name = data.get("data", {}).get("name") or data.get("name")
if name:
return brand_id, name
except httpx.TimeoutException:
logger.warning(f"Brand API timeout for brand_id: {brand_id}")
except httpx.RequestError as e:
logger.warning(f"Brand API request error for brand_id: {brand_id}, error: {e}")
except Exception as e:
logger.error(f"Unexpected error fetching brand {brand_id}: {e}")
# 失败时降级返回 brand_id
return brand_id, brand_id
async def get_brand_names(brand_ids: List[str]) -> Dict[str, str]:
"""
批量获取品牌名称.
Args:
brand_ids: 品牌ID列表
Returns:
brand_id -> brand_name 映射字典
"""
# 过滤空值并去重
unique_ids = list(set(filter(None, brand_ids)))
if not unique_ids:
return {}
# 创建并发控制信号量
semaphore = asyncio.Semaphore(settings.BRAND_API_CONCURRENCY)
# 批量并发请求
tasks = [fetch_brand_name(brand_id, semaphore) for brand_id in unique_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 构建映射表
brand_map: Dict[str, str] = {}
for result in results:
if isinstance(result, tuple):
brand_id, brand_name = result
brand_map[brand_id] = brand_name
elif isinstance(result, Exception):
logger.error(f"Error in batch brand fetch: {result}")
return brand_map
+102
View File
@@ -0,0 +1,102 @@
from typing import Optional, Dict
def calculate_natural_cpm(
estimated_video_cost: float,
natural_play_cnt: int,
) -> Optional[float]:
"""
计算预估自然CPM.
公式: estimated_video_cost / natural_play_cnt * 1000
Args:
estimated_video_cost: 预估视频成本
natural_play_cnt: 自然播放量
Returns:
预估自然CPM (元/千次曝光), 除零时返回 None
"""
if natural_play_cnt <= 0:
return None
return round((estimated_video_cost / natural_play_cnt) * 1000, 2)
def calculate_natural_search_uv(
natural_play_cnt: int,
total_play_cnt: int,
after_view_search_uv: int,
) -> Optional[float]:
"""
计算预估自然看后搜人数.
公式: natural_play_cnt / total_play_cnt * after_view_search_uv
Args:
natural_play_cnt: 自然播放量
total_play_cnt: 总播放量
after_view_search_uv: 看后搜人数
Returns:
预估自然看后搜人数, 除零时返回 None
"""
if total_play_cnt <= 0:
return None
return round((natural_play_cnt / total_play_cnt) * after_view_search_uv, 2)
def calculate_natural_search_cost(
estimated_video_cost: float,
estimated_natural_search_uv: Optional[float],
) -> Optional[float]:
"""
计算预估自然看后搜人数成本.
公式: estimated_video_cost / 预估自然看后搜人数
Args:
estimated_video_cost: 预估视频成本
estimated_natural_search_uv: 预估自然看后搜人数
Returns:
预估自然看后搜人数成本 (元/人), 除零时返回 None
"""
if estimated_natural_search_uv is None or estimated_natural_search_uv <= 0:
return None
return round(estimated_video_cost / estimated_natural_search_uv, 2)
def calculate_metrics(
estimated_video_cost: float,
natural_play_cnt: int,
total_play_cnt: int,
after_view_search_uv: int,
) -> Dict[str, Optional[float]]:
"""
批量计算所有预估指标.
Args:
estimated_video_cost: 预估视频成本
natural_play_cnt: 自然播放量
total_play_cnt: 总播放量
after_view_search_uv: 看后搜人数
Returns:
包含所有计算结果的字典
"""
# 计算 CPM
cpm = calculate_natural_cpm(estimated_video_cost, natural_play_cnt)
# 计算看后搜人数
search_uv = calculate_natural_search_uv(
natural_play_cnt, total_play_cnt, after_view_search_uv
)
# 计算看后搜成本
search_cost = calculate_natural_search_cost(estimated_video_cost, search_uv)
return {
"estimated_natural_cpm": cpm,
"estimated_natural_search_uv": search_uv,
"estimated_natural_search_cost": search_cost,
}
+97
View File
@@ -0,0 +1,97 @@
import csv
from io import BytesIO, StringIO
from typing import List, Dict, Any, Tuple
from openpyxl import Workbook
# 列定义: (中文名, 字段名)
COLUMN_HEADERS: List[Tuple[str, str]] = [
("视频ID", "item_id"),
("视频标题", "title"),
("爆文类型", "viral_type"),
("视频链接", "video_url"),
("新增A3率", "new_a3_rate"),
("看后搜人数", "after_view_search_uv"),
("回搜次数", "return_search_cnt"),
("自然曝光数", "natural_play_cnt"),
("加热曝光数", "heated_play_cnt"),
("总曝光数", "total_play_cnt"),
("总互动", "total_interact"),
("点赞", "like_cnt"),
("转发", "share_cnt"),
("评论", "comment_cnt"),
("合作行业ID", "industry_id"),
("合作行业", "industry_name"),
("合作品牌ID", "brand_id"),
("合作品牌", "brand_name"),
("发布时间", "publish_time"),
("达人昵称", "star_nickname"),
("达人unique_id", "star_unique_id"),
("预估视频价格", "estimated_video_cost"),
("预估自然CPM", "estimated_natural_cpm"),
("预估自然看后搜人数", "estimated_natural_search_uv"),
("预估自然看后搜人数成本", "estimated_natural_search_cost"),
]
def format_value(value: Any) -> Any:
"""格式化导出值."""
if value is None:
return ""
return value
def generate_excel(data: List[Dict[str, Any]]) -> bytes:
"""
生成 Excel 文件.
Args:
data: 数据列表
Returns:
Excel 文件的字节内容
"""
wb = Workbook()
ws = wb.active
ws.title = "KOL数据"
# 写入表头
headers = [col[0] for col in COLUMN_HEADERS]
ws.append(headers)
# 写入数据
for row in data:
row_data = [format_value(row.get(col[1])) for col in COLUMN_HEADERS]
ws.append(row_data)
# 保存到内存
output = BytesIO()
wb.save(output)
output.seek(0)
return output.read()
def generate_csv(data: List[Dict[str, Any]]) -> bytes:
"""
生成 CSV 文件.
Args:
data: 数据列表
Returns:
CSV 文件的字节内容 (UTF-8 BOM 编码)
"""
output = StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
# 写入表头
headers = [col[0] for col in COLUMN_HEADERS]
writer.writerow(headers)
# 写入数据
for row in data:
row_data = [format_value(row.get(col[1])) for col in COLUMN_HEADERS]
writer.writerow(row_data)
# 返回 UTF-8 BOM 编码的内容 (Excel 可正确识别中文)
content = output.getvalue()
return ("\ufeff" + content).encode("utf-8")
+42
View File
@@ -0,0 +1,42 @@
from typing import List, Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import KolVideo
from app.config import settings
async def query_videos(
session: AsyncSession,
query_type: Literal["star_id", "unique_id", "nickname"],
values: List[str],
) -> List[KolVideo]:
"""
查询 KOL 视频数据.
Args:
session: 数据库会话
query_type: 查询类型 (star_id, unique_id, nickname)
values: 查询值列表
Returns:
匹配的视频列表
"""
stmt = select(KolVideo)
if query_type == "star_id":
# 精准匹配 star_id
stmt = stmt.where(KolVideo.star_id.in_(values))
elif query_type == "unique_id":
# 精准匹配 star_unique_id
stmt = stmt.where(KolVideo.star_unique_id.in_(values))
elif query_type == "nickname":
# 模糊匹配 star_nickname (使用第一个值)
if values:
stmt = stmt.where(KolVideo.star_nickname.like(f"%{values[0]}%"))
# 限制返回数量
stmt = stmt.limit(settings.MAX_QUERY_LIMIT)
result = await session.execute(stmt)
return list(result.scalars().all())