Files
ctms/backend/app/main.py
T
Cheng Zhou 5327e00cf1 将接口级权限改为业务语言
1. 更新权限配置格式
   - 从 "METHOD:/path" 改为 "module:operation"
   - 例如: "subjects:create", "visits:update", "risk_issues:delete"
   - 更易理解和管理

2. 新增权限操作列表 API
   - GET /api/v1/api-permissions/operations
   - 返回所有权限操作及其描述

3. 更新前端权限管理界面
   - 显示业务语言的权限名称
   - 按模块和操作类型筛选
   - 改进用户体验

4. 修复导入错误
   - 更新 MODULE_TO_ENDPOINTS 为 OPERATION_TO_ENDPOINTS
   - 禁用 API 端点注册表初始化

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 11:00:41 +08:00

218 lines
7.8 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.core.login_crypto import validate_login_crypto_configuration
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)
# Initialize API endpoint registry
# from app.core.decorators import initialize_api_endpoint_registry
# await initialize_api_endpoint_registry(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:
validate_login_crypto_configuration()
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()