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; } };