优化系统健康评分与缓存监测

This commit is contained in:
Cheng Zhou
2026-06-30 10:43:24 +08:00
parent b25055775e
commit 668e719b6d
10 changed files with 412 additions and 42 deletions
+75 -17
View File
@@ -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(),
}
+15
View File
@@ -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),
+64 -10
View File
@@ -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,
}
@@ -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)
@@ -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():
"""测试缓存失效追踪"""
@@ -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 数和访问用户数。"""
@@ -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<void>");
expect(source).toContain('if (tab === "ip-locations")');
@@ -44,24 +44,37 @@
</template>
</el-progress>
</div>
<div class="score-breakdown">
<span v-for="item in healthScoreItems" :key="item.label">
{{ item.label }} {{ item.deduction ? `-${item.deduction}` : "正常" }}
</span>
</div>
<div v-if="!health.sample_sufficient" class="sample-warning">
1 小时仅 {{ health.last_hour.total_checks }} 次检查样本不足当前评分仅供参考
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
<span class="issue-dot" />
<span>{{ issue }}</span>
</div>
</div>
<div v-else class="health-ok">
<div v-else-if="health.sample_sufficient" class="health-ok">
<span class="ok-icon">&#10003;</span>
<span>系统运行正常未发现问题</span>
</div>
</div>
<div v-if="metrics" class="cache-card">
<div class="cache-title">
<h3>缓存效率</h3>
<span> 1 小时</span>
</div>
<div class="cache-stats">
<div class="cache-stat primary">
<div class="cache-stat-header">
<span class="stat-label">命中率</span>
<span class="stat-label">
综合命中率{{ metrics.cache_metrics.cache_hits }}/{{ metrics.cache_metrics.total_accesses }}
</span>
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
</strong>
@@ -73,6 +86,22 @@
:show-text="false"
/>
</div>
<div class="cache-scope-details">
<span>
项目权限
{{ 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
}}
</span>
<span>
成员角色
{{ 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
}}
</span>
</div>
<div class="cache-stat-row">
<div class="cache-stat-mini">
<span class="stat-label">缓存项目</span>
@@ -173,7 +202,7 @@
</template>
<script setup lang="ts">
import { ref, computed, h, nextTick, onMounted } from "vue";
import { ref, computed, h, nextTick, onMounted, onUnmounted } from "vue";
import type {
PermissionMetricsResponse,
AlertsResponse,
@@ -304,6 +333,16 @@ const summaryCards = computed(() => {
];
});
const healthScoreItems = computed(() => {
if (!health.value) return [];
return [
{ label: "性能", deduction: health.value.score_breakdown.performance.deduction },
{ label: "拒绝率", deduction: health.value.score_breakdown.deny_rate.deduction },
{ label: "缓存", deduction: health.value.score_breakdown.cache.deduction },
{ label: "告警", deduction: health.value.score_breakdown.alerts.deduction },
];
});
const loadOverviewData = async () => {
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
fetchPermissionMetrics(),
@@ -335,7 +374,18 @@ const refresh = () => {
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
};
onMounted(loadOverviewData);
let overviewRefreshTimer: number | undefined;
onMounted(() => {
loadOverviewData();
overviewRefreshTimer = window.setInterval(() => {
if (activeTab.value === "overview") loadOverviewData();
}, 60_000);
});
onUnmounted(() => {
if (overviewRefreshTimer !== undefined) window.clearInterval(overviewRefreshTimer);
});
defineExpose({ refresh });
</script>
@@ -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;
+34 -2
View File
@@ -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;
+34
View File
@@ -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;