"""FastAPI 入口。""" from __future__ import annotations from contextlib import asynccontextmanager from pathlib import Path 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 starlette.responses import Response from starlette.staticfiles import StaticFiles from starlette.types import Scope from .api import ( app_release, attachments, auth, integrations, items, knowledge, tickets, workspaces, ) from .api import settings as settings_api 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 from .services.wechat_bot import manager as wechat_manager 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() if settings.env == "prod" and settings.sms_login_enabled: logger.warning( "短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号", extra={"extra_fields": {"sms_provider": settings.sms_provider}}, ) logger.info( "服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}}, ) # 已登录的微信通道在进程启动时接上,不然重启之后消息就静默收不到了。 await wechat_manager.start_all() yield await wechat_manager.shutdown() 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.include_router(attachments.router, prefix=settings.api_prefix) app.include_router(app_release.router, prefix=settings.api_prefix) app.include_router(settings_api.router, prefix=settings.api_prefix) app.include_router(integrations.admin_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, } # ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)──────── # 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。 class _SPAStaticFiles(StaticFiles): """静态命中就返回,未命中回退 index.html——前端路由(如 /w/)交给浏览器处理。""" async def get_response(self, path: str, scope: Scope) -> Response: try: return await super().get_response(path, scope) except StarletteHTTPException as exc: if exc.status_code == 404: return await super().get_response("index.html", scope) raise _web_dist = Path(settings.web_dist_dir) if settings.web_dist_dir else None if _web_dist is not None and (_web_dist / "index.html").is_file(): app.mount("/", _SPAStaticFiles(directory=str(_web_dist), html=True), name="web")