Files
ctms/backend/app/api/v1/permission_monitoring.py
T
Cheng Zhou 747dd55225 feat(perm): 项目 PM 共管接口权限与监控并细化 PM 写权限边界
- deps 新增 list_active_pm_study_ids、is_active_project_pm 与
  require_admin_or_any_project_pm 依赖,用于把 PM 项目范围带进鉴权与
  监控。get_cra_site_scope 内部延后导入 site CRUD,避免循环依赖。
- system_permissions API 改用 PM/ADMIN 双角色入口,permissions/monitoring
  系统级权限新增 PM 配额,并细化访问日志、告警、监控指标的可见范围。
- members API 调整:项目 PM 仅可管理低于 PM 的项目角色,禁止互相
  改写或授予 PM。
- api_permissions API 增加 GET /api-permissions/me,返回当前用户在该
  项目的有效权限矩阵;保存权限矩阵时校验 PM 行为不被篡改。
- core/api_permissions:新增立项配置接口键、PM 默认拥有的监控/权限
  系统级条目,并在权限元信息中标注 PM 共享角色。
- core/project_permissions:role_has_api_permission 命中默认角色矩阵;
  replace_api_endpoint_permissions 改为部分更新且永不持久化 ADMIN/PM。
- studies setup-config 各端点改用接口级权限装饰器,与新的 setup_config
  权限键对齐。permission_monitor 新增 get_metrics 摘要供 PM 视图调用。
- 测试:新增 test_admin_pm_permissions 覆盖 PM 系统级权限、监控范围和
  成员管理边界;conftest 兼容 SA_UUID 列;权限相关用例同步移除已失效
  的 module_permission 链路。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:38:07 +08:00

766 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, 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
def _role_value(user) -> str:
if not hasattr(user, "role"):
return "ADMIN"
return user.role.value if hasattr(user.role, "value") else str(user.role)
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
if _role_value(current_user) == "ADMIN":
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, str], dict] = {}
all_ip_addresses: set[str] = set()
all_user_ids: set[uuid.UUID] = set()
total_count = 0
allowed_count = 0
denied_count = 0
for ip_address, user_id, allowed in result.all():
ip_info = resolve_ip_location(ip_address)
key = (ip_info.country, ip_info.province, ip_info.city, ip_info.isp)
bucket = buckets.setdefault(
key,
{
"country": ip_info.country,
"province": ip_info.province,
"city": ip_info.city,
"isp": ip_info.isp,
"location": ip_info.location,
"total_count": 0,
"allowed_count": 0,
"denied_count": 0,
"ip_addresses": set(),
"user_ids": set(),
},
)
bucket["total_count"] += 1
bucket["allowed_count" if allowed else "denied_count"] += 1
bucket["ip_addresses"].add(ip_address)
bucket["user_ids"].add(user_id)
total_count += 1
if allowed:
allowed_count += 1
else:
denied_count += 1
all_ip_addresses.add(ip_address)
all_user_ids.add(user_id)
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
return {
"days": days,
"summary": {
"total_count": total_count,
"allowed_count": allowed_count,
"denied_count": denied_count,
"unique_ip_count": len(all_ip_addresses),
"unique_user_count": len(all_user_ids),
},
"items": [
{
"country": item["country"],
"province": item["province"],
"city": item["city"],
"isp": item["isp"],
"location": item["location"],
"total_count": item["total_count"],
"allowed_count": item["allowed_count"],
"denied_count": item["denied_count"],
"unique_ip_count": len(item["ip_addresses"]),
"unique_user_count": len(item["user_ids"]),
}
for item in items
],
}
# ═══════════════════════════════════════════
# 新增端点:实时统计摘要(从DB获取)
# ═══════════════════════════════════════════
@router.get("/stats-summary", status_code=status.HTTP_200_OK)
async def get_stats_summary(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
) -> dict:
"""获取基于数据库的统计摘要"""
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),
},
}