diff --git a/AGENTS.md b/AGENTS.md index 6d520326..9cd3053b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。 diff --git a/docs/docs.go b/docs/docs.go index 465f9c53..fc60b319 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -7514,7 +7514,8 @@ const docTemplate = `{ "type": "string" }, "id": { - "type": "integer" + "type": "string", + "example": "0" }, "is_active": { "type": "boolean" diff --git a/docs/swagger.json b/docs/swagger.json index b8c4f605..36f9bc59 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -7507,7 +7507,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "string", + "example": "0" }, "is_active": { "type": "boolean" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a589f999..55e8210a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1379,7 +1379,8 @@ definitions: gender: type: string id: - type: integer + example: "0" + type: string is_active: type: boolean is_admin: diff --git a/frontend/components/common/admin/app-logs.tsx b/frontend/components/common/admin/app-logs.tsx index 93eed714..2d59117f 100644 --- a/frontend/components/common/admin/app-logs.tsx +++ b/frontend/components/common/admin/app-logs.tsx @@ -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 ( -
+
{data}
) @@ -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(null) const wsRef = useRef(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 (
- {/* Sub Header / Control Bar */} -
-
- 系统后台实时输出的运行日志 (最多缓存 2000 行) -
-
- - {connected ? "已连接" : "断开连接"} - - {connected && ( - - )} - {!connected && ( - - )} -
-
- {/* Log viewer — fixed height, scrollable */}
{/* Load older logs */} {hasMore && ( @@ -317,10 +280,10 @@ export function AppLogs() { )} {logs.length === 0 ? ( -
暂无日志
+
暂无日志
) : (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { diff --git a/internal/apps/admin/auth_source/routers.go b/internal/apps/admin/auth_source/routers.go index 2df74fdd..b5b08159 100644 --- a/internal/apps/admin/auth_source/routers.go +++ b/internal/apps/admin/auth_source/routers.go @@ -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 +} diff --git a/internal/apps/oauth/provider_cache.go b/internal/apps/oauth/provider_cache.go new file mode 100644 index 00000000..f8fdd9dc --- /dev/null +++ b/internal/apps/oauth/provider_cache.go @@ -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) +} diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index 3eef901e..3194c0c8 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -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 }