From b08e8b5ab5ae3aaa1532f003cac7460533e2f301 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 01:12:17 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=89=8D=E7=AB=AF=20API=20=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E5=8C=96=20+=20=E5=90=8E=E7=AB=AF=20Redis=20=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=BC=93=E5=AD=98=20=E2=80=94=20=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E4=BB=8E=202.26s=20=E9=99=8D=E8=87=B3=202ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - rate_limit 中间件新增 RedisAvailability 缓存 - Redis 不可用时跳过限流,30 秒冷却后再重试 - 避免 get_multiplexed_async_connection 每次请求阻塞 2 秒 前端: - plugin store schema 加载改为 Promise.allSettled 并行(原为 for...of 顺序) - 先基于 entities 渲染回退菜单,schema 加载完成后更新 - 移除 Home useEffect 中 unreadCount 依赖,消除双重 fetch - MainLayout 使用选择性 store selector 减少重渲染 --- apps/web/src/layouts/MainLayout.tsx | 4 +- apps/web/src/pages/Home.tsx | 2 +- apps/web/src/stores/plugin.ts | 32 +++++++--- .../erp-server/src/middleware/rate_limit.rs | 64 ++++++++++++++++++- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 3fd7b43..d802ac3 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -178,7 +178,9 @@ const SidebarSubMenu = memo(function SidebarSubMenu({ export default function MainLayout({ children }: { children: React.ReactNode }) { const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore(); const { user, logout } = useAuthStore(); - const { pluginMenuItems, pluginMenuGroups, fetchPlugins } = usePluginStore(); + const pluginMenuItems = usePluginStore((s) => s.pluginMenuItems); + const pluginMenuGroups = usePluginStore((s) => s.pluginMenuGroups); + const fetchPlugins = usePluginStore((s) => s.fetchPlugins); theme.useToken(); const navigate = useNavigate(); const location = useLocation(); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 3915a60..8240b41 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -147,7 +147,7 @@ export default function Home() { loadStats(); return () => { cancelled = true; }; - }, [fetchUnreadCount, unreadCount]); + }, [fetchUnreadCount]); const handleNavigate = useCallback((path: string) => { navigate(path); diff --git a/apps/web/src/stores/plugin.ts b/apps/web/src/stores/plugin.ts index 769d5b1..adf36c0 100644 --- a/apps/web/src/stores/plugin.ts +++ b/apps/web/src/stores/plugin.ts @@ -40,14 +40,30 @@ export const usePluginStore = create((set, get) => ({ const result = await listPlugins(page, 100, status); set({ plugins: result.data }); - // 预加载所有运行中插件的 schema - const schemas: Record = {}; - for (const plugin of result.data) { - if (plugin.status !== 'running' && plugin.status !== 'enabled') continue; - try { - schemas[plugin.id] = await getPluginSchema(plugin.id) as PluginSchemaResponse; - } catch { - // schema 加载失败跳过 + // 先基于 entities 生成回退菜单,确保侧边栏快速渲染 + get().refreshMenuItems(); + + // 并行加载所有运行中插件的 schema,完成后更新菜单 + const activePlugins = result.data.filter( + (p) => p.status === 'running' || p.status === 'enabled' + ); + if (activePlugins.length === 0) return; + + const entries = await Promise.allSettled( + activePlugins.map(async (plugin) => { + try { + const schema = await getPluginSchema(plugin.id) as PluginSchemaResponse; + return [plugin.id, schema] as const; + } catch { + return null; + } + }) + ); + + const schemas: Record = { ...get().schemaCache }; + for (const entry of entries) { + if (entry.status === 'fulfilled' && entry.value) { + schemas[entry.value[0]] = entry.value[1]; } } set({ schemaCache: schemas }); diff --git a/crates/erp-server/src/middleware/rate_limit.rs b/crates/erp-server/src/middleware/rate_limit.rs index 2715177..3ca9fb2 100644 --- a/crates/erp-server/src/middleware/rate_limit.rs +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -1,3 +1,6 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + use axum::body::Body; use axum::extract::State; use axum::http::{Request, StatusCode}; @@ -5,6 +8,7 @@ use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use redis::AsyncCommands; use serde::Serialize; +use tokio::sync::Mutex; use crate::state::AppState; @@ -26,6 +30,53 @@ pub struct RateLimitConfig { pub key_prefix: String, } +/// Redis 可用性状态缓存,避免重复连接失败时阻塞。 +struct RedisAvailability { + available: AtomicBool, + last_check: Mutex, +} + +impl RedisAvailability { + fn new() -> Self { + Self { + available: AtomicBool::new(true), + last_check: Mutex::new(Instant::now() - std::time::Duration::from_secs(60)), + } + } + + /// 检查是否应该尝试连接 Redis。 + /// 如果上次连接失败且冷却期未过,返回 false。 + async fn should_try(&self) -> bool { + if self.available.load(Ordering::Relaxed) { + return true; + } + let mut last = self.last_check.lock().await; + // 连接失败后冷却 30 秒再重试 + if last.elapsed() > std::time::Duration::from_secs(30) { + *last = Instant::now(); + true + } else { + false + } + } + + fn mark_ok(&self) { + self.available.store(true, Ordering::Relaxed); + } + + async fn mark_failed(&self) { + self.available.store(false, Ordering::Relaxed); + *self.last_check.lock().await = Instant::now(); + } +} + +/// 全局 Redis 可用性缓存 +static REDIS_AVAIL: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn redis_avail() -> &'static RedisAvailability { + REDIS_AVAIL.get_or_init(RedisAvailability::new) +} + /// 基于 Redis 的 IP 限流中间件。 /// /// 使用 INCR + EXPIRE 实现固定窗口计数器。 @@ -65,12 +116,23 @@ async fn apply_rate_limit( req: Request, next: Next, ) -> Response { + let avail = redis_avail(); + + // 快速跳过:Redis 不可达时直接放行 + if !avail.should_try().await { + return next.run(req).await; + } + let key = format!("rate_limit:{}:{}", prefix, identifier); let mut conn = match redis_client.get_multiplexed_async_connection().await { - Ok(c) => c, + Ok(c) => { + avail.mark_ok(); + c + } Err(e) => { tracing::warn!(error = %e, "Redis 连接失败,跳过限流"); + avail.mark_failed().await; return next.run(req).await; } };