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.fee_seed import seed_demo_fees 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) await seed_demo_fees(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( "/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()