Implement unified error handling and response format in FastAPI application; add LLM route and update health check response structure.

This commit is contained in:
extreme committed 2026-03-10 17:26:32 +08:00
1 parent e9edba092b
commit 0e4632efcd
7 files changed
+607 -13

No files matched your search

+2 -1
View File
@@ -2,9 +2,10 @@
from fastapi import APIRouter
from app.api.v1.routes import film, health
from app.api.v1.routes import film, health, llm
router = APIRouter()
router.include_router(health.router, tags=["health"])
router.include_router(film.router, prefix="/film", tags=["film"])
router.include_router(llm.router, prefix="/llm", tags=["llm"])
+9 -6
View File
@@ -15,6 +15,7 @@ from app.core.skills_runtime import (
FilmShotlistResult,
)
from app.dependencies import get_llm
from app.schemas.common import ApiResponse, success_response
router = APIRouter()
@@ -50,14 +51,14 @@ class ShotlistExtractRequest(BaseModel):
@router.post(
"/extract/entities",
response_model=FilmEntityExtractionResult,
response_model=ApiResponse[FilmEntityExtractionResult],
summary="关键信息抽取",
description="从小说文本中抽取人物、地点、道具,忠实原文、可追溯证据。",
)
def extract_entities(
body: EntityExtractRequest,
llm: Runnable = Depends(get_llm),
) -> FilmEntityExtractionResult:
) -> ApiResponse[FilmEntityExtractionResult]:
"""FilmEntityExtractor:人物/地点/道具抽取。"""
extractor = FilmEntityExtractor(llm)
extractor.load_skill("film_entity_extractor")
@@ -65,25 +66,26 @@ def extract_entities(
[{"chunk_id": c.chunk_id, "text": c.text} for c in body.chunks],
ensure_ascii=False,
)
return extractor.extract(
result = extractor.extract(
{
"source_id": body.source_id,
"language": body.language or "zh",
"chunks_json": chunks_json,
}
)
return success_response(result)
@router.post(
"/extract/shotlist",
response_model=FilmShotlistResult,
response_model=ApiResponse[FilmShotlistResult],
summary="分镜抽取",
description="将小说片段转为可拍镜头表(景别/机位/运镜/转场/VFX)。",
)
def extract_shotlist(
body: ShotlistExtractRequest,
llm: Runnable = Depends(get_llm),
) -> FilmShotlistResult:
) -> ApiResponse[FilmShotlistResult]:
"""FilmShotlistStoryboarder:场景/镜头/转场抽取。"""
storyboarder = FilmShotlistStoryboarder(llm)
storyboarder.load_skill("film_shotlist")
@@ -91,7 +93,7 @@ def extract_shotlist(
[{"chunk_id": c.chunk_id, "text": c.text} for c in body.chunks],
ensure_ascii=False,
)
return storyboarder.extract(
result = storyboarder.extract(
{
"source_id": body.source_id,
"source_title": body.source_title or "",
@@ -99,3 +101,4 @@ def extract_shotlist(
"chunks_json": chunks_json,
}
)
return success_response(result)
+5 -3
View File
@@ -2,9 +2,11 @@
from fastapi import APIRouter
from app.schemas.common import ApiResponse, success_response
router = APIRouter()
@router.get("/health")
async def v1_health():
return {"status": "ok", "version": "v1"}
@router.get("/health", response_model=ApiResponse[dict])
async def v1_health() -> ApiResponse[dict]:
return success_response(data={"status": "ok", "version": "v1"})
+350
View File
@@ -0,0 +1,350 @@
"""LLM 相关基础配置的 CRUD 接口:Provider / Model / ModelSettings。"""
from __future__ import annotations
from typing import Sequence
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
from app.schemas.common import ApiResponse, PaginatedData, success_response, paginated_response
from app.schemas.llm import (
ModelCreate,
ModelRead,
ModelSettingsRead,
ModelSettingsUpdate,
ModelUpdate,
ProviderCreate,
ProviderRead,
ProviderUpdate,
)
router = APIRouter()
# 列表排序允许的字段(避免注入)
PROVIDER_ORDER_FIELDS = {"name", "created_at", "updated_at"}
MODEL_ORDER_FIELDS = {"name", "category", "created_at", "updated_at"}
DEFAULT_PAGE_SIZE = 10
MAX_PAGE_SIZE = 100
# ---------- Provider ----------
@router.get(
"/providers",
response_model=ApiResponse[PaginatedData[ProviderRead]],
summary="列出模型供应商(分页)",
)
async def list_providers(
db: AsyncSession = Depends(get_db),
q: str | None = Query(None, description="关键字,过滤 name/description"),
order: str | None = Query(None, description="排序字段:name, created_at, updated_at"),
is_desc: bool = Query(False, description="是否倒序"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页条数"),
) -> ApiResponse[PaginatedData[ProviderRead]]:
stmt = select(Provider)
count_stmt = select(func.count()).select_from(Provider)
if q and q.strip():
q_pattern = f"%{q.strip()}%"
stmt = stmt.where(
Provider.name.ilike(q_pattern) | Provider.description.ilike(q_pattern)
)
count_stmt = count_stmt.where(
Provider.name.ilike(q_pattern) | Provider.description.ilike(q_pattern)
)
order_col = order if order and order in PROVIDER_ORDER_FIELDS else "created_at"
order_attr = getattr(Provider, order_col)
stmt = stmt.order_by(order_attr.desc() if is_desc else order_attr.asc())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
providers: Sequence[Provider] = result.scalars().all()
items = [ProviderRead.model_validate(p) for p in providers]
return paginated_response(items, page=page, page_size=page_size, total=total)
@router.post(
"/providers",
response_model=ApiResponse[ProviderRead],
status_code=status.HTTP_201_CREATED,
summary="创建模型供应商",
)
async def create_provider(
body: ProviderCreate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ProviderRead]:
exists = await db.get(Provider, body.id)
if exists is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Provider with id={body.id} already exists",
)
provider = Provider(
id=body.id,
name=body.name,
base_url=body.base_url,
api_key=body.api_key,
api_secret=body.api_secret,
description=body.description,
status=body.status,
created_by=body.created_by,
)
db.add(provider)
await db.flush()
await db.refresh(provider)
return success_response(ProviderRead.model_validate(provider), code=201)
@router.get(
"/providers/{provider_id}",
response_model=ApiResponse[ProviderRead],
summary="获取单个模型供应商",
)
async def get_provider(
provider_id: str,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ProviderRead]:
provider = await db.get(Provider, provider_id)
if provider is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
return success_response(ProviderRead.model_validate(provider))
@router.patch(
"/providers/{provider_id}",
response_model=ApiResponse[ProviderRead],
summary="更新模型供应商",
)
async def update_provider(
provider_id: str,
body: ProviderUpdate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ProviderRead]:
provider = await db.get(Provider, provider_id)
if provider is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(provider, field, value)
await db.flush()
await db.refresh(provider)
return success_response(ProviderRead.model_validate(provider))
@router.delete(
"/providers/{provider_id}",
response_model=ApiResponse[None],
status_code=status.HTTP_200_OK,
summary="删除模型供应商",
)
async def delete_provider(
provider_id: str,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[None]:
provider = await db.get(Provider, provider_id)
if provider is None:
return success_response(None)
await db.delete(provider)
await db.flush()
return success_response(None)
# ---------- Model ----------
@router.get(
"/models",
response_model=ApiResponse[PaginatedData[ModelRead]],
summary="列出模型(分页)",
)
async def list_models(
db: AsyncSession = Depends(get_db),
provider_id: str | None = Query(None, description="按供应商过滤"),
category: ModelCategoryKey | None = Query(None, description="按模型类别过滤"),
q: str | None = Query(None, description="关键字,过滤 name/description"),
order: str | None = Query(None, description="排序字段:name, category, created_at, updated_at"),
is_desc: bool = Query(False, description="是否倒序"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页条数"),
) -> ApiResponse[PaginatedData[ModelRead]]:
stmt = select(Model)
count_stmt = select(func.count()).select_from(Model)
if provider_id is not None:
stmt = stmt.where(Model.provider_id == provider_id)
count_stmt = count_stmt.where(Model.provider_id == provider_id)
if category is not None:
stmt = stmt.where(Model.category == category)
count_stmt = count_stmt.where(Model.category == category)
if q and q.strip():
q_pattern = f"%{q.strip()}%"
stmt = stmt.where(Model.name.ilike(q_pattern) | Model.description.ilike(q_pattern))
count_stmt = count_stmt.where(
Model.name.ilike(q_pattern) | Model.description.ilike(q_pattern)
)
order_col = order if order and order in MODEL_ORDER_FIELDS else "created_at"
order_attr = getattr(Model, order_col)
stmt = stmt.order_by(order_attr.desc() if is_desc else order_attr.asc())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
models: Sequence[Model] = result.scalars().all()
items = [ModelRead.model_validate(m) for m in models]
return paginated_response(items, page=page, page_size=page_size, total=total)
@router.post(
"/models",
response_model=ApiResponse[ModelRead],
status_code=status.HTTP_201_CREATED,
summary="创建模型",
)
async def create_model(
body: ModelCreate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ModelRead]:
exists = await db.get(Model, body.id)
if exists is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Model with id={body.id} already exists",
)
# 确保 provider 存在
provider = await db.get(Provider, body.provider_id)
if provider is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider not found")
model = Model(
id=body.id,
name=body.name,
category=body.category,
provider_id=body.provider_id,
params=body.params,
description=body.description,
is_default=body.is_default,
created_by=body.created_by,
)
db.add(model)
await db.flush()
await db.refresh(model)
return success_response(ModelRead.model_validate(model), code=201)
@router.get(
"/models/{model_id}",
response_model=ApiResponse[ModelRead],
summary="获取单个模型",
)
async def get_model(
model_id: str,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ModelRead]:
model = await db.get(Model, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
return success_response(ModelRead.model_validate(model))
@router.patch(
"/models/{model_id}",
response_model=ApiResponse[ModelRead],
summary="更新模型",
)
async def update_model(
model_id: str,
body: ModelUpdate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ModelRead]:
model = await db.get(Model, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
update_data = body.model_dump(exclude_unset=True)
# 如果更新 provider_id,需要校验新 provider 是否存在
new_provider_id = update_data.get("provider_id")
if new_provider_id is not None:
provider = await db.get(Provider, new_provider_id)
if provider is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider not found")
for field, value in update_data.items():
setattr(model, field, value)
await db.flush()
await db.refresh(model)
return success_response(ModelRead.model_validate(model))
@router.delete(
"/models/{model_id}",
response_model=ApiResponse[None],
status_code=status.HTTP_200_OK,
summary="删除模型",
)
async def delete_model(
model_id: str,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[None]:
model = await db.get(Model, model_id)
if model is None:
return success_response(None)
await db.delete(model)
await db.flush()
return success_response(None)
# ---------- ModelSettings(单例) ----------
async def _get_or_create_settings(db: AsyncSession) -> ModelSettings:
"""内部工具:获取或创建单例设置行(id=1)。"""
settings = await db.get(ModelSettings, 1)
if settings is None:
settings = ModelSettings(id=1)
db.add(settings)
await db.flush()
await db.refresh(settings)
return settings
@router.get(
"/model-settings",
response_model=ApiResponse[ModelSettingsRead],
summary="获取模型全局设置(单例)",
)
async def get_model_settings(
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ModelSettingsRead]:
settings = await _get_or_create_settings(db)
return success_response(ModelSettingsRead.model_validate(settings))
@router.put(
"/model-settings",
response_model=ApiResponse[ModelSettingsRead],
summary="更新模型全局设置(单例)",
)
async def update_model_settings(
body: ModelSettingsUpdate,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ModelSettingsRead]:
settings = await _get_or_create_settings(db)
update_data = body.model_dump()
for field, value in update_data.items():
setattr(settings, field, value)
await db.flush()
await db.refresh(settings)
return success_response(ModelSettingsRead.model_validate(settings))
+51 -3
View File
@@ -2,11 +2,53 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.config import settings
from app.api.v1 import router as api_v1_router
from app.config import settings
from app.schemas.common import ApiResponse
def _error_message(detail: object) -> str:
"""将异常 detail 转为前端可读的 message。"""
if isinstance(detail, str):
return detail
if isinstance(detail, list):
parts = []
for item in detail:
if isinstance(item, dict) and "msg" in item:
loc = item.get("loc", ())
loc_str = ".".join(str(x) for x in loc if x != "body")
parts.append(f"{loc_str}: {item['msg']}" if loc_str else item["msg"])
else:
parts.append(str(item))
return "; ".join(parts) if parts else "Validation error"
return str(detail)
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""HTTP 异常统一为 { code, message, data: null }。"""
from fastapi import HTTPException
if isinstance(exc, HTTPException):
code = exc.status_code
message = _error_message(exc.detail)
else:
code = 500
message = "Internal server error"
body = ApiResponse[None](code=code, message=message, data=None).model_dump()
return JSONResponse(status_code=code, content=body)
async def validation_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""422 校验异常统一为 { code: 422, message, data: null }。"""
assert isinstance(exc, RequestValidationError)
message = _error_message(exc.errors())
body = ApiResponse[None](code=422, message=message, data=None).model_dump()
return JSONResponse(status_code=422, content=body)
@asynccontextmanager
@@ -26,6 +68,11 @@ app = FastAPI(
redoc_url="/redoc",
)
# 统一错误响应格式:{ code, message, data: null }
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, http_exception_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
@@ -41,4 +88,5 @@ app.include_router(api_v1_router, prefix=settings.api_v1_prefix)
@app.get("/health")
async def health():
"""健康检查。"""
return {"status": "ok"}
from app.schemas.common import success_response
return success_response({"status": "ok"})
+73
View File
@@ -0,0 +1,73 @@
"""API 全局响应结构与分页。"""
from __future__ import annotations
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""无分页时的统一响应结构。"""
code: int = Field(200, description="与 HTTP 状态码一致")
message: str = Field("success", description="提示信息")
data: T | None = Field(None, description="实际数据")
class Pagination(BaseModel):
"""分页信息。"""
page: int = Field(..., description="当前页,从 1 开始")
page_size: int = Field(..., description="每页条数")
total: int = Field(..., description="总条数")
max_page: int = Field(..., description="最大页码")
class PaginatedData(BaseModel, Generic[T]):
"""有分页时的 data 结构。"""
items: list[T] = Field(..., description="当前页数据")
pagination: Pagination = Field(..., description="分页信息")
def success_response(
data: T,
*,
code: int = 200,
message: str = "success",
) -> ApiResponse[T]:
"""构造无分页的成功响应。"""
return ApiResponse(code=code, message=message, data=data)
def paginated_response(
items: list[T],
*,
page: int,
page_size: int,
total: int,
code: int = 200,
message: str = "success",
) -> ApiResponse[PaginatedData[T]]:
"""构造有分页的成功响应。"""
max_page = max(1, (total + page_size - 1) // page_size) if page_size > 0 else 1
pagination = Pagination(
page=page,
page_size=page_size,
total=total,
max_page=max_page,
)
payload = PaginatedData(items=items, pagination=pagination)
return ApiResponse(code=code, message=message, data=payload)
def error_response(
*,
code: int = 500,
message: str = "error",
) -> ApiResponse[None]:
"""构造错误响应(data 为 null)。"""
return ApiResponse(code=code, message=message, data=None)
+117
View File
@@ -0,0 +1,117 @@
"""与 LLM 相关的 Pydantic Schema(Provider/Model/ModelSettings)。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from app.models.llm import AgentTypeKey, LogLevel, ModelCategoryKey, ProviderStatus
class ProviderBase(BaseModel):
"""供应商通用字段(不含敏感字段)。"""
name: str = Field(..., description="供应商名称")
base_url: str = Field(..., description="API Base URL")
description: str = Field("", description="说明")
status: ProviderStatus = Field(
ProviderStatus.testing,
description="状态:active/testing/disabled",
)
created_by: str = Field("", description="创建人")
class ProviderCreate(ProviderBase):
"""创建供应商时的请求体,允许填写敏感字段。"""
id: str = Field(..., description="供应商 ID")
api_key: str = Field("", description="API Key(敏感,不在响应中回显)")
api_secret: str = Field("", description="API Secret(敏感,不在响应中回显)")
class ProviderUpdate(BaseModel):
"""更新供应商时的可选字段。"""
name: str | None = Field(None, description="供应商名称")
base_url: str | None = Field(None, description="API Base URL")
description: str | None = Field(None, description="说明")
status: ProviderStatus | None = Field(
None,
description="状态:active/testing/disabled",
)
api_key: str | None = Field(None, description="API Key(敏感,不在响应中回显)")
api_secret: str | None = Field(None, description="API Secret(敏感,不在响应中回显)")
class ProviderRead(ProviderBase):
"""对外返回的供应商信息(不包含 api_key/api_secret)。"""
id: str = Field(..., description="供应商 ID")
class Config:
from_attributes = True
class ModelBase(BaseModel):
"""模型通用字段。"""
name: str = Field(..., description="模型名称")
category: ModelCategoryKey = Field(..., description="模型类别:text/image/video")
provider_id: str = Field(..., description="所属供应商 ID")
params: dict[str, Any] = Field(default_factory=dict, description="模型参数(JSON)")
description: str = Field("", description="说明")
is_default: bool = Field(False, description="是否默认")
created_by: str = Field("", description="创建人")
class ModelCreate(ModelBase):
"""创建模型请求体。"""
id: str = Field(..., description="模型 ID")
class ModelUpdate(BaseModel):
"""更新模型请求体(全部可选)。"""
name: str | None = Field(None, description="模型名称")
category: ModelCategoryKey | None = Field(None, description="模型类别")
provider_id: str | None = Field(None, description="所属供应商 ID")
params: dict[str, Any] | None = Field(None, description="模型参数(JSON)")
description: str | None = Field(None, description="说明")
is_default: bool | None = Field(None, description="是否默认")
class ModelRead(ModelBase):
"""对外返回的模型信息。"""
id: str = Field(..., description="模型 ID")
class Config:
from_attributes = True
class ModelSettingsBase(BaseModel):
"""模型全局设置通用字段。"""
default_text_model_id: str | None = Field(None, description="默认文本模型 ID")
default_image_model_id: str | None = Field(None, description="默认图片模型 ID")
default_video_model_id: str | None = Field(None, description="默认视频模型 ID")
api_timeout: int = Field(30, description="API 超时(秒)")
log_level: LogLevel = Field(LogLevel.info, description="日志级别")
class ModelSettingsUpdate(ModelSettingsBase):
"""更新或保存模型全局设置请求体。"""
pass
class ModelSettingsRead(ModelSettingsBase):
"""对外返回的模型全局设置。"""
id: int = Field(..., description="设置行 ID(通常为 1)")
class Config:
from_attributes = True