feat(deploy): 完成 Phase 3 优化与测试
- T-013 错误处理: 增强 API 错误处理,添加日志记录 - T-015 视频链接: 已在 ResultTable 中实现点击跳转 - T-016 部署配置: 添加前后端 Dockerfile 和 docker-compose.yml - 新增 11 个错误处理测试用例,共 55 个测试全部通过 - 测试覆盖率达到 93% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from io import BytesIO
|
||||
|
||||
from app.services.export_service import generate_excel, generate_csv
|
||||
from app.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 存储最近的查询结果 (简化实现, 生产环境应使用 Redis 等缓存)
|
||||
_cached_data: list = []
|
||||
@@ -37,23 +39,40 @@ async def export_data(
|
||||
Returns:
|
||||
文件下载响应
|
||||
"""
|
||||
data = get_export_data()
|
||||
try:
|
||||
data = get_export_data()
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if not data:
|
||||
logger.warning("Export requested but no data available")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "error": "无数据可导出,请先执行查询"}
|
||||
)
|
||||
|
||||
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"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
logger.info(f"Exporting {len(data)} records as {format}")
|
||||
|
||||
return StreamingResponse(
|
||||
BytesIO(content),
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
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"
|
||||
|
||||
logger.info(f"Export successful: {filename}")
|
||||
return StreamingResponse(
|
||||
BytesIO(content),
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Export error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"success": False, "error": "导出失败,请重试"}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.query import QueryRequest, QueryResponse, VideoData
|
||||
@@ -7,8 +8,10 @@ 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
|
||||
from app.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("/query", response_model=QueryResponse)
|
||||
@@ -26,14 +29,22 @@ async def query(
|
||||
"""
|
||||
try:
|
||||
# 1. 查询数据库
|
||||
logger.info(f"Querying videos: type={request.type}, count={len(request.values)}")
|
||||
videos = await query_videos(db, request.type, request.values)
|
||||
|
||||
if not videos:
|
||||
logger.info("No videos found for query")
|
||||
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 {}
|
||||
brand_map = {}
|
||||
if brand_ids:
|
||||
try:
|
||||
brand_map = await get_brand_names(brand_ids)
|
||||
except Exception as brand_err:
|
||||
logger.warning(f"Failed to fetch brand names, using fallback: {brand_err}")
|
||||
# 降级处理:使用 brand_id 作为名称
|
||||
|
||||
# 3. 转换为响应模型并计算指标
|
||||
data = []
|
||||
@@ -60,7 +71,17 @@ async def query(
|
||||
# 缓存数据供导出使用
|
||||
set_export_data([d.model_dump() for d in data])
|
||||
|
||||
logger.info(f"Query successful: {len(data)} videos found")
|
||||
return QueryResponse(success=True, data=data, total=len(data))
|
||||
|
||||
except SQLAlchemyError as db_err:
|
||||
logger.error(f"Database error: {db_err}")
|
||||
return QueryResponse(
|
||||
success=False,
|
||||
data=[],
|
||||
total=0,
|
||||
error="数据库连接失败,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Query error: {e}")
|
||||
return QueryResponse(success=False, data=[], total=0, error=str(e))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
def setup_logging():
|
||||
"""Configure application logging."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
|
||||
# Reduce noise from external libraries
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance."""
|
||||
return logging.getLogger(name)
|
||||
Reference in New Issue
Block a user