"""FastAPI 入口。""" from __future__ import annotations from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from sqlalchemy import text from starlette.exceptions import HTTPException as StarletteHTTPException from .api import auth, items, knowledge, tickets, workspaces from .config import get_settings from .db import SessionFactory, current_app_role, engine from .errors import AppError, ErrorCode, http_status_for from .logging_setup import get_logger, request_id_ctx, setup_logging from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body settings = get_settings() setup_logging("DEBUG" if settings.env == "dev" else "INFO") logger = get_logger("app") @asynccontextmanager async def lifespan(app: FastAPI): settings.validate_runtime() logger.info( "服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}}, ) yield await engine.dispose() app = FastAPI( title=f"{settings.app_name} API", version="0.1.0", description="达道Task —— 任务 / 工单 / 知识库,一条流。", lifespan=lifespan, docs_url="/docs" if settings.env != "prod" else None, redoc_url=None, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Request-Id", "X-Idempotent-Replay"], ) # 后加的中间件先执行:RequestContext 必须最外层 app.add_middleware(IdempotencyMiddleware) app.add_middleware(RequestContextMiddleware) # ── 异常处理:对外永远是 {code, message, data, request_id} ── @app.exception_handler(AppError) async def handle_app_error(request: Request, exc: AppError) -> JSONResponse: return JSONResponse( status_code=exc.http_status, content=error_body(request_id_ctx.get(), exc.code, exc.message, exc.details), ) @app.exception_handler(RequestValidationError) async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse: details: list[dict[str, Any]] = [ {"field": ".".join(str(p) for p in err["loc"][1:]), "msg": err["msg"]} for err in exc.errors() ] return JSONResponse( status_code=422, content=error_body( request_id_ctx.get(), ErrorCode.VALIDATION_FAILED, "参数校验失败", details ), ) @app.exception_handler(StarletteHTTPException) async def handle_http(request: Request, exc: StarletteHTTPException) -> JSONResponse: code = ErrorCode.NOT_FOUND if exc.status_code == 404 else ErrorCode.BAD_REQUEST return JSONResponse( status_code=exc.status_code, content=error_body(request_id_ctx.get(), code, str(exc.detail)), ) @app.exception_handler(Exception) async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse: logger.exception("未处理异常") return JSONResponse( status_code=http_status_for(ErrorCode.INTERNAL), content=error_body(request_id_ctx.get(), ErrorCode.INTERNAL, "服务器内部错误"), ) app.include_router(auth.router, prefix=settings.api_prefix) app.include_router(workspaces.router, prefix=settings.api_prefix) app.include_router(items.router, prefix=settings.api_prefix) app.include_router(items.tag_router, prefix=settings.api_prefix) app.include_router(tickets.router, prefix=settings.api_prefix) app.include_router(tickets.notify_router, prefix=settings.api_prefix) app.include_router(knowledge.router, prefix=settings.api_prefix) @app.get("/health", tags=["运维"], summary="存活探针") async def health() -> dict: db_ok = True try: async with SessionFactory() as session: await session.execute(text("SELECT 1")) except Exception: # noqa: BLE001 logger.exception("数据库探活失败") db_ok = False return {"status": "ok" if db_ok else "degraded", "db": db_ok, "env": settings.env} @app.get("/version", tags=["运维"], summary="构建标记") async def version() -> dict: """部署后必须校验:commit 必须等于 git rev-parse --short HEAD。""" role = None try: async with SessionFactory() as session: role = await current_app_role(session) except Exception: # noqa: BLE001 logger.warning("读取数据库角色失败") return { "app": settings.app_name, "env": settings.env, "commit": settings.build_commit, "build_time": settings.build_time, "api_prefix": settings.api_prefix, "db_role": role, }