From 668e719b6d080765aaeab4ffec1fcae63456fa25 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 30 Jun 2026 10:43:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=B3=BB=E7=BB=9F=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E8=AF=84=E5=88=86=E4=B8=8E=E7=BC=93=E5=AD=98=E7=9B=91?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/permission_monitoring.py | 92 +++++++++++--- backend/app/core/permission_cache.py | 15 +++ backend/app/core/permission_monitor.py | 74 +++++++++-- .../services/permission_metric_aggregator.py | 6 +- backend/tests/test_permission_monitoring.py | 18 +++ .../tests/test_permission_monitoring_api.py | 34 ++++- .../components/PermissionMonitoring.test.ts | 29 ++++- .../src/components/PermissionMonitoring.vue | 116 +++++++++++++++++- frontend/src/types/api.ts | 36 +++++- frontend/src/views/Login.vue | 34 +++++ 10 files changed, 412 insertions(+), 42 deletions(-) diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index f219cca2..29a2d667 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -15,7 +15,11 @@ 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 -from app.core.permission_monitor import get_permission_monitor +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 @@ -78,7 +82,7 @@ async def get_permission_metrics( row = result.one() total = row.total_checks or 0 monitor = get_permission_monitor() - cache_metrics = monitor.metrics.cache_metrics.to_dict() + cache_metrics = monitor.metrics.cache_metrics.window_stats() return { "window_hours": hours, "check_metrics": { @@ -178,33 +182,87 @@ async def permission_system_health( deny_rate = round(row.denied / total * 100, 2) if total else 0 monitor = get_permission_monitor() - cache_metrics = monitor.metrics.cache_metrics + 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 + ] - health_score = 100 - issues = [] + 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 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("缓存命中率过低") + 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": max(0, health_score), + "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(), } diff --git a/backend/app/core/permission_cache.py b/backend/app/core/permission_cache.py index 9e5445e7..3c693b4f 100644 --- a/backend/app/core/permission_cache.py +++ b/backend/app/core/permission_cache.py @@ -44,8 +44,12 @@ class PermissionCache: if cache_key in self._member_role_cache: cached_role, timestamp = self._member_role_cache[cache_key] if not self._is_expired(timestamp, ttl): + from app.core.permission_monitor import get_permission_monitor + get_permission_monitor().record_cache_hit("member_roles") return cached_role + from app.core.permission_monitor import get_permission_monitor + get_permission_monitor().record_cache_miss("member_roles") membership = await member_crud.get_member(db, study_id, user_id) role = membership.role_in_study if membership and membership.is_active else None @@ -102,6 +106,17 @@ class PermissionCache: self._member_role_cache.clear() def get_cache_stats(self) -> dict[str, Any]: + now = time.time() + self._project_permissions_cache = { + key: value + for key, value in self._project_permissions_cache.items() + if now - value[1] <= self.default_ttl + } + self._member_role_cache = { + key: value + for key, value in self._member_role_cache.items() + if now - value[1] <= self.default_ttl + } return { "project_permissions_count": len(self._project_permissions_cache), "member_role_count": len(self._member_role_cache), diff --git a/backend/app/core/permission_monitor.py b/backend/app/core/permission_monitor.py index 3beeb589..b855afc9 100644 --- a/backend/app/core/permission_monitor.py +++ b/backend/app/core/permission_monitor.py @@ -8,13 +8,15 @@ permission_access_logs 表中,通过 /metrics 端点实时聚合查询。 from __future__ import annotations import time +from collections import deque from dataclasses import dataclass, field from typing import Any from app.core.permission_cache import get_permission_cache -CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10 +CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 50 +CACHE_METRICS_WINDOW_SECONDS = 3600 @dataclass @@ -25,6 +27,11 @@ class CacheMetrics: cache_hits: int = 0 cache_misses: int = 0 cache_invalidations: int = 0 + _access_events: deque[tuple[float, str, bool]] = field( + default_factory=deque, + repr=False, + ) + _invalidation_events: deque[float] = field(default_factory=deque, repr=False) @property def hit_rate(self) -> float: @@ -48,6 +55,53 @@ class CacheMetrics: "miss_rate": round(self.miss_rate, 2), } + def record_access(self, cache_name: str, *, hit: bool) -> None: + self.total_accesses += 1 + if hit: + self.cache_hits += 1 + else: + self.cache_misses += 1 + self._access_events.append((time.time(), cache_name, hit)) + + def record_invalidation(self) -> None: + self.cache_invalidations += 1 + self._invalidation_events.append(time.time()) + + def window_stats(self, seconds: int = CACHE_METRICS_WINDOW_SECONDS) -> dict[str, Any]: + cutoff = time.time() - seconds + while self._access_events and self._access_events[0][0] < cutoff: + self._access_events.popleft() + while self._invalidation_events and self._invalidation_events[0] < cutoff: + self._invalidation_events.popleft() + + def summarize(cache_name: str | None = None) -> dict[str, Any]: + events = [ + event for event in self._access_events + if cache_name is None or event[1] == cache_name + ] + hits = sum(event[2] for event in events) + total = len(events) + misses = total - hits + return { + "total_accesses": total, + "cache_hits": hits, + "cache_misses": misses, + "cache_invalidations": len(self._invalidation_events), + "hit_rate": round(hits / total * 100, 2) if total else 0.0, + "miss_rate": round(misses / total * 100, 2) if total else 0.0, + } + + stats = summarize() + stats.update({ + "window_seconds": seconds, + "scope": "sliding_window", + "by_cache": { + "project_permissions": summarize("project_permissions"), + "member_roles": summarize("member_roles"), + }, + }) + return stats + @dataclass class PermissionSystemMetrics: @@ -70,16 +124,14 @@ class PermissionMonitor: self.metrics = PermissionSystemMetrics() self._alerts: list[dict[str, Any]] = [] - def record_cache_hit(self) -> None: - self.metrics.cache_metrics.total_accesses += 1 - self.metrics.cache_metrics.cache_hits += 1 + def record_cache_hit(self, cache_name: str = "project_permissions") -> None: + self.metrics.cache_metrics.record_access(cache_name, hit=True) - def record_cache_miss(self) -> None: - self.metrics.cache_metrics.total_accesses += 1 - self.metrics.cache_metrics.cache_misses += 1 + def record_cache_miss(self, cache_name: str = "project_permissions") -> None: + self.metrics.cache_metrics.record_access(cache_name, hit=False) def record_cache_invalidation(self) -> None: - self.metrics.cache_metrics.cache_invalidations += 1 + self.metrics.cache_metrics.record_invalidation() def record_error_alert(self, error: Exception) -> None: self._add_alert( @@ -119,12 +171,14 @@ class PermissionMonitor: cache = get_permission_cache() return { "cache_items": cache.get_cache_stats(), - "cache_metrics": self.metrics.cache_metrics.to_dict(), + "cache_metrics": self.metrics.cache_metrics.window_stats(), + "cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(), } def get_metrics(self) -> dict[str, Any]: return { - "cache_metrics": self.metrics.cache_metrics.to_dict(), + "cache_metrics": self.metrics.cache_metrics.window_stats(), + "cache_metrics_lifetime": self.metrics.cache_metrics.to_dict(), "uptime_seconds": self.metrics.uptime_seconds, } diff --git a/backend/app/services/permission_metric_aggregator.py b/backend/app/services/permission_metric_aggregator.py index 112c5c4e..af42278f 100644 --- a/backend/app/services/permission_metric_aggregator.py +++ b/backend/app/services/permission_metric_aggregator.py @@ -40,7 +40,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None: from app.core.permission_monitor import get_permission_monitor monitor = get_permission_monitor() - cache_metrics = monitor.metrics.cache_metrics + cache_metrics = monitor.metrics.cache_metrics.window_stats(3600) snapshot = PermissionMetricSnapshot( id=uuid.uuid4(), @@ -50,8 +50,8 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None: denied_checks=row.denied, avg_elapsed_ms=float(row.avg_ms), max_elapsed_ms=float(row.max_ms), - cache_hits=cache_metrics.cache_hits, - cache_misses=cache_metrics.cache_misses, + cache_hits=cache_metrics["cache_hits"], + cache_misses=cache_metrics["cache_misses"], error_count=0, ) session.add(snapshot) diff --git a/backend/tests/test_permission_monitoring.py b/backend/tests/test_permission_monitoring.py index 0023569b..9a8cc130 100644 --- a/backend/tests/test_permission_monitoring.py +++ b/backend/tests/test_permission_monitoring.py @@ -30,6 +30,24 @@ async def test_cache_metrics(): assert metrics.miss_rate == pytest.approx(25.0, 0.1) +@pytest.mark.asyncio +async def test_cache_metrics_one_hour_window_and_cache_types(): + monitor = PermissionMonitor() + monitor.record_cache_hit("project_permissions") + monitor.record_cache_miss("project_permissions") + monitor.record_cache_hit("member_roles") + + stats = monitor.metrics.cache_metrics.window_stats(3600) + + assert stats["total_accesses"] == 3 + assert stats["hit_rate"] == pytest.approx(66.67, 0.01) + assert stats["scope"] == "sliding_window" + assert stats["by_cache"]["project_permissions"]["total_accesses"] == 2 + assert stats["by_cache"]["project_permissions"]["hit_rate"] == 50 + assert stats["by_cache"]["member_roles"]["total_accesses"] == 1 + assert stats["by_cache"]["member_roles"]["hit_rate"] == 100 + + @pytest.mark.asyncio async def test_cache_invalidation_tracking(): """测试缓存失效追踪""" diff --git a/backend/tests/test_permission_monitoring_api.py b/backend/tests/test_permission_monitoring_api.py index b48238dd..ed2fd85d 100644 --- a/backend/tests/test_permission_monitoring_api.py +++ b/backend/tests/test_permission_monitoring_api.py @@ -258,9 +258,14 @@ async def test_permission_system_health_degraded(db_session): assert "status" in data assert "health_score" in data assert "issues" in data - assert "权限检查响应时间过长" in data["issues"] - assert "权限拒绝率过高" in data["issues"] - assert "缓存命中率过低" in data["issues"] + assert any("权限检查平均响应时间过长" in issue for issue in data["issues"]) + assert any("权限拒绝率偏高" in issue for issue in data["issues"]) + assert any("缓存命中率偏低" in issue for issue in data["issues"]) + assert data["status"] == "unhealthy" + assert data["health_score"] == 30 + assert data["score_breakdown"]["performance"]["deduction"] == 25 + assert data["score_breakdown"]["deny_rate"]["deduction"] == 20 + assert data["score_breakdown"]["cache"]["deduction"] == 25 @pytest.mark.asyncio @@ -272,10 +277,33 @@ async def test_permission_system_health_includes_metrics(db_session): data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert "last_hour" in data assert "cache_stats" in data + assert "score_breakdown" in data + assert "sample_sufficient" in data assert "total_checks" in data["last_hour"] assert "cache_metrics" in data["cache_stats"] +@pytest.mark.asyncio +async def test_permission_system_health_ignores_small_cache_sample(db_session): + """缓存样本不足时不应因为低命中率扣分""" + monitor = PermissionMonitor() + set_permission_monitor(monitor) + + for _ in range(3): + monitor.record_cache_miss() + + data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) + + assert data["score_breakdown"]["cache"]["sample_sufficient"] is False + assert data["score_breakdown"]["cache"]["deduction"] == 0 + non_cache_deductions = sum( + item["deduction"] + for name, item in data["score_breakdown"].items() + if name != "cache" + ) + assert data["health_score"] == 100 - non_cache_deductions + + @pytest.mark.asyncio async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch): """IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。""" diff --git a/frontend/src/components/PermissionMonitoring.test.ts b/frontend/src/components/PermissionMonitoring.test.ts index 9900ca0e..9febcd74 100644 --- a/frontend/src/components/PermissionMonitoring.test.ts +++ b/frontend/src/components/PermissionMonitoring.test.ts @@ -15,6 +15,17 @@ vi.mock("@/api/projectPermissions", () => ({ cache_metrics: { total_accesses: 1000, cache_hits: 850, cache_misses: 150, cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0, + window_seconds: 3600, scope: "sliding_window", + by_cache: { + project_permissions: { + total_accesses: 800, cache_hits: 700, cache_misses: 100, + cache_invalidations: 5, hit_rate: 87.5, miss_rate: 12.5, + }, + member_roles: { + total_accesses: 200, cache_hits: 150, cache_misses: 50, + cache_invalidations: 5, hit_rate: 75, miss_rate: 25, + }, + }, }, uptime_seconds: 3600, }, @@ -22,7 +33,21 @@ vi.mock("@/api/projectPermissions", () => ({ fetchPermissionHealth: vi.fn().mockResolvedValue({ data: { status: "healthy", health_score: 95, issues: [], - metrics: {}, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} }, + sample_sufficient: true, + score_breakdown: { + performance: { weight: 40, deduction: 0 }, + deny_rate: { weight: 20, deduction: 0 }, + cache: { + weight: 25, deduction: 5, sample_sufficient: true, + scope: "sliding_window", window_seconds: 3600, + }, + alerts: { weight: 15, deduction: 0 }, + }, + last_hour: { + total_checks: 30, denied_checks: 2, deny_rate: 6.67, + avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0, + }, + cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} }, }, }), fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }), @@ -94,7 +119,7 @@ describe("PermissionMonitoring.vue", () => { it("resizes the source-analysis chart when its tab becomes active", () => { const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8"); - expect(source).toContain('import { ref, computed, h, nextTick, onMounted } from "vue"'); + expect(source).toContain("onUnmounted"); expect(source).toContain("type IpLocationsExpose"); expect(source).toContain("resize: () => void | Promise"); expect(source).toContain('if (tab === "ip-locations")'); diff --git a/frontend/src/components/PermissionMonitoring.vue b/frontend/src/components/PermissionMonitoring.vue index 5c29eaa4..e969466c 100644 --- a/frontend/src/components/PermissionMonitoring.vue +++ b/frontend/src/components/PermissionMonitoring.vue @@ -44,24 +44,37 @@ +
+ + {{ item.label }} {{ item.deduction ? `-${item.deduction}` : "正常" }} + +
+
+ 近 1 小时仅 {{ health.last_hour.total_checks }} 次检查,样本不足,当前评分仅供参考 +
{{ issue }}
-
+
系统运行正常,未发现问题
-

缓存效率

+
+

缓存效率

+ 近 1 小时 +
- 命中率 + + 综合命中率({{ metrics.cache_metrics.cache_hits }}/{{ metrics.cache_metrics.total_accesses }}) + {{ metrics.cache_metrics.hit_rate.toFixed(1) }}% @@ -73,6 +86,22 @@ :show-text="false" />
+
+ + 项目权限 + {{ metrics.cache_metrics.by_cache.project_permissions.hit_rate.toFixed(1) }}% + ({{ metrics.cache_metrics.by_cache.project_permissions.cache_hits }}/{{ + metrics.cache_metrics.by_cache.project_permissions.total_accesses + }}) + + + 成员角色 + {{ metrics.cache_metrics.by_cache.member_roles.hit_rate.toFixed(1) }}% + ({{ metrics.cache_metrics.by_cache.member_roles.cache_hits }}/{{ + metrics.cache_metrics.by_cache.member_roles.total_accesses + }}) + +
缓存项目 @@ -173,7 +202,7 @@ @@ -632,6 +682,32 @@ defineExpose({ refresh }); padding: 8px 0 16px; } +.score-breakdown { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 6px; + margin: -6px 0 12px; +} + +.score-breakdown span { + padding: 3px 8px; + border-radius: 999px; + background: #f1f5f9; + color: var(--mon-muted); + font-size: 11px; +} + +.sample-warning { + margin-bottom: 10px; + padding: 9px 12px; + border-radius: 10px; + background: #fffbeb; + color: #b45309; + font-size: 12px; + text-align: center; +} + .score-inner { display: flex; flex-direction: column; @@ -699,6 +775,36 @@ defineExpose({ refresh }); gap: 14px; } +.cache-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.cache-title h3 { + margin: 0; +} + +.cache-title span { + color: var(--mon-muted); + font-size: 12px; +} + +.cache-scope-details { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.cache-scope-details span { + flex: 1 1 180px; + padding: 8px 10px; + border-radius: 8px; + background: #f8fafc; + color: var(--mon-muted); + font-size: 12px; +} + .cache-stat.primary { padding: 14px; background: #f8fafc; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ad2ee5d4..0dae874d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -238,7 +238,7 @@ export interface PermissionCheckMetrics { errors: number; } -export interface CacheMetrics { +export interface CacheMetricCounts { total_accesses: number; cache_hits: number; cache_misses: number; @@ -247,6 +247,15 @@ export interface CacheMetrics { miss_rate: number; } +export interface CacheMetrics extends CacheMetricCounts { + window_seconds: number; + scope: "sliding_window"; + by_cache: { + project_permissions: CacheMetricCounts; + member_roles: CacheMetricCounts; + }; +} + export interface PermissionMetricsResponse { check_metrics: PermissionCheckMetrics; cache_metrics: CacheMetrics; @@ -279,10 +288,33 @@ export interface HealthResponse { status: "healthy" | "degraded" | "unhealthy"; health_score: number; issues: string[]; - metrics: PermissionMetricsResponse; + sample_sufficient: boolean; + score_breakdown: { + performance: HealthScoreItem; + deny_rate: HealthScoreItem; + cache: HealthScoreItem & { + sample_sufficient: boolean; + scope: "sliding_window"; + window_seconds: number; + }; + alerts: HealthScoreItem; + }; + last_hour: { + total_checks: number; + denied_checks: number; + deny_rate: number; + avg_elapsed_ms: number; + error_alerts: number; + warning_alerts: number; + }; cache_stats: CacheStatsResponse; } +export interface HealthScoreItem { + weight: number; + deduction: number; +} + // 系统监测 - 访问日志 export interface AccessLogItem { id: string; diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 26fa4209..bf96905e 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1184,6 +1184,40 @@ const onSubmit = async () => { 响应式断点适配 ═══════════════════════ */ +/* 大屏保持 1:1 分栏,通过扩大有效内容区改善内容密度 */ +@media (min-width: 1440px) { + .login-left-brand { + flex: 1; + padding: clamp(64px, 5vw, 96px); + } + + .login-right-form { + flex: 1; + padding: clamp(64px, 6vw, 112px); + } + + .brand-slogan-block, + .brand-features-grid { + max-width: 640px; + } + + .brand-main-title { + font-size: clamp(38px, 2.4vw, 48px); + } + + .brand-sub-desc { + font-size: 16px; + } + + .feature-card { + padding: 20px 24px; + } + + .login-card-container { + max-width: 440px; + } +} + @media (max-width: 960px) { .login-left-brand { display: none;