Files
DadaoTask/backend/app/main.py
T
Steven 2b493bd9f8
CI / backend (push) Canceled after 0s
fix(m0): 迁移可执行 + 测试全绿(33 条)
跑通迁移与测试后修掉的真问题:

- alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。
  CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行,
  否则报 "cannot insert multiple commands into a prepared statement"。
- 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数,
  member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。
- datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。
- 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。
  改为只缓存 <400。
- 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0),
  节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。
- 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError,
  否则任何异常都会让"应用角色不能建表"这条断言假通过。

验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn,
/health、/version、登录、建工作区、列表、/auth/me 全通。
2026-10-28 03:22:59 +08:00

135 lines
4.3 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, 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.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,
}