diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index f7b84d3c..6ad7151c 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -4,6 +4,14 @@
> 范围:Go 后端 + Next.js 前端
> 目标:识别可能在生产环境真实出现的性能问题,并给出高 ROI 优化路线
+**状态图例**:`✅ 已完成` · `🔶 部分完成` · `⬜ 待做`
+
+| 修复批次 | 范围 | 状态 |
+|----------|------|------|
+| P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ |
+| P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ |
+| P1 #9 | 公共配置 Redis 列表缓存 | ✅ |
+
---
## 目录
@@ -41,16 +49,16 @@ flowchart LR
C -.->|串行阻塞| D
```
-当前最大的结构性问题:
+当前最大的结构性问题(2026-06-17 更新):
-1. **前端**:全客户端渲染 + 全局认证瀑布流,所有业务数据请求被 `getUserInfo` 串行阻塞。
-2. **后端**:文件服务路径(`/f/{id}`)是最高频热点,WebP 缓存未命中时在请求线程内做重 CPU/IO 工作,且磁盘缓存使用全局互斥锁。
+1. **前端**:全客户端渲染仍为主模式;~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态。Admin 大 bundle、无 `dynamic()` 分割仍待优化。
+2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。
---
## Critical — 高概率生产问题
-### 1. 图片 WebP 服务:请求路径阻塞 + 全局锁串行化
+### 1. 图片 WebP 服务:请求路径阻塞 + 全局锁串行化 `🔶 部分完成`
**涉及文件**:
@@ -91,14 +99,14 @@ func (c *Cache) Get(key string) ([]byte, error) {
**建议**:
-- [ ] 磁盘缓存改用 `RWMutex`,读路径不互斥
-- [ ] 对同一 cache key 使用 `singleflight` 合并并发 miss
+- [x] ✅ 磁盘缓存改用 `RWMutex`,读路径不互斥 — `pkg/cache/disk/cache.go`
+- [x] ✅ 对同一 cache key 使用 `singleflight` 合并并发 miss — `internal/apps/upload/file_server.go`
- [ ] 部署后强制执行 `upload:warm_image_cache` 异步预热任务
- [ ] 考虑 miss 时先返回原图,后台异步生成 WebP
---
-### 2. 文件访问路径:每次请求多次 DB/Redis 查询
+### 2. 文件访问路径:每次请求多次 DB/Redis 查询 `✅ 已完成`
**涉及文件**:
@@ -142,13 +150,13 @@ func isFilePublic(ctx context.Context, uploadType string) bool {
**建议**:
-- [ ] 为 `StorageReadOnly` / `latestStorageMigrationExecution` 增加 5s TTL 进程内缓存
-- [ ] 配置变更或迁移状态变化时通过 Redis pub/sub 失效
-- [ ] `file_access_whitelist` 增加进程内缓存,复用 `GetByKey` 的失效机制
+- [x] ✅ 为 `StorageReadOnly` / `latestStorageMigrationExecution` 增加 5s TTL 进程内缓存 — `internal/apps/upload/access_cache.go`
+- [x] ✅ 配置变更或迁移状态变化时通过 Redis pub/sub 失效 — `access_cache.go` + `system_config/routers.go`
+- [x] ✅ `file_access_whitelist` 增加进程内缓存,复用 `GetByKey` 的失效机制 — `access_cache.go`
---
-### 3. Admin 文件统计:无界全表扫描
+### 3. Admin 文件统计:无界全表扫描 `✅ 已完成`
**涉及文件**:`internal/apps/upload/stats.go`
@@ -171,12 +179,12 @@ err = db.DB(ctx).Model(&model.Upload{}).
**建议**:
-- [ ] 改为 SQL `GROUP BY` + `CASE WHEN` 聚合
-- [ ] 或维护增量统计表,上传/删除时更新计数
+- [ ] 改为 SQL `GROUP BY` + `CASE WHEN` 聚合(未采用)
+- [x] ✅ 维护增量统计表,上传/删除时更新计数 — `w_upload_stats` + `stats_counter.go` + `GetFileStats` 读统计表
---
-### 4. `w_uploads` 索引缺口
+### 4. `w_uploads` 索引缺口 `✅ 已完成`
**涉及文件**:`internal/db/migrator/goose/postgres/202606090001_initial_schema.sql`
@@ -199,7 +207,7 @@ err = db.DB(ctx).Model(&model.Upload{}).
**建议**:
-- [ ] 通过 goose migration 新增上述复合索引(PostgreSQL + SQLite 双方言)
+- [x] ✅ 通过 goose migration 新增上述复合索引(PostgreSQL + SQLite 双方言)— `202606170001_add_upload_composite_indexes.sql`
---
@@ -227,7 +235,7 @@ err = db.DB(ctx).Model(&model.Upload{}).
---
-### 6. 前端全局认证瀑布流
+### 6. 前端全局认证瀑布流 `✅ 已完成`
**涉及文件**:
@@ -257,13 +265,13 @@ if (loading || !user) {
**建议**:
-- [ ] Layout 不阻塞渲染,子页面自行处理未登录状态
+- [x] ✅ Layout 不阻塞渲染,子页面自行处理未登录状态 — `layout.tsx` + `RequireAuth` / `RequireAdminAuth`
- [ ] 或 Server Component 通过 cookie 预取 session,消除客户端首屏等待
-- [ ] `/login`、`/register` 跳过 `getUserInfo`
+- [x] ✅ `/login`、`/register` 跳过 `getUserInfo` — `user-context.tsx`
---
-### 7. 实时日志面板:2000 行 DOM 无虚拟化
+### 7. 实时日志面板:2000 行 DOM 无虚拟化 `✅ 已完成`
**涉及文件**:`frontend/components/common/admin/app-logs.tsx`
@@ -282,8 +290,8 @@ if (loading || !user) {
**建议**:
-- [ ] 使用 `useVirtualizer` 只渲染可视区域行
-- [ ] 行组件 `React.memo` 避免无效重渲染
+- [x] ✅ 使用 `useVirtualizer` 只渲染可视区域行 — `app-logs.tsx`
+- [x] ✅ 行组件 `React.memo` 避免无效重渲染 — `LogLine`
---
@@ -291,7 +299,7 @@ if (loading || !user) {
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
-| 1 | 公共配置接口无 Redis 缓存 | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | 每次前端启动/登录直查 PostgreSQL |
+| 1 | ~~公共配置接口无 Redis 缓存~~ ✅ | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | ~~每次前端启动/登录直查 PostgreSQL~~ → Redis 列表缓存 + Create/Update 时失效 |
| 2 | CAPTCHA 每次 5 次独立 `GetByKey` | `internal/apps/cap/manager.go` | 登录高峰 Redis 压力 |
| 3 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP |
| 4 | CORS 每次跨域查 `server_address` 配置 | `internal/router/middlewares.go:75` | 预检请求放大 |
@@ -318,28 +326,28 @@ if (loading || !user) {
### P0 — 立即做(1–2 周,收益最大)
-| # | 优化项 | 涉及模块 | 预期收益 | 复杂度 |
-|---|--------|----------|----------|--------|
-| 1 | WebP:`singleflight` + `RWMutex` + 强制预热 | `file_server.go`, `pkg/cache/disk/` | 图片 P99 ↓ 80%+,并发吞吐 ↑ 5–10x | 中 |
-| 2 | 缓存 `StorageReadOnly` / 迁移状态 | `storage_ops.go` | 每文件请求减少 1–3 次 DB | 低 |
-| 3 | 内存缓存 `file_access_whitelist` | `file_server.go` | 每公开文件请求减少 1 次 Redis | 低 |
-| 4 | `GetFileStats` 改为 SQL 聚合 | `stats.go` | Admin 统计从 O(n) → O(1) | 低 |
-| 5 | 新增 `w_uploads` 复合索引 | goose migration | 清理/迁移/秒传全面加速 | 低 |
-| 6 | 前端日志虚拟化 | `app-logs.tsx` | Admin 日志 Tab 流畅度质变 | 低 |
-| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page.tsx`, `logs/page.tsx`, `settings/page.tsx` 等 | 首包 JS ↓ 150–300KB | 低 |
+| # | 优化项 | 涉及模块 | 预期收益 | 复杂度 | 状态 |
+|---|--------|----------|----------|--------|------|
+| 1 | WebP:`singleflight` + `RWMutex` + 强制预热 | `file_server.go`, `pkg/cache/disk/` | 图片 P99 ↓ 80%+,并发吞吐 ↑ 5–10x | 中 | 🔶 锁与去重已完成,预热待做 |
+| 2 | 缓存 `StorageReadOnly` / 迁移状态 | `access_cache.go` | 每文件请求减少 1–3 次 DB | 低 | ✅ |
+| 3 | 内存缓存 `file_access_whitelist` | `access_cache.go` | 每公开文件请求减少 1 次 Redis | 低 | ✅ |
+| 4 | `GetFileStats` 增量统计表 | `stats.go`, `w_upload_stats` | Admin 统计从 O(n) → O(1) | 低 | ✅ |
+| 5 | 新增 `w_uploads` 复合索引 | goose migration | 清理/迁移/秒传全面加速 | 低 | ✅ |
+| 6 | 前端日志虚拟化 | `app-logs.tsx` | Admin 日志 Tab 流畅度质变 | 低 | ✅ |
+| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page.tsx`, `logs/page.tsx`, `settings/page.tsx` 等 | 首包 JS ↓ 150–300KB | 低 | ⬜ |
### P1 — 短期(2–4 周)
-| # | 优化项 | 预期收益 |
-|---|--------|----------|
-| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms |
-| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 |
-| 10 | CAPTCHA 配置快照(一次加载 5 个 key) | 验证码路径 Redis ops ↓ 80% |
-| 11 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms |
-| 12 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 |
-| 13 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 |
-| 14 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms |
-| 15 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 |
+| # | 优化项 | 预期收益 | 状态 |
+|---|--------|----------|------|
+| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms | 🔶 客户端并行化已完成,RSC 预取待做 |
+| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ |
+| 10 | CAPTCHA 配置快照(一次加载 5 个 key) | 验证码路径 Redis ops ↓ 80% | ⬜ |
+| 11 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ |
+| 12 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ |
+| 13 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | ⬜ |
+| 14 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ⬜ |
+| 15 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ |
### P2 — 中期架构演进
@@ -378,6 +386,12 @@ if (loading || !user) {
| 14 | React Compiler 已启用 | `frontend/next.config.ts` |
| 15 | 读副本支持(`dbresolver`) | `internal/db/postgres.go` |
| 16 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` |
+| 17 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` |
+| 18 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook |
+| 19 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` |
+| 20 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` |
+| 21 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` |
+| 22 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` |
---
@@ -385,12 +399,12 @@ if (loading || !user) {
| 场景 | 最可能爆的点 | 对应优先级 |
|------|-------------|-----------|
-| 图片站 / 公开相册 | WebP miss + 磁盘锁 + 白名单 Redis | P0 #1, #2, #3 |
-| 文件量 10 万+ | 统计全表扫描 + 索引缺失 + 清理慢 | P0 #4, #5 |
-| 管理端日常使用 | 认证瀑布 + 大 bundle + 日志 DOM | P0 #6, #7 |
-| 存储迁移进行中 | 迁移状态重复查 + Redis 日志风暴 | P1 #2, P2 #17 |
+| 图片站 / 公开相册 | WebP miss(锁/白名单已优化) | P0 #1 预热待做 |
+| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 |
+| 管理端日常使用 | 大 bundle(认证/日志已优化) | P0 #7 dynamic import |
+| 存储迁移进行中 | Redis 日志风暴 | P2 #17 |
| 登录高峰 | CAPTCHA 5×Redis + OIDC discovery | P1 #10, #11 |
-| 多租户 / 跨域前端 | CORS 配置查询 + 公共配置无缓存 | P1 #9 |
+| 多租户 / 跨域前端 | CORS 配置查询 | P1 CORS 缓存 |
| 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 |
---
@@ -399,32 +413,34 @@ if (loading || !user) {
如果只选 **3 件事** 先做(预计用户感知延迟降低 50–70%):
-1. **WebP 路径解耦** — `singleflight` + `RWMutex` + 部署后预热
-2. **文件路径查询缓存** — 迁移状态 + 白名单进程内缓存
-3. **前端认证与首屏并行化** — 消除全局 auth gate + Admin 代码分割
+1. ~~**WebP 路径解耦**~~ ✅ `singleflight` + `RWMutex` 已落地;**下一步**:部署后预热 + miss 异步回退原图
+2. ~~**文件路径查询缓存**~~ ✅ 迁移状态 + 白名单进程内缓存已落地
+3. ~~**前端认证与首屏并行化**~~ ✅ 全局 auth gate 已移除;**下一步**:Admin `dynamic()` 代码分割
### 实施检查清单
```
P0 后端
-[ ] disk cache RWMutex + singleflight
-[ ] StorageReadOnly 5s 缓存 + pub/sub 失效
-[ ] file_access_whitelist 进程内缓存
-[ ] GetFileStats SQL 聚合改写
-[ ] w_uploads 复合索引 migration
+[x] disk cache RWMutex + singleflight ✅ 2026-06-17
+[x] StorageReadOnly 5s 缓存 + pub/sub 失效 ✅ 2026-06-17
+[x] file_access_whitelist 进程内缓存 ✅ 2026-06-17
+[x] GetFileStats 增量统计表 (w_upload_stats) ✅ 2026-06-17
+[x] w_uploads 复合索引 migration ✅ 2026-06-17
[ ] 批量下载数量上限
+[ ] WebP 部署预热 + miss 异步回退原图
P0 前端
-[ ] app-logs.tsx 虚拟滚动
+[x] app-logs.tsx 虚拟滚动 ✅ 2026-06-17
[ ] SQLConsole / Recharts / Settings Tabs dynamic import
-[ ] 认证 gate 并行化
+[x] 认证 gate 并行化 ✅ 2026-06-17
P1
-[ ] ListVisibleSystemConfigs Redis 缓存
+[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17
[ ] CAPTCHA 配置快照
[ ] OIDC Provider 缓存
[ ] Admin useEffect → React Query 统一
[ ] 状态轮询 visibility 感知
+[ ] Server Component session 预取
```
---
@@ -434,12 +450,14 @@ P1
| 路径 | 文件 | 说明 |
|------|------|------|
| 图片服务 | `internal/apps/upload/file_server.go` | `/f/{id}` 热点 |
-| 磁盘缓存 | `pkg/cache/disk/cache.go` | 全局 Mutex |
-| 迁移状态 | `internal/apps/upload/storage_ops.go` | 无缓存 DB 查询 |
-| 文件统计 | `internal/apps/upload/stats.go` | 全表扫描 |
+| 磁盘缓存 | `pkg/cache/disk/cache.go` | ✅ RWMutex 读路径 |
+| 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub |
+| 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` |
+| 公共配置列表 | `internal/model/system_configs.go` | ✅ Redis 列表缓存 |
| 批量下载 | `internal/apps/upload/routers.go` | 同步 ZIP |
| 上传索引 | `internal/db/migrator/goose/*/202606090001_initial_schema.sql` | 缺失复合索引 |
-| 认证 gate | `frontend/app/(main)/layout.tsx` | 阻塞渲染 |
-| 用户上下文 | `frontend/contexts/user-context.tsx` | 挂载时 fetch |
-| 实时日志 | `frontend/components/common/admin/app-logs.tsx` | 无虚拟化 |
+| 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` |
+| 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 |
+| 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch |
+| 实时日志 | `frontend/components/common/admin/app-logs.tsx` | ✅ `useVirtualizer` |
| API 去重 | `frontend/lib/services/core/api-client.ts` | 已有,可复用模式 |
\ No newline at end of file
diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx
index 2cc9a5f4..a7399130 100644
--- a/frontend/app/(main)/admin/layout.tsx
+++ b/frontend/app/(main)/admin/layout.tsx
@@ -1,35 +1,18 @@
"use client"
-import { AdminUsersProvider } from "@/contexts/admin-users-context"
-import { useUser } from "@/contexts/user-context"
-import { LoadingPage } from "@/components/layout/loading"
-import { ErrorPage } from "@/components/layout/error"
+import {AdminUsersProvider} from "@/contexts/admin-users-context"
+import {RequireAdminAuth} from "@/components/auth/require-auth"
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
- const { user, loading } = useUser()
-
- /* 等待用户信息加载完成 */
- if (loading) {
- return
- }
-
- /* 权限检查:只有管理员才能访问 */
- if (!user?.is_admin) {
- return (
-
- )
- }
-
return (
-
- {children}
-
+
+
+ {children}
+
+
)
-}
+}
\ No newline at end of file
diff --git a/frontend/app/(main)/files/page.tsx b/frontend/app/(main)/files/page.tsx
index 1f45e22c..993f1aa1 100644
--- a/frontend/app/(main)/files/page.tsx
+++ b/frontend/app/(main)/files/page.tsx
@@ -3,11 +3,13 @@
import * as React from "react"
import {motion} from "motion/react"
+import {RequireAuth} from "@/components/auth/require-auth"
import {UserFileManager} from "@/components/common/user/file-manager"
import {FolderOpen} from "lucide-react"
export default function UserFilesPage() {
return (
+
+
)
}
diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx
index dec380c9..64e65025 100644
--- a/frontend/app/(main)/layout.tsx
+++ b/frontend/app/(main)/layout.tsx
@@ -1,13 +1,12 @@
"use client"
import {motion} from "motion/react"
-import {usePathname, useRouter} from "next/navigation"
-import {useEffect, useState} from "react"
+import {usePathname} from "next/navigation"
+import {useState} from "react"
import {AppSidebar} from "@/components/layout/sidebar"
import {SiteHeader} from "@/components/layout/header"
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"
-import {LoadingPage} from "@/components/layout/loading"
-import {useUser} from "@/contexts/user-context"
+import {useAuthRedirect} from "@/hooks/use-auth-redirect"
export default function MainLayout({
@@ -15,26 +14,10 @@ export default function MainLayout({
}: {
children: React.ReactNode
}) {
- const router = useRouter()
const pathname = usePathname()
- const {user, loading} = useUser()
const [isFullWidth, setIsFullWidth] = useState(false)
- useEffect(() => {
- if (loading || user) return
-
- const queryString = window.location.search
- const callbackUrl = queryString ? `${pathname}${queryString}` : pathname
- const loginUrl = new URL("/login", window.location.origin)
-
- loginUrl.searchParams.set("callbackUrl", callbackUrl)
- sessionStorage.setItem("redirect_after_login", callbackUrl)
- router.replace(loginUrl.toString())
- }, [loading, pathname, router, user])
-
- if (loading || !user) {
- return
- }
+ useAuthRedirect()
return (
)
-}
+}
\ No newline at end of file
diff --git a/frontend/app/(main)/settings/layout.tsx b/frontend/app/(main)/settings/layout.tsx
new file mode 100644
index 00000000..8e67ffaa
--- /dev/null
+++ b/frontend/app/(main)/settings/layout.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import {RequireAuth} from "@/components/auth/require-auth"
+
+export default function SettingsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return {children}
+}
\ No newline at end of file
diff --git a/frontend/components/auth/require-auth.tsx b/frontend/components/auth/require-auth.tsx
new file mode 100644
index 00000000..5c5ec631
--- /dev/null
+++ b/frontend/components/auth/require-auth.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import type {ReactNode} from "react"
+import {Loader2} from "lucide-react"
+
+import {ErrorPage} from "@/components/layout/error"
+import {useUser} from "@/contexts/user-context"
+
+type RequireAuthProps = {
+ children: ReactNode
+ fallback?: ReactNode
+ minHeightClassName?: string
+}
+
+/**
+ * Guards page content until the session user is available.
+ * Redirect to login is handled by the parent layout; this only covers the content slot.
+ */
+export function RequireAuth({
+ children,
+ fallback,
+ minHeightClassName = "min-h-[400px]",
+}: RequireAuthProps) {
+ const {user, loading} = useUser()
+
+ if (loading) {
+ return fallback ?? (
+
+
+
+ )
+ }
+
+ if (!user) {
+ return null
+ }
+
+ return <>{children}>
+}
+
+type RequireAdminAuthProps = {
+ children: ReactNode
+}
+
+/** Guards admin routes after the shared shell has rendered. */
+export function RequireAdminAuth({children}: RequireAdminAuthProps) {
+ const {user, loading} = useUser()
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!user?.is_admin) {
+ return (
+
+ )
+ }
+
+ return <>{children}>
+}
\ No newline at end of file
diff --git a/frontend/components/common/admin/app-logs.tsx b/frontend/components/common/admin/app-logs.tsx
index 094bbb30..2045e4cd 100644
--- a/frontend/components/common/admin/app-logs.tsx
+++ b/frontend/components/common/admin/app-logs.tsx
@@ -16,7 +16,8 @@ limitations under the License.
"use client"
-import {useCallback, useEffect, useRef, useState} from "react"
+import {memo, useCallback, useEffect, useRef, useState} from "react"
+import {useVirtualizer} from "@tanstack/react-virtual"
import {toast} from "sonner"
import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react"
@@ -33,6 +34,25 @@ interface LogEntry {
// Distance (px) from bottom to treat as "at bottom"
const BOTTOM_THRESHOLD = 40
+const LOG_LINE_ESTIMATE_PX = 20
+const LOG_VIRTUAL_OVERSCAN = 24
+
+const LogLine = memo(function LogLine({data}: {data: string}) {
+ const level = parseLogLevel(data)
+ const color = level === "error"
+ ? "text-red-400"
+ : level === "warn"
+ ? "text-yellow-400"
+ : level === "debug"
+ ? "text-gray-500"
+ : "text-gray-300"
+
+ return (
+
+ {data}
+
+ )
+})
function getApiBaseUrl(): string {
if (typeof window !== "undefined") {
@@ -87,6 +107,14 @@ export function AppLogs() {
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
+ const rowVirtualizer = useVirtualizer({
+ count: logs.length,
+ getScrollElement: () => containerRef.current,
+ estimateSize: () => LOG_LINE_ESTIMATE_PX,
+ overscan: LOG_VIRTUAL_OVERSCAN,
+ measureElement: (element) => element.getBoundingClientRect().height,
+ })
+
// ---- Scroll detection ------------------------------------------------
const handleScroll = useCallback(() => {
const el = containerRef.current
@@ -154,15 +182,14 @@ export function AppLogs() {
if (isInitial) {
setLogs(data.lines || [])
} else {
+ const el = containerRef.current
+ const previousScrollHeight = el?.scrollHeight ?? 0
setLogs(prev => [...(data.lines || []), ...prev])
requestAnimationFrame(() => {
- const el = containerRef.current
if (!el) return
- const newCount = (data.lines || []).length
- const lineH = 20
isUserScrolling.current = false
- el.scrollTop = el.scrollTop + newCount * lineH
+ el.scrollTop = el.scrollHeight - previousScrollHeight
})
}
setHasMore(data.has_more)
@@ -289,31 +316,31 @@ export function AppLogs() {
)}
- {/* Log lines */}
-
- {logs.length === 0 ? (
-
暂无日志
- ) : (
- logs.map((entry) => {
- const level = parseLogLevel(entry.data)
- const color = level === "error"
- ? "text-red-400"
- : level === "warn"
- ? "text-yellow-400"
- : level === "debug"
- ? "text-gray-500"
- : "text-gray-300"
+ {logs.length === 0 ? (
+
暂无日志
+ ) : (
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const entry = logs[virtualRow.index]
+ if (!entry) return null
+
return (
- {entry.data}
+
)
- })
- )}
-
+ })}
+
+ )}
{/* Floating "back to latest" button */}
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx
index 64cb97ee..b07f1898 100644
--- a/frontend/components/layout/sidebar.tsx
+++ b/frontend/components/layout/sidebar.tsx
@@ -117,7 +117,7 @@ function parseMenuDisplayConfig(raw: string | undefined): Record) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
- const { user, logout } = useUser()
+ const {user, logout, loading: userLoading} = useUser()
const { config } = usePublicConfig()
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
@@ -264,10 +264,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
- {user?.nickname || user?.username || "Unknown User"}
+ {userLoading
+ ? "加载中..."
+ : user?.nickname || user?.username || "未登录"}
- {user?.is_admin ? "系统管理员" : "普通用户"}
+ {userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx
index d9b9cce4..ee55eaaa 100644
--- a/frontend/contexts/user-context.tsx
+++ b/frontend/contexts/user-context.tsx
@@ -103,9 +103,18 @@ export function UserProvider({ children }: { children: ReactNode }) {
}
}, [])
- /** 组件挂载时获取用户信息 */
+ /** 组件挂载时获取用户信息(登录/注册页跳过,避免无意义请求) */
useEffect(() => {
isMountedRef.current = true
+
+ const path = window.location.pathname
+ if (path === "/login" || path === "/register") {
+ setState({user: null, loading: false, error: null})
+ return () => {
+ isMountedRef.current = false
+ }
+ }
+
fetchUser()
return () => {
diff --git a/frontend/hooks/use-auth-redirect.ts b/frontend/hooks/use-auth-redirect.ts
new file mode 100644
index 00000000..81f3efb8
--- /dev/null
+++ b/frontend/hooks/use-auth-redirect.ts
@@ -0,0 +1,27 @@
+"use client"
+
+import {useEffect} from "react"
+import {usePathname, useRouter} from "next/navigation"
+
+import {useUser} from "@/contexts/user-context"
+
+/** Redirects unauthenticated users to login while allowing the shell to render first. */
+export function useAuthRedirect() {
+ const router = useRouter()
+ const pathname = usePathname()
+ const {user, loading} = useUser()
+
+ useEffect(() => {
+ if (loading || user) {
+ return
+ }
+
+ const queryString = window.location.search
+ const callbackUrl = queryString ? `${pathname}${queryString}` : pathname
+ const loginUrl = new URL("/login", window.location.origin)
+
+ loginUrl.searchParams.set("callbackUrl", callbackUrl)
+ sessionStorage.setItem("redirect_after_login", callbackUrl)
+ router.replace(loginUrl.toString())
+ }, [loading, pathname, router, user])
+}
\ No newline at end of file
diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go
index a5db3332..ce08f26e 100644
--- a/internal/apps/admin/system_config/routers.go
+++ b/internal/apps/admin/system_config/routers.go
@@ -4,23 +4,25 @@
package system_config
-import ("context"
+import (
+ "context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+
"github.com/Rain-kl/Wavelet/internal/apps/upload"
+ "github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/pkg/logger"
mail "github.com/Rain-kl/Wavelet/pkg/mail"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-
- "github.com/Rain-kl/Wavelet/internal/common/response")
+)
const maskedConfigValue = "******"
@@ -95,6 +97,10 @@ func CreateSystemConfig(c *gin.Context) {
return
}
+ if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil {
+ logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err)
+ }
+
c.JSON(http.StatusOK, response.OKNil())
}
@@ -231,23 +237,13 @@ func UpdateSystemConfig(c *gin.Context) {
return err
}
- if key == model.ConfigKeyStorageConfig && originalDriver != "" {
- var newCfg storage.Config
- if err := json.Unmarshal([]byte(req.Value), &newCfg); err == nil {
- if newCfg.Driver == originalDriver {
- // Mark failed storage:migrate task execution as succeeded
- if err := tx.Model(&model.TaskExecution{}).
- Where("task_type = ? AND status = ?", "storage:migrate", model.TaskExecutionStatusFailed).
- Updates(map[string]any{
- "status": model.TaskExecutionStatusSucceeded,
- "result": "存储配置直接更新,故障迁移任务自动标记为已解决",
- "finished_at": time.Now(),
- }).Error; err != nil {
- logger.ErrorF(c.Request.Context(), "自动更新迁移任务状态失败: %v", err)
- }
- }
- }
- }
+ resolveStorageMigrationTasksOnDirectDriverUpdate(
+ c.Request.Context(),
+ tx,
+ key,
+ originalDriver,
+ req.Value,
+ )
return nil
}); err != nil {
@@ -255,18 +251,56 @@ func UpdateSystemConfig(c *gin.Context) {
return
}
+ invalidateCachesAfterConfigUpdate(c.Request.Context(), key)
+
+ c.JSON(http.StatusOK, response.OKNil())
+}
+
+func resolveStorageMigrationTasksOnDirectDriverUpdate(
+ ctx context.Context,
+ tx *gorm.DB,
+ key string,
+ originalDriver storage.Driver,
+ newValue string,
+) {
+ if key != model.ConfigKeyStorageConfig || originalDriver == "" {
+ return
+ }
+
+ var newCfg storage.Config
+ if err := json.Unmarshal([]byte(newValue), &newCfg); err != nil {
+ return
+ }
+ if newCfg.Driver != originalDriver {
+ return
+ }
+
+ if err := tx.Model(&model.TaskExecution{}).
+ Where("task_type = ? AND status = ?", "storage:migrate", model.TaskExecutionStatusFailed).
+ Updates(map[string]any{
+ "status": model.TaskExecutionStatusSucceeded,
+ "result": "存储配置直接更新,故障迁移任务自动标记为已解决",
+ "finished_at": time.Now(),
+ }).Error; err != nil {
+ logger.ErrorF(ctx, "自动更新迁移任务状态失败: %v", err)
+ }
+}
+
+func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
if key == model.ConfigKeyStorageConfig {
upload.ResetAccessCaches()
- upload.PublishAccessCacheInvalidation(c.Request.Context())
+ upload.PublishAccessCacheInvalidation(ctx)
storage.ResetCache()
- storage.PublishCacheInvalidation(c.Request.Context())
+ storage.PublishCacheInvalidation(ctx)
}
if key == model.ConfigKeyFileAccessWhitelist {
upload.ResetAccessCaches()
- upload.PublishAccessCacheInvalidation(c.Request.Context())
+ upload.PublishAccessCacheInvalidation(ctx)
}
- c.JSON(http.StatusOK, response.OKNil())
+ if err := model.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
+ logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
+ }
}
// TestSMTPRequest 测试 SMTP 配置请求
diff --git a/internal/apps/config/public_config_cache_test.go b/internal/apps/config/public_config_cache_test.go
new file mode 100644
index 00000000..bf0a1850
--- /dev/null
+++ b/internal/apps/config/public_config_cache_test.go
@@ -0,0 +1,75 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Rain-kl/Wavelet/internal/db"
+ "github.com/Rain-kl/Wavelet/internal/model"
+ "github.com/Rain-kl/Wavelet/internal/testhelper"
+)
+
+func TestListVisibleSystemConfigsUsesRedisCache(t *testing.T) {
+ dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ if err := model.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
+ t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err)
+ }
+
+ if _, err := model.ListVisibleSystemConfigs(ctx); err != nil {
+ t.Fatalf("ListVisibleSystemConfigs() warm cache error = %v", err)
+ }
+
+ if err := dbConn.Create(&model.SystemConfig{
+ Key: "cache_probe_public_key",
+ Value: "cache_probe_public_value",
+ Type: "system",
+ Visibility: model.ConfigVisibilityVisible,
+ Description: "cache probe",
+ }).Error; err != nil {
+ t.Fatalf("Create(cache_probe_public_key) error = %v", err)
+ }
+
+ cached, err := model.ListVisibleSystemConfigs(ctx)
+ if err != nil {
+ t.Fatalf("ListVisibleSystemConfigs() cached call error = %v", err)
+ }
+ for _, item := range cached {
+ if item.Key == "cache_probe_public_key" {
+ t.Fatal("cached visible config list should be stale before invalidation")
+ }
+ }
+
+ exists, err := db.Redis.Exists(ctx, db.PrefixedKey(model.SystemConfigVisibleListRedisKey)).Result()
+ if err != nil {
+ t.Fatalf("Redis.Exists() error = %v", err)
+ }
+ if exists == 0 {
+ t.Fatal("expected visible config list cache key to exist")
+ }
+
+ if err := model.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
+ t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err)
+ }
+
+ refreshed, err := model.ListVisibleSystemConfigs(ctx)
+ if err != nil {
+ t.Fatalf("ListVisibleSystemConfigs() refreshed call error = %v", err)
+ }
+
+ var found bool
+ for _, item := range refreshed {
+ if item.Key == "cache_probe_public_key" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatal("refreshed visible config list should include newly created public config")
+ }
+}
\ No newline at end of file
diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go
index 66de8e84..5feea1f3 100644
--- a/internal/model/system_configs.go
+++ b/internal/model/system_configs.go
@@ -54,6 +54,8 @@ const (
const (
// SystemConfigRedisHashKey Redis Hash key,存储所有系统配置
SystemConfigRedisHashKey = "system:system_configs"
+ // SystemConfigVisibleListRedisKey Redis key,缓存所有 visibility=1 的公共配置列表
+ SystemConfigVisibleListRedisKey = "system:visible_configs"
)
const (
@@ -108,8 +110,25 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
return nil
}
-// ListVisibleSystemConfigs 查询所有可通过公共配置接口暴露的配置
+// InvalidateVisibleSystemConfigsCache clears the cached public config list.
+func InvalidateVisibleSystemConfigsCache(ctx context.Context) error {
+ if db.Redis == nil {
+ return nil
+ }
+ return db.Redis.Del(ctx, db.PrefixedKey(SystemConfigVisibleListRedisKey)).Err()
+}
+
+// ListVisibleSystemConfigs 查询所有可通过公共配置接口暴露的配置(带 Redis 列表缓存)
func ListVisibleSystemConfigs(ctx context.Context) ([]SystemConfig, error) {
+ if db.Redis != nil {
+ var cached []SystemConfig
+ if err := db.GetJSON(ctx, SystemConfigVisibleListRedisKey, &cached); err == nil {
+ return cached, nil
+ } else if !errors.Is(err, redis.Nil) {
+ return nil, err
+ }
+ }
+
database := db.DB(ctx)
if database == nil {
return nil, errors.New(errDatabaseNotInitialized)
@@ -120,6 +139,10 @@ func ListVisibleSystemConfigs(ctx context.Context) ([]SystemConfig, error) {
return nil, err
}
+ if db.Redis != nil {
+ _ = db.SetJSON(ctx, SystemConfigVisibleListRedisKey, configs, 0)
+ }
+
return configs, nil
}