1929 lines
82 KiB
Python
1929 lines
82 KiB
Python
"""权限系统监控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),
|
||
event_type: str = Query("all", pattern="^(all|permission)$"),
|
||
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 = (
|
||
event_type != "permission"
|
||
and 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)
|
||
bucket_delta, bucket_count = _trend_bucket_config(period)
|
||
current_bucket = _trend_bucket_time(now, period)
|
||
start_time = current_bucket - bucket_delta * (bucket_count - 1)
|
||
current_duration = now - start_time
|
||
previous_end = start_time
|
||
previous_start = previous_end - current_duration
|
||
|
||
log_buckets = await _load_trend_log_buckets(db, scope, start_time, now, period)
|
||
cache_buckets, current_cache, previous_cache = await _load_trend_cache_buckets(
|
||
db,
|
||
scope,
|
||
previous_start,
|
||
previous_end,
|
||
start_time,
|
||
now,
|
||
period,
|
||
)
|
||
summary = await _load_trend_summary(db, scope, start_time, now)
|
||
previous_summary = await _load_trend_summary(db, scope, previous_start, previous_end)
|
||
summary.update(current_cache)
|
||
previous_summary.update(previous_cache)
|
||
previous_summary.pop("observed_end_at", None)
|
||
|
||
data_points = []
|
||
for index in range(bucket_count):
|
||
bucket_time = start_time + bucket_delta * index
|
||
values = log_buckets.get(bucket_time, _empty_trend_bucket())
|
||
cache_values = cache_buckets.get(bucket_time, {"cache_hits": 0, "cache_misses": 0})
|
||
cache_total = int(cache_values["cache_hits"]) + int(cache_values["cache_misses"])
|
||
total = int(values["total"])
|
||
data_points.append(
|
||
{
|
||
"bucket_time": bucket_time.isoformat(),
|
||
"sample_state": "partial" if bucket_time == current_bucket else "complete",
|
||
"total_checks": total,
|
||
"allowed_checks": int(values["allowed"]),
|
||
"denied_checks": int(values["denied"]),
|
||
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / total, 2) if total else 0,
|
||
"max_elapsed_ms": round(float(values["max_ms"]), 2),
|
||
"cache_hits": int(cache_values["cache_hits"]),
|
||
"cache_misses": int(cache_values["cache_misses"]),
|
||
"cache_hit_rate": round(int(cache_values["cache_hits"]) / cache_total * 100, 1) if cache_total else 0,
|
||
"cache_sample_available": cache_total > 0,
|
||
"error_count": 0,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"period": period,
|
||
"generated_at": now.isoformat(),
|
||
"range": {
|
||
"start_at": start_time.isoformat(),
|
||
"end_at": now.isoformat(),
|
||
"previous_start_at": previous_start.isoformat(),
|
||
"previous_end_at": previous_end.isoformat(),
|
||
"bucket_seconds": int(bucket_delta.total_seconds()),
|
||
"bucket_count": bucket_count,
|
||
"timezone": "UTC",
|
||
"observed_end_at": summary.pop("observed_end_at"),
|
||
},
|
||
"summary": summary,
|
||
"previous_summary": previous_summary,
|
||
"attribution": await _load_trend_attribution(db, scope, start_time, now),
|
||
"data_points": data_points,
|
||
}
|
||
|
||
|
||
def _trend_bucket_config(period: str) -> tuple[timedelta, int]:
|
||
return {
|
||
"24h": (timedelta(hours=1), 24),
|
||
"7d": (timedelta(hours=6), 28),
|
||
"30d": (timedelta(days=1), 30),
|
||
}[period]
|
||
|
||
|
||
def _empty_trend_bucket() -> dict[str, float | int]:
|
||
return {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
||
|
||
|
||
def _merge_trend_bucket(target: dict[str, float | int], *, total: int, allowed: int, denied: int, elapsed_sum: float, max_ms: float) -> None:
|
||
target["total"] = int(target["total"]) + total
|
||
target["allowed"] = int(target["allowed"]) + allowed
|
||
target["denied"] = int(target["denied"]) + denied
|
||
target["elapsed_sum"] = float(target["elapsed_sum"]) + elapsed_sum
|
||
target["max_ms"] = max(float(target["max_ms"]), max_ms)
|
||
|
||
|
||
async def _load_trend_log_buckets(
|
||
db: AsyncSession,
|
||
scope: MonitoringScope,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
period: str,
|
||
) -> dict[datetime, dict[str, float | int]]:
|
||
"""PostgreSQL 先按小时聚合,SQLite 测试库使用 Python 分桶。"""
|
||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(_empty_trend_bucket)
|
||
dialect_name = db.get_bind().dialect.name
|
||
if dialect_name == "postgresql":
|
||
hour_bucket = func.date_trunc("hour", PermissionAccessLog.created_at).label("hour_bucket")
|
||
query = select(
|
||
hour_bucket,
|
||
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.sum(PermissionAccessLog.elapsed_ms), 0).label("elapsed_sum"),
|
||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||
).where(
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.created_at <= end_time,
|
||
).group_by(hour_bucket)
|
||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||
for row in result.all():
|
||
bucket_time = _trend_bucket_time(row.hour_bucket, period)
|
||
_merge_trend_bucket(
|
||
buckets[bucket_time],
|
||
total=int(row.total or 0),
|
||
allowed=int(row.allowed or 0),
|
||
denied=int(row.denied or 0),
|
||
elapsed_sum=float(row.elapsed_sum or 0),
|
||
max_ms=float(row.max_ms or 0),
|
||
)
|
||
return buckets
|
||
|
||
query = select(
|
||
PermissionAccessLog.created_at,
|
||
PermissionAccessLog.allowed,
|
||
PermissionAccessLog.elapsed_ms,
|
||
).where(
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.created_at <= end_time,
|
||
)
|
||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||
for created_at, allowed, elapsed_ms in result.all():
|
||
bucket_time = _trend_bucket_time(created_at, period)
|
||
_merge_trend_bucket(
|
||
buckets[bucket_time],
|
||
total=1,
|
||
allowed=1 if allowed else 0,
|
||
denied=0 if allowed else 1,
|
||
elapsed_sum=float(elapsed_ms or 0),
|
||
max_ms=float(elapsed_ms or 0),
|
||
)
|
||
return buckets
|
||
|
||
|
||
def _percentile(values: list[float], percentile: float) -> float:
|
||
if not values:
|
||
return 0.0
|
||
ordered = sorted(values)
|
||
position = (len(ordered) - 1) * percentile
|
||
lower = int(position)
|
||
upper = min(lower + 1, len(ordered) - 1)
|
||
fraction = position - lower
|
||
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
|
||
|
||
|
||
async def _load_trend_summary(
|
||
db: AsyncSession,
|
||
scope: MonitoringScope,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
) -> dict:
|
||
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.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow"),
|
||
func.count(func.distinct(PermissionAccessLog.study_id)).label("studies"),
|
||
func.count(func.distinct(PermissionAccessLog.user_id)).label("users"),
|
||
func.count(func.distinct(PermissionAccessLog.endpoint_key)).label("endpoints"),
|
||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||
func.max(PermissionAccessLog.created_at).label("observed_end_at"),
|
||
).where(
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.created_at <= end_time,
|
||
)
|
||
row = (await db.execute(_apply_monitoring_scope_to_log_query(query, scope))).one()
|
||
if db.get_bind().dialect.name == "postgresql":
|
||
percentile_query = select(
|
||
func.percentile_cont(0.95).within_group(PermissionAccessLog.elapsed_ms)
|
||
).where(
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.created_at <= end_time,
|
||
)
|
||
percentile_result = await db.execute(
|
||
_apply_monitoring_scope_to_log_query(percentile_query, scope)
|
||
)
|
||
p95_ms = float(percentile_result.scalar() or 0)
|
||
else:
|
||
elapsed_query = select(PermissionAccessLog.elapsed_ms).where(
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.created_at <= end_time,
|
||
)
|
||
elapsed_result = await db.execute(
|
||
_apply_monitoring_scope_to_log_query(elapsed_query, scope)
|
||
)
|
||
p95_ms = _percentile(
|
||
[float(value) for value in elapsed_result.scalars().all()],
|
||
0.95,
|
||
)
|
||
total = int(row.total or 0)
|
||
denied = int(row.denied or 0)
|
||
return {
|
||
"total_checks": total,
|
||
"allowed_checks": int(row.allowed or 0),
|
||
"denied_checks": denied,
|
||
"deny_rate": round(denied / total * 100, 2) if total else 0,
|
||
"avg_elapsed_ms": round(float(row.avg_ms or 0), 2),
|
||
"p95_elapsed_ms": round(p95_ms, 2),
|
||
"max_elapsed_ms": round(float(row.max_ms or 0), 2),
|
||
"slow_check_count": int(row.slow or 0),
|
||
"active_study_count": int(row.studies or 0),
|
||
"active_user_count": int(row.users or 0),
|
||
"active_endpoint_count": int(row.endpoints or 0),
|
||
"cache_hits": 0,
|
||
"cache_misses": 0,
|
||
"cache_hit_rate": 0,
|
||
"cache_sample_available": False,
|
||
"observed_end_at": row.observed_end_at.isoformat() if row.observed_end_at else None,
|
||
}
|
||
|
||
|
||
async def _load_trend_cache_buckets(
|
||
db: AsyncSession,
|
||
scope: MonitoringScope,
|
||
previous_start: datetime,
|
||
previous_end: datetime,
|
||
current_start: datetime,
|
||
current_end: datetime,
|
||
period: str,
|
||
) -> tuple[dict[datetime, dict[str, int]], dict, dict]:
|
||
empty_summary = {"cache_hits": 0, "cache_misses": 0, "cache_hit_rate": 0, "cache_sample_available": False}
|
||
if not scope.is_admin:
|
||
return {}, dict(empty_summary), dict(empty_summary)
|
||
result = await db.execute(
|
||
select(PermissionMetricSnapshot).where(
|
||
PermissionMetricSnapshot.bucket_time >= previous_start,
|
||
PermissionMetricSnapshot.bucket_time <= current_end,
|
||
)
|
||
)
|
||
buckets: dict[datetime, dict[str, int]] = defaultdict(lambda: {"cache_hits": 0, "cache_misses": 0})
|
||
current_counts = {"cache_hits": 0, "cache_misses": 0}
|
||
previous_counts = {"cache_hits": 0, "cache_misses": 0}
|
||
for snapshot in result.scalars().all():
|
||
snapshot_time = snapshot.bucket_time
|
||
if snapshot_time.tzinfo is None:
|
||
snapshot_time = snapshot_time.replace(tzinfo=timezone.utc)
|
||
if snapshot_time >= current_start:
|
||
current_counts["cache_hits"] += int(snapshot.cache_hits or 0)
|
||
current_counts["cache_misses"] += int(snapshot.cache_misses or 0)
|
||
bucket = buckets[_trend_bucket_time(snapshot_time, period)]
|
||
bucket["cache_hits"] += int(snapshot.cache_hits or 0)
|
||
bucket["cache_misses"] += int(snapshot.cache_misses or 0)
|
||
elif snapshot_time < previous_end:
|
||
previous_counts["cache_hits"] += int(snapshot.cache_hits or 0)
|
||
previous_counts["cache_misses"] += int(snapshot.cache_misses or 0)
|
||
|
||
def serialize(counts: dict[str, int]) -> dict:
|
||
total = counts["cache_hits"] + counts["cache_misses"]
|
||
return {
|
||
**counts,
|
||
"cache_hit_rate": round(counts["cache_hits"] / total * 100, 2) if total else 0,
|
||
"cache_sample_available": total > 0,
|
||
}
|
||
|
||
return dict(buckets), serialize(current_counts), serialize(previous_counts)
|
||
|
||
|
||
async def _load_trend_attribution(
|
||
db: AsyncSession,
|
||
scope: MonitoringScope,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
) -> dict:
|
||
base_conditions = (
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.created_at <= end_time,
|
||
)
|
||
denied_query = select(
|
||
PermissionAccessLog.endpoint_key,
|
||
PermissionAccessLog.role,
|
||
func.count().label("denied_count"),
|
||
).where(*base_conditions, PermissionAccessLog.allowed.is_(False)).group_by(
|
||
PermissionAccessLog.endpoint_key,
|
||
PermissionAccessLog.role,
|
||
).order_by(desc("denied_count")).limit(5)
|
||
denied_rows = (await db.execute(_apply_monitoring_scope_to_log_query(denied_query, scope))).all()
|
||
|
||
slow_query = select(
|
||
PermissionAccessLog.endpoint_key,
|
||
func.count().label("sample_count"),
|
||
func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow_count"),
|
||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||
).where(*base_conditions).group_by(PermissionAccessLog.endpoint_key).order_by(
|
||
desc("slow_count"), desc("avg_ms"), desc("sample_count")
|
||
).limit(5)
|
||
slow_rows = (await db.execute(_apply_monitoring_scope_to_log_query(slow_query, scope))).all()
|
||
|
||
client_type = func.coalesce(PermissionAccessLog.client_type, "unknown")
|
||
client_version = func.coalesce(PermissionAccessLog.client_version, "-")
|
||
client_platform = func.coalesce(PermissionAccessLog.client_platform, "-")
|
||
client_query = select(
|
||
client_type.label("client_type"),
|
||
client_version.label("client_version"),
|
||
client_platform.label("client_platform"),
|
||
func.count().label("total_count"),
|
||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||
).where(*base_conditions).group_by(client_type, client_version, client_platform).order_by(
|
||
desc("total_count")
|
||
).limit(5)
|
||
client_rows = (await db.execute(_apply_monitoring_scope_to_log_query(client_query, scope))).all()
|
||
|
||
return {
|
||
"top_denied": [
|
||
{"endpoint_key": row.endpoint_key, "role": row.role, "denied_count": int(row.denied_count)}
|
||
for row in denied_rows
|
||
],
|
||
"top_slow": [
|
||
{
|
||
"endpoint_key": row.endpoint_key,
|
||
"sample_count": int(row.sample_count),
|
||
"slow_count": int(row.slow_count),
|
||
"avg_elapsed_ms": round(float(row.avg_ms), 2),
|
||
"max_elapsed_ms": round(float(row.max_ms), 2),
|
||
}
|
||
for row in slow_rows
|
||
],
|
||
"client_breakdown": [
|
||
{
|
||
"client_type": row.client_type,
|
||
"client_version": row.client_version,
|
||
"client_platform": row.client_platform,
|
||
"total_count": int(row.total_count),
|
||
"denied_count": int(row.denied_count),
|
||
"deny_rate": round(int(row.denied_count) / int(row.total_count) * 100, 2) if row.total_count else 0,
|
||
"avg_elapsed_ms": round(float(row.avg_ms), 2),
|
||
}
|
||
for row in client_rows
|
||
],
|
||
}
|
||
|
||
|
||
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),
|
||
},
|
||
}
|