Files
DadaoTask/backend/app/main.py
T
Steven e25a1a4e31
CI / backend (push) Canceled after 0s
feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
2026-10-28 03:48:48 +08:00

137 lines
4.5 KiB
Python

"""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, 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.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,
}