"""权限系统监控API 提供权限系统的监控数据、访问日志、趋势分析和告警信息。 """ from __future__ import annotations import uuid from collections import defaultdict from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, Query, status from sqlalchemy import func, select, desc from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session from app.core.permission_monitor import evaluate_permission_system_health, 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.ip_location import resolve_ip_location router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"]) # ═══════════════════════════════════════════ # 原有端点(保持兼容) # ═══════════════════════════════════════════ @router.get("/metrics", status_code=status.HTTP_200_OK) async def get_permission_metrics( _=Depends(get_current_user), ) -> dict: monitor = get_permission_monitor() return monitor.get_metrics() @router.get("/cache-stats", status_code=status.HTTP_200_OK) async def get_cache_statistics( _=Depends(get_current_user), ) -> dict: 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), ) -> dict: 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), ) -> dict: 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), ) -> dict: monitor = get_permission_monitor() monitor.clear_alerts() return {"message": "告警已清除"} @router.get("/health", status_code=status.HTTP_200_OK) async def permission_system_health( _=Depends(get_current_user), ) -> dict: monitor = get_permission_monitor() metrics = monitor.get_metrics() cache_stats = monitor.get_cache_stats() return evaluate_permission_system_health(metrics, cache_stats) # ═══════════════════════════════════════════ # 新增端点:访问日志 # ═══════════════════════════════════════════ @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), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ) -> dict: """分页查询权限访问日志""" conditions = [] if study_id: conditions.append(PermissionAccessLog.study_id == study_id) 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) query = ( select(PermissionAccessLog, User.full_name) .outerjoin(User, PermissionAccessLog.user_id == User.id) ) count_query = select(func.count()).select_from(PermissionAccessLog) summary_query = select( func.count().label("total_count"), func.count(func.distinct(PermissionAccessLog.user_id)).label("unique_user_count"), func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"), func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"), func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_elapsed_ms"), ).select_from(PermissionAccessLog) user_stats_query = ( select( PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role, 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, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role) .order_by(desc("total_count"), desc("last_seen_at")) .limit(10) ) for condition in conditions: query = query.where(condition) count_query = count_query.where(condition) summary_query = summary_query.where(condition) user_stats_query = user_stats_query.where(condition) total_result = await db.execute(count_query) total = total_result.scalar() or 0 summary_result = await db.execute(summary_query) summary_row = summary_result.one() user_stats_result = await db.execute(user_stats_query) user_stats_rows = user_stats_result.all() query = query.order_by(desc(PermissionAccessLog.created_at)) query = query.offset((page - 1) * page_size).limit(page_size) result = await db.execute(query) rows = result.all() items = [] for log, full_name in rows: ip_location = resolve_ip_location(log.ip_address) items.append( { "id": str(log.id), "study_id": str(log.study_id), "user_id": str(log.user_id), "user_name": full_name or "未知用户", "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, "created_at": log.created_at.isoformat(), } ) user_stats = [] for stat in user_stats_rows: sample_location = resolve_ip_location(stat.sample_ip_address) user_stats.append( { "user_id": str(stat.user_id), "user_name": stat.full_name or "未知用户", "role": stat.role, "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": { "total_count": summary_row.total_count, "unique_user_count": summary_row.unique_user_count, "unique_ip_count": summary_row.unique_ip_count, "denied_count": summary_row.denied_count, "avg_elapsed_ms": round(float(summary_row.avg_elapsed_ms), 2), }, "user_stats": user_stats, "items": 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 "未知账号" @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), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ) -> dict: """查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。""" conditions = [] if status_min is not None: conditions.append(SecurityAccessLog.status_code >= status_min) if auth_status: conditions.append(SecurityAccessLog.auth_status == auth_status) 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"), ).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() logs = result.scalars().all() 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 user_names: dict[str, str] = {} if user_ids: users_result = await db.execute(select(User.id, User.full_name).where(User.id.in_(user_ids))) user_names = {str(row.id): row.full_name for row in users_result.all()} items = [] for log in logs: 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, "user_agent": log.user_agent, "auth_status": log.auth_status, "user_identifier": log.user_identifier, "account_label": _security_account_label(log.auth_status, log.user_identifier, user_names), "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, }, "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: """获取趋势数据(从快照表或实时聚合)""" 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: 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。 result = await db.execute( select( PermissionAccessLog.created_at, PermissionAccessLog.allowed, PermissionAccessLog.elapsed_ms, ).where(PermissionAccessLog.created_at >= start_time) ) 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: """获取被拒绝最多的权限""" 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(query) 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), ) -> dict: """获取 IP 省市属地统计。""" start_time = datetime.now(timezone.utc) - timedelta(days=days) query = ( select( PermissionAccessLog.ip_address, PermissionAccessLog.user_id, PermissionAccessLog.allowed, ) .where( PermissionAccessLog.created_at >= start_time, PermissionAccessLog.ip_address.is_not(None), ) ) result = await db.execute(query) buckets: dict[tuple[str, str, str, str], dict] = {} all_ip_addresses: set[str] = set() all_user_ids: set[uuid.UUID] = set() total_count = 0 allowed_count = 0 denied_count = 0 for ip_address, user_id, allowed in result.all(): ip_info = resolve_ip_location(ip_address) key = (ip_info.country, ip_info.province, ip_info.city, ip_info.isp) bucket = buckets.setdefault( key, { "country": ip_info.country, "province": ip_info.province, "city": ip_info.city, "isp": ip_info.isp, "location": ip_info.location, "total_count": 0, "allowed_count": 0, "denied_count": 0, "ip_addresses": set(), "user_ids": set(), }, ) bucket["total_count"] += 1 bucket["allowed_count" if allowed else "denied_count"] += 1 bucket["ip_addresses"].add(ip_address) bucket["user_ids"].add(user_id) total_count += 1 if allowed: allowed_count += 1 else: denied_count += 1 all_ip_addresses.add(ip_address) all_user_ids.add(user_id) items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit] return { "days": days, "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), }, "items": [ { "country": item["country"], "province": item["province"], "city": item["city"], "isp": item["isp"], "location": item["location"], "total_count": item["total_count"], "allowed_count": item["allowed_count"], "denied_count": item["denied_count"], "unique_ip_count": len(item["ip_addresses"]), "unique_user_count": len(item["user_ids"]), } 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: """获取基于数据库的统计摘要""" now = datetime.now(timezone.utc) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) hour_ago = now - timedelta(hours=1) # 今日统计 today_result = await db.execute( 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 = today_result.one() # 最近一小时 hour_result = await db.execute( 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 = hour_result.one() # 总记录数 total_result = await db.execute( select(func.count()).select_from(PermissionAccessLog) ) 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), }, }