diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go index 9329d58a..db4bf8bd 100644 --- a/atsf_server/common/constants.go +++ b/atsf_server/common/constants.go @@ -72,19 +72,19 @@ var ( // All duration's unit is seconds // Shouldn't larger then RateLimitKeyExpirationDuration var ( - GlobalApiRateLimitNum = 60 + GlobalApiRateLimitNum = 300 GlobalApiRateLimitDuration int64 = 3 * 60 - GlobalWebRateLimitNum = 60 + GlobalWebRateLimitNum = 300 GlobalWebRateLimitDuration int64 = 3 * 60 - UploadRateLimitNum = 10 + UploadRateLimitNum = 50 UploadRateLimitDuration int64 = 60 - DownloadRateLimitNum = 10 + DownloadRateLimitNum = 50 DownloadRateLimitDuration int64 = 60 - CriticalRateLimitNum = 20 + CriticalRateLimitNum = 100 CriticalRateLimitDuration int64 = 20 * 60 ) diff --git a/atsf_server/controller/option.go b/atsf_server/controller/option.go index 23e03ca8..1d7638b7 100644 --- a/atsf_server/controller/option.go +++ b/atsf_server/controller/option.go @@ -4,11 +4,37 @@ import ( "atsflare/common" "atsflare/model" "encoding/json" + "fmt" "github.com/gin-gonic/gin" "net/http" + "strconv" "strings" ) +func validateRateLimitOption(key string, value string) error { + maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds()) + + switch key { + case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "UploadRateLimitNum", "DownloadRateLimitNum", "CriticalRateLimitNum": + intValue, err := strconv.Atoi(value) + if err != nil || intValue <= 0 { + return fmt.Errorf("%s 必须为大于 0 的整数", key) + } + return nil + case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "UploadRateLimitDuration", "DownloadRateLimitDuration", "CriticalRateLimitDuration": + intValue, err := strconv.Atoi(value) + if err != nil || intValue <= 0 { + return fmt.Errorf("%s 必须为大于 0 的整数秒", key) + } + if intValue > maxDurationSeconds { + return fmt.Errorf("%s 不能大于 %d 秒", key, maxDurationSeconds) + } + return nil + default: + return nil + } +} + // GetOptions godoc // @Summary List editable options // @Tags Options @@ -81,6 +107,13 @@ func UpdateOption(c *gin.Context) { return } } + if err = validateRateLimitOption(option.Key, option.Value); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } err = model.UpdateOption(option.Key, option.Value) if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/atsf_server/model/option.go b/atsf_server/model/option.go index f1d903fa..aa21c5e4 100644 --- a/atsf_server/model/option.go +++ b/atsf_server/model/option.go @@ -55,6 +55,16 @@ func InitOptionMap() { common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval) common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds())) common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo + common.OptionMap["GlobalApiRateLimitNum"] = strconv.Itoa(common.GlobalApiRateLimitNum) + common.OptionMap["GlobalApiRateLimitDuration"] = strconv.FormatInt(common.GlobalApiRateLimitDuration, 10) + common.OptionMap["GlobalWebRateLimitNum"] = strconv.Itoa(common.GlobalWebRateLimitNum) + common.OptionMap["GlobalWebRateLimitDuration"] = strconv.FormatInt(common.GlobalWebRateLimitDuration, 10) + common.OptionMap["UploadRateLimitNum"] = strconv.Itoa(common.UploadRateLimitNum) + common.OptionMap["UploadRateLimitDuration"] = strconv.FormatInt(common.UploadRateLimitDuration, 10) + common.OptionMap["DownloadRateLimitNum"] = strconv.Itoa(common.DownloadRateLimitNum) + common.OptionMap["DownloadRateLimitDuration"] = strconv.FormatInt(common.DownloadRateLimitDuration, 10) + common.OptionMap["CriticalRateLimitNum"] = strconv.Itoa(common.CriticalRateLimitNum) + common.OptionMap["CriticalRateLimitDuration"] = strconv.FormatInt(common.CriticalRateLimitDuration, 10) common.OptionMapRWMutex.Unlock() options, _ := AllOption() for _, option := range options { @@ -165,5 +175,45 @@ func updateOptionMap(key string, value string) { if value != "" { common.AgentUpdateRepo = value } + case "GlobalApiRateLimitNum": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.GlobalApiRateLimitNum = v + } + case "GlobalApiRateLimitDuration": + if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { + common.GlobalApiRateLimitDuration = v + } + case "GlobalWebRateLimitNum": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.GlobalWebRateLimitNum = v + } + case "GlobalWebRateLimitDuration": + if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { + common.GlobalWebRateLimitDuration = v + } + case "UploadRateLimitNum": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.UploadRateLimitNum = v + } + case "UploadRateLimitDuration": + if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { + common.UploadRateLimitDuration = v + } + case "DownloadRateLimitNum": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.DownloadRateLimitNum = v + } + case "DownloadRateLimitDuration": + if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { + common.DownloadRateLimitDuration = v + } + case "CriticalRateLimitNum": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.CriticalRateLimitNum = v + } + case "CriticalRateLimitDuration": + if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { + common.CriticalRateLimitDuration = v + } } } diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index 8eb4b387..74b52c2a 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -17,6 +17,75 @@ import ( "time" ) +func TestPhase2RateLimitOptionsHotReload(t *testing.T) { + gin.SetMode(gin.TestMode) + common.RedisEnabled = false + setupTestDB(t) + + oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum + oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration + oldCriticalRateLimitNum := common.CriticalRateLimitNum + oldCriticalRateLimitDuration := common.CriticalRateLimitDuration + t.Cleanup(func() { + common.GlobalApiRateLimitNum = oldGlobalApiRateLimitNum + common.GlobalApiRateLimitDuration = oldGlobalApiRateLimitDuration + common.CriticalRateLimitNum = oldCriticalRateLimitNum + common.CriticalRateLimitDuration = oldCriticalRateLimitDuration + }) + + engine := gin.New() + engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + router.SetApiRouter(engine) + + token := prepareRootToken(t) + + performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + "key": "GlobalApiRateLimitNum", + "value": "450", + }) + performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + "key": "GlobalApiRateLimitDuration", + "value": "240", + }) + performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + "key": "CriticalRateLimitNum", + "value": "150", + }) + performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + "key": "CriticalRateLimitDuration", + "value": "900", + }) + + if common.GlobalApiRateLimitNum != 450 { + t.Fatalf("expected GlobalApiRateLimitNum to be hot reloaded, got %d", common.GlobalApiRateLimitNum) + } + if common.GlobalApiRateLimitDuration != 240 { + t.Fatalf("expected GlobalApiRateLimitDuration to be hot reloaded, got %d", common.GlobalApiRateLimitDuration) + } + if common.CriticalRateLimitNum != 150 { + t.Fatalf("expected CriticalRateLimitNum to be hot reloaded, got %d", common.CriticalRateLimitNum) + } + if common.CriticalRateLimitDuration != 900 { + t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration) + } + + resp := performJSONRequest(t, engine, token, http.MethodGet, "/api/option/", nil) + var options []model.Option + decodeResponseData(t, resp, &options) + + optionMap := make(map[string]string, len(options)) + for _, option := range options { + optionMap[option.Key] = option.Value + } + + if optionMap["GlobalApiRateLimitNum"] != "450" { + t.Fatalf("expected option payload to include GlobalApiRateLimitNum=450, got %q", optionMap["GlobalApiRateLimitNum"]) + } + if optionMap["CriticalRateLimitDuration"] != "900" { + t.Fatalf("expected option payload to include CriticalRateLimitDuration=900, got %q", optionMap["CriticalRateLimitDuration"]) + } +} + func TestPhase2AgentLifecycle(t *testing.T) { gin.SetMode(gin.TestMode) common.RedisEnabled = false diff --git a/atsf_server/web/components/layout/dashboard-sidebar.tsx b/atsf_server/web/components/layout/dashboard-sidebar.tsx index af9f2936..b3849076 100644 --- a/atsf_server/web/components/layout/dashboard-sidebar.tsx +++ b/atsf_server/web/components/layout/dashboard-sidebar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -100,36 +101,40 @@ function SidebarNavItem({ item, currentPath, isSidebarCollapsed, + forceExpanded, + onNavigate, depth = 0, }: { item: NavigationItem; currentPath: string; isSidebarCollapsed: boolean; + forceExpanded?: boolean; + onNavigate?: () => void; depth?: number; }) { const active = isNavigationItemActive(currentPath, item); const hasChildren = Boolean(item.children?.length); + const showLabel = forceExpanded || !isSidebarCollapsed; return (
0 && 'ml-3 rounded-xl py-2.5', + 'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors', + depth > 0 && 'ml-3 rounded-xl', active ? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]' : 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]', )} > - + - {!isSidebarCollapsed ? ( - {item.label} - ) : null} + {showLabel ? {item.label} : null} - {!isSidebarCollapsed && hasChildren ? ( + {showLabel && hasChildren ? (
{item.children?.map((child) => ( ))} @@ -146,50 +153,101 @@ function SidebarNavItem({ ); } -export function DashboardSidebar() { - const pathname = usePathname(); - const currentPath = pathname ?? '/'; - const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed); - - return ( - + + + + {showLabel ? ( +
+ 总览页现在承接各模块摘要,侧栏只保留稳定导航。 +
+ ) : null} +
+ ); +} + +export function DashboardSidebar() { + const pathname = usePathname(); + const currentPath = pathname ?? '/'; + const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed); + const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen); + const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen); + + useEffect(() => { + setMobileSidebarOpen(false); + }, [currentPath, setMobileSidebarOpen]); + + return ( + <> +
setMobileSidebarOpen(false)} + aria-hidden='true' + /> + + + + + ); } diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx index ae3b759d..70a3695e 100644 --- a/atsf_server/web/components/layout/dashboard-topbar.tsx +++ b/atsf_server/web/components/layout/dashboard-topbar.tsx @@ -1,73 +1,119 @@ -'use client'; - +'use client'; + +import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; import { useAuth } from '@/components/providers/auth-provider'; import { ThemeToggle } from '@/components/ui/theme-toggle'; -import { getCurrentNavigationItem } from '@/lib/utils/navigation'; -import { publicEnv } from '@/lib/env/public-env'; -import { useAppShellStore } from '@/store/app-shell'; -import { usePathname } from 'next/navigation'; - -export function DashboardTopbar() { - const router = useRouter(); - const pathname = usePathname(); - const currentPath = pathname ?? '/'; - const { logout, user } = useAuth(); - const toggleSidebar = useAppShellStore((state) => state.toggleSidebar); - const currentItem = getCurrentNavigationItem(currentPath); - const [isLoggingOut, setIsLoggingOut] = useState(false); - - const handleLogout = async () => { - setIsLoggingOut(true); - await logout(); - router.replace('/login'); - }; - - return ( -
-
-
- -
-

当前模块

-

- {currentItem?.label ?? 'ATSFlare 控制台'} -

-
-
- -
- {user ? ( - - {user.display_name || user.username} - - ) : null} - - - 静态导出模式 - - - 版本 {publicEnv.appVersion} - - -
-
-
- ); -} +import { publicEnv } from '@/lib/env/public-env'; +import { useAppShellStore } from '@/store/app-shell'; + +export function DashboardTopbar() { + const router = useRouter(); + const { logout, user } = useAuth(); + const toggleSidebar = useAppShellStore((state) => state.toggleSidebar); + const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen); + const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!isUserMenuOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (!menuRef.current?.contains(event.target as Node)) { + setIsUserMenuOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsUserMenuOpen(false); + } + }; + + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('keydown', handleEscape); + + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('keydown', handleEscape); + }; + }, [isUserMenuOpen]); + + const handleLogout = async () => { + setIsLoggingOut(true); + setIsUserMenuOpen(false); + await logout(); + router.replace('/login'); + }; + + const handleSidebarToggle = () => { + if (window.innerWidth < 1000) { + setMobileSidebarOpen(!isMobileSidebarOpen); + return; + } + + toggleSidebar(); + }; + + return ( +
+
+ + +
+ + 版本 {publicEnv.appVersion} + + +
+ + + {isUserMenuOpen ? ( +
+
+

+ {user?.display_name || user?.username || '用户'} +

+ {user?.username ? ( +

@{user.username}

+ ) : null} +
+ +
+ ) : null} +
+
+
+
+ ); +} diff --git a/atsf_server/web/components/ui/theme-toggle.tsx b/atsf_server/web/components/ui/theme-toggle.tsx index 83a16da1..03d0103e 100644 --- a/atsf_server/web/components/ui/theme-toggle.tsx +++ b/atsf_server/web/components/ui/theme-toggle.tsx @@ -1,55 +1,74 @@ -'use client'; - -import { cn } from '@/lib/utils/cn'; -import type { ThemeMode } from '@/lib/theme/theme'; -import { useTheme } from '@/components/providers/theme-provider'; - -const themeOptions: Array<{ value: ThemeMode; label: string }> = [ - { value: 'light', label: '浅色' }, - { value: 'system', label: '跟随系统' }, - { value: 'dark', label: '深色' }, -]; - -interface ThemeToggleProps { - className?: string; -} - -export function ThemeToggle({ className }: ThemeToggleProps) { - const { themeMode, resolvedTheme, setThemeMode } = useTheme(); - - return ( -
- {themeOptions.map((option) => { - const active = themeMode === option.value; - - return ( - - ); - })} -
- ); -} +'use client'; + +import { cn } from '@/lib/utils/cn'; +import { themeModes, type ThemeMode } from '@/lib/theme/theme'; +import { useTheme } from '@/components/providers/theme-provider'; + +interface ThemeToggleProps { + className?: string; +} + +export function ThemeToggle({ className }: ThemeToggleProps) { + const { themeMode, resolvedTheme, setThemeMode } = useTheme(); + const currentIndex = themeModes.indexOf(themeMode); + const nextTheme = themeModes[(currentIndex + 1) % themeModes.length] as ThemeMode; + + const icon = + themeMode === 'light' ? ( + + + + + ) : themeMode === 'dark' ? ( + + + + ) : ( + + + + + ); + + const label = + themeMode === 'light' ? '浅色模式' : themeMode === 'dark' ? '深色模式' : '跟随系统'; + + return ( + + ); +} diff --git a/atsf_server/web/features/config-versions/components/config-versions-page.tsx b/atsf_server/web/features/config-versions/components/config-versions-page.tsx index ce600d52..cfc7b3e3 100644 --- a/atsf_server/web/features/config-versions/components/config-versions-page.tsx +++ b/atsf_server/web/features/config-versions/components/config-versions-page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; @@ -28,7 +28,7 @@ import { PrimaryButton, SecondaryButton, } from '@/features/shared/components/resource-primitives'; -import { formatDateTime } from '@/lib/utils/date'; +import { formatDateTime } from '@/lib/utils/date'; const versionsQueryKey = ['config-versions']; @@ -249,21 +249,7 @@ export function ConfigVersionsPage() { }, }); - const summary = useMemo(() => { - const versions = versionsQuery.data || []; - const activeCount = versions.filter((item) => item.is_active).length; - - return [ - { label: '版本总数', value: versions.length }, - { label: '激活版本', value: activeCount }, - { - label: '最近创建时间', - value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—', - }, - ]; - }, [versionsQuery.data]); - - const handleOpenPublishPreview = async () => { + const handleOpenPublishPreview = async () => { setFeedback(null); setIsPreviewLoading(true); @@ -294,39 +280,20 @@ export function ConfigVersionsPage() { return (
- + + {isPreviewLoading ? '加载预览中...' : '预览并发布'} + + } + /> {feedback ? : null} -
- - {isPreviewLoading ? '加载预览中...' : '预览并发布'} - - } - > -
- {summary.map((item) => ( -
-

- {item.label} -

-

{item.value}

-
- ))} -
-
- - {publishPreview ? ( +
+ {publishPreview ? ( item.href !== '/' && !item.children?.length, -); + try { + const parsed = JSON.parse(rawValue); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function SummaryCard({ + title, + description, + href, + items, + status, + error, +}: { + title: string; + description: string; + href: string; + items: ReadonlyArray<{ label: string; value: string | number }>; + status: 'pending' | 'success' | 'error'; + error?: unknown; +}) { + return ( + + 进入模块 + + } + > + {status === 'pending' ? ( + + ) : status === 'error' ? ( + + ) : ( +
+ {items.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+ )} +
+ ); +} export function DashboardOverview() { + const [ + nodesQuery, + versionsQuery, + managedDomainsQuery, + certificatesQuery, + proxyRoutesQuery, + usersQuery, + ] = useQueries({ + queries: [ + { queryKey: ['dashboard', 'nodes'], queryFn: getNodes }, + { queryKey: ['dashboard', 'config-versions'], queryFn: getConfigVersions }, + { queryKey: ['dashboard', 'managed-domains'], queryFn: getManagedDomains }, + { queryKey: ['dashboard', 'tls-certificates'], queryFn: getTlsCertificates }, + { queryKey: ['dashboard', 'proxy-routes'], queryFn: getProxyRoutes }, + { + queryKey: ['dashboard', 'users'], + queryFn: async () => { + try { + return await searchUsers(''); + } catch { + return getUsers(0); + } + }, + }, + ], + }); + + const nodes = (nodesQuery.data ?? []) as NodeItem[]; + const versions = (versionsQuery.data ?? []) as ConfigVersionItem[]; + const domains = (managedDomainsQuery.data ?? []) as ManagedDomainItem[]; + const certificates = (certificatesQuery.data ?? []) as TlsCertificateItem[]; + const routes = (proxyRoutesQuery.data ?? []) as ProxyRouteItem[]; + const users = (usersQuery.data ?? []) as UserItem[]; + + const expiringCertificates = certificates.filter((item) => { + const expiresAt = new Date(item.not_after).getTime(); + return !Number.isNaN(expiresAt) && expiresAt >= Date.now() && expiresAt - Date.now() <= 30 * 24 * 60 * 60 * 1000; + }).length; + + const expiredCertificates = certificates.filter((item) => { + const expiresAt = new Date(item.not_after).getTime(); + return !Number.isNaN(expiresAt) && expiresAt < Date.now(); + }).length; + + const dashboardCards = [ + { + title: '节点摘要', + description: '集中查看在线状态、待接入节点和自动更新覆盖情况。', + href: '/node', + status: nodesQuery.status, + error: nodesQuery.error, + items: [ + { label: '节点总数', value: nodes.length }, + { label: '在线节点', value: nodes.filter((item) => item.status === 'online').length }, + { label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length }, + { label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length }, + ], + }, + { + title: '版本摘要', + description: '直接感知当前发布沉淀、激活版本和最近一次变更时间。', + href: '/config-version', + status: versionsQuery.status, + error: versionsQuery.error, + items: [ + { label: '版本总数', value: versions.length }, + { label: '激活版本', value: versions.filter((item) => item.is_active).length }, + { label: '最近创建', value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—' }, + ], + }, + { + title: '规则摘要', + description: '查看域名规则启用状态、通配符覆盖和证书绑定规模。', + href: '/managed-domain', + status: managedDomainsQuery.status, + error: managedDomainsQuery.error, + items: [ + { label: '域名规则', value: domains.length }, + { label: '已启用', value: domains.filter((item) => item.enabled).length }, + { label: '通配符规则', value: domains.filter((item) => item.domain.startsWith('*.')).length }, + { label: '已绑证书', value: domains.filter((item) => item.cert_id).length }, + ], + }, + { + title: '证书概览', + description: '快速识别证书存量、近 30 天到期风险和过期情况。', + href: '/tls-certificate', + status: certificatesQuery.status, + error: certificatesQuery.error, + items: [ + { label: '证书总数', value: certificates.length }, + { label: '30 天内到期', value: expiringCertificates }, + { label: '已过期', value: expiredCertificates }, + { label: '最近更新', value: certificates[0]?.updated_at ? formatDateTime(certificates[0].updated_at) : '—' }, + ], + }, + { + title: '发布与摘要', + description: '汇总反向代理规则规模、HTTPS 覆盖和请求头配置密度。', + href: '/proxy-route', + status: proxyRoutesQuery.status, + error: proxyRoutesQuery.error, + items: [ + { label: '规则总数', value: routes.length }, + { label: '已启用', value: routes.filter((item) => item.enabled).length }, + { label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length }, + { + label: '自定义请求头', + value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0), + }, + ], + }, + { + title: '用户概览', + description: '展示当前可管理用户池的角色和状态分布。', + href: '/user', + status: usersQuery.status, + error: usersQuery.error, + items: [ + { label: '用户总数', value: users.length }, + { label: '管理员', value: users.filter((item) => item.role >= 10).length }, + { label: '已激活', value: users.filter((item) => item.status === 1).length }, + { label: '已封禁', value: users.filter((item) => item.status !== 1).length }, + ], + }, + ] as const; + return (
} + title='控制台仪表盘' + description='首页统一承接节点、发布、域名、证书、反向代理和用户六个模块的核心总览,避免在各页面重复扫一遍摘要。' + action={} > -
- {readinessItems.map((item) => ( -
-

{item.title}

-

- {item.description} -

-
- ))} -
-
- -
- -
- {moduleLinks.map((item) => ( - -

{item.label}

-

进入 {item.label} 页面,继续完成管理与发布操作。

- - ))} +
+
+

信息架构

+

首页负责总览

+

+ 模块页保留列表、表单和具体操作,摘要统一回收到仪表盘。 +

- +
+

导航效率

+

顶栏与侧栏已精简

+

+ 顶栏只保留版本、主题切换和用户入口,侧栏宽度与按钮高度同步压缩。 +

+
+
+

响应式

+

小屏可正常展开侧栏

+

+ 小于 1000px 时切换为抽屉侧栏,不再出现按钮可点但导航不可见的问题。 +

+
+
+ - -
    -
  1. 1. 对照后端接口继续压缩页面认知负担,保持核心动作在列表页附近完成。
  2. -
  3. 2. 补齐关键页面的测试覆盖与构建验收。
  4. -
  5. 3. 清理迁移期文档和发布说明中的旧前端表述。
  6. -
-
+
+ {dashboardCards.map((card) => ( + + ))}
); diff --git a/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx b/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx index 5eafd120..57e006e8 100644 --- a/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx +++ b/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx @@ -174,16 +174,6 @@ export function ManagedDomainsPage() { [certificates], ); - const summary = useMemo( - () => [ - { label: '域名规则', value: domains.length }, - { label: '已启用', value: domains.filter((item) => item.enabled).length }, - { label: '通配符规则', value: domains.filter((item) => item.domain.startsWith('*.')).length }, - { label: '已绑证书', value: domains.filter((item) => item.cert_id).length }, - ], - [domains], - ); - const currentCertificate = watchedCertId ? certificateMap.get(Number(watchedCertId)) : null; const handleReset = () => { @@ -236,22 +226,6 @@ export function ManagedDomainsPage() { {feedback ? : null} - -
- {summary.map((item) => ( -
-

- {item.label} -

-

{item.value}

-
- ))} -
-
- item.id === selectedNode.id) ?? selectedNode; }, [nodes, selectedNode]); - const summary = useMemo(() => { - return [ - { label: '节点总数', value: nodes.length }, - { label: '在线节点', value: nodes.filter((item) => item.status === 'online').length }, - { label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length }, - { label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length }, - ]; - }, [nodes]); - const handleReset = () => { setFeedback(null); setEditingNodeId(null); @@ -377,34 +368,7 @@ export function NodesPage() { {feedback ? : null} - void queryClient.invalidateQueries({ queryKey: nodesQueryKey })} - > - 刷新列表 - - } - > -
- {summary.map((item) => ( -
-

- {item.label} -

-

{item.value}

-
- ))} -
-
- -
+
- + void queryClient.invalidateQueries({ queryKey: nodesQueryKey })} + > + 刷新列表 + + } + > {nodesQuery.isLoading ? ( ) : nodesQuery.isError ? ( diff --git a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx index 18b2a1a6..e7fdd936 100644 --- a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -339,20 +339,6 @@ export function ProxyRoutesPage() { [certificates], ); - const summary = useMemo(() => { - const routes = routesQuery.data || []; - - return [ - { label: '规则总数', value: routes.length }, - { label: '已启用', value: routes.filter((item) => item.enabled).length }, - { label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length }, - { - label: '自定义请求头', - value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0), - }, - ]; - }, [routesQuery.data]); - const handleReset = () => { setFeedback(null); setEditingRouteId(null); @@ -409,39 +395,20 @@ export function ProxyRoutesPage() { title='反代规则' description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。' action={ - - 新增规则 - + <> + publishMutation.mutate()} disabled={publishMutation.isPending}> + {publishMutation.isPending ? '发布中...' : '发布当前规则'} + + + 新增规则 + + } /> {feedback ? : null} - publishMutation.mutate()} disabled={publishMutation.isPending}> - {publishMutation.isPending ? '发布中...' : '发布当前规则'} - - } - > -
- {summary.map((item) => ( -
-

- {item.label} -

-

{item.value}

-
- ))} -
-
- - + {routesQuery.isLoading ? ( ) : routesQuery.isError ? ( diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx index 37adec8a..e9e2348a 100644 --- a/atsf_server/web/features/settings/components/settings-page.tsx +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -4,12 +4,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { marked } from 'marked'; import { useEffect, useMemo, useState } from 'react'; -import { EmptyState } from '@/components/feedback/empty-state'; -import { ErrorState } from '@/components/feedback/error-state'; -import { InlineMessage } from '@/components/feedback/inline-message'; -import { LoadingState } from '@/components/feedback/loading-state'; -import { TurnstileWidget } from '@/components/forms/turnstile-widget'; -import { useAuth } from '@/components/providers/auth-provider'; +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { TurnstileWidget } from '@/components/forms/turnstile-widget'; +import { useAuth } from '@/components/providers/auth-provider'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; @@ -74,6 +74,16 @@ const defaultOperationFields = { AgentSyncInterval: '30000', NodeOfflineThreshold: '120000', AgentUpdateRepo: 'Rain-kl/ATSFlare', + GlobalApiRateLimitNum: '300', + GlobalApiRateLimitDuration: '180', + GlobalWebRateLimitNum: '300', + GlobalWebRateLimitDuration: '180', + UploadRateLimitNum: '50', + UploadRateLimitDuration: '60', + DownloadRateLimitNum: '50', + DownloadRateLimitDuration: '60', + CriticalRateLimitNum: '100', + CriticalRateLimitDuration: '1200', ServerAddress: '', }; @@ -134,6 +144,23 @@ function formatDurationLabel(value: string) { return `${milliseconds / 1000} 秒`; } +function formatSecondsLabel(value: string) { + const seconds = Number.parseInt(value, 10); + if (Number.isNaN(seconds)) { + return value; + } + + if (seconds >= 3600 && seconds % 3600 === 0) { + return `${seconds / 3600} 小时`; + } + + if (seconds >= 60 && seconds % 60 === 0) { + return `${seconds / 60} 分钟`; + } + + return `${seconds} 秒`; +} + function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { return [ `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, @@ -157,11 +184,11 @@ export function SettingsPage() { const [operationFields, setOperationFields] = useState(defaultOperationFields); const [otherFields, setOtherFields] = useState(defaultOtherFields); const [accessToken, setAccessToken] = useState(''); - const [wechatCode, setWeChatCode] = useState(''); - const [emailAddress, setEmailAddress] = useState(''); - const [emailCode, setEmailCode] = useState(''); - const [emailTurnstileToken, setEmailTurnstileToken] = useState(''); - const [latestRelease, setLatestRelease] = useState(null); + const [wechatCode, setWeChatCode] = useState(''); + const [emailAddress, setEmailAddress] = useState(''); + const [emailCode, setEmailCode] = useState(''); + const [emailTurnstileToken, setEmailTurnstileToken] = useState(''); + const [latestRelease, setLatestRelease] = useState(null); const isRoot = (user?.role ?? 0) >= 100; @@ -257,6 +284,16 @@ export function SettingsPage() { AgentSyncInterval: optionMap.AgentSyncInterval ?? '30000', NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000', AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare', + GlobalApiRateLimitNum: optionMap.GlobalApiRateLimitNum ?? '300', + GlobalApiRateLimitDuration: optionMap.GlobalApiRateLimitDuration ?? '180', + GlobalWebRateLimitNum: optionMap.GlobalWebRateLimitNum ?? '300', + GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180', + UploadRateLimitNum: optionMap.UploadRateLimitNum ?? '50', + UploadRateLimitDuration: optionMap.UploadRateLimitDuration ?? '60', + DownloadRateLimitNum: optionMap.DownloadRateLimitNum ?? '50', + DownloadRateLimitDuration: optionMap.DownloadRateLimitDuration ?? '60', + CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100', + CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200', ServerAddress: optionMap.ServerAddress ?? publicStatusQuery.data?.server_address ?? '', }); @@ -357,22 +394,22 @@ export function SettingsPage() { }); }; - const handleEmailVerification = () => { - if (!emailAddress.trim()) { - setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' }); - return; - } - - if (publicStatusQuery.data?.turnstile_check && !emailTurnstileToken) { - setFeedback({ tone: 'info', message: '请先完成人机验证。' }); - return; - } - - void runBusyAction('email-send', async () => { - await sendEmailVerification(emailAddress.trim(), emailTurnstileToken || undefined); - setFeedback({ tone: 'success', message: '验证码已发送,请检查邮箱。' }); - }); - }; + const handleEmailVerification = () => { + if (!emailAddress.trim()) { + setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' }); + return; + } + + if (publicStatusQuery.data?.turnstile_check && !emailTurnstileToken) { + setFeedback({ tone: 'info', message: '请先完成人机验证。' }); + return; + } + + void runBusyAction('email-send', async () => { + await sendEmailVerification(emailAddress.trim(), emailTurnstileToken || undefined); + setFeedback({ tone: 'success', message: '验证码已发送,请检查邮箱。' }); + }); + }; const handleBindEmail = () => { if (!emailAddress.trim() || !emailCode.trim()) { @@ -598,46 +635,46 @@ export function SettingsPage() { 当前状态:{profile.email ? `已绑定 ${profile.email}` : '未绑定'}

-
- - setEmailAddress(event.target.value)} - placeholder='请输入邮箱地址' - /> - - - setEmailCode(event.target.value)} - placeholder='请输入邮箱验证码' - /> - - {publicStatus.turnstile_check ? ( - publicStatus.turnstile_site_key ? ( - setEmailTurnstileToken(token)} - onExpire={() => setEmailTurnstileToken('')} - onError={() => setEmailTurnstileToken('')} - /> - ) : ( - - ) - ) : null} -
- - {busyKey === 'email-send' ? '发送中...' : '发送验证码'} - - - {busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'} - -
-
-
+
+ + setEmailAddress(event.target.value)} + placeholder='请输入邮箱地址' + /> + + + setEmailCode(event.target.value)} + placeholder='请输入邮箱验证码' + /> + + {publicStatus.turnstile_check ? ( + publicStatus.turnstile_site_key ? ( + setEmailTurnstileToken(token)} + onExpire={() => setEmailTurnstileToken('')} + onError={() => setEmailTurnstileToken('')} + /> + ) : ( + + ) + ) : null} +
+ + {busyKey === 'email-send' ? '发送中...' : '发送验证码'} + + + {busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'} + +
+
+
@@ -766,6 +803,167 @@ export function SettingsPage() {
+ + void runBusyAction('operation-rate-limit', async () => { + const entries = [ + ['GlobalApiRateLimitNum', operationFields.GlobalApiRateLimitNum], + ['GlobalApiRateLimitDuration', operationFields.GlobalApiRateLimitDuration], + ['GlobalWebRateLimitNum', operationFields.GlobalWebRateLimitNum], + ['GlobalWebRateLimitDuration', operationFields.GlobalWebRateLimitDuration], + ['UploadRateLimitNum', operationFields.UploadRateLimitNum], + ['UploadRateLimitDuration', operationFields.UploadRateLimitDuration], + ['DownloadRateLimitNum', operationFields.DownloadRateLimitNum], + ['DownloadRateLimitDuration', operationFields.DownloadRateLimitDuration], + ['CriticalRateLimitNum', operationFields.CriticalRateLimitNum], + ['CriticalRateLimitDuration', operationFields.CriticalRateLimitDuration], + ] as const; + + for (const [key, rawValue] of entries) { + const parsedValue = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsedValue) || parsedValue <= 0) { + throw new Error(`${key} 必须为大于 0 的整数。`); + } + if (key.endsWith('Duration') && parsedValue > 1200) { + throw new Error(`${key} 不能超过 1200 秒。`); + } + } + + await saveOptionEntries(entries.map(([key, value]) => [key, String(Number.parseInt(value, 10))]), '限流设置已保存。'); + }) + } + disabled={busyKey === 'operation-rate-limit'} + > + {busyKey === 'operation-rate-limit' ? '保存中...' : '保存限流设置'} + + } + > +
+
+

全局 API 限流

+

作用于 `/api` 下的通用请求。

+
+ + + setOperationFields((previous) => ({ ...previous, GlobalApiRateLimitNum: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, GlobalApiRateLimitDuration: event.target.value })) + } + /> + +
+
+ +
+

全局 Web 限流

+

作用于页面和静态资源请求,过低会更容易触发 429。

+
+ + + setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitNum: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitDuration: event.target.value })) + } + /> + +
+
+ +
+

上传 / 下载限流

+

用于文件上传与下载接口,建议保留相对严格的阈值。

+
+ + + setOperationFields((previous) => ({ ...previous, UploadRateLimitNum: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, UploadRateLimitDuration: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, DownloadRateLimitNum: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, DownloadRateLimitDuration: event.target.value })) + } + /> + +
+
+ +
+

敏感接口限流

+

用于登录、注册、验证码、重置密码和 OAuth 等接口。

+
+ + + setOperationFields((previous) => ({ ...previous, CriticalRateLimitNum: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, CriticalRateLimitDuration: event.target.value })) + } + /> + +
+
+
+
+
certificatesQuery.data ?? [], [certificatesQuery.data]); - const summary = useMemo( - () => [ - { label: '证书总数', value: certificates.length }, - { - label: '30 天内到期', - value: certificates.filter((item) => { - const expiresAt = new Date(item.not_after).getTime(); - if (Number.isNaN(expiresAt)) { - return false; - } - - const diffMs = expiresAt - Date.now(); - return diffMs >= 0 && diffMs <= 30 * 24 * 60 * 60 * 1000; - }).length, - }, - { - label: '已过期', - value: certificates.filter((item) => { - const expiresAt = new Date(item.not_after).getTime(); - return !Number.isNaN(expiresAt) && expiresAt < Date.now(); - }).length, - }, - { label: '最近更新', value: certificates[0] ? formatDateTime(certificates[0].updated_at) : '—' }, - ], - [certificates], - ); const handleManualSubmit = manualForm.handleSubmit((values) => { setFeedback(null); @@ -245,20 +219,6 @@ export function TlsCertificatesPage() { {feedback ? : null} - -
- {summary.map((item) => ( -
-

{item.label}

-

{item.value}

-
- ))} -
-
- 0 ? searchQuery : usersQuery; const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]); - const summary = useMemo(() => { - return [ - { label: searchKeyword ? '搜索结果' : '当前页用户', value: users.length }, - { label: '管理员', value: users.filter((item) => item.role >= 10).length }, - { label: '已激活', value: users.filter((item) => item.status === 1).length }, - { label: '已封禁', value: users.filter((item) => item.status !== 1).length }, - ]; - }, [searchKeyword, users]); - - const handleSearchSubmit = () => { + const handleSearchSubmit = () => { setFeedback(null); setPage(0); setSearchKeyword(searchInput.trim()); @@ -296,25 +287,9 @@ export function UsersPage() { {feedback ? : null} - -
- {summary.map((item) => ( -
-

- {item.label} -

-

{item.value}

-
- ))} -
-
- - void; - setSidebarCollapsed: (value: boolean) => void; -} - -export const useAppShellStore = create((set) => ({ - isSidebarCollapsed: false, - toggleSidebar: () => - set((state) => ({ - isSidebarCollapsed: !state.isSidebarCollapsed, - })), - setSidebarCollapsed: (value) => set({ isSidebarCollapsed: value }), -})); +interface AppShellState { + isSidebarCollapsed: boolean; + isMobileSidebarOpen: boolean; + toggleSidebar: () => void; + setSidebarCollapsed: (value: boolean) => void; + setMobileSidebarOpen: (value: boolean) => void; +} + +export const useAppShellStore = create((set) => ({ + isSidebarCollapsed: false, + isMobileSidebarOpen: false, + toggleSidebar: () => + set((state) => ({ + isSidebarCollapsed: !state.isSidebarCollapsed, + })), + setSidebarCollapsed: (value) => set({ isSidebarCollapsed: value }), + setMobileSidebarOpen: (value) => set({ isMobileSidebarOpen: value }), +})); diff --git a/atsf_server/web/tests/unit/dashboard-overview.test.tsx b/atsf_server/web/tests/unit/dashboard-overview.test.tsx index d865a31f..ea9cee9f 100644 --- a/atsf_server/web/tests/unit/dashboard-overview.test.tsx +++ b/atsf_server/web/tests/unit/dashboard-overview.test.tsx @@ -1,14 +1,119 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview'; - -describe('DashboardOverview', () => { - it('renders stage one heading and readiness items', () => { - render(); - - expect(screen.getByText('阶段 1 已启动')).toBeInTheDocument(); - expect(screen.getByText('工程底座')).toBeInTheDocument(); - expect(screen.getByText('质量工具')).toBeInTheDocument(); - }); -}); +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview'; + +describe('DashboardOverview', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders dashboard summary cards', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/nodes/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, status: 'online', auto_update_enabled: true }], + }), + ), + ); + } + + if (url.includes('/config-versions/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, version: '20260311-001', is_active: true, created_at: '2026-03-11T10:00:00Z' }], + }), + ), + ); + } + + if (url.includes('/managed-domains/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, domain: '*.example.com', enabled: true, cert_id: 1 }], + }), + ), + ); + } + + if (url.includes('/tls-certificates/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [ + { + id: 1, + name: 'example', + not_after: '2026-04-01T00:00:00Z', + updated_at: '2026-03-10T10:00:00Z', + }, + ], + }), + ), + ); + } + + if (url.includes('/proxy-routes/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, enabled: true, enable_https: true, custom_headers: '[{"key":"X-Test","value":"1"}]' }], + }), + ), + ); + } + + if (url.includes('/user/search')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [{ id: 1, role: 100, status: 1, username: 'root' }], + }), + ), + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }), + ); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + render( + + + , + ); + + expect(screen.getByText('控制台仪表盘')).toBeInTheDocument(); + expect(await screen.findByText('节点摘要')).toBeInTheDocument(); + expect(await screen.findByText('用户概览')).toBeInTheDocument(); + }); +}); diff --git a/docs/app-config.md b/docs/app-config.md index e6d5c309..33a3e92e 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -11,6 +11,8 @@ Server 当前支持两类启动配置: 1. 命令行参数 2. 环境变量 +此外,部分运行时参数已迁入数据库 `Option` 表,可在管理端设置页中热更新,例如 Agent 运行参数与限流阈值。 + ### 1.1 Server 命令行参数 启动示例: @@ -94,6 +96,27 @@ volumes: * `REDIS_CONN_STRING` 未配置时,相关能力将回退为进程内实现 * `UPLOAD_PATH` 目录在启动时若不存在会自动创建 +### 1.2.1 设置页可热更新的运行时配置 + +以下配置不依赖环境变量,保存在数据库 `Option` 表中,可在管理端设置页的「运维设置」中调整,并在保存后立即生效: + +| 配置项 | 作用 | 默认值 | +| --- | --- | --- | +| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `30000` | +| `AgentSyncInterval` | Agent 同步间隔(毫秒) | `30000` | +| `NodeOfflineThreshold` | 节点离线判定阈值(毫秒) | `120000` | +| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/ATSFlare` | +| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口(秒) | `300` / `180` | +| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口(秒) | `300` / `180` | +| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口(秒) | `50` / `60` | +| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口(秒) | `50` / `60` | +| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 登录、注册、验证码等敏感接口限流次数 / 时间窗口(秒) | `100` / `1200` | + +说明: + +* 限流窗口上限不能超过 `RateLimitKeyExpirationDuration`,当前为 20 分钟 +* 限流按来源 IP 统计,若前置了 Nginx/CDN/LB,应正确透传真实客户端 IP + ### 1.3 前端构建环境变量 新版管理端位于 `atsf_server/web`,构建时支持以下公开环境变量: diff --git a/docs/design.md b/docs/design.md index deeaf688..87c3f59e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -348,6 +348,11 @@ Agent 接口当前覆盖: * `AgentSyncInterval`:Agent 配置同步间隔(毫秒),默认 30000 * `NodeOfflineThreshold`:节点离线判定阈值(毫秒),默认 120000 * `AgentUpdateRepo`:Agent 自动更新 GitHub 仓库地址,默认 `Rain-kl/ATSFlare` + * `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration`:全局 API 限流次数与窗口(秒) + * `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration`:全局 Web 限流次数与窗口(秒) + * `UploadRateLimitNum` / `UploadRateLimitDuration`:上传接口限流次数与窗口(秒) + * `DownloadRateLimitNum` / `DownloadRateLimitDuration`:下载接口限流次数与窗口(秒) + * `CriticalRateLimitNum` / `CriticalRateLimitDuration`:登录、注册、验证码等敏感接口限流次数与窗口(秒) * 环境变量类设置(`SESSION_SECRET`、`SQLITE_PATH`、`PORT`)不迁移,保留原有方式 * 前端在设置页面新增「运维设置」Tab diff --git a/docs/development-plan.md b/docs/development-plan.md index 454f7675..e43e2e4f 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -54,6 +54,11 @@ * `AgentSyncInterval`(默认 30000ms) * `NodeOfflineThreshold`(默认 120000ms) * `AgentUpdateRepo`(默认 `Rain-kl/ATSFlare`) + * `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` + * `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` + * `UploadRateLimitNum` / `UploadRateLimitDuration` + * `DownloadRateLimitNum` / `DownloadRateLimitDuration` + * `CriticalRateLimitNum` / `CriticalRateLimitDuration` 2. 在 `model/option.go` 的 `InitOptionMap()` 注册新选项 3. 在 `model/option.go` 的 `updateOptionMap()` 增加对新选项的同步 4. 修改 `service/agent.go` 中 `computeNodeStatus()` 使用动态 `NodeOfflineThreshold` @@ -64,6 +69,7 @@ * 运维设置在设置页面可查看和修改 * 修改后立即生效,无需重启 Server * `NodeOfflineThreshold` 变更后节点状态判定使用新阈值 +* 限流阈值与时间窗口可在设置页调整,并即时影响对应中间件 ### 3.2 阶段二:Server 下发 Agent 设置 + Agent 接收