mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
fix(frontend): optimize
This commit is contained in:
@@ -32,12 +32,12 @@
|
||||
|
||||
## 严格遵循事项 (Guardrails)
|
||||
|
||||
- 切勿删除 `frontend/node_modules`;如果需要刷新依赖,请使用 `pnpm install` 重新安装。
|
||||
- 切勿删除 `frontend/node_modules`
|
||||
- 保持 `internal/util/` 绝对纯净且不引入任何框架。禁止从 `internal/util/` 及其子包中导入 Gin、GORM、sessions 等 HTTP/Web/数据库相关框架包(例如,Web 会话选项已收敛至 `internal/apps/oauth/session.go`)。
|
||||
- 编写测试用例时,禁止使用硬编码的相对路径(如 `"uploads/test_cache"`)在源码目录下创建临时测试目录,必须统一使用 Go 内置的 `t.TempDir()` 以避免污染源码目录。
|
||||
- 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。
|
||||
- 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。
|
||||
- 在提交更改前运行 `make code-check`。
|
||||
- 在完成代码开发后必须运行 `make code-check`, 并修复报错。
|
||||
- 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。
|
||||
|
||||
## 项目介绍
|
||||
@@ -161,16 +161,6 @@ Handler 规范:
|
||||
- 不要创建物理数据库外键。改为关系字段添加显式索引。
|
||||
- 数据库默认值必须与 Go 模型零值(`nil`、`0`、`false`、`""`)匹配,以避免意外的插入。
|
||||
|
||||
严格依赖防线:
|
||||
|
||||
- `internal/util/` 及其子包必须保持无框架依赖。
|
||||
- 不要从 `internal/util/` 中导入 `github.com/gin-gonic/gin`、`gorm.io/gorm`、`github.com/gin-contrib/sessions` 或 HTTP 中间件/框架包。
|
||||
- 如果实用工具逻辑需要 web 胶水,请将纯验证/计算保留在 `internal/util/` 中,并将 Gin 中间件/响应处理放在 `internal/apps/` 中。
|
||||
|
||||
新增接口与模块开发工作流:
|
||||
|
||||
- 关于自定义业务接口(如 Admin/User/Custom 模块等)的详细包职责、文件结构和核心开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/Wavelet/.agent/skills/new-api/SKILL.md) 技能。
|
||||
|
||||
### 前端规则
|
||||
|
||||
在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。
|
||||
|
||||
+2
-1
@@ -7514,7 +7514,8 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
"type": "string",
|
||||
"example": "0"
|
||||
},
|
||||
"is_active": {
|
||||
"type": "boolean"
|
||||
|
||||
+2
-1
@@ -7507,7 +7507,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
"type": "string",
|
||||
"example": "0"
|
||||
},
|
||||
"is_active": {
|
||||
"type": "boolean"
|
||||
|
||||
+2
-1
@@ -1379,7 +1379,8 @@ definitions:
|
||||
gender:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
example: "0"
|
||||
type: string
|
||||
is_active:
|
||||
type: boolean
|
||||
is_admin:
|
||||
|
||||
@@ -19,12 +19,11 @@ limitations under the License.
|
||||
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"
|
||||
import {ArrowDown, ChevronUp, Loader2} from "lucide-react"
|
||||
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
interface LogEntry {
|
||||
@@ -48,7 +47,7 @@ const LogLine = memo(function LogLine({data}: {data: string}) {
|
||||
: "text-gray-300"
|
||||
|
||||
return (
|
||||
<div className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}>
|
||||
<div className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
@@ -59,7 +58,7 @@ function getApiBaseUrl(): string {
|
||||
// If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin.
|
||||
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
if (base.startsWith("http")) return base
|
||||
|
||||
|
||||
// Relative URL fallback
|
||||
const proto = window.location.protocol
|
||||
const host = window.location.host
|
||||
@@ -92,19 +91,14 @@ export function AppLogs() {
|
||||
const [nextCursor, setNextCursor] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
// autoScroll = true → new logs auto-scroll to bottom
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const pausedRef = useRef(paused)
|
||||
const autoScrollRef = useRef(autoScroll)
|
||||
const isUserScrolling = useRef(false)
|
||||
|
||||
useEffect(() => { pausedRef.current = paused }, [paused])
|
||||
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
@@ -213,10 +207,7 @@ export function AppLogs() {
|
||||
const ws = new WebSocket(buildWsUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => { setConnected(true) }
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (pausedRef.current) return
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "log" && msg.data) {
|
||||
@@ -229,8 +220,7 @@ export function AppLogs() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => { setConnected(false); wsRef.current = null }
|
||||
ws.onerror = () => { setConnected(false) }
|
||||
ws.onclose = () => { wsRef.current = null }
|
||||
}, [])
|
||||
|
||||
// ---- Initialize ------------------------------------------------------
|
||||
@@ -253,8 +243,6 @@ export function AppLogs() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const togglePause = useCallback(() => setPaused(p => !p), [])
|
||||
const reconnect = useCallback(() => connectWs(), [connectWs])
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (nextCursor > 0) loadHistory(nextCursor)
|
||||
}, [nextCursor, loadHistory])
|
||||
@@ -265,38 +253,13 @@ export function AppLogs() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Sub Header / Control Bar */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border/40 mb-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
系统后台实时输出的运行日志 (最多缓存 2000 行)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={connected ? "secondary" : "destructive"} className="h-6">
|
||||
{connected ? "已连接" : "断开连接"}
|
||||
</Badge>
|
||||
{connected && (
|
||||
<Button variant="outline" size="sm" onClick={togglePause} className="h-8">
|
||||
{paused
|
||||
? <><Play className="size-3.5 mr-1.5" />恢复</>
|
||||
: <><Pause className="size-3.5 mr-1.5" />暂停</>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
{!connected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect} className="h-8">
|
||||
重新连接
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log viewer — fixed height, scrollable */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-md border border-border/50 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
|
||||
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
|
||||
>
|
||||
{/* Load older logs */}
|
||||
{hasMore && (
|
||||
@@ -317,10 +280,10 @@ export function AppLogs() {
|
||||
)}
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-gray-500">暂无日志</div>
|
||||
<div className="px-4 py-8 text-center text-gray-500">暂无日志</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative px-3 py-2"
|
||||
className="relative px-3 py-3"
|
||||
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
|
||||
@@ -4,15 +4,19 @@
|
||||
// Package auth_source 提供认证源管理功能
|
||||
package auth_source
|
||||
|
||||
import ("errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response")
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
)
|
||||
|
||||
// AuthSourceRequest 创建或更新认证源的请求参数
|
||||
type AuthSourceRequest struct {
|
||||
@@ -119,6 +123,9 @@ func UpdateAuthSource(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 记录更新前的 Discovery URL,以便更新成功后清除旧缓存条目。
|
||||
existing, _ := model.GetAuthSourceByID(c.Request.Context(), id)
|
||||
|
||||
source := model.AuthSource{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
@@ -136,6 +143,14 @@ func UpdateAuthSource(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery URL 可能已变更,清除旧、新 issuer 的 provider 缓存,
|
||||
// 确保下次登录时重新拉取最新 OIDC 元数据。
|
||||
if existing != nil {
|
||||
oauth.InvalidateOIDCProviderCache(normalizeIssuer(existing.OpenIDDiscoveryURL))
|
||||
}
|
||||
oauth.InvalidateOIDCProviderCache(normalizeIssuer(req.OpenIDDiscoveryURL))
|
||||
|
||||
updated, err := model.GetAuthSourceByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||||
@@ -219,3 +234,12 @@ func parseSourceID(c *gin.Context) (uint64, error) {
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// normalizeIssuer 将 Discovery URL 规范化为 issuer 基础 URL,
|
||||
// 与 oauth.buildOAuthConfig 中的规范化逻辑保持一致。
|
||||
func normalizeIssuer(discoveryURL string) string {
|
||||
issuer := strings.TrimSuffix(strings.TrimSpace(discoveryURL), "/")
|
||||
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
|
||||
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
|
||||
return issuer
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// oidcProviderCache 进程级 OIDC provider 缓存。
|
||||
//
|
||||
// oidc.NewProvider 每次调用都会向远端 issuer 的
|
||||
// /.well-known/openid-configuration 发起 HTTP 请求拉取元数据。
|
||||
// 由于 provider 元数据极少变动,将其缓存后可消除登录发起与回调时的
|
||||
// 重复外部 HTTP 往返。
|
||||
//
|
||||
// 并发安全性:
|
||||
// - mu + entries 防止并发读写 map。
|
||||
// - sfGroup 保证同一 issuer 同时只有一次在途的 NewProvider 调用
|
||||
// (singleflight),后续等待者复用同一结果,彻底消除 thundering herd。
|
||||
type oidcProviderCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*oidc.Provider // key: normalized issuer URL
|
||||
sfGroup singleflight.Group
|
||||
}
|
||||
|
||||
// globalOIDCProviderCache 是包级单例缓存,与进程同生命周期。
|
||||
var globalOIDCProviderCache = &oidcProviderCache{
|
||||
entries: make(map[string]*oidc.Provider),
|
||||
}
|
||||
|
||||
// get 返回缓存的 provider;若无则通过 oidc.NewProvider 获取并写入缓存。
|
||||
// 同一 issuer 并发调用时,singleflight 保证只有一次实际 HTTP 请求。
|
||||
//
|
||||
// 注意:接受 _ 形参以与调用方类型一致,内部有意使用 context.Background() 而非传入的请求 ctx,
|
||||
// 以防止请求被提前取消时导致缓存写入失败。
|
||||
func (c *oidcProviderCache) get(_ context.Context, issuer string) (*oidc.Provider, error) { //nolint:contextcheck // intentional: use Background to avoid request cancellation affecting cache write
|
||||
// 快路径:已有缓存则直接返回。
|
||||
c.mu.RLock()
|
||||
if p, ok := c.entries[issuer]; ok {
|
||||
c.mu.RUnlock()
|
||||
return p, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 慢路径:通过 singleflight 合并并发的首次请求。
|
||||
// 闭包内有意使用 context.Background() 而非请求 ctx,防止请求取消导致缓存写入失败。
|
||||
v, err, _ := c.sfGroup.Do(issuer, func() (any, error) { //nolint:contextcheck // intentional: Background ctx prevents cache write failure on request cancellation
|
||||
// 双检:singleflight 内再次检查,前一个并发组可能已写入缓存。
|
||||
c.mu.RLock()
|
||||
if p, ok := c.entries[issuer]; ok {
|
||||
c.mu.RUnlock()
|
||||
return p, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
p, err := oidc.NewProvider(context.Background(), issuer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.entries[issuer] = p
|
||||
c.mu.Unlock()
|
||||
return p, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(*oidc.Provider), nil //nolint:forcetypeassert // singleflight value 由同函数写入,类型确定
|
||||
}
|
||||
|
||||
// invalidate 从缓存中移除指定 issuer 对应的 provider。
|
||||
// 在认证源的 Discovery URL 被修改时调用,强制下次请求重新拉取元数据。
|
||||
func (c *oidcProviderCache) invalidate(issuer string) {
|
||||
c.mu.Lock()
|
||||
delete(c.entries, issuer)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// InvalidateOIDCProviderCache 从进程级缓存中清除指定 issuer 的 provider 条目。
|
||||
// 当管理员更新认证源的 Discovery URL 后调用,以确保下次登录时重新拉取最新元数据。
|
||||
// issuer 值应为去掉 /.well-known/openid-configuration 后缀的规范化 URL。
|
||||
func InvalidateOIDCProviderCache(issuer string) {
|
||||
globalOIDCProviderCache.invalidate(issuer)
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
package oauth
|
||||
|
||||
import ("context"
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -15,6 +16,7 @@ import ("context"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
|
||||
"github.com/Rain-kl/Wavelet/internal/common"
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -25,7 +27,6 @@ import ("context"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/gorm"
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
)
|
||||
|
||||
// AuthSourceView 登录源展示信息
|
||||
@@ -161,7 +162,9 @@ func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL
|
||||
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
|
||||
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
// 使用进程级缓存获取 provider,避免每次调用都向 issuer 发起
|
||||
// /.well-known/openid-configuration HTTP 请求。
|
||||
provider, err := globalOIDCProviderCache.get(ctx, issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user