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:
@@ -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}"',
|
||||
},
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user