Files
ctms/backend/app/api/v1/permission_monitoring.py
T
Cheng Zhou f11a5c84d9
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(监控): 完善系统监控、登录状态与访问审计能力
2026-07-10 17:12:13 +08:00

1650 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""权限系统监控API
提供权限系统的监控数据、访问日志、趋势分析和告警信息。
"""
from __future__ import annotations
import uuid
import json
import time
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import String, case, cast, desc, func, literal, or_, select, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.deps import get_current_user, get_db_session, is_system_admin
from app.core.permission_monitor import (
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES,
CACHE_METRICS_WINDOW_SECONDS,
get_permission_monitor,
)
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.user import User
from app.services.geo_location_metadata import GeoLocationMetadata, resolve_geo_location_metadata
from app.services.ip_location import IpLocation, resolve_ip_location
from app.services.monitoring_retention import get_monitoring_retention_status
from app.services.permission_log_writer import get_log_writer
from app.services.security_access_log_writer import get_security_log_writer
from app.services.security_events import classify_security_event
from app.services.monitoring_server_location import resolve_monitoring_server_location
from app.services.source_location_aggregator import get_source_location_timeline
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
class MonitoringScope:
def __init__(self, *, is_admin: bool, study_ids: set[uuid.UUID]) -> None:
self.is_admin = is_admin
self.study_ids = study_ids
def can_access_study(self, study_id: uuid.UUID | None) -> bool:
if self.is_admin:
return True
return study_id is not None and study_id in self.study_ids
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
if is_system_admin(current_user):
return MonitoringScope(is_admin=True, study_ids=set())
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
def _apply_monitoring_scope_to_log_query(query, scope: MonitoringScope):
if scope.is_admin:
return query
return query.where(PermissionAccessLog.study_id.in_(scope.study_ids))
KNOWN_PERMISSION_CLIENT_TYPES = ("web", "desktop")
def _permission_client_type_condition(client_type: str):
normalized = client_type.strip().lower()
if normalized == "unknown":
return or_(
PermissionAccessLog.client_type.is_(None),
PermissionAccessLog.client_type == "",
~PermissionAccessLog.client_type.in_(KNOWN_PERMISSION_CLIENT_TYPES),
)
return PermissionAccessLog.client_type == normalized
def _security_client_type_condition(client_type: str):
normalized = client_type.strip().lower()
if normalized == "unknown":
return or_(
SecurityAccessLog.client_type.is_(None),
SecurityAccessLog.client_type == "",
~SecurityAccessLog.client_type.in_(KNOWN_PERMISSION_CLIENT_TYPES),
)
return SecurityAccessLog.client_type == normalized
def _coerce_request_headers(value) -> dict[str, str] | None:
if not value:
return None
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
if not isinstance(value, dict):
return None
headers = {str(key): str(header_value) for key, header_value in value.items() if header_value is not None}
return headers or None
def _coerce_request_snapshot(value) -> dict | None:
if not value:
return None
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
async def _resolve_permission_location_keyword_ips(
db: AsyncSession,
conditions: list,
keyword: str,
) -> list[str]:
token = keyword.strip().lower()
if not token:
return []
ip_query = (
select(PermissionAccessLog.ip_address)
.where(PermissionAccessLog.ip_address.is_not(None))
.distinct()
)
for condition in conditions:
ip_query = ip_query.where(condition)
result = await db.execute(ip_query)
matched_ips: list[str] = []
for ip_address in result.scalars().all():
ip_location = resolve_ip_location(ip_address)
haystack = " ".join(
[
ip_location.location or "",
ip_location.country or "",
ip_location.province or "",
ip_location.city or "",
ip_location.isp or "",
]
).lower()
if token in haystack:
matched_ips.append(ip_address)
return matched_ips
async def _resolve_security_location_keyword_ips(
db: AsyncSession,
conditions: list,
keyword: str,
) -> list[str]:
token = keyword.strip().lower()
if not token:
return []
ip_query = (
select(SecurityAccessLog.client_ip)
.where(SecurityAccessLog.client_ip.is_not(None))
.distinct()
)
for condition in conditions:
ip_query = ip_query.where(condition)
result = await db.execute(ip_query)
matched_ips: list[str] = []
for ip_address in result.scalars().all():
ip_location = resolve_ip_location(ip_address)
haystack = " ".join(
[
ip_location.location or "",
ip_location.country or "",
ip_location.province or "",
ip_location.city or "",
ip_location.isp or "",
]
).lower()
if token in haystack:
matched_ips.append(ip_address)
return matched_ips
def _permission_access_item(log: PermissionAccessLog, full_name: str | None, email: str | None) -> dict:
ip_location = resolve_ip_location(log.ip_address)
return {
"id": str(log.id),
"event_type": "permission",
"study_id": str(log.study_id) if log.study_id else "",
"user_id": str(log.user_id) if log.user_id else "",
"user_name": full_name or "未知用户",
"user_email": email,
"endpoint_key": log.endpoint_key,
"role": log.role,
"allowed": log.allowed,
"elapsed_ms": round(log.elapsed_ms, 2),
"ip_address": log.ip_address,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"user_agent": log.user_agent,
"client_type": log.client_type,
"client_version": log.client_version,
"client_platform": log.client_platform,
"build_channel": log.build_channel,
"build_commit": log.build_commit,
"request_headers": _coerce_request_headers(log.request_headers),
"request_snapshot": _coerce_request_snapshot(getattr(log, "request_snapshot", None)),
"request_id": getattr(log, "request_id", None),
"method": None,
"path": None,
"status_code": None,
"auth_status": "AUTHENTICATED",
"account_label": full_name or email,
"category": None,
"severity": None,
"created_at": log.created_at.isoformat(),
}
def _security_access_item(log: SecurityAccessLog, user_names: dict[str, str] | None = None) -> dict:
ip_location = resolve_ip_location(log.client_ip)
classification = _classify_security_access_log(log, ip_location)
account_label = _security_account_label(log.auth_status, log.user_identifier, user_names)
return {
"id": f"security:{log.id}",
"event_type": "security",
"study_id": "",
"user_id": log.user_identifier or "",
"user_name": account_label,
"user_email": None,
"endpoint_key": f"{log.method} {log.path}",
"role": classification["category"],
"allowed": log.status_code < 400,
"elapsed_ms": round(log.elapsed_ms, 2),
"ip_address": log.client_ip,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"user_agent": log.user_agent,
"client_type": log.client_type,
"client_version": log.client_version,
"client_platform": log.client_platform,
"build_channel": log.build_channel,
"build_commit": log.build_commit,
"request_headers": _coerce_request_headers(getattr(log, "request_headers", None)),
"request_snapshot": _coerce_request_snapshot(getattr(log, "request_snapshot", None)),
"request_id": getattr(log, "request_id", None),
"method": log.method,
"path": log.path,
"status_code": log.status_code,
"auth_status": log.auth_status,
"account_label": account_label,
"category": classification["category"],
"severity": classification["severity"],
"created_at": log.created_at.isoformat(),
}
async def _security_user_names(db: AsyncSession, logs: list[SecurityAccessLog]) -> dict[str, str]:
user_ids: list[uuid.UUID] = []
for log in logs:
if log.auth_status != "AUTHENTICATED" or not log.user_identifier:
continue
try:
user_ids.append(uuid.UUID(log.user_identifier))
except ValueError:
continue
if not user_ids:
return {}
users_result = await db.execute(select(User.id, User.full_name).where(User.id.in_(user_ids)))
return {str(row.id): row.full_name for row in users_result.all()}
# ═══════════════════════════════════════════
# 原有端点(保持兼容)
# ═══════════════════════════════════════════
@router.get("/metrics", status_code=status.HTTP_200_OK)
async def get_permission_metrics(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
hours: int = Query(24, ge=1, le=720),
) -> dict:
"""从 permission_access_logs 实时聚合权限检查指标"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
metrics_query = select(
func.count().label("total_checks"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed_checks"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_checks"),
func.coalesce(func.sum(PermissionAccessLog.elapsed_ms), 0).label("total_time_ms"),
func.coalesce(func.min(PermissionAccessLog.elapsed_ms), 0).label("min_time_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_time_ms"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_time_ms"),
).where(PermissionAccessLog.created_at >= start_time)
result = await db.execute(
_apply_monitoring_scope_to_log_query(metrics_query, scope)
)
row = result.one()
total = row.total_checks or 0
monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics.window_stats()
return {
"window_hours": hours,
"check_metrics": {
"total_checks": total,
"allowed_checks": row.allowed_checks or 0,
"denied_checks": row.denied_checks or 0,
"total_time": round(float(row.total_time_ms) / 1000, 3),
"min_time": round(float(row.min_time_ms) / 1000, 3),
"max_time": round(float(row.max_time_ms) / 1000, 3),
"avg_time": round(float(row.avg_time_ms) / 1000, 3),
"allow_rate": round(row.allowed_checks / total * 100, 2) if total else 0,
"deny_rate": round(row.denied_checks / total * 100, 2) if total else 0,
"error_rate": 0,
"errors": 0,
},
"cache_metrics": cache_metrics,
"uptime_seconds": monitor.metrics.uptime_seconds,
}
@router.get("/cache-stats", status_code=status.HTTP_200_OK)
async def get_cache_statistics(
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
return monitor.get_cache_stats()
@router.get("/alerts", status_code=status.HTTP_200_OK)
async def get_alerts(
level: str | None = None,
limit: int = 100,
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
alerts = monitor.get_alerts(level=level, limit=limit)
return {
"total": len(alerts),
"alerts": alerts,
}
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
async def reset_metrics(
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
monitor.reset_metrics()
return {"message": "指标已重置"}
@router.post("/clear-alerts", status_code=status.HTTP_200_OK)
async def clear_alerts(
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
monitor.clear_alerts()
return {"message": "告警已清除"}
@router.get("/health", status_code=status.HTTP_200_OK)
async def permission_system_health(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
) -> dict:
"""从 DB 聚合最近 1 小时数据评估权限系统健康状态"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(hours=1)
database_started_at = time.perf_counter()
await db.execute(select(literal(1)))
database_latency_ms = round((time.perf_counter() - database_started_at) * 1000, 2)
health_query = select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
).where(PermissionAccessLog.created_at >= start_time)
result = await db.execute(
_apply_monitoring_scope_to_log_query(health_query, scope)
)
row = result.one()
total = row.total or 0
avg_ms = float(row.avg_ms)
deny_rate = round(row.denied / total * 100, 2) if total else 0
monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics.window_stats()
now_timestamp = datetime.now(timezone.utc).timestamp()
recent_alerts = [
alert
for alert in monitor.get_alerts(limit=1000)
if alert["timestamp"] >= now_timestamp - 3600
]
permission_writer = get_log_writer()
security_writer = get_security_log_writer()
permission_writer_stats = permission_writer.stats() if permission_writer else None
security_writer_stats = security_writer.stats() if security_writer else None
retention_stats = get_monitoring_retention_status()
def writer_component(stats: dict | None) -> dict:
if stats is None:
return {"status": "unknown", "reason": "writer_not_initialized"}
queue_capacity = max(1, int(stats.get("queue_capacity") or 1))
queue_usage = round(int(stats.get("queue_size") or 0) / queue_capacity * 100, 2)
if not stats.get("running"):
component_status = "unhealthy"
elif queue_usage >= 80 or stats.get("failed_batches") or stats.get("dropped_entries"):
component_status = "degraded"
else:
component_status = "healthy"
return {**stats, "status": component_status, "queue_usage_percent": queue_usage}
permission_writer_component = writer_component(permission_writer_stats)
security_writer_component = writer_component(security_writer_stats)
if retention_stats["running"]:
retention_has_unrecovered_error = bool(
retention_stats["last_error_at"]
and (
not retention_stats["last_success_at"]
or retention_stats["last_error_at"] > retention_stats["last_success_at"]
)
)
retention_status = "degraded" if retention_has_unrecovered_error else "healthy"
elif retention_stats["last_run_started_at"]:
retention_status = "unhealthy"
else:
retention_status = "unknown"
retention_component = {**retention_stats, "status": retention_status}
storage_result = await db.execute(
select(
select(func.count()).select_from(PermissionAccessLog).scalar_subquery().label("permission_count"),
select(func.min(PermissionAccessLog.created_at)).scalar_subquery().label("permission_oldest"),
select(func.count()).select_from(SecurityAccessLog).scalar_subquery().label("security_count"),
select(func.min(SecurityAccessLog.created_at)).scalar_subquery().label("security_oldest"),
select(func.count()).select_from(PermissionMetricSnapshot).scalar_subquery().label("metric_count"),
)
)
storage_row = storage_result.one()
deductions: dict[str, int] = {
"performance": 0,
"deny_rate": 0,
"cache": 0,
"alerts": 0,
"runtime": 0,
}
issues: list[str] = []
if avg_ms > 200:
deductions["performance"] = 25
elif avg_ms > 50:
deductions["performance"] = 20
elif avg_ms > 10:
deductions["performance"] = 10
if deductions["performance"]:
issues.append(f"权限检查平均响应时间过长({avg_ms:.2f}ms")
if deny_rate > 50:
deductions["deny_rate"] = 20
elif deny_rate > 20:
deductions["deny_rate"] = 10
if deductions["deny_rate"]:
issues.append(f"权限拒绝率偏高({deny_rate:.2f}%")
cache_sample_sufficient = (
cache_metrics["total_accesses"] >= CACHE_HIT_RATE_HEALTH_MIN_ACCESSES
)
if cache_sample_sufficient:
if cache_metrics["hit_rate"] < 20:
deductions["cache"] = 25
elif cache_metrics["hit_rate"] < 50:
deductions["cache"] = 20
elif cache_metrics["hit_rate"] < 80:
deductions["cache"] = 10
if deductions["cache"]:
issues.append(f"近 1 小时缓存命中率偏低({cache_metrics['hit_rate']:.2f}%")
error_alert_count = sum(alert["level"] == "error" for alert in recent_alerts)
warning_alert_count = sum(alert["level"] == "warning" for alert in recent_alerts)
if error_alert_count:
deductions["alerts"] = 10
issues.append(f"近 1 小时发生 {error_alert_count} 条错误告警")
elif warning_alert_count:
deductions["alerts"] = 5
issues.append(f"近 1 小时发生 {warning_alert_count} 条警告")
runtime_components = {
"database": {"status": "healthy", "latency_ms": database_latency_ms},
"permission_log_writer": permission_writer_component,
"security_log_writer": security_writer_component,
"retention": retention_component,
}
component_labels = {
"database": "数据库",
"permission_log_writer": "权限日志写入器",
"security_log_writer": "安全日志写入器",
"retention": "留存清理任务",
}
unhealthy_components = [
component_labels[name]
for name, component in runtime_components.items()
if component["status"] == "unhealthy"
]
degraded_components = [
component_labels[name]
for name, component in runtime_components.items()
if component["status"] == "degraded"
]
if unhealthy_components:
deductions["runtime"] = 20
issues.append(f"运行组件异常:{', '.join(unhealthy_components)}")
elif degraded_components:
deductions["runtime"] = 10
issues.append(f"运行组件降级:{', '.join(degraded_components)}")
elif database_latency_ms > 500:
deductions["runtime"] = 5
issues.append(f"数据库健康检查延迟偏高({database_latency_ms:.2f}ms")
health_score = max(0, 100 - sum(deductions.values()))
sample_sufficient = total >= 10
return {
"status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
"health_score": health_score,
"issues": issues,
"sample_sufficient": sample_sufficient,
"score_breakdown": {
"performance": {"weight": 25, "deduction": deductions["performance"]},
"deny_rate": {"weight": 20, "deduction": deductions["deny_rate"]},
"cache": {
"weight": 25,
"deduction": deductions["cache"],
"sample_sufficient": cache_sample_sufficient,
"scope": "sliding_window",
"window_seconds": CACHE_METRICS_WINDOW_SECONDS,
},
"alerts": {"weight": 10, "deduction": deductions["alerts"]},
"runtime": {"weight": 20, "deduction": deductions["runtime"]},
},
"last_hour": {
"total_checks": total,
"denied_checks": row.denied or 0,
"deny_rate": deny_rate,
"avg_elapsed_ms": round(avg_ms, 2),
"error_alerts": error_alert_count,
"warning_alerts": warning_alert_count,
},
"cache_stats": monitor.get_cache_stats(),
"components": runtime_components,
"storage": {
"permission_access_logs": storage_row.permission_count or 0,
"security_access_logs": storage_row.security_count or 0,
"permission_metric_snapshots": storage_row.metric_count or 0,
"oldest_permission_access_log_at": (
storage_row.permission_oldest.isoformat() if storage_row.permission_oldest else None
),
"oldest_security_access_log_at": (
storage_row.security_oldest.isoformat() if storage_row.security_oldest else None
),
},
}
# ═══════════════════════════════════════════
# 新增端点:访问日志
# ═══════════════════════════════════════════
@router.get("/access-logs", status_code=status.HTTP_200_OK)
async def get_access_logs(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
study_id: Optional[uuid.UUID] = Query(None),
user_id: Optional[uuid.UUID] = Query(None),
endpoint_key: Optional[str] = Query(None),
role: Optional[str] = Query(None),
allowed: Optional[bool] = Query(None),
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
client_ip: Optional[str] = Query(None),
client_type: Optional[str] = Query(None),
keyword: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> dict:
"""分页查询权限访问日志"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if study_id and not scope.can_access_study(study_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
client_ip = client_ip if isinstance(client_ip, str) else None
client_type = client_type if isinstance(client_type, str) else None
keyword = keyword if isinstance(keyword, str) else None
start_time = start_time if isinstance(start_time, datetime) else None
end_time = end_time if isinstance(end_time, datetime) else None
conditions = []
if study_id:
conditions.append(PermissionAccessLog.study_id == study_id)
elif not scope.is_admin:
conditions.append(PermissionAccessLog.study_id.in_(scope.study_ids))
if user_id:
conditions.append(PermissionAccessLog.user_id == user_id)
if endpoint_key:
conditions.append(PermissionAccessLog.endpoint_key == endpoint_key)
if role:
conditions.append(PermissionAccessLog.role == role)
if allowed is not None:
conditions.append(PermissionAccessLog.allowed == allowed)
if start_time:
conditions.append(PermissionAccessLog.created_at >= start_time)
if end_time:
conditions.append(PermissionAccessLog.created_at <= end_time)
if client_ip and client_ip.strip():
conditions.append(PermissionAccessLog.ip_address.ilike(f"%{client_ip.strip()}%"))
if client_type and client_type.strip():
conditions.append(_permission_client_type_condition(client_type))
if keyword and keyword.strip():
token = keyword.strip()
like_token = f"%{token}%"
location_ips = await _resolve_permission_location_keyword_ips(db, conditions, token)
keyword_conditions = [
PermissionAccessLog.endpoint_key.ilike(like_token),
PermissionAccessLog.role.ilike(like_token),
PermissionAccessLog.ip_address.ilike(like_token),
PermissionAccessLog.client_type.ilike(like_token),
PermissionAccessLog.client_version.ilike(like_token),
PermissionAccessLog.client_platform.ilike(like_token),
PermissionAccessLog.user_agent.ilike(like_token),
cast(PermissionAccessLog.request_headers, String).ilike(like_token),
cast(PermissionAccessLog.request_snapshot, String).ilike(like_token),
User.full_name.ilike(like_token),
User.email.ilike(like_token),
cast(PermissionAccessLog.user_id, String).ilike(like_token),
]
if location_ips:
keyword_conditions.append(PermissionAccessLog.ip_address.in_(location_ips))
conditions.append(or_(*keyword_conditions))
include_security_logs = scope.is_admin and study_id is None and role is None and endpoint_key is None and allowed is not True
event_conditions = list(conditions)
if include_security_logs:
matching_security_event = select(SecurityAccessLog.id).where(
PermissionAccessLog.request_id.is_not(None),
SecurityAccessLog.request_id == PermissionAccessLog.request_id,
SecurityAccessLog.status_code >= 400,
).exists()
event_conditions.append(~matching_security_event)
permission_event_query = (
select(
literal("permission").label("event_type"),
cast(PermissionAccessLog.id, String).label("event_id"),
PermissionAccessLog.created_at.label("created_at"),
cast(PermissionAccessLog.user_id, String).label("user_id"),
PermissionAccessLog.ip_address.label("ip_address"),
PermissionAccessLog.allowed.label("allowed"),
PermissionAccessLog.elapsed_ms.label("elapsed_ms"),
)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
)
user_stats_query = (
select(
PermissionAccessLog.ip_address.label("sample_ip_address"),
func.count().label("total_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
.group_by(PermissionAccessLog.ip_address)
.order_by(desc("total_count"), desc("last_seen_at"))
.limit(10)
)
for condition in event_conditions:
permission_event_query = permission_event_query.where(condition)
for condition in conditions:
user_stats_query = user_stats_query.where(condition)
user_stats_result = await db.execute(user_stats_query)
user_stats_rows = user_stats_result.all()
latest_user_by_ip = {}
if user_stats_rows:
ranked_ips = [stat.sample_ip_address for stat in user_stats_rows if stat.sample_ip_address]
latest_user_query = (
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
User.full_name,
User.email,
PermissionAccessLog.role,
PermissionAccessLog.created_at,
)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
.where(PermissionAccessLog.ip_address.in_(ranked_ips))
.order_by(PermissionAccessLog.ip_address, desc(PermissionAccessLog.created_at))
)
for condition in conditions:
latest_user_query = latest_user_query.where(condition)
latest_user_result = await db.execute(latest_user_query)
for row in latest_user_result.all():
latest_user_by_ip.setdefault(row.ip_address, row)
event_queries = [permission_event_query]
if include_security_logs:
security_conditions = [SecurityAccessLog.status_code >= 400]
if user_id:
security_conditions.append(SecurityAccessLog.user_identifier == str(user_id))
if start_time:
security_conditions.append(SecurityAccessLog.created_at >= start_time)
if end_time:
security_conditions.append(SecurityAccessLog.created_at <= end_time)
if client_ip and client_ip.strip():
security_conditions.append(SecurityAccessLog.client_ip.ilike(f"%{client_ip.strip()}%"))
if client_type and client_type.strip():
security_conditions.append(_security_client_type_condition(client_type))
if keyword and keyword.strip():
token = keyword.strip()
like_token = f"%{token}%"
location_ips = await _resolve_security_location_keyword_ips(db, security_conditions, token)
matching_users_result = await db.execute(
select(cast(User.id, String)).where(
or_(User.full_name.ilike(like_token), User.email.ilike(like_token))
)
)
matching_user_ids = list(matching_users_result.scalars().all())
keyword_conditions = [
SecurityAccessLog.method.ilike(like_token),
SecurityAccessLog.path.ilike(like_token),
SecurityAccessLog.client_ip.ilike(like_token),
SecurityAccessLog.client_type.ilike(like_token),
SecurityAccessLog.client_version.ilike(like_token),
SecurityAccessLog.client_platform.ilike(like_token),
SecurityAccessLog.user_agent.ilike(like_token),
SecurityAccessLog.auth_status.ilike(like_token),
SecurityAccessLog.user_identifier.ilike(like_token),
cast(SecurityAccessLog.status_code, String).ilike(like_token),
cast(SecurityAccessLog.request_headers, String).ilike(like_token),
cast(SecurityAccessLog.request_snapshot, String).ilike(like_token),
]
if location_ips:
keyword_conditions.append(SecurityAccessLog.client_ip.in_(location_ips))
if matching_user_ids:
keyword_conditions.append(SecurityAccessLog.user_identifier.in_(matching_user_ids))
security_conditions.append(or_(*keyword_conditions))
security_event_query = select(
literal("security").label("event_type"),
cast(SecurityAccessLog.id, String).label("event_id"),
SecurityAccessLog.created_at.label("created_at"),
SecurityAccessLog.user_identifier.label("user_id"),
SecurityAccessLog.client_ip.label("ip_address"),
(SecurityAccessLog.status_code < 400).label("allowed"),
SecurityAccessLog.elapsed_ms.label("elapsed_ms"),
)
for condition in security_conditions:
security_event_query = security_event_query.where(condition)
event_queries.append(security_event_query)
events = (
union_all(*event_queries).subquery("monitoring_events")
if len(event_queries) > 1
else event_queries[0].subquery("monitoring_events")
)
summary_result = await db.execute(
select(
func.count().label("total_count"),
func.count(func.distinct(events.c.user_id)).label("unique_user_count"),
func.count(func.distinct(events.c.ip_address)).label("unique_ip_count"),
func.count().filter(events.c.allowed.is_(False)).label("denied_count"),
func.coalesce(func.avg(events.c.elapsed_ms), 0).label("avg_elapsed_ms"),
).select_from(events)
)
summary_row = summary_result.one()
total = summary_row.total_count or 0
page_result = await db.execute(
select(events.c.event_type, events.c.event_id, events.c.created_at)
.order_by(desc(events.c.created_at), desc(events.c.event_id))
.offset((page - 1) * page_size)
.limit(page_size)
)
page_events = page_result.all()
permission_ids = [uuid.UUID(row.event_id) for row in page_events if row.event_type == "permission"]
security_ids = [uuid.UUID(row.event_id) for row in page_events if row.event_type == "security"]
items_by_key: dict[tuple[str, str], dict] = {}
if permission_ids:
permission_result = await db.execute(
select(PermissionAccessLog, User.full_name, User.email)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
.where(PermissionAccessLog.id.in_(permission_ids))
)
for log, full_name, email in permission_result.all():
items_by_key[("permission", str(log.id))] = _permission_access_item(log, full_name, email)
if security_ids:
security_result = await db.execute(
select(SecurityAccessLog).where(SecurityAccessLog.id.in_(security_ids))
)
security_logs = list(security_result.scalars().all())
security_user_names = await _security_user_names(db, security_logs)
for log in security_logs:
items_by_key[("security", str(log.id))] = _security_access_item(log, security_user_names)
page_items = [
items_by_key[(row.event_type, row.event_id)]
for row in page_events
if (row.event_type, row.event_id) in items_by_key
]
summary = {
"total_count": total,
"unique_user_count": summary_row.unique_user_count or 0,
"unique_ip_count": summary_row.unique_ip_count or 0,
"denied_count": summary_row.denied_count or 0,
"avg_elapsed_ms": round(float(summary_row.avg_elapsed_ms), 2),
}
user_stats = []
for stat in user_stats_rows:
sample_location = resolve_ip_location(stat.sample_ip_address)
latest_user = latest_user_by_ip.get(stat.sample_ip_address)
user_stats.append(
{
"user_id": str(latest_user.user_id) if latest_user else "",
"user_name": latest_user.full_name if latest_user and latest_user.full_name else "未知用户",
"user_email": latest_user.email if latest_user else None,
"role": latest_user.role if latest_user else "",
"total_count": stat.total_count,
"denied_count": stat.denied_count,
"unique_ip_count": stat.unique_ip_count,
"sample_ip_address": stat.sample_ip_address,
"last_seen_at": stat.last_seen_at.isoformat() if stat.last_seen_at else None,
"primary_location": sample_location.location,
}
)
return {
"total": total,
"page": page,
"page_size": page_size,
"summary": summary,
"user_stats": user_stats,
"items": page_items,
}
def _security_account_label(auth_status: str, user_identifier: str | None, user_names: dict[str, str] | None = None) -> str:
if auth_status == "AUTHENTICATED" and user_identifier:
return (user_names or {}).get(user_identifier) or user_identifier
if auth_status == "INVALID_TOKEN":
return "无效令牌"
return "未知账号"
def _classify_security_access_log(log: SecurityAccessLog, ip_location: IpLocation | None = None) -> dict[str, str]:
if getattr(log, "category", None) and getattr(log, "severity", None):
return {"category": log.category, "severity": log.severity}
return classify_security_event(
path=log.path,
status_code=log.status_code,
auth_status=log.auth_status,
ip_location=ip_location,
)
def _security_endpoint_type(path: str) -> str:
normalized_path = (path or "").lower()
if "/auth" in normalized_path or "/login" in normalized_path:
return "认证接口"
if normalized_path.startswith("/api/"):
return "业务 API"
if normalized_path.endswith((".js", ".css", ".png", ".jpg", ".jpeg", ".svg", ".ico", ".map")):
return "静态资源"
if any(marker in normalized_path for marker in (".env", "wp-", "php", "admin", "shell", "config")):
return "探测路径"
return "其他请求"
@router.get("/security-logs", status_code=status.HTTP_200_OK)
async def get_security_access_logs(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
status_min: Optional[int] = Query(None, ge=100, le=599),
auth_status: Optional[str] = Query(None),
client_type: Optional[str] = Query(None),
client_version: Optional[str] = Query(None),
client_ip: Optional[str] = Query(None),
category: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
keyword: Optional[str] = Query(None),
events_only: bool = Query(False),
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> dict:
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
client_type = client_type if isinstance(client_type, str) else None
client_version = client_version if isinstance(client_version, str) else None
client_ip = client_ip if isinstance(client_ip, str) else None
category = category if isinstance(category, str) else None
severity = severity if isinstance(severity, str) else None
keyword = keyword if isinstance(keyword, str) else None
events_only = events_only if isinstance(events_only, bool) else False
start_time = start_time if isinstance(start_time, datetime) else None
end_time = end_time if isinstance(end_time, datetime) else None
conditions = []
if status_min is not None:
conditions.append(SecurityAccessLog.status_code >= status_min)
if auth_status:
conditions.append(SecurityAccessLog.auth_status == auth_status)
if client_type:
conditions.append(SecurityAccessLog.client_type == client_type)
if client_version:
conditions.append(SecurityAccessLog.client_version == client_version)
if client_ip:
conditions.append(SecurityAccessLog.client_ip.ilike(f"%{client_ip.strip()}%"))
if category:
conditions.append(SecurityAccessLog.category == category.strip().upper())
if severity:
conditions.append(SecurityAccessLog.severity == severity.strip().upper())
if events_only:
conditions.append(SecurityAccessLog.category != "OTHER")
if start_time:
conditions.append(SecurityAccessLog.created_at >= start_time)
if end_time:
conditions.append(SecurityAccessLog.created_at <= end_time)
if keyword and keyword.strip():
token = keyword.strip()
like_token = f"%{token}%"
location_ips = await _resolve_security_location_keyword_ips(db, conditions, token)
matching_users_result = await db.execute(
select(cast(User.id, String)).where(
or_(User.full_name.ilike(like_token), User.email.ilike(like_token))
)
)
matching_user_ids = list(matching_users_result.scalars().all())
keyword_conditions = [
SecurityAccessLog.method.ilike(like_token),
SecurityAccessLog.path.ilike(like_token),
SecurityAccessLog.client_ip.ilike(like_token),
SecurityAccessLog.client_type.ilike(like_token),
SecurityAccessLog.client_version.ilike(like_token),
SecurityAccessLog.client_platform.ilike(like_token),
SecurityAccessLog.user_agent.ilike(like_token),
SecurityAccessLog.auth_status.ilike(like_token),
SecurityAccessLog.user_identifier.ilike(like_token),
SecurityAccessLog.category.ilike(like_token),
SecurityAccessLog.severity.ilike(like_token),
cast(SecurityAccessLog.status_code, String).ilike(like_token),
]
if location_ips:
keyword_conditions.append(SecurityAccessLog.client_ip.in_(location_ips))
if matching_user_ids:
keyword_conditions.append(SecurityAccessLog.user_identifier.in_(matching_user_ids))
conditions.append(or_(*keyword_conditions))
query = select(SecurityAccessLog)
count_query = select(func.count()).select_from(SecurityAccessLog)
summary_query = select(
func.count().label("total_count"),
func.count().filter(SecurityAccessLog.auth_status == "ANONYMOUS").label("anonymous_count"),
func.count().filter(SecurityAccessLog.auth_status == "INVALID_TOKEN").label("invalid_token_count"),
func.count().filter(SecurityAccessLog.status_code >= 400).label("error_count"),
func.count(func.distinct(SecurityAccessLog.client_ip)).label("unique_ip_count"),
func.count().filter(SecurityAccessLog.severity.in_(("CRITICAL", "HIGH"))).label("high_risk_count"),
func.count().filter(SecurityAccessLog.category == "SERVER_ERROR").label("server_error_count"),
).select_from(SecurityAccessLog)
for condition in conditions:
query = query.where(condition)
count_query = count_query.where(condition)
summary_query = summary_query.where(condition)
total_result = await db.execute(count_query)
summary_result = await db.execute(summary_query)
result = await db.execute(
query.order_by(desc(SecurityAccessLog.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
summary_row = summary_result.one()
category_query = select(SecurityAccessLog.category, func.count().label("count")).group_by(SecurityAccessLog.category)
severity_query = select(SecurityAccessLog.severity, func.count().label("count")).group_by(SecurityAccessLog.severity)
top_ip_query = (
select(
SecurityAccessLog.client_ip,
func.count().label("total_count"),
func.count().filter(SecurityAccessLog.severity.in_(("CRITICAL", "HIGH"))).label("high_risk_count"),
func.max(SecurityAccessLog.created_at).label("last_seen_at"),
func.max(SecurityAccessLog.category).label("category"),
)
.where(SecurityAccessLog.client_ip.is_not(None))
.group_by(SecurityAccessLog.client_ip)
.order_by(desc("high_risk_count"), desc("total_count"), desc("last_seen_at"))
.limit(10)
)
endpoint_query = (
select(SecurityAccessLog.path, func.count().label("count"))
.group_by(SecurityAccessLog.path)
.order_by(desc("count"))
.limit(100)
)
for condition in conditions:
category_query = category_query.where(condition)
severity_query = severity_query.where(condition)
top_ip_query = top_ip_query.where(condition)
endpoint_query = endpoint_query.where(condition)
category_result = await db.execute(category_query)
severity_result = await db.execute(severity_query)
top_ip_result = await db.execute(top_ip_query)
endpoint_result = await db.execute(endpoint_query)
endpoint_type_counts: dict[str, dict[str, str | int]] = {}
for row in endpoint_result.all():
endpoint_type = _security_endpoint_type(row.path)
current = endpoint_type_counts.setdefault(endpoint_type, {"count": 0, "sample_path": row.path})
current["count"] = int(current["count"]) + row.count
logs = result.scalars().all()
user_names = await _security_user_names(db, list(logs))
items = []
for log in logs:
ip_location = resolve_ip_location(log.client_ip)
items.append(
{
"id": str(log.id),
"method": log.method,
"path": log.path,
"status_code": log.status_code,
"elapsed_ms": round(log.elapsed_ms, 2),
"client_ip": log.client_ip,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"user_agent": log.user_agent,
"client_type": log.client_type,
"client_version": log.client_version,
"client_platform": log.client_platform,
"build_channel": log.build_channel,
"build_commit": log.build_commit,
"request_headers": _coerce_request_headers(getattr(log, "request_headers", None)),
"request_snapshot": _coerce_request_snapshot(getattr(log, "request_snapshot", None)),
"request_id": getattr(log, "request_id", None),
"auth_status": log.auth_status,
"user_identifier": log.user_identifier,
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
**_classify_security_access_log(log, ip_location),
"created_at": log.created_at.isoformat(),
}
)
return {
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"summary": {
"total_count": summary_row.total_count,
"anonymous_count": summary_row.anonymous_count,
"invalid_token_count": summary_row.invalid_token_count,
"error_count": summary_row.error_count,
"unique_ip_count": summary_row.unique_ip_count,
"high_risk_count": summary_row.high_risk_count,
"server_error_count": summary_row.server_error_count,
"category_counts": {str(row.category or "OTHER"): row.count for row in category_result.all()},
"severity_counts": {str(row.severity or "LOW"): row.count for row in severity_result.all()},
"endpoint_type_counts": endpoint_type_counts,
"top_ips": [
{
"ip": row.client_ip,
"location": resolve_ip_location(row.client_ip).location,
"category": row.category or "OTHER",
"total_count": row.total_count,
"high_risk_count": row.high_risk_count,
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
}
for row in top_ip_result.all()
],
},
"items": items,
}
# ═══════════════════════════════════════════
# 新增端点:趋势数据
# ═══════════════════════════════════════════
@router.get("/trends", status_code=status.HTTP_200_OK)
async def get_trends(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
) -> dict:
"""获取趋势数据(从快照表或实时聚合)"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
now = datetime.now(timezone.utc)
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
start_time = now - period_map[period]
# 先尝试从快照表获取
snapshot_query = (
select(PermissionMetricSnapshot)
.where(PermissionMetricSnapshot.bucket_time >= start_time)
.order_by(PermissionMetricSnapshot.bucket_time)
)
result = await db.execute(snapshot_query)
snapshots = result.scalars().all()
if snapshots and scope.is_admin:
return {
"period": period,
"data_points": [
{
"bucket_time": s.bucket_time.isoformat(),
"total_checks": s.total_checks,
"allowed_checks": s.allowed_checks,
"denied_checks": s.denied_checks,
"avg_elapsed_ms": round(s.avg_elapsed_ms, 2),
"max_elapsed_ms": round(s.max_elapsed_ms, 2),
"cache_hits": s.cache_hits,
"cache_misses": s.cache_misses,
"cache_hit_rate": round(
s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1
) if (s.cache_hits + s.cache_misses) > 0 else 0,
"error_count": s.error_count,
}
for s in snapshots
],
}
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
trend_query = select(
PermissionAccessLog.created_at,
PermissionAccessLog.allowed,
PermissionAccessLog.elapsed_ms,
).where(PermissionAccessLog.created_at >= start_time)
result = await db.execute(
_apply_monitoring_scope_to_log_query(trend_query, scope)
)
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
)
for created_at, allowed, elapsed_ms in result.all():
bucket = _trend_bucket_time(created_at, period)
buckets[bucket]["total"] += 1
buckets[bucket]["allowed" if allowed else "denied"] += 1
buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0)
buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0))
return {
"period": period,
"data_points": [
{
"bucket_time": bucket.isoformat(),
"total_checks": values["total"],
"allowed_checks": values["allowed"],
"denied_checks": values["denied"],
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2),
"max_elapsed_ms": round(float(values["max_ms"]), 2),
"cache_hits": 0,
"cache_misses": 0,
"cache_hit_rate": 0,
"error_count": 0,
}
for bucket, values in sorted(buckets.items())
],
}
def _trend_bucket_time(value: datetime, period: str) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
if period == "24h":
return value.replace(minute=0, second=0, microsecond=0)
if period == "7d":
return value.replace(hour=(value.hour // 6) * 6, minute=0, second=0, microsecond=0)
return value.replace(hour=0, minute=0, second=0, microsecond=0)
# ═══════════════════════════════════════════
# 新增端点:被拒绝最多的权限
# ═══════════════════════════════════════════
@router.get("/top-denied", status_code=status.HTTP_200_OK)
async def get_top_denied(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
days: int = Query(7, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
) -> dict:
"""获取被拒绝最多的权限"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(days=days)
query = (
select(
PermissionAccessLog.endpoint_key,
PermissionAccessLog.role,
func.count().label("denied_count"),
)
.where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.allowed.is_(False),
)
.group_by(PermissionAccessLog.endpoint_key, PermissionAccessLog.role)
.order_by(desc("denied_count"))
.limit(limit)
)
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
rows = result.all()
return {
"days": days,
"items": [
{
"endpoint_key": row.endpoint_key,
"role": row.role,
"denied_count": row.denied_count,
}
for row in rows
],
}
# ═══════════════════════════════════════════
# 新增端点:IP 属地统计
# ═══════════════════════════════════════════
@router.get("/ip-locations", status_code=status.HTTP_200_OK)
async def get_ip_locations(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
days: int = Query(7, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
all_time: bool = False,
) -> dict:
"""获取 IP 省市属地统计。"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
generated_at = datetime.now(timezone.utc)
server_location = await resolve_monitoring_server_location()
start_time = None if all_time else generated_at - timedelta(days=days)
matching_security_request = select(SecurityAccessLog.id).where(
PermissionAccessLog.request_id.is_not(None),
SecurityAccessLog.request_id == PermissionAccessLog.request_id,
).exists()
permission_conditions = [
PermissionAccessLog.ip_address.is_not(None),
~matching_security_request,
]
security_conditions = [SecurityAccessLog.client_ip.is_not(None)]
if start_time is not None:
permission_conditions.insert(0, PermissionAccessLog.created_at >= start_time)
security_conditions.insert(0, SecurityAccessLog.created_at >= start_time)
security_allowed = case((SecurityAccessLog.status_code < 400, True), else_=False)
permission_result = await db.execute(
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
PermissionAccessLog.allowed,
func.count().label("event_count"),
func.min(PermissionAccessLog.created_at).label("first_seen_at"),
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
)
.where(*permission_conditions)
.group_by(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
PermissionAccessLog.allowed,
)
)
security_result = await db.execute(
select(
SecurityAccessLog.client_ip,
SecurityAccessLog.user_identifier,
SecurityAccessLog.auth_status,
security_allowed.label("allowed"),
SecurityAccessLog.severity,
SecurityAccessLog.category,
func.count().label("event_count"),
func.min(SecurityAccessLog.created_at).label("first_seen_at"),
func.max(SecurityAccessLog.created_at).label("last_seen_at"),
)
.where(*security_conditions)
.group_by(
SecurityAccessLog.client_ip,
SecurityAccessLog.user_identifier,
SecurityAccessLog.auth_status,
security_allowed,
SecurityAccessLog.severity,
SecurityAccessLog.category,
)
)
buckets: dict[tuple[str, str, str], dict] = {}
all_ip_addresses: set[str] = set()
all_user_ids: set[str] = set()
located_ip_addresses: set[str] = set()
private_ip_addresses: set[str] = set()
unknown_ip_addresses: set[str] = set()
total_count = 0
allowed_count = 0
denied_count = 0
observed_start_at: datetime | None = None
observed_end_at: datetime | None = None
resolved_locations: dict[str, IpLocation] = {}
resolved_metadata: dict[str, GeoLocationMetadata] = {}
def normalize_observed_at(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
def add_ip_location_row(
ip_address: str,
user_id: str | uuid.UUID | None,
allowed: bool,
event_count: int,
first_seen_at: datetime | None,
last_seen_at: datetime | None,
*,
source_kind: str,
severity: str | None = None,
category: str | None = None,
auth_status: str | None = None,
) -> None:
nonlocal total_count, allowed_count, denied_count, observed_start_at, observed_end_at
ip_info = resolved_locations.get(ip_address)
if ip_info is None:
ip_info = resolve_ip_location(ip_address)
resolved_locations[ip_address] = ip_info
metadata = resolved_metadata.get(ip_address)
if metadata is None:
metadata = resolve_geo_location_metadata(ip_info)
resolved_metadata[ip_address] = metadata
longitude = metadata.longitude
latitude = metadata.latitude
if metadata.accuracy_level == "private":
longitude = server_location.longitude if server_location else None
latitude = server_location.latitude if server_location else None
private_ip_addresses.add(ip_address)
elif longitude is not None and latitude is not None:
located_ip_addresses.add(ip_address)
else:
unknown_ip_addresses.add(ip_address)
country = metadata.country or ip_info.country
key = (metadata.country_code or country, metadata.region_code or ip_info.province, ip_info.city)
location = " / ".join(part for part in [country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
bucket = buckets.setdefault(
key,
{
"country": country,
"country_code": metadata.country_code,
"province": ip_info.province,
"region_code": metadata.region_code,
"city": ip_info.city,
"isp": ip_info.isp,
"location": location,
"longitude": longitude,
"latitude": latitude,
"accuracy_level": metadata.accuracy_level,
"total_count": 0,
"allowed_count": 0,
"denied_count": 0,
"security_event_count": 0,
"high_risk_count": 0,
"auth_failure_count": 0,
"categories": set(),
"ip_addresses": set(),
"user_ids": set(),
"first_seen_at": None,
"last_seen_at": None,
},
)
bucket["total_count"] += event_count
bucket["allowed_count" if allowed else "denied_count"] += event_count
if ip_info.isp and bucket["isp"] and bucket["isp"] != ip_info.isp:
bucket["isp"] = "多运营商"
elif ip_info.isp:
bucket["isp"] = ip_info.isp
bucket["ip_addresses"].add(ip_address)
if user_id:
bucket["user_ids"].add(str(user_id))
normalized_first = normalize_observed_at(first_seen_at)
normalized_last = normalize_observed_at(last_seen_at)
if normalized_first and (bucket["first_seen_at"] is None or normalized_first < bucket["first_seen_at"]):
bucket["first_seen_at"] = normalized_first
if normalized_last and (bucket["last_seen_at"] is None or normalized_last > bucket["last_seen_at"]):
bucket["last_seen_at"] = normalized_last
if normalized_first and (observed_start_at is None or normalized_first < observed_start_at):
observed_start_at = normalized_first
if normalized_last and (observed_end_at is None or normalized_last > observed_end_at):
observed_end_at = normalized_last
normalized_category = (category or "").upper()
normalized_severity = (severity or "").upper()
if source_kind == "security" and normalized_category not in {"", "OTHER", "NOT_FOUND_NOISE"}:
bucket["security_event_count"] += event_count
bucket["categories"].add(normalized_category)
if source_kind == "security" and normalized_severity in {"HIGH", "CRITICAL"}:
bucket["high_risk_count"] += event_count
if source_kind == "security" and not allowed and auth_status in {"INVALID_TOKEN", "ANONYMOUS"}:
bucket["auth_failure_count"] += event_count
total_count += event_count
if allowed:
allowed_count += event_count
else:
denied_count += event_count
all_ip_addresses.add(ip_address)
if user_id:
all_user_ids.add(str(user_id))
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_result.all():
add_ip_location_row(
ip_address,
user_id,
allowed,
int(event_count or 0),
first_seen_at,
last_seen_at,
source_kind="permission",
)
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_result.all():
user_id = None
if auth_status == "AUTHENTICATED" and user_identifier:
try:
user_id = uuid.UUID(user_identifier)
except ValueError:
user_id = user_identifier
add_ip_location_row(
client_ip,
user_id,
bool(allowed),
int(event_count or 0),
first_seen_at,
last_seen_at,
source_kind="security",
severity=severity,
category=category,
auth_status=auth_status,
)
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
def serialize_item(item: dict) -> dict:
total = max(1, int(item["total_count"]))
denied_rate = item["denied_count"] / total
risk_score = min(
100,
round(
denied_rate * 60
+ min(item["high_risk_count"] * 25, 40)
+ min(item["auth_failure_count"] * 5, 20)
),
)
if item["high_risk_count"] > 0 or risk_score >= 60:
risk_level = "high"
elif item["denied_count"] > 0 or item["security_event_count"] > 0 or item["auth_failure_count"] > 0:
risk_level = "attention"
else:
risk_level = "normal"
risk_reasons = []
if item["high_risk_count"]:
risk_reasons.append(f"{item['high_risk_count']} 次高风险安全事件")
if item["auth_failure_count"]:
risk_reasons.append(f"{item['auth_failure_count']} 次认证失败")
if item["denied_count"]:
risk_reasons.append(f"拒绝率 {round(denied_rate * 100, 1)}%")
return {
"country": item["country"],
"country_code": item["country_code"],
"province": item["province"],
"region_code": item["region_code"],
"city": item["city"],
"isp": item["isp"],
"location": item["location"],
"longitude": item["longitude"],
"latitude": item["latitude"],
"accuracy_level": item["accuracy_level"],
"total_count": item["total_count"],
"allowed_count": item["allowed_count"],
"denied_count": item["denied_count"],
"denied_rate": round(denied_rate * 100, 1),
"unique_ip_count": len(item["ip_addresses"]),
"unique_user_count": len(item["user_ids"]),
"security_event_count": item["security_event_count"],
"high_risk_count": item["high_risk_count"],
"risk_score": risk_score,
"risk_level": risk_level,
"risk_reasons": risk_reasons,
"categories": sorted(item["categories"]),
"first_seen_at": item["first_seen_at"].isoformat() if item["first_seen_at"] else None,
"last_seen_at": item["last_seen_at"].isoformat() if item["last_seen_at"] else None,
}
effective_start_at = start_time or observed_start_at or generated_at - timedelta(
days=settings.MONITORING_ACCESS_LOG_RETENTION_DAYS
)
located_count = len(located_ip_addresses)
private_count = len(private_ip_addresses)
unknown_count = len(unknown_ip_addresses)
public_ip_count = located_count + unknown_count
timeline_granularity = "hour" if not all_time and days <= 1 else "day"
timeline = await get_source_location_timeline(
db,
start_at=effective_start_at,
end_at=generated_at,
granularity=timeline_granularity,
)
return {
"days": None if all_time else days,
"generated_at": generated_at.isoformat(),
"period": {
"mode": "retained_all" if all_time else "rolling",
"requested_days": None if all_time else days,
"start_at": effective_start_at.isoformat(),
"end_at": generated_at.isoformat(),
"observed_end_at": observed_end_at.isoformat() if observed_end_at else None,
"retention_days": settings.MONITORING_ACCESS_LOG_RETENTION_DAYS,
},
"server_location": server_location.to_public_dict() if server_location else None,
"data_quality": {
"resolver": "ip2region",
"located_ip_count": located_count,
"private_ip_count": private_count,
"unknown_ip_count": unknown_count,
"location_coverage_rate": round((located_count / public_ip_count) * 100, 1) if public_ip_count else 100.0,
"returned_region_count": len(items),
},
"timeline_granularity": timeline_granularity,
"timeline_complete_through": generated_at.replace(minute=0, second=0, microsecond=0).isoformat(),
"timeline": timeline,
"summary": {
"total_count": total_count,
"allowed_count": allowed_count,
"denied_count": denied_count,
"unique_ip_count": len(all_ip_addresses),
"unique_user_count": len(all_user_ids),
"security_event_count": sum(item["security_event_count"] for item in buckets.values()),
"high_risk_count": sum(item["high_risk_count"] for item in buckets.values()),
},
"items": [serialize_item(item) for item in items],
}
# ═══════════════════════════════════════════
# 新增端点:实时统计摘要(从DB获取)
# ═══════════════════════════════════════════
@router.get("/stats-summary", status_code=status.HTTP_200_OK)
async def get_stats_summary(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
) -> dict:
"""获取基于数据库的统计摘要"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
hour_ago = now - timedelta(hours=1)
# 今日统计
today_query = select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(PermissionAccessLog.created_at >= today_start)
today_result = await db.execute(
_apply_monitoring_scope_to_log_query(today_query, scope)
)
today = today_result.one()
# 最近一小时
hour_query = select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
).where(PermissionAccessLog.created_at >= hour_ago)
hour_result = await db.execute(
_apply_monitoring_scope_to_log_query(hour_query, scope)
)
hour = hour_result.one()
# 总记录数
total_query = select(func.count()).select_from(PermissionAccessLog)
total_result = await db.execute(
_apply_monitoring_scope_to_log_query(total_query, scope)
)
total_logs = total_result.scalar() or 0
return {
"total_logs": total_logs,
"today": {
"total_checks": today.total,
"allowed_checks": today.allowed,
"denied_checks": today.denied,
"avg_elapsed_ms": round(float(today.avg_ms), 2),
"max_elapsed_ms": round(float(today.max_ms), 2),
"allow_rate": round(today.allowed / today.total * 100, 1) if today.total > 0 else 0,
"deny_rate": round(today.denied / today.total * 100, 1) if today.total > 0 else 0,
},
"last_hour": {
"total_checks": hour.total,
"allowed_checks": hour.allowed,
"denied_checks": hour.denied,
"requests_per_minute": round(hour.total / 60, 1),
},
}