Files
ctms/backend/app/main.py
T
Cheng Zhou c68dddfc01
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
2026-07-14 14:19:17 +08:00

350 lines
13 KiB
Python

import asyncio
import logging
import re
import time
from contextlib import asynccontextmanager
from collections import defaultdict
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.api.v1.router import api_router
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
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
from app.services.permission_log_writer import start_log_writer, stop_log_writer
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
from app.services.source_location_aggregator import run_hourly_source_location_aggregation
from app.services.monitoring_server_location import resolve_monitoring_server_location
from app.services.monitoring_retention import run_monitoring_retention
from app.services.security_access_log_writer import (
get_security_log_writer,
start_security_log_writer,
stop_security_log_writer,
)
from app.core.security import decode_token
from app.core.deps import get_db_session
from app.core.request_context import (
build_request_audit_context,
build_request_snapshot,
build_sanitized_request_headers,
get_request_audit_context,
reset_request_audit_context,
resolve_ctms_client_type,
resolve_client_ip,
set_request_audit_context,
)
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()
await resolve_monitoring_server_location()
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
source_location_aggregator_task = asyncio.create_task(
run_hourly_source_location_aggregation(stop_event)
)
await start_log_writer()
await start_security_log_writer()
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)
retention_task = asyncio.create_task(run_monitoring_retention(stop_event))
yield
stop_event.set()
await stop_log_writer()
await stop_security_log_writer()
await scheduler_task
await aggregator_task
await source_location_aggregator_task
await retention_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()
validate_onlyoffice_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=get_cors_allowed_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=[
"Accept",
"Authorization",
"Content-Type",
"X-CTMS-Client-Source",
"X-CTMS-Client-Type",
"X-CTMS-Client-Version",
"X-CTMS-Client-Platform",
"X-CTMS-Build-Channel",
"X-CTMS-Build-Commit",
"X-Request-ID",
"X-Correlation-ID",
],
expose_headers=["X-Request-ID"],
)
@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
should_security_log = path.startswith("/api/") and path != "/api/v1/auth/session/heartbeat"
started_at = time.perf_counter()
audit_context = build_request_audit_context(request)
audit_context_token = set_request_audit_context(audit_context)
status_code = 500
try:
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,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
raise
status_code = int(response.status_code)
if audit_context.request_id:
response.headers["X-Request-ID"] = audit_context.request_id
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
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,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
return response
finally:
reset_request_audit_context(audit_context_token)
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(
"/readyz",
tags=["health"],
summary="服务就绪检查",
description="验证应用可访问数据库;失败时返回 503。",
)
async def readiness(db=Depends(get_db_session)):
started_at = time.perf_counter()
try:
await db.execute(text("SELECT 1"))
except Exception as exc:
logger.exception("Readiness database check failed")
return JSONResponse(
status_code=503,
content={
"status": "not_ready",
"database": {
"status": "unhealthy",
"error_type": type(exc).__name__,
},
},
)
return {
"status": "ready",
"database": {
"status": "healthy",
"latency_ms": round((time.perf_counter() - started_at) * 1000, 2),
},
}
@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")
app.include_router(onlyoffice_internal_router)
return app
app = create_app()
def _resolve_auth_context(request) -> tuple[str, str | None]:
authorization = request.headers.get("authorization") or ""
if not authorization.lower().startswith("bearer "):
return "ANONYMOUS", None
token = authorization.split(" ", 1)[1].strip()
if not token:
return "ANONYMOUS", None
try:
payload = decode_token(token)
except Exception:
return "INVALID_TOKEN", None
subject = payload.get("sub")
return ("AUTHENTICATED", str(subject)) if subject else ("INVALID_TOKEN", None)
def _enqueue_security_access_log(request, path: str, status_code: int, started_at: float) -> None:
writer = get_security_log_writer()
if not writer:
return
auth_status, user_identifier = _resolve_auth_context(request)
context = get_request_audit_context()
writer.enqueue(
{
"method": request.method,
"path": path,
"status_code": status_code,
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
"client_ip": resolve_client_ip(request),
"user_agent": request.headers.get("user-agent"),
"client_type": resolve_ctms_client_type(request.headers),
"client_version": request.headers.get("x-ctms-client-version"),
"client_platform": request.headers.get("x-ctms-client-platform"),
"build_channel": request.headers.get("x-ctms-build-channel"),
"build_commit": request.headers.get("x-ctms-build-commit"),
"request_headers": context.request_headers if context else build_sanitized_request_headers(request),
"request_snapshot": context.request_snapshot if context else build_request_snapshot(request),
"request_id": context.request_id if context else None,
"auth_status": auth_status,
"user_identifier": user_identifier,
}
)