Files
DadaoTask/backend/app/main.py
T
2026-11-10 13:47:56 +08:00

190 lines
6.8 KiB
Python

"""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 import reminders
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()
# 到期/逾期提醒:多 worker 下靠 advisory lock 保证只有一个进程在扫。
reminders.start()
yield
await reminders.shutdown()
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/<id>)交给浏览器处理。"""
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")