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,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