release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
+126
-61
@@ -5,26 +5,44 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import FastAPI
|
||||
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.core.config import get_cors_allowed_origins, settings
|
||||
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
|
||||
from app.api.v1.collaboration import internal_router as collaboration_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.notification_scheduler import run_notification_sync_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(
|
||||
@@ -36,8 +54,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":
|
||||
@@ -46,12 +68,17 @@ async def lifespan(_: FastAPI):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
notification_scheduler_task = asyncio.create_task(run_notification_sync_job(stop_event))
|
||||
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 notification_scheduler_task
|
||||
await retention_task
|
||||
|
||||
|
||||
async def _ensure_legacy_primary_keys(conn) -> None:
|
||||
@@ -89,6 +116,7 @@ async def _ensure_legacy_primary_keys(conn) -> None:
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
validate_login_crypto_configuration()
|
||||
validate_onlyoffice_configuration()
|
||||
app = FastAPI(
|
||||
title="CTMS 后端 API",
|
||||
description="临床试验项目管理系统后端接口文档",
|
||||
@@ -123,74 +151,85 @@ def create_app() -> FastAPI:
|
||||
"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/")
|
||||
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:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
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)
|
||||
key = f"{request.method} {normalized_path} 5xx"
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
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 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)
|
||||
raise
|
||||
|
||||
status_code = int(response.status_code)
|
||||
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
|
||||
return response
|
||||
finally:
|
||||
reset_request_audit_context(audit_context_token)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -218,6 +257,36 @@ def create_app() -> FastAPI:
|
||||
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"],
|
||||
@@ -234,22 +303,14 @@ def create_app() -> FastAPI:
|
||||
return {"totals": totals, "by_endpoint": summary}
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(onlyoffice_internal_router)
|
||||
app.include_router(collaboration_internal_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _resolve_client_ip(request) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _resolve_auth_context(request) -> tuple[str, str | None]:
|
||||
authorization = request.headers.get("authorization") or ""
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
@@ -270,19 +331,23 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
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),
|
||||
"client_ip": resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"client_type": request.headers.get("x-ctms-client-type"),
|
||||
"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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user