761 lines
30 KiB
Python
761 lines
30 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 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"])
|
|
|
|
|
|
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())
|
|
return MonitoringScope(
|
|
is_admin=False,
|
|
study_ids=await list_active_pm_study_ids(db, current_user.id),
|
|
)
|
|
|
|
|
|
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.to_dict()
|
|
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
|
|
|
|
health_score = 100
|
|
issues = []
|
|
|
|
if avg_ms > 10:
|
|
health_score -= 10
|
|
issues.append("权限检查响应时间过长")
|
|
if deny_rate > 50:
|
|
health_score -= 5
|
|
issues.append("权限拒绝率过高")
|
|
if (
|
|
cache_metrics.total_accesses >= 10
|
|
and cache_metrics.hit_rate < 50
|
|
):
|
|
health_score -= 10
|
|
issues.append("缓存命中率过低")
|
|
|
|
return {
|
|
"status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
|
|
"health_score": max(0, health_score),
|
|
"issues": issues,
|
|
"last_hour": {
|
|
"total_checks": total,
|
|
"denied_checks": row.denied or 0,
|
|
"deny_rate": deny_rate,
|
|
"avg_elapsed_ms": round(avg_ms, 2),
|
|
},
|
|
"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.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:
|
|
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
|
|
scope = await resolve_monitoring_scope(db, _)
|
|
if not scope.is_admin:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
|
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:
|
|
"""获取趋势数据(从快照表或实时聚合)"""
|
|
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))
|
|
buckets: dict[tuple[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)
|
|
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)
|
|
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:
|
|
"""获取基于数据库的统计摘要"""
|
|
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),
|
|
},
|
|
}
|