Files
MoviePilot/app/factory.py
T
jxxghp 7e851dbfa7 refactor(chain): 处理链功能域 mixin 化,清理未使用导入并根治兼容层循环导入
- ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin
- TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py
- 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export
- 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名)
- 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间
- 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入
- 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError
- 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录
2026-08-16 16:30:16 +08:00

334 lines
10 KiB
Python

import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from app.api.response import ResponseAPIRoute
from app.application.plugins import register_api_app
from app.runtime.config import settings
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
from app.schemas.openai import (
AnthropicErrorDetail,
AnthropicErrorResponse,
OpenAIErrorDetail,
OpenAIErrorResponse,
)
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
from app.schemas.response import Response as ApiResponse, ValidationIssue
from app.startup.lifecycle import lifespan
from version import APP_VERSION
def _get_http_exception_message(detail: Any) -> str:
"""将 HTTPException 的 detail 转换为统一消息文本。"""
if isinstance(detail, str) and detail:
return detail
if detail is None:
return "请求失败"
try:
return json.dumps(detail, ensure_ascii=False)
except TypeError:
return str(detail)
def _localize_exception_message(request: Request, message: str) -> str:
"""直接按异常所属请求的语言翻译消息,避免中间件上下文已被恢复。"""
return LocaleHelper.translate_text(
message,
locale=LocaleHelper.get_locale_from_request(request),
)
def _is_mcp_jsonrpc_request(request: Request) -> bool:
"""判断请求是否指向保持原生响应的 MCP JSON-RPC 根端点。"""
request_path = getattr(getattr(request, "url", None), "path", "")
return request_path.rstrip("/") == f"{settings.API_V1_STR}/mcp"
def _get_native_ai_protocol(request: Request) -> str | None:
"""识别需要保持原生错误体的 OpenAI 或 Anthropic 兼容请求。"""
request_path = getattr(getattr(request, "url", None), "path", "")
if request_path.startswith(f"{settings.API_V1_STR}/openai/v1/"):
return "openai"
if request_path.startswith(f"{settings.API_V1_STR}/anthropic/v1/"):
return "anthropic"
return None
def _native_ai_error_response(
protocol: str,
status_code: int,
message: str,
) -> JSONResponse:
"""按 OpenAI 或 Anthropic 兼容协议构造原生错误响应。"""
if protocol == "openai":
error_type = (
"authentication_error"
if status_code in {401, 403}
else "server_error"
if status_code >= 500
else "invalid_request_error"
)
return JSONResponse(
status_code=status_code,
content=OpenAIErrorResponse(
error=OpenAIErrorDetail(
message=message,
type=error_type,
code=error_type,
)
).model_dump(mode="json"),
)
error_type = (
"authentication_error"
if status_code in {401, 403}
else "api_error"
if status_code >= 500
else "invalid_request_error"
)
return JSONResponse(
status_code=status_code,
content=AnthropicErrorResponse(
error=AnthropicErrorDetail(type=error_type, message=message)
).model_dump(mode="json"),
)
def _mcp_jsonrpc_error_response(
status_code: int,
code: int,
message: str,
) -> JSONResponse:
"""构造带 HTTP 状态码的 MCP JSON-RPC 原生错误响应。"""
return JSONResponse(
status_code=status_code,
content=McpJsonRpcError(
jsonrpc="2.0",
id=None,
error=McpJsonRpcErrorDetail(code=code, message=message),
).model_dump(mode="json"),
)
def _protocol_validation_error_response(
request: Request,
exc: RequestValidationError,
) -> JSONResponse | None:
"""为 OpenAI 与 Anthropic 兼容端点生成协议原生的参数错误响应。"""
errors = exc.errors()
first_error = errors[0] if errors else {}
location = ".".join(
str(item)
for item in first_error.get("loc", ())
if item not in {"body"}
)
message = str(first_error.get("msg") or "Invalid request parameters.")
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol == "openai":
return JSONResponse(
status_code=422,
content=OpenAIErrorResponse(
error=OpenAIErrorDetail(
message=message,
type="invalid_request_error",
param=location or None,
code="invalid_request_error",
)
).model_dump(mode="json"),
)
if native_ai_protocol == "anthropic":
if location:
message = f"{location}: {message}"
return JSONResponse(
status_code=422,
content=AnthropicErrorResponse(
error=AnthropicErrorDetail(
type="invalid_request_error",
message=message,
)
).model_dump(mode="json"),
)
if _is_mcp_jsonrpc_request(request):
if location:
message = f"{location}: {message}"
return _mcp_jsonrpc_error_response(
status_code=422,
code=-32602,
message=message,
)
return None
async def localized_http_exception_handler(
request: Request,
exc: HTTPException,
) -> JSONResponse:
"""
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
:param request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: 统一 JSON 错误响应
"""
message = _localize_exception_message(
request,
_get_http_exception_message(exc.detail),
)
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol:
return _native_ai_error_response(
protocol=native_ai_protocol,
status_code=exc.status_code,
message=message,
)
if _is_mcp_jsonrpc_request(request):
error_codes = {
400: -32600,
401: -32001,
403: -32001,
404: -32601,
409: -32009,
}
return _mcp_jsonrpc_error_response(
status_code=exc.status_code,
code=error_codes.get(exc.status_code, -32000),
message=message,
)
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](success=False, message=message).model_dump(mode="json"),
headers=exc.headers,
)
async def localized_validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""
将请求参数校验错误转换为统一响应并保留结构化错误数据。
:param request: 当前 HTTP 请求
:param exc: FastAPI 请求参数校验异常
:return: 统一 JSON 错误响应
"""
protocol_response = _protocol_validation_error_response(request, exc)
if protocol_response is not None:
return protocol_response
errors = [
ValidationIssue(
location=list(error.get("loc", ())),
message=str(error.get("msg") or "请求参数错误"),
error_type=str(error.get("type") or "validation_error"),
)
for error in exc.errors()
]
return JSONResponse(
status_code=422,
content=ApiResponse[list[ValidationIssue]](
success=False,
message=_localize_exception_message(request, "请求参数不正确"),
data=errors,
).model_dump(mode="json"),
)
async def localized_unhandled_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
"""
将未捕获异常隐藏为统一的服务器错误响应,避免泄露内部细节。
:param request: 当前 HTTP 请求
:param exc: 未捕获异常
:return: 统一 JSON 错误响应
"""
logger.error(
f"API 请求发生未捕获异常: {exc}",
exc_info=(type(exc), exc, exc.__traceback__),
)
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol:
return _native_ai_error_response(
protocol=native_ai_protocol,
status_code=500,
message="Internal server error.",
)
if _is_mcp_jsonrpc_request(request):
return _mcp_jsonrpc_error_response(
status_code=500,
code=-32603,
message="Internal error",
)
return JSONResponse(
status_code=500,
content=ApiResponse[None](
success=False,
message=_localize_exception_message(request, "未知错误"),
).model_dump(mode="json"),
)
def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例。
"""
_app = FastAPI(
title=settings.PROJECT_NAME,
version=APP_VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
_app.add_exception_handler(
RequestValidationError,
localized_validation_exception_handler,
)
_app.add_exception_handler(Exception, localized_unhandled_exception_handler)
# 动态注册的插件接口也必须使用统一响应路由类。
_app.router.route_class = ResponseAPIRoute
# 配置 CORS 中间件
_app.add_middleware(
CORSMiddleware, # noqa
allow_origins=settings.ALLOWED_HOSTS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@_app.middleware("http")
async def locale_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
为每个请求设置后端多语言上下文。
"""
token = LocaleHelper.set_current_locale(
LocaleHelper.get_locale_from_request(request)
)
try:
return await call_next(request)
finally:
LocaleHelper.reset_current_locale(token)
return _app
# 创建 FastAPI 应用实例
app = create_app()
# 向 application 层插件路由服务注入应用实例,插件 API 的动态注册/移除
# 统一经服务完成,避免 api.endpoints 反向依赖本模块。
register_api_app(app)