mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
405 lines
13 KiB
Python
405 lines
13 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.adapters.web.correlation import CorrelationIdMiddleware
|
|
from app.adapters.web.metrics import HttpMetricsMiddleware
|
|
from app.adapters.observability.otel import build_observation_port
|
|
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
|
from app.adapters.web.health import install_health_routes
|
|
from app.application.plugin.routes import configure_plugin_routes
|
|
from app.application.plugin.runtime import get_plugin_manager
|
|
from app.schemas.exception import (
|
|
PersistenceUnavailableError,
|
|
)
|
|
from app.adapters.web.security.access import (
|
|
configure_token_codec,
|
|
verify_apikey,
|
|
verify_token,
|
|
)
|
|
from app.application.security.token import create_access_token, decode_access_token
|
|
from app.runtime.config import global_vars
|
|
from app.runtime.settings import RuntimeSettingsCompat
|
|
|
|
settings = RuntimeSettingsCompat()
|
|
from app.runtime.correlation import get_correlation_id
|
|
from app.runtime.localization import LocaleHelper
|
|
from app.runtime.log import configure_correlation_id_provider, logger
|
|
from app.runtime.observability import configure_observation
|
|
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 app.runtime.version import get_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,
|
|
headers: dict[str, str] | None = None,
|
|
) -> 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"),
|
|
headers=headers,
|
|
)
|
|
|
|
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"),
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
def _mcp_jsonrpc_error_response(
|
|
status_code: int,
|
|
code: int,
|
|
message: str,
|
|
headers: dict[str, str] | None = None,
|
|
) -> 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"),
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
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,
|
|
headers=exc.headers,
|
|
)
|
|
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,
|
|
headers=exc.headers,
|
|
)
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=ApiResponse[None](success=False, message=message).model_dump(mode="json"),
|
|
headers=exc.headers,
|
|
)
|
|
|
|
|
|
async def persistence_unavailable_handler(
|
|
request: Request,
|
|
_exc: PersistenceUnavailableError,
|
|
) -> JSONResponse:
|
|
"""将持久化能力暂不可用映射为可重试的 503 响应。"""
|
|
return await localized_http_exception_handler(
|
|
request,
|
|
HTTPException(
|
|
status_code=503,
|
|
detail="服务当前繁忙,请稍后重试",
|
|
headers={"Retry-After": "1"},
|
|
),
|
|
)
|
|
|
|
|
|
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 应用实例。
|
|
"""
|
|
configure_correlation_id_provider(get_correlation_id)
|
|
configure_observation(build_observation_port())
|
|
_app = FastAPI(
|
|
title=settings.PROJECT_NAME,
|
|
version=get_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(
|
|
PersistenceUnavailableError,
|
|
persistence_unavailable_handler,
|
|
)
|
|
_app.add_exception_handler(
|
|
RequestValidationError,
|
|
localized_validation_exception_handler,
|
|
)
|
|
_app.add_exception_handler(Exception, localized_unhandled_exception_handler)
|
|
# 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。
|
|
_app.router.route_class = ResponseAPIRoute
|
|
# 编排器探针使用原生 APIRoute 和最小响应,不进入业务响应包络或版本前缀。
|
|
install_health_routes(_app)
|
|
|
|
# 配置 CORS 中间件
|
|
_app.add_middleware(
|
|
CORSMiddleware, # noqa
|
|
allow_origins=settings.ALLOWED_HOSTS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
_app.add_middleware(CorrelationIdMiddleware)
|
|
_app.add_middleware(HttpMetricsMiddleware)
|
|
|
|
@_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)
|
|
|
|
# HTTP 适配器只持有令牌编解码端口,具体实现由组合根在创建应用时连接。
|
|
configure_token_codec(create_access_token, decode_access_token)
|
|
|
|
# 向 application 层插件路由服务注入应用实例,插件 API 的动态注册/移除
|
|
# 统一经服务完成,避免 api.endpoints 反向依赖本模块。
|
|
configure_plugin_routes(FastAPIDynamicRouteRegistry(
|
|
app=_app,
|
|
plugin_ids=lambda: get_plugin_manager().get_running_plugin_ids(),
|
|
plugin_apis=lambda plugin_id: get_plugin_manager().get_plugin_apis(plugin_id),
|
|
verify_token=verify_token,
|
|
verify_apikey=verify_apikey,
|
|
prefix=f"{settings.API_V1_STR}/plugin",
|
|
protected_routes={
|
|
f"{settings.API_V1_STR}/openapi.json",
|
|
"/docs",
|
|
"/docs/oauth2-redirect",
|
|
"/redoc",
|
|
},
|
|
log=logger,
|
|
event_loop=lambda: global_vars.CURRENT_EVENT_LOOP,
|
|
))
|
|
|
|
return _app
|
|
|
|
|
|
# 创建 FastAPI 应用实例;所有组合根装配副作用都在 create_app() 内部完成
|
|
app = create_app()
|