b491b6a146
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
921 lines
37 KiB
Python
921 lines
37 KiB
Python
"""权限系统监控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, HTTPException, 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, is_system_admin, list_active_pm_study_ids
|
||
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.ip_location import IpLocation, resolve_ip_location
|
||
|
||
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())
|
||
user_id = getattr(current_user, "id", None)
|
||
if user_id:
|
||
study_ids = await list_active_pm_study_ids(db, user_id)
|
||
if study_ids:
|
||
return MonitoringScope(is_admin=False, study_ids=study_ids)
|
||
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))
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 原有端点(保持兼容)
|
||
# ═══════════════════════════════════════════
|
||
|
||
@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)
|
||
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
|
||
]
|
||
|
||
deductions: dict[str, int] = {
|
||
"performance": 0,
|
||
"deny_rate": 0,
|
||
"cache": 0,
|
||
"alerts": 0,
|
||
}
|
||
issues: list[str] = []
|
||
|
||
if avg_ms > 200:
|
||
deductions["performance"] = 40
|
||
elif avg_ms > 50:
|
||
deductions["performance"] = 25
|
||
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"] = 15
|
||
issues.append(f"近 1 小时发生 {error_alert_count} 条错误告警")
|
||
elif warning_alert_count:
|
||
deductions["alerts"] = 5
|
||
issues.append(f"近 1 小时发生 {warning_alert_count} 条警告")
|
||
|
||
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": 40, "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": 15, "deduction": deductions["alerts"]},
|
||
},
|
||
"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(),
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 新增端点:访问日志
|
||
# ═══════════════════════════════════════════
|
||
|
||
@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:
|
||
"""分页查询权限访问日志"""
|
||
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="权限不足")
|
||
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)
|
||
|
||
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.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"),
|
||
)
|
||
.group_by(PermissionAccessLog.ip_address)
|
||
.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()
|
||
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,
|
||
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)
|
||
|
||
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)
|
||
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 "未知用户",
|
||
"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": {
|
||
"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 "未知账号"
|
||
|
||
|
||
SENSITIVE_PROBE_MARKERS = (
|
||
"/.env",
|
||
".env",
|
||
"/.git",
|
||
".git/config",
|
||
"backup",
|
||
"config.php",
|
||
"wp-config",
|
||
"database.yml",
|
||
)
|
||
|
||
|
||
CHINA_IP_COUNTRY_LABELS = {"中国", "China", "Mainland China", "中国香港", "中国澳门", "中国台湾"}
|
||
|
||
|
||
def _is_non_china_ip(ip_location: IpLocation) -> bool:
|
||
country = (ip_location.country or "").strip()
|
||
return bool(country) and country not in CHINA_IP_COUNTRY_LABELS and not country.startswith("中国")
|
||
|
||
|
||
def _classify_security_access_log(log: SecurityAccessLog, ip_location: IpLocation | None = None) -> dict[str, str]:
|
||
path = (log.path or "").lower()
|
||
if any(marker in path for marker in SENSITIVE_PROBE_MARKERS):
|
||
return {"category": "PROBE", "severity": "CRITICAL"}
|
||
if ip_location and _is_non_china_ip(ip_location):
|
||
return {"category": "ABNORMAL_IP", "severity": "HIGH"}
|
||
if log.status_code >= 500:
|
||
return {"category": "SERVER_ERROR", "severity": "HIGH"}
|
||
if log.auth_status == "INVALID_TOKEN":
|
||
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
|
||
if log.auth_status == "ANONYMOUS" and log.status_code in {401, 403}:
|
||
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
|
||
if log.status_code == 404:
|
||
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
|
||
return {"category": "OTHER", "severity": "LOW"}
|
||
|
||
|
||
@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),
|
||
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
|
||
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)
|
||
|
||
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:
|
||
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,
|
||
"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,
|
||
},
|
||
"items": items,
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 新增端点:趋势数据
|
||
# ═══════════════════════════════════════════
|
||
|
||
@router.get("/trends", status_code=status.HTTP_200_OK)
|
||
async def get_trends(
|
||
db: AsyncSession = Depends(get_db_session),
|
||
_=Depends(get_current_user),
|
||
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
||
) -> dict:
|
||
"""获取趋势数据(从快照表或实时聚合)"""
|
||
scope = await resolve_monitoring_scope(db, _)
|
||
if not scope.is_admin and not scope.study_ids:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||
now = datetime.now(timezone.utc)
|
||
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
||
start_time = now - period_map[period]
|
||
|
||
# 先尝试从快照表获取
|
||
snapshot_query = (
|
||
select(PermissionMetricSnapshot)
|
||
.where(PermissionMetricSnapshot.bucket_time >= start_time)
|
||
.order_by(PermissionMetricSnapshot.bucket_time)
|
||
)
|
||
result = await db.execute(snapshot_query)
|
||
snapshots = result.scalars().all()
|
||
|
||
if snapshots and scope.is_admin:
|
||
return {
|
||
"period": period,
|
||
"data_points": [
|
||
{
|
||
"bucket_time": s.bucket_time.isoformat(),
|
||
"total_checks": s.total_checks,
|
||
"allowed_checks": s.allowed_checks,
|
||
"denied_checks": s.denied_checks,
|
||
"avg_elapsed_ms": round(s.avg_elapsed_ms, 2),
|
||
"max_elapsed_ms": round(s.max_elapsed_ms, 2),
|
||
"cache_hits": s.cache_hits,
|
||
"cache_misses": s.cache_misses,
|
||
"cache_hit_rate": round(
|
||
s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1
|
||
) if (s.cache_hits + s.cache_misses) > 0 else 0,
|
||
"error_count": s.error_count,
|
||
}
|
||
for s in snapshots
|
||
],
|
||
}
|
||
|
||
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
|
||
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
|
||
trend_query = select(
|
||
PermissionAccessLog.created_at,
|
||
PermissionAccessLog.allowed,
|
||
PermissionAccessLog.elapsed_ms,
|
||
).where(PermissionAccessLog.created_at >= start_time)
|
||
result = await db.execute(
|
||
_apply_monitoring_scope_to_log_query(trend_query, scope)
|
||
)
|
||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
|
||
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
||
)
|
||
for created_at, allowed, elapsed_ms in result.all():
|
||
bucket = _trend_bucket_time(created_at, period)
|
||
buckets[bucket]["total"] += 1
|
||
buckets[bucket]["allowed" if allowed else "denied"] += 1
|
||
buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0)
|
||
buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0))
|
||
|
||
return {
|
||
"period": period,
|
||
"data_points": [
|
||
{
|
||
"bucket_time": bucket.isoformat(),
|
||
"total_checks": values["total"],
|
||
"allowed_checks": values["allowed"],
|
||
"denied_checks": values["denied"],
|
||
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2),
|
||
"max_elapsed_ms": round(float(values["max_ms"]), 2),
|
||
"cache_hits": 0,
|
||
"cache_misses": 0,
|
||
"cache_hit_rate": 0,
|
||
"error_count": 0,
|
||
}
|
||
for bucket, values in sorted(buckets.items())
|
||
],
|
||
}
|
||
|
||
|
||
def _trend_bucket_time(value: datetime, period: str) -> datetime:
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
if period == "24h":
|
||
return value.replace(minute=0, second=0, microsecond=0)
|
||
if period == "7d":
|
||
return value.replace(hour=(value.hour // 6) * 6, minute=0, second=0, microsecond=0)
|
||
return value.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 新增端点:被拒绝最多的权限
|
||
# ═══════════════════════════════════════════
|
||
|
||
@router.get("/top-denied", status_code=status.HTTP_200_OK)
|
||
async def get_top_denied(
|
||
db: AsyncSession = Depends(get_db_session),
|
||
_=Depends(get_current_user),
|
||
days: int = Query(7, ge=1, le=90),
|
||
limit: int = Query(20, ge=1, le=100),
|
||
) -> dict:
|
||
"""获取被拒绝最多的权限"""
|
||
scope = await resolve_monitoring_scope(db, _)
|
||
if not scope.is_admin and not scope.study_ids:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||
|
||
query = (
|
||
select(
|
||
PermissionAccessLog.endpoint_key,
|
||
PermissionAccessLog.role,
|
||
func.count().label("denied_count"),
|
||
)
|
||
.where(
|
||
PermissionAccessLog.created_at >= start_time,
|
||
PermissionAccessLog.allowed.is_(False),
|
||
)
|
||
.group_by(PermissionAccessLog.endpoint_key, PermissionAccessLog.role)
|
||
.order_by(desc("denied_count"))
|
||
.limit(limit)
|
||
)
|
||
|
||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||
rows = result.all()
|
||
|
||
return {
|
||
"days": days,
|
||
"items": [
|
||
{
|
||
"endpoint_key": row.endpoint_key,
|
||
"role": row.role,
|
||
"denied_count": row.denied_count,
|
||
}
|
||
for row in rows
|
||
],
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 新增端点:IP 属地统计
|
||
# ═══════════════════════════════════════════
|
||
|
||
@router.get("/ip-locations", status_code=status.HTTP_200_OK)
|
||
async def get_ip_locations(
|
||
db: AsyncSession = Depends(get_db_session),
|
||
_=Depends(get_current_user),
|
||
days: int = Query(7, ge=1, le=90),
|
||
limit: int = Query(20, ge=1, le=100),
|
||
) -> 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="权限不足")
|
||
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(_apply_monitoring_scope_to_log_query(query, scope))
|
||
security_result = await db.execute(
|
||
select(
|
||
SecurityAccessLog.client_ip,
|
||
SecurityAccessLog.user_identifier,
|
||
SecurityAccessLog.auth_status,
|
||
SecurityAccessLog.status_code,
|
||
).where(
|
||
SecurityAccessLog.created_at >= start_time,
|
||
SecurityAccessLog.client_ip.is_not(None),
|
||
)
|
||
)
|
||
buckets: dict[tuple[str, str, str], dict] = {}
|
||
all_ip_addresses: set[str] = set()
|
||
all_user_ids: set[str] = set()
|
||
total_count = 0
|
||
allowed_count = 0
|
||
denied_count = 0
|
||
|
||
def add_ip_location_row(ip_address: str, user_id: str | uuid.UUID | None, allowed: bool) -> None:
|
||
nonlocal total_count, allowed_count, denied_count
|
||
ip_info = resolve_ip_location(ip_address)
|
||
key = (ip_info.country, ip_info.province, ip_info.city)
|
||
location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
|
||
bucket = buckets.setdefault(
|
||
key,
|
||
{
|
||
"country": ip_info.country,
|
||
"province": ip_info.province,
|
||
"city": ip_info.city,
|
||
"isp": "",
|
||
"location": 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)
|
||
if user_id:
|
||
bucket["user_ids"].add(str(user_id))
|
||
total_count += 1
|
||
if allowed:
|
||
allowed_count += 1
|
||
else:
|
||
denied_count += 1
|
||
all_ip_addresses.add(ip_address)
|
||
if user_id:
|
||
all_user_ids.add(str(user_id))
|
||
|
||
for ip_address, user_id, allowed in result.all():
|
||
add_ip_location_row(ip_address, user_id, allowed)
|
||
|
||
for client_ip, user_identifier, auth_status, status_code 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, status_code < 400)
|
||
|
||
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:
|
||
"""获取基于数据库的统计摘要"""
|
||
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),
|
||
},
|
||
}
|