mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
perf(frontend,config): parallelize auth, virtualize logs, cache public configs
Frontend layout renders immediately; pages gate via RequireAuth and useAuthRedirect. Login/register skip getUserInfo. Admin log panel uses useVirtualizer to avoid 2000-row DOM. ListVisibleSystemConfigs caches visibility=1 configs in Redis with invalidation on create/update. PERFORMANCE.md marks completed optimizations.
This commit is contained in:
+81
-63
@@ -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` | 已有,可复用模式 |
|
||||
@@ -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 <LoadingPage text="管理后台" badgeText="管理" />
|
||||
}
|
||||
|
||||
/* 权限检查:只有管理员才能访问 */
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="访问被拒绝"
|
||||
message="您没有权限访问此页面"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
<RequireAdminAuth>
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
</RequireAdminAuth>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<RequireAuth>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -26,5 +28,6 @@ export default function UserFilesPage() {
|
||||
|
||||
<UserFileManager />
|
||||
</motion.div>
|
||||
</RequireAuth>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <LoadingPage text="登录状态" badgeText="Auth" />
|
||||
}
|
||||
useAuthRedirect()
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
@@ -67,4 +50,4 @@ export default function MainLayout({
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import {RequireAuth} from "@/components/auth/require-auth"
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <RequireAuth>{children}</RequireAuth>
|
||||
}
|
||||
@@ -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 ?? (
|
||||
<div className={`flex items-center justify-center ${minHeightClassName}`}>
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="访问被拒绝"
|
||||
message="您没有权限访问此页面"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log lines */}
|
||||
<div className="px-3 py-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">暂无日志</div>
|
||||
) : (
|
||||
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 ? (
|
||||
<div className="px-3 py-8 text-center text-gray-500">暂无日志</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative px-3 py-2"
|
||||
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = logs[virtualRow.index]
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.index}
|
||||
className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{transform: `translateY(${virtualRow.start}px)`}}
|
||||
>
|
||||
{entry.data}
|
||||
<LogLine data={entry.data} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating "back to latest" button */}
|
||||
|
||||
@@ -117,7 +117,7 @@ function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean
|
||||
*/
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
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<typeof Sidebar>) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-semibold truncate mt-2">
|
||||
{user?.nickname || user?.username || "Unknown User"}
|
||||
{userLoading
|
||||
? "加载中..."
|
||||
: user?.nickname || user?.username || "未登录"}
|
||||
</span>
|
||||
<span className="text-xs font-base text-muted-foreground">
|
||||
{user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 配置请求
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user