Files
ctms/backend/app/api/v1/permission_monitoring.py
T
Cheng Zhou 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
发布候选:整合桌面端界面与发布稳定化里程碑
2026-07-01 10:53:24 +08:00

921 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""权限系统监控API
提供权限系统的监控数据、访问日志、趋势分析和告警信息。
"""
from __future__ import annotations
import uuid
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),
},
}