修复(权限管理):统一 PM 系统导航与权限校验

This commit is contained in:
Cheng Zhou
2026-07-10 22:12:59 +08:00
parent f11a5c84d9
commit f2fa10c178
47 changed files with 4071 additions and 1555 deletions
+343 -64
View File
@@ -602,6 +602,7 @@ async def get_access_logs(
end_time: Optional[datetime] = Query(None),
client_ip: Optional[str] = Query(None),
client_type: Optional[str] = Query(None),
event_type: str = Query("all", pattern="^(all|permission)$"),
keyword: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
@@ -660,7 +661,14 @@ async def get_access_logs(
keyword_conditions.append(PermissionAccessLog.ip_address.in_(location_ips))
conditions.append(or_(*keyword_conditions))
include_security_logs = scope.is_admin and study_id is None and role is None and endpoint_key is None and allowed is not True
include_security_logs = (
event_type != "permission"
and scope.is_admin
and study_id is None
and role is None
and endpoint_key is None
and allowed is not True
)
event_conditions = list(conditions)
if include_security_logs:
matching_security_event = select(SecurityAccessLog.id).where(
@@ -1118,81 +1126,352 @@ async def get_trends(
_=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]
bucket_delta, bucket_count = _trend_bucket_config(period)
current_bucket = _trend_bucket_time(now, period)
start_time = current_bucket - bucket_delta * (bucket_count - 1)
current_duration = now - start_time
previous_end = start_time
previous_start = previous_end - current_duration
# 先尝试从快照表获取
snapshot_query = (
select(PermissionMetricSnapshot)
.where(PermissionMetricSnapshot.bucket_time >= start_time)
.order_by(PermissionMetricSnapshot.bucket_time)
log_buckets = await _load_trend_log_buckets(db, scope, start_time, now, period)
cache_buckets, current_cache, previous_cache = await _load_trend_cache_buckets(
db,
scope,
previous_start,
previous_end,
start_time,
now,
period,
)
result = await db.execute(snapshot_query)
snapshots = result.scalars().all()
summary = await _load_trend_summary(db, scope, start_time, now)
previous_summary = await _load_trend_summary(db, scope, previous_start, previous_end)
summary.update(current_cache)
previous_summary.update(previous_cache)
previous_summary.pop("observed_end_at", None)
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))
data_points = []
for index in range(bucket_count):
bucket_time = start_time + bucket_delta * index
values = log_buckets.get(bucket_time, _empty_trend_bucket())
cache_values = cache_buckets.get(bucket_time, {"cache_hits": 0, "cache_misses": 0})
cache_total = int(cache_values["cache_hits"]) + int(cache_values["cache_misses"])
total = int(values["total"])
data_points.append(
{
"bucket_time": bucket_time.isoformat(),
"sample_state": "partial" if bucket_time == current_bucket else "complete",
"total_checks": total,
"allowed_checks": int(values["allowed"]),
"denied_checks": int(values["denied"]),
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / total, 2) if total else 0,
"max_elapsed_ms": round(float(values["max_ms"]), 2),
"cache_hits": int(cache_values["cache_hits"]),
"cache_misses": int(cache_values["cache_misses"]),
"cache_hit_rate": round(int(cache_values["cache_hits"]) / cache_total * 100, 1) if cache_total else 0,
"cache_sample_available": cache_total > 0,
"error_count": 0,
}
)
return {
"period": period,
"data_points": [
"generated_at": now.isoformat(),
"range": {
"start_at": start_time.isoformat(),
"end_at": now.isoformat(),
"previous_start_at": previous_start.isoformat(),
"previous_end_at": previous_end.isoformat(),
"bucket_seconds": int(bucket_delta.total_seconds()),
"bucket_count": bucket_count,
"timezone": "UTC",
"observed_end_at": summary.pop("observed_end_at"),
},
"summary": summary,
"previous_summary": previous_summary,
"attribution": await _load_trend_attribution(db, scope, start_time, now),
"data_points": data_points,
}
def _trend_bucket_config(period: str) -> tuple[timedelta, int]:
return {
"24h": (timedelta(hours=1), 24),
"7d": (timedelta(hours=6), 28),
"30d": (timedelta(days=1), 30),
}[period]
def _empty_trend_bucket() -> dict[str, float | int]:
return {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
def _merge_trend_bucket(target: dict[str, float | int], *, total: int, allowed: int, denied: int, elapsed_sum: float, max_ms: float) -> None:
target["total"] = int(target["total"]) + total
target["allowed"] = int(target["allowed"]) + allowed
target["denied"] = int(target["denied"]) + denied
target["elapsed_sum"] = float(target["elapsed_sum"]) + elapsed_sum
target["max_ms"] = max(float(target["max_ms"]), max_ms)
async def _load_trend_log_buckets(
db: AsyncSession,
scope: MonitoringScope,
start_time: datetime,
end_time: datetime,
period: str,
) -> dict[datetime, dict[str, float | int]]:
"""PostgreSQL 先按小时聚合,SQLite 测试库使用 Python 分桶。"""
buckets: dict[datetime, dict[str, float | int]] = defaultdict(_empty_trend_bucket)
dialect_name = db.get_bind().dialect.name
if dialect_name == "postgresql":
hour_bucket = func.date_trunc("hour", PermissionAccessLog.created_at).label("hour_bucket")
query = select(
hour_bucket,
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.sum(PermissionAccessLog.elapsed_ms), 0).label("elapsed_sum"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.created_at <= end_time,
).group_by(hour_bucket)
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
for row in result.all():
bucket_time = _trend_bucket_time(row.hour_bucket, period)
_merge_trend_bucket(
buckets[bucket_time],
total=int(row.total or 0),
allowed=int(row.allowed or 0),
denied=int(row.denied or 0),
elapsed_sum=float(row.elapsed_sum or 0),
max_ms=float(row.max_ms or 0),
)
return buckets
query = select(
PermissionAccessLog.created_at,
PermissionAccessLog.allowed,
PermissionAccessLog.elapsed_ms,
).where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.created_at <= end_time,
)
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
for created_at, allowed, elapsed_ms in result.all():
bucket_time = _trend_bucket_time(created_at, period)
_merge_trend_bucket(
buckets[bucket_time],
total=1,
allowed=1 if allowed else 0,
denied=0 if allowed else 1,
elapsed_sum=float(elapsed_ms or 0),
max_ms=float(elapsed_ms or 0),
)
return buckets
def _percentile(values: list[float], percentile: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
position = (len(ordered) - 1) * percentile
lower = int(position)
upper = min(lower + 1, len(ordered) - 1)
fraction = position - lower
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
async def _load_trend_summary(
db: AsyncSession,
scope: MonitoringScope,
start_time: datetime,
end_time: datetime,
) -> dict:
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.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow"),
func.count(func.distinct(PermissionAccessLog.study_id)).label("studies"),
func.count(func.distinct(PermissionAccessLog.user_id)).label("users"),
func.count(func.distinct(PermissionAccessLog.endpoint_key)).label("endpoints"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
func.max(PermissionAccessLog.created_at).label("observed_end_at"),
).where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.created_at <= end_time,
)
row = (await db.execute(_apply_monitoring_scope_to_log_query(query, scope))).one()
if db.get_bind().dialect.name == "postgresql":
percentile_query = select(
func.percentile_cont(0.95).within_group(PermissionAccessLog.elapsed_ms)
).where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.created_at <= end_time,
)
percentile_result = await db.execute(
_apply_monitoring_scope_to_log_query(percentile_query, scope)
)
p95_ms = float(percentile_result.scalar() or 0)
else:
elapsed_query = select(PermissionAccessLog.elapsed_ms).where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.created_at <= end_time,
)
elapsed_result = await db.execute(
_apply_monitoring_scope_to_log_query(elapsed_query, scope)
)
p95_ms = _percentile(
[float(value) for value in elapsed_result.scalars().all()],
0.95,
)
total = int(row.total or 0)
denied = int(row.denied or 0)
return {
"total_checks": total,
"allowed_checks": int(row.allowed or 0),
"denied_checks": denied,
"deny_rate": round(denied / total * 100, 2) if total else 0,
"avg_elapsed_ms": round(float(row.avg_ms or 0), 2),
"p95_elapsed_ms": round(p95_ms, 2),
"max_elapsed_ms": round(float(row.max_ms or 0), 2),
"slow_check_count": int(row.slow or 0),
"active_study_count": int(row.studies or 0),
"active_user_count": int(row.users or 0),
"active_endpoint_count": int(row.endpoints or 0),
"cache_hits": 0,
"cache_misses": 0,
"cache_hit_rate": 0,
"cache_sample_available": False,
"observed_end_at": row.observed_end_at.isoformat() if row.observed_end_at else None,
}
async def _load_trend_cache_buckets(
db: AsyncSession,
scope: MonitoringScope,
previous_start: datetime,
previous_end: datetime,
current_start: datetime,
current_end: datetime,
period: str,
) -> tuple[dict[datetime, dict[str, int]], dict, dict]:
empty_summary = {"cache_hits": 0, "cache_misses": 0, "cache_hit_rate": 0, "cache_sample_available": False}
if not scope.is_admin:
return {}, dict(empty_summary), dict(empty_summary)
result = await db.execute(
select(PermissionMetricSnapshot).where(
PermissionMetricSnapshot.bucket_time >= previous_start,
PermissionMetricSnapshot.bucket_time <= current_end,
)
)
buckets: dict[datetime, dict[str, int]] = defaultdict(lambda: {"cache_hits": 0, "cache_misses": 0})
current_counts = {"cache_hits": 0, "cache_misses": 0}
previous_counts = {"cache_hits": 0, "cache_misses": 0}
for snapshot in result.scalars().all():
snapshot_time = snapshot.bucket_time
if snapshot_time.tzinfo is None:
snapshot_time = snapshot_time.replace(tzinfo=timezone.utc)
if snapshot_time >= current_start:
current_counts["cache_hits"] += int(snapshot.cache_hits or 0)
current_counts["cache_misses"] += int(snapshot.cache_misses or 0)
bucket = buckets[_trend_bucket_time(snapshot_time, period)]
bucket["cache_hits"] += int(snapshot.cache_hits or 0)
bucket["cache_misses"] += int(snapshot.cache_misses or 0)
elif snapshot_time < previous_end:
previous_counts["cache_hits"] += int(snapshot.cache_hits or 0)
previous_counts["cache_misses"] += int(snapshot.cache_misses or 0)
def serialize(counts: dict[str, int]) -> dict:
total = counts["cache_hits"] + counts["cache_misses"]
return {
**counts,
"cache_hit_rate": round(counts["cache_hits"] / total * 100, 2) if total else 0,
"cache_sample_available": total > 0,
}
return dict(buckets), serialize(current_counts), serialize(previous_counts)
async def _load_trend_attribution(
db: AsyncSession,
scope: MonitoringScope,
start_time: datetime,
end_time: datetime,
) -> dict:
base_conditions = (
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.created_at <= end_time,
)
denied_query = select(
PermissionAccessLog.endpoint_key,
PermissionAccessLog.role,
func.count().label("denied_count"),
).where(*base_conditions, PermissionAccessLog.allowed.is_(False)).group_by(
PermissionAccessLog.endpoint_key,
PermissionAccessLog.role,
).order_by(desc("denied_count")).limit(5)
denied_rows = (await db.execute(_apply_monitoring_scope_to_log_query(denied_query, scope))).all()
slow_query = select(
PermissionAccessLog.endpoint_key,
func.count().label("sample_count"),
func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow_count"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(*base_conditions).group_by(PermissionAccessLog.endpoint_key).order_by(
desc("slow_count"), desc("avg_ms"), desc("sample_count")
).limit(5)
slow_rows = (await db.execute(_apply_monitoring_scope_to_log_query(slow_query, scope))).all()
client_type = func.coalesce(PermissionAccessLog.client_type, "unknown")
client_version = func.coalesce(PermissionAccessLog.client_version, "-")
client_platform = func.coalesce(PermissionAccessLog.client_platform, "-")
client_query = select(
client_type.label("client_type"),
client_version.label("client_version"),
client_platform.label("client_platform"),
func.count().label("total_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
).where(*base_conditions).group_by(client_type, client_version, client_platform).order_by(
desc("total_count")
).limit(5)
client_rows = (await db.execute(_apply_monitoring_scope_to_log_query(client_query, scope))).all()
return {
"top_denied": [
{"endpoint_key": row.endpoint_key, "role": row.role, "denied_count": int(row.denied_count)}
for row in denied_rows
],
"top_slow": [
{
"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,
"endpoint_key": row.endpoint_key,
"sample_count": int(row.sample_count),
"slow_count": int(row.slow_count),
"avg_elapsed_ms": round(float(row.avg_ms), 2),
"max_elapsed_ms": round(float(row.max_ms), 2),
}
for bucket, values in sorted(buckets.items())
for row in slow_rows
],
"client_breakdown": [
{
"client_type": row.client_type,
"client_version": row.client_version,
"client_platform": row.client_platform,
"total_count": int(row.total_count),
"denied_count": int(row.denied_count),
"deny_rate": round(int(row.denied_count) / int(row.total_count) * 100, 2) if row.total_count else 0,
"avg_elapsed_ms": round(float(row.avg_ms), 2),
}
for row in client_rows
],
}