feat(video-analysis): 完成视频分析模块迭代任务

Bug 修复:
- T-019: 修复品牌API响应解析,正确解析 data[0].brand_name
- T-020: 添加品牌API Bearer Token认证

视频分析功能:
- T-021: SessionID池服务,从内部API获取Cookie列表
- T-022: SessionID自动重试,失效时自动切换重试
- T-023: 巨量云图API封装,支持超时和错误处理
- T-024: 视频分析数据接口 GET /api/v1/videos/{item_id}/analysis
- T-025: 数据库A3指标更新
- T-026: 视频分析前端页面,展示6大类25+指标

测试覆盖率:
- brand_api.py: 100%
- session_pool.py: 100%
- yuntu_api.py: 100%
- video_analysis.py: 99%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zfc
2026-01-28 17:51:35 +08:00
co-authored by Claude Opus 4.5
parent cdc364cb2a
commit f123f68be3
17 changed files with 2259 additions and 23 deletions
+55
View File
@@ -0,0 +1,55 @@
"""
视频分析API路由 (T-024)
GET /api/v1/videos/{item_id}/analysis
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.video_analysis import get_video_analysis_data
from app.services.yuntu_api import YuntuAPIError
router = APIRouter(prefix="/videos", tags=["视频分析"])
@router.get("/{item_id}/analysis")
async def get_video_analysis(
item_id: str,
db: AsyncSession = Depends(get_db),
):
"""
获取视频分析数据。
返回6大类指标:
- 基础信息 (8字段)
- 触达指标 (7字段)
- A3指标 (3字段)
- 搜索指标 (5字段)
- 费用指标 (3字段)
- 成本指标 (6字段,计算得出)
Args:
item_id: 视频ID
Returns:
视频分析数据
Raises:
404: 视频不存在
500: API调用失败
"""
try:
result = await get_video_analysis_data(db, item_id)
return {
"success": True,
"data": result,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except YuntuAPIError as e:
# API失败但有降级数据时不抛错
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)}")
+6
View File
@@ -8,6 +8,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore", # 忽略额外的环境变量
)
# Database
@@ -18,11 +19,16 @@ class Settings(BaseSettings):
# Brand API
BRAND_API_BASE_URL: str = "https://api.internal.intelligrow.cn"
BRAND_API_TOKEN: str = "" # Bearer Token for Brand API authentication
# Yuntu API (for SessionID pool)
YUNTU_API_TOKEN: str = "" # Bearer Token for Yuntu Cookie API
# API Settings
MAX_QUERY_LIMIT: int = 1000
BRAND_API_TIMEOUT: float = 3.0
BRAND_API_CONCURRENCY: int = 10
YUNTU_API_TIMEOUT: float = 10.0 # 巨量云图API超时
settings = Settings()
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.api.v1 import query, export
from app.api.v1 import query, export, video_analysis
app = FastAPI(
title="KOL Insight API",
@@ -22,6 +22,7 @@ app.add_middleware(
# 注册 API 路由
app.include_router(query.router, prefix="/api/v1", tags=["Query"])
app.include_router(export.router, prefix="/api/v1", tags=["Export"])
app.include_router(video_analysis.router, prefix="/api/v1", tags=["VideoAnalysis"])
@app.get("/")
+16 -5
View File
@@ -24,19 +24,30 @@ async def fetch_brand_name(
"""
async with semaphore:
try:
# 构建请求头,包含 Bearer Token 认证 (T-020)
headers = {}
if settings.BRAND_API_TOKEN:
headers["Authorization"] = f"Bearer {settings.BRAND_API_TOKEN}"
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}"
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands/{brand_id}",
headers=headers,
)
if response.status_code == 200:
data = response.json()
# 尝试从响应中获取品牌名称
# T-019: 正确解析品牌API响应
# 响应格式: {"total": 1, "data": [{"brand_id": xxx, "brand_name": "xxx"}]}
if isinstance(data, dict):
name = data.get("data", {}).get("name") or data.get("name")
if name:
return brand_id, name
data_list = data.get("data", [])
if isinstance(data_list, list) and len(data_list) > 0:
first_item = data_list[0]
if isinstance(first_item, dict):
name = first_item.get("brand_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:
+141
View File
@@ -0,0 +1,141 @@
"""
SessionID池服务 (T-021)
从内部API获取Cookie列表,随机选取sessionid用于巨量云图API调用。
"""
import asyncio
import random
import logging
from typing import List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class SessionPool:
"""SessionID池管理器"""
def __init__(self):
self._sessions: List[str] = []
self._lock = asyncio.Lock()
async def refresh(self) -> bool:
"""
从内部API刷新SessionID列表。
Returns:
bool: 刷新是否成功
"""
async with self._lock:
try:
headers = {}
if settings.YUNTU_API_TOKEN:
headers["Authorization"] = f"Bearer {settings.YUNTU_API_TOKEN}"
async with httpx.AsyncClient(
timeout=settings.YUNTU_API_TIMEOUT
) as client:
response = await client.get(
f"{settings.BRAND_API_BASE_URL}/v1/yuntu/get_cookie",
params={"page": 1, "page_size": 100},
headers=headers,
)
if response.status_code == 200:
data = response.json()
# 响应格式: {"data": [{"sessionid": "xxx", ...}, ...]}
if isinstance(data, dict):
cookie_list = data.get("data", [])
if isinstance(cookie_list, list):
self._sessions = [
item.get("sessionid")
for item in cookie_list
if isinstance(item, dict) and item.get("sessionid")
]
logger.info(
f"SessionPool refreshed: {len(self._sessions)} sessions"
)
return len(self._sessions) > 0
logger.warning(
f"Failed to refresh session pool: status={response.status_code}"
)
return False
except httpx.TimeoutException:
logger.error("SessionPool refresh timeout")
return False
except httpx.RequestError as e:
logger.error(f"SessionPool refresh request error: {e}")
return False
except Exception as e:
logger.error(f"SessionPool refresh unexpected error: {e}")
return False
def get_random(self) -> Optional[str]:
"""
随机获取一个SessionID。
Returns:
Optional[str]: SessionID,池为空时返回None
"""
if not self._sessions:
return None
return random.choice(self._sessions)
def remove(self, session_id: str) -> None:
"""
从池中移除失效的SessionID。
Args:
session_id: 要移除的SessionID
"""
try:
self._sessions.remove(session_id)
logger.info(f"Removed invalid session: {session_id[:8]}...")
except ValueError:
pass # 已经被移除
@property
def size(self) -> int:
"""返回池中SessionID数量"""
return len(self._sessions)
@property
def is_empty(self) -> bool:
"""检查池是否为空"""
return len(self._sessions) == 0
# 全局单例
session_pool = SessionPool()
async def get_session_with_retry(max_retries: int = 3) -> Optional[str]:
"""
获取SessionID,必要时刷新池 (T-022 支持)。
Args:
max_retries: 最大重试次数
Returns:
Optional[str]: SessionID,获取失败返回None
"""
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
session_id = session_pool.get_random()
if session_id:
return session_id
logger.error("Failed to get session after all retries")
return None
+320
View File
@@ -0,0 +1,320 @@
"""
视频分析服务 (T-024)
实现视频分析数据获取和成本指标计算。
"""
import logging
from datetime import datetime
from typing import Dict, Optional, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import update
from app.models.kol_video import KolVideo
from app.services.yuntu_api import (
get_video_analysis as fetch_yuntu_analysis,
parse_analysis_response,
YuntuAPIError,
)
logger = logging.getLogger(__name__)
def calculate_cost_metrics(
cost: float,
natural_play_cnt: int,
a3_increase_cnt: int,
natural_a3_increase_cnt: int,
after_view_search_uv: int,
total_play_cnt: int,
) -> Dict[str, Optional[float]]:
"""
计算成本指标。
Args:
cost: 总花费
natural_play_cnt: 自然播放数
a3_increase_cnt: 新增A3
natural_a3_increase_cnt: 自然新增A3
after_view_search_uv: 看后搜人数
total_play_cnt: 总播放数
Returns:
Dict: 成本指标字典
"""
metrics = {}
# CPM = cost / total_play_cnt * 1000
if total_play_cnt and total_play_cnt > 0:
metrics["cpm"] = round(cost / total_play_cnt * 1000, 2)
else:
metrics["cpm"] = None
# 自然CPM = cost / natural_play_cnt * 1000
if natural_play_cnt and natural_play_cnt > 0:
metrics["natural_cpm"] = round(cost / natural_play_cnt * 1000, 2)
else:
metrics["natural_cpm"] = None
# CPA3 = cost / a3_increase_cnt
if a3_increase_cnt and a3_increase_cnt > 0:
metrics["cpa3"] = round(cost / a3_increase_cnt, 2)
else:
metrics["cpa3"] = None
# 自然CPA3 = cost / natural_a3_increase_cnt
if natural_a3_increase_cnt and natural_a3_increase_cnt > 0:
metrics["natural_cpa3"] = round(cost / natural_a3_increase_cnt, 2)
else:
metrics["natural_cpa3"] = None
# CPsearch = cost / after_view_search_uv
if after_view_search_uv and after_view_search_uv > 0:
metrics["cp_search"] = round(cost / after_view_search_uv, 2)
else:
metrics["cp_search"] = None
# 预估自然看后搜人数 = natural_play_cnt / total_play_cnt * after_view_search_uv
if total_play_cnt and total_play_cnt > 0 and after_view_search_uv:
estimated_natural_search_uv = (
natural_play_cnt / total_play_cnt * after_view_search_uv
)
metrics["estimated_natural_search_uv"] = round(estimated_natural_search_uv, 2)
# 自然CPsearch = cost / estimated_natural_search_uv
if estimated_natural_search_uv > 0:
metrics["natural_cp_search"] = round(cost / estimated_natural_search_uv, 2)
else:
metrics["natural_cp_search"] = None
else:
metrics["estimated_natural_search_uv"] = None
metrics["natural_cp_search"] = None
return metrics
async def get_video_base_info(
session: AsyncSession, item_id: str
) -> Optional[KolVideo]:
"""
从数据库获取视频基础信息。
Args:
session: 数据库会话
item_id: 视频ID
Returns:
KolVideo or None
"""
stmt = select(KolVideo).where(KolVideo.item_id == item_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_video_analysis_data(
session: AsyncSession, item_id: str
) -> Dict[str, Any]:
"""
获取视频分析数据(T-024主接口)。
包含:
- 基础信息(从数据库)
- 触达指标(从巨量云图API)
- A3指标
- 搜索指标
- 费用指标
- 成本指标(计算得出)
Args:
session: 数据库会话
item_id: 视频ID
Returns:
Dict: 完整的视频分析数据
Raises:
ValueError: 视频不存在时抛出
YuntuAPIError: API调用失败时抛出
"""
# 1. 从数据库获取基础信息
video = await get_video_base_info(session, item_id)
if video is None:
raise ValueError(f"Video not found: {item_id}")
# 2. 构建基础信息
base_info = {
"item_id": video.item_id,
"title": video.title,
"video_url": video.video_url,
"star_id": video.star_id,
"star_unique_id": video.star_unique_id,
"star_nickname": video.star_nickname,
"publish_time": video.publish_time.isoformat() if video.publish_time else None,
"industry_name": video.industry_name,
}
# 3. 调用巨量云图API获取实时数据
try:
publish_time = video.publish_time or datetime.now()
industry_id = video.industry_id or ""
api_response = await fetch_yuntu_analysis(
item_id=item_id,
publish_time=publish_time,
industry_id=industry_id,
)
# 4. 解析API响应
analysis_data = parse_analysis_response(api_response)
except YuntuAPIError as e:
logger.error(f"Failed to get yuntu analysis for {item_id}: {e.message}")
# API失败时,使用数据库中的数据
analysis_data = {
"total_show_cnt": video.total_play_cnt or 0,
"natural_show_cnt": video.natural_play_cnt or 0,
"ad_show_cnt": video.heated_play_cnt or 0,
"total_play_cnt": video.total_play_cnt or 0,
"natural_play_cnt": video.natural_play_cnt or 0,
"ad_play_cnt": video.heated_play_cnt or 0,
"effective_play_cnt": 0,
"a3_increase_cnt": 0,
"ad_a3_increase_cnt": 0,
"natural_a3_increase_cnt": 0,
"after_view_search_uv": video.after_view_search_uv or 0,
"after_view_search_pv": 0,
"brand_search_uv": 0,
"product_search_uv": 0,
"return_search_cnt": video.return_search_cnt or 0,
"cost": video.estimated_video_cost or 0,
"natural_cost": 0,
"ad_cost": 0,
}
# 5. 计算成本指标
cost = analysis_data.get("cost", 0) or (video.estimated_video_cost or 0)
cost_metrics = calculate_cost_metrics(
cost=cost,
natural_play_cnt=analysis_data.get("natural_play_cnt", 0),
a3_increase_cnt=analysis_data.get("a3_increase_cnt", 0),
natural_a3_increase_cnt=analysis_data.get("natural_a3_increase_cnt", 0),
after_view_search_uv=analysis_data.get("after_view_search_uv", 0),
total_play_cnt=analysis_data.get("total_play_cnt", 0),
)
# 6. 组装返回数据
return {
"base_info": base_info,
"reach_metrics": {
"total_show_cnt": analysis_data.get("total_show_cnt", 0),
"natural_show_cnt": analysis_data.get("natural_show_cnt", 0),
"ad_show_cnt": analysis_data.get("ad_show_cnt", 0),
"total_play_cnt": analysis_data.get("total_play_cnt", 0),
"natural_play_cnt": analysis_data.get("natural_play_cnt", 0),
"ad_play_cnt": analysis_data.get("ad_play_cnt", 0),
"effective_play_cnt": analysis_data.get("effective_play_cnt", 0),
},
"a3_metrics": {
"a3_increase_cnt": analysis_data.get("a3_increase_cnt", 0),
"ad_a3_increase_cnt": analysis_data.get("ad_a3_increase_cnt", 0),
"natural_a3_increase_cnt": analysis_data.get("natural_a3_increase_cnt", 0),
},
"search_metrics": {
"after_view_search_uv": analysis_data.get("after_view_search_uv", 0),
"after_view_search_pv": analysis_data.get("after_view_search_pv", 0),
"brand_search_uv": analysis_data.get("brand_search_uv", 0),
"product_search_uv": analysis_data.get("product_search_uv", 0),
"return_search_cnt": analysis_data.get("return_search_cnt", 0),
},
"cost_metrics_raw": {
"cost": analysis_data.get("cost", 0),
"natural_cost": analysis_data.get("natural_cost", 0),
"ad_cost": analysis_data.get("ad_cost", 0),
},
"cost_metrics_calculated": cost_metrics,
}
async def update_video_a3_metrics(
session: AsyncSession,
item_id: str,
total_new_a3_cnt: int,
heated_new_a3_cnt: int,
natural_new_a3_cnt: int,
total_cost: float,
) -> bool:
"""
更新数据库中的A3指标 (T-025)。
Args:
session: 数据库会话
item_id: 视频ID
total_new_a3_cnt: 总新增A3
heated_new_a3_cnt: 加热新增A3
natural_new_a3_cnt: 自然新增A3
total_cost: 总花费
Returns:
bool: 更新是否成功
"""
try:
stmt = (
update(KolVideo)
.where(KolVideo.item_id == item_id)
.values(
total_new_a3_cnt=total_new_a3_cnt,
heated_new_a3_cnt=heated_new_a3_cnt,
natural_new_a3_cnt=natural_new_a3_cnt,
total_cost=total_cost,
)
)
result = await session.execute(stmt)
await session.commit()
if result.rowcount > 0:
logger.info(f"Updated A3 metrics for video {item_id}")
return True
else:
logger.warning(f"No video found to update: {item_id}")
return False
except Exception as e:
logger.error(f"Failed to update A3 metrics for {item_id}: {e}")
await session.rollback()
return False
async def get_and_update_video_analysis(
session: AsyncSession, item_id: str
) -> Dict[str, Any]:
"""
获取视频分析数据并更新数据库中的A3指标 (T-024 + T-025 组合)。
Args:
session: 数据库会话
item_id: 视频ID
Returns:
Dict: 完整的视频分析数据
"""
# 获取分析数据
result = await get_video_analysis_data(session, item_id)
# 提取A3指标
a3_metrics = result.get("a3_metrics", {})
cost_raw = result.get("cost_metrics_raw", {})
# 更新数据库
await update_video_a3_metrics(
session=session,
item_id=item_id,
total_new_a3_cnt=a3_metrics.get("a3_increase_cnt", 0),
heated_new_a3_cnt=a3_metrics.get("ad_a3_increase_cnt", 0),
natural_new_a3_cnt=a3_metrics.get("natural_a3_increase_cnt", 0),
total_cost=cost_raw.get("cost", 0),
)
return result
+228
View File
@@ -0,0 +1,228 @@
"""
巨量云图API封装 (T-023)
封装GetContentMaterialAnalysisInfo接口调用,获取视频分析数据。
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
import httpx
from app.config import settings
from app.services.session_pool import session_pool, get_session_with_retry
logger = logging.getLogger(__name__)
# 巨量云图API基础URL
YUNTU_BASE_URL = "https://yuntu.oceanengine.com"
# 触发点ID列表(固定值)
TRIGGER_POINT_IDS = ["610000", "610300", "610301"]
class YuntuAPIError(Exception):
"""巨量云图API错误"""
def __init__(self, message: str, status_code: int = 0, response_data: Any = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class SessionInvalidError(YuntuAPIError):
"""SessionID失效错误"""
pass
async def call_yuntu_api(
item_id: str,
publish_time: datetime,
industry_id: str,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
调用巨量云图GetContentMaterialAnalysisInfo接口。
Args:
item_id: 视频ID
publish_time: 发布时间
industry_id: 行业ID
session_id: 可选的sessionid,不提供则从池中获取
Returns:
Dict: API响应数据
Raises:
SessionInvalidError: SessionID失效时抛出
YuntuAPIError: API调用失败时抛出
"""
# 获取sessionid
if session_id is None:
session_id = await get_session_with_retry()
if session_id is None:
raise YuntuAPIError("Failed to get valid session")
# 构造请求参数
# end_date = start_date + 30天
start_date = publish_time.strftime("%Y-%m-%d")
end_date = (publish_time + timedelta(days=30)).strftime("%Y-%m-%d")
request_data = {
"is_my_video": "0",
"object_id": item_id,
"object_type": 2,
"start_date": start_date,
"end_date": end_date,
"assist_type": 3,
"assist_video_type": 3,
"industry_id_list": [industry_id] if industry_id else [],
"trigger_point_id_list": TRIGGER_POINT_IDS,
}
# 构造请求头
headers = {
"Content-Type": "application/json",
"Cookie": f"sessionid={session_id}",
}
try:
async with httpx.AsyncClient(timeout=settings.YUNTU_API_TIMEOUT) as client:
response = await client.post(
f"{YUNTU_BASE_URL}/yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo",
json=request_data,
headers=headers,
)
# 检查SessionID是否失效
if response.status_code in (401, 403):
logger.warning(f"Session invalid: {session_id[:8]}...")
raise SessionInvalidError(
f"Session invalid: {response.status_code}",
status_code=response.status_code,
)
if response.status_code != 200:
raise YuntuAPIError(
f"API returned {response.status_code}",
status_code=response.status_code,
response_data=response.text,
)
data = response.json()
# 检查业务错误码
if data.get("code") != 0:
error_msg = data.get("message", "Unknown error")
raise YuntuAPIError(
f"API business error: {error_msg}",
status_code=response.status_code,
response_data=data,
)
return data
except httpx.TimeoutException:
logger.error(f"Yuntu API timeout for item_id: {item_id}")
raise YuntuAPIError("API request timeout")
except httpx.RequestError as e:
logger.error(f"Yuntu API request error: {e}")
raise YuntuAPIError(f"API request error: {e}")
async def get_video_analysis(
item_id: str,
publish_time: datetime,
industry_id: str,
max_retries: int = 3,
) -> Dict[str, Any]:
"""
获取视频分析数据,支持SessionID失效自动重试 (T-022)。
Args:
item_id: 视频ID
publish_time: 发布时间
industry_id: 行业ID
max_retries: 最大重试次数
Returns:
Dict: 视频分析数据
Raises:
YuntuAPIError: 所有重试失败后抛出
"""
last_error = None
for attempt in range(max_retries):
# 从池中获取sessionid
session_id = await get_session_with_retry()
if session_id is None:
last_error = YuntuAPIError("Failed to get valid session")
continue
try:
result = await call_yuntu_api(
item_id=item_id,
publish_time=publish_time,
industry_id=industry_id,
session_id=session_id,
)
return result
except SessionInvalidError:
# SessionID失效,从池中移除并重试
session_pool.remove(session_id)
logger.info(
f"Session invalid, retrying... attempt {attempt + 1}/{max_retries}"
)
last_error = SessionInvalidError("All sessions invalid")
continue
except YuntuAPIError as e:
last_error = e
logger.error(f"Yuntu API error on attempt {attempt + 1}: {e.message}")
# 非SessionID问题,不再重试
break
raise last_error or YuntuAPIError("Unknown error after retries")
def parse_analysis_response(data: Dict[str, Any]) -> Dict[str, Any]:
"""
解析巨量云图API响应,提取关键指标。
Args:
data: API原始响应数据
Returns:
Dict: 结构化的分析数据
"""
result_data = data.get("data", {})
return {
# 触达指标
"total_show_cnt": result_data.get("total_show_cnt", 0), # 总曝光数
"natural_show_cnt": result_data.get("natural_show_cnt", 0), # 自然曝光数
"ad_show_cnt": result_data.get("ad_show_cnt", 0), # 加热曝光数
"total_play_cnt": result_data.get("total_play_cnt", 0), # 总播放数
"natural_play_cnt": result_data.get("natural_play_cnt", 0), # 自然播放数
"ad_play_cnt": result_data.get("ad_play_cnt", 0), # 加热播放数
"effective_play_cnt": result_data.get("effective_play_cnt", 0), # 有效播放数
# A3指标
"a3_increase_cnt": result_data.get("a3_increase_cnt", 0), # 新增A3
"ad_a3_increase_cnt": result_data.get("ad_a3_increase_cnt", 0), # 加热新增A3
"natural_a3_increase_cnt": result_data.get("natural_a3_increase_cnt", 0), # 自然新增A3
# 搜索指标
"after_view_search_uv": result_data.get("after_view_search_uv", 0), # 看后搜人数
"after_view_search_pv": result_data.get("after_view_search_pv", 0), # 看后搜次数
"brand_search_uv": result_data.get("brand_search_uv", 0), # 品牌搜索人数
"product_search_uv": result_data.get("product_search_uv", 0), # 商品搜索人数
"return_search_cnt": result_data.get("return_search_cnt", 0), # 回搜次数
# 费用指标
"cost": result_data.get("cost", 0), # 总花费
"natural_cost": result_data.get("natural_cost", 0), # 自然花费
"ad_cost": result_data.get("ad_cost", 0), # 加热花费
}