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:
parent
8fbcb72a3f
commit
d838a9bea2
14
.env.example
Normal file
14
.env.example
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Database configuration
|
||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/yuntu_kol
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_DB=yuntu_kol
|
||||||
|
|
||||||
|
# Brand API
|
||||||
|
BRAND_API_BASE_URL=https://api.internal.intelligrow.cn
|
||||||
|
|
||||||
|
# Frontend API URL (for production)
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://frontend:3000
|
||||||
30
backend/Dockerfile
Normal file
30
backend/Dockerfile
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Start application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@ -1,13 +1,15 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from app.services.export_service import generate_excel, generate_csv
|
from app.services.export_service import generate_excel, generate_csv
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# 存储最近的查询结果 (简化实现, 生产环境应使用 Redis 等缓存)
|
# 存储最近的查询结果 (简化实现, 生产环境应使用 Redis 等缓存)
|
||||||
_cached_data: list = []
|
_cached_data: list = []
|
||||||
@ -37,23 +39,40 @@ async def export_data(
|
|||||||
Returns:
|
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":
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
content = generate_excel(data)
|
logger.info(f"Exporting {len(data)} records as {format}")
|
||||||
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(
|
if format == "xlsx":
|
||||||
BytesIO(content),
|
content = generate_excel(data)
|
||||||
media_type=media_type,
|
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
headers={
|
filename = f"kol_data_{timestamp}.xlsx"
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
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 fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.schemas.query import QueryRequest, QueryResponse, VideoData
|
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.calculator import calculate_metrics
|
||||||
from app.services.brand_api import get_brand_names
|
from app.services.brand_api import get_brand_names
|
||||||
from app.api.v1.export import set_export_data
|
from app.api.v1.export import set_export_data
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/query", response_model=QueryResponse)
|
@router.post("/query", response_model=QueryResponse)
|
||||||
@ -26,14 +29,22 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 1. 查询数据库
|
# 1. 查询数据库
|
||||||
|
logger.info(f"Querying videos: type={request.type}, count={len(request.values)}")
|
||||||
videos = await query_videos(db, request.type, request.values)
|
videos = await query_videos(db, request.type, request.values)
|
||||||
|
|
||||||
if not videos:
|
if not videos:
|
||||||
|
logger.info("No videos found for query")
|
||||||
return QueryResponse(success=True, data=[], total=0)
|
return QueryResponse(success=True, data=[], total=0)
|
||||||
|
|
||||||
# 2. 提取品牌ID并批量获取品牌名称
|
# 2. 提取品牌ID并批量获取品牌名称
|
||||||
brand_ids = [v.brand_id for v in videos if v.brand_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. 转换为响应模型并计算指标
|
# 3. 转换为响应模型并计算指标
|
||||||
data = []
|
data = []
|
||||||
@ -60,7 +71,17 @@ async def query(
|
|||||||
# 缓存数据供导出使用
|
# 缓存数据供导出使用
|
||||||
set_export_data([d.model_dump() for d in data])
|
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))
|
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:
|
except Exception as e:
|
||||||
|
logger.error(f"Query error: {e}")
|
||||||
return QueryResponse(success=False, data=[], total=0, error=str(e))
|
return QueryResponse(success=False, data=[], total=0, error=str(e))
|
||||||
|
|||||||
20
backend/app/core/logging.py
Normal file
20
backend/app/core/logging.py
Normal file
@ -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)
|
||||||
163
backend/tests/test_error_handling.py
Normal file
163
backend/tests/test_error_handling.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import KolVideo
|
||||||
|
from app.api.v1.export import set_export_data, get_export_data
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Tests for error handling scenarios."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(self, override_get_db):
|
||||||
|
"""Create test client with dependency override."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def seed_data(self, test_session, sample_video_data):
|
||||||
|
"""Seed test data."""
|
||||||
|
data = sample_video_data.copy()
|
||||||
|
data["item_id"] = "error_test_001"
|
||||||
|
data["star_id"] = "error_star_001"
|
||||||
|
video = KolVideo(**data)
|
||||||
|
test_session.add(video)
|
||||||
|
await test_session.commit()
|
||||||
|
return video
|
||||||
|
|
||||||
|
# Query API error handling tests
|
||||||
|
|
||||||
|
@patch("app.api.v1.query.query_videos")
|
||||||
|
@patch("app.api.v1.query.get_brand_names", new_callable=AsyncMock)
|
||||||
|
async def test_query_database_error(
|
||||||
|
self, mock_brand, mock_query, client
|
||||||
|
):
|
||||||
|
"""Test query returns error on database failure."""
|
||||||
|
mock_brand.return_value = {}
|
||||||
|
mock_query.side_effect = SQLAlchemyError("Database connection failed")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"type": "star_id", "values": ["test_id"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "数据库连接失败" in data["error"]
|
||||||
|
|
||||||
|
@patch("app.api.v1.query.get_brand_names", new_callable=AsyncMock)
|
||||||
|
async def test_query_brand_api_failure_fallback(
|
||||||
|
self, mock_brand, client, test_session, seed_data
|
||||||
|
):
|
||||||
|
"""Test query continues with fallback when brand API fails."""
|
||||||
|
mock_brand.side_effect = Exception("Brand API timeout")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"type": "star_id", "values": ["error_star_001"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Should still succeed, brand name falls back to brand_id
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["total"] >= 0
|
||||||
|
|
||||||
|
@patch("app.api.v1.query.query_videos")
|
||||||
|
@patch("app.api.v1.query.get_brand_names", new_callable=AsyncMock)
|
||||||
|
async def test_query_generic_error(
|
||||||
|
self, mock_brand, mock_query, client
|
||||||
|
):
|
||||||
|
"""Test query returns error on unexpected exception."""
|
||||||
|
mock_brand.return_value = {}
|
||||||
|
mock_query.side_effect = Exception("Unexpected error")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"type": "star_id", "values": ["test_id"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "Unexpected error" in data["error"]
|
||||||
|
|
||||||
|
# Export API error handling tests
|
||||||
|
|
||||||
|
async def test_export_no_data_error(self, client):
|
||||||
|
"""Test export returns error when no data is cached."""
|
||||||
|
# Clear cached data
|
||||||
|
set_export_data([])
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/export?format=xlsx")
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "无数据可导出" in data["error"]
|
||||||
|
|
||||||
|
async def test_export_with_data_success(self, client, sample_video_data):
|
||||||
|
"""Test export succeeds when data is cached."""
|
||||||
|
# Set cached data
|
||||||
|
set_export_data([sample_video_data])
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/export?format=xlsx")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "application/vnd.openxmlformats" in response.headers["content-type"]
|
||||||
|
|
||||||
|
async def test_export_csv_with_data_success(self, client, sample_video_data):
|
||||||
|
"""Test CSV export succeeds when data is cached."""
|
||||||
|
set_export_data([sample_video_data])
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/export?format=csv")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/csv" in response.headers["content-type"]
|
||||||
|
|
||||||
|
@patch("app.api.v1.export.generate_excel")
|
||||||
|
async def test_export_generation_error(
|
||||||
|
self, mock_generate, client, sample_video_data
|
||||||
|
):
|
||||||
|
"""Test export returns error when file generation fails."""
|
||||||
|
mock_generate.side_effect = Exception("Excel generation failed")
|
||||||
|
set_export_data([sample_video_data])
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/export?format=xlsx")
|
||||||
|
assert response.status_code == 500
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "导出失败" in data["error"]
|
||||||
|
|
||||||
|
# Input validation tests
|
||||||
|
|
||||||
|
async def test_query_validation_empty_values(self, client):
|
||||||
|
"""Test query returns 422 for empty values."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"type": "star_id", "values": []},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_query_validation_invalid_type(self, client):
|
||||||
|
"""Test query returns 422 for invalid query type."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"type": "invalid_type", "values": ["test"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_query_validation_missing_type(self, client):
|
||||||
|
"""Test query returns 422 for missing type field."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"values": ["test"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_query_validation_missing_values(self, client):
|
||||||
|
"""Test query returns 422 for missing values field."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/query",
|
||||||
|
json={"type": "star_id"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://backend:8000/api/v1
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- kol-network
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- CORS_ORIGINS=http://localhost:3000,http://frontend:3000
|
||||||
|
- BRAND_API_BASE_URL=${BRAND_API_BASE_URL:-https://api.internal.intelligrow.cn}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- kol-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:14-alpine
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-yuntu_kol}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- kol-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kol-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
41
frontend/Dockerfile
Normal file
41
frontend/Dockerfile
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user