179 lines
6.5 KiB
Python
179 lines
6.5 KiB
Python
"""两个入口共用的 HTTP 层:响应信封、鉴权依赖、异常处理器
|
|
|
|
抓取服务与交易服务是两个独立进程(见 README「两个部署单元」),但对外契约必须
|
|
一致:同一套 `ApiResponse` 信封、同一份错误码表、同一个 Bearer token。共用的部分
|
|
集中在这里,两侧的差异只体现在各自注册的路由与容器。
|
|
|
|
这里刻意不放任何站点或业务知识——`get_container` 不标注具体容器类型,shared 因此
|
|
不需要认识 `ScrapingContainer` / `TradingContainer`,避免共用层反向依赖两侧。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
from typing import Any, Generic, TypeVar
|
|
|
|
from fastapi import Depends, FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.shared.errors import AppError, AuthenticationError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class ApiResponse(BaseModel, Generic[T]):
|
|
"""统一 API 响应格式"""
|
|
|
|
success: bool = Field(description="请求是否成功")
|
|
msg: str = Field(description="提示信息;失败时为错误原因")
|
|
data: T | None = Field(default=None, description="响应数据;无数据或失败时为 null")
|
|
code: int = Field(
|
|
description="错误码:0 表示成功,其余见统一错误码表(如 1001 鉴权失败、"
|
|
"1002 请求参数校验失败、5xxx 交易服务、6xxx 网关)"
|
|
)
|
|
|
|
|
|
# ---- 依赖注入 ----
|
|
|
|
|
|
def get_container(request: Request) -> Any:
|
|
"""从请求中获取服务容器
|
|
|
|
返回类型故意留成 Any:抓取侧与交易侧的容器结构不同,由各自路由标注具体类型。
|
|
"""
|
|
return request.app.state.container
|
|
|
|
|
|
def require_bearer_token(
|
|
request: Request,
|
|
container: Any = Depends(get_container),
|
|
) -> None:
|
|
"""Bearer Token 鉴权依赖
|
|
|
|
从请求头 Authorization 中提取 Bearer Token,
|
|
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
|
|
"""
|
|
authorization = request.headers.get("Authorization")
|
|
if not authorization:
|
|
logger.warning("鉴权失败:缺少 Authorization 请求头")
|
|
raise AuthenticationError("Missing Authorization header")
|
|
|
|
token = authorization.replace("Bearer ", "", 1).strip()
|
|
if token == authorization:
|
|
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
|
|
raise AuthenticationError("Invalid Authorization scheme")
|
|
if not token:
|
|
logger.warning("鉴权失败:Bearer token 为空")
|
|
raise AuthenticationError("Invalid token")
|
|
|
|
if not secrets.compare_digest(token, container.settings.bearer_token):
|
|
logger.warning("鉴权失败:token 不匹配")
|
|
raise AuthenticationError("Invalid token")
|
|
|
|
|
|
# ---- 异常处理 ----
|
|
|
|
|
|
def _format_validation_msg(errors: list[dict]) -> str:
|
|
"""将校验错误整理为便于前端展示的消息。"""
|
|
if not errors:
|
|
return "Validation error"
|
|
|
|
messages: list[str] = []
|
|
for error in errors:
|
|
loc = ".".join(str(part) for part in error.get("loc", []) if part != "body")
|
|
msg = str(error.get("msg", "Validation error"))
|
|
messages.append(f"{loc}: {msg}" if loc else msg)
|
|
|
|
return "; ".join(messages)
|
|
|
|
|
|
def jsonable_errors(errors: list[dict]) -> list[dict]:
|
|
"""剔除校验错误里不可 JSON 序列化的 ctx(如原始异常对象)"""
|
|
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
|
|
|
|
|
|
def register_exception_handlers(app: FastAPI) -> None:
|
|
"""给应用挂上全套异常处理器
|
|
|
|
两个入口都调用它,保证抓取失败与下单失败返回的错误结构完全一致,
|
|
上游只需要按 code 分支,不必区分是哪个服务回的。
|
|
"""
|
|
|
|
@app.exception_handler(AppError)
|
|
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
|
"""业务异常处理器:返回结构化的错误响应"""
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=ApiResponse[None](
|
|
success=False,
|
|
code=exc.err_code,
|
|
msg=exc.message,
|
|
data=None,
|
|
).model_dump(),
|
|
headers=exc.headers,
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
"""请求参数校验异常处理器"""
|
|
errors = exc.errors()
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content=ApiResponse[object](
|
|
success=False,
|
|
msg=_format_validation_msg(errors),
|
|
data=jsonable_errors(errors),
|
|
code=1002,
|
|
).model_dump(),
|
|
)
|
|
|
|
@app.exception_handler(ValidationError)
|
|
async def pydantic_validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse:
|
|
"""Pydantic 模型校验异常处理器"""
|
|
errors = exc.errors()
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content=ApiResponse[object](
|
|
success=False,
|
|
msg=_format_validation_msg(errors),
|
|
data=jsonable_errors(errors),
|
|
code=1002,
|
|
).model_dump(),
|
|
)
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
"""HTTP 异常处理器(404、500 等)"""
|
|
status_code = int(getattr(exc, "status_code", 500) or 500)
|
|
err_code = 1404 if status_code == 404 else 1500
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=ApiResponse[None](
|
|
success=False,
|
|
msg=str(getattr(exc, "detail", "HTTP error")),
|
|
data=None,
|
|
code=err_code,
|
|
).model_dump(),
|
|
headers=getattr(exc, "headers", None),
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
|
"""兜底异常处理器:捕获所有未处理的异常"""
|
|
logger.exception("未处理异常:%s", exc)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content=ApiResponse[None](
|
|
success=False,
|
|
msg="Internal server error",
|
|
data=None,
|
|
code=1500,
|
|
).model_dump(),
|
|
)
|