Files
ctms/backend/app/main.py
T
2026-03-30 22:05:02 +08:00

213 lines
7.5 KiB
Python

import asyncio
import logging
import re
import time
from contextlib import asynccontextmanager
from collections import defaultdict
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.api.v1.router import api_router
from app.core.config import settings
from app.core.exceptions import register_exception_handlers
from app.crud.user import ensure_admin_exists
from app.db.base import Base
from app.db.session import SessionLocal, engine
from app.services.visit_scheduler import run_daily_lost_visit_job
logger = logging.getLogger("ctms.setup_config")
UUID_RE = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
setup_config_stats: dict[str, int] = defaultdict(int)
@asynccontextmanager
async def lifespan(_: FastAPI):
stop_event = asyncio.Event()
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
# Ensure models are imported so metadata is populated
from app.models import user as user_model # noqa: F401
if settings.ENV == "development":
async with engine.begin() as conn:
await _ensure_legacy_primary_keys(conn)
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await ensure_admin_exists(session)
yield
stop_event.set()
await scheduler_task
async def _ensure_legacy_primary_keys(conn) -> None:
# Fix legacy databases where tables with an "id" column were created without a PK.
result = await conn.execute(
text(
"""
SELECT c.relname AS table_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND EXISTS (
SELECT 1
FROM pg_attribute a
WHERE a.attrelid = c.oid
AND a.attname = 'id'
AND a.attisdropped = false
)
AND NOT EXISTS (
SELECT 1
FROM pg_constraint con
WHERE con.conrelid = c.oid
AND con.contype = 'p'
)
"""
)
)
tables = [row.table_name for row in result.fetchall()]
for table_name in tables:
await conn.execute(
text(f'ALTER TABLE "{table_name}" ADD CONSTRAINT "{table_name}_pkey" PRIMARY KEY (id)')
)
def create_app() -> FastAPI:
app = FastAPI(
title="CTMS 后端 API",
description="临床试验项目管理系统后端接口文档",
version="0.1.0",
debug=settings.ENV == "development",
lifespan=lifespan,
openapi_tags=[
{"name": "auth", "description": "认证与登录"},
{"name": "users", "description": "用户管理"},
{"name": "studies", "description": "项目管理"},
{"name": "sites", "description": "中心管理"},
{"name": "study-members", "description": "项目成员"},
{"name": "attachments", "description": "通用附件"},
{"name": "audit-logs", "description": "审计日志"},
{"name": "dashboard", "description": "项目总览与统计"},
{"name": "subjects", "description": "参与者"},
{"name": "visits", "description": "访视"},
{"name": "aes", "description": "不良事件"},
{"name": "finance", "description": "费用管理"},
{"name": "faq-categories", "description": "FAQ 分类"},
{"name": "faqs", "description": "FAQ 条目"},
{"name": "health", "description": "健康检查"},
],
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def setup_config_monitoring_middleware(request, call_next):
path = request.url.path
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
started_at = time.perf_counter()
try:
response = await call_next(request)
except Exception:
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
key = f"{request.method} {normalized_path} 5xx"
setup_config_stats[key] += 1
logger.exception(
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
500,
duration_ms,
)
raise
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
status_code = int(response.status_code)
status_bucket = f"{status_code // 100}xx"
key = f"{request.method} {normalized_path} {status_bucket}"
setup_config_stats[key] += 1
if status_code >= 500:
logger.error(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
elif status_code >= 400:
logger.warning(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
else:
logger.info(
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
request.method,
normalized_path,
status_code,
duration_ms,
)
return response
register_exception_handlers(app)
@app.get(
"/",
tags=["health"],
summary="服务入口说明",
description="返回后端服务入口说明,避免将根路径误认为前端页面。",
)
async def root() -> dict[str, str]:
return {
"service": "ctms-backend",
"status": "ok",
"health": "/health",
"docs": "/docs",
"api": "/api/v1",
}
@app.get(
"/health",
tags=["health"],
summary="健康检查",
description="返回服务存活状态。",
)
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get(
"/health/setup-config-stats",
tags=["health"],
summary="立项配置接口监控统计",
description="返回当前进程内立项配置接口访问计数(按接口+状态段汇总)。",
)
async def setup_config_health_stats() -> dict[str, dict[str, int]]:
summary: dict[str, int] = dict(setup_config_stats)
totals = {
"2xx": sum(v for k, v in summary.items() if k.endswith(" 2xx")),
"4xx": sum(v for k, v in summary.items() if k.endswith(" 4xx")),
"5xx": sum(v for k, v in summary.items() if k.endswith(" 5xx")),
}
return {"totals": totals, "by_endpoint": summary}
app.include_router(api_router, prefix="/api/v1")
return app
app = create_app()