diff --git a/backend/OpenFlare/plugins/agent/config/config.go b/backend/OpenFlare/plugins/agent/config/config.go index 9f25fa36..4c48dc2c 100644 --- a/backend/OpenFlare/plugins/agent/config/config.go +++ b/backend/OpenFlare/plugins/agent/config/config.go @@ -16,7 +16,7 @@ import ( "time" "Wavelet/OpenFlare/share/edge/nodeip" - "Wavelet/pkg/util" + "Wavelet/OpenFlare/share/ofutil" ) const ( @@ -150,7 +150,7 @@ func Load(path string) (*Config, error) { func applyDefaults(cfg *Config, baseDir string) { baseDir = filepath.Clean(baseDir) cfg.Version = Version - cfg.OpenrestyResolvers = util.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers) + cfg.OpenrestyResolvers = ofutil.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers) applyAgentIdentityDefaults(cfg) applyAgentPathDefaults(cfg, baseDir) applyAgentTimingDefaults(cfg) diff --git a/backend/OpenFlare/plugins/agent/nginx/manager.go b/backend/OpenFlare/plugins/agent/nginx/manager.go index d5d25d01..6226ec84 100644 --- a/backend/OpenFlare/plugins/agent/nginx/manager.go +++ b/backend/OpenFlare/plugins/agent/nginx/manager.go @@ -26,9 +26,9 @@ import ( "sync" "time" + "Wavelet/OpenFlare/share/ofutil" sharedprotocol "Wavelet/OpenFlare/share/protocol" openrestyrender "Wavelet/OpenFlare/share/render/openresty" - "Wavelet/pkg/util" "Wavelet/OpenFlare/plugins/agent/protocol" "Wavelet/OpenFlare/plugins/agent/runtimeuser" @@ -1534,7 +1534,7 @@ func ResolverDirective(explicitResolvers []string) string { } func resolverAddresses(explicitResolvers []string) []string { - if resolvers := util.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 { + if resolvers := ofutil.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 { return resolvers } data, err := os.ReadFile("/etc/resolv.conf") diff --git a/backend/OpenFlare/plugins/server/admin/auth_source/routers.go b/backend/OpenFlare/plugins/server/admin/auth_source/routers.go deleted file mode 100644 index 900b56c2..00000000 --- a/backend/OpenFlare/plugins/server/admin/auth_source/routers.go +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package auth_source 提供认证源管理功能 -package auth_source - -import ( - "errors" - "fmt" - "net/http" - "strings" - - "Wavelet/OpenFlare/plugins/server/admin" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -// AuthSourceRequest 创建或更新认证源的请求参数 -type AuthSourceRequest struct { - Name string `json:"name"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - IsActive bool `json:"is_active"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - OpenIDDiscoveryURL string `json:"openid_discovery_url"` - Scopes string `json:"scopes"` - IconURL string `json:"icon_url"` -} - -// ToggleAuthSourceRequest 切换认证源启用状态的请求参数 -type ToggleAuthSourceRequest struct { - IsActive bool `json:"is_active"` -} - -// ListAuthSources 获取认证源列表 -// @Summary 获取认证源列表 -// @Description 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.AuthSource} "认证源列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/auth-sources [get] -func ListAuthSources(c *gin.Context) { - sources, err := repository.GetAuthSources(c.Request.Context()) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(sources)) -} - -// CreateAuthSource 创建认证源 -// @Summary 创建认证源 -// @Description 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body auth_source.AuthSourceRequest true "创建认证源参数" -// @Success 200 {object} response.Any{data=model.AuthSource} "创建成功,返回认证源信息" -// @Failure 400 {object} response.Any "参数错误或验证失败" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/auth-sources [post] -func CreateAuthSource(c *gin.Context) { - var req AuthSourceRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - source := model.AuthSource{ - Name: req.Name, - Type: req.Type, - DisplayName: req.DisplayName, - IsActive: req.IsActive, - ClientID: req.ClientID, - ClientSecret: req.ClientSecret, - OpenIDDiscoveryURL: req.OpenIDDiscoveryURL, - Scopes: req.Scopes, - IconURL: req.IconURL, - } - if err := repository.CreateAuthSource(c.Request.Context(), &source); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - _ = repository.InvalidateAuthSourceCache(c.Request.Context()) - source.Sanitize() - c.JSON(http.StatusOK, response.OK(source)) -} - -// UpdateAuthSource 更新认证源 -// @Summary 更新认证源 -// @Description 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path uint64 true "认证源 ID 或名称" -// @Param request body auth_source.AuthSourceRequest true "更新认证源参数" -// @Success 200 {object} response.Any{data=model.AuthSource} "更新成功,返回更新后的认证源信息" -// @Failure 400 {object} response.Any "参数错误或验证失败" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/auth-sources/{id} [put] -func UpdateAuthSource(c *gin.Context) { - id, err := parseSourceID(c) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - var req AuthSourceRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - // 记录更新前的 Discovery URL,以便更新成功后清除旧缓存条目。 - existing, _ := repository.GetAuthSourceByID(c.Request.Context(), id) - - source := model.AuthSource{ - ID: id, - Name: req.Name, - Type: req.Type, - DisplayName: req.DisplayName, - IsActive: req.IsActive, - ClientID: req.ClientID, - ClientSecret: req.ClientSecret, - OpenIDDiscoveryURL: req.OpenIDDiscoveryURL, - Scopes: req.Scopes, - IconURL: req.IconURL, - } - keepSecret := source.ClientSecret == "" - if err := repository.UpdateAuthSource(c.Request.Context(), &source, keepSecret); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - // Discovery URL 可能已变更,清除旧、新 issuer 的 provider 缓存, - // 确保下次登录时重新拉取最新 OIDC 元数据。 - if existing != nil { - oauth.InvalidateOIDCProviderCache(normalizeIssuer(existing.OpenIDDiscoveryURL)) - } - oauth.InvalidateOIDCProviderCache(normalizeIssuer(req.OpenIDDiscoveryURL)) - _ = repository.InvalidateAuthSourceCache(c.Request.Context()) - - updated, err := repository.GetAuthSourceByID(c.Request.Context(), id) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - updated.Sanitize() - c.JSON(http.StatusOK, response.OK(updated)) -} - -// ToggleAuthSource 切换认证源启用状态 -// @Summary 切换认证源启用状态 -// @Description 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path uint64 true "认证源 ID 或名称" -// @Param request body auth_source.ToggleAuthSourceRequest true "启用状态" -// @Success 200 {object} response.Any{data=string} "切换成功" -// @Failure 400 {object} response.Any "验证失败或认证源不存在" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/auth-sources/{id}/toggle [put] -func ToggleAuthSource(c *gin.Context) { - id, err := parseSourceID(c) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - var req ToggleAuthSourceRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if err := repository.ToggleAuthSource(c.Request.Context(), id, req.IsActive); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - _ = repository.InvalidateAuthSourceCache(c.Request.Context()) - c.JSON(http.StatusOK, response.OKNil()) -} - -// DeleteAuthSource 删除认证源 -// @Summary 删除认证源 -// @Description 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param id path uint64 true "认证源 ID 或名称" -// @Success 200 {object} response.Any{data=string} "删除成功" -// @Failure 400 {object} response.Any "ID 无效或删除失败" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/auth-sources/{id} [delete] -func DeleteAuthSource(c *gin.Context) { - id, err := parseSourceID(c) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - if err := repository.DeleteAuthSource(c.Request.Context(), id); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - _ = repository.InvalidateAuthSourceCache(c.Request.Context()) - c.JSON(http.StatusOK, response.OKNil()) -} - -func parseSourceID(c *gin.Context) (uint64, error) { - raw := c.Param("id") - if raw == "" { - return 0, errors.New(admin.InvalidAuthSourceID) - } - source, err := repository.GetAuthSourceByName(c.Request.Context(), raw) - if err == nil { - return source.ID, nil - } - var id uint64 - if _, scanErr := fmt.Sscanf(raw, "%d", &id); scanErr != nil || id == 0 { - return 0, errors.New(admin.InvalidAuthSourceID) - } - 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/backend/OpenFlare/plugins/server/admin/auth_source/routers_test.go b/backend/OpenFlare/plugins/server/admin/auth_source/routers_test.go deleted file mode 100644 index be28bf3f..00000000 --- a/backend/OpenFlare/plugins/server/admin/auth_source/routers_test.go +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package auth_source - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -func setupTestRouter(authUser *model.User) *gin.Engine { - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin") - - // Mock authentication middleware - adminGroup.Use(func(c *gin.Context) { - if authUser != nil { - oauth.SetToContext(c, oauth.UserObjKey, authUser) - } - c.Next() - }) - - adminGroup.GET("/auth-sources", ListAuthSources) - adminGroup.POST("/auth-sources", CreateAuthSource) - adminGroup.PUT("/auth-sources/:id", UpdateAuthSource) - adminGroup.PUT("/auth-sources/:id/toggle", ToggleAuthSource) - adminGroup.DELETE("/auth-sources/:id", DeleteAuthSource) - return r -} - -func TestListAuthSources(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - // Seed source - source := model.AuthSource{ - ID: 1, - Name: "google", - Type: "oidc", - DisplayName: "Google Auth", - IsActive: true, - ClientID: "client_id_123", - ClientSecret: "client_secret_456", - OpenIDDiscoveryURL: "https://accounts.google.com", - } - dbConn.Create(&source) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - req, _ := http.NewRequest("GET", "/api/v1/admin/auth-sources", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d", w.Code) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var sources []model.AuthSource - _ = json.Unmarshal(dataBytes, &sources) - - if len(sources) != 1 { - t.Errorf("expected 1 auth source, got %d", len(sources)) - } - if sources[0].Name != "google" { - t.Errorf("expected name 'google', got '%s'", sources[0].Name) - } - // Verify sanitize removed the secret - if sources[0].ClientSecret != "" { - t.Error("client secret should be sanitized") - } - if !sources[0].ClientSecretConfigured { - t.Error("client secret configured flag should be true") - } -} - -func TestCreateAuthSource(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("create successfully", func(t *testing.T) { - reqPayload := AuthSourceRequest{ - Name: "github", - Type: "oidc", - DisplayName: "GitHub OIDC", - IsActive: true, - ClientID: "client_id_gh", - ClientSecret: "client_secret_gh", - OpenIDDiscoveryURL: "https://github.com", - } - body, _ := json.Marshal(reqPayload) - req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - // Verify database - var src model.AuthSource - dbConn.Where("name = ?", "github").First(&src) - if src.ClientID != "client_id_gh" { - t.Errorf("expected client_id_gh, got '%s'", src.ClientID) - } - }) - - t.Run("create invalid validation failure", func(t *testing.T) { - reqPayload := AuthSourceRequest{ - Name: "invalid name!", - Type: "oidc", - DisplayName: "Invalid", - IsActive: true, - ClientID: "client_id_val", - ClientSecret: "client_secret_val", - OpenIDDiscoveryURL: "https://discovery.url", - } - body, _ := json.Marshal(reqPayload) - req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d", w.Code) - } - }) -} - -func TestUpdateAuthSource(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - // Seed source - source := model.AuthSource{ - ID: 1, - Name: "microsoft", - Type: "oidc", - DisplayName: "Microsoft", - IsActive: true, - ClientID: "old_client_id", - ClientSecret: "old_secret", - OpenIDDiscoveryURL: "https://login.microsoftonline.com", - } - dbConn.Create(&source) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("update keep client secret", func(t *testing.T) { - reqPayload := AuthSourceRequest{ - Name: "microsoft", - Type: "oidc", - DisplayName: "Microsoft Updated", - IsActive: true, - ClientID: "new_client_id", - ClientSecret: "", // empty implies keeping existing secret - OpenIDDiscoveryURL: "https://login.microsoftonline.com", - } - body, _ := json.Marshal(reqPayload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var src model.AuthSource - dbConn.First(&src, 1) - if src.DisplayName != "Microsoft Updated" { - t.Errorf("expected display name update, got '%s'", src.DisplayName) - } - if src.ClientSecret != "old_secret" { - t.Errorf("expected old secret to be preserved, got '%s'", src.ClientSecret) - } - }) - - t.Run("update new client secret", func(t *testing.T) { - reqPayload := AuthSourceRequest{ - Name: "microsoft", - Type: "oidc", - DisplayName: "Microsoft Updated Again", - IsActive: true, - ClientID: "new_client_id", - ClientSecret: "brand_new_secret", - OpenIDDiscoveryURL: "https://login.microsoftonline.com", - } - body, _ := json.Marshal(reqPayload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/microsoft", bytes.NewBuffer(body)) // Using Name instead of ID - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var src model.AuthSource - dbConn.First(&src, 1) - if src.ClientSecret != "brand_new_secret" { - t.Errorf("expected secret update, got '%s'", src.ClientSecret) - } - }) -} - -func TestToggleAuthSource(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - source := model.AuthSource{ - ID: 1, - Name: "test_source", - Type: "oidc", - DisplayName: "Test Source", - IsActive: false, - ClientID: "", - ClientSecret: "", - OpenIDDiscoveryURL: "https://test.discovery.url", - } - dbConn.Create(&source) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("cannot activate without credentials", func(t *testing.T) { - payload := ToggleAuthSourceRequest{IsActive: true} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request when activating without client_id/secret, got %d", w.Code) - } - }) - - t.Run("toggle success after setting credentials", func(t *testing.T) { - // Set credentials first - dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Updates(map[string]interface{}{ - "client_id": "id", - "client_secret": "secret", - }) - - payload := ToggleAuthSourceRequest{IsActive: true} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var src model.AuthSource - dbConn.First(&src, 1) - if !src.IsActive { - t.Error("auth source should be activated") - } - }) -} - -func TestDeleteAuthSource(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - source := model.AuthSource{ - ID: 1, - Name: "delete_me", - Type: "oidc", - DisplayName: "Delete Me", - IsActive: true, - ClientID: "id", - ClientSecret: "secret", - OpenIDDiscoveryURL: "https://delete.me", - } - dbConn.Create(&source) - - externalAccount := model.ExternalAccount{ - ID: 10, - AuthSourceID: 1, - UserID: 50, - ExternalID: "ext_50", - } - dbConn.Create(&externalAccount) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - req, _ := http.NewRequest("DELETE", "/api/v1/admin/auth-sources/1", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d", w.Code) - } - - // Verify AuthSource is deleted - var srcCount int64 - dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Count(&srcCount) - if srcCount != 0 { - t.Error("AuthSource should be deleted from the database") - } - - // Verify ExternalAccount bindings are also deleted - var extCount int64 - dbConn.Model(&model.ExternalAccount{}).Where("auth_source_id = ?", 1).Count(&extCount) - if extCount != 0 { - t.Error("related ExternalAccount bindings should be deleted") - } -} diff --git a/backend/OpenFlare/plugins/server/admin/cache/logics.go b/backend/OpenFlare/plugins/server/admin/cache/logics.go deleted file mode 100644 index eb5ffc79..00000000 --- a/backend/OpenFlare/plugins/server/admin/cache/logics.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cache - -import ( - "context" - - "Wavelet/OpenFlare/plugins/server/repository" -) - -func saveOrUpdateConfig(ctx context.Context, key, value string) error { - return repository.SaveOrUpdateSystemConfig(ctx, key, value) -} diff --git a/backend/OpenFlare/plugins/server/admin/cache/routers.go b/backend/OpenFlare/plugins/server/admin/cache/routers.go deleted file mode 100644 index 8a9433f2..00000000 --- a/backend/OpenFlare/plugins/server/admin/cache/routers.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package cache provides HTTP handlers for managing disk cache. -package cache - -import ( - "net/http" - "strconv" - - "Wavelet/OpenFlare/plugins/server/infra/diskcache" - "Wavelet/OpenFlare/plugins/server/model" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -type updateCacheConfigRequest struct { - MaxSizeMB int64 `json:"max_size_mb" binding:"required,min=1"` - TTLMinutes int64 `json:"ttl_minutes" binding:"required,min=0"` - LRUEnabled bool `json:"lru_enabled"` -} - -// GetCacheStatus 获取磁盘缓存状态与当前统计数据 -// @Summary 获取缓存状态 -// @Description 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=diskcache.Status} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/cache/status [get] -func GetCacheStatus(c *gin.Context) { - status := diskcache.GetGlobalCache().Status() - c.JSON(http.StatusOK, response.OK(status)) -} - -// UpdateCacheConfig 更新磁盘缓存策略配置 -// @Summary 更新缓存配置 -// @Description 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新 -// @Tags admin -// @Accept json -// @Produce json -// @Param request body cache.updateCacheConfigRequest true "缓存配置请求体" -// @Security SessionCookie -// @Success 200 {object} response.Any "更新成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "服务内部错误" -// @Router /api/v1/admin/cache/config [post] -func UpdateCacheConfig(c *gin.Context) { - var req updateCacheConfigRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - ctx := c.Request.Context() - - if err := saveOrUpdateConfig(ctx, model.ConfigKeyDiskCacheMaxSizeMB, strconv.FormatInt(req.MaxSizeMB, 10)); err != nil { - response.AbortInternal(c, err.Error()) - return - } - - if err := saveOrUpdateConfig(ctx, model.ConfigKeyDiskCacheTTLMinutes, strconv.FormatInt(req.TTLMinutes, 10)); err != nil { - response.AbortInternal(c, err.Error()) - return - } - - if err := saveOrUpdateConfig(ctx, model.ConfigKeyDiskCacheLRUEnabled, strconv.FormatBool(req.LRUEnabled)); err != nil { - response.AbortInternal(c, err.Error()) - return - } - - diskcache.GetGlobalCache().ReloadConfig(ctx) - - c.JSON(http.StatusOK, response.OKNil()) -} - -// ClearCache 一键清空所有磁盘缓存数据 -// @Summary 清空缓存 -// @Description 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any "清理成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "服务内部错误" -// @Router /api/v1/admin/cache/clear [post] -func ClearCache(c *gin.Context) { - if err := diskcache.GetGlobalCache().Clear(); err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/admin/db_manage/routers.go b/backend/OpenFlare/plugins/server/admin/db_manage/routers.go deleted file mode 100644 index 66838049..00000000 --- a/backend/OpenFlare/plugins/server/admin/db_manage/routers.go +++ /dev/null @@ -1,484 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package db_manage provides router handlers for managing database tables, -// overview information, and executing custom SQL queries. -package db_manage - -import ( - "database/sql" - "fmt" - "math" - "net/http" - "os" - "strings" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "Wavelet/pkg/response" -) - -const ( - binaryKB = 0 - binaryMB = 1 - binaryGB = 2 - valueThreshold = 10 - maxStringLength = 200 -) - -// DBOverviewResponse 数据库运行概览响应结构体 -type DBOverviewResponse struct { - Type string `json:"type"` - Version string `json:"version"` - Name string `json:"name"` - Size string `json:"size"` - TableCount int64 `json:"table_count"` - Connections int64 `json:"connections"` -} - -// GetTableDataRequest 分页拉取表数据请求结构体 -type GetTableDataRequest struct { - Table string `form:"table" binding:"required"` - Page int `form:"page,default=1"` - PageSize int `form:"pageSize,default=10"` -} - -// TableDataResponse 动态数据表响应结构体 -type TableDataResponse struct { - Columns []string `json:"columns"` - Total int64 `json:"total"` - Results []map[string]any `json:"results"` -} - -// ExecuteSQLRequest 执行自定义 SQL 请求结构体 -type ExecuteSQLRequest struct { - SQL string `json:"sql" binding:"required"` -} - -// ExecuteSQLResponse 执行自定义 SQL 响应结构体 -type ExecuteSQLResponse struct { - Type string `json:"type"` // "select" 或 "exec" - Columns []string `json:"columns,omitempty"` - Results []map[string]any `json:"results,omitempty"` - AffectedRows int64 `json:"affected_rows"` - ExecutionTimeMs int64 `json:"execution_time_ms"` -} - -// formatBytes 格式化字节大小为可读字符串 -func formatBytes(bytes uint64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - value := float64(bytes) / float64(div) - var suffix string - switch exp { - case binaryKB: - suffix = "KiB" - case binaryMB: - suffix = "MiB" - case binaryGB: - suffix = "GiB" - default: - suffix = "TiB" - } - - if value == math.Trunc(value) { - if value >= valueThreshold { - return fmt.Sprintf("%.0f %s", value, suffix) - } - return fmt.Sprintf("%.1f %s", value, suffix) - } - return fmt.Sprintf("%.1f %s", value, suffix) -} - -// getSQLiteOverview 获取 SQLite 数据库概览信息 -func getSQLiteOverview(gormDB *gorm.DB) DBOverviewResponse { - name := config.Config.Database.SQLitePath - if name == "" { - name = "./data/openflare.db" - } - - var version string - var ver string - if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil { - version = "SQLite " + ver - } else { - version = "SQLite" - } - - var sizeStr string - if fi, err := os.Stat(name); err == nil { - size := max(fi.Size(), 0) - sizeStr = formatBytes(uint64(size)) - } else { - sizeStr = "0 B" - } - - var tableCount int64 - if err := gormDB.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&tableCount).Error; err != nil { - tableCount = 0 - } - - var connCount int64 - if sqlDB, err := gormDB.DB(); err == nil { - connCount = int64(sqlDB.Stats().OpenConnections) - } else { - connCount = 1 - } - - return DBOverviewResponse{ - Type: "sqlite", - Version: version, - Name: name, - Size: sizeStr, - TableCount: tableCount, - Connections: connCount, - } -} - -// getPostgresOverview 获取 PostgreSQL 数据库概览信息 -func getPostgresOverview(gormDB *gorm.DB) DBOverviewResponse { - name := config.Config.Database.Database - - var version string - var ver string - if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil { - version = ver - } else { - version = "PostgreSQL" - } - - var sizeStr string - var sizeBytes sql.NullInt64 - if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid { - size := max(sizeBytes.Int64, 0) - sizeStr = formatBytes(uint64(size)) - } else { - sizeStr = "0 B" - } - - var tableCount int64 - if err := gormDB.Raw("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()").Scan(&tableCount).Error; err != nil { - tableCount = 0 - } - - var connCount int64 - var pgc sql.NullInt64 - if err := gormDB.Raw("SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()").Scan(&pgc).Error; err == nil && pgc.Valid { - connCount = pgc.Int64 - } else { - if sqlDB, err := gormDB.DB(); err == nil { - connCount = int64(sqlDB.Stats().OpenConnections) - } else { - connCount = 1 - } - } - - return DBOverviewResponse{ - Type: "postgres", - Version: version, - Name: name, - Size: sizeStr, - TableCount: tableCount, - Connections: connCount, - } -} - -// GetDBOverview 获取数据库运行概览 -// @Summary 获取数据库运行概览 -// @Description 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=db_manage.DBOverviewResponse} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/db-manage/overview [get] -func GetDBOverview(c *gin.Context) { - gormDB := db.DB(c.Request.Context()) - if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") - return - } - - var overview DBOverviewResponse - - if !config.Config.Database.Enabled { - overview = getSQLiteOverview(gormDB) - } else { - overview = getPostgresOverview(gormDB) - } - - c.JSON(http.StatusOK, response.OK(overview)) -} - -// ListDBTables 获取数据库所有表名 -// @Summary 获取数据库所有表名 -// @Description 返回当前数据库的所有用户自定义表名称列表,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]string} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/db-manage/tables [get] -func ListDBTables(c *gin.Context) { - gormDB := db.DB(c.Request.Context()) - if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") - return - } - - var tables []string - var err error - - if !config.Config.Database.Enabled { - err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error - } else { - err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error - } - - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(tables)) -} - -// GetDBTableData 获取数据表 data -func GetDBTableData(c *gin.Context) { - var req GetTableDataRequest - if err := c.ShouldBindQuery(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - gormDB := db.DB(c.Request.Context()) - if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") - return - } - - // 安全转义表名并拼接 - quotedTable := `"` + strings.ReplaceAll(req.Table, `"`, `""`) + `"` - - var total int64 - if err := gormDB.Raw("SELECT count(*) FROM " + quotedTable).Scan(&total).Error; err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - offset := max((req.Page-1)*req.PageSize, 0) - limit := req.PageSize - if limit <= 0 { - limit = 10 - } - - rows, err := gormDB.Raw("SELECT * FROM "+quotedTable+" LIMIT ? OFFSET ?", limit, offset).Rows() - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - defer func() { - _ = rows.Close() - }() - - cols, err := rows.Columns() - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - results, err := scanTableRows(rows, cols) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(TableDataResponse{ - Columns: cols, - Total: total, - Results: results, - })) -} - -// scanTableRows 扫描并提取数据表行数据,做截断处理 -func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]any, error) { - results := make([]map[string]any, 0) - for rows.Next() { - columns := make([]any, len(cols)) - columnPointers := make([]any, len(cols)) - for i := range columns { - columnPointers[i] = &columns[i] - } - - if err := rows.Scan(columnPointers...); err != nil { - return nil, err - } - - rowMap := make(map[string]any) - for i, colName := range cols { - val := columns[i] - if b, ok := val.([]byte); ok { - strVal := string(b) - runes := []rune(strVal) - if len(runes) > maxStringLength { - strVal = string(runes[:maxStringLength]) + "..." - } - rowMap[colName] = strVal - } else if str, ok := val.(string); ok { - runes := []rune(str) - if len(runes) > maxStringLength { - str = string(runes[:maxStringLength]) + "..." - } - rowMap[colName] = str - } else { - rowMap[colName] = val - } - } - results = append(results, rowMap) - } - return results, nil -} - -// executeSQLQuery 执行并解析查询类 SQL 语句 -func executeSQLQuery(gormDB *gorm.DB, sqlStr string, startTime time.Time) (ExecuteSQLResponse, error) { - rows, err := gormDB.Raw(sqlStr).Rows() - if err != nil { - return ExecuteSQLResponse{}, err - } - defer func() { - _ = rows.Close() - }() - - cols, err := rows.Columns() - if err != nil { - return ExecuteSQLResponse{}, err - } - - results := make([]map[string]any, 0) - for rows.Next() { - columns := make([]any, len(cols)) - columnPointers := make([]any, len(cols)) - for i := range columns { - columnPointers[i] = &columns[i] - } - - if err := rows.Scan(columnPointers...); err != nil { - return ExecuteSQLResponse{}, err - } - - rowMap := make(map[string]any) - for i, colName := range cols { - val := columns[i] - if b, ok := val.([]byte); ok { - rowMap[colName] = string(b) - } else { - rowMap[colName] = val - } - } - results = append(results, rowMap) - } - - executionTime := time.Since(startTime).Milliseconds() - return ExecuteSQLResponse{ - Type: "select", - Columns: cols, - Results: results, - AffectedRows: int64(len(results)), - ExecutionTimeMs: executionTime, - }, nil -} - -// executeSQLMutation 执行修改/更新类 SQL 语句 -func executeSQLMutation(gormDB *gorm.DB, sqlStr string, startTime time.Time) (ExecuteSQLResponse, error) { - tx := gormDB.Exec(sqlStr) - if tx.Error != nil { - return ExecuteSQLResponse{}, tx.Error - } - - executionTime := time.Since(startTime).Milliseconds() - return ExecuteSQLResponse{ - Type: "exec", - AffectedRows: tx.RowsAffected, - ExecutionTimeMs: executionTime, - }, nil -} - -// ExecuteSQL 执行 SQL 查询 -// @Summary 执行 SQL 查询 -// @Description 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body db_manage.ExecuteSQLRequest true "SQL 请求参数" -// @Success 200 {object} response.Any{data=db_manage.ExecuteSQLResponse} "执行完毕" -// @Failure 400 {object} response.Any "SQL 语句错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/db-manage/query [post] -func ExecuteSQL(c *gin.Context) { - var req ExecuteSQLRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - gormDB := db.DB(c.Request.Context()) - if gormDB == nil { - response.AbortInternal(c, "数据库未初始化") - return - } - - trimmedSQL := strings.TrimSpace(req.SQL) - if trimmedSQL == "" { - response.AbortBadRequest(c, "SQL 语句不能为空") - return - } - - startTime := time.Now() - - // 识别是否是查询语句(SELECT, SHOW, EXPLAIN 等) - isQuery := false - lowerSQL := strings.ToLower(trimmedSQL) - queryKeywords := []string{"select", "show", "explain", "describe", "pragma"} - for _, kw := range queryKeywords { - if strings.HasPrefix(lowerSQL, kw) { - isQuery = true - break - } - } - - var resp ExecuteSQLResponse - var err error - - if isQuery { - resp, err = executeSQLQuery(gormDB, trimmedSQL, startTime) - } else { - resp, err = executeSQLMutation(gormDB, trimmedSQL, startTime) - } - - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(resp)) -} diff --git a/backend/OpenFlare/plugins/server/admin/errs.go b/backend/OpenFlare/plugins/server/admin/errs.go deleted file mode 100644 index c869ed34..00000000 --- a/backend/OpenFlare/plugins/server/admin/errs.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package admin 提供管理后台功能 -package admin - -// 管理后台错误消息常量 -const ( - AdminRequired = "未经授权访问" - TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - InvalidAuthSourceID = "认证源 ID 无效" - InvalidCursorParam = "无效的 cursor 参数" - InvalidTaskExecutionID = "无效的任务执行记录 ID" -) diff --git a/backend/OpenFlare/plugins/server/admin/logs/routers.go b/backend/OpenFlare/plugins/server/admin/logs/routers.go deleted file mode 100644 index aea910aa..00000000 --- a/backend/OpenFlare/plugins/server/admin/logs/routers.go +++ /dev/null @@ -1,430 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package logs 提供日志查询与分析功能 -package logs - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "Wavelet/OpenFlare/plugins/server/admin" - analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/repository/logstore" - "Wavelet/pkg/logger" - "Wavelet/pkg/util" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -const ( - defaultLimit = 200 - maxLimit = 500 - maxPageSize = 100 - hoursInDay = 24 - analyticsDays = 7 - topActiveLimit = 10 -) - -// logsResponse 历史日志查询响应 -type logsResponse struct { - Lines []logger.LogEntry `json:"lines"` - HasMore bool `json:"has_more"` - NextCursor int `json:"next_cursor"` // 用于加载更早日志的 cursor -} - -// GetLogs 获取历史日志 -// @Summary 获取系统日志 -// @Description 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param cursor query int false "日志游标,0=获取最新" default(0) -// @Param limit query int false "每页条数" default(200) -// @Success 200 {object} response.Any{data=logs.logsResponse} "日志列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/logs [get] -func GetLogs(c *gin.Context) { - cursorStr := c.DefaultQuery("cursor", "0") - limitStr := c.DefaultQuery("limit", "200") - - var cursor, limit int - if err := parsePositiveInt(cursorStr, &cursor); err != nil { - response.AbortWithError(c, http.StatusBadRequest, admin.InvalidCursorParam) - return - } - if err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 { - limit = defaultLimit - } - if limit > maxLimit { - limit = maxLimit - } - - entries, hasMore := logger.GlobalRingBuffer.Query(cursor, limit) - - resp := logsResponse{ - Lines: entries, - HasMore: hasMore, - } - if len(entries) > 0 { - resp.NextCursor = entries[0].Index - } - - c.JSON(http.StatusOK, response.OK(resp)) -} - -// wsMessage WebSocket 消息格式 -type wsMessage struct { - Type string `json:"type"` // "log" | "error" - Data json.RawMessage `json:"data"` -} - -// HandleLogWebSocket WebSocket 端点,实时推送系统日志 -// @Summary 系统日志实时推送 -// @Description 通过 WebSocket 实时推送系统日志,需要管理员权限 -// @Tags admin -// @Router /api/v1/admin/logs/ws [get] -func HandleLogWebSocket(c *gin.Context) { - upgrader := getUpgrader() - - conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) - if err != nil { - return - } - defer func() { _ = conn.Close() }() - - // 订阅 ring buffer - ch := logger.GlobalRingBuffer.Subscribe() - defer logger.GlobalRingBuffer.Unsubscribe(ch) - - // 在独立 goroutine 中读取客户端消息(保持连接活跃 + 检测断开) - done := make(chan struct{}) - util.Go(func() { - defer close(done) - for { - _, _, err := conn.ReadMessage() - if err != nil { - return - } - } - }) - - // 主循环:推送日志 - for { - select { - case <-done: - return - case entry, ok := <-ch: - if !ok { - return - } - data, _ := json.Marshal(entry) - msg := wsMessage{Type: "log", Data: data} - payload, _ := json.Marshal(msg) - if err := conn.WriteMessage(1, payload); err != nil { - return - } - } - } -} - -// accessLogItem 访问日志单条数据 -type accessLogItem struct { - ID uint64 `json:"id,string"` - UserID uint64 `json:"user_id,string"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Path string `json:"path"` - Method string `json:"method"` - IP string `json:"ip"` - UserAgent string `json:"user_agent"` - Headers string `json:"headers"` - Status int32 `json:"status"` - Latency int64 `json:"latency"` - CreatedAt string `json:"created_at"` -} - -// accessLogsResponse 访问日志查询响应 -type accessLogsResponse struct { - Total uint64 `json:"total"` - List []accessLogItem `json:"list"` -} - -func buildAccessLogFilter(ctx context.Context, c *gin.Context) (analyticsmodel.AccessLogFilter, error) { - filter := analyticsmodel.AccessLogFilter{} - - username := c.Query("username") - if username != "" { - userIDs, err := repository.ListUserIDsByUsernameContains(ctx, username) - if err != nil { - return filter, fmt.Errorf("查询用户信息失败: %w", err) - } - filter.UserIDs = userIDs - } - - if path := c.Query("path"); path != "" { - filter.Path = path - } - - if startTime := c.Query("start_time"); startTime != "" { - if t, err := parseAccessLogTime(startTime); err == nil { - filter.StartTime = &t - } - } - - if endTime := c.Query("end_time"); endTime != "" { - if t, err := parseAccessLogTime(endTime); err == nil { - filter.EndTime = &t - } - } - - return filter, nil -} - -func parseAccessLogTime(value string) (time.Time, error) { - if t, err := time.Parse(time.RFC3339, value); err == nil { - return t, nil - } - return time.Parse("2006-01-02 15:04:05", value) -} - -func enrichAccessLogsWithUsers(ctx context.Context, list []accessLogItem) { - if len(list) == 0 { - return - } - - userIDs := make([]uint64, 0, len(list)) - seen := make(map[uint64]struct{}, len(list)) - for _, item := range list { - if _, ok := seen[item.UserID]; ok { - continue - } - seen[item.UserID] = struct{}{} - userIDs = append(userIDs, item.UserID) - } - - userMap := make(map[uint64]struct{ Username, Nickname string }) - if users, err := repository.ListUsersByIDs(ctx, userIDs); err == nil { - for _, u := range users { - userMap[u.ID] = struct{ Username, Nickname string }{Username: u.Username, Nickname: u.Nickname} - } - } - for i := range list { - if info, ok := userMap[list[i].UserID]; ok { - list[i].Username = info.Username - list[i].Nickname = info.Nickname - } - } -} - -// GetAccessLogs 获取 ClickHouse 异步采集的访问日志 -// @Summary 获取用户访问日志 -// @Description 分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限,日志存储未启用时报错) -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param page query int false "页码" default(1) -// @Param page_size query int false "每页条数" default(20) -// @Param username query string false "用户名模糊搜索" -// @Param path query string false "接口路径模糊搜索" -// @Param start_time query string false "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)" -// @Param end_time query string false "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)" -// @Success 200 {object} response.Any{data=logs.accessLogsResponse} "访问日志列表" -// @Failure 400 {object} response.Any "日志存储未启用或参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/logs/access [get] -func GetAccessLogs(c *gin.Context) { - ctx := c.Request.Context() - store, err := logstore.Active(ctx) - if err != nil { - logger.ErrorF(ctx, "获取日志存储实例失败: %v", err) - response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法检索访问日志") - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - if pageSize < 1 { - pageSize = 20 - } - if pageSize > maxPageSize { - pageSize = maxPageSize - } - - filter, err := buildAccessLogFilter(ctx, c) - if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, err.Error()) - return - } - if filter.UserIDs != nil && len(filter.UserIDs) == 0 { - c.JSON(http.StatusOK, response.OK(accessLogsResponse{Total: 0, List: []accessLogItem{}})) - return - } - - logs, total, err := store.UserAccessLogs.List(ctx, filter, page, pageSize) - if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, err.Error()) - return - } - if total == 0 { - c.JSON(http.StatusOK, response.OK(accessLogsResponse{Total: 0, List: []accessLogItem{}})) - return - } - - list := make([]accessLogItem, len(logs)) - for i, logItem := range logs { - list[i] = accessLogItem{ - ID: logItem.ID, - UserID: logItem.UserID, - Path: logItem.Path, - Method: logItem.Method, - IP: logItem.IP, - UserAgent: logItem.UserAgent, - Headers: logItem.Headers, - Status: logItem.Status, - Latency: logItem.Latency, - CreatedAt: logItem.CreatedAt.Format(time.RFC3339), - } - } - enrichAccessLogsWithUsers(ctx, list) - - c.JSON(http.StatusOK, response.OK(accessLogsResponse{ - Total: total, - List: list, - })) -} - -// trendItem 趋势图数据点 -type trendItem struct { - Date string `json:"date"` - Count uint64 `json:"count"` -} - -// browserItem 浏览器占比排行 -type browserItem struct { - Browser string `json:"browser"` - Count uint64 `json:"count"` -} - -// topUserItem 活跃用户数据 -type topUserItem struct { - UserID uint64 `json:"user_id,string"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Count uint64 `json:"count"` -} - -// logsAnalyticsResponse 访问日志数据分析结果 -type logsAnalyticsResponse struct { - Trend []trendItem `json:"trend"` - Browsers []browserItem `json:"browsers"` - TopUsers []topUserItem `json:"top_users"` -} - -// GetLogsAnalytics 获取 ClickHouse 访问日志图表聚合指标 -// @Summary 获取访问日志分析数据 -// @Description 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,日志存储未启用时报错) -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=logs.logsAnalyticsResponse} "分析统计数据" -// @Failure 400 {object} response.Any "日志存储未启用" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/logs/analytics [get] -func GetLogsAnalytics(c *gin.Context) { - ctx := c.Request.Context() - store, err := logstore.Active(ctx) - if err != nil { - logger.ErrorF(ctx, "获取日志存储实例失败: %v", err) - response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法获取分析数据") - return - } - - startTime := time.Now().AddDate(0, 0, -(analyticsDays - 1)).Truncate(hoursInDay * time.Hour) - - trendPoints, err := store.UserAccessLogs.GetDailyTrend(ctx, analyticsDays) - if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, "查询访问趋势失败: "+err.Error()) - return - } - trendList := make([]trendItem, len(trendPoints)) - for i, point := range trendPoints { - trendList[i] = trendItem{ - Date: point.Date, - Count: point.Count, - } - } - - browserPoints, err := store.UserAccessLogs.GetBrowserDistribution(ctx, startTime) - if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, "查询浏览器分布失败: "+err.Error()) - return - } - browserList := make([]browserItem, len(browserPoints)) - for i, point := range browserPoints { - browserList[i] = browserItem{ - Browser: point.Browser, - Count: point.Count, - } - } - - topUserPoints, err := store.UserAccessLogs.GetTopActiveUsers(ctx, startTime, topActiveLimit) - if err != nil { - response.AbortWithError(c, http.StatusInternalServerError, "查询活跃用户失败: "+err.Error()) - return - } - - topUsers := make([]topUserItem, len(topUserPoints)) - userIDs := make([]uint64, len(topUserPoints)) - for i, point := range topUserPoints { - topUsers[i] = topUserItem{ - UserID: point.UserID, - Count: point.Count, - } - userIDs[i] = point.UserID - } - - if len(userIDs) > 0 { - userProfileMap := make(map[uint64]struct { - Username string - Nickname string - }) - if users, errProfile := repository.ListUsersByIDs(ctx, userIDs); errProfile == nil { - for _, u := range users { - userProfileMap[u.ID] = struct { - Username string - Nickname string - }{ - Username: u.Username, - Nickname: u.Nickname, - } - } - } - for i := range topUsers { - if profile, ok := userProfileMap[topUsers[i].UserID]; ok { - topUsers[i].Username = profile.Username - topUsers[i].Nickname = profile.Nickname - } - } - } - - c.JSON(http.StatusOK, response.OK(logsAnalyticsResponse{ - Trend: trendList, - Browsers: browserList, - TopUsers: topUsers, - })) -} diff --git a/backend/OpenFlare/plugins/server/admin/logs/utils.go b/backend/OpenFlare/plugins/server/admin/logs/utils.go deleted file mode 100644 index 6b86922c..00000000 --- a/backend/OpenFlare/plugins/server/admin/logs/utils.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package logs - -import ( - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/gorilla/websocket" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" -) - -// getUpgrader 返回 WebSocket 升级器并执行 Origin 安全检查以防止 CSWSH 攻击 -func getUpgrader() *websocket.Upgrader { - return &websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - origin := r.Header.Get("Origin") - if origin == "" { - return true - } - - // 1. 同源检查 (Same-origin check) - u, err := url.Parse(origin) - if err == nil && strings.EqualFold(u.Host, r.Host) { - return true - } - - // 2. 检查配置的允许跨域 Origin (Check allowed origins in system config) - ctx := r.Context() - if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress); err == nil && sc.Value != "" { - originToCheck := strings.TrimRight(strings.TrimSpace(origin), "/") - allowedOrigins := strings.SplitSeq(sc.Value, ",") - for allowed := range allowedOrigins { - allowed = strings.TrimRight(strings.TrimSpace(allowed), "/") - if allowed != "" && strings.EqualFold(allowed, originToCheck) { - return true - } - } - } - return false - }, - } -} - -// parsePositiveInt 解析非负整数字符串 -func parsePositiveInt(s string, result *int) error { - if s == "" { - *result = 0 - return nil - } - n, err := strconv.Atoi(s) - if err != nil || n < 0 { - return err - } - *result = n - return nil -} diff --git a/backend/OpenFlare/plugins/server/admin/logs/utils_test.go b/backend/OpenFlare/plugins/server/admin/logs/utils_test.go deleted file mode 100644 index fc087218..00000000 --- a/backend/OpenFlare/plugins/server/admin/logs/utils_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package logs - -import ( - "context" - "net/http" - "testing" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" -) - -func setupTestDB(t *testing.T) *gorm.DB { - t.Helper() - dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("failed to open sqlite in memory: %v", err) - } - err = dbConn.AutoMigrate(&model.SystemConfig{}) - if err != nil { - t.Fatalf("failed to migrate schema: %v", err) - } - db.SetDB(dbConn) - return dbConn -} - -func TestWebSocketCheckOrigin(t *testing.T) { - dbConn := setupTestDB(t) - - // Clean up global DB after test - defer db.SetDB(nil) - - // Seed ConfigKeyServerAddress with allowed frontend origin - allowedOrigin := "http://localhost:3000" - if err := dbConn.Create(&model.SystemConfig{ - Key: model.ConfigKeyServerAddress, - Value: allowedOrigin, - }).Error; err != nil { - t.Fatalf("failed to seed server address config: %v", err) - } - - upgrader := getUpgrader() - if upgrader.CheckOrigin == nil { - t.Fatal("expected CheckOrigin to be defined") - } - - tests := []struct { - name string - origin string - host string - wantOK bool - }{ - { - name: "empty origin (non-browser clients)", - origin: "", - host: "localhost:8000", - wantOK: true, - }, - { - name: "same-origin request", - origin: "http://localhost:8000", - host: "localhost:8000", - wantOK: true, - }, - { - name: "same-origin request case insensitive", - origin: "HTTP://LOCALHOST:8000", - host: "localhost:8000", - wantOK: true, - }, - { - name: "configured allowed origin request", - origin: "http://localhost:3000", - host: "localhost:8000", - wantOK: true, - }, - { - name: "configured allowed origin request with trailing slash", - origin: "http://localhost:3000/", - host: "localhost:8000", - wantOK: true, - }, - { - name: "unauthorized third-party origin", - origin: "http://evil.com", - host: "localhost:8000", - wantOK: false, - }, - { - name: "invalid origin format", - origin: "::not-a-valid-url", - host: "localhost:8000", - wantOK: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := http.NewRequestWithContext(context.Background(), "GET", "/api/v1/admin/logs/ws", nil) - if err != nil { - t.Fatalf("failed to create request: %v", err) - } - req.Host = tt.host - if tt.origin != "" { - req.Header.Set("Origin", tt.origin) - } - - got := upgrader.CheckOrigin(req) - if got != tt.wantOK { - t.Errorf("CheckOrigin() = %v, want %v", got, tt.wantOK) - } - }) - } -} diff --git a/backend/OpenFlare/plugins/server/admin/middlewares.go b/backend/OpenFlare/plugins/server/admin/middlewares.go deleted file mode 100644 index 271916b8..00000000 --- a/backend/OpenFlare/plugins/server/admin/middlewares.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package admin - -import ( - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/logger" - "Wavelet/pkg/response" - otel_trace "Wavelet/pkg/trace" - - "Wavelet/OpenFlare/plugins/server/oauth" - - "github.com/gin-gonic/gin" -) - -// LoginAdminRequired 返回管理员权限校验中间件 -func LoginAdminRequired() gin.HandlerFunc { - return func(c *gin.Context) { - // init trace - ctx, span := otel_trace.Start(c.Request.Context(), "LoginAdminRequired") - defer span.End() - - user, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey) - - // 如果是通过 Access Token 鉴权,需要检查令牌本身是否具有管理员权限 - if tokenAuth, _ := oauth.GetFromContext[bool](c, oauth.TokenAuthKey); tokenAuth { - tokenAdmin, _ := oauth.GetFromContext[bool](c, oauth.TokenAdminKey) - if !tokenAdmin { - response.AbortNotFound(c, TokenAdminRequired) - return - } - } - - if !user.IsAdmin { - response.AbortNotFound(c, AdminRequired) - return - } - - // log - logger.InfoF(ctx, "[LoginAdminRequired] %d %s", user.ID, user.Username) - - // next - c.Next() - } -} diff --git a/backend/OpenFlare/plugins/server/admin/push/channels.go b/backend/OpenFlare/plugins/server/admin/push/channels.go deleted file mode 100644 index 1f5cbdb2..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/channels.go +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "encoding/json" - "errors" - "net/http" - "strconv" - "strings" - - pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/response" -) - -// ListChannelDefinitions 获取各种消息通道的表单配置定义列表 -// @Summary 获取所有消息通道配置字段定义 -// @Description 返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]Definition} "通道配置定义列表" -// @Router /api/v1/admin/push/channels/definitions [get] -func ListChannelDefinitions(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(ListDefinitions())) -} - -// ListChannels 获取消息通道列表 -// @Summary 获取所有消息通道 -// @Description 返回系统配置的所有消息通道列表,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.PushChannel} "消息通道列表" -// @Router /api/v1/admin/push/channels [get] -func ListChannels(c *gin.Context) { - channels, err := listPushChannels(c.Request.Context()) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(channels)) -} - -// CreateChannelRequest 创建通道参数 -type CreateChannelRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Type string `json:"type" binding:"required"` - Token string `json:"token"` - URL string `json:"url"` - Other string `json:"other"` - Enabled bool `json:"enabled"` -} - -// CreateChannel 创建消息通道 -// @Summary 创建消息通道 -// @Description 新建一个消息通道配置,需要管理员权限 -// @Tags admin-push -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body CreateChannelRequest true "创建参数" -// @Success 200 {object} response.Any{data=model.PushChannel} "创建成功" -// @Router /api/v1/admin/push/channels [post] -func CreateChannel(c *gin.Context) { - var req CreateChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - channel, err := createPushChannel(c.Request.Context(), req) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(channel)) -} - -// UpdateChannelRequest 修改通道参数 -type UpdateChannelRequest struct { - Description string `json:"description"` - Type string `json:"type" binding:"required"` - Token string `json:"token"` - URL string `json:"url"` - Other string `json:"other"` - Enabled bool `json:"enabled"` -} - -// UpdateChannel 更新消息通道 -// @Summary 更新消息通道 -// @Description 修改消息通道配置,需要管理员权限 -// @Tags admin-push -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path uint64 true "通道ID" -// @Param request body UpdateChannelRequest true "更新参数" -// @Success 200 {object} response.Any{data=model.PushChannel} "更新成功" -// @Router /api/v1/admin/push/channels/{id} [put] -func UpdateChannel(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "invalid channel id") - return - } - - var req UpdateChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - channel, err := updatePushChannel(c.Request.Context(), id, req) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "channel not found") - return - } - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(channel)) -} - -// DeleteChannel 删除消息通道 -// @Summary 删除消息通道 -// @Description 根据ID删除消息通道,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Param id path uint64 true "通道ID" -// @Success 200 {object} response.Any "删除成功" -// @Router /api/v1/admin/push/channels/{id} [delete] -func DeleteChannel(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "invalid channel id") - return - } - - if err := deletePushChannel(c.Request.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "channel not found") - return - } - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} - -// TestChannelRequest 测试通道连通性参数 -type TestChannelRequest struct { - Name string `json:"name"` - Type string `json:"type"` - Token string `json:"token"` - URL string `json:"url"` - Other string `json:"other"` - Target string `json:"target"` -} - -// TestChannel 测试通道连通性 -// @Summary 测试通道连通性 -// @Description 触发一次临时的或现有的通道连通性推送测试,需要管理员权限 -// @Tags admin-push -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body TestChannelRequest true "测试参数" -// @Success 200 {object} response.Any "测试触发成功" -// @Router /api/v1/admin/push/channels/test [post] -func TestChannel(c *gin.Context) { - var req TestChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - ctx := c.Request.Context() - url, token, other, channelType, err := loadChannelForTest(ctx, req) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if channelType == channelEmail { - url, token, other = resolveSMTPConfig(ctx, url, token, other) - } - - tempChannel := model.PushChannel{ - Name: "test_temp", - URL: url, - Token: token, - Other: other, - Type: channelType, - Enabled: true, - } - if err := tempChannel.Validate(); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - url = tempChannel.URL - - var config pkgpush.Config - var renderedJSON string - switch channelType { - case channelLark: - config = pkgpush.Config{Channel: channelLark, URL: url, Secret: token} - renderedJSON = other - case channelEmail: - config = pkgpush.Config{Channel: channelEmail, URL: url, Key: token, Secret: other} - case channelTelegram: - config = pkgpush.Config{Channel: channelTelegram, URL: url, Secret: token, Key: other} - default: - config = pkgpush.Config{Channel: channelCustom, URL: url} - customPushReq := CustomPushRequest{ - Title: "通道测试通知", - Content: "这是一条来自系统的消息通道连通性测试消息。", - Description: "系统通道测试", - URL: "https://example.com", - To: req.Target, - } - renderedJSON = renderCustomPayload(other, customPushReq) - } - - payload := SendPayload{ - EventKey: "test_channel", - Config: config, - Target: req.Target, - Body: NotificationMessage{ - Title: "通道测试通知", - Content: "这是一条来自系统的消息通道连通性测试消息。", - Level: defaultLevelInfo, - }, - Template: renderedJSON, - } - if err := enqueuePushTask(ctx, payload); err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} - -// CustomPushRequest 外部公开推送请求参数 -type CustomPushRequest struct { - Title string `json:"title" form:"title"` - Description string `json:"description" form:"description"` - Content string `json:"content" form:"content"` - URL string `json:"url" form:"url"` - To string `json:"to" form:"to"` - Token string `json:"token" form:"token"` -} - -func escapeJSONString(s string) string { - b, _ := json.Marshal(s) - const minJSONLen = 2 - if len(b) >= minJSONLen { - return string(b[1 : len(b)-1]) - } - return s -} - -func renderCustomPayload(template string, req CustomPushRequest) string { - result := template - result = strings.ReplaceAll(result, "$title", escapeJSONString(req.Title)) - result = strings.ReplaceAll(result, "$description", escapeJSONString(req.Description)) - result = strings.ReplaceAll(result, "$content", escapeJSONString(req.Content)) - result = strings.ReplaceAll(result, "$url", escapeJSONString(req.URL)) - result = strings.ReplaceAll(result, "$to", escapeJSONString(req.To)) - return result -} diff --git a/backend/OpenFlare/plugins/server/admin/push/channels_definition.go b/backend/OpenFlare/plugins/server/admin/push/channels_definition.go deleted file mode 100644 index 80646f86..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/channels_definition.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "slices" - "sync" -) - -const ( - // KeyURL represents the URL field key - KeyURL = "url" - // KeyToken represents the Token field key - KeyToken = "token" - // KeyOther represents the Other field key - KeyOther = "other" - - // TypeText represents standard text input type - TypeText = "text" - // TypePassword represents password input type - TypePassword = "password" - // TypeTextarea represents textarea input type - TypeTextarea = "textarea" -) - -// Field represents a form field configuration for a channel. -type Field struct { - Key string `json:"key"` // unique key for the field (e.g. url, token, other) - Label string `json:"label"` // human readable label (e.g. "Webhook 地址") - Type string `json:"type"` // input type: "text" | "password" | "textarea" - Required bool `json:"required"` // whether this field is required - Placeholder string `json:"placeholder"` // input placeholder - Description string `json:"description"` // field explanation/help text -} - -// Definition represents the metadata and form schema for a notification channel. -type Definition struct { - Type string `json:"type"` // channel type (e.g., custom, lark, email) - Name string `json:"name"` // display name - Description string `json:"description"` // short description - Fields []Field `json:"fields"` // form fields -} - -var ( - defMu sync.RWMutex - definitions = make(map[string]Definition) -) - -// RegisterChannelDefinition registers a channel definition. -func RegisterChannelDefinition(def Definition) { - defMu.Lock() - defer defMu.Unlock() - definitions[def.Type] = def -} - -// ListDefinitions returns all registered channel definitions. -func ListDefinitions() []Definition { - defMu.RLock() - defer defMu.RUnlock() - - // We want a stable order: custom, lark, telegram, email - order := []string{channelCustom, channelLark, channelTelegram, channelEmail} - res := make([]Definition, 0, len(definitions)) - for _, t := range order { - if d, ok := definitions[t]; ok { - res = append(res, d) - } - } - // Add any others - for t, d := range definitions { - found := slices.Contains(order, t) - if !found { - res = append(res, d) - } - } - return res -} - -func init() { - // Register custom webhook channel - RegisterChannelDefinition(Definition{ - Type: channelCustom, - Name: "自定义消息通道", - Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。", - Fields: []Field{ - { - Key: KeyURL, - Label: "请求地址", - Type: TypeText, - Required: true, - Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议", - Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook", - }, - { - Key: KeyOther, - Label: "请求体 (JSON)", - Type: TypeTextarea, - Required: true, - Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式", - Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}", - }, - }, - }) - - // Register Lark robot channel - RegisterChannelDefinition(Definition{ - Type: channelLark, - Name: "飞书群机器人", - Description: "配置飞书群自定义机器人的 Webhook 接口投递。", - Fields: []Field{ - { - Key: KeyURL, - Label: "Webhook 地址", - Type: TypeText, - Required: true, - Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", - Description: "从飞书群机器人设置中复制 of Webhook URL", - // Note: using 'of' was in feishu.go, let's keep original wording or fix it - }, - { - Key: KeyToken, - Label: "签名校验密钥 (Secret) (可选)", - Type: TypeText, - Required: false, - Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入", - Description: "飞书群机器人安全设置中的签名校验 Key", - }, - { - Key: KeyOther, - Label: "自定义卡片 JSON 模版 (可选)", - Type: TypeTextarea, - Required: false, - Placeholder: "可选,留空则默认使用系统内置的精美互动卡片", - Description: "若填写,必须是合法的飞书卡片 JSON 格式", - }, - }, - }) - - // Register Telegram channel - RegisterChannelDefinition(Definition{ - Type: channelTelegram, - Name: "Telegram 机器人", - Description: "配置 Telegram 机器人推送消息。", - Fields: []Field{ - { - Key: KeyURL, - Label: "API 基础地址 (可选)", - Type: TypeText, - Required: false, - Placeholder: "https://api.telegram.org", - Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org", - }, - { - Key: KeyToken, - Label: "机器人 Token (Bot Token)", - Type: TypePassword, - Required: true, - Placeholder: "在此输入 Telegram 机器人的 Bot Token", - Description: "通过 BotFather 申请到的机器人 Access Token", - }, - { - Key: KeyOther, - Label: "默认会话 ID (Chat ID) (可选)", - Type: TypeText, - Required: false, - Placeholder: "例如 -100123456789 或 @channel_name", - Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID", - }, - }, - }) - - // Register Email channel - RegisterChannelDefinition(Definition{ - Type: channelEmail, - Name: "邮件推送通道", - Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。", - Fields: []Field{}, - }) -} diff --git a/backend/OpenFlare/plugins/server/admin/push/constants.go b/backend/OpenFlare/plugins/server/admin/push/constants.go deleted file mode 100644 index 0b0e8860..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/constants.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -const ( - channelCustom = "custom" - channelEmail = "email" - channelLark = "lark" - channelTelegram = "telegram" - defaultLevelInfo = "INFO" - keyTitle = "title" - keyContent = "content" - keyLevel = "level" -) diff --git a/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login.go b/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login.go deleted file mode 100644 index b93cb447..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package custom_events defines custom push notification events. -package custom_events - -import ( - "context" - "time" - - "Wavelet/OpenFlare/plugins/server/admin/push" - "Wavelet/OpenFlare/plugins/server/listener" -) - -// AdminLogin is the metadata definition for the admin login event. -var AdminLogin = push.EventMetadata{ - Key: "admin_login", - Name: "管理员登录", - DefaultTemplate: push.NotificationMessage{ - Title: "管理员登录提醒", - Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。", - Level: "INFO", - }, - Description: "当管理员成功登录系统时触发此通知", -} - -func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) { - if event.User == nil { - return - } - - body := map[string]any{ - "user": event.User, - "ip": event.IP, - "time": time.Now().Format("2006-01-02 15:04:05"), - } - push.DefaultTrigger.Trigger(ctx, AdminLogin, body) -} diff --git a/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login_test.go b/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login_test.go deleted file mode 100644 index 550b4d0d..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login_test.go +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package custom_events - -import ( - "context" - "encoding/json" - "sync" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/admin/push" - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/listener" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/hibiken/asynq" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" -) - -var registerOnce sync.Once - -func ensureRegistered() { - registerOnce.Do(Register) -} - -func setupAdminLoginIntegrationTest(t *testing.T) (*gorm.DB, func()) { - t.Helper() - - dbConn, mr, cleanup := testhelper.SetupTestEnvironment(t) - - err := dbConn.AutoMigrate( - &model.PushEvent{}, - &model.PushHistory{}, - &model.PushChannel{}, - ) - require.NoError(t, err) - - sysUser := &model.User{ - ID: 999, - Username: "system", - Nickname: "系统", - Password: "*", - IsActive: true, - } - require.NoError(t, dbConn.Create(sysUser).Error) - - task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()}) - task.RegisterHandler(push.SendNotificationTask, &push.PushHandler{}) - task.RegisterTaskMeta(push.SendNotificationMeta) - - ensureRegistered() - - require.NoError(t, push.SyncEvents(context.Background())) - - return dbConn, func() { - cleanup() - if task.AsynqClient != nil { - task.AsynqClient.Close() - task.AsynqClient = nil - } - } -} - -func seedMockPushChannel(t *testing.T, dbConn *gorm.DB) *model.PushChannel { - t.Helper() - - channel := &model.PushChannel{ - Name: "mock_channel", - Type: "custom", - URL: "https://webhook.site/admin-login", - Other: `{"text": "$content"}`, - Enabled: true, - } - require.NoError(t, dbConn.Create(channel).Error) - return channel -} - -func enableAdminLoginEvent(t *testing.T, dbConn *gorm.DB, channelName string, targets []string) { - t.Helper() - - var event model.PushEvent - require.NoError(t, dbConn.Where("event_key = ?", AdminLogin.Key).First(&event).Error) - - event.Enabled = true - event.Channels = []string{channelName} - event.Targets = targets - require.NoError(t, dbConn.Save(&event).Error) -} - -func waitForAsyncTrigger(t *testing.T) { - t.Helper() - time.Sleep(100 * time.Millisecond) -} - -func countPushTasks(t *testing.T, dbConn *gorm.DB) int64 { - t.Helper() - - var count int64 - require.NoError(t, dbConn.Model(&model.TaskExecution{}). - Where("task_type = ?", push.SendNotificationTask). - Count(&count).Error) - return count -} - -func TestAdminLoginPushIntegration(t *testing.T) { - dbConn, cleanup := setupAdminLoginIntegrationTest(t) - defer cleanup() - - channel := seedMockPushChannel(t, dbConn) - defer dbConn.Delete(channel) - - enableAdminLoginEvent(t, dbConn, channel.Name, []string{"ops_team"}) - - adminUser := &model.User{ - ID: 1001, - Username: "super_admin", - IsAdmin: true, - IsActive: true, - } - require.NoError(t, dbConn.Create(adminUser).Error) - - t.Run("admin login emits push task with user and ip", func(t *testing.T) { - dbConn.Where("task_type = ?", push.SendNotificationTask).Delete(&model.TaskExecution{}) - - listener.EmitAdminLoggedIn(context.Background(), adminUser, "203.0.113.42") - waitForAsyncTrigger(t) - - var execution model.TaskExecution - require.NoError(t, dbConn.Where("task_type = ?", push.SendNotificationTask).First(&execution).Error) - - var payload push.SendPayload - require.NoError(t, json.Unmarshal([]byte(execution.Payload), &payload)) - - assert.Equal(t, AdminLogin.Key, payload.EventKey) - assert.Equal(t, "ops_team", payload.Target) - assert.Equal(t, "管理员登录提醒", payload.Body.Title) - assert.Contains(t, payload.Body.Content, "super_admin") - assert.Contains(t, payload.Body.Content, "203.0.113.42") - }) - - t.Run("non-admin login does not trigger push", func(t *testing.T) { - dbConn.Where("task_type = ?", push.SendNotificationTask).Delete(&model.TaskExecution{}) - - nonAdmin := &model.User{ - ID: 2002, - Username: "regular_user", - IsAdmin: false, - IsActive: true, - } - require.NoError(t, dbConn.Create(nonAdmin).Error) - - listener.EmitAdminLoggedIn(context.Background(), nonAdmin, "198.51.100.1") - waitForAsyncTrigger(t) - - assert.Equal(t, int64(0), countPushTasks(t, dbConn)) - }) - - t.Run("disabled admin login event does not enqueue push", func(t *testing.T) { - dbConn.Where("task_type = ?", push.SendNotificationTask).Delete(&model.TaskExecution{}) - - var event model.PushEvent - require.NoError(t, dbConn.Where("event_key = ?", AdminLogin.Key).First(&event).Error) - event.Enabled = false - require.NoError(t, dbConn.Save(&event).Error) - repository.DeleteActivePushEventCache(context.Background(), AdminLogin.Key) - - listener.EmitAdminLoggedIn(context.Background(), adminUser, "10.0.0.1") - waitForAsyncTrigger(t) - - assert.Equal(t, int64(0), countPushTasks(t, dbConn)) - }) -} diff --git a/backend/OpenFlare/plugins/server/admin/push/custom_events/register.go b/backend/OpenFlare/plugins/server/admin/push/custom_events/register.go deleted file mode 100644 index 8696ad76..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/custom_events/register.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package custom_events - -import ( - "Wavelet/OpenFlare/plugins/server/admin/push" - "Wavelet/OpenFlare/plugins/server/listener" -) - -// Register wires push notification handlers for domain events and registers -// built-in event metadata. Must be called once during application bootstrap -// before push.SyncEvents. -func Register() { - push.RegisterBuiltInEvent(AdminLogin) - listener.OnAdminLoggedIn(handleAdminLogin) -} diff --git a/backend/OpenFlare/plugins/server/admin/push/events.go b/backend/OpenFlare/plugins/server/admin/push/events.go deleted file mode 100644 index 3cc6fea5..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/events.go +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package push defines push notification HTTP routes, background tasks, and events. -package push - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "strings" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/model" - pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/pkg/logger" - "Wavelet/pkg/util" - - "gorm.io/gorm" -) - -// NotificationMessage represents the structured notification message payload. -type NotificationMessage struct { - Title string `json:"title"` - Content string `json:"content"` - Level string `json:"level"` - Ext map[string]any `json:"ext,omitempty"` -} - -// Flatten converts the structured NotificationMessage back to a flat map (original json structure). -func (m NotificationMessage) Flatten() map[string]any { - res := map[string]any{ - keyTitle: m.Title, - keyContent: m.Content, - keyLevel: m.Level, - } - maps.Copy(res, m.Ext) - return res -} - -// EventMetadata represents the metadata of a push notification event. -type EventMetadata struct { - Key string `json:"key"` - Name string `json:"name"` - DefaultTemplate NotificationMessage `json:"default_template"` - Description string `json:"description"` -} - -// SendPayload 异步投递推送载荷 (供 task/Worker 使用) -type SendPayload struct { - EventKey string `json:"event_key"` - Config pkgpush.Config `json:"config"` - Target string `json:"target"` - Body NotificationMessage `json:"body"` - Template string `json:"template"` -} - -// BuiltInEvents lists all built-in events defined in custom_events. -var BuiltInEvents []EventMetadata - -// RegisterBuiltInEvent registers a built-in event definition. -func RegisterBuiltInEvent(meta EventMetadata) { - BuiltInEvents = append(BuiltInEvents, meta) -} - -// EventTrigger represents the unified event trigger class. -type EventTrigger struct{} - -// DefaultTrigger is the singleton instance of EventTrigger. -var DefaultTrigger = &EventTrigger{} - -// Trigger receives event metadata and processes the event notification dispatch asynchronously. -// -//nolint:contextcheck -func (t *EventTrigger) Trigger(ctx context.Context, meta EventMetadata, body map[string]any) { - asyncCtx := context.WithoutCancel(ctx) - util.Go(func() { - if body == nil { - body = make(map[string]any) - } - if _, hasUser := body["user"]; !hasUser || body["user"] == nil { - body["user"] = getSystemUser(asyncCtx) - } - - eventPtr, err := repository.GetActivePushEventByKey(asyncCtx, meta.Key) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return - } - logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err) - return - } - event := *eventPtr - if len(event.Channels) == 0 { - return - } - - flatBody := getFlatBody(body) - msg, _ := t.buildMessage(&event, meta, flatBody, body) - t.enqueuePushTasks(asyncCtx, meta, &event, msg, flatBody) - }) -} - -func (t *EventTrigger) buildMessage(event *model.PushEvent, meta EventMetadata, flatBody map[string]any, body map[string]any) (NotificationMessage, string) { - var msg NotificationMessage - renderedTemplate := "" - - templateSource := event.Template - if templateSource != "" { - var err error - msg, renderedTemplate, err = t.parseCustomTemplate(event, templateSource, flatBody) - if err != nil { - msg.Title = event.Name - msg.Content = renderedTemplate - msg.Level = defaultLevelInfo - } - } else { - msg = t.parseDefaultTemplate(meta, flatBody) - } - - if msg.Ext == nil { - msg.Ext = make(map[string]any) - } - for k, v := range body { - if k == keyTitle || k == keyContent || k == keyLevel { - continue - } - if _, exists := msg.Ext[k]; !exists { - msg.Ext[k] = v - } - } - - return msg, renderedTemplate -} - -func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSource string, flatBody map[string]any) (NotificationMessage, string, error) { - var msg NotificationMessage - renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody) - - var tMap map[string]any - if err := json.Unmarshal([]byte(renderedTemplate), &tMap); err != nil { - return msg, renderedTemplate, err - } - - if title, ok := tMap[keyTitle].(string); ok && title != "" { - msg.Title = title - } else { - msg.Title = event.Name - } - delete(tMap, keyTitle) - - if content, ok := tMap[keyContent].(string); ok && content != "" { - msg.Content = content - } else { - msg.Content = renderedTemplate - } - delete(tMap, keyContent) - - if level, ok := tMap[keyLevel].(string); ok && level != "" { - msg.Level = level - } else { - msg.Level = defaultLevelInfo - } - delete(tMap, keyLevel) - - msg.Ext = tMap - return msg, renderedTemplate, nil -} - -func (t *EventTrigger) parseDefaultTemplate(meta EventMetadata, flatBody map[string]any) NotificationMessage { - var msg NotificationMessage - msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody) - msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody) - msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody) - - if meta.DefaultTemplate.Ext != nil { - msg.Ext = make(map[string]any) - for k, v := range meta.DefaultTemplate.Ext { - if strVal, ok := v.(string); ok { - msg.Ext[k] = pkgpush.ParseTemplate(strVal, flatBody) - } else { - msg.Ext[k] = v - } - } - } - return msg -} - -func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, msg NotificationMessage, flatBody map[string]any) { - for _, channelName := range event.Channels { - customChannel, err := repository.GetActivePushChannelByName(ctx, channelName) - if err == nil { - t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody) - continue - } - logger.WarnF(ctx, "push_event_trigger: channel %q not found in DB or disabled: %v", channelName, err) - } -} - -func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, channel *model.PushChannel, msg NotificationMessage, flatBody map[string]any) { - if len(event.Targets) == 0 { - t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg) - return - } - - for _, target := range event.Targets { - resolvedTarget := resolveTarget(ctx, target, flatBody, channel.Name) - t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, resolvedTarget, msg) - } -} - -func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta EventMetadata, channel *model.PushChannel, target string, msg NotificationMessage) { - var config pkgpush.Config - var renderedTemplate string - - switch channel.Type { - case channelLark: - config = pkgpush.Config{Channel: channelLark, URL: channel.URL, Secret: channel.Token} - renderedTemplate = channel.Other - case channelEmail: - url, token, other := resolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other) - config = pkgpush.Config{Channel: channelEmail, URL: url, Key: token, Secret: other} - case channelTelegram: - config = pkgpush.Config{Channel: channelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other} - default: - config = pkgpush.Config{Channel: channelCustom, URL: channel.URL} - customPushReq := CustomPushRequest{ - Title: msg.Title, - Content: msg.Content, - Description: meta.Description, - To: target, - } - if urlVal, ok := msg.Ext["url"].(string); ok { - customPushReq.URL = urlVal - } - renderedTemplate = renderCustomPayload(channel.Other, customPushReq) - } - - payload := SendPayload{ - EventKey: meta.Key, - Config: config, - Target: target, - Body: msg, - Template: renderedTemplate, - } - if err := enqueuePushTask(ctx, payload); err != nil { - logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s channel %s -> %s: %v", channel.Type, channel.Name, target, err) - } -} - -func enqueuePushTask(ctx context.Context, payload SendPayload) error { - payloadBytes, err := json.Marshal(payload) - if err != nil { - return err - } - _, err = task.DispatchTask(ctx, "send_notification", payloadBytes, "system") - return err -} - -func getFlatBody(body map[string]any) map[string]any { - jsonBytes, err := json.Marshal(body) - if err != nil { - return body - } - var jsonMap map[string]any - if err := json.Unmarshal(jsonBytes, &jsonMap); err != nil { - return body - } - - flatResult := make(map[string]any) - flattenMap("", jsonMap, flatResult) - return flatResult -} - -func flattenMap(prefix string, m map[string]any, result map[string]any) { - for k, v := range m { - key := k - if prefix != "" { - key = prefix + "." + k - } - if nestedMap, ok := v.(map[string]any); ok { - flattenMap(key, nestedMap, result) - } else { - result[key] = v - } - } -} - -func resolveTarget(ctx context.Context, target string, flatBody map[string]any, channel string) string { - target = strings.TrimSpace(target) - if target == "" { - return "" - } - - resolved := resolveDynamicKeyword(target, flatBody) - if strings.Contains(resolved, "@") { - return resolved - } - if val, matched := resolveSystemTarget(ctx, resolved, channel); matched { - return val - } - - user, found := resolveTargetUser(ctx, resolved, channel) - if !found { - return resolved - } - if channel == channelEmail && user.Email != "" { - return user.Email - } - if channel != channelEmail && user.Username != "" { - return user.Username - } - return resolved -} - -func resolveDynamicKeyword(target string, flatBody map[string]any) string { - switch target { - case "user.id", "id": - if val, ok := flatBody["user.id"]; ok { - return fmt.Sprintf("%v", val) - } - if val, ok := flatBody["id"]; ok { - return fmt.Sprintf("%v", val) - } - case "user.username", "username": - if val, ok := flatBody["user.username"]; ok { - return fmt.Sprintf("%v", val) - } - if val, ok := flatBody["username"]; ok { - return fmt.Sprintf("%v", val) - } - case "user.email", channelEmail: - if val, ok := flatBody["user.email"]; ok { - return fmt.Sprintf("%v", val) - } - if val, ok := flatBody["email"]; ok { - return fmt.Sprintf("%v", val) - } - } - return target -} diff --git a/backend/OpenFlare/plugins/server/admin/push/logics.go b/backend/OpenFlare/plugins/server/admin/push/logics.go deleted file mode 100644 index 8117a478..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/logics.go +++ /dev/null @@ -1,414 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "context" - "encoding/json" - "errors" - "strconv" - "strings" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/model" - pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" - "Wavelet/OpenFlare/plugins/server/repository" - - "gorm.io/gorm" -) - -type smtpConfig struct { - Host string - Port string - Username string - Password string -} - -func loadSMTPConfig(ctx context.Context) smtpConfig { - host, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost) - port, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort) - user, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPUsername) - pass, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPassword) - return smtpConfig{ - Host: host.Value, - Port: port.Value, - Username: user.Value, - Password: pass.Value, - } -} - -func syncBuiltInEvents(ctx context.Context) error { - for _, meta := range BuiltInEvents { - _, err := repository.GetPushEventByKey(ctx, meta.Key) - if errors.Is(err, gorm.ErrRecordNotFound) { - var defaultTemplateStr string - if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil { - defaultTemplateStr = string(defaultTemplateBytes) - } - event := model.PushEvent{ - EventKey: meta.Key, - Name: meta.Name, - Channels: []string{}, - Targets: []string{}, - Template: defaultTemplateStr, - Enabled: false, - } - if err := repository.CreatePushEvent(ctx, &event); err != nil { - return err - } - } else if err != nil { - return err - } - } - return nil -} - -func listPushEvents(ctx context.Context) ([]model.PushEvent, error) { - return repository.ListPushEvents(ctx) -} - -func createPushEvent(ctx context.Context, req CreateEventRequest) (model.PushEvent, error) { - eventKey, eventName, defaultTemplateBytes, err := getEventInfo(req) - if err != nil { - return model.PushEvent{}, err - } - - count, err := repository.CountPushEventsByKey(ctx, eventKey) - if err != nil { - return model.PushEvent{}, err - } - if count > 0 { - return model.PushEvent{}, errors.New("this notification event is already configured") - } - - templateStr := strings.TrimSpace(req.Template) - if templateStr == "" { - templateStr = string(defaultTemplateBytes) - } else { - var tempMap map[string]any - if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil { - return model.PushEvent{}, errors.New("custom template is not a valid JSON format") - } - } - - channels := req.Channels - if channels == nil { - channels = []string{} - } - targets := req.Targets - if targets == nil { - targets = []string{} - } - - event := model.PushEvent{ - EventKey: eventKey, - Name: eventName, - TaskType: req.TaskType, - Channels: channels, - Targets: targets, - Template: templateStr, - Enabled: req.Enabled, - } - if err := event.Validate(); err != nil { - return model.PushEvent{}, err - } - if err := repository.CreatePushEvent(ctx, &event); err != nil { - return model.PushEvent{}, err - } - return event, nil -} - -func deletePushEvent(ctx context.Context, id uint64) error { - event, err := repository.GetPushEventByID(ctx, id) - if err != nil { - return err - } - return repository.DeletePushEvent(ctx, &event) -} - -func updatePushEvent(ctx context.Context, id uint64, req UpdateEventRequest) error { - event, err := repository.GetPushEventByID(ctx, id) - if err != nil { - return err - } - - event.Channels = req.Channels - event.Targets = req.Targets - event.Template = req.Template - event.Enabled = req.Enabled - if err := event.Validate(); err != nil { - return err - } - return repository.SavePushEvent(ctx, &event) -} - -func togglePushEvent(ctx context.Context, id uint64) (bool, error) { - event, err := repository.GetPushEventByID(ctx, id) - if err != nil { - return false, err - } - - enabled := !event.Enabled - if enabled && len(event.Channels) == 0 { - return false, errors.New("cannot enable event without any push channels configured") - } - if err := repository.UpdatePushEventEnabled(ctx, &event, enabled); err != nil { - return false, err - } - return enabled, nil -} - -func listPushHistories(ctx context.Context, filter repository.PushHistoryListFilter) (int64, []model.PushHistory, error) { - return repository.ListPushHistories(ctx, filter) -} - -func applySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) { - if cfg.Channel != channelEmail || (cfg.URL != "" && cfg.Key != "") { - return - } - smtp := loadSMTPConfig(ctx) - if smtp.Host == "" || smtp.Username == "" { - return - } - port := smtp.Port - if port == "" { - port = "587" - } - cfg.URL = smtp.Host + ":" + port - cfg.Key = smtp.Username - cfg.Secret = smtp.Password -} - -func listPushChannels(ctx context.Context) ([]model.PushChannel, error) { - return repository.ListPushChannels(ctx) -} - -func createPushChannel(ctx context.Context, req CreateChannelRequest) (model.PushChannel, error) { - count, err := repository.CountPushChannelsByName(ctx, req.Name) - if err != nil { - return model.PushChannel{}, err - } - if count > 0 { - return model.PushChannel{}, errors.New("channel name already exists") - } - - channel := model.PushChannel{ - Name: req.Name, - Description: req.Description, - Type: req.Type, - Token: req.Token, - URL: req.URL, - Other: req.Other, - Enabled: req.Enabled, - } - if err := channel.Validate(); err != nil { - return model.PushChannel{}, err - } - if err := repository.CreatePushChannel(ctx, &channel); err != nil { - return model.PushChannel{}, err - } - return channel, nil -} - -func updatePushChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (model.PushChannel, error) { - channel, err := repository.GetPushChannelByID(ctx, id) - if err != nil { - return model.PushChannel{}, err - } - - channel.Description = req.Description - channel.Type = req.Type - channel.Token = req.Token - channel.URL = req.URL - channel.Other = req.Other - channel.Enabled = req.Enabled - if err := channel.Validate(); err != nil { - return model.PushChannel{}, err - } - if err := repository.SavePushChannel(ctx, &channel); err != nil { - return model.PushChannel{}, err - } - return channel, nil -} - -func deletePushChannel(ctx context.Context, id uint64) error { - channel, err := repository.GetPushChannelByID(ctx, id) - if err != nil { - return err - } - return repository.DeletePushChannel(ctx, &channel) -} - -func loadChannelForTest(ctx context.Context, req TestChannelRequest) (string, string, string, string, error) { - if req.Name != "" { - channel, err := repository.GetPushChannelByName(ctx, req.Name) - if err != nil { - return "", "", "", "", errors.New("channel not found") - } - return channel.URL, channel.Token, channel.Other, channel.Type, nil - } - return req.URL, req.Token, req.Other, req.Type, nil -} - -func listActivePushEventsByTaskType(ctx context.Context, taskType string) ([]model.PushEvent, error) { - return repository.ListActivePushEventsByTaskType(ctx, taskType) -} - -func loadUserFromPayload(ctx context.Context, data map[string]any) any { - if u, exists := data["user"]; exists && u != nil { - return u - } - - if userID, ok := extractUserID(data); ok && userID > 0 { - if user, err := repository.GetUserByID(ctx, userID); err == nil { - return &user - } - } - - if username := extractUsername(data); username != "" { - if user, err := repository.GetUserByUsername(ctx, username); err == nil { - return &user - } - } - return nil -} - -func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg string) error { - title := req.Body.Title - content := req.Body.Content - level := req.Body.Level - if title == "" { - title = "系统通知" - } - if level == "" { - level = defaultLevelInfo - } - - target := req.Target - if target == "" { - if req.Config.URL != "" { - target = req.Config.URL - const maxTargetLen = 50 - const truncatedLen = 47 - if len(target) > maxTargetLen { - target = target[:truncatedLen] + "..." - } - } else { - target = "default" - } - } - - history := model.PushHistory{ - EventKey: req.EventKey, - Channel: req.Config.Channel, - Target: target, - Title: title, - Content: content, - Level: level, - Status: status, - ErrorMsg: errMsg, - } - return repository.CreatePushHistory(ctx, &history) -} - -func resolveTargetUser(ctx context.Context, resolved string, _ string) (model.User, bool) { - found := false - var user model.User - - if id, err := strconv.ParseUint(resolved, 10, 64); err == nil { - if u, err := repository.GetUserByID(ctx, id); err == nil { - user = u - found = true - } - } - if !found { - if u, err := repository.GetUserByUsername(ctx, resolved); err == nil { - user = u - found = true - } - } - return user, found -} - -func resolveSystemTarget(ctx context.Context, resolved string, channel string) (string, bool) { - if resolved != "系统" && resolved != "system" && resolved != "0" { - return "", false - } - adminUser, err := repository.GetFirstAdminUser(ctx) - if err != nil { - return resolved, true - } - if channel == channelEmail && adminUser.Email != "" { - return adminUser.Email, true - } - if channel != channelEmail && adminUser.Username != "" { - return adminUser.Username, true - } - return resolved, true -} - -func resolveSMTPConfig(ctx context.Context, url, token, other string) (string, string, string) { - if url != "" && token != "" { - return url, token, other - } - smtp := loadSMTPConfig(ctx) - if smtp.Host == "" || smtp.Username == "" { - return url, token, other - } - port := smtp.Port - if port == "" { - port = "587" - } - if url == "" { - url = smtp.Host + ":" + port - } - if token == "" { - token = smtp.Username - } - if other == "" { - other = smtp.Password - } - return url, token, other -} - -func getSystemUser(ctx context.Context) *model.User { - user := repository.GetSystemUser(ctx) - return &user -} - -func getEventInfo(req CreateEventRequest) (string, string, []byte, error) { - if req.TaskType != "" { - meta := task.GetTaskMetaByAsynqTask(req.TaskType) - if meta == nil { - return "", "", nil, errors.New("unsupported task type") - } - eventKey := "task_completed:" + req.TaskType - eventName := "任务完成: " + meta.Name - defaultTemplate := NotificationMessage{ - Title: "任务完成: " + meta.Name, - Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。", - Level: defaultLevelInfo, - } - defaultTemplateBytes, err := json.Marshal(defaultTemplate) - if err != nil { - return "", "", nil, err - } - return eventKey, eventName, defaultTemplateBytes, nil - } - - if req.EventKey == "" { - return "", "", nil, errors.New("either event_key or task_type must be provided") - } - - meta, found := findBuiltInEvent(req.EventKey) - if !found { - return "", "", nil, errors.New("unsupported built-in event key") - } - - defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate) - if err != nil { - return "", "", nil, err - } - return req.EventKey, meta.Name, defaultTemplateBytes, nil -} diff --git a/backend/OpenFlare/plugins/server/admin/push/push_test.go b/backend/OpenFlare/plugins/server/admin/push/push_test.go deleted file mode 100644 index ac7089c1..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/push_test.go +++ /dev/null @@ -1,766 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "sync" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/model" - pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/alicebob/miniredis/v2" - "github.com/gin-gonic/gin" - "github.com/hibiken/asynq" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" - - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/pkg/response" -) - -var adminLoginEvent = EventMetadata{ - Key: "admin_login", - Name: "管理员登录", - DefaultTemplate: NotificationMessage{ - Title: "管理员登录提醒", - Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。", - Level: "INFO", - }, - Description: "当管理员成功登录系统时触发此通知", -} - -func init() { - RegisterBuiltInEvent(adminLoginEvent) -} - -// mockPusher mock implementation of pkgpush.Pusher -type mockPusher struct { - mu sync.Mutex - sentBody map[string]any - sentTgt string -} - -func (m *mockPusher) Send(ctx context.Context, cfg pkgpush.Config, target string, body map[string]any, template string, ext map[string]any) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.sentBody = body - m.sentTgt = target - return "", nil -} - -func (m *mockPusher) ValidateConfig(cfg pkgpush.Config) error { - return nil -} - -func setupPushTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { - t.Helper() - dbConn, mr, cleanup := testhelper.SetupTestEnvironment(t) - - // AutoMigrate push tables in SQLite test environment - err := dbConn.AutoMigrate(&model.PushEvent{}, &model.PushHistory{}, &model.User{}, &model.PushChannel{}, &model.SystemConfig{}) - require.NoError(t, err) - - // 写入数据库系统默认用户 Seed 记录 - sysUser := &model.User{ - ID: 999, - Username: "system", - Nickname: "系统", - Password: "*", - IsActive: true, - } - err = dbConn.Create(sysUser).Error - require.NoError(t, err) - - // Initialize AsynqClient pointing to miniredis - task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ - Addr: mr.Addr(), - }) - - // Register the task handler and metadata - task.RegisterHandler(SendNotificationTask, &PushHandler{}) - task.RegisterTaskMeta(SendNotificationMeta) - - return dbConn, mr, func() { - cleanup() - if task.AsynqClient != nil { - task.AsynqClient.Close() - task.AsynqClient = nil - } - } -} - -func setupTestRouter(authUser *model.User) *gin.Engine { - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin/push") - - adminGroup.Use(func(c *gin.Context) { - if authUser != nil { - oauth.SetToContext(c, "user_obj", authUser) - } - c.Next() - }) - - adminGroup.GET("/events", ListEvents) - adminGroup.GET("/events/builtin", ListBuiltInEvents) - adminGroup.POST("/events", CreateEvent) - adminGroup.PUT("/events/:id", UpdateEvent) - adminGroup.DELETE("/events/:id", DeleteEvent) - adminGroup.POST("/events/:id/toggle", ToggleEvent) - adminGroup.GET("/histories", ListHistories) - adminGroup.POST("/test", TestPush) - - return r -} - -func TestSyncEvents(t *testing.T) { - dbConn, _, cleanup := setupPushTest(t) - defer cleanup() - - // 1. SyncEvents first time - err := SyncEvents(context.Background()) - require.NoError(t, err) - - // Verify event exists in DB - var event model.PushEvent - err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error - require.NoError(t, err) - assert.Equal(t, "管理员登录", event.Name) - assert.False(t, event.Enabled) - - // Verify DefaultTemplate matches GORM template field - var defaultMsg NotificationMessage - err = json.Unmarshal([]byte(event.Template), &defaultMsg) - require.NoError(t, err) - assert.Equal(t, adminLoginEvent.DefaultTemplate.Title, defaultMsg.Title) - assert.Equal(t, adminLoginEvent.DefaultTemplate.Content, defaultMsg.Content) -} - -func TestEventTrigger(t *testing.T) { - dbConn, _, cleanup := setupPushTest(t) - defer cleanup() - - // SyncEvents - err := SyncEvents(context.Background()) - require.NoError(t, err) - - t.Run("trigger disabled event silently ignored", func(t *testing.T) { - body := map[string]any{ - "user": map[string]any{"username": "test_admin"}, - "ip": "127.0.0.1", - } - DefaultTrigger.Trigger(context.Background(), adminLoginEvent, body) - - // Sleep briefly since Trigger runs in goroutine - time.Sleep(50 * time.Millisecond) - - // Verify no tasks enqueued in TaskExecution GORM table - var count int64 - dbConn.Model(&model.TaskExecution{}).Count(&count) - assert.Equal(t, int64(0), count) - }) - - t.Run("trigger enabled event enqueues task", func(t *testing.T) { - // Create an enabled custom channel in GORM - customChan := &model.PushChannel{ - Name: "mock_channel", - Type: "custom", - URL: "https://webhook.site/trigger", - Other: `{"text": "$content"}`, - Enabled: true, - } - err = dbConn.Create(customChan).Error - require.NoError(t, err) - defer dbConn.Delete(customChan) - - // Enable the push event in DB using struct to trigger JSON serializer - var event model.PushEvent - err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error - require.NoError(t, err) - - event.Enabled = true - event.Channels = []string{"mock_channel"} - event.Targets = []string{"admin_user"} - err = dbConn.Save(&event).Error - require.NoError(t, err) - repository.DeleteActivePushEventCache(context.Background(), "admin_login") - - // Trigger - body := map[string]any{ - "user": map[string]any{ - "username": "super_admin", - }, - "ip": "1.1.1.1", - "time": "2026-06-14 18:00:00", - } - DefaultTrigger.Trigger(context.Background(), adminLoginEvent, body) - - // Wait for goroutine execution - time.Sleep(50 * time.Millisecond) - - // Verify TaskExecution enqueued record - var execution model.TaskExecution - err = dbConn.Where("task_type = ?", SendNotificationTask).First(&execution).Error - require.NoError(t, err) - - // Verify enqueued payload structure - var payload SendPayload - err = json.Unmarshal([]byte(execution.Payload), &payload) - require.NoError(t, err) - assert.Equal(t, "admin_login", payload.EventKey) - assert.Equal(t, "custom", payload.Config.Channel) - assert.Equal(t, "https://webhook.site/trigger", payload.Config.URL) - assert.Equal(t, "admin_user", payload.Target) - assert.Equal(t, "管理员登录提醒", payload.Body.Title) - assert.Contains(t, payload.Body.Content, "super_admin") - assert.Contains(t, payload.Body.Content, "1.1.1.1") - }) - - t.Run("trigger without user injects virtual system user", func(t *testing.T) { - // Create an enabled custom channel in GORM - customChan := &model.PushChannel{ - Name: "mock_channel", - Type: "custom", - URL: "https://webhook.site/trigger", - Other: `{"text": "$content"}`, - Enabled: true, - } - err = dbConn.Create(customChan).Error - require.NoError(t, err) - defer dbConn.Delete(customChan) - - // Enable the push event in DB - var event model.PushEvent - err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error - require.NoError(t, err) - - // 清理旧任务执行记录 - dbConn.Where("task_type = ?", SendNotificationTask).Delete(&model.TaskExecution{}) - - event.Enabled = true - event.Channels = []string{"mock_channel"} - event.Targets = []string{"user.username"} // 动态目标 - err = dbConn.Save(&event).Error - require.NoError(t, err) - repository.DeleteActivePushEventCache(context.Background(), "admin_login") - - // Trigger with empty body (simulates cron scheduler triggering) - DefaultTrigger.Trigger(context.Background(), adminLoginEvent, nil) - - // Wait for goroutine execution - time.Sleep(50 * time.Millisecond) - - // Verify TaskExecution enqueued record - var execution model.TaskExecution - err = dbConn.Where("task_type = ?", SendNotificationTask).First(&execution).Error - require.NoError(t, err) - - var payload SendPayload - err = json.Unmarshal([]byte(execution.Payload), &payload) - require.NoError(t, err) - - // 检查 payload 是否将 target (user.username) 成功替换为 "system" - assert.Equal(t, "system", payload.Target) - // 检查 payload 中的 Content,应当被替换为 "system" 变量 - assert.Contains(t, payload.Body.Content, "system") - }) -} - -func TestPushHandler(t *testing.T) { - dbConn, _, cleanup := setupPushTest(t) - defer cleanup() - - mPusher := &mockPusher{} - pkgpush.Register("mock_channel", mPusher) - - handler := &PushHandler{} - - payload := SendPayload{ - EventKey: "admin_login", - Config: pkgpush.Config{ - Channel: "mock_channel", - URL: "http://mock-url", - }, - Target: "admin_user", - Body: NotificationMessage{ - Title: "Structured Alert", - Content: "Hello World", - Level: "WARNING", - Ext: map[string]any{"extra_val": 42}, - }, - } - payloadBytes, err := json.Marshal(payload) - require.NoError(t, err) - - t.Run("validate payload", func(t *testing.T) { - validated, valErr := handler.ValidatePayload(payloadBytes) - require.NoError(t, valErr) - assert.NotEmpty(t, validated) - }) - - t.Run("execute task successfully", func(t *testing.T) { - res, execErr := handler.Execute(context.Background(), payloadBytes) - require.NoError(t, execErr) - assert.Contains(t, res.Message, "推送成功") - - // Verify mock pusher received flattened variables - mPusher.mu.Lock() - assert.Equal(t, "admin_user", mPusher.sentTgt) - assert.Equal(t, "Structured Alert", mPusher.sentBody["title"]) - assert.Equal(t, "Hello World", mPusher.sentBody["content"]) - assert.Equal(t, "WARNING", mPusher.sentBody["level"]) - assert.InDelta(t, float64(42), mPusher.sentBody["extra_val"], 1e-9) // unmarshaled json numbers are float64 by default - mPusher.mu.Unlock() - - // Verify PushHistory recorded - var history model.PushHistory - err = dbConn.First(&history).Error - require.NoError(t, err) - assert.Equal(t, "admin_login", history.EventKey) - assert.Equal(t, "mock_channel", history.Channel) - assert.Equal(t, "success", history.Status) - assert.Equal(t, "Structured Alert", history.Title) - }) -} - -func TestPushRouters(t *testing.T) { - dbConn, _, cleanup := setupPushTest(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - r := setupTestRouter(adminUser) - - // Sync events to populate db - err := SyncEvents(context.Background()) - require.NoError(t, err) - - t.Run("list events", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/push/events", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp response.Any - err = json.Unmarshal(w.Body.Bytes(), &resp) - require.NoError(t, err) - - dataBytes, _ := json.Marshal(resp.Data) - var events []model.PushEvent - err = json.Unmarshal(dataBytes, &events) - require.NoError(t, err) - - assert.Len(t, events, 1) - assert.Equal(t, "admin_login", events[0].EventKey) - }) - - t.Run("toggle event status", func(t *testing.T) { - var event model.PushEvent - dbConn.First(&event) - - // 1. 未配置任何渠道时开启,应该被拒绝 - req, _ := http.NewRequest("POST", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10)+"/toggle", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - // 2. 为该事件关联渠道后,再切换开启,应当成功 - event.Channels = []string{"email"} - dbConn.Save(&event) - repository.DeleteActivePushEventCache(context.Background(), event.EventKey) - - req2, _ := http.NewRequest("POST", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10)+"/toggle", nil) - w2 := httptest.NewRecorder() - r.ServeHTTP(w2, req2) - assert.Equal(t, http.StatusOK, w2.Code) - - var updated model.PushEvent - dbConn.First(&updated) - assert.True(t, updated.Enabled) - }) - - t.Run("update event", func(t *testing.T) { - var event model.PushEvent - dbConn.First(&event) - - updateReq := UpdateEventRequest{ - Channels: []string{"email"}, - Targets: []string{"user@test.com"}, - Template: `{"title": "Custom Login Alert", "content": "Alert", "level": "WARNING"}`, - Enabled: true, - } - bodyBytes, _ := json.Marshal(updateReq) - req, _ := http.NewRequest("PUT", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var updated model.PushEvent - dbConn.First(&updated) - assert.Equal(t, []string{"email"}, updated.Channels) - assert.Equal(t, []string{"user@test.com"}, updated.Targets) - assert.Contains(t, updated.Template, "Custom Login Alert") - }) - - t.Run("list push histories", func(t *testing.T) { - // Populate history record - hist := model.PushHistory{ - EventKey: "admin_login", - Channel: "email", - Target: "user@test.com", - Title: "Custom Login Alert", - Content: "Alert", - Level: "WARNING", - Status: "success", - } - dbConn.Create(&hist) - - req, _ := http.NewRequest("GET", "/api/v1/admin/push/histories?page=1&page_size=10", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataMap, ok := resp.Data.(map[string]any) - assert.True(t, ok) - assert.InDelta(t, float64(1), dataMap["total"], 1e-9) - }) - - t.Run("test push endpoint", func(t *testing.T) { - mPusher := &mockPusher{} - pkgpush.Register("test_channel", mPusher) - - testReq := TestPushRequest{ - Config: pkgpush.Config{ - Channel: "test_channel", - URL: "http://test-url", - }, - Target: "test_target", - } - bodyBytes, _ := json.Marshal(testReq) - req, _ := http.NewRequest("POST", "/api/v1/admin/push/test", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("list built-in events", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/push/events/builtin", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp response.Any - err := json.Unmarshal(w.Body.Bytes(), &resp) - require.NoError(t, err) - - builtins, ok := resp.Data.([]any) - assert.True(t, ok) - assert.NotEmpty(t, builtins) - }) - - t.Run("create and delete push event", func(t *testing.T) { - // Clean up any existing admin_login event first - dbConn.Where("event_key = ?", "admin_login").Delete(&model.PushEvent{}) - - // 1. Create event - createReq := CreateEventRequest{ - EventKey: "admin_login", - Channels: []string{"email"}, - Targets: []string{"admin@test.com"}, - Enabled: true, - } - bodyBytes, _ := json.Marshal(createReq) - req, _ := http.NewRequest("POST", "/api/v1/admin/push/events", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - // Verify created in DB - var event model.PushEvent - err := dbConn.Where("event_key = ?", "admin_login").First(&event).Error - require.NoError(t, err) - assert.Equal(t, "admin_login", event.EventKey) - assert.Equal(t, "管理员登录", event.Name) - assert.True(t, event.Enabled) - - // 2. Try creating again (should fail) - w2 := httptest.NewRecorder() - req2, _ := http.NewRequest("POST", "/api/v1/admin/push/events", bytes.NewBuffer(bodyBytes)) - req2.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w2, req2) - assert.Equal(t, http.StatusBadRequest, w2.Code) - - // 3. Delete event - w3 := httptest.NewRecorder() - req3, _ := http.NewRequest("DELETE", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10), nil) - r.ServeHTTP(w3, req3) - assert.Equal(t, http.StatusOK, w3.Code) - - // Verify deleted from DB - var count int64 - dbConn.Model(&model.PushEvent{}).Where("event_key = ?", "admin_login").Count(&count) - assert.Equal(t, int64(0), count) - }) -} - -func TestResolveTarget(t *testing.T) { - dbConn, _, cleanup := setupPushTest(t) - defer cleanup() - - // 1. 创建测试用户与管理员用户 - testUser := &model.User{ - ID: 9999, - Username: "target_user", - Email: "target@test.com", - IsAdmin: false, - } - err := dbConn.Create(testUser).Error - require.NoError(t, err) - - adminUser := &model.User{ - ID: 8888, - Username: "admin_user", - Email: "admin@test.com", - IsAdmin: true, - } - err = dbConn.Create(adminUser).Error - require.NoError(t, err) - - flatBody := map[string]any{ - "user.id": float64(9999), // JSON 反序列化后一般是 float64 - "user.username": "target_user", - "user.email": "target@test.com", - } - - ctx := context.Background() - - t.Run("dynamic user.id resolved and converted for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "user.id", flatBody, "email") - assert.Equal(t, "target@test.com", res) - }) - - t.Run("dynamic user.username resolved and converted for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "user.username", flatBody, "email") - assert.Equal(t, "target@test.com", res) - }) - - t.Run("dynamic user.email resolved directly for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "user.email", flatBody, "email") - assert.Equal(t, "target@test.com", res) - }) - - t.Run("fixed user id resolved and converted for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "9999", flatBody, "email") - assert.Equal(t, "target@test.com", res) - }) - - t.Run("fixed username resolved and converted for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "target_user", flatBody, "email") - assert.Equal(t, "target@test.com", res) - }) - - t.Run("fixed email address resolved directly for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "fixed@example.com", flatBody, "email") - assert.Equal(t, "fixed@example.com", res) - }) - - t.Run("fixed username resolved for non-email channel", func(t *testing.T) { - res := resolveTarget(ctx, "target_user", flatBody, "lark") - assert.Equal(t, "target_user", res) - }) - - t.Run("non-exist user resolved as fallback", func(t *testing.T) { - res := resolveTarget(ctx, "non_exist_user", flatBody, "email") - assert.Equal(t, "non_exist_user", res) - }) - - t.Run("system target resolves to admin email for email channel", func(t *testing.T) { - res := resolveTarget(ctx, "系统", flatBody, "email") - assert.Equal(t, "admin@test.com", res) - - res2 := resolveTarget(ctx, "system", flatBody, "email") - assert.Equal(t, "admin@test.com", res2) - - res3 := resolveTarget(ctx, "0", flatBody, "email") - assert.Equal(t, "admin@test.com", res3) - }) - - t.Run("system target resolves to admin username for lark channel", func(t *testing.T) { - res := resolveTarget(ctx, "系统", flatBody, "lark") - assert.Equal(t, "admin_user", res) - }) -} - -func TestPushChannelAPI(t *testing.T) { - // 1. 模型校验测试 - t.Run("validate push channel model constraints", func(t *testing.T) { - // 校验名称合法性 - c1 := &model.PushChannel{Name: "invalid-name!", URL: "https://hook.com", Other: "{}"} - require.Error(t, c1.Validate()) - - // 校验 URL 安全前缀 HTTPS - c2 := &model.PushChannel{Name: "custom_channel", URL: "http://insecure-hook.com", Other: "{}"} - require.Error(t, c2.Validate()) - - // 校验 JSON 格式 - c3 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{invalid-json}"} - require.Error(t, c3.Validate()) - - // 正确配置 - c4 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{\"content\":\"$content\"}"} - require.NoError(t, c4.Validate()) - - // 飞书渠道校验:非 HTTPS 地址报错 - c5 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "http://open.feishu.cn", Other: ""} - require.Error(t, c5.Validate()) - - // 飞书正确配置 - c6 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "https://open.feishu.cn", Other: ""} - require.NoError(t, c6.Validate()) - - // Telegram 渠道校验 - cTelegramErr := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "https://api.telegram.org", Token: "", Other: ""} - require.Error(t, cTelegramErr.Validate()) - - cTelegramErr2 := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "http://api.telegram.org", Token: "123:abc", Other: ""} - require.Error(t, cTelegramErr2.Validate()) - - cTelegramOk := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "", Token: "123:abc", Other: "-100123"} - require.NoError(t, cTelegramOk.Validate()) - assert.Equal(t, "https://api.telegram.org", cTelegramOk.URL) - - // 邮件配置校验:允许空配置以复用系统全局设置 - c7 := &model.PushChannel{Name: "email_channel", Type: "email", URL: "", Token: "", Other: ""} - assert.NoError(t, c7.Validate()) - - // 邮件正确配置 (非 HTTPS 协议 URL 允许) - c8 := &model.PushChannel{Name: "email_channel", Type: "email", URL: "smtp.exmail.qq.com:465", Token: "user@example.com", Other: "authcode"} - assert.NoError(t, c8.Validate()) - }) - - // 2. HTTP CRUD & 触发鉴权测试 - dbConn, _, cleanup := setupPushTest(t) - defer cleanup() - - // 构建路由以进行 HTTP 模拟请求 - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin") - { - adminGroup.GET("/push/channels", ListChannels) - adminGroup.POST("/push/channels", CreateChannel) - adminGroup.PUT("/push/channels/:id", UpdateChannel) - adminGroup.DELETE("/push/channels/:id", DeleteChannel) - adminGroup.POST("/push/channels/test", TestChannel) - } - - var createdID uint64 - - t.Run("admin create channel", func(t *testing.T) { - reqBody := CreateChannelRequest{ - Name: "my_custom_channel", - Description: "My custom channel webhook", - Type: "custom", - Token: "my_chan_token", - URL: "https://webhook.site/test", - Other: `{"title": "$title", "body": "$content"}`, - Enabled: true, - } - bodyBytes, _ := json.Marshal(reqBody) - req, _ := http.NewRequest("POST", "/api/v1/admin/push/channels", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataMap, ok := resp.Data.(map[string]any) - assert.True(t, ok) - assert.Equal(t, "my_custom_channel", dataMap["name"]) - createdID = uint64(dataMap["id"].(float64)) - }) - - t.Run("admin list channels", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/push/channels", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - list, ok := resp.Data.([]any) - assert.True(t, ok) - assert.Len(t, list, 1) - }) - - t.Run("admin update channel", func(t *testing.T) { - updateReq := UpdateChannelRequest{ - Description: "Updated remark", - Type: "custom", - Token: "new_chan_token", - URL: "https://webhook.site/updated", - Other: `{"text": "$content"}`, - Enabled: true, - } - bodyBytes, _ := json.Marshal(updateReq) - req, _ := http.NewRequest("PUT", "/api/v1/admin/push/channels/"+strconv.FormatUint(createdID, 10), bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var updated model.PushChannel - dbConn.First(&updated, createdID) - assert.Equal(t, "Updated remark", updated.Description) - assert.Equal(t, "new_chan_token", updated.Token) - assert.JSONEq(t, `{"text": "$content"}`, updated.Other) - }) - - t.Run("admin test channel endpoint", func(t *testing.T) { - testReq := TestChannelRequest{ - Name: "my_custom_channel", - Target: "test_target", - } - bodyBytes, _ := json.Marshal(testReq) - req, _ := http.NewRequest("POST", "/api/v1/admin/push/channels/test", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("admin delete channel", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/push/channels/"+strconv.FormatUint(createdID, 10), nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var count int64 - dbConn.Model(&model.PushChannel{}).Where("id = ?", createdID).Count(&count) - assert.Equal(t, int64(0), count) - }) -} diff --git a/backend/OpenFlare/plugins/server/admin/push/routers.go b/backend/OpenFlare/plugins/server/admin/push/routers.go deleted file mode 100644 index 10ee84d6..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/routers.go +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package push defines push notification HTTP routes. -package push - -import ( - "context" - "errors" - "fmt" - "net/http" - "strconv" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/pkg/push" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "Wavelet/pkg/response" -) - -// UpdateEventRequest 更新事件请求参数 -type UpdateEventRequest struct { - Channels []string `json:"channels"` - Targets []string `json:"targets"` - Template string `json:"template" binding:"required"` - Enabled bool `json:"enabled"` -} - -// TestPushRequest 测试推送通道请求参数 -type TestPushRequest struct { - Config push.Config `json:"config" binding:"required"` - Target string `json:"target"` -} - -// SyncEvents automatically registers/updates built-in events in the database. -func SyncEvents(ctx context.Context) error { - return syncBuiltInEvents(ctx) -} - -// ListEvents 获取通知事件列表 -// @Summary 获取所有通知事件 -// @Description 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.PushEvent} "通知事件列表" -// @Router /api/v1/admin/push/events [get] -func ListEvents(c *gin.Context) { - ctx := c.Request.Context() - events, err := listPushEvents(ctx) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(events)) -} - -// CreateEventRequest 创建事件请求参数 -type CreateEventRequest struct { - EventKey string `json:"event_key"` - TaskType string `json:"task_type"` // 关联的异步任务类型 - Channels []string `json:"channels"` - Targets []string `json:"targets"` - Template string `json:"template"` - Enabled bool `json:"enabled"` -} - -func findBuiltInEvent(key string) (EventMetadata, bool) { - for _, meta := range BuiltInEvents { - if meta.Key == key { - return meta, true - } - } - return EventMetadata{}, false -} - -// ListBuiltInEvents 获取内置通知事件列表 -// @Summary 获取所有内置通知事件 -// @Description 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]EventMetadata} "内置通知事件列表" -// @Router /api/v1/admin/push/events/builtin [get] -func ListBuiltInEvents(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(BuiltInEvents)) -} - -// CreateEvent 创建通知事件 -// @Summary 创建通知事件 -// @Description 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限 -// @Tags admin-push -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body CreateEventRequest true "创建参数" -// @Success 200 {object} response.Any{data=model.PushEvent} "创建成功" -// @Router /api/v1/admin/push/events [post] -func CreateEvent(c *gin.Context) { - var req CreateEventRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - event, err := createPushEvent(c.Request.Context(), req) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(event)) -} - -// DeleteEvent 删除通知事件配置 -// @Summary 删除通知事件配置 -// @Description 删除数据库中的特定通知事件配置,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Param id path int true "事件 ID" -// @Success 200 {object} response.Any{data=string} "删除成功" -// @Router /api/v1/admin/push/events/{id} [delete] -func DeleteEvent(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "invalid event id") - return - } - - if err := deletePushEvent(c.Request.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "notification event not found") - return - } - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} - -// UpdateEvent 更新通知事件 -// @Summary 更新通知事件 -// @Description 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限 -// @Tags admin-push -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path int true "事件 ID" -// @Param request body push.UpdateEventRequest true "更新参数" -// @Success 200 {object} response.Any{data=string} "修改成功" -// @Router /api/v1/admin/push/events/{id} [put] -func UpdateEvent(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "invalid event id") - return - } - - var req UpdateEventRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if err := updatePushEvent(c.Request.Context(), id, req); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "notification event not found") - return - } - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} - -// ToggleEvent 快捷切换通知事件启用状态 -// @Summary 快捷切换通知事件启用状态 -// @Description 启用或禁用指定的通知事件 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Param id path int true "事件 ID" -// @Success 200 {object} response.Any{data=string} "切换成功" -// @Router /api/v1/admin/push/events/{id}/toggle [post] -func ToggleEvent(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "invalid event id") - return - } - - enabled, err := togglePushEvent(c.Request.Context(), id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, "notification event not found") - return - } - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(enabled)) -} - -// pushHistoriesResponse 推送历史分页响应 -// -//nolint:unused -type pushHistoriesResponse struct { - Total int64 `json:"total"` - Results []model.PushHistory `json:"results"` -} - -// ListHistories 分页获取通知推送历史 -// @Summary 分页获取通知推送历史 -// @Description 返回分页的通知历史日志数据,需要管理员权限 -// @Tags admin-push -// @Produce json -// @Security SessionCookie -// @Param page query int false "当前页码" -// @Param page_size query int false "分页大小" -// @Param event_key query string false "过滤事件名称" -// @Param status query string false "过滤发送状态" -// @Success 200 {object} response.Any{data=pushHistoriesResponse} "推送历史列表" -// @Router /api/v1/admin/push/histories [get] -func ListHistories(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - - total, results, err := listPushHistories(c.Request.Context(), repository.PushHistoryListFilter{ - EventKey: c.Query("event_key"), - Status: c.Query("status"), - Page: page, - PageSize: pageSize, - }) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(map[string]any{ - "total": total, - "results": results, - })) -} - -// TestPush 测试推送通道发送 -// @Summary 测试推送通道发送 -// @Description 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息 -// @Tags admin-push -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body push.TestPushRequest true "测试请求体" -// @Success 200 {object} response.Any{data=string} "测试成功" -// @Router /api/v1/admin/push/test [post] -func TestPush(c *gin.Context) { - var req TestPushRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - pusher, err := push.GetPusher(req.Config.Channel) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - if err := pusher.ValidateConfig(req.Config); err != nil { - response.AbortBadRequest(c, fmt.Sprintf("validation failed: %v", err)) - return - } - - applySMTPFallbackToPushConfig(c.Request.Context(), &req.Config) - - testBody := map[string]any{ - keyTitle: "测试通道推送", - keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。", - keyLevel: defaultLevelInfo, - } - if _, err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/admin/push/task_listener.go b/backend/OpenFlare/plugins/server/admin/push/task_listener.go deleted file mode 100644 index 738f7e69..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/task_listener.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "context" - "encoding/json" - "strconv" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/logger" -) - -// RegisterTaskListeners subscribes push notification handlers to task completion events. -func RegisterTaskListeners() { - task.OnTaskCompleted(handleTaskCompleted) -} - -func handleTaskCompleted(ctx context.Context, execution *model.TaskExecution, result *task.TaskResult, execErr error) { - events, err := listActivePushEventsByTaskType(ctx, execution.TaskType) - if err != nil { - logger.ErrorF(ctx, "push_task_completed_listener: failed to query push events for task type %s: %v", execution.TaskType, err) - return - } - if len(events) == 0 { - return - } - - body := map[string]any{ - "task_id": execution.TaskID, - "task_name": execution.TaskName, - "task_type": execution.TaskType, - "task_status": string(execution.Status), - "task_duration": execution.Duration, - "time": time.Now().Format("2006-01-02 15:04:05"), - } - if execErr != nil { - body["task_error"] = execErr.Error() - } else { - body["task_error"] = "" - } - if result != nil { - body["task_result"] = result.Message - } else { - body["task_result"] = "" - } - - var payloadMap map[string]any - if execution.Payload != "" { - if err := json.Unmarshal([]byte(execution.Payload), &payloadMap); err == nil { - body["payload"] = payloadMap - extractUserFromMap(ctx, payloadMap, body) - } - } - if result != nil && result.Detail != "" { - var detailMap map[string]any - if err := json.Unmarshal([]byte(result.Detail), &detailMap); err == nil { - body["detail"] = detailMap - extractUserFromMap(ctx, detailMap, body) - } - } - - for _, event := range events { - meta := EventMetadata{ - Key: event.EventKey, - Name: event.Name, - Description: "异步任务执行完毕触发的自动通知", - } - DefaultTrigger.Trigger(ctx, meta, body) - } -} - -func extractUserFromMap(ctx context.Context, data map[string]any, body map[string]any) { - if u, exists := body["user"]; exists && u != nil { - return - } - if user := loadUserFromPayload(ctx, data); user != nil { - body["user"] = user - } -} - -func extractUserID(data map[string]any) (uint64, bool) { - for _, k := range []string{"user_id", "userId", "uid"} { - val, ok := data[k] - if !ok || val == nil { - continue - } - switch v := val.(type) { - case float64: - if v >= 0 { - return uint64(v), true - } - case int: - if v >= 0 { - return uint64(v), true - } - case int64: - if v >= 0 { - return uint64(v), true - } - case uint64: - return v, true - case string: - if id, err := strconv.ParseUint(v, 10, 64); err == nil { - return id, true - } - } - } - return 0, false -} - -func extractUsername(data map[string]any) string { - for _, k := range []string{"username", "user_name"} { - if val, ok := data[k]; ok && val != nil { - if s, ok := val.(string); ok && s != "" { - return s - } - } - } - return "" -} diff --git a/backend/OpenFlare/plugins/server/admin/push/tasks.go b/backend/OpenFlare/plugins/server/admin/push/tasks.go deleted file mode 100644 index f3f3bfa9..00000000 --- a/backend/OpenFlare/plugins/server/admin/push/tasks.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package push defines push notification HTTP routes and background tasks. -package push - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/pkg/push" -) - -const ( - // SendNotificationTask 发送推送通知任务标识 - SendNotificationTask = "push:send" - // TaskTypeSendNotification 推送通知管理类型 - TaskTypeSendNotification = "send_notification" -) - -// SendNotificationMeta represents the task metadata. -var SendNotificationMeta = task.TaskMeta{ - Type: TaskTypeSendNotification, - AsynqTask: SendNotificationTask, - Name: "推送通知", - Description: "异步执行系统通知的多渠道派发与推送", - SupportsTime: false, - MaxRetry: task.DefaultMaxRetry, - Queue: task.QueueDefault, - Retryable: true, - Params: []task.TaskParam{ - { - Name: "event_key", - Label: "事件标识", - Type: "string", - Required: true, - Placeholder: "admin_login", - }, - { - Name: "target", - Label: "目标接收者", - Type: "string", - Required: false, - }, - }, -} - -// PushHandler 通知推送异步任务处理器 -// -//nolint:revive -type PushHandler struct{} - -// ValidatePayload 校验并标准化推送参数 -func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) { - if len(payload) == 0 { - return nil, errors.New("payload is required") - } - - var req SendPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("invalid json format: %w", err) - } - - if req.Config.Channel == "" { - return nil, errors.New("channel type is required") - } - - return json.Marshal(req) -} - -// Execute 异步执行推送操作并记录推送历史审计 -func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { - var req SendPayload - if err := json.Unmarshal(payload, &req); err != nil { - task.AppendLog(ctx, "解析推送参数失败: %v", err) - return nil, fmt.Errorf("parse payload failed: %w", err) - } - - task.AppendLog(ctx, "开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target) - - pusher, err := push.GetPusher(req.Config.Channel) - if err != nil { - errWrap := fmt.Errorf("get pusher failed: %w", err) - task.AppendLog(ctx, "推送失败: %v", errWrap) - if task.IsFinalAttempt(ctx) { - h.recordHistory(ctx, req, "failed", errWrap.Error()) - } - return nil, errWrap - } - - // 执行真正的消息推送,扁平化为原始 json 格式 - flatBody := req.Body.Flatten() - upstreamResp, err := pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil) - - title := req.Body.Title - content := req.Body.Content - - if err != nil { - task.AppendLog(ctx, "消息推送失败 (标题: %s): %v", title, err) - if upstreamResp != "" { - task.AppendLog(ctx, "上游返回: %s", upstreamResp) - } - if task.IsFinalAttempt(ctx) { - h.recordHistory(ctx, req, "failed", err.Error()) - } - return nil, fmt.Errorf("pusher.Send failed: %w", err) - } - - task.AppendLog(ctx, "消息推送成功 (标题: %s, 内容摘要: %s)", title, content) - if upstreamResp != "" { - task.AppendLog(ctx, "上游返回: %s", upstreamResp) - } - h.recordHistory(ctx, req, "success", "") - - return &task.TaskResult{ - Message: fmt.Sprintf("推送成功: [%s] -> %s", req.Config.Channel, req.Target), - }, nil -} - -func (h *PushHandler) recordHistory(ctx context.Context, req SendPayload, status string, errMsg string) { - if dbErr := recordPushHistory(ctx, req, status, errMsg); dbErr != nil { - task.AppendLog(ctx, "写入推送历史审计记录失败: %v", dbErr) - } -} diff --git a/backend/OpenFlare/plugins/server/admin/status/clickhouse.go b/backend/OpenFlare/plugins/server/admin/status/clickhouse.go deleted file mode 100644 index e7b989ec..00000000 --- a/backend/OpenFlare/plugins/server/admin/status/clickhouse.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package status - -import ( - "context" - "errors" - "net/http" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/model" - analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" - "Wavelet/OpenFlare/plugins/server/openflare/chwriter" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/repository/logstore" - "Wavelet/pkg/logger" - "Wavelet/pkg/response" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// 日志库名取值(与 model 配置值、logstore provider 分支保持一致)。 -const ( - logDBNamePostgres = "postgres" - logDBNameSQLite = "sqlite" - logDBNameClickHouse = "clickhouse" -) - -// defaultLogRetentionDays 日志保留天数配置缺失时的兜底值(与 seed 默认一致)。 -const defaultLogRetentionDays = 90 - -// LogDatabaseStatus 日志库状态。 -type LogDatabaseStatus struct { - ActiveDatabase string `json:"active_database"` - Migration string `json:"migration"` // idle | migrating - RetentionDays map[string]int `json:"retention_days"` - AvailableTargets []string `json:"available_targets"` - ClickHouse *analyticsmodel.ClickHouseOperationalStats `json:"clickhouse,omitempty"` -} - -// GetLogDatabaseStatus 返回当前日志库状态。 -// @Summary 获取日志数据库状态 -// @Description 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=status.LogDatabaseStatus} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/status/log-database [get] -func GetLogDatabaseStatus(c *gin.Context) { - ctx := c.Request.Context() - store, err := logstore.Active(ctx) - if err != nil { - logger.ErrorF(ctx, "获取日志存储实例失败: %v", err) - response.AbortInternal(c, "日志存储初始化失败") - return - } - - // 分支判定复用同一 store 实例的 ActiveDatabase,避免与 Active 解析之间出现 TOCTOU。 - activeDB, err := store.Status.ActiveDatabase(ctx) - if err != nil { - logger.ErrorF(ctx, "获取日志库状态失败: %v", err) - response.AbortInternal(c, "获取日志库状态失败") - return - } - migration := "idle" - if logstore.Migrating(ctx) { - migration = "migrating" - } - - out := LogDatabaseStatus{ - ActiveDatabase: activeDB, - Migration: migration, - RetentionDays: map[string]int{ - logDBNamePostgres: retentionOr(ctx, model.ConfigKeyLogRetentionDaysPostgres), - logDBNameSQLite: retentionOr(ctx, model.ConfigKeyLogRetentionDaysSQLite), - logDBNameClickHouse: retentionOr(ctx, model.ConfigKeyLogRetentionDaysClickHouse), - }, - AvailableTargets: availableTargets(activeDB), - } - - if activeDB == logDBNameClickHouse { - stats, err := store.Status.ClickHouseOperationalStats(ctx) - if err != nil { - logger.ErrorF(ctx, "获取 ClickHouse 运行指标失败: %v", err) - } else { - stats.BatchWriters = collectBatchWriterStats() - out.ClickHouse = stats - } - } - - c.JSON(http.StatusOK, response.OK(out)) -} - -// retentionOr 读取保留天数配置,缺失或非法时返回默认值。 -func retentionOr(ctx context.Context, key string) int { - v, err := repository.GetIntByKey(ctx, key) - if err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err) - } - return defaultLogRetentionDays - } - if v < 1 { - return defaultLogRetentionDays - } - return v -} - -// availableTargets 返回当前日志主库的合法迁移目标(复用调用方已解析的 active): -// 当前为 clickhouse 时目标为主库(postgres/sqlite 按启动配置);当前为主库时目标为 clickhouse(仅 CH 启用时)。 -func availableTargets(active string) []string { - if active == logDBNameClickHouse { - if config.Config.Database.Enabled { - return []string{logDBNamePostgres} - } - return []string{logDBNameSQLite} - } - if config.Config.ClickHouse.Enabled { - return []string{logDBNameClickHouse} - } - return []string{} -} - -// collectBatchWriterStats 把批量写入器的运行期指标转成 status 层 DTO。 -// 转换留在消费侧:model 不应反向依赖上游 pkg/batchwriter 的类型。 -func collectBatchWriterStats() []analyticsmodel.BatchWriterStats { - in := chwriter.WriterStats() - out := make([]analyticsmodel.BatchWriterStats, 0, len(in)) - for _, s := range in { - out = append(out, analyticsmodel.BatchWriterStats{ - Name: s.Name, - Depth: s.Depth, - Cap: s.Cap, - Drops: s.Drops, - FlushErrors: s.FlushErrors, - Running: s.Running, - }) - } - return out -} diff --git a/backend/OpenFlare/plugins/server/admin/status/clickhouse_test.go b/backend/OpenFlare/plugins/server/admin/status/clickhouse_test.go deleted file mode 100644 index e7ede05a..00000000 --- a/backend/OpenFlare/plugins/server/admin/status/clickhouse_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package status - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/repository/logstore" - - "github.com/gin-gonic/gin" -) - -// restoreConfig 恢复测试中临时修改的全局配置。 -func restoreConfig(t *testing.T) { - t.Helper() - dbEnabled := config.Config.Database.Enabled - chEnabled := config.Config.ClickHouse.Enabled - t.Cleanup(func() { - config.Config.Database.Enabled = dbEnabled - config.Config.ClickHouse.Enabled = chEnabled - }) -} - -func TestAvailableTargets(t *testing.T) { - restoreConfig(t) - logstore.ResetForTest() - t.Cleanup(logstore.ResetForTest) - - // 当前 clickhouse → 主库(postgres/sqlite 按启动配置)。 - logstore.SetConfigReader(func(_ context.Context, key string) (string, error) { - if key == "log_database" { - return logDBNameClickHouse, nil - } - return "", nil - }) - config.Config.Database.Enabled = true - config.Config.ClickHouse.Enabled = true - if got := availableTargets(logDBNameClickHouse); !reflect.DeepEqual(got, []string{logDBNamePostgres}) { - t.Fatalf("clickhouse active + postgres main: got %v, want [postgres]", got) - } - - config.Config.Database.Enabled = false - if got := availableTargets(logDBNameClickHouse); !reflect.DeepEqual(got, []string{logDBNameSQLite}) { - t.Fatalf("clickhouse active + sqlite main: got %v, want [sqlite]", got) - } - - // 当前主库 → clickhouse(CH 启用时)。 - logstore.SetConfigReader(func(_ context.Context, key string) (string, error) { - if key == "log_database" { - return logDBNamePostgres, nil - } - return "", nil - }) - config.Config.ClickHouse.Enabled = true - if got := availableTargets(logDBNamePostgres); !reflect.DeepEqual(got, []string{logDBNameClickHouse}) { - t.Fatalf("postgres active: got %v, want [clickhouse]", got) - } - - // CH 禁用时排除 clickhouse。 - config.Config.ClickHouse.Enabled = false - if got := availableTargets(logDBNamePostgres); len(got) != 0 { - t.Fatalf("CH disabled: got %v, want empty", got) - } -} - -// TestGetLogDatabaseStatusSmoke 覆盖 handler 的 CH 激活分支(无需 DB/CH 连接)。 -func TestGetLogDatabaseStatusSmoke(t *testing.T) { - restoreConfig(t) - config.Config.Database.Enabled = false - config.Config.ClickHouse.Enabled = true - logstore.ResetForTest() - t.Cleanup(logstore.ResetForTest) - - logstore.SetConfigReader(func(_ context.Context, key string) (string, error) { - if key == "log_database" { - return logDBNameClickHouse, nil - } - return "", nil - }) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/status/log-database", nil) - - GetLogDatabaseStatus(c) - - if w.Code != http.StatusOK { - t.Fatalf("status code = %d, want 200; body=%s", w.Code, w.Body.String()) - } - var resp struct { - Data LogDatabaseStatus `json:"data"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("unmarshal body: %v", err) - } - if resp.Data.ActiveDatabase != logDBNameClickHouse { - t.Fatalf("active_database = %q, want clickhouse", resp.Data.ActiveDatabase) - } - if resp.Data.Migration != "idle" { - t.Fatalf("migration = %q, want idle", resp.Data.Migration) - } - for _, key := range []string{logDBNamePostgres, logDBNameSQLite, logDBNameClickHouse} { - if got := resp.Data.RetentionDays[key]; got != defaultLogRetentionDays { - t.Fatalf("retention_days[%s] = %d, want default %d", key, got, defaultLogRetentionDays) - } - } - if got := resp.Data.AvailableTargets; !reflect.DeepEqual(got, []string{logDBNameSQLite}) { - t.Fatalf("available_targets = %v, want [sqlite] (test main DB disabled)", got) - } -} diff --git a/backend/OpenFlare/plugins/server/admin/status/routers.go b/backend/OpenFlare/plugins/server/admin/status/routers.go deleted file mode 100644 index 66e950d6..00000000 --- a/backend/OpenFlare/plugins/server/admin/status/routers.go +++ /dev/null @@ -1,357 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package status 提供系统状态查询接口 -package status - -import ( - "context" - "fmt" - "log" - "math" - "net/http" - "os" - "os/exec" - "runtime" - "strconv" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -// startTime 记录服务启动时间 -var startTime = time.Now() - -const ( - hoursInDay = 24 - minutesInHour = 60 - secondsInMinute = 60 - nanosPerSecond = 1e9 - binaryKB = 0 - binaryMB = 1 - binaryGB = 2 - valueThreshold = 10 // 格式化时区分整数显示的阈值 -) - -// SystemStatusResponse 系统状态响应结构体 -type SystemStatusResponse struct { - Uptime string `json:"uptime"` - NumGoroutine int `json:"num_goroutine"` - Alloc string `json:"alloc"` - TotalAlloc string `json:"total_alloc"` - Sys string `json:"sys"` - Lookups uint64 `json:"lookups"` - Mallocs uint64 `json:"mallocs"` - Frees uint64 `json:"frees"` - HeapAlloc string `json:"heap_alloc"` - HeapSys string `json:"heap_sys"` - HeapIdle string `json:"heap_idle"` - HeapInuse string `json:"heap_inuse"` - HeapReleased string `json:"heap_released"` - HeapObjects uint64 `json:"heap_objects"` - StackInuse string `json:"stack_inuse"` - StackSys string `json:"stack_sys"` - MSpanInuse string `json:"mspan_inuse"` - MSpanSys string `json:"mspan_sys"` - MCacheInuse string `json:"mcache_inuse"` - MCacheSys string `json:"mcache_sys"` - BuckHashSys string `json:"buck_hash_sys"` - GCSys string `json:"gc_sys"` - OtherSys string `json:"other_sys"` - NextGC string `json:"next_gc"` - LastGCTime string `json:"last_gc_time"` - PauseTotalNs string `json:"pause_total_ns"` - LastPause string `json:"last_pause"` - NumGC uint32 `json:"num_gc"` -} - -// formatBytes 格式化字节大小 -func formatBytes(bytes uint64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - value := float64(bytes) / float64(div) - var suffix string - switch exp { - case binaryKB: - suffix = "KiB" - case binaryMB: - suffix = "MiB" - case binaryGB: - suffix = "GiB" - default: - suffix = "TiB" - } - - // 格式化规则: - // - 如果是整数(如 16, 73, 105, 986, 112): - // - 如果 >= 10,则格式化为 "%.0f" (e.g. "16 KiB") - // - 如果 < 10,则格式化为 "%.1f" (e.g. "9.0 KiB") - // - 如果不是整数(如 5.8, 9.1, 7.6, 4.8):格式化为 "%.1f" - if value == math.Trunc(value) { - if value >= valueThreshold { - return fmt.Sprintf("%.0f %s", value, suffix) - } - return fmt.Sprintf("%.1f %s", value, suffix) - } - return fmt.Sprintf("%.1f %s", value, suffix) -} - -// formatDuration 格式化时间持续时间 -func formatDuration(d time.Duration) string { - days := int(d.Hours()) / hoursInDay - hours := int(d.Hours()) % hoursInDay - minutes := int(d.Minutes()) % minutesInHour - seconds := int(d.Seconds()) % secondsInMinute - - var res string - if days > 0 { - res += fmt.Sprintf("%d天", days) - } - if hours > 0 { - res += fmt.Sprintf("%d小时", hours) - } - if minutes > 0 { - res += fmt.Sprintf("%d分钟", minutes) - } - if seconds > 0 || res == "" { - res += fmt.Sprintf("%d秒钟", seconds) - } - return res -} - -// GetSystemStatus 获取系统状态信息 -// @Summary 获取系统状态信息 -// @Description 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=status.SystemStatusResponse} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/status [get] -func GetSystemStatus(c *gin.Context) { - var m runtime.MemStats - runtime.ReadMemStats(&m) - - uptime := formatDuration(time.Since(startTime)) - numGoroutine := runtime.NumGoroutine() - - var lastGCTime string - switch { - case m.LastGC > 0 && m.LastGC <= math.MaxInt64: - lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC)))) - case m.LastGC > 0: - lastGCTime = "未知" - default: - lastGCTime = "无" - } - - var lastPause string - if m.NumGC > 0 { - lastPause = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC-1)%256])/nanosPerSecond) - } else { - lastPause = "0.000s" - } - - res := SystemStatusResponse{ - Uptime: uptime, - NumGoroutine: numGoroutine, - Alloc: formatBytes(m.Alloc), - TotalAlloc: formatBytes(m.TotalAlloc), - Sys: formatBytes(m.Sys), - Lookups: m.Lookups, - Mallocs: m.Mallocs, - Frees: m.Frees, - HeapAlloc: formatBytes(m.HeapAlloc), - HeapSys: formatBytes(m.HeapSys), - HeapIdle: formatBytes(m.HeapIdle), - HeapInuse: formatBytes(m.HeapInuse), - HeapReleased: formatBytes(m.HeapReleased), - HeapObjects: m.HeapObjects, - StackInuse: formatBytes(m.StackInuse), - StackSys: formatBytes(m.StackSys), - MSpanInuse: formatBytes(m.MSpanInuse), - MSpanSys: formatBytes(m.MSpanSys), - MCacheInuse: formatBytes(m.MCacheInuse), - MCacheSys: formatBytes(m.MCacheSys), - BuckHashSys: formatBytes(m.BuckHashSys), - GCSys: formatBytes(m.GCSys), - OtherSys: formatBytes(m.OtherSys), - NextGC: formatBytes(m.NextGC), - LastGCTime: lastGCTime, - PauseTotalNs: fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/nanosPerSecond), - LastPause: lastPause, - NumGC: m.NumGC, - } - - c.JSON(http.StatusOK, response.OK(res)) -} - -// DatabaseInfoResponse 数据库信息响应结构体 -type DatabaseInfoResponse struct { - Type string `json:"type"` - Name string `json:"name"` - Version string `json:"version"` -} - -// getSQLiteInfo 返回 SQLite 数据库信息 -func getSQLiteInfo(ctx context.Context) DatabaseInfoResponse { - info := DatabaseInfoResponse{ - Type: "sqlite", - Name: config.Config.Database.SQLitePath, - Version: "SQLite", - } - if info.Name == "" { - info.Name = "./data/openflare.db" - } - gormDB := db.DB(ctx) - if gormDB == nil { - return info - } - var ver string - if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil && ver != "" { - info.Version = "SQLite " + ver - } - return info -} - -// getPostgresInfo 返回 PostgreSQL 数据库信息 -func getPostgresInfo(ctx context.Context) DatabaseInfoResponse { - info := DatabaseInfoResponse{ - Type: "postgres", - Name: config.Config.Database.Database, - Version: "PostgreSQL", - } - gormDB := db.DB(ctx) - if gormDB == nil { - return info - } - var ver string - if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil && ver != "" { - info.Version = ver - } - return info -} - -// GetDatabaseInfo 获取当前数据库类型及版本信息 -// @Summary 获取数据库信息 -// @Description 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=status.DatabaseInfoResponse} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/db-info [get] -func GetDatabaseInfo(c *gin.Context) { - var info DatabaseInfoResponse - if !config.Config.Database.Enabled { - info = getSQLiteInfo(c.Request.Context()) - } else { - info = getPostgresInfo(c.Request.Context()) - } - c.JSON(http.StatusOK, response.OK(info)) -} - -// ExportDatabase 导出数据库 -// @Summary 导出数据库 -// @Description SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 -// @Tags admin -// @Produce application/octet-stream -// @Security SessionCookie -// @Success 200 {file} binary "数据库文件" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "导出失败" -// @Router /api/v1/admin/db-export [get] -func ExportDatabase(c *gin.Context) { - if !config.Config.Database.Enabled { - exportSQLite(c) - } else { - exportPostgres(c) - } -} - -// exportSQLite 以 HTTP 附件方式下载 SQLite .db 文件 -func exportSQLite(c *gin.Context) { - path := config.Config.Database.SQLitePath - if path == "" { - path = "./data/openflare.db" - } - - f, err := os.Open(path) //nolint:gosec // path is loaded from server startup configuration, not user input - if err != nil { - response.AbortInternal(c, "无法打开数据库文件: "+err.Error()) - return - } - defer func() { - if closeErr := f.Close(); closeErr != nil { - _ = closeErr - } - }() - - fi, err := f.Stat() - if err != nil { - response.AbortInternal(c, "无法读取数据库文件信息: "+err.Error()) - return - } - - c.Header("Content-Disposition", `attachment; filename="openflare.db"`) - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Length", strconv.FormatInt(fi.Size(), 10)) - c.Status(http.StatusOK) - http.ServeContent(c.Writer, c.Request, "openflare.db", fi.ModTime(), f) -} - -// exportPostgres 执行 pg_dump 并将输出流式传输给客户端 -func exportPostgres(c *gin.Context) { - dbCfg := config.Config.Database - - // 检查 pg_dump 是否可用 - pgDumpPath, err := exec.LookPath("pg_dump") - if err != nil { - response.AbortInternal(c, "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具") - return - } - - args := []string{ - "--no-password", - "-h", dbCfg.Host, - "-p", strconv.Itoa(dbCfg.Port), - "-U", dbCfg.Username, - dbCfg.Database, - } - - cmd := exec.CommandContext(c.Request.Context(), pgDumpPath, args...) //nolint:gosec // pgDumpPath is a looked up command path, args are from database configuration - if dbCfg.Password != "" { - cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password) - } else { - cmd.Env = os.Environ() - } - - fileName := fmt.Sprintf("openflare_%s.sql", time.Now().Format("20060102_150405")) - c.Header("Content-Disposition", `attachment; filename="`+fileName+`"`) - c.Header("Content-Type", "application/octet-stream") - c.Status(http.StatusOK) - - cmd.Stdout = c.Writer - cmd.Stderr = nil // 忽略 stderr 以避免污染输出流 - - if err := cmd.Run(); err != nil { - // 响应头已发出,无法再写 JSON 错误,记录到服务器日志 - log.Printf("[db-export] pg_dump failed: %v\n", err) - } -} diff --git a/backend/OpenFlare/plugins/server/admin/system_config/errs.go b/backend/OpenFlare/plugins/server/admin/system_config/errs.go deleted file mode 100644 index 0ec1df84..00000000 --- a/backend/OpenFlare/plugins/server/admin/system_config/errs.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package system_config 提供系统配置管理功能 -package system_config - -// 系统配置错误消息常量 -const ( - SystemConfigNotFound = "系统配置不存在" - ConfigKeyRequired = "配置键不能为空" - ConfigValueRequired = "配置值不能为空" - ConfigKeyExists = "配置键已存在" - StorageDriverSwitchRequiresMigration = "存在存量文件,请通过存储迁移任务切换存储引擎" -) diff --git a/backend/OpenFlare/plugins/server/admin/system_config/logics.go b/backend/OpenFlare/plugins/server/admin/system_config/logics.go deleted file mode 100644 index 76fab90b..00000000 --- a/backend/OpenFlare/plugins/server/admin/system_config/logics.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package system_config - -import ( - "context" - "encoding/json" - "errors" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/objectstore" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/pkg/logger" - - "gorm.io/gorm" -) - -func createSystemConfig(ctx context.Context, req CreateSystemConfigRequest) error { - // 防御:受保护 key(log_database / log_db_migration)仅允许内部写入,Handler 已拦截。 - if isProtectedConfigKey(req.Key) { - return errors.New(protectedConfigKeyMessage) - } - - exists, err := repository.SystemConfigExists(ctx, req.Key) - if err != nil { - return err - } - if exists { - return errors.New(ConfigKeyExists) - } - - config := model.SystemConfig{ - Key: req.Key, - Value: req.Value, - Type: req.Type, - Visibility: req.Visibility, - Description: req.Description, - } - if err := repository.CreateSystemConfig(ctx, &config); err != nil { - return err - } - - invalidateSystemConfigCaches(ctx, req.Key) - if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { - logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) - } - return nil -} - -func listSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) { - return repository.ListAdminSystemConfigs(ctx, configType) -} - -func getSystemConfig(ctx context.Context, key string) (model.SystemConfig, error) { - return repository.GetAdminSystemConfigByKey(ctx, key) -} - -func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigRequest) error { - config, err := repository.GetAdminSystemConfigByKey(ctx, key) - if err != nil { - return err - } - - var originalDriver objectstore.Driver - if key == model.ConfigKeyStorageConfig { - var currentCfg objectstore.Config - if err := json.Unmarshal([]byte(config.Value), ¤tCfg); err == nil { - originalDriver = currentCfg.Driver - } - - validatedVal, err := validateAndMergeStorageConfig(ctx, req.Value, config.Value) - if err != nil { - return err - } - req.Value = validatedVal - } - - if err := repository.RunInTransaction(ctx, func(tx *gorm.DB) error { - updates := map[string]any{ - "description": req.Description, - } - if req.Visibility != nil { - updates["visibility"] = *req.Visibility - config.Visibility = *req.Visibility - } - if key != model.ConfigKeySMTPPassword || req.Value != maskedConfigValue { - updates["value"] = req.Value - config.Value = req.Value - } - if err := repository.UpdateSystemConfigFieldsTx(tx, &config, updates); err != nil { - return err - } - resolveStorageMigrationTasksOnDirectDriverUpdate(ctx, tx, key, originalDriver, req.Value) - return nil - }); err != nil { - return err - } - - invalidateCachesAfterConfigUpdate(ctx, key) - return nil -} - -func resolveStorageMigrationTasksOnDirectDriverUpdate( - ctx context.Context, - tx *gorm.DB, - key string, - originalDriver objectstore.Driver, - newValue string, -) { - if key != model.ConfigKeyStorageConfig || originalDriver == "" { - return - } - - var newCfg objectstore.Config - if err := json.Unmarshal([]byte(newValue), &newCfg); err != nil { - return - } - if newCfg.Driver != originalDriver { - return - } - - if err := repository.MarkFailedTaskExecutionsSucceededTx( - tx, - "storage:migrate", - "存储配置直接更新,故障迁移任务自动标记为已解决", - time.Now(), - ); err != nil { - logger.ErrorF(ctx, "自动更新迁移任务状态失败: %v", err) - } -} diff --git a/backend/OpenFlare/plugins/server/admin/system_config/routers.go b/backend/OpenFlare/plugins/server/admin/system_config/routers.go deleted file mode 100644 index 520bf286..00000000 --- a/backend/OpenFlare/plugins/server/admin/system_config/routers.go +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package system_config - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "Wavelet/OpenFlare/plugins/server/cap" - "Wavelet/OpenFlare/plugins/server/infra/objectstore" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" - "Wavelet/pkg/logger" - mail "Wavelet/pkg/mail" - "Wavelet/pkg/response" -) - -const maskedConfigValue = "******" - -// protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。 -const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改" - -// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。 -var protectedConfigKeys = map[string]bool{ - model.ConfigKeyLogDatabase: true, - model.ConfigKeyLogDBMigration: true, -} - -func isProtectedConfigKey(key string) bool { return protectedConfigKeys[key] } - -// CreateSystemConfigRequest 创建系统配置请求 -type CreateSystemConfigRequest struct { - Key string `json:"key" binding:"required,max=64"` - Value string `json:"value" binding:"required"` - Type string `json:"type" binding:"required,oneof=system business"` - Visibility int `json:"visibility" binding:"oneof=0 1"` - Description string `json:"description" binding:"max=255"` -} - -// UpdateSystemConfigRequest 更新系统配置请求 -type UpdateSystemConfigRequest struct { - Value string `json:"value" binding:"required"` - Visibility *int `json:"visibility" binding:"omitempty,oneof=0 1"` - Description string `json:"description" binding:"max=255"` -} - -// CreateSystemConfig 创建系统配置 -// @Summary 创建系统配置 -// @Description 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body system_config.CreateSystemConfigRequest true "创建请求参数" -// @Success 200 {object} response.Any{data=string} "创建成功" -// @Failure 400 {object} response.Any "参数错误或配置键已存在" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/system-configs [post] -func CreateSystemConfig(c *gin.Context) { - var req CreateSystemConfigRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - // 与 PUT 路径一致:log_database / log_db_migration 仅允许内部(迁移任务/bootstrap)写入。 - if isProtectedConfigKey(req.Key) { - response.AbortBadRequest(c, protectedConfigKeyMessage) - return - } - - if err := createSystemConfig(c.Request.Context(), req); err != nil { - if err.Error() == ConfigKeyExists { - response.AbortBadRequest(c, ConfigKeyExists) - return - } - if err.Error() == protectedConfigKeyMessage { - response.AbortBadRequest(c, protectedConfigKeyMessage) - return - } - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OKNil()) -} - -// ListSystemConfigs 获取系统配置列表 -// @Summary 获取系统配置列表 -// @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param type query string false "配置类型(system/business)" -// @Success 200 {object} response.Any{data=[]model.SystemConfig} "系统配置列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/system-configs [get] -func ListSystemConfigs(c *gin.Context) { - configs, err := listSystemConfigs(c.Request.Context(), c.Query("type")) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - for i := range configs { - configs[i].Value = maskSensitiveConfig(configs[i].Key, configs[i].Value) - } - - c.JSON(http.StatusOK, response.OK(configs)) -} - -// GetSystemConfig 获取单个系统配置 -// @Summary 获取单个系统配置 -// @Description 根据配置键获取对应的系统配置详情,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param key path string true "配置键" -// @Success 200 {object} response.Any{data=model.SystemConfig} "系统配置详情" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "配置不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/system-configs/{key} [get] -func GetSystemConfig(c *gin.Context) { - config, err := getSystemConfig(c.Request.Context(), c.Param("key")) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, SystemConfigNotFound) - } else { - response.AbortInternal(c, err.Error()) - } - return - } - - config.Value = maskSensitiveConfig(config.Key, config.Value) - - c.JSON(http.StatusOK, response.OK(config)) -} - -// UpdateSystemConfig 更新系统配置 -// @Summary 更新系统配置 -// @Description 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param key path string true "配置键" -// @Param request body system_config.UpdateSystemConfigRequest true "更新请求参数" -// @Success 200 {object} response.Any{data=string} "更新成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "配置不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/system-configs/{key} [put] -func UpdateSystemConfig(c *gin.Context) { - var req UpdateSystemConfigRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - key := c.Param("key") - if isProtectedConfigKey(key) { - response.AbortBadRequest(c, protectedConfigKeyMessage) - return - } - if err := updateSystemConfig(c.Request.Context(), key, req); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, SystemConfigNotFound) - return - } - if isStorageConfigValidationError(err) { - response.AbortBadRequest(c, err.Error()) - return - } - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OKNil()) -} - -func invalidateSystemConfigCaches(ctx context.Context, key string) { - if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil { - logger.WarnF(ctx, "清理系统配置缓存失败: %v", err) - } - if cap.IsRuntimeConfigKey(key) { - cap.InvalidateRuntimeSettings() - } -} - -func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) { - invalidateSystemConfigCaches(ctx, key) - - if key == model.ConfigKeyStorageConfig { - upload.ResetAccessCaches() - upload.PublishAccessCacheInvalidation(ctx) - objectstore.ResetCache() - objectstore.PublishCacheInvalidation(ctx) - } - if key == model.ConfigKeyFileAccessWhitelist { - upload.ResetAccessCaches() - upload.PublishAccessCacheInvalidation(ctx) - } - - if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { - logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) - } -} - -// TestSMTPRequest 测试 SMTP 配置请求 -type TestSMTPRequest struct { - SMTPHost string `json:"smtp_host" binding:"required,max=255"` - SMTPPort int `json:"smtp_port" binding:"required"` - SMTPUsername string `json:"smtp_username" binding:"required,max=255"` - SMTPPassword string `json:"smtp_password" binding:"required,max=255"` - To string `json:"to" binding:"required,email"` -} - -// TestSMTPResponse 测试 SMTP 配置响应 -type TestSMTPResponse struct { - Success bool `json:"success"` - Log string `json:"log"` - Error string `json:"error"` -} - -// TestSMTP 测试 SMTP 邮件发送 -// @Summary 测试 SMTP 邮件发送 -// @Description 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body system_config.TestSMTPRequest true "测试请求参数" -// @Success 200 {object} response.Any{data=system_config.TestSMTPResponse} "测试执行完毕" -// @Failure 400 {object} response.Any "参数错误" -// @Router /api/v1/admin/system-configs/smtp/test [post] -func TestSMTP(c *gin.Context) { - var req TestSMTPRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - password := req.SMTPPassword - if password == maskedConfigValue { - if sc, err := repository.GetSystemConfigByKey(c.Request.Context(), model.ConfigKeySMTPPassword); err == nil { - password = sc.Value - } - } - - cfg := mail.Config{ - Host: req.SMTPHost, - Port: req.SMTPPort, - Username: req.SMTPUsername, - Password: password, - } - - subject := "OpenFlare SMTP Test Mail" - body := `
If you received this message, your SMTP configuration is correct and mail sending is working properly.
-Sent from OpenFlare.
` - - logs, err := mail.SendMailWithLog(c.Request.Context(), cfg, req.To, subject, body) - resp := TestSMTPResponse{ - Success: err == nil, - Log: logs, - } - if err != nil { - resp.Error = err.Error() - } - - c.JSON(http.StatusOK, response.OK(resp)) -} - -func isStorageConfigValidationError(err error) bool { - msg := err.Error() - return msg == StorageDriverSwitchRequiresMigration || - strings.HasPrefix(msg, "解析") || - strings.HasPrefix(msg, "验证") || - strings.HasPrefix(msg, "初始化测试") || - strings.HasPrefix(msg, "存储连通性") || - strings.HasPrefix(msg, "序列化") || - strings.HasPrefix(msg, "检查存量文件") -} - -func maskSensitiveConfig(key, value string) string { - if value == "" { - return value - } - switch key { - case model.ConfigKeySMTPPassword: - return maskedConfigValue - case model.ConfigKeyStorageConfig: - var cfg objectstore.Config - if err := json.Unmarshal([]byte(value), &cfg); err == nil { - masked := objectstore.MaskSecrets(cfg) - if val, err := json.Marshal(masked); err == nil { - return string(val) - } - } - } - return value -} - -// validateAndMergeStorageConfig parses, merges unmasked secrets, validates parameter values, -// and tests connectivity of the new storage configuration. -func validateAndMergeStorageConfig(ctx context.Context, value string, currentConfig string) (string, error) { - var currentCfg objectstore.Config - if err := json.Unmarshal([]byte(currentConfig), ¤tCfg); err != nil { - return "", fmt.Errorf("解析当前存储配置失败: %w", err) - } - - var newCfg objectstore.Config - if err := json.Unmarshal([]byte(value), &newCfg); err != nil { - return "", fmt.Errorf("解析目标存储配置失败: %w", err) - } - - // 合并被掩码屏蔽的敏感信息,获取完整的真实配置 - targetCfg := objectstore.MergeMaskedSecrets(newCfg, currentCfg) - if err := validateMergedStorageConfig(ctx, currentCfg, newCfg, targetCfg); err != nil { - return "", err - } - - // 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符 - unmaskedVal, err := json.Marshal(targetCfg) - if err != nil { - return "", fmt.Errorf("序列化存储配置失败: %w", err) - } - - return string(unmaskedVal), nil -} - -func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, targetCfg objectstore.Config) error { - if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver { - uploadCount, err := repository.CountActiveUploads(ctx) - if err != nil { - return fmt.Errorf("检查存量文件失败: %w", err) - } - if uploadCount > 0 { - return errors.New(StorageDriverSwitchRequiresMigration) - } - if err := validateDriverConfig(targetCfg, newCfg.Driver); err != nil { - return fmt.Errorf("验证目标存储配置参数失败: %w", err) - } - pendingCfg := targetCfg - pendingCfg.Driver = newCfg.Driver - return testStorageBackend(ctx, pendingCfg, newCfg.Driver) - } - - if err := objectstore.ValidateConfig(targetCfg); err != nil { - return fmt.Errorf("验证存储配置参数失败: %w", err) - } - return testStorageBackend(ctx, targetCfg, targetCfg.Driver) -} - -func validateDriverConfig(cfg objectstore.Config, driver objectstore.Driver) error { - cfg.Driver = driver - return objectstore.ValidateConfig(cfg) -} - -func testStorageBackend(ctx context.Context, cfg objectstore.Config, driver objectstore.Driver) error { - testBackend, err := objectstore.NewBackend(ctx, cfg, driver) - if err != nil { - return fmt.Errorf("初始化测试存储实例失败: %w", err) - } - if err := testBackend.Test(ctx); err != nil { - return fmt.Errorf("存储连通性测试失败: %w", err) - } - return nil -} diff --git a/backend/OpenFlare/plugins/server/admin/system_config/routers_test.go b/backend/OpenFlare/plugins/server/admin/system_config/routers_test.go deleted file mode 100644 index 076f0e09..00000000 --- a/backend/OpenFlare/plugins/server/admin/system_config/routers_test.go +++ /dev/null @@ -1,544 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package system_config - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "net/textproto" - "strings" - "testing" - - "Wavelet/OpenFlare/plugins/server/infra/objectstore" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -const expectedDefaultConfigsCount = 37 - -func setupTestRouter(authUser *model.User) *gin.Engine { - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin") - - // Mock authentication middleware - adminGroup.Use(func(c *gin.Context) { - if authUser != nil { - oauth.SetToContext(c, oauth.UserObjKey, authUser) - } - c.Next() - }) - - adminGroup.POST("/system-configs", CreateSystemConfig) - adminGroup.GET("/system-configs", ListSystemConfigs) - - systemConfigRouter := adminGroup.Group("/system-configs/:key") - { - systemConfigRouter.GET("", GetSystemConfig) - systemConfigRouter.PUT("", UpdateSystemConfig) - } - - return r -} - -func TestCreateSystemConfig(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("create successfully", func(t *testing.T) { - payload := CreateSystemConfigRequest{ - Key: "custom_key", - Value: "custom_value", - Type: "system", - Visibility: model.ConfigVisibilityVisible, - Description: "desc", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - // Verify database - var cfg model.SystemConfig - err := dbConn.Where("key = ?", "custom_key").First(&cfg).Error - if err != nil { - t.Fatalf("failed to find system config in DB: %v", err) - } - - // Verify caches are invalidated after create and repopulate on read - _, err = db.Redis.HGet( - context.Background(), - db.PrefixedKey(repository.SystemConfigRedisHashKey), - "custom_key", - ).Result() - if err == nil { - t.Fatal("expected redis cache miss immediately after create") - } - - loaded, err := repository.GetSystemConfigByKey(context.Background(), "custom_key") - if err != nil { - t.Fatalf("GetSystemConfigByKey(custom_key) error = %v", err) - } - if loaded.Value != "custom_value" { - t.Errorf("GetSystemConfigByKey(custom_key).Value = %q, want %q", loaded.Value, "custom_value") - } - if loaded.Visibility != model.ConfigVisibilityVisible { - t.Errorf("GetSystemConfigByKey(custom_key).Visibility = %d, want %d", loaded.Visibility, model.ConfigVisibilityVisible) - } - }) - - t.Run("create duplicate key error", func(t *testing.T) { - // Key "custom_key" already exists from previous test - payload := CreateSystemConfigRequest{ - Key: "custom_key", - Value: "another_value", - Type: "system", - Description: "desc", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request on duplicate key, got %d", w.Code) - } - }) -} - -func TestListSystemConfigs(t *testing.T) { - _, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("list all seeded configurations", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var configs []model.SystemConfig - _ = json.Unmarshal(dataBytes, &configs) - - // Defaults seed configurations - if len(configs) != expectedDefaultConfigsCount { - t.Errorf("expected %d default configs, got %d", expectedDefaultConfigsCount, len(configs)) - } - }) - - t.Run("filter by type business", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs?type=business", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var configs []model.SystemConfig - _ = json.Unmarshal(dataBytes, &configs) - - if len(configs) != 8 { - t.Errorf("expected 8 business configs, got %d: %v", len(configs), configs) - } - }) -} - -func TestGetSystemConfig(t *testing.T) { - _, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("get existing configuration", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d", w.Code) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var cfg model.SystemConfig - _ = json.Unmarshal(dataBytes, &cfg) - - if cfg.Value != "OpenFlare" { - t.Errorf("expected 'OpenFlare', got '%s'", cfg.Value) - } - }) - - t.Run("get non-existent config", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs/non_existent_key", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 Not Found, got %d", w.Code) - } - }) -} - -func TestUpdateSystemConfig(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("update successfully", func(t *testing.T) { - hidden := model.ConfigVisibilityHidden - payload := UpdateSystemConfigRequest{ - Value: "Super Site Name", - Visibility: &hidden, - Description: "Updated Description", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - // Verify database - var cfg model.SystemConfig - dbConn.Where("key = ?", model.ConfigKeySiteName).First(&cfg) - if cfg.Value != "Super Site Name" || cfg.Description != "Updated Description" || cfg.Visibility != model.ConfigVisibilityHidden { - t.Errorf("database values not updated: %+v", cfg) - } - - // Verify caches are invalidated after update and repopulate on read - _, err := db.Redis.HGet( - context.Background(), - db.PrefixedKey(repository.SystemConfigRedisHashKey), - model.ConfigKeySiteName, - ).Result() - if err == nil { - t.Fatal("expected redis cache miss immediately after update") - } - - loaded, err := repository.GetSystemConfigByKey(context.Background(), model.ConfigKeySiteName) - if err != nil { - t.Fatalf("GetSystemConfigByKey(site_name) error = %v", err) - } - if loaded.Value != "Super Site Name" { - t.Errorf("GetSystemConfigByKey(site_name).Value = %q, want %q", loaded.Value, "Super Site Name") - } - if loaded.Visibility != model.ConfigVisibilityHidden { - t.Errorf("GetSystemConfigByKey(site_name).Visibility = %d, want %d", loaded.Visibility, model.ConfigVisibilityHidden) - } - }) - - t.Run("update non-existent config", func(t *testing.T) { - payload := UpdateSystemConfigRequest{ - Value: "New Value", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/invalid_key", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 Not Found, got %d", w.Code) - } - }) -} - -func TestTestSMTP(t *testing.T) { - _, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - r := setupTestRouter(adminUser) - r.POST("/api/v1/admin/system-configs/smtp/test", TestSMTP) - - // Start a mock SMTP server - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to start mock smtp server: %v", err) - } - defer func() { _ = l.Close() }() - - port := l.Addr().(*net.TCPAddr).Port - - go func() { - conn, err := l.Accept() - if err != nil { - return - } - defer func() { _ = conn.Close() }() - - writer := bufio.NewWriter(conn) - reader := bufio.NewReader(conn) - tp := textproto.NewReader(reader) - - // 220 Ready - _, _ = writer.WriteString("220 mock.smtp.com SMTP Ready\r\n") - _ = writer.Flush() - - // Read HELO/EHLO - _, _ = tp.ReadLine() - _, _ = writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n") - _ = writer.Flush() - - // Read AUTH PLAIN - _, _ = tp.ReadLine() - _, _ = writer.WriteString("235 Authentication successful\r\n") - _ = writer.Flush() - - // Read MAIL FROM - _, _ = tp.ReadLine() - _, _ = writer.WriteString("250 OK\r\n") - _ = writer.Flush() - - // Read RCPT TO - _, _ = tp.ReadLine() - _, _ = writer.WriteString("250 OK\r\n") - _ = writer.Flush() - - // Read DATA - _, _ = tp.ReadLine() - _, _ = writer.WriteString("354 Start mail input\r\n") - _ = writer.Flush() - - // Read body lines until dot - for { - line, err := tp.ReadLine() - if err != nil || line == "." { - break - } - } - _, _ = writer.WriteString("250 OK\r\n") - _ = writer.Flush() - - // Read QUIT - _, _ = tp.ReadLine() - _, _ = writer.WriteString("221 Bye\r\n") - _ = writer.Flush() - }() - - payload := TestSMTPRequest{ - SMTPHost: "127.0.0.1", - SMTPPort: port, - SMTPUsername: "sender@example.com", - SMTPPassword: "password", - To: "recipient@example.com", - } - - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs/smtp/test", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var testResp TestSMTPResponse - json.Unmarshal(dataBytes, &testResp) - - if !testResp.Success { - t.Errorf("expected test success, got failed: %s. Log: %s", testResp.Error, testResp.Log) - } -} - -func TestUpdateStorageConfigValidation(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("update storage config successfully", func(t *testing.T) { - tempDir := t.TempDir() - cfg := objectstore.DefaultConfig() - cfg.Local.Root = tempDir - - cfgBytes, _ := json.Marshal(cfg) - payload := UpdateSystemConfigRequest{ - Value: string(cfgBytes), - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - // Verify database - var dbCfg model.SystemConfig - dbConn.Where("key = ?", "storage_config").First(&dbCfg) - var savedCfg objectstore.Config - _ = json.Unmarshal([]byte(dbCfg.Value), &savedCfg) - if savedCfg.Local.Root != tempDir { - t.Errorf("expected local root to be updated to %s, got %s", tempDir, savedCfg.Local.Root) - } - }) - - t.Run("update storage config failed connectivity check", func(t *testing.T) { - cfg := objectstore.DefaultConfig() - cfg.Driver = objectstore.DriverS3 - cfg.S3.Bucket = "non-existent-bucket" - cfg.S3.Endpoint = "http://127.0.0.1:9999" // Will fail connectivity check - - cfgBytes, _ := json.Marshal(cfg) - payload := UpdateSystemConfigRequest{ - Value: string(cfgBytes), - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) - } - }) - - t.Run("reject driver switch when uploads exist", func(t *testing.T) { - upload := model.Upload{ - ID: 88001, - UserID: 1, - FileName: "keep.txt", - FilePath: "uploads/keep.txt", - FileSize: 4, - MimeType: "text/plain", - Extension: "txt", - Type: "attachment", - Status: model.UploadStatusUsed, - } - if err := dbConn.Create(&upload).Error; err != nil { - t.Fatalf("seed upload failed: %v", err) - } - - tempDir := t.TempDir() - cfg := objectstore.DefaultConfig() - cfg.Driver = objectstore.DriverS3 - cfg.S3.Endpoint = "http://127.0.0.1:19998" - cfg.S3.Region = "us-east-1" - cfg.S3.Bucket = "wavelet" - cfg.S3.AccessKeyID = "test" - cfg.S3.SecretAccessKey = "test" - cfg.Local.Root = tempDir - - cfgBytes, _ := json.Marshal(cfg) - payload := UpdateSystemConfigRequest{Value: string(cfgBytes)} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), StorageDriverSwitchRequiresMigration) { - t.Fatalf("expected migration-required error, got: %s", w.Body.String()) - } - }) - - t.Run("switch to local while active s3 is unreachable", func(t *testing.T) { - if err := dbConn.Where("1 = 1").Delete(&model.Upload{}).Error; err != nil { - t.Fatalf("clear uploads failed: %v", err) - } - - activeCfg := objectstore.DefaultConfig() - activeCfg.Driver = objectstore.DriverS3 - activeCfg.S3.Endpoint = "http://127.0.0.1:9999" - activeCfg.S3.Region = "us-east-1" - activeCfg.S3.Bucket = "wavelet" - activeCfg.S3.AccessKeyID = "test" - activeCfg.S3.SecretAccessKey = "test" - activeBytes, _ := json.Marshal(activeCfg) - seedCfg := model.SystemConfig{ - Key: "storage_config", - Value: string(activeBytes), - Type: "system", - } - if err := dbConn.Where("key = ?", "storage_config"). - Assign(map[string]any{"value": seedCfg.Value, "type": seedCfg.Type}). - FirstOrCreate(&seedCfg).Error; err != nil { - t.Fatalf("seed active storage config failed: %v", err) - } - - tempDir := t.TempDir() - stagedCfg := activeCfg - stagedCfg.Driver = objectstore.DriverLocal - stagedCfg.Local.Root = tempDir - - cfgBytes, _ := json.Marshal(stagedCfg) - payload := UpdateSystemConfigRequest{Value: string(cfgBytes)} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var dbCfg model.SystemConfig - if err := dbConn.Where("key = ?", "storage_config").First(&dbCfg).Error; err != nil { - t.Fatalf("load saved storage config failed: %v", err) - } - var savedCfg objectstore.Config - if err := json.Unmarshal([]byte(dbCfg.Value), &savedCfg); err != nil { - t.Fatalf("parse saved storage config failed: %v", err) - } - if savedCfg.Driver != objectstore.DriverLocal { - t.Fatalf("active driver = %q, want %q after save", savedCfg.Driver, objectstore.DriverLocal) - } - if savedCfg.Local.Root != tempDir { - t.Fatalf("staged local root = %q, want %q", savedCfg.Local.Root, tempDir) - } - }) -} diff --git a/backend/OpenFlare/plugins/server/admin/task/errs.go b/backend/OpenFlare/plugins/server/admin/task/errs.go deleted file mode 100644 index e45edf79..00000000 --- a/backend/OpenFlare/plugins/server/admin/task/errs.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package task 提供任务管理接口 -package task - -// 任务管理相关错误消息 -const ( - InvalidTaskType = "无效的任务类型" - InvalidTimeRange = "无效的时间范围" - TaskDispatchFailed = "任务下发失败" - UserIDRequired = "用户ID必填" - TaskNotFound = "任务执行记录不存在" - TaskNotRetryable = "该任务不支持重试" - TaskNotFailed = "只有失败的任务才能重试" - TaskMaxRetryExceeded = "已达到最大重试次数" - TaskRetryFailed = "任务重试失败" - InvalidCronExpression = "无效的 Cron 表达式" - ScheduleNotFound = "定时任务不存在" - ScheduleSaveFailed = "保存定时任务失败" - ScheduleDeleteFailed = "删除定时任务失败" -) diff --git a/backend/OpenFlare/plugins/server/admin/task/routers.go b/backend/OpenFlare/plugins/server/admin/task/routers.go deleted file mode 100644 index 3eb07924..00000000 --- a/backend/OpenFlare/plugins/server/admin/task/routers.go +++ /dev/null @@ -1,451 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "Wavelet/OpenFlare/plugins/server/repository" - - "Wavelet/OpenFlare/plugins/server/admin" - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/infra/task/scheduler" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/logger" - - "github.com/gin-gonic/gin" - "github.com/robfig/cron/v3" - - "Wavelet/pkg/response" -) - -// ListTaskTypes 获取支持的任务类型列表 -// @Summary 获取支持的任务类型 -// @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]task.TaskMeta} "任务类型列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/tasks/types [get] -func ListTaskTypes(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(task.GetDispatchableTasks())) -} - -// DispatchTaskRequest 下发任务请求 -type DispatchTaskRequest struct { - TaskType string `json:"task_type" binding:"required"` - StartTime *time.Time `json:"start_time"` - EndTime *time.Time `json:"end_time"` - UserID *uint64 `json:"user_id"` - Payload string `json:"payload"` -} - -// DispatchTask 下发任务 -// @Summary 下发异步任务 -// @Description 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body DispatchTaskRequest true "任务请求参数" -// @Success 200 {object} response.Any{data=string} "任务已入队" -// @Failure 400 {object} response.Any "任务类型不存在或参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "任务入队失败" -// @Router /api/v1/admin/tasks/dispatch [post] -func DispatchTask(c *gin.Context) { - var req DispatchTaskRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - meta := getAdminTaskMeta(req.TaskType) - if meta == nil { - response.AbortBadRequest(c, InvalidTaskType) - return - } - - var payloadBytes []byte - if strings.TrimSpace(req.Payload) != "" { - payloadBytes = []byte(req.Payload) - } - - validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - taskID, err := task.DispatchTask(c.Request.Context(), req.TaskType, validated, "manual") - if err != nil { - response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskDispatchFailed, err)) - return - } - - c.JSON(http.StatusOK, response.OK(taskID)) -} - -// ListTaskExecutions 查询任务执行记录列表 -// @Summary 查询任务执行记录 -// @Description 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param status query string false "状态筛选 (pending/running/succeeded/failed)" -// @Param task_type query string false "任务类型筛选" -// @Param task_type_prefix query string false "任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先)" -// @Param task_types query string false "逗号分隔的精确任务类型列表(IN 筛选,优先于前缀)" -// @Param page query int false "页码" default(1) -// @Param page_size query int false "每页条数" default(20) -// @Success 200 {object} response.Any{data=object} "任务执行记录列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/tasks/executions [get] -func ListTaskExecutions(c *gin.Context) { - var req model.ListTaskExecutionsRequest - if err := c.ShouldBindQuery(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if req.TaskType != "" { - if meta := task.GetTaskMeta(req.TaskType); meta != nil { - req.TaskType = meta.AsynqTask - } - } - - executions, total, err := repository.ListTaskExecutions(c.Request.Context(), req) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(gin.H{ - "items": executions, - "total": total, - "page": req.Page, - "page_size": req.PageSize, - })) -} - -// GetTaskExecution 查询单条任务执行详情 -// @Summary 查询任务执行详情 -// @Description 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param id path int true "任务执行记录 ID" -// @Success 200 {object} response.Any{data=model.TaskExecution} "任务执行详情" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "记录不存在" -// @Router /api/v1/admin/tasks/executions/{id} [get] -func GetTaskExecution(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, admin.InvalidTaskExecutionID) - return - } - - execution, err := repository.GetTaskExecutionByID(c.Request.Context(), id) - if err != nil { - response.AbortNotFound(c, TaskNotFound) - return - } - - c.JSON(http.StatusOK, response.OK(execution)) -} - -// RetryTask 重试失败的任务 -// @Summary 重试失败任务 -// @Description 重新下发一条失败的任务,创建新的执行记录,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param id path int true "任务执行记录 ID" -// @Success 200 {object} response.Any{data=string} "新任务的 TaskID" -// @Failure 400 {object} response.Any "任务不支持重试或参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "记录不存在" -// @Failure 500 {object} response.Any "重试失败" -// @Router /api/v1/admin/tasks/executions/{id}/retry [post] -func RetryTask(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, admin.InvalidTaskExecutionID) - return - } - - newTaskID, err := task.RetryTask(c.Request.Context(), id) - if err != nil { - errMsg := err.Error() - switch { - case strings.Contains(errMsg, "不存在"): - response.AbortNotFound(c, errMsg) - case strings.Contains(errMsg, "只有失败的任务") || strings.Contains(errMsg, "不支持重试") || strings.Contains(errMsg, "已达到最大重试"): - response.AbortBadRequest(c, errMsg) - default: - response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskRetryFailed, err)) - } - return - } - - c.JSON(http.StatusOK, response.OK(newTaskID)) -} - -// ListSchedules 获取定时任务列表 -// @Summary 获取定时任务列表 -// @Description 返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.Schedule} "定时任务列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/tasks/schedules [get] -func ListSchedules(c *gin.Context) { - schedules, err := repository.ListSchedules(c.Request.Context()) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - visible := make([]model.Schedule, 0, len(schedules)) - for _, schedule := range schedules { - meta := task.GetTaskMeta(schedule.TaskType) - if meta != nil && meta.InternalOnly { - continue - } - visible = append(visible, schedule) - } - c.JSON(http.StatusOK, response.OK(visible)) -} - -// CreateScheduleRequest 创建定时任务请求 -type CreateScheduleRequest struct { - Name string `json:"name" binding:"required"` - TaskType string `json:"task_type" binding:"required"` - Cron string `json:"cron" binding:"required"` - Payload string `json:"payload"` - IsActive *bool `json:"is_active" binding:"required"` -} - -// CreateSchedule 创建定时任务 -// @Summary 创建定时任务 -// @Description 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body CreateScheduleRequest true "创建定时任务请求参数" -// @Success 200 {object} response.Any{data=model.Schedule} "创建成功的定时任务信息" -// @Failure 400 {object} response.Any "Cron 表达式无效、异步任务类型不存在或参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "保存定时任务失败" -// @Router /api/v1/admin/tasks/schedules [post] -func CreateSchedule(c *gin.Context) { - var req CreateScheduleRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - // 校验 Cron 表达式 - if _, err := cron.ParseStandard(req.Cron); err != nil { - response.AbortBadRequest(c, InvalidCronExpression) - return - } - - // 校验关联的异步任务类型 - meta := getAdminTaskMeta(req.TaskType) - if meta == nil { - response.AbortBadRequest(c, InvalidTaskType) - return - } - - // 校验并规范化 Payload - var payloadBytes []byte - if strings.TrimSpace(req.Payload) != "" { - payloadBytes = []byte(req.Payload) - } - validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - schedule := &model.Schedule{ - Name: req.Name, - TaskType: req.TaskType, - Cron: req.Cron, - Payload: string(validated), - IsActive: *req.IsActive, - } - - if err := repository.CreateSchedule(c.Request.Context(), schedule); err != nil { - response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleSaveFailed, err)) - return - } - - // 触发调度服务重载 - if err := scheduler.ReloadScheduler(); err != nil { - logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err) - } - - c.JSON(http.StatusOK, response.OK(schedule)) -} - -// UpdateScheduleRequest 修改定时任务请求 -type UpdateScheduleRequest struct { - Name string `json:"name" binding:"required"` - TaskType string `json:"task_type" binding:"required"` - Cron string `json:"cron" binding:"required"` - Payload string `json:"payload"` - IsActive *bool `json:"is_active" binding:"required"` -} - -// UpdateSchedule 修改定时任务 -// @Summary 修改定时任务 -// @Description 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path int true "定时任务 ID" -// @Param request body UpdateScheduleRequest true "修改定时任务请求参数" -// @Success 200 {object} response.Any{data=model.Schedule} "修改后的定时任务信息" -// @Failure 400 {object} response.Any "Cron 表达式无效、参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "定时任务不存在" -// @Failure 500 {object} response.Any "修改定时任务失败" -// @Router /api/v1/admin/tasks/schedules/{id} [put] -func UpdateSchedule(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "无效的定时任务ID") - return - } - - var req UpdateScheduleRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - // 检查定时任务是否存在 - schedule, err := repository.GetScheduleByID(c.Request.Context(), id) - if err != nil { - response.AbortNotFound(c, ScheduleNotFound) - return - } - if existingMeta := task.GetTaskMeta(schedule.TaskType); existingMeta != nil && existingMeta.InternalOnly { - response.AbortBadRequest(c, InvalidTaskType) - return - } - - // 校验 Cron 表达式 - if _, err := cron.ParseStandard(req.Cron); err != nil { - response.AbortBadRequest(c, InvalidCronExpression) - return - } - - // 校验关联的异步任务类型 - meta := getAdminTaskMeta(req.TaskType) - if meta == nil { - response.AbortBadRequest(c, InvalidTaskType) - return - } - - // 校验并规范化 Payload - var payloadBytes []byte - if strings.TrimSpace(req.Payload) != "" { - payloadBytes = []byte(req.Payload) - } - validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - schedule.Name = req.Name - schedule.TaskType = req.TaskType - schedule.Cron = req.Cron - schedule.Payload = string(validated) - schedule.IsActive = *req.IsActive - - if err := repository.UpdateSchedule(c.Request.Context(), schedule); err != nil { - response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleSaveFailed, err)) - return - } - - // 触发调度服务重载 - if err := scheduler.ReloadScheduler(); err != nil { - logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err) - } - - c.JSON(http.StatusOK, response.OK(schedule)) -} - -func getAdminTaskMeta(taskType string) *task.TaskMeta { - meta := task.GetTaskMeta(taskType) - if meta == nil || meta.InternalOnly { - return nil - } - return meta -} - -// DeleteSchedule 删除定时任务 -// @Summary 删除定时任务 -// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param id path int true "定时任务 ID" -// @Success 200 {object} response.Any{data=string} "删除结果" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "定时任务不存在" -// @Failure 500 {object} response.Any "删除定时任务失败" -// @Router /api/v1/admin/tasks/schedules/{id} [delete] -func DeleteSchedule(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - response.AbortBadRequest(c, "无效的定时任务ID") - return - } - schedule, err := repository.GetScheduleByID(c.Request.Context(), id) - if err != nil { - response.AbortNotFound(c, ScheduleNotFound) - return - } - if meta := task.GetTaskMeta(schedule.TaskType); meta != nil && meta.InternalOnly { - response.AbortBadRequest(c, InvalidTaskType) - return - } - - if err := repository.DeleteSchedule(c.Request.Context(), id); err != nil { - response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleDeleteFailed, err)) - return - } - - // 触发调度服务重载 - if err := scheduler.ReloadScheduler(); err != nil { - logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err) - } - - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/admin/task/routers_test.go b/backend/OpenFlare/plugins/server/admin/task/routers_test.go deleted file mode 100644 index a351b45b..00000000 --- a/backend/OpenFlare/plugins/server/admin/task/routers_test.go +++ /dev/null @@ -1,735 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/repository" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" - "Wavelet/OpenFlare/plugins/server/testhelper" - uploadtask "Wavelet/OpenFlare/plugins/server/upload/task" - "Wavelet/OpenFlare/plugins/server/user" - - "github.com/gin-gonic/gin" - "github.com/hibiken/asynq" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "Wavelet/pkg/response" -) - -const ( - testInternalOnlyTaskType = "test_internal_only_admin" - testInternalOnlyAsynqTask = "test:internal_only_admin" -) - -func registerInternalOnlyTaskMeta() { - task.RegisterTaskMeta(task.TaskMeta{ - Type: testInternalOnlyTaskType, - AsynqTask: testInternalOnlyAsynqTask, - Name: "内部测试任务", - InternalOnly: true, - }) -} - -func setupTaskTestEnvironment(t *testing.T) func() { - t.Helper() - _, mr, cleanup := testhelper.SetupTestEnvironment(t) - bootstrap.RegisterTasks() - task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ - Addr: mr.Addr(), - }) - return func() { - if task.AsynqClient != nil { - _ = task.AsynqClient.Close() - task.AsynqClient = nil - } - cleanup() - } -} - -func setupTestRouter(authUser *model.User) *gin.Engine { - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin") - - // Mock authentication middleware - adminGroup.Use(func(c *gin.Context) { - if authUser != nil { - oauth.SetToContext(c, oauth.UserObjKey, authUser) - } - c.Next() - }) - - adminGroup.GET("/tasks/types", ListTaskTypes) - adminGroup.POST("/tasks/dispatch", DispatchTask) - adminGroup.GET("/tasks/executions", ListTaskExecutions) - adminGroup.GET("/tasks/executions/:id", GetTaskExecution) - adminGroup.POST("/tasks/executions/:id/retry", RetryTask) - adminGroup.GET("/tasks/schedules", ListSchedules) - adminGroup.POST("/tasks/schedules", CreateSchedule) - adminGroup.PUT("/tasks/schedules/:id", UpdateSchedule) - adminGroup.DELETE("/tasks/schedules/:id", DeleteSchedule) - return r -} - -func TestListTaskTypes(t *testing.T) { - cleanup := setupTaskTestEnvironment(t) - defer cleanup() - registerInternalOnlyTaskMeta() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/types", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d", w.Code) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var taskMetas []task.TaskMeta - _ = json.Unmarshal(dataBytes, &taskMetas) - - if len(taskMetas) == 0 { - t.Error("expected at least one dispatchable task type") - } - - foundCleanup := false - foundWarmImageCache := false - for _, m := range taskMetas { - if m.Type == testInternalOnlyTaskType { - t.Errorf("internal-only task type %s must not be listed", testInternalOnlyTaskType) - } - if m.Type == uploadtask.TaskTypeSystemCleanup { - foundCleanup = true - } - if m.Type == uploadtask.TaskTypeWarmImageCache { - foundWarmImageCache = true - } - } - if !foundCleanup { - t.Errorf("expected task type %s to be listed", uploadtask.TaskTypeSystemCleanup) - } - if !foundWarmImageCache { - t.Errorf("expected task type %s to be listed", uploadtask.TaskTypeWarmImageCache) - } -} - -func TestInternalOnlyTaskAdminBoundaries(t *testing.T) { - cleanup := setupTaskTestEnvironment(t) - defer cleanup() - registerInternalOnlyTaskMeta() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - ctx := context.Background() - - t.Run("list hides internal-only schedule", func(t *testing.T) { - internalSchedule := &model.Schedule{ - Name: "隐藏的系统内部排程", - TaskType: testInternalOnlyTaskType, - Cron: "*/5 * * * *", - Payload: "{}", - IsActive: true, - } - publicSchedule := &model.Schedule{ - Name: "可见的公开排程", - TaskType: uploadtask.TaskTypeSystemCleanup, - Cron: "0 * * * *", - Payload: "{}", - IsActive: true, - } - require.NoError(t, repository.CreateSchedule(ctx, internalSchedule)) - require.NoError(t, repository.CreateSchedule(ctx, publicSchedule)) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/tasks/schedules", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp response.Any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - data, err := json.Marshal(resp.Data) - require.NoError(t, err) - var schedules []model.Schedule - require.NoError(t, json.Unmarshal(data, &schedules)) - assert.NotContains(t, scheduleIDs(schedules), internalSchedule.ID) - assert.Contains(t, scheduleIDs(schedules), publicSchedule.ID) - }) - - t.Run("dispatch rejects internal-only task", func(t *testing.T) { - body, err := json.Marshal(DispatchTaskRequest{TaskType: testInternalOnlyTaskType}) - require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/tasks/dispatch", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp response.Any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, InvalidTaskType, resp.ErrorMsg) - }) - - t.Run("create schedule rejects internal-only task", func(t *testing.T) { - isActive := true - body, err := json.Marshal(CreateScheduleRequest{ - Name: "内部任务排程", - TaskType: testInternalOnlyTaskType, - Cron: "0 * * * *", - IsActive: &isActive, - }) - require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/tasks/schedules", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp response.Any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, InvalidTaskType, resp.ErrorMsg) - }) - - t.Run("update cannot convert existing internal schedule to public task", func(t *testing.T) { - schedule := &model.Schedule{ - Name: "系统内部排程", - TaskType: testInternalOnlyTaskType, - Cron: "0 * * * *", - IsActive: true, - } - require.NoError(t, repository.CreateSchedule(ctx, schedule)) - isActive := false - body, err := json.Marshal(UpdateScheduleRequest{ - Name: "尝试修改内部排程", - TaskType: uploadtask.TaskTypeSystemCleanup, - Cron: "5 * * * *", - IsActive: &isActive, - }) - require.NoError(t, err) - req := httptest.NewRequest( - http.MethodPut, - fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID), - bytes.NewReader(body), - ) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - unchanged, err := repository.GetScheduleByID(ctx, schedule.ID) - require.NoError(t, err) - assert.Equal(t, "系统内部排程", unchanged.Name) - assert.Equal(t, testInternalOnlyTaskType, unchanged.TaskType) - assert.True(t, unchanged.IsActive) - }) - - t.Run("update public schedule rejects internal-only target task", func(t *testing.T) { - schedule := &model.Schedule{ - Name: "公开排程", - TaskType: uploadtask.TaskTypeSystemCleanup, - Cron: "0 * * * *", - IsActive: true, - } - require.NoError(t, repository.CreateSchedule(ctx, schedule)) - isActive := true - body, err := json.Marshal(UpdateScheduleRequest{ - Name: "尝试切入内部任务", - TaskType: testInternalOnlyTaskType, - Cron: "10 * * * *", - IsActive: &isActive, - }) - require.NoError(t, err) - req := httptest.NewRequest( - http.MethodPut, - fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID), - bytes.NewReader(body), - ) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - unchanged, err := repository.GetScheduleByID(ctx, schedule.ID) - require.NoError(t, err) - assert.Equal(t, "公开排程", unchanged.Name) - assert.Equal(t, uploadtask.TaskTypeSystemCleanup, unchanged.TaskType) - }) - - t.Run("delete rejects internal-only schedule", func(t *testing.T) { - schedule := &model.Schedule{ - Name: "不可删除的系统内部排程", - TaskType: testInternalOnlyTaskType, - Cron: "*/5 * * * *", - IsActive: true, - } - require.NoError(t, repository.CreateSchedule(ctx, schedule)) - req := httptest.NewRequest( - http.MethodDelete, - fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID), - nil, - ) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp response.Any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, InvalidTaskType, resp.ErrorMsg) - preserved, err := repository.GetScheduleByID(ctx, schedule.ID) - require.NoError(t, err) - assert.Equal(t, testInternalOnlyTaskType, preserved.TaskType) - }) - - t.Run("delete missing schedule returns not found", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/tasks/schedules/999999", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - var resp response.Any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, ScheduleNotFound, resp.ErrorMsg) - }) - - t.Run("delete public schedule remains allowed", func(t *testing.T) { - schedule := &model.Schedule{ - Name: "可删除的公开排程", - TaskType: uploadtask.TaskTypeSystemCleanup, - Cron: "0 * * * *", - IsActive: false, - } - require.NoError(t, repository.CreateSchedule(ctx, schedule)) - req := httptest.NewRequest( - http.MethodDelete, - fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID), - nil, - ) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - _, err := repository.GetScheduleByID(ctx, schedule.ID) - assert.Error(t, err) - }) -} - -func scheduleIDs(schedules []model.Schedule) []uint64 { - ids := make([]uint64, 0, len(schedules)) - for _, schedule := range schedules { - ids = append(ids, schedule.ID) - } - return ids -} - -func TestDispatchTask(t *testing.T) { - cleanup := setupTaskTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("dispatch valid task successfully", func(t *testing.T) { - payload := DispatchTaskRequest{ - TaskType: uploadtask.TaskTypeSystemCleanup, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code, "Body: %s", w.Body.String()) - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - assert.Empty(t, resp.ErrorMsg) - assert.NotNil(t, resp.Data) - - // 返回的 data 应该是 taskID - taskID, ok := resp.Data.(string) - assert.True(t, ok) - assert.NotEmpty(t, taskID) - }) - - t.Run("dispatch send_email task successfully with valid payload", func(t *testing.T) { - payload := DispatchTaskRequest{ - TaskType: user.TaskTypeSendEmail, - Payload: `{"to":"receiver@example.com","subject":"Test Subject","body":"Test Body"}`, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code, "Body: %s", w.Body.String()) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Empty(t, resp.ErrorMsg) - assert.NotNil(t, resp.Data) - }) - - t.Run("dispatch send_email task failure with invalid payload json", func(t *testing.T) { - payload := DispatchTaskRequest{ - TaskType: user.TaskTypeSendEmail, - Payload: `{"to":`, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(t, resp.ErrorMsg, "无效的 JSON 格式") - }) - - t.Run("dispatch send_email task failure with missing fields", func(t *testing.T) { - payload := DispatchTaskRequest{ - TaskType: user.TaskTypeSendEmail, - Payload: `{"to":"","subject":"Test","body":"Test"}`, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(t, resp.ErrorMsg, "不能为空") - }) - - t.Run("dispatch invalid task type failure", func(t *testing.T) { - payload := DispatchTaskRequest{ - TaskType: "invalid_task_type", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, InvalidTaskType, resp.ErrorMsg) - }) - - t.Run("dispatch with empty body failure", func(t *testing.T) { - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer([]byte("{}"))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} - -func TestListTaskExecutions(t *testing.T) { - cleanup := setupTaskTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - ctx := context.Background() - - // 准备测试数据 - now := time.Now() - records := []*model.TaskExecution{ - {TaskID: "exec_001", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "manual", Duration: 1500, Result: "清理完成", StartedAt: &now, FinishedAt: &now}, - {TaskID: "exec_002", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusFailed, TriggeredBy: "system", ErrorMessage: "连接超时", StartedAt: &now, FinishedAt: &now}, - {TaskID: "exec_003", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusPending, TriggeredBy: "manual", Retryable: true, MaxRetry: 3}, - } - for _, r := range records { - err := repository.CreateTaskExecution(ctx, r) - require.NoError(t, err) - } - - t.Run("list all executions", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var data map[string]interface{} - json.Unmarshal(dataBytes, &data) - - assert.InDelta(t, float64(3), data["total"], 1e-9) - }) - - t.Run("filter by status", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?status=failed", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var data map[string]interface{} - json.Unmarshal(dataBytes, &data) - - assert.InDelta(t, float64(1), data["total"], 1e-9) - }) - - t.Run("filter by task_type (asynq task name)", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=system:cleanup", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var data map[string]interface{} - json.Unmarshal(dataBytes, &data) - - assert.InDelta(t, float64(3), data["total"], 1e-9) - }) - - t.Run("filter by task_type (management task type)", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=system_cleanup", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var data map[string]interface{} - json.Unmarshal(dataBytes, &data) - - assert.InDelta(t, float64(3), data["total"], 1e-9) - }) - - t.Run("pagination", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?page=1&page_size=2", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var data map[string]interface{} - json.Unmarshal(dataBytes, &data) - - assert.InDelta(t, float64(3), data["total"], 1e-9) - }) -} - -func TestGetTaskExecution(t *testing.T) { - cleanup := setupTaskTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - ctx := context.Background() - - // 创建测试记录 - execution := &model.TaskExecution{ - TaskID: "detail_001", - TaskType: "system:cleanup", - TaskName: "系统垃圾清理", - Status: model.TaskExecutionStatusSucceeded, - Log: "[10:00:01] 开始扫描\n[10:00:02] 找到 50 个文件\n[10:00:03] 清理完成", - Result: "共清理 50 个文件", - Duration: 2000, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - t.Run("get existing execution", func(t *testing.T) { - url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d", execution.ID) - req, _ := http.NewRequest("GET", url, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var detail model.TaskExecution - json.Unmarshal(dataBytes, &detail) - - assert.Equal(t, "detail_001", detail.TaskID) - assert.Equal(t, model.TaskExecutionStatusSucceeded, detail.Status) - assert.Contains(t, detail.Log, "开始扫描") - assert.Contains(t, detail.Log, "清理完成") - assert.Equal(t, int64(2000), detail.Duration) - }) - - t.Run("get non-existent execution", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions/99999999", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("invalid ID format", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions/invalid", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} - -func TestRetryTask(t *testing.T) { - cleanup := setupTaskTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - ctx := context.Background() - - t.Run("retry failed task successfully", func(t *testing.T) { - now := time.Now() - execution := &model.TaskExecution{ - TaskID: "retry_api_001", - TaskType: "system:cleanup", - TaskName: "系统垃圾清理", - Status: model.TaskExecutionStatusFailed, - ErrorMessage: "S3 连接超时", - Retryable: true, - MaxRetry: 3, - RetryCount: 0, - TriggeredBy: "manual", - StartedAt: &now, - FinishedAt: &now, - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID) - req, _ := http.NewRequest("POST", url, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp response.Any - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Empty(t, resp.ErrorMsg) - assert.NotNil(t, resp.Data) - - // 验证新记录 - newTaskID, ok := resp.Data.(string) - assert.True(t, ok) - assert.NotEmpty(t, newTaskID) - - newExecution, err := repository.GetTaskExecutionByTaskID(ctx, newTaskID) - require.NoError(t, err) - assert.Equal(t, 1, newExecution.RetryCount) - assert.Equal(t, "retry", newExecution.TriggeredBy) - }) - - t.Run("retry succeeded task fails", func(t *testing.T) { - execution := &model.TaskExecution{ - TaskID: "retry_succeeded_001", - TaskType: "system:cleanup", - TaskName: "系统垃圾清理", - Status: model.TaskExecutionStatusSucceeded, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID) - req, _ := http.NewRequest("POST", url, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("retry non-retryable task fails", func(t *testing.T) { - execution := &model.TaskExecution{ - TaskID: "retry_not_allowed_001", - TaskType: "system:cleanup", - TaskName: "系统垃圾清理", - Status: model.TaskExecutionStatusFailed, - Retryable: false, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID) - req, _ := http.NewRequest("POST", url, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("retry non-existent task", func(t *testing.T) { - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/executions/99999999/retry", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("retry with invalid ID", func(t *testing.T) { - req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/executions/invalid/retry", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} diff --git a/backend/OpenFlare/plugins/server/admin/template/errs.go b/backend/OpenFlare/plugins/server/admin/template/errs.go deleted file mode 100644 index d8dda622..00000000 --- a/backend/OpenFlare/plugins/server/admin/template/errs.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package template 提供模板管理功能 -package template - -// 模板管理相关错误消息 -const ( - TemplateNotFound = "模板不存在" - TemplateKeyRequired = "模板标识符不能为空" - TemplateNameRequired = "模板名称不能为空" - TemplateContentRequired = "模板内容不能为空" - TemplateKeyExists = "模板标识符已存在" - SystemTemplateCannotDelete = "系统预置模板不可删除" - SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符" -) diff --git a/backend/OpenFlare/plugins/server/admin/template/logics.go b/backend/OpenFlare/plugins/server/admin/template/logics.go deleted file mode 100644 index 3f77b8e8..00000000 --- a/backend/OpenFlare/plugins/server/admin/template/logics.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package template - -import ( - "context" - "errors" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" -) - -func createTemplate(ctx context.Context, req CreateTemplateRequest) (model.Template, error) { - exists, err := repository.TemplateExistsByKey(ctx, req.Key) - if err != nil { - return model.Template{}, err - } - if exists { - return model.Template{}, errors.New(TemplateKeyExists) - } - - tmpl := model.Template{ - Key: req.Key, - Name: req.Name, - Type: req.Type, - Subject: req.Subject, - Content: req.Content, - Description: req.Description, - IsSystem: false, - } - if err := tmpl.Validate(); err != nil { - return model.Template{}, err - } - if err := repository.CreateTemplate(ctx, &tmpl); err != nil { - return model.Template{}, err - } - return tmpl, nil -} - -func listTemplates(ctx context.Context) ([]model.Template, error) { - return repository.ListTemplates(ctx) -} - -func getTemplate(ctx context.Context, key string) (model.Template, error) { - return repository.GetTemplateByKey(ctx, key) -} - -func updateTemplate(ctx context.Context, key string, req UpdateTemplateRequest) (model.Template, error) { - tmpl, err := repository.GetTemplateByKey(ctx, key) - if err != nil { - return model.Template{}, err - } - - tmpl.Name = req.Name - tmpl.Type = req.Type - tmpl.Subject = req.Subject - tmpl.Content = req.Content - tmpl.Description = req.Description - if err := tmpl.Validate(); err != nil { - return model.Template{}, err - } - if err := repository.SaveTemplate(ctx, &tmpl); err != nil { - return model.Template{}, err - } - return tmpl, nil -} - -func deleteTemplate(ctx context.Context, key string) error { - tmpl, err := repository.GetTemplateByKey(ctx, key) - if err != nil { - return err - } - if tmpl.IsSystem { - return errors.New(SystemTemplateCannotDelete) - } - return repository.DeleteTemplate(ctx, &tmpl) -} diff --git a/backend/OpenFlare/plugins/server/admin/template/routers.go b/backend/OpenFlare/plugins/server/admin/template/routers.go deleted file mode 100644 index 203ad8ff..00000000 --- a/backend/OpenFlare/plugins/server/admin/template/routers.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package template - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "Wavelet/pkg/response" -) - -// CreateTemplateRequest 创建模板请求 -type CreateTemplateRequest struct { - Key string `json:"key" binding:"required,max=80"` - Name string `json:"name" binding:"required,max=100"` - Type string `json:"type" binding:"required,max=20"` - Subject string `json:"subject" binding:"max=255"` - Content string `json:"content" binding:"required"` - Description string `json:"description" binding:"max=255"` -} - -// UpdateTemplateRequest 更新模板请求 -type UpdateTemplateRequest struct { - Name string `json:"name" binding:"required,max=100"` - Type string `json:"type" binding:"required,max=20"` - Subject string `json:"subject" binding:"max=255"` - Content string `json:"content" binding:"required"` - Description string `json:"description" binding:"max=255"` -} - -func abortTemplateLogicError(c *gin.Context, err error) bool { - if err == nil { - return false - } - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, TemplateNotFound) - return true - } - msg := err.Error() - switch msg { - case TemplateKeyExists, SystemTemplateCannotDelete: - response.AbortBadRequest(c, msg) - return true - } - response.AbortInternal(c, msg) - return true -} - -// CreateTemplate 创建模板 -// @Summary 创建模板 -// @Description 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body template.CreateTemplateRequest true "创建请求参数" -// @Success 200 {object} response.Any{data=string} "创建成功" -// @Failure 400 {object} response.Any "参数错误或模板标识符已存在" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/templates [post] -func CreateTemplate(c *gin.Context) { - var req CreateTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - tmpl, err := createTemplate(c.Request.Context(), req) - if abortTemplateLogicError(c, err) { - return - } - - c.JSON(http.StatusOK, response.OK(tmpl)) -} - -// ListTemplates 获取模板列表 -// @Summary 获取模板列表 -// @Description 返回所有通知模板列表,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.Template} "模板列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/templates [get] -func ListTemplates(c *gin.Context) { - templates, err := listTemplates(c.Request.Context()) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(templates)) -} - -// GetTemplate 获取单个模板 -// @Summary 获取单个模板 -// @Description 根据模板标识符获取对应的模板详情,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param key path string true "模板标识符" -// @Success 200 {object} response.Any{data=model.Template} "模板详情" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "模板不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/templates/{key} [get] -func GetTemplate(c *gin.Context) { - tmpl, err := getTemplate(c.Request.Context(), c.Param("key")) - if abortTemplateLogicError(c, err) { - return - } - - c.JSON(http.StatusOK, response.OK(tmpl)) -} - -// UpdateTemplate 更新模板 -// @Summary 更新模板 -// @Description 根据模板标识符更新对应的模板内容,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param key path string true "模板标识符" -// @Param request body template.UpdateTemplateRequest true "更新请求参数" -// @Success 200 {object} response.Any{data=model.Template} "更新成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "模板不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/templates/{key} [put] -func UpdateTemplate(c *gin.Context) { - var req UpdateTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - tmpl, err := updateTemplate(c.Request.Context(), c.Param("key"), req) - if abortTemplateLogicError(c, err) { - return - } - - c.JSON(http.StatusOK, response.OK(tmpl)) -} - -// DeleteTemplate 删除模板 -// @Summary 删除模板 -// @Description 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param key path string true "模板标识符" -// @Success 200 {object} response.Any{data=string} "删除成功" -// @Failure 400 {object} response.Any "不可删除系统模板" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "模板不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/templates/{key} [delete] -func DeleteTemplate(c *gin.Context) { - if err := deleteTemplate(c.Request.Context(), c.Param("key")); abortTemplateLogicError(c, err) { - return - } - - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/admin/template/routers_test.go b/backend/OpenFlare/plugins/server/admin/template/routers_test.go deleted file mode 100644 index fb8b59ff..00000000 --- a/backend/OpenFlare/plugins/server/admin/template/routers_test.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package template - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -func setupTestRouter(authUser *model.User) *gin.Engine { - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin") - - // Mock authentication middleware - adminGroup.Use(func(c *gin.Context) { - if authUser != nil { - oauth.SetToContext(c, oauth.UserObjKey, authUser) - } - c.Next() - }) - - adminGroup.GET("/templates", ListTemplates) - adminGroup.POST("/templates", CreateTemplate) - - templateRouter := adminGroup.Group("/templates/:key") - { - templateRouter.GET("", GetTemplate) - templateRouter.PUT("", UpdateTemplate) - templateRouter.DELETE("", DeleteTemplate) - } - - return r -} - -func TestCreateTemplate(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("create successfully", func(t *testing.T) { - payload := CreateTemplateRequest{ - Key: "test_template", - Name: "Test Template", - Type: "email", - Subject: "Test Subject", - Content: "Hello {{.Name}}", - Description: "Test Desc", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/templates", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var tmpl model.Template - err := dbConn.Where("key = ?", "test_template").First(&tmpl).Error - if err != nil { - t.Fatalf("failed to find template in DB: %v", err) - } - if tmpl.Name != "Test Template" { - t.Errorf("expected Name 'Test Template', got '%s'", tmpl.Name) - } - }) - - t.Run("create duplicate key error", func(t *testing.T) { - payload := CreateTemplateRequest{ - Key: "test_template", - Name: "Another Name", - Type: "email", - Subject: "Another Subject", - Content: "Hello", - Description: "desc", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/templates", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request on duplicate key, got %d", w.Code) - } - }) -} - -func TestListTemplates(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - // Seed system templates manually for testing - t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true} - t2 := model.Template{Key: "register_email", Name: "Register Code", Type: "email", Content: "code {{.Code}}", IsSystem: true} - dbConn.Create(&t1) - dbConn.Create(&t2) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("list templates", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/templates", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var templates []model.Template - _ = json.Unmarshal(dataBytes, &templates) - - if len(templates) != 2 { - t.Errorf("expected 2 templates, got %d", len(templates)) - } - }) -} - -func TestGetTemplate(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true} - dbConn.Create(&t1) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("get existing", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/templates/login_email", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d", w.Code) - } - }) - - t.Run("get non-existent", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/templates/non_existent", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 Not Found, got %d", w.Code) - } - }) -} - -func TestUpdateTemplate(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true} - dbConn.Create(&t1) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("update successfully", func(t *testing.T) { - payload := UpdateTemplateRequest{ - Name: "Updated Login Code", - Type: "email", - Subject: "New Subject", - Content: "new code {{.Code}}", - Description: "new desc", - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/templates/login_email", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var tmpl model.Template - dbConn.Where("key = ?", "login_email").First(&tmpl) - if tmpl.Name != "Updated Login Code" || tmpl.Subject != "New Subject" { - t.Errorf("database values not updated: %+v", tmpl) - } - }) -} - -func TestDeleteTemplate(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true} - t2 := model.Template{Key: "custom_tmpl", Name: "Custom", Type: "email", Content: "hi", IsSystem: false} - dbConn.Create(&t1) - dbConn.Create(&t2) - - adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("delete system template should fail", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/templates/login_email", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request when deleting system template, got %d", w.Code) - } - }) - - t.Run("delete custom template should succeed", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/templates/custom_tmpl", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - - var count int64 - dbConn.Model(&model.Template{}).Where("key = ?", "custom_tmpl").Count(&count) - if count != 0 { - t.Error("custom template was not deleted from DB") - } - }) -} diff --git a/backend/OpenFlare/plugins/server/admin/user/errs.go b/backend/OpenFlare/plugins/server/admin/user/errs.go deleted file mode 100644 index 936eb5a2..00000000 --- a/backend/OpenFlare/plugins/server/admin/user/errs.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package user 提供用户管理功能 -package user - -const ( - userNotFound = "用户不存在" - cannotDisable = "不能禁用管理员用户" - cannotDelete = "不能删除管理员用户" - cannotDeleteSelf = "不能删除当前登录用户" - updateUserFailed = "更新用户状态失败" - deleteUserFailed = "删除用户失败" - usernameExists = "用户名已存在" - usernameRequired = "用户名不能为空" - passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - createUserFailed = "创建用户失败" - emailRequired = "邮箱不能为空" - emailExists = "邮箱已被注册" - cannotRevokeSelfAdmin = "不能撤销当前登录用户的管理员权限" - updateUserInfoFailed = "更新用户信息失败" -) diff --git a/backend/OpenFlare/plugins/server/admin/user/logics.go b/backend/OpenFlare/plugins/server/admin/user/logics.go deleted file mode 100644 index 39929fb3..00000000 --- a/backend/OpenFlare/plugins/server/admin/user/logics.go +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package user - -import ( - "context" - "errors" - "strings" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/repository" -) - -func listUsers(ctx context.Context, req listUsersRequest) (int64, []model.User, error) { - return repository.ListAdminUsers(ctx, repository.AdminUserListFilter{ - UserID: req.UserID, - Username: strings.TrimSpace(req.Username), - Email: strings.TrimSpace(req.Email), - Page: req.Page, - PageSize: req.PageSize, - }) -} - -func getUserDetail(ctx context.Context, id uint64) (model.User, error) { - return repository.GetAdminUserDetail(ctx, id) -} - -func updateUserStatus(ctx context.Context, id uint64, active bool) error { - flags, err := repository.GetUserAdminFlags(ctx, id) - if err != nil { - return err - } - if !active && flags.IsAdmin { - return errors.New(cannotDisable) - } - - var tokens []model.AccessToken - if !active { - tokens, _ = repository.ListAccessTokensByUserID(ctx, id) - } - - err = repository.UpdateUserActive(ctx, id, active) - if err == nil { - oauth.InvalidateCachedUser(ctx, id) - if !active { - for _, token := range tokens { - oauth.InvalidateCachedToken(ctx, token.TokenHash) - } - } - } - return err -} - -func deleteUser(ctx context.Context, currentUserID, targetID uint64) error { - if currentUserID == targetID { - return errors.New(cannotDeleteSelf) - } - flags, err := repository.GetUserAdminFlags(ctx, targetID) - if err != nil { - return err - } - if flags.IsAdmin { - return errors.New(cannotDelete) - } - - tokens, _ := repository.ListAccessTokensByUserID(ctx, targetID) - - err = repository.DeleteUserWithRelations(ctx, targetID) - if err == nil { - oauth.InvalidateCachedUser(ctx, targetID) - for _, token := range tokens { - oauth.InvalidateCachedToken(ctx, token.TokenHash) - } - } - return err -} - -func createUser(ctx context.Context, req createUserRequest) (model.User, error) { - req.Username = strings.TrimSpace(req.Username) - req.Nickname = strings.TrimSpace(req.Nickname) - req.Password = strings.TrimSpace(req.Password) - req.Email = strings.TrimSpace(req.Email) - - if req.Username == "" { - return model.User{}, errors.New(usernameRequired) - } - if req.Email == "" { - return model.User{}, errors.New(emailRequired) - } - if len(req.Password) < minPasswordLength { - return model.User{}, errors.New(passwordTooShort) - } - - count, err := repository.CountUsersByUsername(ctx, req.Username) - if err != nil { - return model.User{}, err - } - if count > 0 { - return model.User{}, errors.New(usernameExists) - } - - emailCount, err := repository.CountUsersByEmail(ctx, req.Email) - if err != nil { - return model.User{}, err - } - if emailCount > 0 { - return model.User{}, errors.New(emailExists) - } - - newUser := model.User{ - ID: idgen.NextUint64ID(), - Username: req.Username, - Nickname: req.Nickname, - Email: req.Email, - IsActive: req.IsActive, - IsAdmin: req.IsAdmin, - LastLoginAt: time.Time{}, - } - if newUser.Nickname == "" { - newUser.Nickname = req.Username - } - if err := newUser.SetEncryptedPassword(req.Password); err != nil { - return model.User{}, err - } - if err := repository.CreateUser(ctx, &newUser); err != nil { - return model.User{}, err - } - return newUser, nil -} - -type updateUserParam struct { - ID uint64 - Nickname string - Email string - IsAdmin bool - Password string -} - -func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam) error { - param.Nickname = strings.TrimSpace(param.Nickname) - param.Email = strings.TrimSpace(param.Email) - param.Password = strings.TrimSpace(param.Password) - - if param.Email == "" { - return errors.New(emailRequired) - } - - targetUser, err := repository.GetAdminUserDetail(ctx, param.ID) - if err != nil { - return err - } - - // 不能撤销当前登录用户的管理员权限 - if currentUserID == param.ID && !param.IsAdmin && targetUser.IsAdmin { - return errors.New(cannotRevokeSelfAdmin) - } - - // 如果修改了邮箱,检查邮箱是否被其他用户占用 - if targetUser.Email != param.Email { - count, err := repository.CountUsersByEmail(ctx, param.Email) - if err != nil { - return err - } - if count > 0 { - return errors.New(emailExists) - } - } - - // 密码强度校验(如果输入了新密码) - if param.Password != "" && len(param.Password) < minPasswordLength { - return errors.New(passwordTooShort) - } - - // 是否需要撤销 Token (重置密码或取消管理员) - needRevokeTokens := (param.Password != "") || (targetUser.IsAdmin && !param.IsAdmin) - var tokens []model.AccessToken - if needRevokeTokens { - tokens, _ = repository.ListAccessTokensByUserID(ctx, param.ID) - } - - // 更新字段 - targetUser.Nickname = param.Nickname - if targetUser.Nickname == "" { - targetUser.Nickname = targetUser.Username - } - targetUser.Email = param.Email - targetUser.IsAdmin = param.IsAdmin - - if param.Password != "" { - if err := targetUser.SetEncryptedPassword(param.Password); err != nil { - return err - } - } - - // 执行更新 - err = repository.UpdateUser(ctx, &targetUser) - if err == nil { - oauth.InvalidateCachedUser(ctx, param.ID) - if needRevokeTokens { - for _, token := range tokens { - oauth.InvalidateCachedToken(ctx, token.TokenHash) - } - } - } - return err -} diff --git a/backend/OpenFlare/plugins/server/admin/user/routers.go b/backend/OpenFlare/plugins/server/admin/user/routers.go deleted file mode 100644 index 4790b408..00000000 --- a/backend/OpenFlare/plugins/server/admin/user/routers.go +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package user - -import ( - "errors" - "net/http" - "slices" - "strconv" - "time" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/pkg/logger" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "Wavelet/pkg/response" -) - -// minPasswordLength 密码最小长度 -const minPasswordLength = 8 - -// listUsersRequest 用户列表查询请求 -type listUsersRequest struct { - Page int `form:"page" binding:"min=1"` - PageSize int `form:"page_size" binding:"min=1,max=100"` - UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"` - Username string `form:"username"` - Email string `form:"email"` -} - -type user struct { - ID uint64 `json:"id,string"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Email string `json:"email"` - AvatarURL string `json:"avatar_url"` - IsActive bool `json:"is_active"` - IsAdmin bool `json:"is_admin"` - Bio string `json:"bio"` - Phone string `json:"phone"` - Gender string `json:"gender"` - Website string `json:"website"` - Location string `json:"location"` - LastLoginAt time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// listUsersResponse 用户列表响应 -type listUsersResponse struct { - Users []user `json:"users"` - Total int64 `json:"total"` -} - -func parseUserID(c *gin.Context) (uint64, bool) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - response.AbortBadRequest(c, userNotFound) - return 0, false - } - return id, true -} - -func toUser(u model.User) user { - return user{ - ID: u.ID, - Username: u.Username, - Nickname: u.Nickname, - Email: u.Email, - AvatarURL: u.AvatarURL, - IsActive: u.IsActive, - IsAdmin: u.IsAdmin, - Bio: u.Bio, - Phone: u.Phone, - Gender: u.Gender, - Website: u.Website, - Location: u.Location, - LastLoginAt: u.LastLoginAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - } -} - -func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbiddenMsgs, badRequestMsgs []string) bool { - if err == nil { - return false - } - if errors.Is(err, gorm.ErrRecordNotFound) { - response.AbortNotFound(c, notFoundMsg) - return true - } - msg := err.Error() - if slices.Contains(badRequestMsgs, msg) { - response.AbortBadRequest(c, msg) - return true - } - if slices.Contains(forbiddenMsgs, msg) { - response.AbortForbidden(c, msg) - return true - } - logger.ErrorF(c.Request.Context(), "Admin user error: %v", err) - response.AbortInternal(c, "内部服务器错误") - return true -} - -// ListUsers 获取用户列表 -// @Summary 获取用户列表 -// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param request query listUsersRequest true "查询参数" -// @Success 200 {object} response.Any{data=user.listUsersResponse} "用户列表" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/users [get] -func ListUsers(c *gin.Context) { - var req listUsersRequest - if err := c.ShouldBindQuery(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - total, modelUsers, err := listUsers(c.Request.Context(), req) - if err != nil { - logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err) - response.AbortInternal(c, "获取用户列表失败") - return - } - - users := make([]user, 0, len(modelUsers)) - for _, modelUser := range modelUsers { - users = append(users, toUser(modelUser)) - } - - c.JSON(http.StatusOK, response.OK(listUsersResponse{ - Users: users, - Total: total, - })) -} - -// GetUser 获取用户详情 -// @Summary 获取用户详情 -// @Description 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param id path int true "用户 ID" -// @Success 200 {object} response.Any{data=user.user} "用户详情" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 404 {object} response.Any "用户不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/users/{id} [get] -func GetUser(c *gin.Context) { - id, ok := parseUserID(c) - if !ok { - return - } - - targetUser, err := getUserDetail(c.Request.Context(), id) - if abortUserLogicError(c, err, userNotFound, nil, nil) { - return - } - - c.JSON(http.StatusOK, response.OK(toUser(targetUser))) -} - -// updateUserStatusRequest 更新用户状态请求 -type updateUserStatusRequest struct { - IsActive bool `json:"is_active"` -} - -// UpdateUserStatus 更新用户状态(启用/禁用) -// @Summary 更新用户状态 -// @Description 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path int true "用户 ID" -// @Param request body updateUserStatusRequest true "状态参数" -// @Success 200 {object} response.Any{data=string} "更新成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限或尝试禁用管理员" -// @Failure 404 {object} response.Any "用户不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/users/{id}/status [put] -func UpdateUserStatus(c *gin.Context) { - var req updateUserStatusRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - id, ok := parseUserID(c) - if !ok { - return - } - - if err := updateUserStatus(c.Request.Context(), id, req.IsActive); err != nil { - if abortUserLogicError(c, err, userNotFound, []string{cannotDisable}, nil) { - return - } - response.AbortInternal(c, updateUserFailed) - return - } - - c.JSON(http.StatusOK, response.OKNil()) -} - -// DeleteUser 删除用户 -// @Summary 删除用户 -// @Description 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Param id path int true "用户 ID" -// @Success 200 {object} response.Any{data=string} "删除成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限、尝试删除管理员或当前用户" -// @Failure 404 {object} response.Any "用户不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/users/{id} [delete] -func DeleteUser(c *gin.Context) { - id, ok := parseUserID(c) - if !ok { - return - } - - currUser, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey) - if err := deleteUser(c.Request.Context(), currUser.ID, id); err != nil { - if abortUserLogicError(c, err, userNotFound, []string{cannotDelete, cannotDeleteSelf}, nil) { - return - } - response.AbortInternal(c, deleteUserFailed) - return - } - - c.JSON(http.StatusOK, response.OKNil()) -} - -// createUserRequest 创建用户请求 -type createUserRequest struct { - Username string `json:"username" binding:"required,min=3,max=64"` - Password string `json:"password" binding:"required,min=8,max=64"` - Nickname string `json:"nickname" binding:"omitempty,max=64"` - Email string `json:"email" binding:"required,email,max=255"` - IsActive bool `json:"is_active"` - IsAdmin bool `json:"is_admin"` -} - -// CreateUser 创建用户 -// @Summary 创建用户 -// @Description 创建一个本地密码登录的新用户,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param request body user.createUserRequest true "创建用户参数" -// @Success 200 {object} response.Any{data=user.user} "创建成功" -// @Failure 400 {object} response.Any "参数错误或用户名已存在" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/users [post] -func CreateUser(c *gin.Context) { - var req createUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - newUser, err := createUser(c.Request.Context(), req) - if abortUserLogicError(c, err, "", nil, []string{usernameRequired, emailRequired, passwordTooShort, usernameExists, emailExists}) { - return - } - - c.JSON(http.StatusOK, response.OK(toUser(newUser))) -} - -// updateUserRequest 更新用户信息请求 -type updateUserRequest struct { - Nickname string `json:"nickname" binding:"max=64"` - Email string `json:"email" binding:"required,email,max=255"` - IsAdmin bool `json:"is_admin"` - Password string `json:"password" binding:"omitempty,min=8,max=64"` -} - -// UpdateUser 更新用户信息 -// @Summary 更新用户信息 -// @Description 更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限 -// @Tags admin -// @Accept json -// @Produce json -// @Security SessionCookie -// @Param id path int true "用户 ID" -// @Param request body user.updateUserRequest true "更新参数" -// @Success 200 {object} response.Any{data=string} "更新成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限或尝试修改自身权限" -// @Failure 404 {object} response.Any "用户不存在" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/users/{id} [put] -func UpdateUser(c *gin.Context) { - var req updateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - id, ok := parseUserID(c) - if !ok { - return - } - - currUser, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey) - err := updateUser(c.Request.Context(), currUser.ID, updateUserParam{ - ID: id, - Nickname: req.Nickname, - Email: req.Email, - IsAdmin: req.IsAdmin, - Password: req.Password, - }) - - if err != nil { - if abortUserLogicError(c, err, userNotFound, []string{cannotRevokeSelfAdmin}, []string{emailRequired, emailExists, passwordTooShort}) { - return - } - response.AbortInternal(c, updateUserInfoFailed) - return - } - - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/admin/user/routers_test.go b/backend/OpenFlare/plugins/server/admin/user/routers_test.go deleted file mode 100644 index a8e3af50..00000000 --- a/backend/OpenFlare/plugins/server/admin/user/routers_test.go +++ /dev/null @@ -1,583 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package user - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -func setupTestRouter(authUser *model.User) *gin.Engine { - r := testhelper.NewTestGinEngine() - adminGroup := r.Group("/api/v1/admin") - - // Mock authentication middleware - adminGroup.Use(func(c *gin.Context) { - if authUser != nil { - oauth.SetToContext(c, oauth.UserObjKey, authUser) - } - c.Next() - }) - - adminGroup.GET("/users", ListUsers) - adminGroup.POST("/users", CreateUser) - adminGroup.GET("/users/:id", GetUser) - adminGroup.PUT("/users/:id/status", UpdateUserStatus) - adminGroup.DELETE("/users/:id", DeleteUser) - return r -} - -func TestListUsers(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - // Seed users - users := []model.User{ - { - ID: 1001, - Username: "alice", - Nickname: "Alice Nickname", - IsActive: true, - IsAdmin: false, - LastLoginAt: time.Now(), - }, - { - ID: 1002, - Username: "bob", - Nickname: "Bob Nickname", - IsActive: true, - IsAdmin: false, - LastLoginAt: time.Now(), - }, - { - ID: 1003, - Username: "charlie", - Nickname: "Charlie Nickname", - IsActive: false, - IsAdmin: true, - LastLoginAt: time.Now(), - }, - } - - for _, u := range users { - if err := dbConn.Create(&u).Error; err != nil { - t.Fatalf("failed to seed user: %v", err) - } - } - - adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("basic pagination list", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=2", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - // Parse data map to our structure - dataBytes, _ := json.Marshal(resp.Data) - var listResp listUsersResponse - if err := json.Unmarshal(dataBytes, &listResp); err != nil { - t.Fatalf("failed to parse list response: %v", err) - } - - if len(listResp.Users) != 2 { - t.Errorf("expected 2 users, got %d", len(listResp.Users)) - } - if listResp.Total != 3 { - t.Errorf("expected total 3, got %d", listResp.Total) - } - // Ordered by ID ASC - if listResp.Users[0].ID != 1001 || listResp.Users[1].ID != 1002 { - t.Errorf("expected ordered ASC, got first ID %d, second ID %d", listResp.Users[0].ID, listResp.Users[1].ID) - } - }) - - t.Run("filter by user_id", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=10&user_id=1001", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var listResp listUsersResponse - _ = json.Unmarshal(dataBytes, &listResp) - - if len(listResp.Users) != 1 || listResp.Users[0].ID != 1001 { - t.Errorf("expected 1 user with ID 1001, got total %d", len(listResp.Users)) - } - }) - - t.Run("filter by username prefix", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=10&username=bo", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - dataBytes, _ := json.Marshal(resp.Data) - var listResp listUsersResponse - _ = json.Unmarshal(dataBytes, &listResp) - - if len(listResp.Users) != 1 || listResp.Users[0].Username != "bob" { - t.Errorf("expected bob, got %v", listResp.Users) - } - }) - - t.Run("invalid pagination parameter", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=0&page_size=10", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d", w.Code) - } - }) -} - -func TestGetUser(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - targetUser := model.User{ - ID: 1001, - Username: "alice", - Password: "secret-hash", - Nickname: "Alice Nickname", - Email: "alice@example.com", - AvatarURL: "https://example.com/avatar.png", - IsActive: true, - IsAdmin: false, - Bio: "hello", - Phone: "123456", - Gender: "female", - Website: "https://example.com", - Location: "Shanghai", - } - if err := dbConn.Create(&targetUser).Error; err != nil { - t.Fatalf("failed to seed user: %v", err) - } - - adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("get full user profile", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/users/1001", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - dataBytes, _ := json.Marshal(resp.Data) - var resUser user - if err := json.Unmarshal(dataBytes, &resUser); err != nil { - t.Fatalf("failed to parse response data: %v", err) - } - - if resUser.Email != targetUser.Email || resUser.Bio != targetUser.Bio || resUser.Phone != targetUser.Phone || - resUser.Gender != targetUser.Gender || resUser.Website != targetUser.Website || resUser.Location != targetUser.Location { - t.Errorf("profile fields were not returned correctly: %+v", resUser) - } - if bytes.Contains(dataBytes, []byte("secret-hash")) { - t.Error("response should not include password") - } - }) - - t.Run("get non-existent user", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/admin/users/9999", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String()) - } - }) -} - -func TestUpdateUserStatus(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - // Seed users - regularUser := model.User{ - ID: 1001, - Username: "alice", - IsActive: true, - IsAdmin: false, - } - adminUser := model.User{ - ID: 1002, - Username: "bob", - IsActive: true, - IsAdmin: true, - } - - dbConn.Create(®ularUser) - dbConn.Create(&adminUser) - - router := setupTestRouter(&adminUser) - - t.Run("disable regular user successfully", func(t *testing.T) { - payload := updateUserStatusRequest{IsActive: false} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/users/1001/status", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - // Verify DB status - var u model.User - dbConn.First(&u, 1001) - if u.IsActive { - t.Error("user should be deactivated in the database") - } - }) - - t.Run("cannot disable admin user", func(t *testing.T) { - payload := updateUserStatusRequest{IsActive: false} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/users/1002/status", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.ErrorMsg != cannotDisable { - t.Errorf("expected error message '%s', got '%s'", cannotDisable, resp.ErrorMsg) - } - }) - - t.Run("cannot enable/disable non-existent user", func(t *testing.T) { - payload := updateUserStatusRequest{IsActive: false} - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("PUT", "/api/v1/admin/users/9999/status", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String()) - } - }) -} - -func TestCreateUser(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true} - router := setupTestRouter(adminUser) - - t.Run("create user successfully", func(t *testing.T) { - payload := createUserRequest{ - Username: "newuser", - Password: "newpassword123", - Nickname: "New Nickname", - Email: "newuser@example.com", - IsActive: true, - IsAdmin: false, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - if resp.ErrorMsg != "" { - t.Errorf("expected empty error message, got '%s'", resp.ErrorMsg) - } - - dataBytes, _ := json.Marshal(resp.Data) - var resUser user - if err := json.Unmarshal(dataBytes, &resUser); err != nil { - t.Fatalf("failed to parse response data: %v", err) - } - - if resUser.Username != "newuser" || resUser.Nickname != "New Nickname" || !resUser.IsActive || resUser.IsAdmin { - t.Errorf("unexpected user values: %+v", resUser) - } - - // Verify in DB - var dbUser model.User - if err := dbConn.Where("username = ?", "newuser").First(&dbUser).Error; err != nil { - t.Fatalf("failed to find user in db: %v", err) - } - if dbUser.Email != "newuser@example.com" { - t.Errorf("expected email 'newuser@example.com', got '%s'", dbUser.Email) - } - if !dbUser.CheckPassword("newpassword123") { - t.Error("password was not hashed correctly") - } - }) - - t.Run("create user with duplicate username", func(t *testing.T) { - // Create the first user - existing := model.User{ - ID: 2001, - Username: "dupuser", - Nickname: "Dup User", - Email: "dupuser@example.com", - } - dbConn.Create(&existing) - - payload := createUserRequest{ - Username: "dupuser", - Password: "password123", - Nickname: "Another Nick", - Email: "another@example.com", - IsActive: true, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.ErrorMsg != usernameExists { - t.Errorf("expected error '%s', got '%s'", usernameExists, resp.ErrorMsg) - } - }) - - t.Run("create user with duplicate email", func(t *testing.T) { - existing := model.User{ - ID: 2002, - Username: "existingemail", - Nickname: "Existing Email", - Email: "dupemail@example.com", - } - dbConn.Create(&existing) - - payload := createUserRequest{ - Username: "newuser2", - Password: "password123", - Nickname: "New User 2", - Email: "dupemail@example.com", - IsActive: true, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) - } - - var resp response.Any - _ = json.Unmarshal(w.Body.Bytes(), &resp) - if resp.ErrorMsg != emailExists { - t.Errorf("expected error '%s', got '%s'", emailExists, resp.ErrorMsg) - } - }) - - t.Run("validation error - password too short", func(t *testing.T) { - payload := createUserRequest{ - Username: "shortpass", - Password: "123", - Email: "shortpass@example.com", - IsActive: true, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) - } - }) - - t.Run("validation error - invalid email format", func(t *testing.T) { - payload := map[string]interface{}{ - "username": "bademail", - "password": "password123", - "email": "not-an-email", - "is_active": true, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) - } - }) -} - -func TestDeleteUser(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - if err := dbConn.AutoMigrate(&model.AccessToken{}, &model.ExternalAccount{}); err != nil { - t.Fatalf("failed to migrate delete-related tables: %v", err) - } - - regularUser := model.User{ - ID: 1001, - Username: "alice", - IsActive: true, - IsAdmin: false, - } - adminUser := model.User{ - ID: 1002, - Username: "bob", - IsActive: true, - IsAdmin: true, - } - selfUser := model.User{ - ID: 1003, - Username: "charlie", - IsActive: true, - IsAdmin: false, - } - - if err := dbConn.Create(®ularUser).Error; err != nil { - t.Fatalf("failed to seed regular user: %v", err) - } - if err := dbConn.Create(&adminUser).Error; err != nil { - t.Fatalf("failed to seed admin user: %v", err) - } - if err := dbConn.Create(&selfUser).Error; err != nil { - t.Fatalf("failed to seed self user: %v", err) - } - if err := dbConn.Create(&model.AccessToken{ - UserID: regularUser.ID, - Name: "api", - TokenHash: "hash-for-delete-user-test", - MaskedToken: "at_****test", - }).Error; err != nil { - t.Fatalf("failed to seed access token: %v", err) - } - if err := dbConn.Create(&model.ExternalAccount{ - ID: 5001, - AuthSourceID: 1, - UserID: regularUser.ID, - ExternalID: "external-alice", - }).Error; err != nil { - t.Fatalf("failed to seed external account: %v", err) - } - - router := setupTestRouter(&selfUser) - - t.Run("delete regular user successfully", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1001", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - var count int64 - if err := dbConn.Model(&model.User{}).Where("id = ?", 1001).Count(&count).Error; err != nil { - t.Fatalf("failed to count deleted user: %v", err) - } - if count != 0 { - t.Errorf("expected deleted user count 0, got %d", count) - } - - if err := dbConn.Model(&model.AccessToken{}).Where("user_id = ?", 1001).Count(&count).Error; err != nil { - t.Fatalf("failed to count deleted access tokens: %v", err) - } - if count != 0 { - t.Errorf("expected deleted access token count 0, got %d", count) - } - - if err := dbConn.Model(&model.ExternalAccount{}).Where("user_id = ?", 1001).Count(&count).Error; err != nil { - t.Fatalf("failed to count deleted external accounts: %v", err) - } - if count != 0 { - t.Errorf("expected deleted external account count 0, got %d", count) - } - }) - - t.Run("cannot delete admin user", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1002", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String()) - } - }) - - t.Run("cannot delete current user", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1003", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String()) - } - }) - - t.Run("delete non-existent user", func(t *testing.T) { - req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/9999", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String()) - } - }) -} diff --git a/backend/OpenFlare/plugins/server/cap/errs.go b/backend/OpenFlare/plugins/server/cap/errs.go deleted file mode 100644 index 9fd43234..00000000 --- a/backend/OpenFlare/plugins/server/cap/errs.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package cap 提供人机验证中间件 -package cap - -const ( - errCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials -) diff --git a/backend/OpenFlare/plugins/server/cap/manager.go b/backend/OpenFlare/plugins/server/cap/manager.go deleted file mode 100644 index 6bbb4a27..00000000 --- a/backend/OpenFlare/plugins/server/cap/manager.go +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package cap provides CAPTCHA and proof-of-work (PoW) verification services. -package cap - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "strconv" - "strings" - "sync" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" -) - -const ( - redeemTokenIDLength = 8 // 兑换 Token ID 字节长度 - redeemVerTokenLength = 15 // 兑换验证 Token 字节长度 - tokenPartsCount = 2 // 兑换 Token 由两部分组成 - valuePartsCount = 2 // 存储值由 scope 和过期时间组成 -) - -// Manager orchestrates challenge generation and solution validation. -type Manager struct { - secret []byte - store pkgcap.Store -} - -// NewManager creates a new CAPTCHA Manager. -func NewManager(secret []byte, store pkgcap.Store) *Manager { - return &Manager{ - secret: secret, - store: store, - } -} - -// Generate creates a challenge response. -func (m *Manager) Generate(ctx context.Context, scope string) (*pkgcap.ChallengeResponse, error) { - settings, err := CurrentSettings(ctx) - if err != nil { - return nil, err - } - - challengeConfig := pkgcap.ChallengeConfig{ - Count: settings.ChallengeCount, - Size: settings.ChallengeSize, - Difficulty: settings.ChallengeDifficulty, - Expires: settings.ChallengeTTL, - } - return pkgcap.GenerateChallenge(m.secret, challengeConfig, scope) -} - -// RedeemResponse is returned to the client on redeem. -type RedeemResponse struct { - Success bool `json:"success"` - Token string `json:"token,omitempty"` - Expires int64 `json:"expires,omitempty"` - Error string `json:"error,omitempty"` -} - -// Redeem verifies PoW solutions and returns a one-time redeem token. -func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) { - sigHex := pkgcap.JwtSigHex(token) - if sigHex == "" { - return &RedeemResponse{Success: false, Error: "invalid_token"}, nil - } - - nonceKey := "cap:nonce:" + sigHex - - payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.secret, scope) - if err != nil { - return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // validation errors are returned as response, not system errors - } - - now := time.Now().UnixNano() / int64(time.Millisecond) - nonceTTL := max(time.Duration(payload.Expires-now)*time.Millisecond, time.Second) - - set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL) - if err != nil { - return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err - } - if !set { - return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil - } - - settings, err := CurrentSettings(ctx) - if err != nil { - return &RedeemResponse{Success: false, Error: "settings_load_error"}, err - } - - id := pkgcap.RandomHex(redeemTokenIDLength) - verToken := pkgcap.RandomHex(redeemVerTokenLength) - verHashBytes := sha256.Sum256([]byte(verToken)) - verHashHex := hex.EncodeToString(verHashBytes[:]) - - tokenKey := "cap:token:" + id + ":" + verHashHex - tokenExpires := time.Now().Add(settings.TokenTTL) - storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope - - if err := m.store.Set(ctx, tokenKey, storeVal, settings.TokenTTL); err != nil { - return &RedeemResponse{Success: false, Error: "token_store_error"}, err - } - - return &RedeemResponse{ - Success: true, - Token: id + ":" + verToken, - Expires: tokenExpires.UnixNano() / int64(time.Millisecond), - }, nil -} - -// VerifyToken validates and consumes the redeem token (single-use). -func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope string) (bool, error) { - if token == "" { - return false, nil - } - parts := strings.Split(token, ":") - if len(parts) != tokenPartsCount { - return false, nil - } - id := parts[0] - verToken := parts[1] - - verHashBytes := sha256.Sum256([]byte(verToken)) - verHashHex := hex.EncodeToString(verHashBytes[:]) - - tokenKey := "cap:token:" + id + ":" + verHashHex - - val, exists, err := sGetAndDelete(ctx, m.store, tokenKey) - if err != nil { - return false, err - } - if !exists { - return false, nil - } - - valParts := strings.Split(val, "|") - if len(valParts) != valuePartsCount { - return false, nil - } - - expNano, err := strconv.ParseInt(valParts[0], 10, 64) - if err != nil { - return false, nil //nolint:nilerr // invalid format is treated as validation failure - } - tokenScope := valParts[1] - - if expectedScope != "" && tokenScope != expectedScope { - return false, nil - } - - if time.Now().UnixNano() > expNano { - return false, nil - } - - return true, nil -} - -func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string, bool, error) { - if store == nil { - return "", false, nil - } - return store.GetAndDelete(ctx, key) -} - -var ( - defaultManager *Manager - once sync.Once -) - -// GetDefaultManager yields the global singleton CAPTCHA manager. -func GetDefaultManager() *Manager { - once.Do(func() { - var secret []byte - if config.Config != nil && strings.TrimSpace(config.Config.App.SessionSecret) != "" { - secret = []byte(config.Config.App.SessionSecret) - } - if len(secret) == 0 { - return - } - - var store pkgcap.Store - if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil { - store = pkgcap.NewRedisStore(db.Redis) - } else { - store = pkgcap.NewMemoryStore(1 * time.Minute) - } - - defaultManager = NewManager(secret, store) - }) - return defaultManager -} diff --git a/backend/OpenFlare/plugins/server/cap/manager_test.go b/backend/OpenFlare/plugins/server/cap/manager_test.go deleted file mode 100644 index 5ab21b8f..00000000 --- a/backend/OpenFlare/plugins/server/cap/manager_test.go +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" - - pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" -) - -func installTestManagerSettings(t *testing.T) func() { - t.Helper() - return InstallTestRuntimeSettings(RuntimeSettings{ - ChallengeCount: 3, - ChallengeSize: 32, - ChallengeDifficulty: 3, - ChallengeTTL: 5 * time.Second, - TokenTTL: 10 * time.Second, - }) -} - -func TestCapFullFlow(t *testing.T) { - cleanup := installTestManagerSettings(t) - defer cleanup() - - secret := []byte("a-very-long-secret-key-at-least-16-bytes") - store := pkgcap.NewMemoryStore(1 * time.Minute) - manager := NewManager(secret, store) - - scope := "test-scope" - ctx := context.Background() - resp, err := manager.Generate(ctx, scope) - if err != nil { - t.Fatalf("Generate() error = %v", err) - } - - if resp.Challenge.C != 3 { - t.Fatalf("Generate().Challenge.C = %d, want %d", resp.Challenge.C, 3) - } - - solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) - - redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope) - if err != nil { - t.Fatalf("Redeem() error = %v", err) - } - if !redeemResp.Success { - t.Fatalf("Redeem().Success = false, error = %s", redeemResp.Error) - } - if redeemResp.Token == "" { - t.Fatal("Redeem().Token is empty") - } - - valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope) - if err != nil { - t.Fatalf("VerifyToken() error = %v", err) - } - if !valid { - t.Fatal("VerifyToken() = false, want true") - } - - validAgain, err := manager.VerifyToken(ctx, redeemResp.Token, scope) - if err != nil { - t.Fatalf("VerifyToken() second call error = %v", err) - } - if validAgain { - t.Fatal("VerifyToken() second call = true, want false") - } -} - -func TestRedeemConcurrentRace(t *testing.T) { - const goroutines = 50 - - cleanup := installTestManagerSettings(t) - defer cleanup() - - secret := []byte("race-test-secret-key-at-least-16-bytes") - store := pkgcap.NewMemoryStore(1 * time.Minute) - manager := NewManager(secret, store) - - ctx := context.Background() - resp, err := manager.Generate(ctx, "login") - if err != nil { - t.Fatalf("Generate() error = %v", err) - } - solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) - - var ( - wg sync.WaitGroup - success atomic.Int32 - barrier = make(chan struct{}) - ) - - for range goroutines { - wg.Add(1) - go func() { - defer wg.Done() - <-barrier - r, _ := manager.Redeem(ctx, resp.Token, solutions, "login") - if r != nil && r.Success { - success.Add(1) - } - }() - } - close(barrier) - wg.Wait() - - if got := success.Load(); got != 1 { - t.Fatalf("successful Redeem count = %d, want %d", got, 1) - } -} - -func TestVerifyTokenConcurrentRace(t *testing.T) { - const goroutines = 50 - - cleanup := installTestManagerSettings(t) - defer cleanup() - - secret := []byte("race-test-secret-key-at-least-16-bytes") - store := pkgcap.NewMemoryStore(1 * time.Minute) - manager := NewManager(secret, store) - - ctx := context.Background() - resp, err := manager.Generate(ctx, "login") - if err != nil { - t.Fatalf("Generate() error = %v", err) - } - solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) - redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, "login") - if err != nil || !redeemResp.Success { - t.Fatalf("Redeem() error = %v, resp = %+v", err, redeemResp) - } - - var ( - wg sync.WaitGroup - success atomic.Int32 - barrier = make(chan struct{}) - ) - - for range goroutines { - wg.Add(1) - go func() { - defer wg.Done() - <-barrier - ok, _ := manager.VerifyToken(ctx, redeemResp.Token, "login") - if ok { - success.Add(1) - } - }() - } - close(barrier) - wg.Wait() - - if got := success.Load(); got != 1 { - t.Fatalf("successful VerifyToken count = %d, want %d", got, 1) - } -} diff --git a/backend/OpenFlare/plugins/server/cap/middleware.go b/backend/OpenFlare/plugins/server/cap/middleware.go deleted file mode 100644 index a5867e01..00000000 --- a/backend/OpenFlare/plugins/server/cap/middleware.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. -func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc { - return func(c *gin.Context) { - if !ProtectionEnabled(c.Request.Context()) { - c.Next() - return - } - if mgr == nil { - response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) - return - } - - token := c.GetHeader("X-Cap-Token") - if token == "" { - response.AbortUnauthorized(c, errCapTokenMissing) - return - } - - valid, err := mgr.VerifyToken(c.Request.Context(), token, scope) - if err != nil || !valid { - response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) - return - } - - c.Next() - } -} diff --git a/backend/OpenFlare/plugins/server/cap/routers.go b/backend/OpenFlare/plugins/server/cap/routers.go deleted file mode 100644 index 846e4e42..00000000 --- a/backend/OpenFlare/plugins/server/cap/routers.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "net/http" - - pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" - "Wavelet/pkg/response" - - "github.com/gin-gonic/gin" -) - -// ChallengeResponse is a local type alias for the pkg/cap.ChallengeResponse struct -type ChallengeResponse = pkgcap.ChallengeResponse - -type challengeRequest struct { - Scope string `json:"scope" form:"scope"` -} - -type redeemRequest struct { - Token string `json:"token" binding:"required"` - Solutions []int `json:"solutions" binding:"required"` - Scope string `json:"scope" form:"scope"` -} - -// Challenge 生成 PoW 人机验证难题 -// @Summary 生成人机验证难题 -// @Description 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 -// @Tags cap -// @Accept json -// @Produce json -// @Param request body challengeRequest false "可选范围限制参数" -// @Success 200 {object} response.Any{data=cap.ChallengeResponse} "成功返回 PoW 难题" -// @Failure 500 {object} response.Any "内部服务错误" -// @Router /api/cap/challenge [post] -func Challenge(c *gin.Context) { - var req challengeRequest - _ = c.ShouldBind(&req) // 允许不传 body,默认使用 login scope - - if req.Scope == "" { - req.Scope = "login" - } - - mgr := GetDefaultManager() - if mgr == nil { - response.AbortInternal(c, "captcha is not configured") - return - } - resp, err := mgr.Generate(c.Request.Context(), req.Scope) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - c.JSON(http.StatusOK, response.OK(resp)) -} - -// Redeem 提交 PoW 解答并兑换一次性凭证 Token -// @Summary 校验人机验证解答 -// @Description 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 -// @Tags cap -// @Accept json -// @Produce json -// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组" -// @Success 200 {object} response.Any{data=cap.RedeemResponse} "核销成功,返回 X-Cap-Token" -// @Failure 400 {object} response.Any "参数错误或核销失败" -// @Failure 500 {object} response.Any "内部服务错误" -// @Router /api/cap/redeem [post] -func Redeem(c *gin.Context) { - var req redeemRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, "无效的参数") - return - } - - if req.Scope == "" { - req.Scope = "login" - } - - mgr := GetDefaultManager() - if mgr == nil { - response.AbortInternal(c, "captcha is not configured") - return - } - resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - if !resp.Success { - response.AbortBadRequest(c, resp.Error) - return - } - - c.JSON(http.StatusOK, response.OK(resp)) -} diff --git a/backend/OpenFlare/plugins/server/cap/routers_test.go b/backend/OpenFlare/plugins/server/cap/routers_test.go deleted file mode 100644 index a2ac2dcc..00000000 --- a/backend/OpenFlare/plugins/server/cap/routers_test.go +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" - - "github.com/gin-gonic/gin" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/model" - pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" - "Wavelet/pkg/response" -) - -func decodeAPIResponse[T any](t *testing.T, body []byte) T { - t.Helper() - var envelope struct { - ErrorMsg string `json:"error_msg"` - Data T `json:"data"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - t.Fatalf("failed to unmarshal API envelope: %v", err) - } - if envelope.ErrorMsg != "" { - t.Fatalf("unexpected API error_msg: %s", envelope.ErrorMsg) - } - return envelope.Data -} - -func TestCapEndpointsAndMiddleware(t *testing.T) { - sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - oldSecret := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "test-captcha-session-secret" - once = sync.Once{} - defaultManager = nil - t.Cleanup(func() { - config.Config.App.SessionSecret = oldSecret - once = sync.Once{} - defaultManager = nil - }) - r := testhelper.NewTestGinEngine() - - // Mount CAPTCHA API endpoints - capGroup := r.Group("/api/cap") - { - capGroup.POST("/challenge", Challenge) - capGroup.POST("/redeem", Redeem) - } - - r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login"), func(c *gin.Context) { - c.JSON(http.StatusOK, response.OK("login success")) - }) - - // Ensure CAPTCHA is disabled initially for step 2 - if err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "false").Error; err != nil { - t.Fatalf("failed to disable cap_login_enabled in DB: %v", err) - } - if err := repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil { - t.Fatalf("InvalidateSystemConfigCache() error = %v", err) - } - InvalidateRuntimeSettings() - - // 1. Test challenge generation - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/cap/challenge", nil) - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) - } - - challengeResp := decodeAPIResponse[pkgcap.ChallengeResponse](t, w.Body.Bytes()) - - if challengeResp.Token == "" { - t.Fatalf("expected token in challenge response") - } - - // 2. Test login with CAPTCHA disabled (should pass) - w = httptest.NewRecorder() - req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String()) - } - - // 3. Enable CAPTCHA in DB and invalidate runtime snapshot - err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error - if err != nil { - t.Fatalf("failed to enable cap_login_enabled in DB: %v", err) - } - if err := repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil { - t.Fatalf("InvalidateSystemConfigCache() error = %v", err) - } - InvalidateRuntimeSettings() - - // 4. Test login with CAPTCHA enabled but no header (should be blocked) - w = httptest.NewRecorder() - req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) - r.ServeHTTP(w, req) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 Unauthorized, got %d. Body: %s", w.Code, w.Body.String()) - } - - // 5. Solve the challenge - solutions := pkgcap.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D) - - // 6. Redeem solutions - redeemReqPayload := redeemRequest{ - Token: challengeResp.Token, - Solutions: solutions, - } - bodyBytes, _ := json.Marshal(redeemReqPayload) - w = httptest.NewRecorder() - req, _ = http.NewRequest("POST", "/api/cap/redeem", bytes.NewBuffer(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String()) - } - - redeemResp := decodeAPIResponse[RedeemResponse](t, w.Body.Bytes()) - - if !redeemResp.Success || redeemResp.Token == "" { - t.Fatalf("redeem failed or returned empty token: %+v", redeemResp) - } - - // 7. Login with valid redeem token (should pass) - w = httptest.NewRecorder() - req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) - req.Header.Set("X-Cap-Token", redeemResp.Token) - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK with valid cap token, got %d. Body: %s", w.Code, w.Body.String()) - } - - // 8. Replay attack: Login with the same redeem token again (should be blocked as it is single-use) - w = httptest.NewRecorder() - req, _ = http.NewRequest("POST", "/api/v1/user/login", nil) - req.Header.Set("X-Cap-Token", redeemResp.Token) - r.ServeHTTP(w, req) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 Unauthorized on replayed token, got %d. Body: %s", w.Code, w.Body.String()) - } -} diff --git a/backend/OpenFlare/plugins/server/cap/runtime_settings.go b/backend/OpenFlare/plugins/server/cap/runtime_settings.go deleted file mode 100644 index d8e8784e..00000000 --- a/backend/OpenFlare/plugins/server/cap/runtime_settings.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "context" - "encoding/json" - "errors" - "strconv" - "sync" - "sync/atomic" - "time" - - "golang.org/x/sync/singleflight" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/pkg/util" -) - -const ( - defaultChallengeCount = 1 - defaultChallengeSize = 32 - defaultChallengeDifficulty = 4 - defaultChallengeTTL = 10 * time.Minute - defaultTokenTTL = 20 * time.Minute -) - -// RuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs. -type RuntimeSettings struct { - LoginEnabled bool - ChallengeCount int - ChallengeSize int - ChallengeDifficulty int - ChallengeTTL time.Duration - TokenTTL time.Duration -} - -var runtimeConfigKeys = []string{ - model.ConfigKeyCapLoginEnabled, - model.ConfigKeyCapChallengeCount, - model.ConfigKeyCapChallengeSize, - model.ConfigKeyCapChallengeDifficulty, - model.ConfigKeyCapChallengeTTL, - model.ConfigKeyCapTokenTTL, -} - -var runtimeConfigKeySet = func() map[string]struct{} { - set := make(map[string]struct{}, len(runtimeConfigKeys)) - for _, key := range runtimeConfigKeys { - set[key] = struct{}{} - } - return set -}() - -type runtimeSettingsStore struct { - snapshot atomic.Pointer[RuntimeSettings] - loadGroup singleflight.Group - listenerOnce sync.Once -} - -var settingsStore = &runtimeSettingsStore{} - -// IsRuntimeConfigKey reports whether a system config key affects CAPTCHA runtime settings. -func IsRuntimeConfigKey(key string) bool { - _, ok := runtimeConfigKeySet[key] - return ok -} - -// CurrentSettings returns the cached CAPTCHA runtime settings snapshot. -func CurrentSettings(ctx context.Context) (RuntimeSettings, error) { - return settingsStore.current(ctx) -} - -// ProtectionEnabled reports whether CAPTCHA verification is required for protected routes. -func ProtectionEnabled(ctx context.Context) bool { - settings, err := CurrentSettings(ctx) - if err != nil { - return false - } - return settings.LoginEnabled -} - -// InvalidateRuntimeSettings drops the in-process CAPTCHA settings snapshot. -func InvalidateRuntimeSettings() { - settingsStore.snapshot.Store(nil) -} - -// ResetRuntimeSettingsForTest clears the CAPTCHA runtime snapshot. -func ResetRuntimeSettingsForTest() { - InvalidateRuntimeSettings() -} - -// InstallTestRuntimeSettings installs a fixed snapshot for unit tests. -func InstallTestRuntimeSettings(settings RuntimeSettings) func() { - snapshot := settings - settingsStore.snapshot.Store(&snapshot) - return InvalidateRuntimeSettings -} - -func (s *runtimeSettingsStore) current(ctx context.Context) (RuntimeSettings, error) { - s.ensureInvalidationListener() - - if snapshot := s.snapshot.Load(); snapshot != nil { - return *snapshot, nil - } - - loaded, err, _ := s.loadGroup.Do("cap-runtime-settings", func() (any, error) { - if snapshot := s.snapshot.Load(); snapshot != nil { - return *snapshot, nil - } - - settings, loadErr := loadRuntimeSettings(ctx) - if loadErr != nil { - return RuntimeSettings{}, loadErr - } - - s.snapshot.Store(&settings) - return settings, nil - }) - if err != nil { - return RuntimeSettings{}, err - } - - settings, ok := loaded.(RuntimeSettings) - if !ok { - return RuntimeSettings{}, errors.New("cap runtime settings loader returned unexpected type") - } - return settings, nil -} - -func loadRuntimeSettings(ctx context.Context) (RuntimeSettings, error) { - configs, err := repository.ListSystemConfigsByKeys(ctx, runtimeConfigKeys) - if err != nil { - return RuntimeSettings{}, err - } - return parseRuntimeSettings(configs), nil -} - -func parseRuntimeSettings(configs map[string]model.SystemConfig) RuntimeSettings { - settings := RuntimeSettings{ - ChallengeCount: defaultChallengeCount, - ChallengeSize: defaultChallengeSize, - ChallengeDifficulty: defaultChallengeDifficulty, - ChallengeTTL: defaultChallengeTTL, - TokenTTL: defaultTokenTTL, - } - - if sc, ok := configs[model.ConfigKeyCapLoginEnabled]; ok { - if enabled, err := strconv.ParseBool(sc.Value); err == nil { - settings.LoginEnabled = enabled - } - } - if sc, ok := configs[model.ConfigKeyCapChallengeCount]; ok { - if count, err := strconv.Atoi(sc.Value); err == nil && count > 0 { - settings.ChallengeCount = count - } - } - if sc, ok := configs[model.ConfigKeyCapChallengeSize]; ok { - if size, err := strconv.Atoi(sc.Value); err == nil && size > 0 { - settings.ChallengeSize = size - } - } - if sc, ok := configs[model.ConfigKeyCapChallengeDifficulty]; ok { - if difficulty, err := strconv.Atoi(sc.Value); err == nil && difficulty > 0 { - settings.ChallengeDifficulty = difficulty - } - } - if sc, ok := configs[model.ConfigKeyCapChallengeTTL]; ok { - if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 { - settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second - } - } - if sc, ok := configs[model.ConfigKeyCapTokenTTL]; ok { - if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 { - settings.TokenTTL = time.Duration(ttlSeconds) * time.Second - } - } - - return settings -} - -func (s *runtimeSettingsStore) ensureInvalidationListener() { - s.listenerOnce.Do(startRuntimeSettingsInvalidationListener) -} - -func startRuntimeSettingsInvalidationListener() { - if db.Redis == nil { - return - } - - util.Go(func() { - pubsub := db.Redis.Subscribe(context.Background(), repository.SystemConfigInvalidationChannel) - defer func() { - _ = pubsub.Close() - }() - - for msg := range pubsub.Channel() { - var payload struct { - Key string `json:"key"` - } - if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil { - InvalidateRuntimeSettings() - continue - } - if payload.Key == "" || payload.Key == "*" || IsRuntimeConfigKey(payload.Key) { - InvalidateRuntimeSettings() - } - } - }) -} diff --git a/backend/OpenFlare/plugins/server/cap/runtime_settings_test.go b/backend/OpenFlare/plugins/server/cap/runtime_settings_test.go deleted file mode 100644 index 563de0ae..00000000 --- a/backend/OpenFlare/plugins/server/cap/runtime_settings_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "context" - "testing" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" -) - -func TestCurrentSettingsLoadsSnapshotOnce(t *testing.T) { - _, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - ResetRuntimeSettingsForTest() - repository.ResetSystemConfigRAMCacheForTest() - - first, err := CurrentSettings(ctx) - if err != nil { - t.Fatalf("CurrentSettings() first error = %v", err) - } - if first.ChallengeCount != 1 { - t.Fatalf("CurrentSettings().ChallengeCount = %d, want %d", first.ChallengeCount, 1) - } - - if err := db.DB(ctx).Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeyCapChallengeCount). - Update("value", "4").Error; err != nil { - t.Fatalf("Update(cap_challenge_count) error = %v", err) - } - if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapChallengeCount); err != nil { - t.Fatalf("InvalidateSystemConfigCache() error = %v", err) - } - InvalidateRuntimeSettings() - - second, err := CurrentSettings(ctx) - if err != nil { - t.Fatalf("CurrentSettings() second error = %v", err) - } - if second.ChallengeCount != 4 { - t.Fatalf("CurrentSettings().ChallengeCount = %d, want %d", second.ChallengeCount, 4) - } -} - -func TestProtectionEnabledReflectsLoginSwitch(t *testing.T) { - _, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - ResetRuntimeSettingsForTest() - - if ProtectionEnabled(ctx) { - t.Fatal("ProtectionEnabled() = true, want false from seed defaults") - } - - if err := db.DB(ctx).Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeyCapLoginEnabled). - Update("value", "true").Error; err != nil { - t.Fatalf("Update(cap_login_enabled) error = %v", err) - } - if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapLoginEnabled); err != nil { - t.Fatalf("InvalidateSystemConfigCache() error = %v", err) - } - InvalidateRuntimeSettings() - - if !ProtectionEnabled(ctx) { - t.Fatal("ProtectionEnabled() = false, want true after config update") - } -} - -func TestParseRuntimeSettingsUsesDefaultsForMissingKeys(t *testing.T) { - settings := parseRuntimeSettings(map[string]model.SystemConfig{}) - - if settings.ChallengeCount != defaultChallengeCount { - t.Fatalf("ChallengeCount = %d, want %d", settings.ChallengeCount, defaultChallengeCount) - } - if settings.ChallengeTTL != defaultChallengeTTL { - t.Fatalf("ChallengeTTL = %s, want %s", settings.ChallengeTTL, defaultChallengeTTL) - } - if settings.TokenTTL != defaultTokenTTL { - t.Fatalf("TokenTTL = %s, want %s", settings.TokenTTL, defaultTokenTTL) - } -} - -func TestIsRuntimeConfigKey(t *testing.T) { - if !IsRuntimeConfigKey(model.ConfigKeyCapChallengeCount) { - t.Fatalf("IsRuntimeConfigKey(%s) = false, want true", model.ConfigKeyCapChallengeCount) - } - if IsRuntimeConfigKey(model.ConfigKeySiteName) { - t.Fatalf("IsRuntimeConfigKey(%s) = true, want false", model.ConfigKeySiteName) - } -} - -func TestInstallTestRuntimeSettings(t *testing.T) { - cleanup := InstallTestRuntimeSettings(RuntimeSettings{ - LoginEnabled: true, - ChallengeCount: 2, - TokenTTL: 30 * time.Minute, - }) - defer cleanup() - - settings, err := CurrentSettings(context.Background()) - if err != nil { - t.Fatalf("CurrentSettings() error = %v", err) - } - if !settings.LoginEnabled { - t.Fatal("LoginEnabled = false, want true") - } - if settings.ChallengeCount != 2 { - t.Fatalf("ChallengeCount = %d, want %d", settings.ChallengeCount, 2) - } -} diff --git a/backend/OpenFlare/plugins/server/cap/testhelper_hook.go b/backend/OpenFlare/plugins/server/cap/testhelper_hook.go deleted file mode 100644 index 219f2583..00000000 --- a/backend/OpenFlare/plugins/server/cap/testhelper_hook.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import "Wavelet/OpenFlare/plugins/server/testhelper" - -func init() { - testhelper.RegisterCleanup(ResetRuntimeSettingsForTest) -} diff --git a/backend/OpenFlare/plugins/server/config/public_config_cache_test.go b/backend/OpenFlare/plugins/server/config/public_config_cache_test.go deleted file mode 100644 index 36b9a7e0..00000000 --- a/backend/OpenFlare/plugins/server/config/public_config_cache_test.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "context" - "testing" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" -) - -func TestListVisibleSystemConfigsUsesRedisCache(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { - t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err) - } - - if _, err := repository.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 := repository.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") - } - } - - // Since system configs are now purely cached in process-local RAM (L1) and not written to Redis (L2), - // we do not verify the existence of the legacy Redis key here. - - if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { - t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err) - } - - refreshed, err := repository.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") - } -} diff --git a/backend/OpenFlare/plugins/server/config/routers.go b/backend/OpenFlare/plugins/server/config/routers.go deleted file mode 100644 index 2f92f494..00000000 --- a/backend/OpenFlare/plugins/server/config/routers.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package config 提供公开配置查询接口 -package config - -import ( - "net/http" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -// GetPublicConfig 获取公共配置 -// @Summary 获取公共配置 -// @Description 返回系统配置表中 visibility 为 1 的配置键值集合 -// @Tags config -// @Accept json -// @Produce json -// @Success 200 {object} response.Any -// @Router /api/v1/config/public [get] -func GetPublicConfig(c *gin.Context) { - ctx := c.Request.Context() - configs, err := repository.ListVisibleSystemConfigs(ctx) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - - resp := make(map[string]string, len(configs)) - for _, config := range configs { - resp[config.Key] = config.Value - } - - c.JSON(http.StatusOK, response.OK(resp)) -} - -// GetRobotsTXT 动态生成 robots.txt -// @Summary 获取 robots.txt -// @Description 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容 -// @Tags config -// @Produce text/plain -// @Success 200 {string} string "robots.txt 内容" -// @Router /robots.txt [get] -func GetRobotsTXT(c *gin.Context) { - ctx := c.Request.Context() - enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled) - content := "User-Agent: *\nDisallow: /\n" - if err == nil && enabled { - content = "User-Agent: *\nAllow: /\n" - } - c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(content)) -} diff --git a/backend/OpenFlare/plugins/server/config/routers_test.go b/backend/OpenFlare/plugins/server/config/routers_test.go deleted file mode 100644 index 509f9794..00000000 --- a/backend/OpenFlare/plugins/server/config/routers_test.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -func TestGetPublicConfigUsesVisibility(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - if err := dbConn.Create(&model.SystemConfig{ - Key: "custom_public_key", - Value: "custom_public_value", - Type: "system", - Visibility: model.ConfigVisibilityVisible, - Description: "custom public config", - }).Error; err != nil { - t.Fatalf("Create(custom_public_key) error = %v", err) - } - if err := dbConn.Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeySiteName). - Update("visibility", model.ConfigVisibilityHidden).Error; err != nil { - t.Fatalf("Update(%s.visibility) error = %v", model.ConfigKeySiteName, err) - } - - gin.SetMode(gin.TestMode) - router := gin.New() - router.GET("/api/v1/config/public", GetPublicConfig) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("GetPublicConfig() status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String()) - } - - var resp response.Any - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("json.Unmarshal(GetPublicConfig()) error = %v", err) - } - dataBytes, err := json.Marshal(resp.Data) - if err != nil { - t.Fatalf("json.Marshal(GetPublicConfig().data) error = %v", err) - } - var configs map[string]string - if err := json.Unmarshal(dataBytes, &configs); err != nil { - t.Fatalf("json.Unmarshal(GetPublicConfig().data) error = %v", err) - } - - if got := configs["custom_public_key"]; got != "custom_public_value" { - t.Errorf("GetPublicConfig()[custom_public_key] = %q, want %q", got, "custom_public_value") - } - if _, ok := configs[model.ConfigKeySiteName]; ok { - t.Errorf("GetPublicConfig()[%s] is present, want hidden", model.ConfigKeySiteName) - } -} diff --git a/backend/OpenFlare/plugins/server/config/system_config_cache_test.go b/backend/OpenFlare/plugins/server/config/system_config_cache_test.go deleted file mode 100644 index 521640a7..00000000 --- a/backend/OpenFlare/plugins/server/config/system_config_cache_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/redis/go-redis/v9" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" -) - -func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - repository.ResetSystemConfigRAMCacheForTest() - if err := repository.InvalidateAllSystemConfigCaches(ctx); err != nil { - t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err) - } - time.Sleep(50 * time.Millisecond) // Wait for async Redis broadcast to be processed - - warm, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) - if err != nil { - t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err) - } - if warm.Value != "OpenFlare" { - t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", warm.Value, "OpenFlare") - } - - if err := dbConn.Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeySiteName). - Update("value", "ram_probe_value").Error; err != nil { - t.Fatalf("Update(site_name) error = %v", err) - } - if err := db.HDel(ctx, repository.SystemConfigRedisHashKey, model.ConfigKeySiteName); err != nil { - t.Fatalf("HDel(site_name) error = %v", err) - } - - cached, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) - if err != nil { - t.Fatalf("GetSystemConfigByKey(site_name) cached error = %v", err) - } - if cached.Value != "OpenFlare" { - t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want stale RAM value %q", cached.Value, "OpenFlare") - } - - if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil { - t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err) - } - time.Sleep(50 * time.Millisecond) // Wait for async Redis broadcast to be processed - - refreshed, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) - if err != nil { - t.Fatalf("GetSystemConfigByKey(site_name) refreshed error = %v", err) - } - if refreshed.Value != "ram_probe_value" { - t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", refreshed.Value, "ram_probe_value") - } - - // Since system configs are now purely cached in process-local RAM (L1) and not written to Redis (L2), - // we do not verify if the Redis hash field is repopulated. -} - -func TestInvalidateSystemConfigCacheClearsRedisField(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) - if err != nil { - t.Fatalf("GetSystemConfigByKey(site_name) error = %v", err) - } - _ = sc - - if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil { - t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err) - } - time.Sleep(50 * time.Millisecond) // Wait for async Redis broadcast to be processed - - _, err = db.Redis.HGet(ctx, db.PrefixedKey(repository.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result() - if !errors.Is(err, redis.Nil) { - t.Fatalf("HGet(site_name) error = %v, want redis.Nil", err) - } - - if err := dbConn.Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeySiteName). - Update("value", "after_invalidate").Error; err != nil { - t.Fatalf("Update(site_name) error = %v", err) - } - - refreshed, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) - if err != nil { - t.Fatalf("GetSystemConfigByKey(site_name) refreshed error = %v", err) - } - if refreshed.Value != "after_invalidate" { - t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", refreshed.Value, "after_invalidate") - } -} diff --git a/backend/OpenFlare/plugins/server/health/routers.go b/backend/OpenFlare/plugins/server/health/routers.go deleted file mode 100644 index a57e0e88..00000000 --- a/backend/OpenFlare/plugins/server/health/routers.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package health 提供健康检查端点 -package health - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -// Health 健康检查 -// @Summary 健康检查 -// @Description 检查服务是否正常运行,可用于负载均衡存活探测 -// @Tags health -// @Produce json -// @Success 200 {object} response.Any{data=string} "服务正常" -// @Router /api/health [get] -func Health(c *gin.Context) { - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/infra/config/config.go b/backend/OpenFlare/plugins/server/infra/config/config.go deleted file mode 100644 index be3a1cae..00000000 --- a/backend/OpenFlare/plugins/server/infra/config/config.go +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package config 负责应用配置的加载、解析与环境变量覆盖。 -package config - -import ( - "encoding/json" - "errors" - "flag" - "log" - "os" - "strconv" - "strings" - - "github.com/spf13/viper" -) - -// 默认队列优先级 -const ( - webhookQueuePriority = 10 - whitelistQueuePriority = 5 - defaultQueuePriority = 3 -) - -// Config 全局配置单例,初始化后不可变 -var Config *configModel - -// findConfigPath searches upward for the config file to handle tests running in subdirectories. -func findConfigPath(configPath string) string { - if _, err := os.Stat(configPath); err == nil { - return configPath - } - var dir strings.Builder - dir.WriteString(".") - for range 5 { - dir.WriteString("/..") - path := dir.String() + "/" + configPath - if _, err := os.Stat(path); err == nil { - return path - } - } - return configPath -} - -// isTest checks if the current execution context is within 'go test'. -func isTest() bool { - if flag.Lookup("test.v") != nil { - return true - } - for _, arg := range os.Args { - if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") { - return true - } - } - return false -} - -func init() { - // 加载配置文件路径 - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = findConfigPath("config.yaml") - } - - // 设置配置文件 - viper.SetConfigFile(configPath) - viper.AutomaticEnv() - - // 读取配置文件(可选:找不到文件时使用空默认值 + 环境变量) - if err := viper.ReadInConfig(); err != nil { - var notFoundErr viper.ConfigFileNotFoundError - if !errors.As(err, ¬FoundErr) { - // 文件存在但读取/解析失败 - if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable - log.Fatalf("[Config] read config failed: %v\n", err) - } - } - log.Println("[Config] no config file found, using environment variables only") - viper.SetConfigType("yaml") - if err := viper.ReadConfig(strings.NewReader("")); err != nil { - log.Fatalf("[Config] failed to init empty config: %v\n", err) - } - } - - // 解析配置到结构体 - var c configModel - if err := viper.Unmarshal(&c); err != nil { - log.Fatalf("[Config] parse config failed: %v\n", err) - } - - applyDefaults(&c) - - // 环境变量覆盖(优先级高于 config.yaml) - applyEnvOverrides(&c) - applyDefaults(&c) - - // Disable standard DB/Redis/ClickHouse initializations during tests to prevent connection attempts. - if isTest() { - c.Database.Enabled = false - c.Database.SQLitePath = ":memory:" - c.Redis.Enabled = false - } - - // 设置全局配置 - Config = &c - - // 打印配置 - printConfig(&c) -} - -func applyDefaults(c *configModel) { - if c.App.SessionAge <= 0 { - c.App.SessionAge = 86400 - } - if c.Otel.TracerName == "" { - c.Otel.TracerName = "github.com/Rain-kl/OpenFlare" - } - applyClickHouseDefaults(c) -} - -func applyClickHouseDefaults(c *configModel) { - // Tests disable ClickHouse by default to avoid accidental connections. - // Opt in with CLICKHOUSE_ENABLED=true for live integration tests (e.g. -tags live_ch). - if isTest() { - if v, ok := os.LookupEnv("CLICKHOUSE_ENABLED"); !ok { - c.ClickHouse.Enabled = false - return - } else if b, err := strconv.ParseBool(v); err != nil || !b { - c.ClickHouse.Enabled = false - return - } - // Keep Enabled=true from env and continue applying host/pool defaults. - } - // 未显式启用(缺省或 enabled: false)时保持关闭,不再强制打开; - // 显式启用后补齐连接默认参数。 - if c.ClickHouse.Database == "" { - c.ClickHouse.Database = "openflare" - } - if len(c.ClickHouse.Hosts) == 0 { - c.ClickHouse.Hosts = []string{"127.0.0.1:9000"} - } - if c.ClickHouse.Username == "" { - c.ClickHouse.Username = "default" - } - // Pool / buffer defaults target small control-plane hosts (e.g. 3c6g): - // oversized open/idle pools waste RAM and amplify concurrent CH pressure; - // large block buffers add client memory without helping our small batch inserts. - if c.ClickHouse.MaxIdleConn <= 0 { - c.ClickHouse.MaxIdleConn = 8 - } - if c.ClickHouse.MaxOpenConn <= 0 { - c.ClickHouse.MaxOpenConn = 16 - } - if c.ClickHouse.ConnMaxLifetime <= 0 { - c.ClickHouse.ConnMaxLifetime = 3600 - } - if c.ClickHouse.DialTimeout <= 0 { - c.ClickHouse.DialTimeout = 5 - } - if c.ClickHouse.BlockBufferSize == 0 { - c.ClickHouse.BlockBufferSize = 32 - } -} - -// ─── 环境变量覆盖层 ──────────────────────────────────────────────────────────── -// 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。 - -func envStr(key, fallback string) string { - if v, ok := os.LookupEnv(key); ok { - return v - } - return fallback -} - -func envInt(key string, fallback int) int { - if v, ok := os.LookupEnv(key); ok { - if n, err := strconv.Atoi(v); err == nil { - return n - } - } - return fallback -} - -func envInt64(key string, fallback int64) int64 { - if v, ok := os.LookupEnv(key); ok { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - return n - } - } - return fallback -} - -func envFloat64(key string, fallback float64) float64 { - if v, ok := os.LookupEnv(key); ok { - if n, err := strconv.ParseFloat(v, 64); err == nil { - return n - } - } - return fallback -} - -func envBool(key string, fallback bool) bool { - if v, ok := os.LookupEnv(key); ok { - if b, err := strconv.ParseBool(v); err == nil { - return b - } - } - return fallback -} - -// applyEnvOverrides 将环境变量值覆盖到配置结构体上(仅当环境变量已设置时生效) -func applyEnvOverrides(c *configModel) { - // ─── App ─── - c.App.AppName = envStr("APP_NAME", c.App.AppName) - c.App.Env = envStr("APP_ENV", c.App.Env) - c.App.Addr = envStr("APP_ADDR", c.App.Addr) - c.App.NodeID = envInt64("APP_NODE_ID", c.App.NodeID) - c.App.APIPrefix = envStr("APP_API_PREFIX", c.App.APIPrefix) - c.App.GracefulShutdownTimeout = envInt("APP_GRACEFUL_SHUTDOWN_TIMEOUT", c.App.GracefulShutdownTimeout) - c.App.SessionCookieName = envStr("APP_SESSION_COOKIE_NAME", c.App.SessionCookieName) - c.App.SessionSecret = envStr("APP_SESSION_SECRET", c.App.SessionSecret) - c.App.SessionDomain = envStr("APP_SESSION_DOMAIN", c.App.SessionDomain) - c.App.SessionAge = envInt("APP_SESSION_AGE", c.App.SessionAge) - c.App.SessionHTTPOnly = envBool("APP_SESSION_HTTP_ONLY", c.App.SessionHTTPOnly) - c.App.SessionSecure = envBool("APP_SESSION_SECURE", c.App.SessionSecure) - - // ─── Database ─── - c.Database.Host = envStr("DB_HOST", c.Database.Host) - c.Database.Port = envInt("DB_PORT", c.Database.Port) - c.Database.Username = envStr("DB_USERNAME", c.Database.Username) - c.Database.Password = envStr("DB_PASSWORD", c.Database.Password) - c.Database.Database = envStr("DB_NAME", c.Database.Database) - c.Database.SSLMode = envStr("DB_SSL_MODE", c.Database.SSLMode) - c.Database.TimeZone = envStr("DB_TIMEZONE", c.Database.TimeZone) - c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel) - c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn) - c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn) - // 当 DB_HOST 环境变量已设置时自动启用数据库 - if _, ok := os.LookupEnv("DB_HOST"); ok { - c.Database.Enabled = true - } - c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled) - c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath) - - // ─── Redis ─── - if v, ok := os.LookupEnv("REDIS_ADDR"); ok { - c.Redis.Addrs = []string{v} - c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用 - } - c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled) - c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username) - c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password) - c.Redis.DB = envInt("REDIS_DB", c.Redis.DB) - c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix) - c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize) - c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications) - - // ─── ClickHouse ─── - if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok { - c.ClickHouse.Hosts = []string{v} - c.ClickHouse.Enabled = true - } - c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled) - c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username) - c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password) - c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database) - - // ─── Log ─── - c.Log.Level = envStr("LOG_LEVEL", c.Log.Level) - c.Log.Format = envStr("LOG_FORMAT", c.Log.Format) - c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output) - - // ─── OTel ─── - c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate) - c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName) - - // ─── Worker ─── - c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency) - c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority) - - // 无 yaml 且无环境变量时,使用代码级默认队列 - if len(c.Worker.Queues) == 0 { - c.Worker.Queues = []QueueConfig{ - {Name: "webhook", Priority: webhookQueuePriority}, - {Name: "whitelist_only", Priority: whitelistQueuePriority}, - {Name: "default", Priority: defaultQueuePriority}, - } - } -} - -// printConfig 打印配置内容 -func printConfig(c *configModel) { - configJSON, err := json.MarshalIndent(c, "", " ") - if err != nil { - log.Printf("[Config] failed to marshal config: %v\n", err) - return - } - log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON)) -} diff --git a/backend/OpenFlare/plugins/server/infra/config/config_test.go b/backend/OpenFlare/plugins/server/infra/config/config_test.go deleted file mode 100644 index d353490f..00000000 --- a/backend/OpenFlare/plugins/server/infra/config/config_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import "testing" - -func TestApplyEnvOverridesRedisMaintNotifications(t *testing.T) { - t.Setenv("REDIS_MAINT_NOTIFICATIONS", "true") - - cfg := &configModel{} - applyEnvOverrides(cfg) - - if !cfg.Redis.MaintNotifications { - t.Fatal("REDIS_MAINT_NOTIFICATIONS=true was not applied") - } -} - -// TestApplyClickHouseDefaultsRespectsDisabledConfig 回归:配置未显式启用(缺省或 -// enabled: false)时 ClickHouse 必须保持关闭,不得被 applyClickHouseDefaults 强制打开。 -// CLICKHOUSE_ENABLED=true 仅用于绕过测试分支(isTest 默认强制关闭),以便测真实默认逻辑。 -func TestApplyClickHouseDefaultsRespectsDisabledConfig(t *testing.T) { - t.Setenv("CLICKHOUSE_ENABLED", "true") - - cfg := &configModel{ClickHouse: clickHouseConfig{Enabled: false}} - applyClickHouseDefaults(cfg) - - if cfg.ClickHouse.Enabled { - t.Fatal("ClickHouse must stay disabled when config does not enable it") - } -} - -// TestApplyClickHouseDefaultsEnablesWhenConfigured 显式 enabled: true 时保持启用并补齐默认连接参数。 -func TestApplyClickHouseDefaultsEnablesWhenConfigured(t *testing.T) { - t.Setenv("CLICKHOUSE_ENABLED", "true") - - cfg := &configModel{ClickHouse: clickHouseConfig{Enabled: true}} - applyClickHouseDefaults(cfg) - - if !cfg.ClickHouse.Enabled { - t.Fatal("ClickHouse must stay enabled when explicitly configured") - } - if cfg.ClickHouse.Database != "openflare" { - t.Fatalf("database default not applied: %q", cfg.ClickHouse.Database) - } - if len(cfg.ClickHouse.Hosts) != 1 || cfg.ClickHouse.Hosts[0] != "127.0.0.1:9000" { - t.Fatalf("hosts default not applied: %v", cfg.ClickHouse.Hosts) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/config/model.go b/backend/OpenFlare/plugins/server/infra/config/model.go deleted file mode 100644 index 7fe0b7c1..00000000 --- a/backend/OpenFlare/plugins/server/infra/config/model.go +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import "time" - -type configModel struct { - App appConfig `mapstructure:"app"` - Database databaseConfig `mapstructure:"database"` - Redis redisConfig `mapstructure:"redis"` - Log logConfig `mapstructure:"log"` - Scheduler schedulerConfig `mapstructure:"scheduler"` - Worker workerConfig `mapstructure:"worker"` - ClickHouse clickHouseConfig `mapstructure:"clickhouse"` - Otel otelConfig `mapstructure:"otel"` -} - -// appConfig 应用基本配置 -type appConfig struct { - AppName string `mapstructure:"app_name"` - Env string `mapstructure:"env"` - Addr string `mapstructure:"addr"` - NodeID int64 `mapstructure:"node_id"` - APIPrefix string `mapstructure:"api_prefix"` - GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"` - SessionCookieName string `mapstructure:"session_cookie_name"` - SessionSecret string `mapstructure:"session_secret"` - SessionDomain string `mapstructure:"session_domain"` - SessionAge int `mapstructure:"session_age"` - SessionHTTPOnly bool `mapstructure:"session_http_only"` - SessionSecure bool `mapstructure:"session_secure"` -} - -// IsProduction 检查当前环境是否为生产环境 -func (a *appConfig) IsProduction() bool { - return a.Env == "production" -} - -// databaseConfig 数据库配置 -type databaseConfig struct { - Enabled bool `mapstructure:"enabled"` - SQLitePath string `mapstructure:"sqlite_path"` // PostgreSQL 禁用时的 SQLite 文件路径 - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - Database string `mapstructure:"database"` - MaxIdleConn int `mapstructure:"max_idle_conn"` - MaxOpenConn int `mapstructure:"max_open_conn"` - ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` - ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"` - LogLevel string `mapstructure:"log_level"` - SSLMode string `mapstructure:"ssl_mode"` - TimeZone string `mapstructure:"time_zone"` - ApplicationName string `mapstructure:"application_name"` - SearchPath string `mapstructure:"search_path"` - PreferSimpleProtocol bool `mapstructure:"prefer_simple_protocol"` - StatementCacheCapacity int `mapstructure:"statement_cache_capacity"` - DefaultQueryExecMode string `mapstructure:"default_query_exec_mode"` - Replicas []databaseReplicaConfig `mapstructure:"replicas"` - SlowThreshold time.Duration `mapstructure:"slow_threshold"` -} - -// databaseReplicaConfig 只读副本配置 -type databaseReplicaConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` -} - -// clickHouseConfig ClickHouse 原生客户端配置。 -// 连接池 / block_buffer 默认值按小型控制面主机(如 3c6g)收敛,见 applyClickHouseDefaults。 -type clickHouseConfig struct { - Enabled bool `mapstructure:"enabled"` - Hosts []string `mapstructure:"hosts"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - Database string `mapstructure:"database"` - MaxIdleConn int `mapstructure:"max_idle_conn"` // 默认 8 - MaxOpenConn int `mapstructure:"max_open_conn"` // 默认 16 - ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // 秒 - DialTimeout int `mapstructure:"dial_timeout"` // 秒 - BlockBufferSize uint8 `mapstructure:"block_buffer_size"` // 默认 32 -} - -// redisConfig Redis配置 -type redisConfig struct { - Enabled bool `mapstructure:"enabled"` - Addrs []string `mapstructure:"addrs"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - DB int `mapstructure:"db"` - ClusterMode bool `mapstructure:"cluster_mode"` - MasterName string `mapstructure:"master_name"` - KeyPrefix string `mapstructure:"key_prefix"` - PoolSize int `mapstructure:"pool_size"` - MinIdleConn int `mapstructure:"min_idle_conn"` - DialTimeout int `mapstructure:"dial_timeout"` - ReadTimeout int `mapstructure:"read_timeout"` - WriteTimeout int `mapstructure:"write_timeout"` - MaxRetries int `mapstructure:"max_retries"` - PoolTimeout int `mapstructure:"pool_timeout"` - ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"` - MaintNotifications bool `mapstructure:"maint_notifications"` -} - -// logConfig 日志配置 -type logConfig struct { - Level string `mapstructure:"level"` - Format string `mapstructure:"format"` - Output string `mapstructure:"output"` - FilePath string `mapstructure:"file_path"` - MaxSize int `mapstructure:"max_size"` - MaxAge int `mapstructure:"max_age"` - MaxBackups int `mapstructure:"max_backups"` - Compress bool `mapstructure:"compress"` -} - -// schedulerConfig 定时任务配置 -type schedulerConfig struct { -} - -// workerConfig 工作配置 -type workerConfig struct { - Concurrency int `mapstructure:"concurrency"` - StrictPriority bool `mapstructure:"strict_priority"` - Queues []QueueConfig `mapstructure:"queues"` -} - -// QueueConfig 队列配置 -type QueueConfig struct { - Name string `mapstructure:"name"` - Priority int `mapstructure:"priority"` -} - -// otelConfig OpenTelemetry 配置 -type otelConfig struct { - SamplingRate float64 `mapstructure:"sampling_rate"` - TracerName string `mapstructure:"tracer_name"` -} diff --git a/backend/OpenFlare/plugins/server/infra/diskcache/cache.go b/backend/OpenFlare/plugins/server/infra/diskcache/cache.go deleted file mode 100644 index ab0bd109..00000000 --- a/backend/OpenFlare/plugins/server/infra/diskcache/cache.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package diskcache wraps the generic pkg/cache/disk to provide database configuration integration. -package diskcache - -import ( - "context" - "strconv" - "sync" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - pkgcache "Wavelet/pkg/cache/disk" -) - -// Status represents the runtime cache statistics. -type Status = pkgcache.Status - -const ( - defaultCacheDir = "uploads/diskcache" - defaultMaxSizeMB = 100 - defaultTTLMinutes = 60 - defaultCleanupInterval = 10 - - // DefaultExpiration applies the cache-wide default TTL. - DefaultExpiration = pkgcache.DefaultExpiration - // NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply. - NoExpiration = pkgcache.NoExpiration -) - -// ErrCacheMiss represents a cache miss. -var ErrCacheMiss = pkgcache.ErrCacheMiss - -// DiskCache is a wrapper around the generic pkg/diskcache that integrates with the DB for configs. -type DiskCache struct { - *pkgcache.Cache -} - -var ( - globalCache *DiskCache - globalCacheOnce sync.Once -) - -// GetGlobalCache returns the global singleton DiskCache instance. -func GetGlobalCache() *DiskCache { - globalCacheOnce.Do(func() { - pureCache := pkgcache.New(defaultCacheDir) - globalCache = &DiskCache{pureCache} - // Load initial configs from database - globalCache.ReloadConfig(context.Background()) - // Start background routine to clean expired items every 10 minutes - go globalCache.StartCleanupWorker(defaultCleanupInterval * time.Minute) - }) - return globalCache -} - -// New creates a new DiskCache wrapper. -func New(basePath string) *DiskCache { - return &DiskCache{pkgcache.New(basePath)} -} - -// ReloadConfig reloads policies from database configs dynamically. -func (c *DiskCache) ReloadConfig(ctx context.Context) { - // Ensure DB is initialized before querying - if db.DB(ctx) == nil { - return - } - - // 1. Max Size - maxSizeMB := int64(defaultMaxSizeMB) - if scMaxSize, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheMaxSizeMB); err == nil && scMaxSize.Value != "" { - if val, err := strconv.ParseInt(scMaxSize.Value, 10, 64); err == nil && val > 0 { - maxSizeMB = val - } - } - - // 2. Default TTL - ttlMinutes := int64(defaultTTLMinutes) - if scTTL, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheTTLMinutes); err == nil && scTTL.Value != "" { - if val, err := strconv.ParseInt(scTTL.Value, 10, 64); err == nil && val >= 0 { - ttlMinutes = val - } - } - - // 3. LRU Enabled - lruEnabled := true - if scLRU, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheLRUEnabled); err == nil && scLRU.Value != "" { - if val, err := strconv.ParseBool(scLRU.Value); err == nil { - lruEnabled = val - } - } - - c.UpdatePolicy(maxSizeMB, ttlMinutes, lruEnabled) -} diff --git a/backend/OpenFlare/plugins/server/infra/diskcache/cache_test.go b/backend/OpenFlare/plugins/server/infra/diskcache/cache_test.go deleted file mode 100644 index 967372e5..00000000 --- a/backend/OpenFlare/plugins/server/infra/diskcache/cache_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package diskcache - -import ( - "context" - "os" - "testing" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" -) - -func TestDiskCacheReloadConfig(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - testDir := "uploads/test_diskcache_reload" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) - - c := New(testDir) - defer func() { _ = c.Clear() }() - - // Update DB config values - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheMaxSizeMB).Update("value", "250") - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheTTLMinutes).Update("value", "120") - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheLRUEnabled).Update("value", "false") - - // Invalidate Redis config cache to force DB reload - if db.Redis != nil { - db.Redis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)) - } - - // Reload config - c.ReloadConfig(context.Background()) - - status := c.Status() - if status.MaxSizeMB != 250 { - t.Errorf("expected MaxSizeMB to be 250, got %d", status.MaxSizeMB) - } - if status.TTLMinutes != 120 { - t.Errorf("expected TTLMinutes to be 120, got %d", status.TTLMinutes) - } - if status.LRUEnabled != false { - t.Errorf("expected LRUEnabled to be false, got %t", status.LRUEnabled) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/config.go b/backend/OpenFlare/plugins/server/infra/objectstore/config.go deleted file mode 100644 index e49b94ad..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/config.go +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package objectstore provides dynamically configured file storage backends. -package objectstore - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - - "gorm.io/gorm" -) - -// Driver identifies a supported storage backend. -type Driver string - -const ( - // DriverLocal stores files on the local filesystem. - DriverLocal Driver = "local" - // DriverS3 stores files in an S3-compatible object store. - DriverS3 Driver = "s3" - // DriverR2 stores files in Cloudflare R2. - DriverR2 Driver = "r2" - // DriverMinIO stores files in MinIO. - DriverMinIO Driver = "minio" - // DriverOSS stores files in Aliyun OSS. - DriverOSS Driver = "oss" - // DriverWebDAV stores files through WebDAV. - DriverWebDAV Driver = "webdav" - - // ConfigMask replaces secrets returned to the frontend. - ConfigMask = "******" -) - -// LocalConfig configures local filesystem storage. -type LocalConfig struct { - Root string `json:"root"` -} - -// ObjectConfig configures S3-compatible or OSS object storage. -type ObjectConfig struct { - Endpoint string `json:"endpoint"` - Region string `json:"region"` - Bucket string `json:"bucket"` - AccessKeyID string `json:"access_key_id"` - SecretAccessKey string `json:"secret_access_key"` - AccountID string `json:"account_id,omitempty"` - PathStyle bool `json:"path_style"` - KeyPrefix string `json:"key_prefix"` - CDNURL string `json:"cdn_url"` -} - -// WebDAVConfig configures WebDAV storage. -type WebDAVConfig struct { - Endpoint string `json:"endpoint"` - Username string `json:"username"` - Password string `json:"password"` - BasePath string `json:"base_path"` -} - -// Config contains all storage backends and the currently active driver. -type Config struct { - Driver Driver `json:"driver"` - Local LocalConfig `json:"local"` - S3 ObjectConfig `json:"s3"` - R2 ObjectConfig `json:"r2"` - MinIO ObjectConfig `json:"minio"` - OSS ObjectConfig `json:"oss"` - WebDAV WebDAVConfig `json:"webdav"` -} - -// DefaultConfig returns the local-storage default configuration. -func DefaultConfig() Config { - return Config{ - Driver: DriverLocal, - Local: LocalConfig{Root: "."}, - S3: ObjectConfig{Region: "us-east-1"}, - R2: ObjectConfig{Region: "auto"}, - MinIO: ObjectConfig{Region: "us-east-1", PathStyle: true}, - } -} - -// LoadConfig loads the active storage configuration. -func LoadConfig(ctx context.Context) (Config, error) { - pubSubOnce.Do(startPubSubListener) - - cacheMutex.RLock() - isCacheValid := time.Since(lastChecked) < 5*time.Second && activeConfigJSON != "" - configJSON := activeConfigJSON - cacheMutex.RUnlock() - - if isCacheValid { - cfg := DefaultConfig() - if strings.TrimSpace(configJSON) != "" { - if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { - return Config{}, fmt.Errorf("parse storage config from cache: %w", err) - } - } - return cfg, nil - } - - return loadConfigByKey(ctx, model.ConfigKeyStorageConfig, DefaultConfig()) -} - -func loadConfigByKey(ctx context.Context, key string, fallback Config) (Config, error) { - sc, err := repository.GetSystemConfigByKey(ctx, key) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fallback, nil - } - return Config{}, err - } - if strings.TrimSpace(sc.Value) == "" { - return fallback, nil - } - if err := json.Unmarshal([]byte(sc.Value), &fallback); err != nil { - return Config{}, fmt.Errorf("parse %s: %w", key, err) - } - return fallback, nil -} - -// ValidateConfig validates the selected backend configuration. -func ValidateConfig(cfg Config) error { - switch cfg.Driver { - case DriverLocal: - if strings.TrimSpace(cfg.Local.Root) == "" { - return errors.New("local root is required") - } - case DriverS3: - return validateObjectConfig(cfg.S3, false) - case DriverR2: - if strings.TrimSpace(cfg.R2.AccountID) == "" && strings.TrimSpace(cfg.R2.Endpoint) == "" { - return errors.New("R2 account ID or endpoint is required") - } - return validateObjectConfig(cfg.R2, false) - case DriverMinIO: - if strings.TrimSpace(cfg.MinIO.Endpoint) == "" { - return errors.New("MinIO endpoint is required") - } - return validateObjectConfig(cfg.MinIO, true) - case DriverOSS: - if strings.TrimSpace(cfg.OSS.Endpoint) == "" { - return errors.New("OSS endpoint is required") - } - return validateObjectConfig(cfg.OSS, true) - case DriverWebDAV: - if strings.TrimSpace(cfg.WebDAV.Endpoint) == "" { - return errors.New("WebDAV endpoint is required") - } - default: - return fmt.Errorf("unsupported storage driver %q", cfg.Driver) - } - return nil -} - -func validateObjectConfig(cfg ObjectConfig, endpointRequired bool) error { - if endpointRequired && strings.TrimSpace(cfg.Endpoint) == "" { - return errors.New("endpoint is required") - } - if strings.TrimSpace(cfg.Region) == "" { - return errors.New("region is required") - } - if strings.TrimSpace(cfg.Bucket) == "" { - return errors.New("bucket is required") - } - if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" { - return errors.New("access key ID and secret access key are required") - } - return nil -} - -// SaveActiveConfig persists the active storage configuration. -func SaveActiveConfig(ctx context.Context, cfg Config) error { - return saveSystemConfig(ctx, model.ConfigKeyStorageConfig, cfg, "文件存储驱动及连接配置(JSON)") -} - -func saveSystemConfig(ctx context.Context, key string, value any, description string) error { - err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { - return upsertSystemConfig(ctx, tx, key, value, description) - }) - if err == nil && key == model.ConfigKeyStorageConfig { - ResetCache() - PublishCacheInvalidation(ctx) - } - return err -} - -func upsertSystemConfig(ctx context.Context, tx *gorm.DB, key string, value any, description string) error { - data, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("marshal %s: %w", key, err) - } - sc := model.SystemConfig{ - Key: key, - Value: string(data), - Type: "system", - Visibility: model.ConfigVisibilityHidden, - Description: description, - } - if err := tx.Where("key = ?", key). - Assign(map[string]any{"value": sc.Value, "description": description, "visibility": model.ConfigVisibilityHidden}). - FirstOrCreate(&sc).Error; err != nil { - return err - } - return repository.InvalidateSystemConfigCache(ctx, key) -} - -// MergeMaskedSecrets restores unchanged secrets from the current configuration. -func MergeMaskedSecrets(next, current Config) Config { - mergeObjectSecret := func(dst *ObjectConfig, src ObjectConfig) { - if dst.AccessKeyID == ConfigMask { - dst.AccessKeyID = src.AccessKeyID - } - if dst.SecretAccessKey == ConfigMask { - dst.SecretAccessKey = src.SecretAccessKey - } - } - mergeObjectSecret(&next.S3, current.S3) - mergeObjectSecret(&next.R2, current.R2) - mergeObjectSecret(&next.MinIO, current.MinIO) - mergeObjectSecret(&next.OSS, current.OSS) - if next.WebDAV.Password == ConfigMask { - next.WebDAV.Password = current.WebDAV.Password - } - return next -} - -// MaskSecrets replaces stored credentials with placeholders for API responses. -func MaskSecrets(cfg Config) Config { - maskObject := func(value *ObjectConfig) { - if value.AccessKeyID != "" { - value.AccessKeyID = ConfigMask - } - if value.SecretAccessKey != "" { - value.SecretAccessKey = ConfigMask - } - } - maskObject(&cfg.S3) - maskObject(&cfg.R2) - maskObject(&cfg.MinIO) - maskObject(&cfg.OSS) - if cfg.WebDAV.Password != "" { - cfg.WebDAV.Password = ConfigMask - } - return cfg -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/http.go b/backend/OpenFlare/plugins/server/infra/objectstore/http.go deleted file mode 100644 index b1555191..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/http.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" - - "Wavelet/pkg/httppool" -) - -func getHTTPObject(ctx context.Context, baseURL, key string) (*Object, error) { - objectURL, err := url.JoinPath(baseURL, key) - if err != nil { - return nil, fmt.Errorf("build CDN object URL: %w", err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, objectURL, nil) - if err != nil { - return nil, fmt.Errorf("create CDN request: %w", err) - } - const cdnRequestTimeout = 30 * time.Second - client := httppool.NewClient(cdnRequestTimeout) - response, err := client.Do(request) - if err != nil { - return nil, fmt.Errorf("get CDN object: %w", err) - } - if response.StatusCode != http.StatusOK { - _ = response.Body.Close() - return nil, fmt.Errorf("get CDN object: unexpected status %d", response.StatusCode) - } - contentType := response.Header.Get("Content-Type") - if contentType == "" { - contentType = defaultContentType - } - return &Object{ - Body: response.Body, - ContentLength: response.ContentLength, - ContentType: contentType, - }, nil -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/local.go b/backend/OpenFlare/plugins/server/infra/objectstore/local.go deleted file mode 100644 index 0f02e5c8..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/local.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "errors" - "fmt" - "io" - "mime" - "os" - "path/filepath" - "strings" -) - -type localBackend struct { - root string -} - -func newLocalBackend(cfg LocalConfig) (*localBackend, error) { - root := filepath.Clean(cfg.Root) - if root == "" { - return nil, errors.New("local root is required") - } - return &localBackend{root: root}, nil -} - -func (b *localBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (PutResult, error) { - path, err := b.path(key) - if err != nil { - return PutResult{}, err - } - if err := os.MkdirAll(filepath.Dir(path), storageDirPerm); err != nil { - return PutResult{}, err - } - file, err := os.OpenFile( //nolint:gosec // path is constrained to the configured storage root. - path, - os.O_CREATE|os.O_TRUNC|os.O_WRONLY, - storageFilePerm, - ) - if err != nil { - return PutResult{}, err - } - if _, err := io.Copy(file, body); err != nil { - _ = file.Close() - _ = os.Remove(path) - return PutResult{}, err - } - if err := file.Close(); err != nil { - _ = os.Remove(path) - return PutResult{}, err - } - return PutResult{Key: filepath.ToSlash(key)}, nil -} - -func (b *localBackend) Get(_ context.Context, key string) (*Object, error) { - path, err := b.path(key) - if err != nil { - return nil, err - } - file, err := os.Open(path) //nolint:gosec // path is constrained to the configured storage root. - if err != nil { - return nil, err - } - info, err := file.Stat() - if err != nil { - _ = file.Close() - return nil, err - } - contentType := mime.TypeByExtension(filepath.Ext(path)) - if contentType == "" { - contentType = defaultContentType - } - return &Object{Body: file, ContentLength: info.Size(), ContentType: contentType}, nil -} - -func (b *localBackend) Delete(_ context.Context, key string) error { - path, err := b.path(key) - if err != nil { - return err - } - err = os.Remove(path) - if os.IsNotExist(err) { - return nil - } - return err -} - -func (b *localBackend) Test(_ context.Context) error { - return os.MkdirAll(b.root, storageDirPerm) -} - -func (b *localBackend) path(key string) (string, error) { - if filepath.IsAbs(key) { - cleanPath := filepath.Clean(key) - absRoot, err := filepath.Abs(b.root) - if err != nil { - return "", err - } - absPath, err := filepath.Abs(cleanPath) - if err != nil { - return "", err - } - rel, err := filepath.Rel(absRoot, absPath) - if err != nil || strings.HasPrefix(rel, "..") { - return "", errors.New("storage key escapes local root") - } - return cleanPath, nil - } - cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(key, "/"))) - if cleanKey == "." || cleanKey == "" || strings.HasPrefix(cleanKey, "..") { - return "", fmt.Errorf("invalid local storage key %q", key) - } - path := filepath.Join(b.root, cleanKey) - rel, err := filepath.Rel(b.root, path) - if err != nil || strings.HasPrefix(rel, "..") { - return "", errors.New("storage key escapes local root") - } - return path, nil -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/local_test.go b/backend/OpenFlare/plugins/server/infra/objectstore/local_test.go deleted file mode 100644 index 8525f078..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/local_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "bytes" - "context" - "io" - "testing" -) - -func TestLocalBackendRoundTrip(t *testing.T) { - backend, err := newLocalBackend(LocalConfig{Root: t.TempDir()}) - if err != nil { - t.Fatalf("newLocalBackend() returned error: %v", err) - } - ctx := context.Background() - const key = "uploads/2026/06/13/test.txt" - const content = "wavelet storage" - - storedResult, err := backend.Put(ctx, key, bytes.NewBufferString(content), int64(len(content)), "text/plain") - if err != nil { - t.Fatalf("Put(%q) returned error: %v", key, err) - } - if storedResult.Key != key { - t.Errorf("Put(%q) key = %q, want %q", key, storedResult.Key, key) - } - - object, err := backend.Get(ctx, key) - if err != nil { - t.Fatalf("Get(%q) returned error: %v", key, err) - } - got, err := io.ReadAll(object.Body) - if err != nil { - t.Fatalf("ReadAll(Get(%q)) returned error: %v", key, err) - } - if err := object.Body.Close(); err != nil { - t.Fatalf("Close(Get(%q)) returned error: %v", key, err) - } - if string(got) != content { - t.Errorf("Get(%q) content = %q, want %q", key, got, content) - } - - if err := backend.Delete(ctx, key); err != nil { - t.Fatalf("Delete(%q) returned error: %v", key, err) - } - if _, err := backend.Get(ctx, key); err == nil { - t.Errorf("Get(%q) after Delete() returned nil error", key) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/oss.go b/backend/OpenFlare/plugins/server/infra/objectstore/oss.go deleted file mode 100644 index 356d14ef..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/oss.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "fmt" - "io" - "strings" - - "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" - "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" -) - -type ossBackend struct { - client *oss.Client - bucket string - keyPrefix string - cdnURL string -} - -func newOSSBackend(cfg ObjectConfig) (*ossBackend, error) { - options := oss.LoadDefaultConfig(). - WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey)). - WithRegion(cfg.Region) - if cfg.Endpoint != "" { - options.WithEndpoint(cfg.Endpoint) - } - return &ossBackend{ - client: oss.NewClient(options), - bucket: cfg.Bucket, - keyPrefix: strings.Trim(cfg.KeyPrefix, "/"), - cdnURL: strings.TrimRight(cfg.CDNURL, "/"), - }, nil -} - -func (b *ossBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) (PutResult, error) { - key = b.key(key) - _, err := b.client.PutObject(ctx, &oss.PutObjectRequest{ - Bucket: oss.Ptr(b.bucket), - Key: oss.Ptr(key), - Body: body, - }) - if err != nil { - return PutResult{}, fmt.Errorf("put OSS object: %w", err) - } - return PutResult{Key: key, Bucket: b.bucket}, nil -} - -func (b *ossBackend) Get(ctx context.Context, key string) (*Object, error) { - key = b.key(key) - if b.cdnURL != "" { - return getHTTPObject(ctx, b.cdnURL, key) - } - output, err := b.client.GetObject(ctx, &oss.GetObjectRequest{ - Bucket: oss.Ptr(b.bucket), - Key: oss.Ptr(key), - }) - if err != nil { - return nil, fmt.Errorf("get OSS object: %w", err) - } - contentType := defaultContentType - if output.ContentType != nil { - contentType = *output.ContentType - } - return &Object{Body: output.Body, ContentLength: output.ContentLength, ContentType: contentType}, nil -} - -func (b *ossBackend) Delete(ctx context.Context, key string) error { - _, err := b.client.DeleteObject(ctx, &oss.DeleteObjectRequest{ - Bucket: oss.Ptr(b.bucket), - Key: oss.Ptr(b.key(key)), - }) - if err != nil { - return fmt.Errorf("delete OSS object: %w", err) - } - return nil -} - -func (b *ossBackend) Test(ctx context.Context) error { - ok, err := b.client.IsBucketExist(ctx, b.bucket) - if err != nil { - return fmt.Errorf("access OSS bucket: %w", err) - } - if !ok { - return fmt.Errorf("OSS bucket %q does not exist", b.bucket) - } - return nil -} - -func (b *ossBackend) key(key string) string { - key = strings.TrimLeft(key, "/") - if b.keyPrefix == "" || strings.HasPrefix(key, b.keyPrefix+"/") { - return key - } - return b.keyPrefix + "/" + key -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/s3.go b/backend/OpenFlare/plugins/server/infra/objectstore/s3.go deleted file mode 100644 index a314be48..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/s3.go +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "fmt" - "io" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -type s3Backend struct { - client *s3.Client - bucket string - keyPrefix string - cdnURL string -} - -func newS3Backend(ctx context.Context, cfg ObjectConfig) (*s3Backend, error) { - awsCfg, err := awsconfig.LoadDefaultConfig(ctx, - awsconfig.WithRegion(cfg.Region), - awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( - cfg.AccessKeyID, - cfg.SecretAccessKey, - "", - )), - ) - if err != nil { - return nil, fmt.Errorf("load S3 config: %w", err) - } - client := s3.NewFromConfig(awsCfg, func(options *s3.Options) { - if cfg.Endpoint != "" { - options.BaseEndpoint = aws.String(strings.TrimRight(cfg.Endpoint, "/")) - } - options.UsePathStyle = cfg.PathStyle - }) - return &s3Backend{ - client: client, - bucket: cfg.Bucket, - keyPrefix: strings.Trim(cfg.KeyPrefix, "/"), - cdnURL: strings.TrimRight(cfg.CDNURL, "/"), - }, nil -} - -func newR2Backend(ctx context.Context, cfg ObjectConfig) (*s3Backend, error) { - if cfg.Endpoint == "" { - cfg.Endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID) - } - cfg.Region = "auto" - return newS3Backend(ctx, cfg) -} - -func (b *s3Backend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) { - key = b.key(key) - _, err := b.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(b.bucket), - Key: aws.String(key), - Body: body, - ContentLength: aws.Int64(size), - ContentType: aws.String(contentType), - }) - if err != nil { - return PutResult{}, fmt.Errorf("put S3 object: %w", err) - } - return PutResult{Key: key, Bucket: b.bucket}, nil -} - -func (b *s3Backend) Get(ctx context.Context, key string) (*Object, error) { - key = b.key(key) - if b.cdnURL != "" { - return getHTTPObject(ctx, b.cdnURL, key) - } - output, err := b.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(b.bucket), - Key: aws.String(key), - }) - if err != nil { - return nil, fmt.Errorf("get S3 object: %w", err) - } - contentType := defaultContentType - if output.ContentType != nil { - contentType = *output.ContentType - } - var size int64 - if output.ContentLength != nil { - size = *output.ContentLength - } - return &Object{Body: output.Body, ContentLength: size, ContentType: contentType}, nil -} - -func (b *s3Backend) Delete(ctx context.Context, key string) error { - _, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(b.bucket), - Key: aws.String(b.key(key)), - }) - if err != nil { - return fmt.Errorf("delete S3 object: %w", err) - } - return nil -} - -func (b *s3Backend) Test(ctx context.Context) error { - _, err := b.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(b.bucket)}) - if err != nil { - return fmt.Errorf("access S3 bucket: %w", err) - } - return nil -} - -func (b *s3Backend) key(key string) string { - key = strings.TrimLeft(key, "/") - if b.keyPrefix == "" || strings.HasPrefix(key, b.keyPrefix+"/") { - return key - } - return b.keyPrefix + "/" + key -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/storage.go b/backend/OpenFlare/plugins/server/infra/objectstore/storage.go deleted file mode 100644 index 13a96065..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/storage.go +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "strings" - "sync" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/pkg/util" - - "gorm.io/gorm" -) - -const ( - defaultContentType = "application/octet-stream" - storageDirPerm = 0o750 - storageFilePerm = 0o600 -) - -// Object describes a readable stored object. -type Object struct { - CachePath string - Body io.ReadCloser - ContentLength int64 - ContentType string -} - -// PutResult describes the result of a successful Put operation. -type PutResult struct { - Key string - Bucket string -} - -// Backend defines storage operations used by the upload domain. -type Backend interface { - Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) - Get(ctx context.Context, key string) (*Object, error) - Delete(ctx context.Context, key string) error - Test(ctx context.Context) error -} - -var ( - // IsEnabledFunc preserves the legacy S3 test hook while tests migrate to backend injection. - IsEnabledFunc = func() bool { return false } - mockBackend Backend - - activeBackend Backend - activeDriver Driver - activeConfigJSON string - lastChecked time.Time - cacheMutex sync.RWMutex -) - -// ConfigInvalidationChannel is the Redis pub/sub channel used to evict storage caches cluster-wide. -const ConfigInvalidationChannel = "storage:config_invalidation" - -var pubSubOnce sync.Once - -// ResetCache clears the local cache for storage configuration and client singletons. -func ResetCache() { - cacheMutex.Lock() - defer cacheMutex.Unlock() - activeBackend = nil - activeDriver = "" - activeConfigJSON = "" - lastChecked = time.Time{} -} - -// PublishCacheInvalidation broadcasts cache eviction to all nodes in the cluster via Redis. -func PublishCacheInvalidation(ctx context.Context) { - if db.Redis != nil { - _ = db.Redis.Publish(ctx, ConfigInvalidationChannel, "reset").Err() - } -} - -// startPubSubListener starts the background subscriber for cache invalidations. -func startPubSubListener() { - if db.Redis == nil { - return - } - util.Go(func() { - pubsub := db.Redis.Subscribe(context.Background(), ConfigInvalidationChannel) - defer func() { - _ = pubsub.Close() - }() - - ch := pubsub.Channel() - for range ch { - ResetCache() - } - }) -} - -// Active returns the configured active driver and backend, using an in-memory cache with 5s TTL. -func Active(ctx context.Context) (Driver, Backend, error) { - if IsEnabledFunc() && mockBackend != nil { - return DriverS3, mockBackend, nil - } - - pubSubOnce.Do(startPubSubListener) - - cacheMutex.RLock() - isCacheValid := time.Since(lastChecked) < 5*time.Second && activeBackend != nil - if isCacheValid { - d, b := activeDriver, activeBackend - cacheMutex.RUnlock() - return d, b, nil - } - cacheMutex.RUnlock() - - cacheMutex.Lock() - defer cacheMutex.Unlock() - - // Double-check under write lock - if time.Since(lastChecked) < 5*time.Second && activeBackend != nil { - return activeDriver, activeBackend, nil - } - - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyStorageConfig) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return "", nil, err - } - - lastChecked = time.Now() - - // Reuse existing backend client singleton if configuration JSON matches - if sc.Value == activeConfigJSON && activeBackend != nil { - return activeDriver, activeBackend, nil - } - - cfg := DefaultConfig() - if strings.TrimSpace(sc.Value) != "" { - if err := json.Unmarshal([]byte(sc.Value), &cfg); err != nil { - return "", nil, fmt.Errorf("parse storage config: %w", err) - } - } - - backend, err := NewBackend(ctx, cfg, cfg.Driver) - if err != nil { - return "", nil, err - } - - activeDriver = cfg.Driver - activeBackend = backend - activeConfigJSON = sc.Value - - return activeDriver, activeBackend, nil -} - -type functionBackend struct { - put func(context.Context, string, io.Reader, int64, string) error - get func(context.Context, string) (*Object, error) - delete func(context.Context, string) error -} - -func (b *functionBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) { - if err := b.put(ctx, key, body, size, contentType); err != nil { - return PutResult{}, err - } - return PutResult{Key: key}, nil -} - -func (b *functionBackend) Get(ctx context.Context, key string) (*Object, error) { - return b.get(ctx, key) -} - -func (b *functionBackend) Delete(ctx context.Context, key string) error { - return b.delete(ctx, key) -} - -func (b *functionBackend) Test(context.Context) error { - return nil -} - -// MockStorage replaces object operations for package tests and returns a restore function. -func MockStorage( - put func(context.Context, string, io.Reader, int64, string) error, - get func(context.Context, string) (*Object, error), - deleteObject func(context.Context, string) error, -) func() { - previous := mockBackend - mockBackend = &functionBackend{put: put, get: get, delete: deleteObject} - return func() { - mockBackend = previous - } -} - -// NewBackend constructs a concrete backend from configuration. -func NewBackend(ctx context.Context, cfg Config, driver Driver) (Backend, error) { - if driver == DriverS3 && mockBackend != nil { - return mockBackend, nil - } - switch driver { - case DriverLocal: - return newLocalBackend(cfg.Local) - case DriverS3: - return newS3Backend(ctx, cfg.S3) - case DriverR2: - return newR2Backend(ctx, cfg.R2) - case DriverMinIO: - return newS3Backend(ctx, cfg.MinIO) - case DriverOSS: - return newOSSBackend(cfg.OSS) - case DriverWebDAV: - return newWebDAVBackend(cfg.WebDAV) - default: - return nil, fmt.Errorf("unsupported storage driver %q", driver) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/storage_test.go b/backend/OpenFlare/plugins/server/infra/objectstore/storage_test.go deleted file mode 100644 index 46fcbae9..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/storage_test.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "encoding/json" - "io" - "sync" - "testing" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - - "github.com/alicebob/miniredis/v2" - "github.com/redis/go-redis/v9" -) - -func TestStorageCache(t *testing.T) { - // 1. Reset cache - ResetCache() - - if activeConfigJSON != "" || activeDriver != "" || activeBackend != nil || !lastChecked.IsZero() { - t.Fatal("ResetCache did not clear cache variables") - } - - // 2. Set up cache manually - expectedConfig := Config{ - Driver: DriverLocal, - Local: LocalConfig{Root: "/tmp/wavelet-test"}, - } - cfgJSON, err := json.Marshal(expectedConfig) - if err != nil { - t.Fatalf("Marshal config failed: %v", err) - } - - cacheMutex.Lock() - activeConfigJSON = string(cfgJSON) - lastChecked = time.Now() - cacheMutex.Unlock() - - // 3. Call LoadConfig and verify it loads from cache (doesn't hit database, which would fail/panic because DB is not initialized) - ctx := context.Background() - loadedCfg, err := LoadConfig(ctx) - if err != nil { - t.Fatalf("LoadConfig failed: %v", err) - } - - if loadedCfg.Driver != expectedConfig.Driver || loadedCfg.Local.Root != expectedConfig.Local.Root { - t.Errorf("Loaded config %+v, expected %+v", loadedCfg, expectedConfig) - } - - // 4. Test Active() returns cached driver and backend - mockBnd := &functionBackend{ - put: func(context.Context, string, io.Reader, int64, string) error { return nil }, - get: func(context.Context, string) (*Object, error) { return nil, nil }, - delete: func(context.Context, string) error { return nil }, - } - - cacheMutex.Lock() - activeBackend = mockBnd - activeDriver = DriverLocal - cacheMutex.Unlock() - - drv, bnd, err := Active(ctx) - if err != nil { - t.Fatalf("Active failed: %v", err) - } - if drv != DriverLocal || bnd != mockBnd { - t.Errorf("Active returned driver %v, backend %v; expected %v, %v", drv, bnd, DriverLocal, mockBnd) - } - - // 5. Test ResetCache again - ResetCache() - if activeConfigJSON != "" || activeDriver != "" || activeBackend != nil || !lastChecked.IsZero() { - t.Fatal("ResetCache did not clear cache variables after setting them") - } -} - -func TestStorageCachePubSub(t *testing.T) { - // 1. Start miniredis - mr, err := miniredis.Run() - if err != nil { - t.Fatalf("Failed to run miniredis: %v", err) - } - defer mr.Close() - - // 2. Initialize Redis client - rdb := redis.NewClient(&redis.Options{ - Addr: mr.Addr(), - }) - defer rdb.Close() - - // 3. Set db.Redis to our client - oldRedis := db.Redis - db.Redis = rdb - defer func() { - db.Redis = oldRedis - }() - - // Reset cache and set some cached config - ResetCache() - cacheMutex.Lock() - activeConfigJSON = "some_config" - lastChecked = time.Now() - cacheMutex.Unlock() - - // 4. Force trigger lazy initialization of subscription - // Reset the once guard so it runs the listener - pubSubOnce = sync.Once{} - ctx := context.Background() - - // Create mock backend for Active call - mockBnd := &functionBackend{ - put: func(context.Context, string, io.Reader, int64, string) error { return nil }, - get: func(context.Context, string) (*Object, error) { return nil, nil }, - delete: func(context.Context, string) error { return nil }, - } - cacheMutex.Lock() - activeBackend = mockBnd - activeDriver = DriverLocal - cacheMutex.Unlock() - - _, _, _ = Active(ctx) // This calls startPubSubListener() - - // Allow some time for subscriber connection - time.Sleep(100 * time.Millisecond) - - // 5. Publish cache invalidation - PublishCacheInvalidation(ctx) - - // Allow message propagation - time.Sleep(100 * time.Millisecond) - - // 6. Verify cache was cleared - cacheMutex.RLock() - configJSON := activeConfigJSON - cacheMutex.RUnlock() - - if configJSON != "" { - t.Error("Memory cache was not cleared after Redis Pub/Sub broadcast") - } -} diff --git a/backend/OpenFlare/plugins/server/infra/objectstore/webdav.go b/backend/OpenFlare/plugins/server/infra/objectstore/webdav.go deleted file mode 100644 index 3dd4d01b..00000000 --- a/backend/OpenFlare/plugins/server/infra/objectstore/webdav.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package objectstore - -import ( - "context" - "fmt" - "io" - "net/http" - "path" - "strings" - - "Wavelet/pkg/httppool" - - "github.com/studio-b12/gowebdav" -) - -type contextTransport struct { - ctx context.Context - parent http.RoundTripper -} - -func (t *contextTransport) RoundTrip(req *http.Request) (*http.Response, error) { - return t.parent.RoundTrip(req.WithContext(t.ctx)) -} - -type webDAVBackend struct { - endpoint string - username string - password string - basePath string -} - -func newWebDAVBackend(cfg WebDAVConfig) (*webDAVBackend, error) { - return &webDAVBackend{ - endpoint: strings.TrimRight(cfg.Endpoint, "/"), - username: cfg.Username, - password: cfg.Password, - basePath: strings.Trim(cfg.BasePath, "/"), - }, nil -} - -func (b *webDAVBackend) newClient(ctx context.Context) *gowebdav.Client { - client := gowebdav.NewClient(b.endpoint, b.username, b.password) - client.SetTransport(&contextTransport{ - ctx: ctx, - parent: httppool.DefaultTransport(), - }) - return client -} - -func (b *webDAVBackend) Put(ctx context.Context, key string, body io.Reader, size int64, _ string) (PutResult, error) { - key = b.key(key) - client := b.newClient(ctx) - if dir := path.Dir(key); dir != "." && dir != "/" { - if err := client.MkdirAll(dir, storageDirPerm); err != nil { - return PutResult{}, fmt.Errorf("create WebDAV directory: %w", err) - } - } - if err := client.WriteStreamWithLength(key, body, size, storageFilePerm); err != nil { - return PutResult{}, fmt.Errorf("put WebDAV object: %w", err) - } - return PutResult{Key: key}, nil -} - -func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) { - key = b.key(key) - client := b.newClient(ctx) - info, err := client.Stat(key) - if err != nil { - return nil, fmt.Errorf("stat WebDAV object: %w", err) - } - body, err := client.ReadStream(key) - if err != nil { - return nil, fmt.Errorf("get WebDAV object: %w", err) - } - contentType := defaultContentType - if typed, ok := info.(interface{ ContentType() string }); ok && typed.ContentType() != "" { - contentType = typed.ContentType() - } - return &Object{Body: body, ContentLength: info.Size(), ContentType: contentType}, nil -} - -func (b *webDAVBackend) Delete(ctx context.Context, key string) error { - client := b.newClient(ctx) - if err := client.Remove(b.key(key)); err != nil { - return fmt.Errorf("delete WebDAV object: %w", err) - } - return nil -} - -func (b *webDAVBackend) Test(ctx context.Context) error { - client := b.newClient(ctx) - if err := client.Connect(); err != nil { - return fmt.Errorf("connect WebDAV: %w", err) - } - return nil -} - -func (b *webDAVBackend) key(key string) string { - return "/" + path.Join(b.basePath, strings.TrimLeft(key, "/")) -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/clickhouse.go b/backend/OpenFlare/plugins/server/infra/persistence/clickhouse.go deleted file mode 100644 index 5e7c4651..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/clickhouse.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package db 提供数据库连接与基础设施 -package db - -import ( - "context" - "log" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/ClickHouse/clickhouse-go/v2" - "github.com/ClickHouse/clickhouse-go/v2/lib/driver" -) - -const ( - clickhouseMaxExecTime = 60 // ClickHouse 最大执行时间(秒) - clickhouseReadTimeoutFactor = 2 // ReadTimeout 为 DialTimeout 的倍数 - - // async_insert 仅挂在运行时 ChConn(写路径)上,不进入 migrator OpenDB: - // 迁移/DDL 需要同步可见结果,且不应走异步 insert 缓冲。 - // - // 为何启用:batchwriter 仍可能在短间隔内写出相对小的块;服务端 async_insert - // 把多次 INSERT 合并成更大 part,减轻 3c6g 上 background merge 的 CPU 压力。 - // wait_for_async_insert=1:调用方在 flush 返回前等待落盘,避免进程崩溃丢批。 - // max_data_size / busy_timeout:约 10MB 或 ~2s 触发刷出,在延迟与 part 数之间折中。 - clickhouseAsyncInsertMaxDataSize = 10_000_000 - clickhouseAsyncInsertBusyTimeoutMs = 2000 -) - -var ( - // ChConn ClickHouse 原生连接实例,用于批量写入与查询 - ChConn driver.Conn -) - -func init() { - if !config.Config.ClickHouse.Enabled { - return - } - - cfg := config.Config.ClickHouse - if cfg.Database == "" { - log.Fatalf("[ClickHouse] database name is required (expected: openflare)\n") - } - - opts := buildClickHouseOptions() - - var err error - ChConn, err = clickhouse.Open(opts) - if err != nil { - log.Fatalf("[ClickHouse] init connection failed: %v\n", err) - } - - if err = ChConn.Ping(context.Background()); err != nil { - log.Fatalf("[ClickHouse] ping failed: %v\n", err) - } - - log.Println("[ClickHouse] connection established successfully") -} - -// buildClickHouseOptions builds the runtime native client options (queries + batch inserts). -// Migrator uses a separate clickhouse.OpenDB path without async_insert settings. -func buildClickHouseOptions() *clickhouse.Options { - cfg := config.Config.ClickHouse - - return &clickhouse.Options{ - Addr: cfg.Hosts, - Auth: clickhouse.Auth{ - Database: cfg.Database, - Username: cfg.Username, - Password: cfg.Password, - }, - Settings: clickhouse.Settings{ - "max_execution_time": clickhouseMaxExecTime, - "async_insert": 1, - "wait_for_async_insert": 1, - "async_insert_max_data_size": clickhouseAsyncInsertMaxDataSize, - "async_insert_busy_timeout_ms": clickhouseAsyncInsertBusyTimeoutMs, - }, - Compression: &clickhouse.Compression{ - Method: clickhouse.CompressionLZ4, - }, - DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, - MaxOpenConns: cfg.MaxOpenConn, - MaxIdleConns: cfg.MaxIdleConn, - ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second, - ReadTimeout: time.Duration(cfg.DialTimeout*clickhouseReadTimeoutFactor) * time.Second, - BlockBufferSize: cfg.BlockBufferSize, - } -} - -// ChConnReady reports whether the native ClickHouse connection is initialized. -func ChConnReady() bool { - return ChConn != nil -} - -// SetChConnForTest sets the package-level native ClickHouse connection for testing. -func SetChConnForTest(c driver.Conn) { - ChConn = c -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/errs.go b/backend/OpenFlare/plugins/server/infra/persistence/errs.go deleted file mode 100644 index 46aac911..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/errs.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -const ( - errRedisHashSetFailed = "failed to set redis hash: %w" - errRedisHashDeleteFailed = "failed to delete redis hash field: %w" - errUnmarshalDataFailed = "failed to unmarshal data: %w" - errMarshalDataFailed = "failed to marshal data: %w" - errRedisKeySetFailed = "failed to set redis key: %w" -) diff --git a/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake.go b/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake.go deleted file mode 100644 index 530bd253..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package idgen 提供分布式 ID 生成器 -package idgen - -import ( - "fmt" - "log" - - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/bwmarrin/snowflake" -) - -// 2025-12-01 00:00:00 UTC 的毫秒时间戳 -const epoch int64 = 1764547200000 - -const maxNegativeIDRetries = 3 - -var node *snowflake.Node - -func init() { - snowflake.Epoch = epoch - - nodeID := config.Config.App.NodeID - var err error - node, err = snowflake.NewNode(nodeID) - if err != nil { - log.Fatalf("[Snowflake] init failed: %v\n", err) - } - log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID) -} - -// NextUint64ID 生成下一个分布式唯一 ID。 -// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。 -func NextUint64ID() uint64 { - for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ { - id := node.Generate().Int64() - if id >= 0 { - return uint64(id) - } - log.Printf("[Snowflake] generated negative ID: %d (attempt %d/%d)", id, attempt, maxNegativeIDRetries) - } - panic(fmt.Sprintf("[Snowflake] generated negative ID after %d attempts", maxNegativeIDRetries)) -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake_test.go b/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake_test.go deleted file mode 100644 index d0e5cdfa..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package idgen - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNextUint64ID(t *testing.T) { - id := NextUint64ID() - assert.NotZero(t, id) -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/postgres.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres.go deleted file mode 100644 index 3629c07c..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/postgres.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -import ( - "context" - "log" - "net" - "net/url" - "strconv" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/glebarez/sqlite" - "go.opentelemetry.io/otel/attribute" - "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/plugin/dbresolver" - "gorm.io/plugin/opentelemetry/tracing" -) - -// baseTracingOptionCount 是 newGORMTracingPlugin 预置的 tracing 选项数量。 -const baseTracingOptionCount = 3 - -var ( - db *gorm.DB -) - -func init() { - if !config.Config.Database.Enabled { - // PostgreSQL 禁用,使用 SQLite - initSQLite() - return - } - - initPostgres() -} - -// initSQLite 初始化 SQLite 数据库(PostgreSQL 禁用时的后备方案) -func initSQLite() { - sqlitePath := config.Config.Database.SQLitePath - if sqlitePath == "" { - sqlitePath = "./data/openflare.db" - } - - var err error - db, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - Logger: &gormZapLogger{ - logLevel: parseLogLevel(config.Config.Database.LogLevel), - slowThreshold: config.Config.Database.SlowThreshold, - ignoreRecordNotFoundError: config.Config.App.IsProduction(), - }, - }) - if err != nil { - log.Fatalf("[SQLite] init connection failed: %v\n", err) - } - - // Trace 注入 - if err = db.Use( - newGORMTracingPlugin([]attribute.KeyValue{ - attribute.String("db.instance", sqlitePath), - attribute.String("db.system", "SQLite"), - }), - ); err != nil { - log.Fatalf("[SQLite] init trace failed: %v\n", err) - } - - log.Printf("[SQLite] initialized (path: %s)\n", sqlitePath) -} - -// initPostgres 初始化 PostgreSQL 数据库 -func initPostgres() { - var err error - dbConfig := config.Config.Database - - // 构建主库 DSN 并连接 - primaryDSN := buildDSN(dbConfig.Host, dbConfig.Port, dbConfig.Username, dbConfig.Password) - - pgConfig := postgres.Config{ - DSN: primaryDSN, - PreferSimpleProtocol: dbConfig.PreferSimpleProtocol, - } - - db, err = gorm.Open(postgres.New(pgConfig), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - Logger: &gormZapLogger{ - logLevel: parseLogLevel(config.Config.Database.LogLevel), - slowThreshold: config.Config.Database.SlowThreshold, - ignoreRecordNotFoundError: config.Config.App.IsProduction(), - }, - }) - if err != nil { - log.Fatalf("[PostgreSQL] init connection failed: %v\n", err) - } - - // Trace 注入 - if err = db.Use( - newGORMTracingPlugin([]attribute.KeyValue{ - attribute.String("db.instance", dbConfig.Database), - attribute.String("db.ip", dbConfig.Host), - attribute.String("server.address", net.JoinHostPort(dbConfig.Host, strconv.Itoa(dbConfig.Port))), - attribute.String("db.system", "PostgreSQL"), - }), - ); err != nil { - log.Fatalf("[PostgreSQL] init trace failed: %v\n", err) - } - - if len(dbConfig.Replicas) > 0 { - var replicaDialectors = make([]gorm.Dialector, 0, len(dbConfig.Replicas)) - for _, replica := range dbConfig.Replicas { - username := replica.Username - if username == "" { - username = dbConfig.Username - } - password := replica.Password - if password == "" { - password = dbConfig.Password - } - replicaDSN := buildDSN(replica.Host, replica.Port, username, password) - replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{ - DSN: replicaDSN, - PreferSimpleProtocol: dbConfig.PreferSimpleProtocol, - })) - } - - resolver := dbresolver.Register(dbresolver.Config{ - Replicas: replicaDialectors, - Policy: dbresolver.RandomPolicy{}, - }) - - resolver.SetMaxIdleConns(dbConfig.MaxIdleConn). - SetMaxOpenConns(dbConfig.MaxOpenConn). - SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second). - SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second) - - if err = db.Use(resolver); err != nil { - log.Fatalf("[PostgreSQL] init dbresolver failed: %v\n", err) - } - log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(dbConfig.Replicas)) - } else { - log.Println("[PostgreSQL] initialized in Standalone mode") - } - - // 获取通用数据库对象设置连接池 - sqlDB, err := db.DB() - if err != nil { - log.Fatalf("[PostgreSQL] load sql db failed: %v\n", err) - } - - sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConn) - sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConn) - sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second) - sqlDB.SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second) - -} - -// newGORMTracingPlugin 构造数据库链路追踪插件。查询参数只保留占位符,避免凭据等绑定值进入 Span。 -func newGORMTracingPlugin(attrs []attribute.KeyValue, extraOptions ...tracing.Option) gorm.Plugin { - options := make([]tracing.Option, 0, baseTracingOptionCount+len(extraOptions)) - options = append(options, - tracing.WithoutMetrics(), - tracing.WithoutQueryVariables(), - tracing.WithAttributes(attrs...), - ) - options = append(options, extraOptions...) - return tracing.NewPlugin(options...) -} - -// buildDSN 构建 PostgreSQL DSN -func buildDSN(host string, port int, username, password string) string { - cfg := config.Config.Database - pqURL := &url.URL{ - Scheme: "postgres", - Host: net.JoinHostPort(host, strconv.Itoa(port)), - Path: cfg.Database, - } - if username != "" { - pqURL.User = url.UserPassword(username, password) - } - - query := pqURL.Query() - sslMode := cfg.SSLMode - if sslMode == "" { - sslMode = "disable" - } - query.Set("sslmode", sslMode) - if cfg.ApplicationName != "" { - query.Set("application_name", cfg.ApplicationName) - } - if cfg.SearchPath != "" { - query.Set("search_path", cfg.SearchPath) - } - if cfg.DefaultQueryExecMode != "" { - query.Set("default_query_exec_mode", cfg.DefaultQueryExecMode) - } - if cfg.StatementCacheCapacity > 0 { - query.Set("statement_cache_capacity", strconv.Itoa(cfg.StatementCacheCapacity)) - } - - rawQuery := query.Encode() - if cfg.TimeZone != "" { - if rawQuery != "" { - rawQuery += "&" - } - rawQuery += "TimeZone=" + cfg.TimeZone - } - pqURL.RawQuery = rawQuery - - return pqURL.String() -} - -// DB 返回带上下文追踪的 GORM 数据库实例 -func DB(ctx context.Context) *gorm.DB { - if db == nil { - return nil - } - return db.WithContext(ctx) -} - -// SetDB sets the package-level database instance for testing. -func SetDB(d *gorm.DB) { - db = d -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger.go deleted file mode 100644 index 2c8370ef..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "Wavelet/pkg/logger" - - "gorm.io/gorm" - gormLogger "gorm.io/gorm/logger" -) - -// nanoToMilli 纳秒转毫秒的除数 -const nanoToMilli = 1e6 - -type gormZapLogger struct { - logLevel gormLogger.LogLevel - ignoreRecordNotFoundError bool - slowThreshold time.Duration -} - -func (l *gormZapLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface { - clone := *l - clone.logLevel = level - return &clone -} - -func (l *gormZapLogger) Info(ctx context.Context, fmt string, args ...any) { - if l.logLevel >= gormLogger.Info { - logger.InfoF(ctx, fmt, args...) - } -} - -func (l *gormZapLogger) Warn(ctx context.Context, fmt string, args ...any) { - if l.logLevel >= gormLogger.Warn { - logger.WarnF(ctx, fmt, args...) - } -} - -func (l *gormZapLogger) Error(ctx context.Context, fmt string, args ...any) { - if l.logLevel >= gormLogger.Error { - logger.ErrorF(ctx, fmt, args...) - } -} - -// ParamsFilter 让 GORM 的 Trace 回调只接收参数化 SQL,避免绑定值被 Dialector.Explain 展开到日志。 -func (l *gormZapLogger) ParamsFilter(_ context.Context, sql string, _ ...any) (string, []any) { - return sql, nil -} - -func (l *gormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { - elapsed := time.Since(begin) - switch { - case err != nil && l.logLevel >= gormLogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.ignoreRecordNotFoundError): - _, rows := fc() - logger.ErrorF(ctx, "database query failed: %s [%.3fms] [rows:%v]", err, float64(elapsed.Nanoseconds())/nanoToMilli, formatRows(rows)) - case elapsed > l.slowThreshold && l.slowThreshold != 0 && l.logLevel >= gormLogger.Warn: - _, rows := fc() - slowLog := fmt.Sprintf("SLOW SQL >= %v", l.slowThreshold) - logger.WarnF(ctx, "%s [%.3fms] [rows:%v]", slowLog, float64(elapsed.Nanoseconds())/nanoToMilli, formatRows(rows)) - case l.logLevel == gormLogger.Info: - sql, rows := fc() - logger.DebugF(ctx, "[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/nanoToMilli, formatRows(rows), sql) - } -} - -func formatRows(rows int64) any { - if rows == -1 { - return "-" - } - return rows -} - -func parseLogLevel(level string) gormLogger.LogLevel { - level = strings.ToLower(level) - switch level { - case "silent": - return gormLogger.Silent - case "error": - return gormLogger.Error - case "warn": - return gormLogger.Warn - case "info": - return gormLogger.Info - case "debug": - return gormLogger.Info - default: - return gormLogger.Info - } -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger_test.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger_test.go deleted file mode 100644 index 0d16dc4a..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - gormLogger "gorm.io/gorm/logger" -) - -type paramsFilterCaptureLogger struct { - filter *gormZapLogger - traces []string -} - -func (l *paramsFilterCaptureLogger) LogMode(gormLogger.LogLevel) gormLogger.Interface { - return l -} - -func (l *paramsFilterCaptureLogger) Info(context.Context, string, ...interface{}) {} - -func (l *paramsFilterCaptureLogger) Warn(context.Context, string, ...interface{}) {} - -func (l *paramsFilterCaptureLogger) Error(context.Context, string, ...interface{}) {} - -func (l *paramsFilterCaptureLogger) ParamsFilter(ctx context.Context, sql string, params ...interface{}) (string, []interface{}) { - return l.filter.ParamsFilter(ctx, sql, params...) -} - -func (l *paramsFilterCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) { - sql, _ := fc() - l.traces = append(l.traces, sql) -} - -func TestParseLogLevel(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - configuredLevel string - want gormLogger.LogLevel - }{ - { - name: "debug enables SQL trace processing", - configuredLevel: "debug", - want: gormLogger.Info, - }, - { - name: "development preserves configured level", - configuredLevel: "warn", - want: gormLogger.Warn, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := parseLogLevel(tt.configuredLevel); got != tt.want { - t.Fatalf("parseLogLevel() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestGormZapLoggerParamsFilterDropsBoundValues(t *testing.T) { - t.Parallel() - - const ( - query = "UPDATE openflare_pages_sources SET remote_url = ? WHERE id = ?" - secret = "https://example.test/release.zip?token=super-secret" - ) - - filteredSQL, filteredParams := (&gormZapLogger{}).ParamsFilter(t.Context(), query, secret, int64(42)) - if filteredSQL != query { - t.Fatalf("ParamsFilter() sql = %q, want %q", filteredSQL, query) - } - if filteredParams != nil { - t.Fatalf("ParamsFilter() params = %#v, want nil", filteredParams) - } -} - -func TestGormZapLoggerKeepsParameterizedSQLInTrace(t *testing.T) { - t.Parallel() - - const secret = "https://example.test/release.zip?token=trace-secret" - capture := ¶msFilterCaptureLogger{filter: &gormZapLogger{}} - testDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: capture}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := testDB.Exec("CREATE TABLE source_secrets (remote_url TEXT NOT NULL)").Error; err != nil { - t.Fatalf("create table: %v", err) - } - - capture.traces = nil - if err := testDB.Exec("INSERT INTO source_secrets (remote_url) VALUES (?)", secret).Error; err != nil { - t.Fatalf("insert source secret: %v", err) - } - if len(capture.traces) != 1 { - t.Fatalf("trace count = %d, want 1", len(capture.traces)) - } - - traceSQL := capture.traces[0] - if strings.Contains(traceSQL, secret) || strings.Contains(traceSQL, "trace-secret") { - t.Fatalf("trace SQL leaked bound value: %q", traceSQL) - } - if !strings.Contains(traceSQL, "VALUES (?)") { - t.Fatalf("trace SQL = %q, want parameter placeholder", traceSQL) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/postgres_tracing_test.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres_tracing_test.go deleted file mode 100644 index 6958521f..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/postgres_tracing_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -import ( - "context" - "strings" - "testing" - - "github.com/glebarez/sqlite" - "go.opentelemetry.io/otel/attribute" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/sdk/trace/tracetest" - semconv "go.opentelemetry.io/otel/semconv/v1.30.0" - "gorm.io/gorm" - gormLogger "gorm.io/gorm/logger" - "gorm.io/plugin/opentelemetry/tracing" -) - -func TestGORMTracingPluginDoesNotRecordQueryVariables(t *testing.T) { - t.Parallel() - - const secret = "https://example.test/release.zip?token=otel-secret" - spanRecorder := tracetest.NewSpanRecorder() - tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) - t.Cleanup(func() { - if err := tracerProvider.Shutdown(context.Background()); err != nil { - t.Errorf("shutdown tracer provider: %v", err) - } - }) - - testDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: gormLogger.Default.LogMode(gormLogger.Silent), - }) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := testDB.Use(newGORMTracingPlugin( - []attribute.KeyValue{attribute.String("db.instance", "trace-test")}, - tracing.WithTracerProvider(tracerProvider), - )); err != nil { - t.Fatalf("register tracing plugin: %v", err) - } - if err := testDB.Exec("CREATE TABLE source_secrets (remote_url TEXT NOT NULL)").Error; err != nil { - t.Fatalf("create table: %v", err) - } - if err := testDB.Exec("INSERT INTO source_secrets (remote_url) VALUES (?)", secret).Error; err != nil { - t.Fatalf("insert source secret: %v", err) - } - - var queryText string - for _, span := range spanRecorder.Ended() { - for _, attr := range span.Attributes() { - if attr.Key == semconv.DBQueryTextKey && strings.Contains(attr.Value.AsString(), "INSERT INTO source_secrets") { - queryText = attr.Value.AsString() - } - } - } - if queryText == "" { - t.Fatal("database query text attribute not found") - } - if strings.Contains(queryText, secret) || strings.Contains(queryText, "otel-secret") { - t.Fatalf("db.query.text leaked bound value: %q", queryText) - } - if !strings.Contains(queryText, "VALUES (?)") { - t.Fatalf("db.query.text = %q, want parameter placeholder", queryText) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/redis.go b/backend/OpenFlare/plugins/server/infra/persistence/redis.go deleted file mode 100644 index c7dcdb05..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/redis.go +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -import ( - "context" - "encoding/json" - "fmt" - "log" - "strconv" - "strings" - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/redis/go-redis/extra/redisotel/v9" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" - "go.opentelemetry.io/otel/attribute" -) - -var ( - // Redis 全局 Redis 客户端实例 - Redis redis.UniversalClient -) - -func init() { - cfg := config.Config.Redis - - if !cfg.Enabled { - log.Println("[Redis] is disabled, skipping Redis initialization") - return - } - - if cfg.ClusterMode { - // Cluster 模式 - Redis = redis.NewClusterClient(&redis.ClusterOptions{ - Addrs: cfg.Addrs, - Username: cfg.Username, - Password: cfg.Password, - PoolSize: cfg.PoolSize, - MinIdleConns: cfg.MinIdleConn, - DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, - ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, - WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, - MaxRetries: cfg.MaxRetries, - PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, - ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, - MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications), - }) - log.Println("[Redis] initialized in Cluster mode") - } else { - // Standalone 或 Sentinel 模式 - options := &redis.UniversalOptions{ - Addrs: cfg.Addrs, - MasterName: cfg.MasterName, // 非空时启用 Sentinel - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - PoolSize: cfg.PoolSize, - MinIdleConns: cfg.MinIdleConn, - DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, - ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, - WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, - MaxRetries: cfg.MaxRetries, - PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, - ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, - MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications), - } - if cfg.MasterName != "" { - client := redis.NewFailoverClient(options.Failover()) - // FailoverOptions 暂不暴露该配置,在首次建连前写入客户端选项。 - client.Options().MaintNotificationsConfig = redisMaintNotificationsConfig(cfg.MaintNotifications) - Redis = client - log.Println("[Redis] initialized in Sentinel mode") - } else { - Redis = redis.NewUniversalClient(options) - log.Println("[Redis] initialized in Standalone mode") - } - } - - // OpenTelemetry 追踪(UniversalClient 兼容) - if err := redisotel.InstrumentTracing( - Redis, - redisotel.WithAttributes( - attribute.String("db.instance", strconv.Itoa(cfg.DB)), - attribute.String("db.ip", strings.Join(cfg.Addrs, ",")), - attribute.String("db.system", "Redis"), - ), - ); err != nil { - log.Fatalf("[Redis] failed to init trace: %v\n", err) - } - - // 测试连接 - _, err := Redis.Ping(context.Background()).Result() - if err != nil { - log.Fatalf("[Redis] failed to connect to redis: %v\n", err) - } -} - -func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config { - mode := maintnotifications.ModeDisabled - if enabled { - mode = maintnotifications.ModeAuto - } - return &maintnotifications.Config{Mode: mode} -} - -// PrefixedKey 返回带前缀的 Key -func PrefixedKey(key string) string { - prefix := config.Config.Redis.KeyPrefix - if prefix == "" { - return key - } - return prefix + key -} - -// HSetJSON 将泛型数据序列化为 JSON 并设置到 Redis Hash -// ctx: 上下文 -// hashKey: Redis Hash key -// fieldKey: Hash field key -// data: 要存储的数据(泛型) -func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) error { - jsonData, err := json.Marshal(data) - if err != nil { - return err - } - - if err := Redis.HSet(ctx, PrefixedKey(hashKey), fieldKey, jsonData).Err(); err != nil { - return fmt.Errorf(errRedisHashSetFailed, err) - } - - return nil -} - -// HDel removes one or more fields from a Redis Hash. -func HDel(ctx context.Context, hashKey string, fieldKeys ...string) error { - if Redis == nil || len(fieldKeys) == 0 { - return nil - } - if err := Redis.HDel(ctx, PrefixedKey(hashKey), fieldKeys...).Err(); err != nil { - return fmt.Errorf(errRedisHashDeleteFailed, err) - } - return nil -} - -// HGetJSON 从 Redis Hash 获取数据并反序列化为泛型类型 -// ctx: 上下文 -// hashKey: Redis Hash key -// fieldKey: Hash field key -// data: 用于接收数据的指针(泛型) -func HGetJSON[T any](ctx context.Context, hashKey, fieldKey string, data *T) error { - val, err := Redis.HGet(ctx, PrefixedKey(hashKey), fieldKey).Result() - if err != nil { - return err - } - - if err := json.Unmarshal([]byte(val), data); err != nil { - return fmt.Errorf(errUnmarshalDataFailed, err) - } - - return nil -} - -// GetJSON 从Redis获取数据并反序列化为泛型类型 -// ctx: 上下文 -// key: Redis key -// data: 用于接收数据的指针(泛型) -func GetJSON[T any](ctx context.Context, key string, data *T) error { - val, err := Redis.Get(ctx, PrefixedKey(key)).Bytes() - if err != nil { - return err - } - - if err := json.Unmarshal(val, data); err != nil { - return fmt.Errorf(errUnmarshalDataFailed, err) - } - - return nil -} - -// SetJSON 将泛型数据序列化为JSON并设置到Redis -// ctx: 上下文 -// key: Redis key -// data: 要存储的数据(泛型) -// expiration: 过期时间 -func SetJSON[T any](ctx context.Context, key string, data T, expiration time.Duration) error { - jsonData, err := json.Marshal(data) - if err != nil { - return fmt.Errorf(errMarshalDataFailed, err) - } - - if err := Redis.Set(ctx, PrefixedKey(key), jsonData, expiration).Err(); err != nil { - return fmt.Errorf(errRedisKeySetFailed, err) - } - - return nil -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/redis_test.go b/backend/OpenFlare/plugins/server/infra/persistence/redis_test.go deleted file mode 100644 index 55ec55e2..00000000 --- a/backend/OpenFlare/plugins/server/infra/persistence/redis_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package db - -import ( - "testing" - - "github.com/redis/go-redis/v9/maintnotifications" -) - -func TestRedisMaintNotificationsConfig(t *testing.T) { - for _, test := range []struct { - name string - enabled bool - want maintnotifications.Mode - }{ - {name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled}, - {name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto}, - } { - t.Run(test.name, func(t *testing.T) { - cfg := redisMaintNotificationsConfig(test.enabled) - if cfg.Mode != test.want { - t.Fatalf("maintenance notifications mode = %v, want %v", cfg.Mode, test.want) - } - }) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/task/constants.go b/backend/OpenFlare/plugins/server/infra/task/constants.go deleted file mode 100644 index 4ad07259..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/constants.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package task 定义异步任务类型与调度常量 -package task - -// 任务队列名称 -const ( - QueueDefault = "default" -) - -// DefaultMaxRetry 任务默认最大重试次数 -const DefaultMaxRetry = 3 diff --git a/backend/OpenFlare/plugins/server/infra/task/errs.go b/backend/OpenFlare/plugins/server/infra/task/errs.go deleted file mode 100644 index 6e020ef3..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/errs.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -const ( - errUnknownTaskType = "未知的任务类型: %s" - errCreateTaskExecutionFailed = "创建任务执行记录失败: %w" - errTaskEnqueueFailed = "任务入队失败: %w" - errTaskExecutionNotFound = "任务执行记录不存在: %w" - errRetryOnlyFailedTask = "只有失败的任务才能重试,当前状态: %s" - errTaskNotRetryable = "该任务不支持重试" - errTaskMaxRetryExceeded = "已达到最大重试次数 %d" - errCreateRetryExecutionFailed = "创建重试任务执行记录失败: %w" - errRetryTaskEnqueueFailed = "重试任务入队失败: %w" - errUnregisteredTaskHandler = "未注册的任务处理器: %s" -) diff --git a/backend/OpenFlare/plugins/server/infra/task/executor.go b/backend/OpenFlare/plugins/server/infra/task/executor.go deleted file mode 100644 index 1f530c76..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/executor.go +++ /dev/null @@ -1,486 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "Wavelet/OpenFlare/plugins/server/repository" - - "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/logger" - otel_trace "Wavelet/pkg/trace" - - "github.com/hibiken/asynq" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -// handlerRegistry 已注册的任务处理器 -var handlerRegistry = make(map[string]TaskHandler) - -// CompletedHandler is called when a task execution completes. -type CompletedHandler func(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) - -var taskCompletedHandlers []CompletedHandler - -// OnTaskCompleted registers a handler for task completion events. -// Handlers must be registered during application bootstrap before processing tasks. -func OnTaskCompleted(handler CompletedHandler) { - taskCompletedHandlers = append(taskCompletedHandlers, handler) -} - -// RegisterHandler 注册任务处理器 -// 传入任务类型标识(对应 constants.go 中的 AsynqTask 常量)和 TaskHandler 实现 -func RegisterHandler(asynqTaskType string, handler TaskHandler) { - handlerRegistry[asynqTaskType] = handler -} - -// getHandler 获取已注册的处理器 -func getHandler(asynqTaskType string) (TaskHandler, bool) { - h, ok := handlerRegistry[asynqTaskType] - return h, ok -} - -// ValidateAndNormalizePayload 校验并标准化任务参数。 -// 如果 Handler 实现了 PayloadValidator,调用其 ValidatePayload 方法; -// 否则直接返回原始 payload。 -func ValidateAndNormalizePayload(asynqTaskType string, payload []byte) ([]byte, error) { - handler, ok := getHandler(asynqTaskType) - if !ok { - return payload, nil - } - if validator, ok := handler.(PayloadValidator); ok { - return validator.ValidatePayload(payload) - } - return payload, nil -} - -// contextKey 用于 context 存取 taskID -type contextKey string - -const taskIDKey contextKey = "task_execution_task_id" -const traceEnvelopeVersion = 1 - -type traceEnvelope struct { - WaveletTraceEnvelope bool `json:"_wavelet_trace_envelope"` - Version int `json:"version"` - TraceContext map[string]string `json:"trace_context,omitempty"` - Payload []byte `json:"payload"` -} - -// withTaskID 将 taskID 注入 context -func withTaskID(ctx context.Context, taskID string) context.Context { - return context.WithValue(ctx, taskIDKey, taskID) -} - -// GetTaskID 从 context 中获取 taskID -func GetTaskID(ctx context.Context) string { - if v, ok := ctx.Value(taskIDKey).(string); ok { - return v - } - return "" -} - -// IsFinalAttempt 判断当前任务执行是否为最后一次重试尝试(若再次失败即为最终失败) -func IsFinalAttempt(ctx context.Context) bool { - retryCount, hasRetryCount := asynq.GetRetryCount(ctx) - maxRetry, hasMaxRetry := asynq.GetMaxRetry(ctx) - if !hasRetryCount || !hasMaxRetry { - return true - } - return retryCount >= maxRetry -} - -// AppendLog 追加日志到任务执行记录 -// 在 TaskHandler.Execute 中调用,日志会自动追加到 TaskExecution.Log 字段 -func AppendLog(ctx context.Context, format string, args ...any) { - taskID := GetTaskID(ctx) - if taskID == "" { - // 上下文中没有 taskID,降级到普通日志 - logger.InfoF(ctx, format, args...) - return - } - - logLine := fmt.Sprintf(format, args...) - if err := repository.AppendTaskExecutionLog(ctx, taskID, logLine); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 追加任务日志失败 taskID=%s: %v", taskID, err) - } -} - -// DispatchTask 下发任务(创建 TaskExecution 记录 → 入队 Asynq) -func DispatchTask(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { - meta := GetTaskMeta(taskType) - if meta == nil { - return "", fmt.Errorf(errUnknownTaskType, taskType) - } - - // 生成唯一的 TaskID - taskID := generateTaskID(taskType, triggeredBy) - - // 创建任务执行记录 - execution := &model.TaskExecution{ - TaskID: taskID, - TaskType: meta.AsynqTask, - TaskName: meta.Name, - Status: model.TaskExecutionStatusPending, - Retryable: meta.Retryable, - MaxRetry: meta.MaxRetry, - RetryCount: 0, - Payload: string(payload), - TriggeredBy: triggeredBy, - } - - if err := repository.CreateTaskExecution(ctx, execution); err != nil { - return "", fmt.Errorf(errCreateTaskExecutionFailed, err) - } - - // 入队 Asynq - taskInfo := asynq.NewTask(meta.AsynqTask, injectTaskTraceContext(ctx, payload)) - if _, err := AsynqClient.Enqueue( - taskInfo, - asynq.TaskID(taskID), - asynq.MaxRetry(meta.MaxRetry), - asynq.Queue(meta.Queue), - ); err != nil { - // 入队失败,更新执行记录状态 - execution.Status = model.TaskExecutionStatusFailed - execution.ErrorMessage = fmt.Sprintf("入队失败: %v", err) - now := time.Now() - execution.StartedAt = &now - execution.FinishedAt = &now - _ = repository.UpdateTaskExecution(ctx, execution) - return "", fmt.Errorf(errTaskEnqueueFailed, err) - } - - if err := repository.AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 追加入队日志失败 taskID=%s: %v", taskID, err) - } - - return taskID, nil -} - -// RetryTask 重试失败的任务 -func RetryTask(ctx context.Context, id uint64) (string, error) { - execution, err := repository.GetTaskExecutionByID(ctx, id) - if err != nil { - return "", fmt.Errorf(errTaskExecutionNotFound, err) - } - - if execution.Status != model.TaskExecutionStatusFailed { - return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status) - } - - if !execution.Retryable { - return "", errors.New(errTaskNotRetryable) - } - - // 生成新的 TaskID - newTaskID := generateRetryTaskID(execution.TaskID, execution.RetryCount+1) - - // 创建新的执行记录 - newExecution := &model.TaskExecution{ - TaskID: newTaskID, - TaskType: execution.TaskType, - TaskName: execution.TaskName, - Status: model.TaskExecutionStatusPending, - Retryable: execution.Retryable, - MaxRetry: execution.MaxRetry, - RetryCount: execution.RetryCount + 1, - Payload: execution.Payload, - TriggeredBy: "retry", - } - - if err := repository.CreateTaskExecution(ctx, newExecution); err != nil { - return "", fmt.Errorf(errCreateRetryExecutionFailed, err) - } - - meta := GetTaskMeta(execution.TaskType) - queueName := QueueDefault - if meta != nil { - queueName = meta.Queue - } - - // 入队 Asynq - taskInfo := asynq.NewTask(execution.TaskType, injectTaskTraceContext(ctx, []byte(execution.Payload))) - if _, err := AsynqClient.Enqueue( - taskInfo, - asynq.TaskID(newTaskID), - asynq.MaxRetry(execution.MaxRetry), - asynq.Queue(queueName), - ); err != nil { - newExecution.Status = model.TaskExecutionStatusFailed - newExecution.ErrorMessage = fmt.Sprintf("重试入队失败: %v", err) - now := time.Now() - newExecution.StartedAt = &now - newExecution.FinishedAt = &now - _ = repository.UpdateTaskExecution(ctx, newExecution) - return "", fmt.Errorf(errRetryTaskEnqueueFailed, err) - } - - if err := repository.AppendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 追加重试日志失败 taskID=%s: %v", newTaskID, err) - } - - return newTaskID, nil -} - -// ProcessTask Asynq 实际调用的统一处理函数 -// Worker 注册时统一使用此函数,内部自动分发到对应的 TaskHandler -func ProcessTask(ctx context.Context, t *asynq.Task) error { - taskPayload := t.Payload() - ctx, taskPayload, hasRemoteTraceContext := extractTaskTraceContext(ctx, taskPayload) - - // 初始化 Trace - ctx, span := otel_trace.Start(ctx, "TaskProcess_"+t.Type(), trace.WithSpanKind(trace.SpanKindConsumer)) - defer span.End() - - // 添加任务信息到 Span - span.SetAttributes( - attribute.String("task.type", t.Type()), - attribute.Int("task.payload_size", len(taskPayload)), - attribute.Bool("task.trace_context_propagated", hasRemoteTraceContext), - attribute.String("task.id", t.ResultWriter().TaskID()), - ) - - taskID := t.ResultWriter().TaskID() - - // 注入 taskID 到 context - ctx = withTaskID(ctx, taskID) - - // 查找处理器 - handler, ok := getHandler(t.Type()) - if !ok { - err := fmt.Errorf(errUnregisteredTaskHandler, t.Type()) - logger.ErrorF(ctx, "[TaskExecutor] %v", err) - span.SetStatus(codes.Error, err.Error()) - return err - } - - // 加载或动态创建执行记录 - now := time.Now() - execution, err := getOrCreateTaskExecution(ctx, taskID, t, taskPayload, now) - if err == nil { - updateExecutionOnStart(ctx, execution, now) - } - - if execution != nil { - AppendLog(ctx, "[系统] 开始执行异步任务 [名称: %s, 类型: %s],重试次数: %d/%d", - execution.TaskName, t.Type(), execution.RetryCount, execution.MaxRetry) - } else { - AppendLog(ctx, "[系统] 开始执行异步任务 [类型: %s]", t.Type()) - } - - // 开始计时 - start := time.Now() - - // 执行业务逻辑 - result, execErr := handler.Execute(ctx, taskPayload) - - // 计算耗时并归档记录 - duration := time.Since(start) - finishTime := time.Now() - - completeTaskExecution(ctx, execution, t, duration, finishTime, result, execErr, span) - - if execution == nil && execErr != nil { - span.SetStatus(codes.Error, execErr.Error()) - return execErr - } - - return execErr -} - -func injectTaskTraceContext(ctx context.Context, payload []byte) []byte { - carrier := propagation.MapCarrier{} - otel.GetTextMapPropagator().Inject(ctx, carrier) - if len(carrier) == 0 { - return payload - } - - envelope := traceEnvelope{ - WaveletTraceEnvelope: true, - Version: traceEnvelopeVersion, - TraceContext: map[string]string(carrier), - Payload: payload, - } - data, err := json.Marshal(envelope) - if err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 序列化任务 Trace 上下文失败: %v", err) - return payload - } - return data -} - -func extractTaskTraceContext(ctx context.Context, payload []byte) (context.Context, []byte, bool) { - var envelope traceEnvelope - if err := json.Unmarshal(payload, &envelope); err != nil { - return ctx, payload, false - } - if !envelope.WaveletTraceEnvelope || envelope.Version != traceEnvelopeVersion { - return ctx, payload, false - } - if len(envelope.TraceContext) == 0 { - return ctx, envelope.Payload, false - } - - extractedCtx := otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(envelope.TraceContext)) - return extractedCtx, envelope.Payload, true -} - -func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution, now time.Time) { - if execution == nil { - return - } - dirty := false - if retryCount, hasRetry := asynq.GetRetryCount(ctx); hasRetry && execution.RetryCount != retryCount { - execution.RetryCount = retryCount - dirty = true - } - if execution.Status != model.TaskExecutionStatusRunning { - execution.Status = model.TaskExecutionStatusRunning - execution.StartedAt = &now - dirty = true - } - if dirty { - if updateErr := repository.UpdateTaskExecution(ctx, execution); updateErr != nil { - logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", execution.TaskID, updateErr) - } - } -} - -// getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录 -func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, payload []byte, now time.Time) (*model.TaskExecution, error) { - execution, err := repository.GetTaskExecutionByTaskID(ctx, taskID) - if err == nil { - return execution, nil - } - - meta := GetTaskMetaByAsynqTask(t.Type()) - if meta == nil { - return nil, err - } - - execution = &model.TaskExecution{ - TaskID: taskID, - TaskType: meta.AsynqTask, - TaskName: meta.Name, - Status: model.TaskExecutionStatusRunning, - Retryable: meta.Retryable, - MaxRetry: meta.MaxRetry, - RetryCount: 0, - Payload: string(payload), - TriggeredBy: "schedule", - StartedAt: &now, - } - - if createErr := repository.CreateTaskExecution(ctx, execution); createErr != nil { - logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr) - return nil, createErr - } - - return execution, nil -} - -// completeTaskExecution 完成并更新任务执行记录的状态和执行结果 -func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, finishTime time.Time, result *TaskResult, execErr error, span trace.Span) { - if execution == nil { - return - } - - execution.Duration = duration.Milliseconds() - execution.FinishedAt = &finishTime - - if execErr != nil { - handleFailedTask(ctx, execution, t, duration, execErr, span) - } else { - handleSuccessfulTask(ctx, execution, t, duration, result) - } - - if err := repository.UpdateTaskExecution(ctx, execution); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err) - } - if shouldFlushTaskExecutionLog(ctx, execErr) { - if err := repository.FlushTaskExecutionLog(ctx, execution.TaskID); err != nil { - logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err) - } - } - - notifyTaskCompleted(ctx, execution, result, execErr) -} - -func notifyTaskCompleted(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) { - if len(taskCompletedHandlers) == 0 { - return - } - - asyncCtx := context.WithoutCancel(ctx) - for _, handler := range taskCompletedHandlers { - go handler(asyncCtx, execution, result, execErr) - } -} - -func shouldFlushTaskExecutionLog(ctx context.Context, execErr error) bool { - if isTerminalTaskExecutionError(execErr) { - return true - } - - retryCount, hasRetryCount := asynq.GetRetryCount(ctx) - maxRetry, hasMaxRetry := asynq.GetMaxRetry(ctx) - if !hasRetryCount || !hasMaxRetry { - return true - } - return retryCount >= maxRetry -} - -func isTerminalTaskExecutionError(execErr error) bool { - return execErr == nil || errors.Is(execErr, asynq.SkipRetry) -} - -func handleFailedTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, execErr error, span trace.Span) { - execution.Status = model.TaskExecutionStatusFailed - execution.ErrorMessage = execErr.Error() - logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr) - span.SetStatus(codes.Error, execErr.Error()) - span.RecordError(execErr) - - AppendLog(ctx, "[系统] 任务执行失败,耗时: %d ms,错误原因: %v", duration.Milliseconds(), execErr) -} - -func handleSuccessfulTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, result *TaskResult) { - execution.Status = model.TaskExecutionStatusSucceeded - execution.ErrorMessage = "" // 清除历史重试失败遗留的错误信息 - if result != nil { - execution.Result = result.Message - if result.Detail != "" { - execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail) - } - } - logger.InfoF(ctx, "[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds()) - - resultMsg := "成功" - if result != nil { - resultMsg = result.Message - } - AppendLog(ctx, "[系统] 任务执行成功,耗时: %d ms,执行结果: %s", duration.Milliseconds(), resultMsg) -} - -// generateTaskID 生成任务 ID -func generateTaskID(taskType string, triggeredBy string) string { - return fmt.Sprintf("%s_%s_%d", triggeredBy, taskType, idgen.NextUint64ID()) -} - -// generateRetryTaskID 生成重试任务 ID -func generateRetryTaskID(originalTaskID string, retryCount int) string { - return fmt.Sprintf("retry_%d_%s", retryCount, originalTaskID) -} diff --git a/backend/OpenFlare/plugins/server/infra/task/executor_test.go b/backend/OpenFlare/plugins/server/infra/task/executor_test.go deleted file mode 100644 index c89e1f8d..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/executor_test.go +++ /dev/null @@ -1,456 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "context" - "errors" - "fmt" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/repository" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/testhelper" - - "github.com/hibiken/asynq" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" -) - -// mockHandler 用于测试的模拟任务处理器 -type mockHandler struct { - executeFunc func(ctx context.Context, payload []byte) (*TaskResult, error) -} - -func (h *mockHandler) Execute(ctx context.Context, payload []byte) (*TaskResult, error) { - if h.executeFunc != nil { - return h.executeFunc(ctx, payload) - } - return &TaskResult{Message: "mock success"}, nil -} - -// successHandler 返回成功的处理器 -func successHandler() *mockHandler { - return &mockHandler{ - executeFunc: func(ctx context.Context, payload []byte) (*TaskResult, error) { - AppendLog(ctx, "执行成功,处理了 %d 条数据", 100) - return &TaskResult{Message: "处理完成,共 100 条"}, nil - }, - } -} - -// failHandler 返回失败的处理器 -func failHandler() *mockHandler { - return &mockHandler{ - executeFunc: func(ctx context.Context, payload []byte) (*TaskResult, error) { - AppendLog(ctx, "开始执行任务") - return nil, fmt.Errorf("模拟执行失败: 数据库连接超时") - }, - } -} - -const testTaskType = "test:mock_task" - -func setupTest(t *testing.T) func() { - t.Helper() - _, mr, cleanup := testhelper.SetupTestEnvironment(t) - AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ - Addr: mr.Addr(), - }) - // 注册测试用 handler - RegisterHandler(testTaskType, successHandler()) - return func() { - if AsynqClient != nil { - _ = AsynqClient.Close() - AsynqClient = nil - } - cleanup() - } -} - -func TestRegisterAndGetHandler(t *testing.T) { - _ = testTaskType - cleanup := setupTest(t) - defer cleanup() - - // 验证 handler 已注册 - h, ok := getHandler(testTaskType) - assert.True(t, ok, "handler should be registered") - assert.NotNil(t, h) - - // 未注册的 handler - _, ok = getHandler("nonexistent") - assert.False(t, ok, "non-existent handler should return false") -} - -func TestGetTaskIDFromContext(t *testing.T) { - ctx := context.Background() - - // 空 context - taskID := GetTaskID(ctx) - assert.Empty(t, taskID) - - // 注入 taskID - ctx = withTaskID(ctx, "test_task_123") - taskID = GetTaskID(ctx) - assert.Equal(t, "test_task_123", taskID) -} - -func TestAppendLogWithoutTaskID(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - // 没有 taskID 的 context,应降级到普通日志,不报错 - AppendLog(ctx, "这条日志应该降级处理,不会报错") -} - -func TestAppendLogWithTaskID(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - // 先创建一条执行记录 - execution := &model.TaskExecution{ - TaskID: "log_test_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusRunning, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 注入 taskID 并追加日志 - ctx = withTaskID(ctx, "log_test_001") - AppendLog(ctx, "第一条日志") - AppendLog(ctx, "处理了 %d 条数据", 50) - - // 验证日志 - found, err := repository.GetTaskExecutionByTaskID(ctx, "log_test_001") - require.NoError(t, err) - assert.Contains(t, found.Log, "第一条日志") - assert.Contains(t, found.Log, "处理了 50 条数据") -} - -func TestTaskTraceContextEnvelope(t *testing.T) { - payload := []byte(`{"hello":"wavelet"}`) - traceID := "4bf92f3577b34da6a3ce929d0e0e4736" - parentCtx := otel.GetTextMapPropagator().Extract( - context.Background(), - propagation.MapCarrier{ - "traceparent": "00-" + traceID + "-00f067aa0ba902b7-01", - }, - ) - - wrappedPayload := injectTaskTraceContext(parentCtx, payload) - require.NotEqual(t, string(payload), string(wrappedPayload)) - - gotCtx, gotPayload, ok := extractTaskTraceContext(context.Background(), wrappedPayload) - require.True(t, ok) - assert.Equal(t, payload, gotPayload) - assert.Equal(t, traceID, trace.SpanContextFromContext(gotCtx).TraceID().String()) -} - -func TestTaskTraceContextEnvelopeKeepsLegacyPayload(t *testing.T) { - payload := []byte(`{"legacy":true}`) - - gotCtx, gotPayload, ok := extractTaskTraceContext(context.Background(), payload) - require.False(t, ok) - assert.Equal(t, context.Background(), gotCtx) - assert.Equal(t, payload, gotPayload) -} - -func TestTaskTraceContextEnvelopeSkipsEmptyContext(t *testing.T) { - payload := []byte(`{"background":true}`) - - wrappedPayload := injectTaskTraceContext(context.Background(), payload) - assert.Equal(t, payload, wrappedPayload) -} - -func TestProcessTaskSuccess(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - // 注册成功 handler - RegisterHandler(testTaskType, successHandler()) - - // 创建执行记录 - execution := &model.TaskExecution{ - TaskID: "process_success_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusPending, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 通过 asynq 的 Task 不能直接设置 taskID,ProcessTask 通过 t.ResultWriter().TaskID() 获取 - // 但 asynq.Task 在没有经过 asynq server 的情况下 ResultWriter 可能为 nil - // 我们需要在 ProcessTask 内部改用 taskID 注入的方式测试 - // 为了测试 ProcessTask,我们直接模拟调用 handler - - // 直接通过 handler 测试 - handler, ok := getHandler(testTaskType) - require.True(t, ok) - - ctx = withTaskID(ctx, "process_success_001") - result, err := handler.Execute(ctx, nil) - require.NoError(t, err) - assert.Equal(t, "处理完成,共 100 条", result.Message) - - // 验证日志被追加 - found, err := repository.GetTaskExecutionByTaskID(ctx, "process_success_001") - require.NoError(t, err) - assert.Contains(t, found.Log, "执行成功,处理了 100 条数据") -} - -func TestProcessTaskFailure(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - // 注册失败 handler - RegisterHandler(testTaskType, failHandler()) - - // 创建执行记录 - execution := &model.TaskExecution{ - TaskID: "process_fail_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusPending, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 直接调用 handler - handler, ok := getHandler(testTaskType) - require.True(t, ok) - - ctx = withTaskID(ctx, "process_fail_001") - _, err = handler.Execute(ctx, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "模拟执行失败") - - // 验证日志 - found, err := repository.GetTaskExecutionByTaskID(ctx, "process_fail_001") - require.NoError(t, err) - assert.Contains(t, found.Log, "开始执行任务") -} - -func TestCompleteTaskExecutionFlushesLog(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "complete_flush_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusRunning, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - ctx = withTaskID(ctx, execution.TaskID) - AppendLog(ctx, "任务执行中的日志") - - finishTime := time.Now() - completeTaskExecution( - ctx, - execution, - asynq.NewTask(testTaskType, nil), - 100*time.Millisecond, - finishTime, - &TaskResult{Message: "处理完成"}, - nil, - trace.SpanFromContext(ctx), - ) - - found, err := repository.GetTaskExecutionByTaskID(ctx, execution.TaskID) - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusSucceeded, found.Status) - assert.Contains(t, found.Log, "任务执行中的日志") - assert.Contains(t, found.Log, "任务执行成功") -} - -func TestCompleteTaskExecutionFlushesPermanentFailureLog(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "complete_permanent_flush_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusRunning, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - ctx = withTaskID(ctx, execution.TaskID) - AppendLog(ctx, "永久失败前的日志") - execErr := PermanentError("来源配置无效") - - finishTime := time.Now() - completeTaskExecution( - ctx, - execution, - asynq.NewTask(testTaskType, nil), - 100*time.Millisecond, - finishTime, - nil, - execErr, - trace.SpanFromContext(ctx), - ) - - found, err := repository.GetTaskExecutionByTaskID(ctx, execution.TaskID) - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusFailed, found.Status) - assert.Equal(t, "来源配置无效", found.ErrorMessage) - assert.Contains(t, found.Log, "永久失败前的日志") - assert.Contains(t, found.Log, "任务执行失败") - keys, err := db.Redis.Keys(ctx, "*"+execution.TaskID+"*").Result() - require.NoError(t, err) - assert.Empty(t, keys) -} - -func TestPermanentErrorIsTerminalForLogFlush(t *testing.T) { - assert.True(t, isTerminalTaskExecutionError(PermanentError("配置无效"))) - assert.False(t, isTerminalTaskExecutionError(errors.New("temporary failure"))) -} - -func TestRetryTask(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - // 创建一条失败的执行记录(可重试) - now := time.Now() - execution := &model.TaskExecution{ - TaskID: "retry_test_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusFailed, - Retryable: true, - MaxRetry: 3, - RetryCount: 0, - ErrorMessage: "首次执行失败", - StartedAt: &now, - FinishedAt: &now, - Duration: 100, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 重试 - newTaskID, err := RetryTask(ctx, execution.ID) - require.NoError(t, err) - assert.NotEmpty(t, newTaskID) - assert.Contains(t, newTaskID, "retry_1_") - - // 验证新记录 - newExecution, err := repository.GetTaskExecutionByTaskID(ctx, newTaskID) - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusPending, newExecution.Status) - assert.Equal(t, 1, newExecution.RetryCount) - assert.Equal(t, "retry", newExecution.TriggeredBy) - assert.Equal(t, execution.TaskType, newExecution.TaskType) - assert.True(t, newExecution.Retryable) - - // 原记录不变 - original, err := repository.GetTaskExecutionByID(ctx, execution.ID) - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusFailed, original.Status) - assert.Equal(t, 0, original.RetryCount) -} - -func TestRetryTaskNotFailed(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - // 创建一条成功的记录 - execution := &model.TaskExecution{ - TaskID: "retry_not_failed_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusSucceeded, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 尝试重试成功的任务 - _, err = RetryTask(ctx, execution.ID) - require.Error(t, err) - assert.Contains(t, err.Error(), "只有失败的任务才能重试") -} - -func TestRetryTaskNotRetryable(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "retry_not_allowed_001", - TaskType: testTaskType, - TaskName: "测试任务", - Status: model.TaskExecutionStatusFailed, - Retryable: false, - MaxRetry: 0, - TriggeredBy: "manual", - } - err := repository.CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - _, err = RetryTask(ctx, execution.ID) - require.Error(t, err) - assert.Contains(t, err.Error(), "不支持重试") -} - -func TestRetryTaskNonExistent(t *testing.T) { - cleanup := setupTest(t) - defer cleanup() - ctx := context.Background() - - _, err := RetryTask(ctx, 99999999) - require.Error(t, err) - assert.Contains(t, err.Error(), "不存在") -} - -func TestGenerateTaskID(t *testing.T) { - id1 := generateTaskID("test_type", "manual") - id2 := generateTaskID("test_type", "manual") - - // 两个 ID 应不同(包含 Snowflake ID) - assert.NotEqual(t, id1, id2) - assert.Contains(t, id1, "manual_test_type_") -} - -func TestGenerateRetryTaskID(t *testing.T) { - id := generateRetryTaskID("original_task_123", 2) - assert.Equal(t, "retry_2_original_task_123", id) -} diff --git a/backend/OpenFlare/plugins/server/infra/task/handler.go b/backend/OpenFlare/plugins/server/infra/task/handler.go deleted file mode 100644 index 2b9c628d..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/handler.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import "context" - -// TaskResult 任务执行结果 -// -//nolint:revive // TaskResult 保留完整名称以避免与通用 Result 混淆 -type TaskResult struct { - Message string // 结果摘要,如 "共清理 120 个文件,耗时 3.2s" - Detail string // 可选的详细结果 JSON -} - -// PayloadValidator 可选接口,带参数的任务 Handler 应实现此接口。 -// 框架在 Admin 下发时自动调用,完成参数校验和标准化(如 Trim 空白)。 -// 无参数的任务无需实现,框架会直接透传 payload。 -type PayloadValidator interface { - ValidatePayload(payload []byte) ([]byte, error) -} - -// TaskHandler 异步任务处理器接口 -// 所有异步任务必须实现此接口,框架将自动管理任务执行记录的创建、状态流转和日志写入。 -// -// 开发者只需实现 Execute 方法编写业务逻辑,在方法内通过 task.AppendLog(ctx, ...) 追加执行日志。 -// 任务的创建、状态更新、错误记录、重试计数全部由框架透明处理。 -// -//nolint:revive // TaskHandler 保留完整名称以避免与通用 Handler 混淆 -type TaskHandler interface { - // Execute 执行任务业务逻辑 - // - ctx: 已注入 Trace Span 和 taskID 的上下文 - // - payload: 调度时传入的原始参数(可为 nil) - // - 返回 TaskResult 描述执行结果,或 error 表示执行失败 - Execute(ctx context.Context, payload []byte) (*TaskResult, error) -} diff --git a/backend/OpenFlare/plugins/server/infra/task/handlers/register.go b/backend/OpenFlare/plugins/server/infra/task/handlers/register.go deleted file mode 100644 index 621950b5..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/handlers/register.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package handlers 注册异步任务处理器 -package handlers - -import ( - "Wavelet/OpenFlare/plugins/server/admin/push" - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/openflare" - cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" - "Wavelet/OpenFlare/plugins/server/openflare/pages" - "Wavelet/OpenFlare/plugins/server/openflare/tasks" - "Wavelet/OpenFlare/plugins/server/openflare/tls" - "Wavelet/OpenFlare/plugins/server/upload" - "Wavelet/OpenFlare/plugins/server/user" -) - -// Register registers all built-in task handlers and their metadata. -func Register() { - task.RegisterHandler(upload.StorageMigrationTask, &upload.MigrationHandler{}) - task.RegisterTaskMeta(upload.StorageMigrationMeta) - - // system cleanup - task.RegisterHandler(upload.SystemCleanupTask, &upload.SystemCleanupHandler{}) - task.RegisterTaskMeta(upload.SystemCleanupMeta) - - // upload - task.RegisterHandler(upload.WarmImageCacheTask, &upload.WarmImageCacheHandler{}) - task.RegisterTaskMeta(upload.WarmImageCacheMeta) - - task.RegisterHandler(upload.RebuildUploadStatsTask, &upload.RebuildUploadStatsHandler{}) - task.RegisterTaskMeta(upload.RebuildUploadStatsMeta) - - // user - task.RegisterHandler(user.SendEmailTask, &user.SendEmailHandler{}) - task.RegisterTaskMeta(user.SendEmailMeta) - - // push - task.RegisterHandler(push.SendNotificationTask, &push.PushHandler{}) - task.RegisterTaskMeta(push.SendNotificationMeta) - - // openflare - task.RegisterHandler(openflare.SSLRenewTask, &openflare.SSLRenewHandler{}) - task.RegisterTaskMeta(openflare.SSLRenewMeta) - - task.RegisterHandler(openflare.WAFIPGroupSyncTask, &openflare.WAFIPGroupSyncHandler{}) - task.RegisterTaskMeta(openflare.WAFIPGroupSyncMeta) - - task.RegisterHandler(openflare.UptimeKumaSyncTask, &openflare.UptimeKumaSyncHandler{}) - task.RegisterTaskMeta(openflare.UptimeKumaSyncMeta) - - task.RegisterHandler(openflare.LogDBSwitchTask, &tasks.LogDBSwitchHandler{}) - task.RegisterTaskMeta(openflare.LogDBSwitchMeta) - - task.RegisterHandler(cf.SyncMemberTask, &cf.SyncMemberTaskHandler{}) - task.RegisterTaskMeta(cf.SyncMemberMeta) - task.RegisterHandler(cf.SyncGroupTask, &cf.SyncGroupTaskHandler{}) - task.RegisterTaskMeta(cf.SyncGroupMeta) - task.RegisterHandler(cf.SyncByNodeTask, &cf.SyncByNodeTaskHandler{}) - task.RegisterTaskMeta(cf.SyncByNodeMeta) - - // pages source actions are only dispatched by the Pages domain API/scanner. - task.RegisterHandler(pages.PagesSourceScanTask, &pages.SourceScanHandler{}) - task.RegisterTaskMeta(pages.PagesSourceScanMeta) - - task.RegisterHandler(pages.PagesSourceActionTask, &pages.SourceActionHandler{}) - task.RegisterTaskMeta(pages.PagesSourceActionMeta) - - // tls single renew - task.RegisterHandler(tls.SSLSingleRenewTask, &tls.SSLSingleRenewHandler{}) - task.RegisterTaskMeta(tls.SSLSingleRenewMeta) -} diff --git a/backend/OpenFlare/plugins/server/infra/task/meta.go b/backend/OpenFlare/plugins/server/infra/task/meta.go deleted file mode 100644 index 888ed80c..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/meta.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "sync" -) - -// TaskParam 任务参数定义 -// -//nolint:revive // TaskParam 保留完整名称以避免与通用 Param 混淆 -type TaskParam struct { - Name string `json:"name"` // 参数键名 - Label string `json:"label"` // 显示名称 - Type string `json:"type"` // 类型:string, text, number, boolean - Required bool `json:"required"` // 是否必填 - Placeholder string `json:"placeholder"` // 占位符 - Description string `json:"description"` // 描述 -} - -// TaskMeta 任务元数据 -// -//nolint:revive // TaskMeta 保留完整名称以避免与通用 Meta 混淆 -type TaskMeta struct { - Type string `json:"type"` - AsynqTask string `json:"asynq_task"` - Name string `json:"name"` - Description string `json:"description"` - SupportsTime bool `json:"supports_time"` - MaxRetry int `json:"max_retry"` - Queue string `json:"queue"` - Retryable bool `json:"retryable"` // 是否支持手动重试 - InternalOnly bool `json:"-"` // 是否仅允许内部业务入口调度 - Params []TaskParam `json:"params,omitempty"` -} - -var ( - dispatchableTasksMutex sync.RWMutex - dispatchableTasks []TaskMeta -) - -// RegisterTaskMeta 注册任务元数据到全局列表 -func RegisterTaskMeta(meta TaskMeta) { - dispatchableTasksMutex.Lock() - defer dispatchableTasksMutex.Unlock() - for _, t := range dispatchableTasks { - if t.Type == meta.Type { - return - } - } - dispatchableTasks = append(dispatchableTasks, meta) -} - -// GetDispatchableTasks 获取允许通过通用 Admin 入口调度的元数据列表。 -func GetDispatchableTasks() []TaskMeta { - dispatchableTasksMutex.RLock() - defer dispatchableTasksMutex.RUnlock() - - metas := make([]TaskMeta, 0, len(dispatchableTasks)) - for _, meta := range dispatchableTasks { - if meta.InternalOnly { - continue - } - metas = append(metas, meta) - } - return metas -} - -// GetTaskMeta 根据任务类型获取元数据 -func GetTaskMeta(taskType string) *TaskMeta { - dispatchableTasksMutex.RLock() - defer dispatchableTasksMutex.RUnlock() - for _, t := range dispatchableTasks { - if t.Type == taskType { - copied := t - return &copied - } - } - return nil -} - -// GetTaskMetaByAsynqTask 根据 Asynq 任务名称获取元数据 -func GetTaskMetaByAsynqTask(asynqTask string) *TaskMeta { - dispatchableTasksMutex.RLock() - defer dispatchableTasksMutex.RUnlock() - for _, t := range dispatchableTasks { - if t.AsynqTask == asynqTask { - copied := t - return &copied - } - } - return nil -} - -// GetRegisteredAsynqTasks 返回所有已注册的 Asynq 任务名称,以便动态注册路由 -func GetRegisteredAsynqTasks() []string { - keys := make([]string, 0, len(handlerRegistry)) - for k := range handlerRegistry { - keys = append(keys, k) - } - return keys -} diff --git a/backend/OpenFlare/plugins/server/infra/task/meta_test.go b/backend/OpenFlare/plugins/server/infra/task/meta_test.go deleted file mode 100644 index 4ff4c522..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/meta_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task_test - -import ( - "testing" - - "Wavelet/OpenFlare/plugins/server/infra/task" - taskhandlers "Wavelet/OpenFlare/plugins/server/infra/task/handlers" -) - -func TestDuplicateTaskMeta(t *testing.T) { - // Call Register twice to simulate being imported by multiple packages (routers, worker, etc.) - taskhandlers.Register() - taskhandlers.Register() - - metas := task.GetDispatchableTasks() - - // Check if we have duplicates by checking if a Type appears more than once - seen := make(map[string]int) - for _, m := range metas { - seen[m.Type]++ - } - - for taskType, count := range seen { - if count > 1 { - t.Errorf("Task type %q registered %d times, expected at most 1", taskType, count) - } - } -} - -func TestInternalOnlyTaskMetaIsHiddenFromDispatchableTasks(t *testing.T) { - const taskType = "test_internal_only_meta" - meta := task.TaskMeta{ - Type: taskType, - AsynqTask: "test:internal_only_meta", - Name: "内部测试任务", - InternalOnly: true, - } - task.RegisterTaskMeta(meta) - - registered := task.GetTaskMeta(taskType) - if registered == nil { - t.Fatal("GetTaskMeta() did not return internal-only metadata") - } - if !registered.InternalOnly { - t.Fatal("GetTaskMeta() lost InternalOnly flag") - } - - for _, dispatchable := range task.GetDispatchableTasks() { - if dispatchable.Type == taskType { - t.Fatalf("GetDispatchableTasks() exposed internal-only task %q", taskType) - } - } -} diff --git a/backend/OpenFlare/plugins/server/infra/task/permanent_error.go b/backend/OpenFlare/plugins/server/infra/task/permanent_error.go deleted file mode 100644 index a0dad4f3..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/permanent_error.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "strings" - - "github.com/hibiken/asynq" -) - -const defaultPermanentErrorMessage = "任务无法继续执行" - -type permanentTaskError struct { - message string -} - -// PermanentError marks a safe domain message as a non-retryable task failure. -// It intentionally accepts no underlying error so Error never exposes provider, -// URL, header, response-body, or other sensitive implementation details. -func PermanentError(message string) error { - message = strings.TrimSpace(message) - if message == "" { - message = defaultPermanentErrorMessage - } - return &permanentTaskError{message: message} -} - -func (e *permanentTaskError) Error() string { - return e.message -} - -func (e *permanentTaskError) Unwrap() error { - return asynq.SkipRetry -} diff --git a/backend/OpenFlare/plugins/server/infra/task/permanent_error_test.go b/backend/OpenFlare/plugins/server/infra/task/permanent_error_test.go deleted file mode 100644 index d90c3e8c..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/permanent_error_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "testing" - - "github.com/hibiken/asynq" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPermanentErrorSkipsRetryWithoutExposingAsynqMessage(t *testing.T) { - err := PermanentError(" 来源配置无效 ") - - require.ErrorIs(t, err, asynq.SkipRetry) - assert.Equal(t, "来源配置无效", err.Error()) - assert.NotContains(t, err.Error(), asynq.SkipRetry.Error()) -} - -func TestPermanentErrorUsesSafeFallbackForBlankMessage(t *testing.T) { - err := PermanentError(" ") - - require.ErrorIs(t, err, asynq.SkipRetry) - require.Equal(t, defaultPermanentErrorMessage, err.Error()) -} diff --git a/backend/OpenFlare/plugins/server/infra/task/scheduler/errs.go b/backend/OpenFlare/plugins/server/infra/task/scheduler/errs.go deleted file mode 100644 index ccb41465..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/scheduler/errs.go +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package scheduler 提供定时任务调度功能 -package scheduler - -const ( - errLoadLocationFailed = "failed to load location: %w" -) diff --git a/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler.go b/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler.go deleted file mode 100644 index 49bb3fbe..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package scheduler - -import ( - "context" - "fmt" - "os/signal" - "sync" - "syscall" - "time" - - "Wavelet/OpenFlare/plugins/server/repository" - - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" - "Wavelet/pkg/logger" - - "github.com/hibiken/asynq" -) - -var ( - activeScheduler *asynq.Scheduler - schedulerMutex sync.Mutex - quitChan chan struct{} - schedulerOnce sync.Once -) - -// GetAsynqClient 获取全局 AsynqClient -func GetAsynqClient() *asynq.Client { - return task.AsynqClient -} - -// StartScheduler 启动调度器 (该函数阻塞,直到调度器退出) -func StartScheduler() error { - bootstrap.RegisterScheduler() - - var err error - schedulerOnce.Do(func() { - quitChan = make(chan struct{}) - done := quitChan - - // 初始化并运行首次调度 - if err = ReloadScheduler(); err != nil { - err = fmt.Errorf("initial reload failed: %w", err) - return - } - - signalCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stopSignals() - - if waitForStop(done, signalCtx.Done()) { - StopScheduler() - } - }) - return err -} - -// StopScheduler 停止调度服务并解除 StartScheduler 阻塞 -func StopScheduler() { - schedulerMutex.Lock() - defer schedulerMutex.Unlock() - - if activeScheduler != nil { - activeScheduler.Shutdown() - activeScheduler = nil - } - - if quitChan != nil { - close(quitChan) - quitChan = nil - } -} - -// ReloadScheduler 重载调度器配置 (线程安全) -func ReloadScheduler() error { - schedulerMutex.Lock() - defer schedulerMutex.Unlock() - - // 1. 如果有运行中的调度器,先关闭它 - if activeScheduler != nil { - activeScheduler.Shutdown() - activeScheduler = nil - } - - // 2. 从数据库载入启用的定时任务配置 - schedules, err := repository.ListActiveSchedules(context.Background()) - if err != nil { - return fmt.Errorf("load schedules from db failed: %w", err) - } - - location, err := time.LoadLocation("Asia/Shanghai") - if err != nil { - return fmt.Errorf(errLoadLocationFailed, err) - } - - // 3. 实例化新的调度器 - newScheduler := asynq.NewScheduler( - task.RedisOpt, - &asynq.SchedulerOpts{ - Location: location, - }, - ) - - // 4. 遍历并注册任务 - for _, s := range schedules { - meta := task.GetTaskMeta(s.TaskType) - if meta == nil { - continue // 忽略排程配置中无效的任务类型 - } - - // 构造 Asynq 载荷。定时任务使用对应 Meta 中的 Asynq 标识,同时将数据库中保存的 json 作为参数 - t := asynq.NewTask(meta.AsynqTask, []byte(s.Payload)) - - if _, err := newScheduler.Register( - s.Cron, - t, - asynq.MaxRetry(meta.MaxRetry), - asynq.Queue(meta.Queue), - ); err != nil { - // 定时任务配置可能有误(如 Cron 格式不被 Asynq 识别),记录日志并跳过 - logger.ErrorF(context.Background(), "[Scheduler] 注册定时任务失败 id=%d name=%s: %v", s.ID, s.Name, err) - continue - } - } - - // 5. 启动并替换全局调度器。进程信号由 StartScheduler 统一处理。 - if err := newScheduler.Start(); err != nil { - return fmt.Errorf("start scheduler failed: %w", err) - } - activeScheduler = newScheduler - - logger.InfoF(context.Background(), "[Scheduler] 成功重新加载定时任务,共注册 %d 个活动任务", len(schedules)) - return nil -} - -func waitForStop(done <-chan struct{}, signals <-chan struct{}) bool { - select { - case <-done: - return false - case <-signals: - return true - } -} diff --git a/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler_test.go b/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler_test.go deleted file mode 100644 index c9c2307e..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package scheduler - -import "testing" - -func TestWaitForStop(t *testing.T) { - tests := []struct { - name string - closeDone bool - closeSignal bool - wantSignal bool - }{ - { - name: "explicit stop", - closeDone: true, - }, - { - name: "process signal", - closeSignal: true, - wantSignal: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - done := make(chan struct{}) - signals := make(chan struct{}) - if tt.closeDone { - close(done) - } - if tt.closeSignal { - close(signals) - } - - if got := waitForStop(done, signals); got != tt.wantSignal { - t.Errorf("waitForStop() = %t, want %t", got, tt.wantSignal) - } - }) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/task/utils.go b/backend/OpenFlare/plugins/server/infra/task/utils.go deleted file mode 100644 index 5bc2e8ba..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/utils.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/hibiken/asynq" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" -) - -type redisClientConnOpt struct { - options redis.Options -} - -func (opt redisClientConnOpt) MakeRedisClient() any { - return redis.NewClient(&opt.options) -} - -type redisClusterConnOpt struct { - options redis.ClusterOptions -} - -func (opt redisClusterConnOpt) MakeRedisClient() any { - return redis.NewClusterClient(&opt.options) -} - -type redisFailoverConnOpt struct { - options redis.FailoverOptions - maintNotificationsEnabled bool -} - -func (opt redisFailoverConnOpt) MakeRedisClient() any { - client := redis.NewFailoverClient(&opt.options) - // go-redis v9.16 does not expose maintenance notification settings on - // FailoverOptions, so apply the configured mode before the client is used. - client.Options().MaintNotificationsConfig = maintNotificationsConfig(opt.maintNotificationsEnabled) - return client -} - -func maintNotificationsConfig(enabled bool) *maintnotifications.Config { - mode := maintnotifications.ModeDisabled - if enabled { - mode = maintnotifications.ModeAuto - } - return &maintnotifications.Config{Mode: mode} -} - -// RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster) -var RedisOpt asynq.RedisConnOpt - -// AsynqClient asynq 客户端,用于任务入队 -var AsynqClient *asynq.Client - -func init() { - RedisOpt = NewRedisConnOpt() - AsynqClient = asynq.NewClient(RedisOpt) -} - -// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项 -func NewRedisConnOpt() asynq.RedisConnOpt { - cfg := config.Config.Redis - addrs := cfg.Addrs - - if cfg.ClusterMode { - return redisClusterConnOpt{ - options: redis.ClusterOptions{ - Addrs: addrs, - Username: cfg.Username, - Password: cfg.Password, - MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications), - }, - } - } - - if cfg.MasterName != "" { - return redisFailoverConnOpt{ - maintNotificationsEnabled: cfg.MaintNotifications, - options: redis.FailoverOptions{ - MasterName: cfg.MasterName, - SentinelAddrs: addrs, - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - }, - } - } - - addr := "localhost:6379" - if len(addrs) > 0 { - addr = addrs[0] - } - return redisClientConnOpt{ - options: redis.Options{ - Addr: addr, - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - PoolSize: cfg.PoolSize, - MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications), - }, - } -} - -// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离 -func PrefixedQueue(queue string) string { - prefix := config.Config.Redis.KeyPrefix - if prefix == "" { - return queue - } - return prefix + queue -} diff --git a/backend/OpenFlare/plugins/server/infra/task/utils_test.go b/backend/OpenFlare/plugins/server/infra/task/utils_test.go deleted file mode 100644 index b4c69566..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/utils_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package task - -import ( - "testing" - - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" -) - -func TestNewRedisConnOptConfiguresMaintenanceNotifications(t *testing.T) { - previous := config.Config.Redis - t.Cleanup(func() { config.Config.Redis = previous }) - - for _, test := range []struct { - name string - enabled bool - want maintnotifications.Mode - }{ - {name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled}, - {name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto}, - } { - t.Run(test.name, func(t *testing.T) { - config.Config.Redis.MaintNotifications = test.enabled - - t.Run("standalone", func(t *testing.T) { - config.Config.Redis.ClusterMode = false - config.Config.Redis.MasterName = "" - config.Config.Redis.Addrs = []string{"127.0.0.1:6379"} - - client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client) - if !ok { - t.Fatal("standalone option did not create *redis.Client") - } - defer func() { _ = client.Close() }() - assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want) - }) - - t.Run("cluster", func(t *testing.T) { - config.Config.Redis.ClusterMode = true - config.Config.Redis.MasterName = "" - config.Config.Redis.Addrs = []string{"127.0.0.1:6379"} - - client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.ClusterClient) - if !ok { - t.Fatal("cluster option did not create *redis.ClusterClient") - } - defer func() { _ = client.Close() }() - assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want) - }) - - t.Run("sentinel", func(t *testing.T) { - config.Config.Redis.ClusterMode = false - config.Config.Redis.MasterName = "openflare" - config.Config.Redis.Addrs = []string{"127.0.0.1:26379"} - - client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client) - if !ok { - t.Fatal("sentinel option did not create *redis.Client") - } - defer func() { _ = client.Close() }() - assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want) - }) - }) - } -} - -func assertMaintenanceNotificationsMode(t *testing.T, cfg *maintnotifications.Config, want maintnotifications.Mode) { - t.Helper() - if cfg == nil || cfg.Mode != want { - t.Fatalf("maintenance notifications mode = %v, want %v", cfg, want) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/task/worker/middlewares.go b/backend/OpenFlare/plugins/server/infra/task/worker/middlewares.go deleted file mode 100644 index 587bb323..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/worker/middlewares.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package worker 提供 Asynq 任务处理服务器与中间件 -package worker - -import ( - "context" - - "github.com/hibiken/asynq" -) - -// taskLoggingMiddleware 任务处理中间件 -// 注意:OTel Span 创建、日志记录、TaskExecution 状态管理 -// 已由 task.ProcessTask 统一处理,此中间件保留用于未来扩展(如限流、监控等) -func taskLoggingMiddleware(h asynq.Handler) asynq.Handler { - return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error { - return h.ProcessTask(ctx, t) - }) -} diff --git a/backend/OpenFlare/plugins/server/infra/task/worker/worker.go b/backend/OpenFlare/plugins/server/infra/task/worker/worker.go deleted file mode 100644 index 7b2688d2..00000000 --- a/backend/OpenFlare/plugins/server/infra/task/worker/worker.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package worker - -import ( - "time" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/infra/task" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" - - "github.com/hibiken/asynq" -) - -// workerShutdownTimeout Worker 优雅关闭超时时间 -const workerShutdownTimeout = 3 * time.Minute - -// StartWorker 启动任务处理服务器 -func StartWorker() error { - bootstrap.RegisterWorker() - asynqServer := asynq.NewServer( - task.RedisOpt, - asynq.Config{ - Concurrency: config.Config.Worker.Concurrency, - ShutdownTimeout: workerShutdownTimeout, - Queues: buildQueuesFromConfig(), - StrictPriority: config.Config.Worker.StrictPriority, - }, - ) - - // 注册 Asynq 任务路由 - mux := asynq.NewServeMux() - mux.Use(taskLoggingMiddleware) - - // 统一使用 task.ProcessTask 处理所有任务类型 - // 框架内部自动分发到对应的 TaskHandler 实现 - // 动态注册所有已注册的任务处理器路由,框架内部自动分发到对应的 TaskHandler 实现 - for _, taskName := range task.GetRegisteredAsynqTasks() { - mux.HandleFunc(taskName, task.ProcessTask) - } - - // 启动服务器 - return asynqServer.Run(mux) -} - -// buildQueuesFromConfig 从配置构建队列映射 -func buildQueuesFromConfig() map[string]int { - queues := make(map[string]int) - - // 从配置读取队列 - if len(config.Config.Worker.Queues) > 0 { - for _, q := range config.Config.Worker.Queues { - if q.Name != "" && q.Priority > 0 { - queues[q.Name] = q.Priority - } - } - } - - // 如果配置为空,使用默认队列 - if len(queues) == 0 { - queues = map[string]int{ - task.QueueDefault: 1, - } - } - - return queues -} diff --git a/backend/OpenFlare/plugins/server/listener/admin_login.go b/backend/OpenFlare/plugins/server/listener/admin_login.go deleted file mode 100644 index 03dc7d5d..00000000 --- a/backend/OpenFlare/plugins/server/listener/admin_login.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package listener provides domain event dispatch for cross-module integration. -// Core domains emit events here; operational modules (push, webhooks, etc.) -// subscribe at the application composition root. -package listener - -import ( - "context" - - "Wavelet/OpenFlare/plugins/server/model" -) - -// AdminLoggedIn is emitted when an administrator successfully authenticates. -type AdminLoggedIn struct { - User *model.User - IP string -} - -// AdminLoggedInHandler handles administrator login domain events. -type AdminLoggedInHandler func(ctx context.Context, event AdminLoggedIn) - -var adminLoggedInHandlers []AdminLoggedInHandler - -// OnAdminLoggedIn registers a handler for administrator login events. -// Handlers must be registered during application bootstrap before serving traffic. -func OnAdminLoggedIn(handler AdminLoggedInHandler) { - adminLoggedInHandlers = append(adminLoggedInHandlers, handler) -} - -// EmitAdminLoggedIn dispatches an administrator login event to all registered handlers. -func EmitAdminLoggedIn(ctx context.Context, user *model.User, ip string) { - if user == nil || !user.IsAdmin { - return - } - - event := AdminLoggedIn{User: user, IP: ip} - for _, handler := range adminLoggedInHandlers { - handler(ctx, event) - } -} diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse.go b/backend/OpenFlare/plugins/server/migrator/clickhouse.go similarity index 95% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse.go rename to backend/OpenFlare/plugins/server/migrator/clickhouse.go index 11a2a730..57aad2e0 100644 --- a/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse.go +++ b/backend/OpenFlare/plugins/server/migrator/clickhouse.go @@ -12,7 +12,7 @@ import ( "log" "time" - "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" "github.com/ClickHouse/clickhouse-go/v2" "github.com/pressly/goose/v3" @@ -32,11 +32,11 @@ var clickhouseMigrationFS embed.FS // MigrateClickHouse runs goose migrations against ClickHouse when enabled. func MigrateClickHouse() Report { - if !config.Config.ClickHouse.Enabled { + if !runtimeconfig.ClickHouseEnabled() { return Report{Backend: "ClickHouse"} } - cfg := config.Config.ClickHouse + cfg := runtimeconfig.Get().ClickHouse sqlDB := clickhouse.OpenDB(&clickhouse.Options{ Addr: cfg.Hosts, Auth: clickhouse.Auth{ diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse_test.go b/backend/OpenFlare/plugins/server/migrator/clickhouse_test.go similarity index 83% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse_test.go rename to backend/OpenFlare/plugins/server/migrator/clickhouse_test.go index dc9d4cfd..e5124110 100644 --- a/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse_test.go +++ b/backend/OpenFlare/plugins/server/migrator/clickhouse_test.go @@ -6,7 +6,7 @@ package migrator import ( "testing" - "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" "github.com/pressly/goose/v3" ) @@ -46,11 +46,6 @@ func TestClickHouseGooseDialect(t *testing.T) { } func TestMigrateClickHouseSkipsWhenDisabled(t *testing.T) { - previousEnabled := config.Config.ClickHouse.Enabled - config.Config.ClickHouse.Enabled = false - t.Cleanup(func() { - config.Config.ClickHouse.Enabled = previousEnabled - }) - + t.Cleanup(runtimeconfig.Override(runtimeconfig.DatabaseEnabled(), false)) MigrateClickHouse() } diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180001_access_log_request_length.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180001_access_log_request_length.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180001_access_log_request_length.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180001_access_log_request_length.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql rename to backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606050001_bridge_legacy.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606050001_bridge_legacy.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606050001_bridge_legacy.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606050001_bridge_legacy.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606090001_initial_schema.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606090001_initial_schema.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606100001_create_schedules.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606100001_create_schedules.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606100002_access_token_is_admin.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606100002_access_token_is_admin.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606130001_add_upload_access_mode.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606130001_add_upload_access_mode.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606130002_expand_system_config_value.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606130002_expand_system_config_value.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606130003_add_storage_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606130003_add_storage_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140001_create_push_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140001_create_push_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140003_add_system_user.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140003_add_system_user.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140004_create_push_channels.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140004_create_push_channels.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606160001_remove_push_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606160001_remove_push_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606170002_create_upload_stats_table.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606170002_create_upload_stats_table.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606170003_backfill_upload_stats.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606170003_backfill_upload_stats.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190001_create_of_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190001_create_of_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190001_create_of_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190001_create_of_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190002_create_of_origins.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190002_create_of_origins.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190002_create_of_origins.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190002_create_of_origins.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190003_create_of_apply_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190003_create_of_apply_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190003_create_of_apply_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190003_create_of_apply_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190005_create_of_nodes.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190005_create_of_nodes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190005_create_of_nodes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190005_create_of_nodes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190006_create_of_waf_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190006_create_of_waf_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190006_create_of_waf_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190006_create_of_waf_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190007_create_of_tls_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190007_create_of_tls_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190007_create_of_tls_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190007_create_of_tls_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190008_create_of_config_versions.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190008_create_of_config_versions.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190008_create_of_config_versions.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190008_create_of_config_versions.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190009_create_of_pages_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190009_create_of_pages_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190009_create_of_pages_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190009_create_of_pages_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190010_create_of_observability_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190010_create_of_observability_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190010_create_of_observability_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190010_create_of_observability_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200002_update_security_defaults.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200002_update_security_defaults.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200002_update_security_defaults.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200002_update_security_defaults.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200006_migrate_legacy_data.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200006_migrate_legacy_data.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200006_migrate_legacy_data.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200006_migrate_legacy_data.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220002_remove_notice_option.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220002_remove_notice_option.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220002_remove_notice_option.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220002_remove_notice_option.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220005_drop_of_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220005_drop_of_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220005_drop_of_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606220005_drop_of_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606270001_make_version_primary_key.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606270001_make_version_primary_key.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606270001_make_version_primary_key.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606270001_make_version_primary_key.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120002_import_zone_domains.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607120002_import_zone_domains.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120002_import_zone_domains.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607120002_import_zone_domains.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607170001_add_pages_system_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607170001_add_pages_system_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607170001_add_pages_system_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607170001_add_pages_system_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070001_add_sw_offline_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608070001_add_sw_offline_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070001_add_sw_offline_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608070001_add_sw_offline_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608080001_create_log_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608080001_create_log_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608080002_log_retention_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608080002_log_retention_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090001_metric_retention_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608090001_metric_retention_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090001_metric_retention_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608090001_metric_retention_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090003_add_log_indexes.sql b/backend/OpenFlare/plugins/server/migrator/goose/postgres/202608090003_add_log_indexes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090003_add_log_indexes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/postgres/202608090003_add_log_indexes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606050001_bridge_legacy.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606050001_bridge_legacy.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606050001_bridge_legacy.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606050001_bridge_legacy.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606090001_initial_schema.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606090001_initial_schema.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606100001_create_schedules.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606100001_create_schedules.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606100002_access_token_is_admin.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606100002_access_token_is_admin.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606130002_expand_system_config_value.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606130002_expand_system_config_value.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606130003_add_storage_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606130003_add_storage_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140001_create_push_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140001_create_push_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140003_add_system_user.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140003_add_system_user.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140004_create_push_channels.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140004_create_push_channels.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606160001_remove_push_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606160001_remove_push_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190001_create_of_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190001_create_of_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190001_create_of_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190001_create_of_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190002_create_of_origins.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190002_create_of_origins.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190002_create_of_origins.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190002_create_of_origins.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190005_create_of_nodes.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190005_create_of_nodes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190005_create_of_nodes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190005_create_of_nodes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190008_create_of_config_versions.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190008_create_of_config_versions.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190008_create_of_config_versions.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190008_create_of_config_versions.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200002_update_security_defaults.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200002_update_security_defaults.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200002_update_security_defaults.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200002_update_security_defaults.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220002_remove_notice_option.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220002_remove_notice_option.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220002_remove_notice_option.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220002_remove_notice_option.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220005_drop_of_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220005_drop_of_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220005_drop_of_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606220005_drop_of_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606270001_make_version_primary_key.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606270001_make_version_primary_key.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606270001_make_version_primary_key.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606270001_make_version_primary_key.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120002_import_zone_domains.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607120002_import_zone_domains.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120002_import_zone_domains.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607120002_import_zone_domains.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608080001_create_log_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608080001_create_log_tables.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608080002_log_retention_configs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608080002_log_retention_configs.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090001_metric_retention_config.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608090001_metric_retention_config.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090001_metric_retention_config.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608090001_metric_retention_config.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090003_add_log_indexes.sql b/backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608090003_add_log_indexes.sql similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090003_add_log_indexes.sql rename to backend/OpenFlare/plugins/server/migrator/goose/sqlite/202608090003_add_log_indexes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/legacy_dump_test.go b/backend/OpenFlare/plugins/server/migrator/legacy_dump_test.go similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/legacy_dump_test.go rename to backend/OpenFlare/plugins/server/migrator/legacy_dump_test.go diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator.go b/backend/OpenFlare/plugins/server/migrator/migrator.go similarity index 96% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator.go rename to backend/OpenFlare/plugins/server/migrator/migrator.go index 53707d0e..eae6f35a 100644 --- a/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator.go +++ b/backend/OpenFlare/plugins/server/migrator/migrator.go @@ -12,8 +12,8 @@ import ( "fmt" "log" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/openflare/zone" "Wavelet/OpenFlare/plugins/server/repository" @@ -27,7 +27,7 @@ var migrationFS embed.FS // dbType 返回当前数据库类型名称(用于日志输出) func dbType() string { - if !config.Config.Database.Enabled { + if !runtimeconfig.DatabaseEnabled() { return "SQLite" } return "PostgreSQL" @@ -52,14 +52,14 @@ type Report struct { } func gooseDialect() string { - if !config.Config.Database.Enabled { + if !runtimeconfig.DatabaseEnabled() { return dialectSqlite } return dialectPostgres } func migrationDir() string { - if !config.Config.Database.Enabled { + if !runtimeconfig.DatabaseEnabled() { return "goose/sqlite" } return "goose/postgres" diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator_test.go b/backend/OpenFlare/plugins/server/migrator/migrator_test.go similarity index 81% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator_test.go rename to backend/OpenFlare/plugins/server/migrator/migrator_test.go index b8b8a1c8..5016569c 100644 --- a/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator_test.go +++ b/backend/OpenFlare/plugins/server/migrator/migrator_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" @@ -45,15 +45,13 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) { }, }) - previousDBEnabled := config.Config.Database.Enabled - config.Config.Database.Enabled = false db.SetDB(sqliteDB) t.Cleanup(func() { - config.Config.Database.Enabled = previousDBEnabled db.SetDB(nil) _ = redisClient.Close() mr.Close() }) + t.Cleanup(runtimeconfig.Override(false, runtimeconfig.ClickHouseEnabled())) Migrate() @@ -156,7 +154,7 @@ func TestCloudflarePointingPostgresMigrationQuotesAuthorizationColumn(t *testing } } -func TestMigrateClearsStaleSystemConfigCache(t *testing.T) { +func TestMigrateSeedsSystemConfigs(t *testing.T) { sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, }) @@ -164,44 +162,12 @@ func TestMigrateClearsStaleSystemConfigCache(t *testing.T) { t.Fatalf("gorm.Open(sqlite) error = %v", err) } - mr, err := miniredis.Run() - if err != nil { - t.Fatalf("miniredis.Run() error = %v", err) - } - redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - - previousDBEnabled := config.Config.Database.Enabled - previousRedis := db.Redis - config.Config.Database.Enabled = false db.SetDB(sqliteDB) - db.Redis = redisClient - t.Cleanup(func() { - config.Config.Database.Enabled = previousDBEnabled - db.SetDB(nil) - db.Redis = previousRedis - _ = redisClient.Close() - mr.Close() - }) - - staleConfig := model.SystemConfig{ - Key: model.ConfigKeyEmailLoginVerificationEnabled, - Value: "true", - Type: "system", - } - if err := db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, model.ConfigKeyEmailLoginVerificationEnabled, &staleConfig); err != nil { - t.Fatalf("HSetJSON() error = %v", err) - } + t.Cleanup(func() { db.SetDB(nil) }) + t.Cleanup(runtimeconfig.Override(false, runtimeconfig.ClickHouseEnabled())) Migrate() - exists, err := db.Redis.Exists(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)).Result() - if err != nil { - t.Fatalf("Redis.Exists() error = %v", err) - } - if exists != 0 { - t.Fatalf("system config cache exists = %d, want 0", exists) - } - enabled, err := repository.GetBoolByKey(context.Background(), model.ConfigKeyEmailLoginVerificationEnabled) if err != nil { t.Fatalf("GetBoolByKey(%s) error = %v", model.ConfigKeyEmailLoginVerificationEnabled, err) diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_migration_test.go b/backend/OpenFlare/plugins/server/migrator/pages_source_migration_test.go similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_migration_test.go rename to backend/OpenFlare/plugins/server/migrator/pages_source_migration_test.go diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_scan_migration_test.go b/backend/OpenFlare/plugins/server/migrator/pages_source_scan_migration_test.go similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_scan_migration_test.go rename to backend/OpenFlare/plugins/server/migrator/pages_source_scan_migration_test.go diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/system_cleanup_migration_test.go b/backend/OpenFlare/plugins/server/migrator/system_cleanup_migration_test.go similarity index 100% rename from backend/OpenFlare/plugins/server/infra/persistence/migrator/system_cleanup_migration_test.go rename to backend/OpenFlare/plugins/server/migrator/system_cleanup_migration_test.go diff --git a/backend/OpenFlare/plugins/server/model/access_token.go b/backend/OpenFlare/plugins/server/model/access_token.go deleted file mode 100644 index 7665f3ed..00000000 --- a/backend/OpenFlare/plugins/server/model/access_token.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package model 定义数据模型与 GORM 实体 -package model - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "fmt" - "time" -) - -const ( - tokenByteLength = 24 // Token 随机字节长度 - maskThreshold = 8 // 脱敏显示阈值 -) - -// AccessToken 个人访问令牌实体 -type AccessToken struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - UserID uint64 `json:"user_id" gorm:"index;not null"` - Name string `json:"name" gorm:"size:128;not null"` - TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"` - MaskedToken string `json:"masked_token" gorm:"size:64;not null"` - IsAdmin bool `json:"is_admin" gorm:"default:false"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName 表名 -func (AccessToken) TableName() string { - return "w_access_tokens" -} - -// GenerateTokenString 生成加密安全的随机 Token 值 -func GenerateTokenString() (string, error) { - bytes := make([]byte, tokenByteLength) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return "at_" + hex.EncodeToString(bytes), nil -} - -// HashToken 计算 Token 的 SHA-256 哈希值用于数据库存储与查询 -func HashToken(token string) string { - h := sha256.New() - h.Write([]byte(token)) - return hex.EncodeToString(h.Sum(nil)) -} - -// MaskTokenString 生成脱敏显示的 Token,仅保留前缀和最后四位 -func MaskTokenString(token string) string { - if len(token) <= maskThreshold { - return "at_****" - } - return fmt.Sprintf("%s...%s", token[:7], token[len(token)-4:]) -} diff --git a/backend/OpenFlare/plugins/server/model/analytics/user_access_log.go b/backend/OpenFlare/plugins/server/model/analytics/user_access_log.go index 261b3e5c..46ab3759 100644 --- a/backend/OpenFlare/plugins/server/model/analytics/user_access_log.go +++ b/backend/OpenFlare/plugins/server/model/analytics/user_access_log.go @@ -4,40 +4,8 @@ package analytics import ( - "fmt" - "time" + risklogstore "Wavelet/plugins/domain/risk_control/logstore" ) -const ( - userAccessLogTableName = "w_user_access_logs" - userAccessLogInsertColumns = "id, user_id, path, method, ip, user_agent, headers, status, latency, created_at" -) - -// UserAccessLog stores HTTP access records in ClickHouse. -type UserAccessLog struct { - ID uint64 `gorm:"column:id"` - UserID uint64 `gorm:"column:user_id"` - Path string `gorm:"column:path"` - Method string `gorm:"column:method"` - IP string `gorm:"column:ip"` - UserAgent string `gorm:"column:user_agent"` - Headers string `gorm:"column:headers"` - Status int32 `gorm:"column:status"` - Latency int64 `gorm:"column:latency"` - CreatedAt time.Time `gorm:"column:created_at"` -} - -// TableName returns the ClickHouse table name. -func (UserAccessLog) TableName() string { - return userAccessLogTableName -} - -// InsertColumns returns comma-separated column names for batch insert. -func (UserAccessLog) InsertColumns() string { - return userAccessLogInsertColumns -} - -// BatchInsertSQL returns the INSERT prefix used by native batch writers. -func (UserAccessLog) BatchInsertSQL() string { - return fmt.Sprintf("INSERT INTO %s (%s)", userAccessLogTableName, userAccessLogInsertColumns) -} +// UserAccessLog is Wavelet risk_control's w_user_access_logs entity. +type UserAccessLog = risklogstore.UserAccessLog diff --git a/backend/OpenFlare/plugins/server/model/auth_source.go b/backend/OpenFlare/plugins/server/model/auth_source.go deleted file mode 100644 index 4a63031e..00000000 --- a/backend/OpenFlare/plugins/server/model/auth_source.go +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "errors" - "regexp" - "strings" - "time" -) - -// 认证源类型 -const ( - AuthSourceTypeOIDC = "oidc" -) - -// Shared GORM column name constants used across model package. -const ( - colName = "name" - colEnabled = "enabled" - colRemark = "remark" - tableOfProxyRoutes = "of_proxy_routes" -) - -var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) - -// AuthSource 认证源实体 -type AuthSource struct { - ID uint64 `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` - Type string `json:"type" gorm:"size:20;not null"` - DisplayName string `json:"display_name" gorm:"size:100"` - IsActive bool `json:"is_active" gorm:"index;not null;default:false"` - ClientID string `json:"client_id" gorm:"size:255"` - ClientSecret string `json:"-" gorm:"size:1024"` - OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"` - Scopes string `json:"scopes" gorm:"size:255"` - IconURL string `json:"icon_url" gorm:"size:1024"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"` -} - -// TableName 表名 -func (*AuthSource) TableName() string { - return "w_auth_sources" -} - -// ExternalAccount 外部账号绑定实体 -type ExternalAccount struct { - ID uint64 `json:"id" gorm:"primaryKey"` - AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"` - UserID uint64 `json:"user_id" gorm:"index;not null"` - ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"` - ExternalUsername string `json:"external_username" gorm:"size:255"` - Email string `json:"email" gorm:"size:255"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TableName 表名 -func (ExternalAccount) TableName() string { - return "w_external_accounts" -} - -// ExternalAccountView 外部帐号绑定视图(脱敏展示用) -type ExternalAccountView struct { - ID uint64 `json:"id"` - AuthSourceID uint64 `json:"auth_source_id"` - AuthSourceName string `json:"auth_source_name"` - AuthSourceType string `json:"auth_source_type"` - AuthSourceLabel string `json:"auth_source_label"` - ExternalUsername string `json:"external_username"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` -} - -// Normalize 对认证源字段进行标准化处理 -func (source *AuthSource) Normalize() { - source.Type = strings.ToLower(strings.TrimSpace(source.Type)) - source.Name = strings.TrimSpace(source.Name) - source.DisplayName = strings.TrimSpace(source.DisplayName) - source.ClientID = strings.TrimSpace(source.ClientID) - source.ClientSecret = strings.TrimSpace(source.ClientSecret) - source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL) - source.Scopes = strings.TrimSpace(source.Scopes) - source.IconURL = strings.TrimSpace(source.IconURL) - if source.DisplayName == "" { - source.DisplayName = source.Name - } - if source.Type == AuthSourceTypeOIDC && source.Scopes == "" { - source.Scopes = "openid profile email" - } -} - -// Validate 校验认证源字段合法性 -func (source *AuthSource) Validate() error { - source.Normalize() - if source.Name == "" { - return errors.New(errAuthSourceNameRequired) - } - if !authSourceNamePattern.MatchString(source.Name) { - return errors.New(errAuthSourceNameInvalid) - } - if source.Type != AuthSourceTypeOIDC { - return errors.New(errAuthSourceTypeUnsupported) - } - if source.OpenIDDiscoveryURL == "" { - return errors.New(errAuthSourceDiscoveryURLRequired) - } - if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { - return errors.New(errAuthSourceClientCredentialsRequired) - } - return nil -} - -// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志 -func (source *AuthSource) Sanitize() { - source.ClientSecretConfigured = source.ClientSecret != "" - source.ClientSecret = "" -} diff --git a/backend/OpenFlare/plugins/server/model/openflare_proxy_route.go b/backend/OpenFlare/plugins/server/model/openflare_proxy_route.go index 4e95b00e..43fdf760 100644 --- a/backend/OpenFlare/plugins/server/model/openflare_proxy_route.go +++ b/backend/OpenFlare/plugins/server/model/openflare_proxy_route.go @@ -7,6 +7,8 @@ import ( "time" ) +const tableOfProxyRoutes = "of_proxy_routes" + // ProxyRoute OpenFlare 代理规则实体。 // 域名与证书仅通过 of_zone_domains 关联,不再持久化在本表。 type ProxyRoute struct { diff --git a/backend/OpenFlare/plugins/server/model/platform_aliases.go b/backend/OpenFlare/plugins/server/model/platform_aliases.go new file mode 100644 index 00000000..23b3e1a9 --- /dev/null +++ b/backend/OpenFlare/plugins/server/model/platform_aliases.go @@ -0,0 +1,96 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + + adminmodel "Wavelet/plugins/domain/admin/model" + authmodel "Wavelet/plugins/domain/auth" + usermodel "Wavelet/plugins/domain/user" + uploadmodels "Wavelet/plugins/domain/upload/models" +) + +const ( + tokenByteLength = 24 + maskThreshold = 8 +) + +// User is the Wavelet w_users entity. +type User = usermodel.User + +// AccessToken is the Wavelet w_access_tokens entity. +type AccessToken = usermodel.AccessToken + +// AuthSource is the Wavelet w_auth_sources entity. +type AuthSource = authmodel.AuthSource + +// ExternalAccount is the Wavelet w_external_accounts entity. +type ExternalAccount = authmodel.ExternalAccount + +// TaskExecution is the Wavelet w_task_executions entity. +type TaskExecution = adminmodel.TaskExecution + +// Template is the Wavelet w_templates entity. +type Template = adminmodel.Template + +// Schedule is the Wavelet w_schedules entity. +type Schedule = adminmodel.Schedule + +// Upload is the Wavelet w_uploads entity. +type Upload = uploadmodels.Upload + +// UploadMetadata is the Wavelet upload metadata JSON. +type UploadMetadata = uploadmodels.UploadMetadata + +// UploadStatus is the Wavelet upload status. +type UploadStatus = uploadmodels.UploadStatus + +// UploadStat is the Wavelet w_upload_stats entity. +type UploadStat = uploadmodels.UploadStat + +const ( + // UploadStatusPending is a newly stored unused upload. + UploadStatusPending = uploadmodels.UploadStatusPending + // UploadStatusUsed is an in-use upload. + UploadStatusUsed = uploadmodels.UploadStatusUsed + // UploadStatusDeleted is a soft-deleted upload. + UploadStatusDeleted = uploadmodels.UploadStatusDeleted + + // UploadStatDimensionTotal is the total stats dimension. + UploadStatDimensionTotal = uploadmodels.UploadStatDimensionTotal + // UploadStatDimensionType is the type stats dimension. + UploadStatDimensionType = uploadmodels.UploadStatDimensionType + // UploadStatDimensionCategory is the category stats dimension. + UploadStatDimensionCategory = uploadmodels.UploadStatDimensionCategory + // UploadStatDimensionTrend is the trend stats dimension. + UploadStatDimensionTrend = uploadmodels.UploadStatDimensionTrend +) + +// GenerateTokenString 生成加密安全的随机 Token 值 +func GenerateTokenString() (string, error) { + bytes := make([]byte, tokenByteLength) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return "at_" + hex.EncodeToString(bytes), nil +} + +// HashToken 计算 Token 的 SHA-256 哈希值用于数据库存储与查询 +func HashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +// MaskTokenString 生成脱敏显示的 Token,仅保留前缀和最后四位 +func MaskTokenString(token string) string { + if len(token) <= maskThreshold { + return "at_****" + } + return fmt.Sprintf("%s...%s", token[:7], token[len(token)-4:]) +} diff --git a/backend/OpenFlare/plugins/server/model/push_channel.go b/backend/OpenFlare/plugins/server/model/push_channel.go deleted file mode 100644 index 111aa06d..00000000 --- a/backend/OpenFlare/plugins/server/model/push_channel.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "encoding/json" - "errors" - "regexp" - "strings" - "time" -) - -const ( - // TypeCustom 自定义消息通道类型 - TypeCustom = "custom" - // TypeEmail 邮件推送消息通道类型 - TypeEmail = "email" - // TypeTelegram 电报机器人推送消息通道类型 - TypeTelegram = "telegram" -) - -// PushChannel 消息通道模型 -type PushChannel struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` // 通道名称,仅英文字母和下划线,唯一 - Description string `json:"description" gorm:"size:255"` // 备注 - Type string `json:"type" gorm:"size:50;not null;default:'custom'"` // 通道类型:custom, lark, email - Token string `json:"token" gorm:"size:100"` // 鉴权令牌或发信用户名等 - URL string `json:"url" gorm:"type:text;not null"` // 请求地址,HTTPS 协议或 SMTP 地址 - Other string `json:"other" gorm:"type:text;not null"` // 请求体/SMTP 密码等 - Enabled bool `json:"enabled" gorm:"index;not null;default:true"` // 通道是否启用 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 指定 GORM 表名 -func (*PushChannel) TableName() string { - return "w_push_channels" -} - -var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) - -// Validate 参数合法性与 JSON 格式校验 -func (pc *PushChannel) Validate() error { - pc.Name = strings.TrimSpace(pc.Name) - pc.URL = strings.TrimSpace(pc.URL) - pc.Other = strings.TrimSpace(pc.Other) - pc.Type = strings.TrimSpace(pc.Type) - - if pc.Type == "" { - pc.Type = TypeCustom - } - - if pc.Type == TypeTelegram && pc.URL == "" { - pc.URL = "https://api.telegram.org" - } - - if pc.Name == "" { - return errors.New("channel name is required") - } - if !nameRegex.MatchString(pc.Name) { - return errors.New("channel name can only contain letters, numbers, and underscores") - } - if pc.Type != TypeEmail && pc.URL == "" { - return errors.New("request URL/address is required") - } - - if pc.Type != TypeEmail && !strings.HasPrefix(pc.URL, "https://") { - return errors.New("request URL must use HTTPS protocol for security reasons") - } - - switch pc.Type { - case TypeCustom: - if pc.Other == "" { - return errors.New("payload schema (request body) is required") - } - return validateJSON(pc.Other) - case TypeEmail: - // Email channel SMTP configs fall back to global settings, so they are not required to be filled. - case TypeTelegram: - if pc.Token == "" { - return errors.New("telegram bot token is required") - } - } - return nil -} - -func validateJSON(s string) error { - var jsonTest map[string]any - if err := json.Unmarshal([]byte(s), &jsonTest); err == nil { - return nil - } - var jsonArr []any - if err := json.Unmarshal([]byte(s), &jsonArr); err == nil { - return nil - } - return errors.New("payload schema must be a valid JSON format") -} diff --git a/backend/OpenFlare/plugins/server/model/push_event.go b/backend/OpenFlare/plugins/server/model/push_event.go deleted file mode 100644 index e64add1a..00000000 --- a/backend/OpenFlare/plugins/server/model/push_event.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "errors" - "strings" - "time" -) - -// PushEvent 系统通知事件模型 -type PushEvent struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` // 如 admin_login - Name string `json:"name" gorm:"size:100;not null"` // 如 管理员登录 - TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"` // 关联的异步任务类型 - Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` // 推送渠道列表,如 ["lark"] - Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` // 推送目标用户/邮箱列表 - Template string `json:"template" gorm:"type:text;not null"` // 消息模板 JSON - Enabled bool `json:"enabled" gorm:"index;not null;default:false"` // 是否启用 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 指定 GORM 表名 -func (*PushEvent) TableName() string { - return "w_push_events" -} - -// Validate 基础校验 -func (pe *PushEvent) Validate() error { - pe.EventKey = strings.TrimSpace(pe.EventKey) - pe.Name = strings.TrimSpace(pe.Name) - pe.Template = strings.TrimSpace(pe.Template) - - if pe.EventKey == "" { - return errors.New("event key is required") - } - if pe.Name == "" { - return errors.New("event name is required") - } - if pe.Template == "" { - return errors.New("event template is required") - } - if pe.Enabled && len(pe.Channels) == 0 { - return errors.New("cannot enable event without any push channels configured") - } - return nil -} diff --git a/backend/OpenFlare/plugins/server/model/push_history.go b/backend/OpenFlare/plugins/server/model/push_history.go deleted file mode 100644 index f36e806f..00000000 --- a/backend/OpenFlare/plugins/server/model/push_history.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// PushHistory 推送日志/历史实体 -type PushHistory struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - EventKey string `json:"event_key" gorm:"size:80;not null;index"` - Channel string `json:"channel" gorm:"size:50;not null"` - Target string `json:"target" gorm:"size:255;not null"` - Title string `json:"title" gorm:"size:255;not null"` - Content string `json:"content" gorm:"type:text;not null"` - Level string `json:"level" gorm:"size:20;not null"` - Status string `json:"status" gorm:"size:20;not null"` // success / failed - ErrorMsg string `json:"error_msg" gorm:"type:text"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` -} - -// TableName 指定表名 -func (PushHistory) TableName() string { - return "w_push_histories" -} diff --git a/backend/OpenFlare/plugins/server/model/schedule.go b/backend/OpenFlare/plugins/server/model/schedule.go deleted file mode 100644 index 5d577f6b..00000000 --- a/backend/OpenFlare/plugins/server/model/schedule.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// Schedule 定时任务配置表 -type Schedule struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:128;not null"` - TaskType string `json:"task_type" gorm:"size:64;not null"` - Cron string `json:"cron" gorm:"size:64;not null"` - Payload string `json:"payload" gorm:"type:text"` - IsActive bool `json:"is_active" gorm:"not null;default:true"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName 表名 -func (Schedule) TableName() string { - return "w_schedules" -} diff --git a/backend/OpenFlare/plugins/server/model/system_configs.go b/backend/OpenFlare/plugins/server/model/system_configs.go index aa3ec19a..d47be0c7 100644 --- a/backend/OpenFlare/plugins/server/model/system_configs.go +++ b/backend/OpenFlare/plugins/server/model/system_configs.go @@ -3,7 +3,9 @@ package model -import "time" +import ( + adminmodel "Wavelet/plugins/domain/admin/model" +) // 配置键常量 - 所有系统配置的 key 定义 const ( @@ -134,23 +136,10 @@ const ( const ( // ConfigVisibilityHidden 表示配置不通过公共配置接口暴露 - ConfigVisibilityHidden = 0 + ConfigVisibilityHidden = adminmodel.ConfigVisibilityHidden // ConfigVisibilityVisible 表示配置通过公共配置接口暴露 - ConfigVisibilityVisible = 1 + ConfigVisibilityVisible = adminmodel.ConfigVisibilityVisible ) -// SystemConfig 系统配置实体 -type SystemConfig struct { - Key string `json:"key" gorm:"primaryKey;size:64;not null"` - Value string `json:"value" gorm:"type:text;not null"` - Type string `json:"type" gorm:"size:32;not null;default:'system'"` - Visibility int `json:"visibility" gorm:"not null;default:0"` - Description string `json:"description" gorm:"size:255"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` -} - -// TableName 表名 -func (SystemConfig) TableName() string { - return "w_system_configs" -} +// SystemConfig is the Wavelet w_system_configs entity. +type SystemConfig = adminmodel.SystemConfig diff --git a/backend/OpenFlare/plugins/server/model/task_execution.go b/backend/OpenFlare/plugins/server/model/task_execution.go deleted file mode 100644 index 2ecb4a80..00000000 --- a/backend/OpenFlare/plugins/server/model/task_execution.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// TaskExecutionStatus 任务执行状态 -type TaskExecutionStatus string - -// 任务执行状态 -const ( - TaskExecutionStatusPending TaskExecutionStatus = "pending" - TaskExecutionStatusRunning TaskExecutionStatus = "running" - TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded" - TaskExecutionStatusFailed TaskExecutionStatus = "failed" -) - -// TaskExecution 任务执行记录 -type TaskExecution struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"` - TaskType string `json:"task_type" gorm:"size:64;index;not null"` - TaskName string `json:"task_name" gorm:"size:128"` - Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"` - Retryable bool `json:"retryable" gorm:"not null;default:false"` - MaxRetry int `json:"max_retry" gorm:"not null;default:0"` - RetryCount int `json:"retry_count" gorm:"not null;default:0"` - Log string `json:"log" gorm:"type:text"` - ErrorMessage string `json:"error_message" gorm:"type:text"` - Result string `json:"result" gorm:"type:text"` - StartedAt *time.Time `json:"started_at" gorm:"index"` - FinishedAt *time.Time `json:"finished_at"` - Duration int64 `json:"duration" gorm:"comment:耗时毫秒"` - Payload string `json:"payload" gorm:"type:text"` - TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TaskExecutionCleanupStats describes task execution log cleanup results. -type TaskExecutionCleanupStats struct { - HighFrequencyDeleted int64 - LowFrequencyDeleted int64 -} - -// TableName 表名 -func (TaskExecution) TableName() string { - return "w_task_executions" -} - -// ListTaskExecutionsRequest 查询任务执行记录列表请求 -type ListTaskExecutionsRequest struct { - Status string `form:"status"` - TaskType string `form:"task_type"` - TaskTypePrefix string `form:"task_type_prefix"` - // TaskTypes is a comma-separated list of exact asynq task types (IN filter). - // Used when TaskType is empty; takes precedence over TaskTypePrefix. - TaskTypes string `form:"task_types"` - Page int `form:"page"` - PageSize int `form:"page_size"` -} diff --git a/backend/OpenFlare/plugins/server/model/templates.go b/backend/OpenFlare/plugins/server/model/templates.go deleted file mode 100644 index e39f7e3a..00000000 --- a/backend/OpenFlare/plugins/server/model/templates.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "bytes" - "errors" - "strings" - "text/template" - "time" -) - -// Template 邮件/消息模板实体 -type Template struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - Key string `json:"key" gorm:"uniqueIndex;size:80;not null"` - Name string `json:"name" gorm:"size:100;not null"` - Type string `json:"type" gorm:"size:20;not null;default:'email'"` - Subject string `json:"subject" gorm:"size:255"` - Content string `json:"content" gorm:"type:text;not null"` - Description string `json:"description" gorm:"size:255"` - IsSystem bool `json:"is_system" gorm:"index;not null;default:false"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 表名 -func (*Template) TableName() string { - return "w_templates" -} - -// Normalize 规范化模板字段 -func (t *Template) Normalize() { - t.Key = strings.TrimSpace(t.Key) - t.Name = strings.TrimSpace(t.Name) - t.Type = strings.ToLower(strings.TrimSpace(t.Type)) - t.Subject = strings.TrimSpace(t.Subject) - t.Content = strings.TrimSpace(t.Content) - t.Description = strings.TrimSpace(t.Description) - if t.Type == "" { - t.Type = "email" - } -} - -// Validate 校验模板必填字段 -func (t *Template) Validate() error { - t.Normalize() - if t.Key == "" { - return errors.New(errTemplateKeyRequired) - } - if t.Name == "" { - return errors.New(errTemplateNameRequired) - } - if t.Content == "" { - return errors.New(errTemplateContentRequired) - } - return nil -} - -// Render 渲染模板的 Subject 和 Content -func (t *Template) Render(data any) (string, string, error) { - // Render Subject - var subject string - if t.Subject != "" { - tmplSubject, err := template.New(t.Key + "_subject").Parse(t.Subject) - if err != nil { - return "", "", err - } - var subBuf bytes.Buffer - if err := tmplSubject.Execute(&subBuf, data); err != nil { - return "", "", err - } - subject = subBuf.String() - } - - // Render Content - tmplContent, err := template.New(t.Key + "_content").Parse(t.Content) - if err != nil { - return "", "", err - } - var bodyBuf bytes.Buffer - if err := tmplContent.Execute(&bodyBuf, data); err != nil { - return "", "", err - } - - return subject, bodyBuf.String(), nil -} diff --git a/backend/OpenFlare/plugins/server/model/upload_stats.go b/backend/OpenFlare/plugins/server/model/upload_stats.go deleted file mode 100644 index 26cac104..00000000 --- a/backend/OpenFlare/plugins/server/model/upload_stats.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import "time" - -// Upload stats dimension keys stored in w_upload_stats.dimension. -const ( - UploadStatDimensionTotal = "total" - UploadStatDimensionType = "type" - UploadStatDimensionCategory = "category" - UploadStatDimensionTrend = "trend" -) - -// UploadStat stores incremental upload statistics keyed by dimension and stat_key. -type UploadStat struct { - Dimension string `json:"dimension" gorm:"primaryKey;size:32;not null"` - StatKey string `json:"stat_key" gorm:"primaryKey;size:64;not null;default:''"` - FileCount int64 `json:"file_count" gorm:"not null;default:0"` - FileSize int64 `json:"file_size" gorm:"not null;default:0"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName returns the upload stats table name. -func (UploadStat) TableName() string { - return "w_upload_stats" -} diff --git a/backend/OpenFlare/plugins/server/model/uploads.go b/backend/OpenFlare/plugins/server/model/uploads.go deleted file mode 100644 index 7adbce78..00000000 --- a/backend/OpenFlare/plugins/server/model/uploads.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// UploadStatus 上传状态 -type UploadStatus string - -// 上传状态 -const ( - UploadStatusPending UploadStatus = "pending" // 待使用 - UploadStatusUsed UploadStatus = "used" // 已使用 - UploadStatusDeleted UploadStatus = "deleted" // 已删除 -) - -// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据 -type UploadMetadata struct { - Width int `json:"width,omitempty"` // 图像/视频宽度 (px) - Height int `json:"height,omitempty"` // 图像/视频高度 (px) - Duration float64 `json:"duration,omitempty"` // 音视频时长 (s) - OriginalMime string `json:"original_mime,omitempty"` // 原始 MIME 类型 - UserAgent string `json:"user_agent,omitempty"` // 上传者的 UA - ClientIP string `json:"client_ip,omitempty"` // 上传者 IP - Bucket string `json:"bucket,omitempty"` // 存储桶名称 (适用于 S3 等) - Extra map[string]any `json:"extra,omitempty"` // 其它任意业务自定义元数据 -} - -// Upload 上传文件记录 -type Upload struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - UserID uint64 `json:"user_id,string" gorm:"index;not null"` - FileName string `json:"file_name" gorm:"size:255;not null"` // 原始文件名 (例如: image.png) - FilePath string `json:"file_path" gorm:"size:500;not null;index"` // 文件相对路径 / S3 Key - FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节) - MimeType string `json:"mime_type" gorm:"size:100;not null"` // 媒体类型 (MIME, 如 image/png) - Extension string `json:"extension" gorm:"size:50;not null"` // 文件后缀名 (不含点,如 png, pdf) - Hash string `json:"hash" gorm:"size:64;index"` // 文件哈希 (SHA-256/MD5,可用于排重) - Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 业务标识类型 (如 avatar, doc, attachment) - Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态 - AccessMode int `json:"access_mode" gorm:"column:access_mode;not null;default:0"` - Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"` // 业务扩展元数据 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName 表名 -func (Upload) TableName() string { - return "w_uploads" -} diff --git a/backend/OpenFlare/plugins/server/model/users.go b/backend/OpenFlare/plugins/server/model/users.go deleted file mode 100644 index ae4fdaca..00000000 --- a/backend/OpenFlare/plugins/server/model/users.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "errors" - "strconv" - "strings" - "time" - - "Wavelet/OpenFlare/plugins/server/shared" - "Wavelet/pkg/util" -) - -// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应) -type OAuthUserInfo struct { - ID uint64 `json:"id"` - Sub string `json:"sub"` - Username string `json:"username"` - PreferredUsername string `json:"preferred_username"` - Email string `json:"email"` - Name string `json:"name"` - Active bool `json:"active"` - AvatarURL string `json:"avatar_url"` -} - -// GetID 获取用户 ID -func (u *OAuthUserInfo) GetID() uint64 { - if u.ID != 0 { - return u.ID - } - // 从 sub 解析(OIDC 格式) - if u.Sub != "" { - if id, err := strconv.ParseUint(u.Sub, 10, 64); err == nil { - return id - } - } - return 0 -} - -// User 用户表实体 -type User struct { - ID uint64 `json:"id,string" gorm:"primaryKey;not null"` - Username string `json:"username" gorm:"size:64;uniqueIndex"` - Password string `json:"password,omitempty" gorm:"size:255"` - Nickname string `json:"nickname" gorm:"size:255"` - Email string `json:"email" gorm:"size:255;index"` - AvatarURL string `json:"avatar_url" gorm:"size:255"` - IsActive bool `json:"is_active" gorm:"default:true;index"` - IsAdmin bool `json:"is_admin" gorm:"default:false"` - Bio string `json:"bio" gorm:"size:500"` - Phone string `json:"phone" gorm:"size:32"` - Gender string `json:"gender" gorm:"size:16"` - Website string `json:"website" gorm:"size:255"` - Location string `json:"location" gorm:"size:255"` - LastLoginAt time.Time `json:"last_login_at" gorm:"index"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 表名 -func (*User) TableName() string { - return "w_users" -} - -// SetPassword 设置明文密码 -func (u *User) SetPassword(password string) error { - u.Password = password - return nil -} - -// SetEncryptedPassword 设置加密密码 -func (u *User) SetEncryptedPassword(password string) error { - if password == "" { - u.Password = "" - return nil - } - hashed, err := util.HashPassword(password) - if err != nil { - return err - } - u.Password = hashed - return nil -} - -// IsPasswordEncrypted 检查密码是否已加密 -func (u *User) IsPasswordEncrypted() bool { - return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$") -} - -// CheckPassword 验证密码是否匹配 -func (u *User) CheckPassword(password string) bool { - if u.Password == "" || password == "" { - return false - } - if u.IsPasswordEncrypted() { - return util.CheckPasswordHash(u.Password, password) - } - return u.Password == password -} - -// UpdateFromOAuthInfo 根据 OAuth 信息更新用户数据 -func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) { - u.Username = oauthInfo.Username - u.Nickname = oauthInfo.Name - u.Email = oauthInfo.Email - u.AvatarURL = oauthInfo.AvatarURL - u.IsActive = oauthInfo.Active - u.LastLoginAt = time.Now() -} - -// CheckActive 检查用户账户是否激活,未激活则返回错误 -func (u *User) CheckActive() error { - if !u.IsActive { - return errors.New(shared.BannedAccount) - } - return nil -} diff --git a/backend/OpenFlare/plugins/server/oauth/audit.go b/backend/OpenFlare/plugins/server/oauth/audit.go deleted file mode 100644 index 1ace8aa3..00000000 --- a/backend/OpenFlare/plugins/server/oauth/audit.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package oauth 提供 OAuth/OIDC 认证与会话管理 -package oauth - -import ( - "context" - "encoding/json" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/logger" - - "github.com/gin-gonic/gin" -) - -// LogForAudit 将登录鉴权审计日志写入 Logger -func LogForAudit(ctx context.Context, user *model.User, c *gin.Context) { - auditLog := loginRequiredAuditLog{ - UserID: user.ID, - Username: user.Username, - ClientIP: c.ClientIP(), - Method: c.Request.Method, - Path: c.Request.URL.Path, - RequestURI: c.Request.RequestURI, - UserAgent: c.Request.UserAgent(), - Referer: c.Request.Referer(), - } - auditJSON, err := json.Marshal(auditLog) - if err != nil { - logger.ErrorF(ctx, "[LoginRequiredAudit] marshal failed: %v", err) - logger.DebugF(ctx, "[LoginRequiredAudit] %s %d %s", c.ClientIP(), user.ID, user.Username) - } else { - logger.DebugF(ctx, "[LoginRequiredAudit] %s", auditJSON) - } -} diff --git a/backend/OpenFlare/plugins/server/oauth/auth_source_resolver.go b/backend/OpenFlare/plugins/server/oauth/auth_source_resolver.go deleted file mode 100644 index 4b528516..00000000 --- a/backend/OpenFlare/plugins/server/oauth/auth_source_resolver.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "errors" - "slices" - "strings" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -func isOIDCLoginEnabled(ctx context.Context) bool { - enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled) - if err != nil { - return true - } - return enabled -} - -func resolveAuthSource(ctx context.Context, sourceName string) (*model.AuthSource, error) { - name := strings.TrimSpace(strings.ToLower(sourceName)) - if name == "" { - sources, err := repository.GetActiveAuthSourcesCached(ctx) - if err != nil { - return nil, err - } - if len(sources) == 0 { - return nil, errors.New(errNoActiveAuthSource) - } - return repository.GetAuthSourceByNameCached(ctx, sources[0].Name) - } - return repository.GetAuthSourceByNameCached(ctx, name) -} - -func activeLoginSources(ctx context.Context) []AuthSourceView { - enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled) - if err == nil && !enabled { - return nil - } - - dbSources, err := repository.GetActiveAuthSourcesCached(ctx) - if err != nil { - return nil - } - sources := make([]AuthSourceView, 0, len(dbSources)) - for _, source := range dbSources { - sources = append(sources, AuthSourceView{ - ID: source.ID, - Name: source.Name, - Type: source.Type, - DisplayName: source.DisplayName, - IsActive: source.IsActive, - IconURL: source.IconURL, - ClientSecretConfigured: source.ClientSecretConfigured, - }) - } - return sources -} - -func getFrontendLoginRedirectURL(ctx context.Context) (string, error) { - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress) - if err != nil || strings.TrimSpace(sc.Value) == "" { - return "", errors.New(errServerAddressMissing) - } - return strings.TrimRight(sc.Value, "/") + "/login", nil -} - -func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { - if source == nil { - return nil, nil, errors.New(errAuthSourceRequired) - } - - if source.OpenIDDiscoveryURL == "" { - return nil, nil, errors.New(errDiscoveryURLRequired) - } - - // Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake) - issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/") - issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration") - issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server") - - // 使用进程级缓存获取 provider,避免每次调用都向 issuer 发起 - // /.well-known/openid-configuration HTTP 请求。 - provider, err := globalOIDCProviderCache.get(ctx, issuer) - if err != nil { - return nil, nil, err - } - verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID}) - scopes := strings.Fields(source.Scopes) - if len(scopes) == 0 { - scopes = []string{oidc.ScopeOpenID, "profile", "email"} - } - if !containsScope(scopes, oidc.ScopeOpenID) { - scopes = append([]string{oidc.ScopeOpenID}, scopes...) - } - - return &oauth2.Config{ - ClientID: source.ClientID, - ClientSecret: source.ClientSecret, - RedirectURL: redirectURL, - Scopes: scopes, - Endpoint: provider.Endpoint(), - }, verifier, nil -} - -func containsScope(scopes []string, scope string) bool { - return slices.Contains(scopes, scope) -} diff --git a/backend/OpenFlare/plugins/server/oauth/cache.go b/backend/OpenFlare/plugins/server/oauth/cache.go deleted file mode 100644 index b1a235e2..00000000 --- a/backend/OpenFlare/plugins/server/oauth/cache.go +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "errors" - "fmt" - "strconv" - "sync" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/cache/ram" - "Wavelet/pkg/util" -) - -const ( - tokenCacheTTL = 5 * time.Minute - userCacheTTL = 5 * time.Minute - - //nolint:gosec // This is a Redis Pub/Sub channel name, not a credential - oauthTokenInvalidationChannel = "oauth:token_invalidation" - oauthUserInvalidationChannel = "oauth:user_invalidation" -) - -var ( - tokenRAM = ram.MustNew[string, *model.AccessToken](ram.Options{MaximumSize: 2048}) - userRAM = ram.MustNew[uint64, *model.User](ram.Options{MaximumSize: 2048}) - - tokenListenerOnce sync.Once - tokenListenerCtx context.Context - tokenListenerCancel context.CancelFunc - tokenListenerDone chan struct{} - - userListenerOnce sync.Once - userListenerCtx context.Context - userListenerCancel context.CancelFunc - userListenerDone chan struct{} -) - -func tokenCacheKey(tokenHash string) string { - return "oauth:token:" + tokenHash -} - -func userCacheKey(userID uint64) string { - return fmt.Sprintf("oauth:user:%d", userID) -} - -func ensureTokenCacheListener() { - if db.Redis == nil { - return - } - tokenListenerOnce.Do(startTokenCacheInvalidationListener) -} - -func startTokenCacheInvalidationListener() { - tokenListenerCtx, tokenListenerCancel = context.WithCancel(context.Background()) - tokenListenerDone = make(chan struct{}) - - redisClient := db.Redis // 捕获当前客户端:goroutine 不读可变全局,避免与测试置空 db.Redis 竞争 - util.Go(func() { - listenerCtx := tokenListenerCtx - defer close(tokenListenerDone) - - pubsub := redisClient.Subscribe(listenerCtx, oauthTokenInvalidationChannel) - defer func() { - _ = pubsub.Close() - }() - - util.Go(func() { - <-listenerCtx.Done() - _ = pubsub.Close() - }) - - for msg := range pubsub.Channel() { - tokenHash := msg.Payload - if tokenHash == "" || tokenHash == "*" || tokenHash == "reset" { - tokenRAM.InvalidateAll() - } else { - tokenRAM.Invalidate(tokenHash) - } - } - }) -} - -func publishTokenRAMInvalidation(ctx context.Context, tokenHash string) { - if db.Redis == nil { - return - } - _ = db.Redis.Publish(ctx, oauthTokenInvalidationChannel, tokenHash).Err() -} - -func ensureUserCacheListener() { - if db.Redis == nil { - return - } - userListenerOnce.Do(startUserCacheInvalidationListener) -} - -func startUserCacheInvalidationListener() { - userListenerCtx, userListenerCancel = context.WithCancel(context.Background()) - userListenerDone = make(chan struct{}) - - redisClient := db.Redis // 捕获当前客户端:goroutine 不读可变全局,避免与测试置空 db.Redis 竞争 - util.Go(func() { - listenerCtx := userListenerCtx - defer close(userListenerDone) - - pubsub := redisClient.Subscribe(listenerCtx, oauthUserInvalidationChannel) - defer func() { - _ = pubsub.Close() - }() - - util.Go(func() { - <-listenerCtx.Done() - _ = pubsub.Close() - }) - - for msg := range pubsub.Channel() { - userIDStr := msg.Payload - if userIDStr == "" || userIDStr == "*" || userIDStr == "reset" { - userRAM.InvalidateAll() - } else if userID, err := strconv.ParseUint(userIDStr, 10, 64); err == nil { - userRAM.Invalidate(userID) - } - } - }) -} - -func publishUserRAMInvalidation(ctx context.Context, userID uint64) { - if db.Redis == nil { - return - } - _ = db.Redis.Publish(ctx, oauthUserInvalidationChannel, strconv.FormatUint(userID, 10)).Err() -} - -// GetCachedToken 获取缓存的 AccessToken -func GetCachedToken(ctx context.Context, tokenHash string) (*model.AccessToken, error) { - ensureTokenCacheListener() - - if val, ok := tokenRAM.GetIfPresent(tokenHash); ok { - return val, nil - } - - if db.Redis != nil { - var token model.AccessToken - key := tokenCacheKey(tokenHash) - if err := db.GetJSON(ctx, key, &token); err == nil { - // Write back to local cache - tokenRAM.Set(tokenHash, &token) - return &token, nil - } - } - return nil, errors.New("cache miss") -} - -// SetCachedToken 设置 AccessToken 缓存 -func SetCachedToken(ctx context.Context, tokenHash string, token *model.AccessToken) { - ensureTokenCacheListener() - - tokenRAM.Set(tokenHash, token) - if db.Redis != nil { - key := tokenCacheKey(tokenHash) - _ = db.SetJSON(ctx, key, token, tokenCacheTTL) - } -} - -// InvalidateCachedToken 吊销/删除 token 缓存 -func InvalidateCachedToken(ctx context.Context, tokenHash string) { - ensureTokenCacheListener() - - tokenRAM.Invalidate(tokenHash) - if db.Redis != nil { - key := tokenCacheKey(tokenHash) - _ = db.Redis.Del(ctx, db.PrefixedKey(key)).Err() - publishTokenRAMInvalidation(ctx, tokenHash) - } -} - -// GetCachedUser 获取缓存的 User -func GetCachedUser(ctx context.Context, userID uint64) (*model.User, error) { - ensureUserCacheListener() - - if val, ok := userRAM.GetIfPresent(userID); ok { - return val, nil - } - - if db.Redis != nil { - var u model.User - key := userCacheKey(userID) - if err := db.GetJSON(ctx, key, &u); err == nil { - // Write back to local cache - userRAM.Set(userID, &u) - return &u, nil - } - } - return nil, errors.New("cache miss") -} - -// SetCachedUser 设置 User 缓存 -func SetCachedUser(ctx context.Context, userID uint64, u *model.User) { - ensureUserCacheListener() - - userRAM.Set(userID, u) - if db.Redis != nil { - key := userCacheKey(userID) - _ = db.SetJSON(ctx, key, u, userCacheTTL) - } -} - -// InvalidateCachedUser 吊销/失效 User 缓存 -func InvalidateCachedUser(ctx context.Context, userID uint64) { - ensureUserCacheListener() - - userRAM.Invalidate(userID) - if db.Redis != nil { - key := userCacheKey(userID) - _ = db.Redis.Del(ctx, db.PrefixedKey(key)).Err() - publishUserRAMInvalidation(ctx, userID) - } -} - -// StopOauthCacheListener stops both token and user Redis Pub/Sub subscription listeners and resets the sync.Once guards. -func StopOauthCacheListener() { - if tokenListenerCancel != nil { - tokenListenerCancel() - if tokenListenerDone != nil { - <-tokenListenerDone - } - tokenListenerCancel = nil - tokenListenerDone = nil - } - tokenListenerOnce = sync.Once{} - - if userListenerCancel != nil { - userListenerCancel() - if userListenerDone != nil { - <-userListenerDone - } - userListenerCancel = nil - userListenerDone = nil - } - userListenerOnce = sync.Once{} -} - -// ResetOauthRAMCacheForTest clears only the process-local RAM cache. -func ResetOauthRAMCacheForTest() { - tokenRAM.InvalidateAll() - userRAM.InvalidateAll() -} diff --git a/backend/OpenFlare/plugins/server/oauth/cache_test.go b/backend/OpenFlare/plugins/server/oauth/cache_test.go deleted file mode 100644 index 6c6a8ac7..00000000 --- a/backend/OpenFlare/plugins/server/oauth/cache_test.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" -) - -func setupOauthCacheTest(t *testing.T) (*miniredis.Miniredis, func()) { - t.Helper() - - miniRedis, err := miniredis.Run() - if err != nil { - t.Fatalf("failed to start miniredis: %v", err) - } - - db.Redis = redis.NewClient(&redis.Options{ - Addr: miniRedis.Addr(), - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, - }) - - ResetOauthRAMCacheForTest() - - cleanup := func() { - StopOauthCacheListener() - ResetOauthRAMCacheForTest() - db.Redis.Close() - miniRedis.Close() - db.Redis = nil - } - return miniRedis, cleanup -} - -func TestTokenCache_GetSetInvalidate(t *testing.T) { - _, cleanup := setupOauthCacheTest(t) - defer cleanup() - ctx := context.Background() - - tokenHash := "test-token-hash" - token := &model.AccessToken{ - ID: 123, - UserID: 456, - TokenHash: tokenHash, - Name: "test-token", - } - - // 1. Get from empty cache -> miss - _, err := GetCachedToken(ctx, tokenHash) - if err == nil { - t.Fatal("expected cache miss for un-cached token") - } - - // 2. Set to cache - SetCachedToken(ctx, tokenHash, token) - - // 3. Get from cache -> hit - cached, err := GetCachedToken(ctx, tokenHash) - if err != nil { - t.Fatalf("GetCachedToken() failed: %v", err) - } - if cached.ID != token.ID || cached.UserID != token.UserID { - t.Fatalf("expected cached token %+v, got %+v", token, cached) - } - - // 4. Invalidate cache - InvalidateCachedToken(ctx, tokenHash) - - // 5. Get from cache -> miss - _, err = GetCachedToken(ctx, tokenHash) - if err == nil { - t.Fatal("expected cache miss after invalidation") - } -} - -func TestUserCache_GetSetInvalidate(t *testing.T) { - _, cleanup := setupOauthCacheTest(t) - defer cleanup() - ctx := context.Background() - - userID := uint64(789) - user := &model.User{ - ID: userID, - Username: "testuser", - Email: "test@example.com", - } - - // 1. Get from empty cache -> miss - _, err := GetCachedUser(ctx, userID) - if err == nil { - t.Fatal("expected cache miss for un-cached user") - } - - // 2. Set to cache - SetCachedUser(ctx, userID, user) - - // 3. Get from cache -> hit - cached, err := GetCachedUser(ctx, userID) - if err != nil { - t.Fatalf("GetCachedUser() failed: %v", err) - } - if cached.ID != user.ID || cached.Username != user.Username { - t.Fatalf("expected cached user %+v, got %+v", user, cached) - } - - // 4. Invalidate cache - InvalidateCachedUser(ctx, userID) - - // 5. Get from cache -> miss - _, err = GetCachedUser(ctx, userID) - if err == nil { - t.Fatal("expected cache miss after invalidation") - } -} - -func TestOauthCache_PubSubInvalidation(t *testing.T) { - _, cleanup := setupOauthCacheTest(t) - defer cleanup() - ctx := context.Background() - - tokenHash := "pubsub-token-hash" - token := &model.AccessToken{ - ID: 111, - UserID: 222, - TokenHash: tokenHash, - } - - userID := uint64(333) - user := &model.User{ - ID: userID, - Username: "pubsubuser", - } - - // Set caches so they are stored in RAM - SetCachedToken(ctx, tokenHash, token) - SetCachedUser(ctx, userID, user) - - // Verify they are cached - if _, ok := tokenRAM.GetIfPresent(tokenHash); !ok { - t.Fatal("expected token to be in RAM cache") - } - if _, ok := userRAM.GetIfPresent(userID); !ok { - t.Fatal("expected user to be in RAM cache") - } - - // Give Pub/Sub subscription time to establish - time.Sleep(100 * time.Millisecond) - - // Publish invalidation messages directly to simulate peer node invalidation - if err := db.Redis.Publish(ctx, oauthTokenInvalidationChannel, tokenHash).Err(); err != nil { - t.Fatalf("publish token invalidation error: %v", err) - } - if err := db.Redis.Publish(ctx, oauthUserInvalidationChannel, "333").Err(); err != nil { - t.Fatalf("publish user invalidation error: %v", err) - } - - // Wait for background pubsub handlers to process messages - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - _, tokenOk := tokenRAM.GetIfPresent(tokenHash) - _, userOk := userRAM.GetIfPresent(userID) - if !tokenOk && !userOk { - break - } - time.Sleep(10 * time.Millisecond) - } - - if _, ok := tokenRAM.GetIfPresent(tokenHash); ok { - t.Fatal("expected token RAM cache to be invalidated by Pub/Sub") - } - if _, ok := userRAM.GetIfPresent(userID); ok { - t.Fatal("expected user RAM cache to be invalidated by Pub/Sub") - } -} - -func TestOauthCache_PubSubResetAll(t *testing.T) { - _, cleanup := setupOauthCacheTest(t) - defer cleanup() - ctx := context.Background() - - tokenHash := "reset-token-hash" - token := &model.AccessToken{ - ID: 444, - UserID: 555, - TokenHash: tokenHash, - } - - userID := uint64(666) - user := &model.User{ - ID: userID, - Username: "resetuser", - } - - SetCachedToken(ctx, tokenHash, token) - SetCachedUser(ctx, userID, user) - - // Give Pub/Sub subscription time to establish - time.Sleep(100 * time.Millisecond) - - // Publish dynamic reset/wildcard to clear all - if err := db.Redis.Publish(ctx, oauthTokenInvalidationChannel, "*").Err(); err != nil { - t.Fatalf("publish token reset error: %v", err) - } - if err := db.Redis.Publish(ctx, oauthUserInvalidationChannel, "reset").Err(); err != nil { - t.Fatalf("publish user reset error: %v", err) - } - - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - _, tokenOk := tokenRAM.GetIfPresent(tokenHash) - _, userOk := userRAM.GetIfPresent(userID) - if !tokenOk && !userOk { - break - } - time.Sleep(10 * time.Millisecond) - } - - if _, ok := tokenRAM.GetIfPresent(tokenHash); ok { - t.Fatal("expected token RAM cache to be fully cleared by '*'") - } - if _, ok := userRAM.GetIfPresent(userID); ok { - t.Fatal("expected user RAM cache to be fully cleared by 'reset'") - } -} diff --git a/backend/OpenFlare/plugins/server/oauth/constants.go b/backend/OpenFlare/plugins/server/oauth/constants.go deleted file mode 100644 index b58039fe..00000000 --- a/backend/OpenFlare/plugins/server/oauth/constants.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "encoding/json" - "time" -) - -// Session 用户信息字段 Key -const ( - UserNameKey = "username" - UserIDKey = "user_id" - UserObjKey = "user_obj" - TokenAuthKey = "token_auth" // 标记当前请求是否通过 Access Token 鉴权 - TokenAdminKey = "token_admin" // Access Token 本身是否具有管理员权限 - SessionTokenKey = "oauth_session_token" //nolint:gosec // false positive: this is a session key, not hardcoded credentials - PasswordHashKey = "password_hash" -) - -// OAuth State 缓存 Key 格式与过期时间 -const ( - OAuthStateCacheKeyFormat = "oauth:state:%s" - OAuthStateCacheKeyExpiration = 10 * time.Minute - oauthStateLimitKeyFormat = "oauth:state-limit:%s" - oauthStateLimitMax = 20 -) - -// OAuth 授权用途常量 -const ( - OAuthPurposeLogin = "login" - OAuthPurposeBind = "bind" -) - -type oauthStatePayload struct { - SourceName string `json:"source_name"` - Purpose string `json:"purpose"` - UserID uint64 `json:"user_id,omitempty"` - SessionHash string `json:"session_hash"` -} - -func encodeOAuthStatePayload(payload oauthStatePayload) (string, error) { - data, err := json.Marshal(payload) - if err != nil { - return "", err - } - return string(data), nil -} - -func decodeOAuthStatePayload(value string) (oauthStatePayload, error) { - var payload oauthStatePayload - if err := json.Unmarshal([]byte(value), &payload); err != nil { - return oauthStatePayload{}, err - } - return payload, nil -} diff --git a/backend/OpenFlare/plugins/server/oauth/errs.go b/backend/OpenFlare/plugins/server/oauth/errs.go deleted file mode 100644 index e25a78f1..00000000 --- a/backend/OpenFlare/plugins/server/oauth/errs.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -// OAuth 认证相关错误消息 -const ( - errInvalidState = "非法登录请求" - errIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errIDTokenVerifyFailedFormat = "%s: %w" - errNonceMismatch = "nonce 不匹配,可能存在重放攻击" - errNoActiveAuthSource = "未配置可用认证源" - errServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试" - errAuthSourceRequired = "认证源不能为空" - errDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" - errUsernameGenerateFailed = "无法生成可用用户名" - errUsernameFromSourceFailed = "无法从认证源获取用户名" - errAuthSourceDisabled = "认证源未启用" - errInvalidExternalAccountBindingID = "绑定记录 ID 无效" - ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials - errOAuthStateRateLimited = "登录请求过于频繁,请稍后再试" -) diff --git a/backend/OpenFlare/plugins/server/oauth/gin_context.go b/backend/OpenFlare/plugins/server/oauth/gin_context.go deleted file mode 100644 index 646f50a0..00000000 --- a/backend/OpenFlare/plugins/server/oauth/gin_context.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import "github.com/gin-gonic/gin" - -// GetFromContext 从 Gin 请求上下文获取指定类型的值。 -func GetFromContext[T any](c *gin.Context, key string) (T, bool) { - value, exists := c.Get(key) - if !exists { - var zero T - return zero, false - } - typed, ok := value.(T) - return typed, ok -} - -// SetToContext 设置值到 Gin 请求上下文。 -func SetToContext[T any](c *gin.Context, key string, value T) { - c.Set(key, value) -} diff --git a/backend/OpenFlare/plugins/server/oauth/handler_authorize.go b/backend/OpenFlare/plugins/server/oauth/handler_authorize.go deleted file mode 100644 index d761bccb..00000000 --- a/backend/OpenFlare/plugins/server/oauth/handler_authorize.go +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/shared" - "Wavelet/pkg/response" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -// GetLoginURL 获取登录授权地址 -// @Summary 获取登录授权地址 -// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。 -// @Tags oauth -// @Produce json -// @Param source query string false "认证源名称,为空使用第一个启用的认证源" -// @Success 200 {object} response.Any{data=oauth.OAuthAuthorizeResponse} "授权 URL" -// @Failure 400 {object} response.Any "认证源不存在或未配置" -// @Failure 500 {object} response.Any "Redis 异常 or 构造 URL 失败" -// @Router /api/v1/oauth/login [get] -func GetLoginURL(c *gin.Context) { - ctx := c.Request.Context() - if !isOIDCLoginEnabled(ctx) { - response.AbortBadRequest(c, errAuthSourceDisabled) - return - } - - source, err := resolveAuthSource(ctx, c.Query("source")) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if !source.IsActive { - response.AbortBadRequest(c, errAuthSourceDisabled) - return - } - - session := sessions.Default(c) - token, isNew := ensureSessionToken(session) - if isNew { - if err := session.Save(); err != nil { - response.AbortInternal(c, err.Error()) - return - } - } - - userID := GetUserIDFromSession(session) - sessionHash := hashSessionToken(token) - if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - state := uuid.NewString() - payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ - SourceName: source.Name, - Purpose: OAuthPurposeLogin, - UserID: userID, - SessionHash: sessionHash, - }) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { - response.AbortInternal(c, err.Error()) - return - } - authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) -} - -func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state string) (string, error) { - redirectURL, err := getFrontendLoginRedirectURL(ctx) - if err != nil { - return "", err - } - authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL) - if err != nil { - return "", err - } - if verifier != nil { - return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil - } - return authConfig.AuthCodeURL(state), nil -} - -func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error { - if db.Redis == nil || sessionHash == "" { - return nil - } - key := db.PrefixedKey(fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash)) - n, err := db.Redis.Incr(ctx, key).Result() - if err != nil { - return err - } - if n == 1 { - _ = db.Redis.Expire(ctx, key, OAuthStateCacheKeyExpiration).Err() - } - if n > oauthStateLimitMax { - return errors.New(errOAuthStateRateLimited) - } - return nil -} - -// Authorize 发起指定认证源授权 -// @Summary 发起指定认证源授权 -// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 -// @Tags oauth -// @Produce json -// @Param source path string true "认证源名称" -// @Param purpose query string false "授权目的:login(登录)或 bind(绑定账号),默认 login" -// @Success 200 {object} response.Any{data=oauth.OAuthAuthorizeResponse} "授权 URL" -// @Failure 400 {object} response.Any "认证源不存在或未启用" -// @Failure 500 {object} response.Any "Redis 异常或构造 URL 失败" -// @Router /api/v1/oauth/{source}/authorize [get] -func Authorize(c *gin.Context) { - ctx := c.Request.Context() - if !isOIDCLoginEnabled(ctx) { - response.AbortBadRequest(c, errAuthSourceDisabled) - return - } - - source, err := resolveAuthSource(ctx, c.Param("source")) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if !source.IsActive { - response.AbortBadRequest(c, errAuthSourceDisabled) - return - } - purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose"))) - if purpose != OAuthPurposeBind { - purpose = OAuthPurposeLogin - } - - session := sessions.Default(c) - userID := GetUserIDFromSession(session) - if purpose == OAuthPurposeBind && userID == 0 { - response.AbortUnauthorized(c, shared.UnAuthorized) - return - } - - token, isNew := ensureSessionToken(session) - if isNew { - if err := session.Save(); err != nil { - response.AbortInternal(c, err.Error()) - return - } - } - - sessionHash := hashSessionToken(token) - if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - state := uuid.NewString() - payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ - SourceName: source.Name, - Purpose: purpose, - UserID: userID, - SessionHash: sessionHash, - }) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { - response.AbortInternal(c, err.Error()) - return - } - - authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) -} diff --git a/backend/OpenFlare/plugins/server/oauth/handler_callback.go b/backend/OpenFlare/plugins/server/oauth/handler_callback.go deleted file mode 100644 index 87be6550..00000000 --- a/backend/OpenFlare/plugins/server/oauth/handler_callback.go +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/listener" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/shared" - "Wavelet/pkg/logger" - "Wavelet/pkg/response" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// Callback OAuth 回调处理 -// @Summary OAuth 回调处理 -// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 -// @Tags oauth -// @Accept json -// @Produce json -// @Param request body oauth.CallbackRequest true "回调请求参数" -// @Success 200 {object} response.Any{data=oauth.OAuthCallbackResult} "登录或绑定成功" -// @Failure 400 {object} response.Any "state 无效、参数错误或认证源错误" -// @Failure 401 {object} response.Any "绑定场景未登录" -// @Failure 500 {object} response.Any "OAuth 认证失败或内部错误" -// @Router /api/v1/oauth/callback [post] -func Callback(c *gin.Context) { - var req CallbackRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - ctx := c.Request.Context() - stateKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)) - payloadRaw, err := db.Redis.Get(ctx, stateKey).Result() - if err != nil { - response.AbortBadRequest(c, errInvalidState) - return - } - _ = db.Redis.Del(ctx, stateKey) - - payload, err := decodeOAuthStatePayload(payloadRaw) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - session := sessions.Default(c) - currentUserID := GetUserIDFromSession(session) - - if payload.Purpose == OAuthPurposeBind && currentUserID == 0 { - response.AbortUnauthorized(c, shared.UnAuthorized) - return - } - - token, ok := session.Get(SessionTokenKey).(string) - if !ok || token == "" { - response.AbortBadRequest(c, "invalid session context") - return - } - - if hashSessionToken(token) != payload.SessionHash { - response.AbortBadRequest(c, "session mismatch for oauth state") - return - } - - if payload.Purpose == OAuthPurposeBind && currentUserID != payload.UserID { - response.AbortBadRequest(c, "user context mismatch for oauth binding") - return - } - - if !isOIDCLoginEnabled(ctx) { - response.AbortBadRequest(c, errAuthSourceDisabled) - return - } - - source, err := resolveAuthSource(ctx, payload.SourceName) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - if !source.IsActive { - response.AbortBadRequest(c, errAuthSourceDisabled) - return - } - - redirectURL, err := getFrontendLoginRedirectURL(ctx) - if err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - - userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - if err := normalizeOAuthUserInfo(userInfo); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - if userInfo.Sub == "" { - userInfo.Sub = userInfo.Username - } - - if payload.Purpose == OAuthPurposeBind { - handleCallbackBind(ctx, c, source, userInfo) - return - } - - handleCallbackLogin(ctx, c, source, userInfo) -} - -// handleCallbackBind 处理 OAuth 回调中的帐号绑定流程 -func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) { - userID := GetUserIDFromContext(c) - if userID == 0 { - response.AbortUnauthorized(c, shared.UnAuthorized) - return - } - user, err := repository.GetUserByID(ctx, userID) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - if err := repository.BindExternalAccount(ctx, &model.ExternalAccount{ - AuthSourceID: source.ID, - UserID: user.ID, - ExternalID: userInfo.Sub, - ExternalUsername: userInfo.Username, - Email: userInfo.Email, - }); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - user.LastLoginAt = time.Now() - _ = repository.UpdateUserLastLoginAt(ctx, user.ID, user.LastLoginAt) - c.JSON(http.StatusOK, response.OK(buildCallbackResult(&user, "bound"))) -} - -// handleCallbackLogin 处理 OAuth 回调中的登录流程(查找已有帐号或自动注册) -func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) { - var user model.User - - account, err := repository.FindExternalAccount(ctx, source.ID, userInfo.Sub) - switch { - case err == nil: - loaded, loadErr := repository.GetUserByID(ctx, account.UserID) - if loadErr != nil { - response.AbortInternal(c, loadErr.Error()) - return - } - user = loaded - case errors.Is(err, gorm.ErrRecordNotFound): - newUser, ok := handleCallbackRegister(ctx, c, source, userInfo) - if !ok { - return - } - user = newUser - default: - response.AbortInternal(c, err.Error()) - return - } - - user.LastLoginAt = time.Now() - _ = repository.UpdateUserLastLoginAt(ctx, user.ID, user.LastLoginAt) - if err := SetLoginSession(ctx, c, &user); err != nil { - response.AbortInternal(c, err.Error()) - return - } - - SetCachedUser(ctx, user.ID, &user) - - logger.InfoF(ctx, "[LoginAudit] successful OAuth login via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP()) - - listener.EmitAdminLoggedIn(ctx, &user, c.ClientIP()) - - c.JSON(http.StatusOK, response.OK(buildCallbackResult(&user, "logged_in"))) -} - -// handleCallbackRegister 处理 OAuth 回调中的自动注册流程 -// 若注册被禁用则保存 pending 信息并返回 false;若注册成功则返回新用户;若出错则返回 false -func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) (model.User, bool) { - registrationEnabled, regErr := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled) - if regErr != nil { - registrationEnabled = false - } - - if !registrationEnabled { - c.JSON(http.StatusOK, response.OK(buildCallbackResult(nil, "need_bind"))) - return model.User{}, false - } - - username, uniqueErr := uniqueUsername(ctx, userInfo.Username) - if uniqueErr != nil { - response.AbortInternal(c, uniqueErr.Error()) - return model.User{}, false - } - userInfo.Username = username - - var user model.User - if err := repository.CreateUserFromOAuth(ctx, &user, userInfo); err != nil { - response.AbortInternal(c, err.Error()) - return model.User{}, false - } - if err := repository.BindExternalAccount(ctx, &model.ExternalAccount{ - AuthSourceID: source.ID, - UserID: user.ID, - ExternalID: userInfo.Sub, - ExternalUsername: userInfo.Username, - Email: userInfo.Email, - }); err != nil { - response.AbortBadRequest(c, err.Error()) - return model.User{}, false - } - logger.InfoF(ctx, "[LoginAudit] successful OAuth registration via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP()) - - return user, true -} diff --git a/backend/OpenFlare/plugins/server/oauth/handler_external_accounts.go b/backend/OpenFlare/plugins/server/oauth/handler_external_accounts.go deleted file mode 100644 index 406d240d..00000000 --- a/backend/OpenFlare/plugins/server/oauth/handler_external_accounts.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "net/http" - "strconv" - "strings" - - "Wavelet/OpenFlare/plugins/server/repository" - - "Wavelet/OpenFlare/plugins/server/shared" - "Wavelet/pkg/response" - - "github.com/gin-gonic/gin" -) - -// ListExternalAccounts 获取当前用户的外部帐号绑定列表 -// @Summary 获取外部帐号列表 -// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 -// @Tags oauth -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.ExternalAccountView} "外部帐号列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/oauth/external-accounts [get] -func ListExternalAccounts(c *gin.Context) { - userID := GetUserIDFromContext(c) - accounts, err := repository.ListExternalAccountsByUserID(c.Request.Context(), userID) - if err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OK(accounts)) -} - -// DeleteExternalAccount 解除外部帐号绑定 -// @Summary 解除外部帐号绑定 -// @Description 解除当前登录用户与指定外部帐号的绑定关系,需要登录 -// @Tags oauth -// @Produce json -// @Security SessionCookie -// @Param id path uint64 true "外部帐号绑定记录 ID" -// @Success 200 {object} response.Any{data=string} "解除绑定成功" -// @Failure 400 {object} response.Any "ID 无效或解除失败" -// @Failure 401 {object} response.Any "未登录" -// @Router /api/v1/oauth/external-accounts/{id}/delete [post] -func DeleteExternalAccount(c *gin.Context) { - userID := GetUserIDFromContext(c) - if userID == 0 { - response.AbortUnauthorized(c, shared.UnAuthorized) - return - } - rawID := strings.TrimSpace(c.Param("id")) - id, err := strconv.ParseUint(rawID, 10, 64) - if err != nil || id == 0 { - response.AbortBadRequest(c, errInvalidExternalAccountBindingID) - return - } - if err := repository.DeleteExternalAccountForUser(c.Request.Context(), id, userID); err != nil { - response.AbortBadRequest(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/oauth/handler_sources.go b/backend/OpenFlare/plugins/server/oauth/handler_sources.go deleted file mode 100644 index 15b10a20..00000000 --- a/backend/OpenFlare/plugins/server/oauth/handler_sources.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "net/http" - - "Wavelet/pkg/response" - - "github.com/gin-gonic/gin" -) - -// GetLoginSources 获取可用登录源列表 -// @Summary 获取可用登录源 -// @Description 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 -// @Tags oauth -// @Produce json -// @Success 200 {object} response.Any{data=[]oauth.AuthSourceView} "登录源列表" -// @Router /api/v1/oauth/sources [get] -func GetLoginSources(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(activeLoginSources(c.Request.Context()))) -} diff --git a/backend/OpenFlare/plugins/server/oauth/middlewares.go b/backend/OpenFlare/plugins/server/oauth/middlewares.go deleted file mode 100644 index 74db58ef..00000000 --- a/backend/OpenFlare/plugins/server/oauth/middlewares.go +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "errors" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/shared" - "Wavelet/pkg/response" - - otel_trace "Wavelet/pkg/trace" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -type loginRequiredAuditLog struct { - UserID uint64 `json:"user_id"` - Username string `json:"username"` - ClientIP string `json:"client_ip"` - Method string `json:"method"` - Path string `json:"path"` - RequestURI string `json:"request_uri"` - UserAgent string `json:"user_agent"` - Referer string `json:"referer"` -} - -func getUserByToken(ctx context.Context, tokenStr string) (*model.User, *model.AccessToken, error) { - tokenHash := model.HashToken(tokenStr) - tokenRecord, err := GetCachedToken(ctx, tokenHash) - if err != nil { - dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash) - if err != nil { - return nil, nil, err - } - tokenRecord = &dbToken - SetCachedToken(ctx, tokenHash, tokenRecord) - } - - user, err := GetCachedUser(ctx, tokenRecord.UserID) - if err != nil || !user.IsActive { - dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID) - if err != nil { - return nil, nil, err - } - user = &dbUser - SetCachedUser(ctx, tokenRecord.UserID, user) - } - return user, tokenRecord, nil -} - -// GetUserFromRequest 校验 Access Token 或 Session 并返回用户对象,如果未登录或用户失效则返回 error -func GetUserFromRequest(c *gin.Context) (*model.User, error) { - ctx := c.Request.Context() - - // check token in headers - tokenStr := c.GetHeader("X-Access-Token") - if tokenStr == "" { - authHeader := c.GetHeader("Authorization") - if len(authHeader) > 7 && authHeader[:7] == "Bearer " { - tokenStr = authHeader[7:] - } - } - - // 优先使用 Access Token 鉴权 - if tokenStr != "" { - if user, tokenRecord, err := getUserByToken(ctx, tokenStr); err == nil { - // 强行阻止 system 用户任何会话/Token 鉴权通过 - if user.Username == "system" { - return nil, errors.New("system user is not allowed to login") - } - SetToContext(c, TokenAuthKey, true) - SetToContext(c, TokenAdminKey, tokenRecord.IsAdmin) - return user, nil - } - } - - // 降级使用 Session 鉴权 - userID := GetUserIDFromContext(c) - if userID <= 0 { - return nil, errors.New("unauthorized") - } - - user, err := GetCachedUser(ctx, userID) - if err != nil || !user.IsActive { - // load user from db to make sure is active - dbUser, loadErr := repository.GetActiveUserByID(ctx, userID) - if loadErr != nil { - return nil, loadErr - } - user = &dbUser - SetCachedUser(ctx, userID, user) - } - - // 密码哈希校验:当用户存在本地密码时,要求 Session 中的密码哈希必须与当前数据库中一致 - if user.Password != "" { - session := sessions.Default(c) - sessionHash, _ := session.Get(PasswordHashKey).(string) - if sessionHash != user.Password { - return nil, errors.New("session expired due to password change") - } - } - - // set keys in context for session auth - SetToContext(c, TokenAuthKey, false) - SetToContext(c, TokenAdminKey, false) - - // 强行阻止 system 用户任何会话/Token 鉴权通过 - if user.Username == "system" { - return nil, errors.New("system user is not allowed to login") - } - - return user, nil -} - -// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session -func LoginRequired() gin.HandlerFunc { - return func(c *gin.Context) { - // init trace - ctx, span := otel_trace.Start(c.Request.Context(), "LoginRequired") - defer span.End() - - user, err := GetUserFromRequest(c) - if err != nil { - response.AbortUnauthorized(c, shared.UnAuthorized) - return - } - - // log - LogForAudit(ctx, user, c) - - // set user info - SetToContext(c, UserObjKey, user) - - // next - c.Next() - } -} - -// DisallowTokenAuth 拒绝使用 Access Token 进行身份验证的请求访问该端点 -func DisallowTokenAuth() gin.HandlerFunc { - return func(c *gin.Context) { - if tokenAuth, _ := GetFromContext[bool](c, TokenAuthKey); tokenAuth { - response.AbortForbidden(c, ErrTokenAuthNotAllowed) - return - } - c.Next() - } -} diff --git a/backend/OpenFlare/plugins/server/oauth/oauth_test.go b/backend/OpenFlare/plugins/server/oauth/oauth_test.go deleted file mode 100644 index 6fc7a2be..00000000 --- a/backend/OpenFlare/plugins/server/oauth/oauth_test.go +++ /dev/null @@ -1,1339 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/rsa" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "strings" - "testing" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" - "github.com/go-jose/go-jose/v4" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/oauth2" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/testhelper" -) - -// ----------------------------------------------------------------------------- -// Mocks Setup -// ----------------------------------------------------------------------------- - -type mockRedisClient struct { - redis.UniversalClient - store map[string]string -} - -func newMockRedisClient() *mockRedisClient { - return &mockRedisClient{ - store: make(map[string]string), - } -} - -func (m *mockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd { - cmd := redis.NewStatusCmd(ctx) - var val string - switch v := value.(type) { - case []byte: - val = string(v) - case string: - val = v - default: - val = fmt.Sprintf("%v", v) - } - m.store[key] = val - cmd.SetVal("OK") - return cmd -} - -func (m *mockRedisClient) Get(ctx context.Context, key string) *redis.StringCmd { - cmd := redis.NewStringCmd(ctx) - val, ok := m.store[key] - if !ok { - cmd.SetErr(redis.Nil) - } else { - cmd.SetVal(val) - } - return cmd -} - -func (m *mockRedisClient) Del(ctx context.Context, keys ...string) *redis.IntCmd { - cmd := redis.NewIntCmd(ctx) - var count int64 - for _, key := range keys { - if _, ok := m.store[key]; ok { - delete(m.store, key) - count++ - } - } - cmd.SetVal(count) - return cmd -} - -func (m *mockRedisClient) Incr(ctx context.Context, key string) *redis.IntCmd { - cmd := redis.NewIntCmd(ctx) - n := int64(1) - if raw, ok := m.store[key]; ok { - fmt.Sscan(raw, &n) - n++ - } - m.store[key] = fmt.Sprintf("%d", n) - cmd.SetVal(n) - return cmd -} - -func (m *mockRedisClient) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd { - cmd := redis.NewBoolCmd(ctx) - _, ok := m.store[key] - cmd.SetVal(ok) - return cmd -} - -func (m *mockRedisClient) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd { - cmd := redis.NewScanCmd(ctx, nil, cursor, match, count) - var keys []string - for key := range m.store { - if redisMatchPattern(key, match) { - keys = append(keys, key) - } - } - cmd.SetVal(keys, 0) - return cmd -} - -func redisMatchPattern(key, pattern string) bool { - if pattern == "" || pattern == "*" { - return true - } - if strings.HasSuffix(pattern, "*") { - return strings.HasPrefix(key, strings.TrimSuffix(pattern, "*")) - } - return key == pattern -} - -func (m *mockRedisClient) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd { - cmd := redis.NewIntCmd(ctx) - if len(values) >= 2 { - field := fmt.Sprintf("%v", values[0]) - var val string - switch v := values[1].(type) { - case []byte: - val = string(v) - case string: - val = v - default: - val = fmt.Sprintf("%v", v) - } - compositeKey := key + ":" + field - m.store[compositeKey] = val - cmd.SetVal(1) - } else { - cmd.SetVal(0) - } - return cmd -} - -func (m *mockRedisClient) HGet(ctx context.Context, key string, field string) *redis.StringCmd { - cmd := redis.NewStringCmd(ctx) - compositeKey := key + ":" + field - val, ok := m.store[compositeKey] - if !ok { - cmd.SetErr(redis.Nil) - } else { - cmd.SetVal(val) - } - return cmd -} - -func (m *mockRedisClient) Publish(ctx context.Context, channel string, message interface{}) *redis.IntCmd { - cmd := redis.NewIntCmd(ctx) - cmd.SetVal(1) - return cmd -} - -func (m *mockRedisClient) Subscribe(ctx context.Context, channels ...string) *redis.PubSub { - return redis.NewClient(&redis.Options{ - Addr: "127.0.0.1:0", - }).Subscribe(ctx, channels...) -} - -type mockRoundTripper struct { - roundTripFunc func(req *http.Request) (*http.Response, error) -} - -func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return m.roundTripFunc(req) -} - -// Global cryptographic tools for custom OIDC mocking -var ( - testRSAPrivateKey *rsa.PrivateKey - testJWKS jose.JSONWebKeySet -) - -const ( - testIssuerURL = "https://connect.linux.do" - testAuthURL = "https://connect.linux.do/oauth2/authorize" - testTokenURL = "https://connect.linux.do/oauth2/token" - testJWKSURL = "https://connect.linux.do/oauth2/keys" - testClientID = "test_client_id" - testClientSecret = "test_client_secret" - testSourceName = "linuxdo" - testSourceDisplay = "LINUX DO" -) - -func init() { - var err error - testRSAPrivateKey, err = rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - panic(fmt.Sprintf("failed to generate RSA key: %v", err)) - } - jwk := jose.JSONWebKey{ - Key: &testRSAPrivateKey.PublicKey, - KeyID: "test-key-id", - Algorithm: string(jose.RS256), - Use: "sig", - } - testJWKS = jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{jwk}, - } -} - -func normalizeIssuerURL(issuer string) string { - return strings.TrimRight(strings.TrimSpace(issuer), "/") -} - -func seedTestAuthSource(t *testing.T, dbConn *gorm.DB) { - t.Helper() - if err := dbConn.Create(&model.AuthSource{ - ID: 100, - Name: testSourceName, - Type: model.AuthSourceTypeOIDC, - DisplayName: testSourceDisplay, - IsActive: true, - ClientID: testClientID, - ClientSecret: testClientSecret, - OpenIDDiscoveryURL: testIssuerURL, - }).Error; err != nil { - t.Fatalf("failed to seed auth source: %v", err) - } -} - -func oidcDiscoveryResponse() *http.Response { - issuer := normalizeIssuerURL(testIssuerURL) - body := fmt.Sprintf(`{ - "issuer": %q, - "authorization_endpoint": %q, - "token_endpoint": %q, - "jwks_uri": %q, - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"] - }`, issuer, issuer+"/oauth2/authorize", issuer+"/oauth2/token", issuer+"/oauth2/keys") - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - } -} - -type mockClaims struct { - ID uint64 `json:"id"` - Issuer string `json:"iss"` - Subject string `json:"sub"` - Audience string `json:"aud"` - Expiry int64 `json:"exp"` - IssuedAt int64 `json:"iat"` - Nonce string `json:"nonce"` - Username string `json:"preferred_username"` - Email string `json:"email"` - Name string `json:"name"` - Active bool `json:"active"` -} - -func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string) string { - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testRSAPrivateKey}, (&jose.SignerOptions{}).WithType("JWT")) - if err != nil { - panic(err) - } - id, _ := strconv.ParseUint(sub, 10, 64) - claims := mockClaims{ - ID: id, - Issuer: issuer, - Subject: sub, - Audience: aud, - Expiry: time.Now().Add(time.Hour).Unix(), - IssuedAt: time.Now().Unix(), - Nonce: nonce, - Username: username, - Email: email, - Name: name, - Active: true, - } - payload, _ := json.Marshal(claims) - object, err := signer.Sign(payload) - if err != nil { - panic(err) - } - tokenStr, _ := object.CompactSerialize() - return tokenStr -} - -// ----------------------------------------------------------------------------- -// Test Helpers -func newMockOIDCClient(issuer, clientID string, expectedState *string, sub, username, email, name string) *http.Client { - cleanIssuer := normalizeIssuerURL(issuer) - return &http.Client{ - Transport: &mockRoundTripper{ - roundTripFunc: func(req *http.Request) (*http.Response, error) { - urlStr := req.URL.String() - if req.Method == http.MethodGet && strings.Contains(urlStr, "/.well-known/openid-configuration") { - body := fmt.Sprintf(`{ - "issuer": %q, - "authorization_endpoint": %q, - "token_endpoint": %q, - "jwks_uri": %q, - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"] - }`, cleanIssuer, cleanIssuer+"/oauth2/authorize", cleanIssuer+"/oauth2/token", cleanIssuer+"/oauth2/keys") - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - }, nil - } - if req.Method == http.MethodGet && (strings.Contains(urlStr, "/keys") || strings.Contains(urlStr, "/jwks")) { - jwksJSON, _ := json.Marshal(testJWKS) - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(jwksJSON)), - Header: make(http.Header), - }, nil - } - if req.Method == http.MethodPost && (strings.Contains(urlStr, "/token") || strings.Contains(urlStr, "/access_token")) { - var stateVal string - if expectedState != nil { - stateVal = *expectedState - } - idToken := generateMockIDToken(cleanIssuer, sub, clientID, stateVal, username, email, name) - body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken) - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - }, nil - } - return nil, fmt.Errorf("unexpected mock request: %s %s", req.Method, req.URL) - }, - }, - } -} - -func setupTestDB(t *testing.T) *gorm.DB { - t.Helper() - repository.ResetSystemConfigRAMCacheForTest() - repository.ResetAuthSourceRAMCacheForTest() - - dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("failed to open sqlite in memory: %v", err) - } - err = dbConn.AutoMigrate( - &model.User{}, - &model.AuthSource{}, - &model.ExternalAccount{}, - &model.SystemConfig{}, - ) - if err != nil { - t.Fatalf("failed to migrate schema: %v", err) - } - - // 注入测试所需的服务器地址配置 - if err := dbConn.Create(&model.SystemConfig{ - Key: model.ConfigKeyServerAddress, - Value: "http://localhost:3000", - }).Error; err != nil { - t.Fatalf("failed to seed server_address config: %v", err) - } - - return dbConn -} - -func mockContextMiddleware(mockClient *http.Client) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - ctx = context.WithValue(ctx, oauth2.HTTPClient, mockClient) - ctx = oidc.ClientContext(ctx, mockClient) - c.Request = c.Request.WithContext(ctx) - c.Next() - } -} - -func resetOIDCProviderCacheForTest() { - InvalidateOIDCProviderCache(normalizeIssuerURL(testIssuerURL)) - InvalidateOIDCProviderCache("https://github.com") -} - -func setupTestRouter(dbConn *gorm.DB, mockRedis *mockRedisClient, mockClient *http.Client) *gin.Engine { - // 停止各层 Pub/Sub 监听 goroutine(可能在先前测试的 API 调用中随 sync.Once - // 启动),否则它们在 db.Redis 被替换时仍读取旧值,产生数据竞争。 - StopOauthCacheListener() - repository.StopAuthSourceCacheListener() - repository.StopSystemConfigCacheListener() - resetOIDCProviderCacheForTest() - - r := testhelper.NewTestGinEngine(gin.Recovery()) - - // Inject context mock middleware - r.Use(mockContextMiddleware(mockClient)) - - store := cookie.NewStore([]byte(config.Config.App.SessionSecret)) - store.Options(GetSessionOptions(3600)) - r.Use(sessions.Sessions(config.Config.App.SessionCookieName, store)) - - db.SetDB(dbConn) - db.Redis = mockRedis - - api := r.Group("/api/v1") - { - api.GET("/oauth/sources", GetLoginSources) - api.GET("/oauth/login", GetLoginURL) - api.GET("/oauth/:source/authorize", Authorize) - api.GET("/oauth/logout", Logout) - api.POST("/oauth/callback", Callback) - api.GET("/oauth/user-info", LoginRequired(), UserInfo) - api.GET("/oauth/external-accounts", LoginRequired(), ListExternalAccounts) - api.POST("/oauth/external-accounts/:id/delete", LoginRequired(), DeleteExternalAccount) - } - - return r -} - -func performRequest(r http.Handler, method, path string, body []byte, headers map[string]string, cookies []*http.Cookie) *httptest.ResponseRecorder { - var bodyReader io.Reader - if body != nil { - bodyReader = bytes.NewReader(body) - } - req, _ := http.NewRequest(method, path, bodyReader) - for k, v := range headers { - req.Header.Set(k, v) - } - for _, cookie := range cookies { - if cookie != nil { - req.AddCookie(cookie) - } - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func initializeTestConfig() { - config.Config.App.Env = "testing" - config.Config.App.SessionCookieName = "test_session_id" - config.Config.App.SessionSecret = "test_session_secret" - config.Config.App.APIPrefix = "/api" -} - -// ----------------------------------------------------------------------------- -// Tests -// ----------------------------------------------------------------------------- - -func TestGetLoginSources(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - - // Setup empty HTTP Mock - httpMock := &http.Client{ - Transport: &mockRoundTripper{ - roundTripFunc: func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("unexpected request") - }, - }, - } - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // Inject OIDC login enabled config - dbConn.Create(&model.SystemConfig{ - Key: model.ConfigKeyOIDCLoginEnabled, - Value: "true", - }) - - // Inject active DB auth source - dbConn.Create(&model.AuthSource{ - ID: 101, - Name: "github", - Type: model.AuthSourceTypeOIDC, - DisplayName: "GitHub OAuth", - IsActive: true, - ClientID: "gh_client", - ClientSecret: "gh_secret", - OpenIDDiscoveryURL: "https://github.com", - }) - - // Perform GET request - w := performRequest(router, http.MethodGet, "/api/v1/oauth/sources", nil, nil, nil) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - var resp struct { - Data []AuthSourceView `json:"data"` - } - err := json.Unmarshal(w.Body.Bytes(), &resp) - if err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if len(resp.Data) != 1 { - t.Fatalf("expected 1 active source, got %d", len(resp.Data)) - } - - if resp.Data[0].Name != "github" { - t.Errorf("expected github source, got %s", resp.Data[0].Name) - } - - // Test disabling OIDC - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "false") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.store = make(map[string]string) - - w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/sources", nil, nil, nil) - var resp2 struct { - Data []AuthSourceView `json:"data"` - } - _ = json.Unmarshal(w2.Body.Bytes(), &resp2) - if len(resp2.Data) != 0 { - t.Errorf("expected 0 sources when OIDC is disabled, got %d", len(resp2.Data)) - } -} - -func TestGetLoginURL(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - seedTestAuthSource(t, dbConn) - - httpMock := &http.Client{ - Transport: &mockRoundTripper{ - roundTripFunc: func(req *http.Request) (*http.Response, error) { - if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") { - return oidcDiscoveryResponse(), nil - } - return nil, fmt.Errorf("unexpected request") - }, - }, - } - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // Case 1: Default Login URL - w := performRequest(router, http.MethodGet, "/api/v1/oauth/login", nil, nil, nil) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp struct { - Data OAuthAuthorizeResponse `json:"data"` - } - err := json.Unmarshal(w.Body.Bytes(), &resp) - if err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if !strings.Contains(resp.Data.AuthorizeURL, testAuthURL) { - t.Errorf("invalid authorize URL: %s", resp.Data.AuthorizeURL) - } - - parsedURL, _ := url.Parse(resp.Data.AuthorizeURL) - state := parsedURL.Query().Get("state") - if state == "" { - t.Error("missing state in URL") - } - - redisKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)) - stateVal, err := mockRedis.Get(context.Background(), redisKey).Result() - if err != nil { - t.Fatalf("state not found in redis: %v", err) - } - - payload, err := decodeOAuthStatePayload(stateVal) - if err != nil { - t.Fatalf("failed to decode state payload: %v", err) - } - if payload.SourceName != testSourceName || payload.Purpose != OAuthPurposeLogin { - t.Errorf("unexpected payload: %+v", payload) - } - - // Case 2: Unknown Source Login URL - w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source=nonexistent", nil, nil, nil) - if w2.Code != http.StatusBadRequest { - t.Errorf("expected 400 for unknown source, got %d", w2.Code) - } -} - -func TestAuthorize(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - - // Setup GitHub active source - dbConn.Create(&model.AuthSource{ - ID: 101, - Name: "github", - Type: model.AuthSourceTypeOIDC, - DisplayName: "GitHub OAuth", - IsActive: true, - ClientID: "gh_client", - ClientSecret: "gh_secret", - OpenIDDiscoveryURL: "https://github.com", - }) - - // Mock OIDC Discovery request - httpMock := &http.Client{ - Transport: &mockRoundTripper{ - roundTripFunc: func(req *http.Request) (*http.Response, error) { - if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") { - body := `{ - "issuer": "https://github.com", - "authorization_endpoint": "https://github.com/login/oauth/authorize", - "token_endpoint": "https://github.com/login/oauth/access_token", - "jwks_uri": "https://github.com/oauth/keys", - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"] - }` - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - }, nil - } - return nil, fmt.Errorf("unexpected request: %s", req.URL) - }, - }, - } - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // Case 1a: Active Source Authorize with purpose=bind without login -> 401 - wUnauth := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize?purpose=bind", nil, nil, nil) - if wUnauth.Code != http.StatusUnauthorized { - t.Errorf("expected 401 for unauthorized bind authorize, got %d", wUnauth.Code) - } - - // Case 1b: Active Source Authorize with purpose=bind (authenticated) - router.GET("/test-helper/login-777", func(c *gin.Context) { - session := sessions.Default(c) - session.Set(UserIDKey, uint64(777)) - _ = session.Save() - c.String(200, "ok") - }) - wLogin := performRequest(router, http.MethodGet, "/test-helper/login-777", nil, nil, nil) - var activeCookie *http.Cookie - for _, cookie := range wLogin.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie = cookie - break - } - } - - w := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize?purpose=bind", nil, nil, []*http.Cookie{activeCookie}) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d, body: %s", w.Code, w.Body.String()) - } - - var resp struct { - Data OAuthAuthorizeResponse `json:"data"` - } - - _ = json.Unmarshal(w.Body.Bytes(), &resp) - - parsedURL, _ := url.Parse(resp.Data.AuthorizeURL) - state := parsedURL.Query().Get("state") - redisKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)) - stateVal, _ := mockRedis.Get(context.Background(), redisKey).Result() - payload, _ := decodeOAuthStatePayload(stateVal) - - if payload.SourceName != "github" || payload.Purpose != OAuthPurposeBind { - t.Errorf("expected source github with purpose bind, got %+v", payload) - } - - // Case 2: Inactive Source Authorize - dbConn.Model(&model.AuthSource{}).Where("id = ?", 101).Update("is_active", false) - _ = repository.InvalidateAuthSourceCache(context.Background()) - w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize", nil, nil, nil) - if w2.Code != http.StatusBadRequest { - t.Errorf("expected 400 for inactive source, got %d", w2.Code) - } -} - -func TestLogout(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - httpMock := &http.Client{} - router := setupTestRouter(dbConn, mockRedis, httpMock) - - w := performRequest(router, http.MethodGet, "/api/v1/oauth/logout", nil, nil, nil) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } -} - -func TestCallbackLoginAndUserInfo(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - seedTestAuthSource(t, dbConn) - dbConn.Create(&model.SystemConfig{ - Key: model.ConfigKeyRegistrationEnabled, - Value: "true", - Type: "system", - }) - - var state string - - // 1. Mock the outgoing HTTP client for token exchange and user info fetching - httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, "88888", "test_oauth_user", "oauth@linux.do", "Oauth Test User") - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // Get Login URL first to initialize the session and generate the state - wLogin := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - if wLogin.Code != http.StatusOK { - t.Fatalf("failed to get login URL: %s", wLogin.Body.String()) - } - - var loginUrlResp struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wLogin.Body.Bytes(), &loginUrlResp) - - parsedURL, _ := url.Parse(loginUrlResp.Data.AuthorizeURL) - state = parsedURL.Query().Get("state") - - var anonymousCookie *http.Cookie - for _, cookie := range wLogin.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - anonymousCookie = cookie - break - } - } - if anonymousCookie == nil { - t.Fatal("session cookie not found after login URL generation") - } - - // 3. Trigger Callback (Login flow - new user) - reqBody := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state) - w := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{anonymousCookie}) - - if w.Code != http.StatusOK { - t.Fatalf("callback failed with status %d, body: %s", w.Code, w.Body.String()) - } - - var callbackResp struct { - Data OAuthCallbackResult `json:"data"` - } - _ = json.Unmarshal(w.Body.Bytes(), &callbackResp) - - if callbackResp.Data.Status != "logged_in" { - t.Errorf("expected logged_in status, got %s", callbackResp.Data.Status) - } - - if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID != 88888 { - t.Errorf("unexpected user returned: %+v", callbackResp.Data.User) - } - - // Verify user is created in database - var user model.User - if err := dbConn.First(&user, "id = ?", 88888).Error; err != nil { - t.Fatalf("user was not created in DB: %v", err) - } - - // Extract session cookie - cookies := w.Result().Cookies() - var sessionCookie *http.Cookie - for _, cookie := range cookies { - if cookie.Name == config.Config.App.SessionCookieName { - sessionCookie = cookie - break - } - } - if sessionCookie == nil { - t.Fatal("session cookie not found in response") - } - - // 4. Test GET user-info - w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/user-info", nil, nil, []*http.Cookie{sessionCookie}) - if w2.Code != http.StatusOK { - t.Fatalf("failed to fetch user info, status %d", w2.Code) - } - - // 5. Test Callback (Login flow - existing user, username collision check) - var state2 string - // Callback with same username but different external ID (99999) - httpMock2 := newMockOIDCClient(testIssuerURL, testClientID, &state2, "99999", "test_oauth_user", "another@linux.do", "Another User") - - // Create another router for this mock client - router2 := setupTestRouter(dbConn, mockRedis, httpMock2) - - // Call login to get state2 and new anonymous session - wLogin2 := performRequest(router2, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - var loginUrlResp2 struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wLogin2.Body.Bytes(), &loginUrlResp2) - parsedURL2, _ := url.Parse(loginUrlResp2.Data.AuthorizeURL) - state2 = parsedURL2.Query().Get("state") - - var anonymousCookie2 *http.Cookie - for _, cookie := range wLogin2.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - anonymousCookie2 = cookie - break - } - } - - reqBody2 := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state2) - w3 := performRequest(router2, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody2), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{anonymousCookie2}) - - if w3.Code != http.StatusOK { - t.Fatalf("callback for collision failed: %d, body: %s", w3.Code, w3.Body.String()) - } - - var collisionResp struct { - Data OAuthCallbackResult `json:"data"` - } - _ = json.Unmarshal(w3.Body.Bytes(), &collisionResp) - - if collisionResp.Data.User.Username != "test_oauth_user-1" { - t.Errorf("expected collision renamed username, got %s", collisionResp.Data.User.Username) - } - - t.Run("OIDC login when registration disabled - need bind", func(t *testing.T) { - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyRegistrationEnabled).Update("value", "false") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyRegistrationEnabled) - t.Cleanup(func() { - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyRegistrationEnabled).Update("value", "true") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyRegistrationEnabled) - }) - - var state4 string - httpMock4 := newMockOIDCClient(testIssuerURL, testClientID, &state4, "77777", "need_bind_user", "needbind@linux.do", "Need Bind User") - router4 := setupTestRouter(dbConn, mockRedis, httpMock4) - - wLogin4 := performRequest(router4, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - var loginUrlResp4 struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wLogin4.Body.Bytes(), &loginUrlResp4) - parsedURL4, _ := url.Parse(loginUrlResp4.Data.AuthorizeURL) - state4 = parsedURL4.Query().Get("state") - - var anonymousCookie4 *http.Cookie - for _, cookie := range wLogin4.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - anonymousCookie4 = cookie - break - } - } - - reqBody4 := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state4) - w4 := performRequest(router4, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody4), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{anonymousCookie4}) - - if w4.Code != http.StatusOK { - t.Fatalf("callback failed: %d, body: %s", w4.Code, w4.Body.String()) - } - - var needBindResp struct { - Data OAuthCallbackResult `json:"data"` - } - _ = json.Unmarshal(w4.Body.Bytes(), &needBindResp) - - if needBindResp.Data.Status != "need_bind" { - t.Errorf("expected status 'need_bind', got %s", needBindResp.Data.Status) - } - if needBindResp.Data.User != nil { - t.Errorf("expected User to be nil, got %+v", needBindResp.Data.User) - } - }) -} - -func TestCallbackBind(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - - // Create user - user := model.User{ - ID: 777, - Username: "existing_member", - Nickname: "Existing Member", - IsActive: true, - LastLoginAt: time.Now(), - } - dbConn.Create(&user) - - // Create auth source "github" - dbConn.Create(&model.AuthSource{ - ID: 2, - Name: "github", - Type: model.AuthSourceTypeOIDC, - DisplayName: "GitHub", - IsActive: true, - ClientID: "gh_client", - ClientSecret: "gh_secret", - OpenIDDiscoveryURL: "https://github.com", - }) - - var state string - // Mock OIDC discovery, JWKS, and Token exchange for custom source (GitHub) - httpMock := newMockOIDCClient("https://github.com", "gh_client", &state, "github_user_123", "github_tester", "tester@github.com", "GitHub Tester") - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // Set up login helper - router.GET("/test-helper/login-777", func(c *gin.Context) { - session := sessions.Default(c) - session.Set(UserIDKey, uint64(777)) - _ = session.Save() - c.String(200, "ok") - }) - - wLogin := performRequest(router, http.MethodGet, "/test-helper/login-777", nil, nil, nil) - var activeCookie *http.Cookie - for _, cookie := range wLogin.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie = cookie - break - } - } - - // Generate OAuth authorize link (purpose=bind) to set state in Redis and Session - wAuth := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize?purpose=bind", nil, nil, []*http.Cookie{activeCookie}) - if wAuth.Code != http.StatusOK { - t.Fatalf("authorize failed: %d, body: %s", wAuth.Code, wAuth.Body.String()) - } - // Extract the cookie from wAuth to get the session with the token! - for _, cookie := range wAuth.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie = cookie - break - } - } - var authResp struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wAuth.Body.Bytes(), &authResp) - parsedURL, _ := url.Parse(authResp.Data.AuthorizeURL) - state = parsedURL.Query().Get("state") - - // Case 1: Bind attempt without session -> 401 - reqBody := fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state) - w1 := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody), map[string]string{ - "Content-Type": "application/json", - }, nil) - - if w1.Code != http.StatusUnauthorized { - t.Errorf("expected 401 for unauthenticated bind, got %d, body: %s", w1.Code, w1.Body.String()) - } - - // Case 2: Bind success (authenticated) - // Re-run authorize since state is consumed/deleted during Callback attempt - wAuth2 := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize?purpose=bind", nil, nil, []*http.Cookie{activeCookie}) - // Extract updated cookie from wAuth2 - for _, cookie := range wAuth2.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie = cookie - break - } - } - var authResp2 struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wAuth2.Body.Bytes(), &authResp2) - parsedURL2, _ := url.Parse(authResp2.Data.AuthorizeURL) - state = parsedURL2.Query().Get("state") - - reqBody2 := fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state) - w2 := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody2), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{activeCookie}) - - if w2.Code != http.StatusOK { - t.Fatalf("expected 200 for bind callback, got %d, body: %s", w2.Code, w2.Body.String()) - } - - var bindResult struct { - Data OAuthCallbackResult `json:"data"` - } - _ = json.Unmarshal(w2.Body.Bytes(), &bindResult) - if bindResult.Data.Status != "bound" { - t.Errorf("expected status bound, got %s", bindResult.Data.Status) - } - - // Verify DB binding - var binding model.ExternalAccount - if err := dbConn.First(&binding, "user_id = ? AND external_id = ?", 777, "github_user_123").Error; err != nil { - t.Fatalf("DB binding record not found: %v", err) - } - - // Case 3: Bind already bound account to another user - // Create another user - user2 := model.User{ - ID: 888, - Username: "another_member", - Nickname: "Another Member", - IsActive: true, - LastLoginAt: time.Now(), - } - dbConn.Create(&user2) - - router.GET("/test-helper/login-888", func(c *gin.Context) { - session := sessions.Default(c) - session.Set(UserIDKey, uint64(888)) - _ = session.Save() - c.String(200, "ok") - }) - - wLogin2 := performRequest(router, http.MethodGet, "/test-helper/login-888", nil, nil, nil) - var activeCookie2 *http.Cookie - for _, cookie := range wLogin2.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie2 = cookie - break - } - } - - var state3 string - // Re-sign token for new state (since state serves as OIDC Nonce) - httpMock3 := newMockOIDCClient("https://github.com", "gh_client", &state3, "github_user_123", "github_tester", "tester@github.com", "GitHub Tester") - router3 := setupTestRouter(dbConn, mockRedis, httpMock3) - - // Generate state3 and SessionHash using activeCookie2 - wAuth3 := performRequest(router3, http.MethodGet, "/api/v1/oauth/github/authorize?purpose=bind", nil, nil, []*http.Cookie{activeCookie2}) - if wAuth3.Code != http.StatusOK { - t.Fatalf("authorize failed: %d, body: %s", wAuth3.Code, wAuth3.Body.String()) - } - // Extract the cookie to get the updated session token - for _, cookie := range wAuth3.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie2 = cookie - break - } - } - var authResp3 struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wAuth3.Body.Bytes(), &authResp3) - parsedURL3, _ := url.Parse(authResp3.Data.AuthorizeURL) - state3 = parsedURL3.Query().Get("state") - - reqBody3 := fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state3) - w3 := performRequest(router3, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody3), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{activeCookie2}) - - if w3.Code != http.StatusBadRequest { - t.Errorf("expected 400 for already bound account, got %d, body: %s", w3.Code, w3.Body.String()) - } - -} - -func TestExternalAccountsListAndDelete(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - httpMock := &http.Client{} - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // Create user and external accounts - dbConn.Create(&model.User{ - ID: 555, - Username: "account_holder", - IsActive: true, - }) - - dbConn.Create(&model.AuthSource{ - ID: 10, - Name: "gitlab", - Type: model.AuthSourceTypeOIDC, - IsActive: true, - }) - - dbConn.Create(&model.ExternalAccount{ - ID: 2001, - AuthSourceID: 10, - UserID: 555, - ExternalID: "gitlab_123", - ExternalUsername: "gitlab_user", - }) - - router.GET("/test-helper/login-555", func(c *gin.Context) { - session := sessions.Default(c) - session.Set(UserIDKey, uint64(555)) - _ = session.Save() - c.String(200, "ok") - }) - - wLogin := performRequest(router, http.MethodGet, "/test-helper/login-555", nil, nil, nil) - var activeCookie *http.Cookie - for _, cookie := range wLogin.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - activeCookie = cookie - break - } - } - - // 1. List accounts - wList := performRequest(router, http.MethodGet, "/api/v1/oauth/external-accounts", nil, nil, []*http.Cookie{activeCookie}) - if wList.Code != http.StatusOK { - t.Fatalf("failed to list external accounts: %d", wList.Code) - } - - var listResp struct { - Data []model.ExternalAccountView `json:"data"` - } - _ = json.Unmarshal(wList.Body.Bytes(), &listResp) - if len(listResp.Data) != 1 || listResp.Data[0].ExternalUsername != "gitlab_user" { - t.Errorf("unexpected list response: %+v", listResp.Data) - } - - // 2. Delete/Unbind account - wDelete := performRequest(router, http.MethodPost, "/api/v1/oauth/external-accounts/2001/delete", nil, nil, []*http.Cookie{activeCookie}) - if wDelete.Code != http.StatusOK { - t.Fatalf("failed to delete external account binding: %d, body: %s", wDelete.Code, wDelete.Body.String()) - } - - var count int64 - dbConn.Model(&model.ExternalAccount{}).Where("id = ?", 2001).Count(&count) - if count != 0 { - t.Error("binding record was not deleted from DB") - } -} - -func TestOIDCPolicyEnforcement(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - mockRedis := newMockRedisClient() - seedTestAuthSource(t, dbConn) // seeds testSourceName ("linuxdo") active=true - - // Set up mock client & router - var state string - httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, "88888", "test_oauth_user", "oauth@linux.do", "Oauth Test User") - router := setupTestRouter(dbConn, mockRedis, httpMock) - - // --- 1. Test GetLoginURL enforcement --- - // Disable globally - dbConn.Create(&model.SystemConfig{ - Key: model.ConfigKeyOIDCLoginEnabled, - Value: "false", - }) - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyOIDCLoginEnabled) - wLoginDisabled := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - if wLoginDisabled.Code != http.StatusBadRequest { - t.Errorf("expected 400 when OIDC globally disabled, got %d", wLoginDisabled.Code) - } - - // Re-enable globally, but deactivate source - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "true") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyOIDCLoginEnabled) - dbConn.Model(&model.AuthSource{}).Where("name = ?", testSourceName).Update("is_active", false) - _ = repository.InvalidateAuthSourceCache(context.Background()) - - wSourceInactive := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - if wSourceInactive.Code != http.StatusBadRequest { - t.Errorf("expected 400 when OIDC source is inactive, got %d", wSourceInactive.Code) - } - - // --- 2. Test Authorize enforcement --- - // Deactivate globally again - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "false") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyOIDCLoginEnabled) - dbConn.Model(&model.AuthSource{}).Where("name = ?", testSourceName).Update("is_active", true) - _ = repository.InvalidateAuthSourceCache(context.Background()) - - wAuthDisabled := performRequest(router, http.MethodGet, "/api/v1/oauth/"+testSourceName+"/authorize", nil, nil, nil) - if wAuthDisabled.Code != http.StatusBadRequest { - t.Errorf("expected 400 when OIDC globally disabled in Authorize, got %d", wAuthDisabled.Code) - } - - // --- 3. Test Callback enforcement --- - // Set up a valid state beforehand (when enabled) - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "true") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyOIDCLoginEnabled) - dbConn.Model(&model.AuthSource{}).Where("name = ?", testSourceName).Update("is_active", true) - _ = repository.InvalidateAuthSourceCache(context.Background()) - - wLogin := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - - if wLogin.Code != http.StatusOK { - t.Fatalf("failed to setup login: %s", wLogin.Body.String()) - } - var loginUrlResp struct { - Data OAuthAuthorizeResponse `json:"data"` - } - _ = json.Unmarshal(wLogin.Body.Bytes(), &loginUrlResp) - parsedURL, _ := url.Parse(loginUrlResp.Data.AuthorizeURL) - state = parsedURL.Query().Get("state") - - var anonymousCookie *http.Cookie - for _, cookie := range wLogin.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - anonymousCookie = cookie - break - } - } - - // Now disable OIDC globally and attempt callback - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "false") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyOIDCLoginEnabled) - reqBody := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state) - wCallbackDisabled := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{anonymousCookie}) - if wCallbackDisabled.Code != http.StatusBadRequest { - t.Errorf("expected 400 for callback when OIDC globally disabled, got %d, body: %s", wCallbackDisabled.Code, wCallbackDisabled.Body.String()) - } - - // Enable globally but deactivate source and attempt callback - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "true") - repository.ResetSystemConfigRAMCacheForTest() - mockRedis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)+":"+model.ConfigKeyOIDCLoginEnabled) - dbConn.Model(&model.AuthSource{}).Where("name = ?", testSourceName).Update("is_active", false) - - // Since callback deletes state, we need to generate state again - dbConn.Model(&model.AuthSource{}).Where("name = ?", testSourceName).Update("is_active", true) - _ = repository.InvalidateAuthSourceCache(context.Background()) - wLogin2 := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, nil) - _ = json.Unmarshal(wLogin2.Body.Bytes(), &loginUrlResp) - parsedURL, _ = url.Parse(loginUrlResp.Data.AuthorizeURL) - state = parsedURL.Query().Get("state") - var anonymousCookie2 *http.Cookie - for _, cookie := range wLogin2.Result().Cookies() { - if cookie.Name == config.Config.App.SessionCookieName { - anonymousCookie2 = cookie - break - } - } - - // Deactivate source - dbConn.Model(&model.AuthSource{}).Where("name = ?", testSourceName).Update("is_active", false) - _ = repository.InvalidateAuthSourceCache(context.Background()) - reqBody2 := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state) - wCallbackSourceInactive := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody2), map[string]string{ - "Content-Type": "application/json", - }, []*http.Cookie{anonymousCookie2}) - if wCallbackSourceInactive.Code != http.StatusBadRequest { - t.Errorf("expected 400 for callback when OIDC source deactivated, got %d, body: %s", wCallbackSourceInactive.Code, wCallbackSourceInactive.Body.String()) - } -} - -func TestSystemUserBlockedByMiddleware(t *testing.T) { - initializeTestConfig() - dbConn := setupTestDB(t) - - // 1. 创建正常管理员 - adminUser := &model.User{ID: 1001, Username: "normal_admin", IsAdmin: true, IsActive: true} - err := dbConn.Create(adminUser).Error - require.NoError(t, err) - - // 2. 创建系统用户 (根据架构设计,系统用户 id = 999) - systemUser := &model.User{ID: 999, Username: "system", Nickname: "系统", Password: "*", IsActive: true} - err = dbConn.Create(systemUser).Error - require.NoError(t, err) - - // 3. 设置全局测试数据库连接并构建测试路由组 - db.SetDB(dbConn) - rProtected := testhelper.NewTestGinEngine() - store := cookie.NewStore([]byte("secret")) - rProtected.Use(sessions.Sessions("mysession", store)) - rProtected.Use(LoginRequired()) - rProtected.GET("/test-auth", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - - // 4. 测试未登录用户 (401) - w1 := httptest.NewRecorder() - req1, _ := http.NewRequest("GET", "/test-auth", nil) - rProtected.ServeHTTP(w1, req1) - assert.Equal(t, http.StatusUnauthorized, w1.Code) - - // 5. 测试正常用户登录并访问 (200) - rLogin := gin.New() - rLogin.Use(sessions.Sessions("mysession", store)) - rLogin.GET("/login-mock", func(c *gin.Context) { - session := sessions.Default(c) - session.Set("user_id", uint64(1001)) - _ = session.Save() - c.Status(200) - }) - - wLogin := httptest.NewRecorder() - reqLogin, _ := http.NewRequest("GET", "/login-mock", nil) - rLogin.ServeHTTP(wLogin, reqLogin) - cookieStr := wLogin.Header().Get("Set-Cookie") - - w2 := httptest.NewRecorder() - req2, _ := http.NewRequest("GET", "/test-auth", nil) - req2.Header.Set("Cookie", cookieStr) - rProtected.ServeHTTP(w2, req2) - assert.Equal(t, http.StatusOK, w2.Code) - - // 6. 测试 system 用户(ID: 999)登录并访问 (被中间件阻断返回 401) - rLoginSystem := gin.New() - rLoginSystem.Use(sessions.Sessions("mysession", store)) - rLoginSystem.GET("/login-system-mock", func(c *gin.Context) { - session := sessions.Default(c) - session.Set("user_id", uint64(999)) - _ = session.Save() - c.Status(200) - }) - - wLoginSystem := httptest.NewRecorder() - reqLoginSystem, _ := http.NewRequest("GET", "/login-system-mock", nil) - rLoginSystem.ServeHTTP(wLoginSystem, reqLoginSystem) - cookieSystemStr := wLoginSystem.Header().Get("Set-Cookie") - - w3 := httptest.NewRecorder() - req3, _ := http.NewRequest("GET", "/test-auth", nil) - req3.Header.Set("Cookie", cookieSystemStr) - rProtected.ServeHTTP(w3, req3) - assert.Equal(t, http.StatusUnauthorized, w3.Code) -} diff --git a/backend/OpenFlare/plugins/server/oauth/oauth_types.go b/backend/OpenFlare/plugins/server/oauth/oauth_types.go deleted file mode 100644 index 17ab267c..00000000 --- a/backend/OpenFlare/plugins/server/oauth/oauth_types.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -// AuthSourceView 登录源展示信息 -type AuthSourceView struct { - ID uint64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - IsActive bool `json:"is_active"` - IconURL string `json:"icon_url"` - ClientSecretConfigured bool `json:"client_secret_configured"` -} - -// OAuthAuthorizeResponse 授权 URL 响应 -// -//nolint:revive // OAuth 前缀保持包内语义清晰 -type OAuthAuthorizeResponse struct { - AuthorizeURL string `json:"authorize_url"` -} - -// OAuthCallbackResult 回调处理结果 -// -//nolint:revive // OAuth 前缀保持包内语义清晰 -type OAuthCallbackResult struct { - Status string `json:"status"` - User *BasicUserInfo `json:"user,omitempty"` -} - -// CallbackRequest OAuth 回调请求参数 -type CallbackRequest struct { - State string `json:"state" binding:"required"` - Code string `json:"code" binding:"required"` -} diff --git a/backend/OpenFlare/plugins/server/oauth/oauth_userinfo.go b/backend/OpenFlare/plugins/server/oauth/oauth_userinfo.go deleted file mode 100644 index b9eaae56..00000000 --- a/backend/OpenFlare/plugins/server/oauth/oauth_userinfo.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "errors" - "fmt" - "strings" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -func uniqueUsername(ctx context.Context, base string) (string, error) { - base = strings.TrimSpace(base) - if base == "" { - base = "user" - } - - existingUsernames, err := repository.ListUsernamesMatchingBase(ctx, base) - if err != nil { - return "", err - } - - // 将现有的用户名放入 map 中,以便 O(1) 查找 - exists := make(map[string]bool, len(existingUsernames)) - for _, u := range existingUsernames { - exists[strings.ToLower(u)] = true - } - - // 检查 base 是否被占用 - if !exists[strings.ToLower(base)] { - return base, nil - } - - // 顺序查找第一个可用的带后缀用户名 - for i := 1; i <= 1000; i++ { - candidate := fmt.Sprintf("%s-%d", base, i) - if !exists[strings.ToLower(candidate)] { - return candidate, nil - } - } - - return "", errors.New(errUsernameGenerateFailed) -} - -func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code string, nonce string, redirectURL string) (*model.OAuthUserInfo, error) { - authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL) - if err != nil { - return nil, err - } - - token, err := authConfig.Exchange(ctx, code) - if err != nil { - return nil, err - } - - userInfo := &model.OAuthUserInfo{Active: true} - if verifier != nil { - if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil { - return nil, verifyErr - } - } - - if userInfo.Username == "" && userInfo.PreferredUsername != "" { - userInfo.Username = userInfo.PreferredUsername - } - if userInfo.Username == "" && userInfo.Email != "" { - userInfo.Username = strings.Split(userInfo.Email, "@")[0] - } - if userInfo.Username == "" && userInfo.Sub != "" { - userInfo.Username = userInfo.Sub - } - if userInfo.Name == "" { - userInfo.Name = userInfo.Username - } - - return userInfo, nil -} - -// verifyIDToken 验证 OIDC ID Token 并将 Claims 解析到 userInfo -func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *model.OAuthUserInfo) error { - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - return nil - } - idToken, verifyErr := verifier.Verify(ctx, rawIDToken) - if verifyErr != nil { - return fmt.Errorf(errIDTokenVerifyFailedFormat, errIDTokenVerifyFailed, verifyErr) - } - if nonce != "" && idToken.Nonce != nonce { - return errors.New(errNonceMismatch) - } - if claimsErr := idToken.Claims(userInfo); claimsErr != nil { - return claimsErr - } - return nil -} - -func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error { - userInfo.Username = strings.TrimSpace(userInfo.Username) - userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername) - userInfo.Email = strings.TrimSpace(userInfo.Email) - userInfo.Name = strings.TrimSpace(userInfo.Name) - userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL) - - if userInfo.Username == "" && userInfo.PreferredUsername != "" { - userInfo.Username = userInfo.PreferredUsername - } - if userInfo.Username == "" && userInfo.Email != "" { - userInfo.Username = strings.Split(userInfo.Email, "@")[0] - } - if userInfo.Username == "" && userInfo.Sub != "" { - userInfo.Username = userInfo.Sub - } - if userInfo.Username == "" { - return errors.New(errUsernameFromSourceFailed) - } - if userInfo.Name == "" { - userInfo.Name = userInfo.Username - } - if !userInfo.Active { - userInfo.Active = true - } - return nil -} - -func buildCallbackResult(user *model.User, status string) OAuthCallbackResult { - result := OAuthCallbackResult{Status: status} - if user != nil { - info := BuildBasicUserInfo(user, false) - result.User = &info - } - return result -} diff --git a/backend/OpenFlare/plugins/server/oauth/provider_cache.go b/backend/OpenFlare/plugins/server/oauth/provider_cache.go deleted file mode 100644 index 814e05c2..00000000 --- a/backend/OpenFlare/plugins/server/oauth/provider_cache.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "net/http" - "sync" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" - "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), -} - -// discoveryContext 从请求 ctx 提取 HTTP 客户端,并绑定到不可取消的 Background ctx。 -// 这样既能在测试中注入 mock 客户端,又避免请求取消导致 provider 拉取失败。 -func discoveryContext(ctx context.Context) context.Context { - bg := context.Background() - if client, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok && client != nil { - bg = oidc.ClientContext(bg, client) - } - return bg -} - -// get 返回缓存的 provider;若无则通过 oidc.NewProvider 获取并写入缓存。 -// 同一 issuer 并发调用时,singleflight 保证只有一次实际 HTTP 请求。 -func (c *oidcProviderCache) get(ctx context.Context, issuer string) (*oidc.Provider, error) { - // 快路径:已有缓存则直接返回。 - c.mu.RLock() - if p, ok := c.entries[issuer]; ok { - c.mu.RUnlock() - return p, nil - } - c.mu.RUnlock() - - // 慢路径:通过 singleflight 合并并发的首次请求。 - discCtx := discoveryContext(ctx) - v, err, _ := c.sfGroup.Do(issuer, func() (any, error) { - // 双检:singleflight 内再次检查,前一个并发组可能已写入缓存。 - c.mu.RLock() - if p, ok := c.entries[issuer]; ok { - c.mu.RUnlock() - return p, nil - } - c.mu.RUnlock() - - p, err := oidc.NewProvider(discCtx, 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/backend/OpenFlare/plugins/server/oauth/routers.go b/backend/OpenFlare/plugins/server/oauth/routers.go deleted file mode 100644 index d98daeea..00000000 --- a/backend/OpenFlare/plugins/server/oauth/routers.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "net/http" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/pkg/logger" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - - "Wavelet/pkg/response" -) - -// BasicUserInfo 用户基本信息结构体 -type BasicUserInfo struct { - ID uint64 `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Email string `json:"email"` - AvatarURL string `json:"avatar_url"` - IsAdmin bool `json:"is_admin"` - NeedChangePassword bool `json:"need_change_password"` - Bio string `json:"bio"` - Phone string `json:"phone"` - Gender string `json:"gender"` - Website string `json:"website"` - Location string `json:"location"` -} - -// BuildBasicUserInfo 将 User 模型转换为 BasicUserInfo -func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo { - return BasicUserInfo{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - AvatarURL: user.AvatarURL, - IsAdmin: user.IsAdmin, - NeedChangePassword: needChange, - Bio: user.Bio, - Phone: user.Phone, - Gender: user.Gender, - Website: user.Website, - Location: user.Location, - } -} - -// UserInfo 获取当前登录用户信息 -// @Summary 获取当前登录用户信息 -// @Description 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 -// @Tags oauth -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=oauth.BasicUserInfo} "用户信息" -// @Failure 401 {object} response.Any "未登录" -// @Router /api/v1/oauth/user-info [get] -// @Router /api/v1/user-info [get] -// @Router /api/v1/user/self [get] -func UserInfo(c *gin.Context) { - user, _ := GetFromContext[*model.User](c, UserObjKey) - session := sessions.Default(c) - needChange := session.Get("need_change_password") == true - - c.JSON( - http.StatusOK, - response.OK(BuildBasicUserInfo(user, needChange)), - ) -} - -// GetLoginURL 获取登录地址 -// @Summary 获取登录地址 -// @Description 生成 OAuth 登录 URL,前端跳转至该地址完成授权。返回的 URL 中包含 state 参数用于 CSRF 防护。 -// @Tags oauth -// @Produce json -// @Success 200 {object} response.Any{data=string} "OAuth 登录 URL" -// @Failure 500 {object} response.Any "Redis 异常或内部错误" -// @Router /api/v1/oauth/login [get] - -// Logout 退出登录 -// @Summary 退出登录 -// @Description 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 -// @Tags oauth -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=string} "退出成功" -// @Failure 500 {object} response.Any "Session 清除失败" -// @Router /api/v1/oauth/logout [get] -func Logout(c *gin.Context) { - session := sessions.Default(c) - userID := session.Get(UserIDKey) - username := session.Get(UserNameKey) - if userID != nil { - logger.InfoF(c.Request.Context(), "[LoginAudit] user logged out: %v, ID: %v, IP: %s", username, userID, c.ClientIP()) - if id, ok := userID.(uint64); ok { - InvalidateCachedUser(c.Request.Context(), id) - } else if idFloat, ok := userID.(float64); ok { - InvalidateCachedUser(c.Request.Context(), uint64(idFloat)) - } else if idInt, ok := userID.(int); ok && idInt >= 0 { - InvalidateCachedUser(c.Request.Context(), uint64(idInt)) - } - } - session.Options(GetSessionOptions(-1)) - session.Clear() - if err := session.Save(); err != nil { - response.AbortInternal(c, err.Error()) - return - } - c.JSON(http.StatusOK, response.OKNil()) -} diff --git a/backend/OpenFlare/plugins/server/oauth/session.go b/backend/OpenFlare/plugins/server/oauth/session.go deleted file mode 100644 index c57eccc2..00000000 --- a/backend/OpenFlare/plugins/server/oauth/session.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package oauth provides authentication and OAuth integration. -package oauth - -import ( - "net/http" - "strings" - - "Wavelet/OpenFlare/plugins/server/infra/config" - - "github.com/gin-contrib/sessions" -) - -// GetSessionOptions 根据配置构建 Session 选项 -func GetSessionOptions(maxAge int) sessions.Options { - return sessions.Options{ - Path: "/", - Domain: config.Config.App.SessionDomain, - MaxAge: maxAge, - HttpOnly: config.Config.App.SessionHTTPOnly, - Secure: config.Config.App.SessionSecure, - SameSite: http.SameSiteLaxMode, - } -} - -// StripCookieMaxAgeAndExpires 从 Set-Cookie 响应头中移除 Max-Age 和 Expires,从而使其成为浏览器会话 Cookie -func StripCookieMaxAgeAndExpires(header http.Header, cookieName string) { - headers := header["Set-Cookie"] - if len(headers) == 0 { - return - } - - newHeaders := make([]string, 0, len(headers)) - for _, h := range headers { - if strings.HasPrefix(h, cookieName+"=") { - parts := strings.Split(h, ";") - newParts := make([]string, 0, len(parts)) - for _, p := range parts { - trimmed := strings.TrimSpace(p) - lower := strings.ToLower(trimmed) - if strings.HasPrefix(lower, "max-age=") || strings.HasPrefix(lower, "expires=") { - continue - } - newParts = append(newParts, p) - } - newHeaders = append(newHeaders, strings.Join(newParts, ";")) - } else { - newHeaders = append(newHeaders, h) - } - } - header["Set-Cookie"] = newHeaders -} diff --git a/backend/OpenFlare/plugins/server/oauth/session_context.go b/backend/OpenFlare/plugins/server/oauth/session_context.go deleted file mode 100644 index df0bce44..00000000 --- a/backend/OpenFlare/plugins/server/oauth/session_context.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package oauth - -import ( - "context" - "crypto/sha256" - "encoding/hex" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - gsessions "github.com/gorilla/sessions" -) - -// GetUserIDFromSession 从 Session 中提取用户 ID -func GetUserIDFromSession(s sessions.Session) uint64 { - userID, ok := s.Get(UserIDKey).(uint64) - if !ok { - return 0 - } - return userID -} - -// GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID -func GetUserIDFromContext(c *gin.Context) uint64 { - session := sessions.Default(c) - return GetUserIDFromSession(session) -} - -func ensureSessionToken(s sessions.Session) (string, bool) { - token, ok := s.Get(SessionTokenKey).(string) - if !ok || token == "" { - token = uuid.NewString() - s.Set(SessionTokenKey, token) - return token, true - } - return token, false -} - -func hashSessionToken(token string) string { - h := sha256.New() - h.Write([]byte(token)) - return hex.EncodeToString(h.Sum(nil)) -} - -func rotateSessionID(s sessions.Session) { - if inner, ok := s.(interface{ Session() *gsessions.Session }); ok { - if sess := inner.Session(); sess != nil { - sess.ID = "" - } - } -} - -// SetLoginSession writes the authenticated user into a freshly rotated session. -func SetLoginSession(ctx context.Context, c *gin.Context, user *model.User, extras ...map[string]any) error { - session := sessions.Default(c) - session.Clear() - rotateSessionID(session) - - session.Set(UserIDKey, user.ID) - session.Set(UserNameKey, user.Username) - session.Set(PasswordHashKey, user.Password) - if len(extras) > 0 { - for key, value := range extras[0] { - session.Set(key, value) - } - } - - maxAge := config.Config.App.SessionAge - isSessionCookie := false - - ttlHours, err := repository.GetIntByKey(ctx, model.ConfigKeyLoginSessionTTLHours) - if err == nil { - switch { - case ttlHours == -1: - maxAge = 10 * 365 * 24 * 3600 - case ttlHours > 0: - maxAge = ttlHours * 3600 - case ttlHours == 0: - isSessionCookie = true - } - } - session.Options(GetSessionOptions(maxAge)) - - if err := session.Save(); err != nil { - return err - } - - if isSessionCookie { - StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName) - } - - return nil -} diff --git a/backend/OpenFlare/plugins/server/ofevents/events.go b/backend/OpenFlare/plugins/server/ofevents/events.go new file mode 100644 index 00000000..ea1b6540 --- /dev/null +++ b/backend/OpenFlare/plugins/server/ofevents/events.go @@ -0,0 +1,13 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package ofevents lists OpenFlare-specific push events registered onto Wavelet's PushRegistry. +package ofevents + +import "Wavelet/core/contracts" + +// All returns OpenFlare-only built-in push events. +// Platform events (admin login, etc.) stay in Wavelet message_gateway. +func All() []contracts.PushEventMeta { + return nil +} diff --git a/backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go index 6e80254c..62eb3d6e 100644 --- a/backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/OpenFlare/plugins/server/testhelper" diff --git a/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go b/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go index 6ad45fc9..cc0f5804 100644 --- a/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/share/protocol" diff --git a/backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go index dea11f63..3ea5fddc 100644 --- a/backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go @@ -4,163 +4,45 @@ package apiutil import ( - "encoding/json" "net/http" "net/http/httptest" "testing" - "Wavelet/OpenFlare/plugins/server/admin" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" "Wavelet/OpenFlare/plugins/server/testhelper" "Wavelet/pkg/response" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" ) -func setupAdminMiddlewareTest(t *testing.T) (*gin.Engine, *gorm.DB, func()) { - t.Helper() - - dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - require.NoError(t, dbConn.AutoMigrate(&model.User{}, &model.AccessToken{})) - db.SetDB(dbConn) - - sessionCookieName := "test_admin_middleware_session" - if config.Config.App.SessionCookieName != "" { - sessionCookieName = config.Config.App.SessionCookieName +func TestAdminMiddlewaresRunsAuthThenAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + var order []string + auth := sequentialAuth{ + auth: func(c *gin.Context) { + order = append(order, "auth") + c.Next() + }, + admin: func(c *gin.Context) { + order = append(order, "admin") + c.Next() + }, } - store := cookie.NewStore([]byte("test_admin_middleware_session_secret")) - store.Options(oauth.GetSessionOptions(3600)) - engine := testhelper.NewTestGinEngine(sessions.Sessions(sessionCookieName, store)) - mws := ginHandlerMiddlewares(AdminMiddlewares()...) - protected := engine.Group("/protected", mws...) - protected.GET("", func(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(gin.H{"ok": true})) + + engine := testhelper.NewTestGinEngine() + group := engine.Group("/protected", ginHandlerMiddlewares(AdminMiddlewares(auth)...)...) + group.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OKNil()) }) - cleanup := func() { - db.SetDB(nil) - } - - return engine, dbConn, cleanup -} - -func seedUser(t *testing.T, dbConn *gorm.DB, username string, isAdmin bool) *model.User { - t.Helper() - - user := &model.User{ - ID: idgen.NextUint64ID(), - Username: username, - Nickname: username, - Email: username + "@openflare.test", - IsActive: true, - IsAdmin: isAdmin, - } - require.NoError(t, dbConn.Create(user).Error) - return user -} - -func seedAccessToken(t *testing.T, dbConn *gorm.DB, user *model.User, isAdmin bool) string { - t.Helper() - - token, err := model.GenerateTokenString() - require.NoError(t, err) - require.NoError(t, dbConn.Create(&model.AccessToken{ - UserID: user.ID, - Name: user.Username + "-token", - TokenHash: model.HashToken(token), - MaskedToken: model.MaskTokenString(token), - IsAdmin: isAdmin, - }).Error) - return token -} - -func decodeResponse(t *testing.T, rec *httptest.ResponseRecorder) response.Any { - t.Helper() - - var resp response.Any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - return resp -} - -func TestAdminRequiredUnauthenticated(t *testing.T) { - engine, _, cleanup := setupAdminMiddlewareTest(t) - defer cleanup() - rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/protected", nil) engine.ServeHTTP(rec, req) - assert.Equal(t, http.StatusUnauthorized, rec.Code) - resp := decodeResponse(t, rec) - assert.NotEmpty(t, resp.ErrorMsg) -} - -func TestAdminRequiredNonAdminToken(t *testing.T) { - engine, dbConn, cleanup := setupAdminMiddlewareTest(t) - defer cleanup() - - user := seedUser(t, dbConn, "regular", false) - token := seedAccessToken(t, dbConn, user, false) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("X-Access-Token", token) - engine.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusNotFound, rec.Code) - resp := decodeResponse(t, rec) - assert.Equal(t, admin.TokenAdminRequired, resp.ErrorMsg) -} - -func TestAdminRequiredAdminWithoutTokenAdmin(t *testing.T) { - engine, dbConn, cleanup := setupAdminMiddlewareTest(t) - defer cleanup() - - user := seedUser(t, dbConn, "admin-no-token-admin", true) - token := seedAccessToken(t, dbConn, user, false) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("X-Access-Token", token) - engine.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusNotFound, rec.Code) - resp := decodeResponse(t, rec) - assert.Equal(t, admin.TokenAdminRequired, resp.ErrorMsg) -} - -func TestAdminRequiredAdminWithTokenAdmin(t *testing.T) { - engine, dbConn, cleanup := setupAdminMiddlewareTest(t) - defer cleanup() - - user := seedUser(t, dbConn, "admin", true) - token := seedAccessToken(t, dbConn, user, true) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("X-Access-Token", token) - engine.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code) - resp := decodeResponse(t, rec) - assert.Empty(t, resp.ErrorMsg) + assert.Equal(t, []string{"auth", "admin"}, order) } -// ginHandlerMiddlewares 把内核形态的 []any 中间件还原为 gin 切片, -// 便于在纯 gin 测试引擎上验证 AdminMiddlewares 的门禁行为。 func ginHandlerMiddlewares(middlewares ...any) []gin.HandlerFunc { handlers := make([]gin.HandlerFunc, 0, len(middlewares)) for _, m := range middlewares { @@ -175,3 +57,11 @@ func ginHandlerMiddlewares(middlewares ...any) []gin.HandlerFunc { } return handlers } + +type sequentialAuth struct { + testhelper.StubAuth + auth, admin gin.HandlerFunc +} + +func (s sequentialAuth) RequireAuthMiddleware() any { return s.auth } +func (s sequentialAuth) RequireAdminMiddleware() any { return s.admin } diff --git a/backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go b/backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go index 31691065..36e52c2c 100644 --- a/backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go @@ -10,7 +10,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/glebarez/sqlite" diff --git a/backend/OpenFlare/plugins/server/openflare/async_tasks.go b/backend/OpenFlare/plugins/server/openflare/async_tasks.go index 73b734f6..bcb0c68a 100644 --- a/backend/OpenFlare/plugins/server/openflare/async_tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/async_tasks.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/tasks" "Wavelet/OpenFlare/plugins/server/openflare/uptimekuma" diff --git a/backend/OpenFlare/plugins/server/openflare/async_tasks_test.go b/backend/OpenFlare/plugins/server/openflare/async_tasks_test.go index d89b8fa7..9ed01dde 100644 --- a/backend/OpenFlare/plugins/server/openflare/async_tasks_test.go +++ b/backend/OpenFlare/plugins/server/openflare/async_tasks_test.go @@ -7,7 +7,7 @@ import ( "context" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go b/backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go index c7ba45a7..50df8cee 100644 --- a/backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go +++ b/backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/chwriter" ) @@ -21,7 +21,7 @@ import ( // // go test -tags live_ch ./internal/apps/openflare/chwriter -run TestLiveAppWritePath -count=1 -timeout 2m func TestLiveAppWritePath(t *testing.T) { - if !db.ChConnReady() { + if db.ChConn == nil { t.Skip("ClickHouse connection not ready") } ctx := context.Background() diff --git a/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go index e16e28cd..488ebdd3 100644 --- a/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go @@ -8,7 +8,7 @@ import ( "errors" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/credential" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go index 68c92585..096c2552 100644 --- a/backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/pkg/response" diff --git a/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go index 0c03c222..05c3f441 100644 --- a/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go @@ -12,7 +12,7 @@ import ( "io" "strings" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go index 4180b107..6e965ed2 100644 --- a/backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go @@ -17,8 +17,8 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" oftls "Wavelet/OpenFlare/plugins/server/openflare/tls" @@ -31,9 +31,9 @@ func TestBuildCertificateSupportFilesDecryptsSealedPrivateKey(t *testing.T) { defer cleanup() require.NoError(t, db.DB(context.Background()).AutoMigrate(&model.TLSCertificate{})) - oldSecret := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "test-session-secret-for-tls-seal" - t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret }) + previous := runtimeconfig.Get() + runtimeconfig.SetSessionSecret("test-session-secret-for-tls-seal") + t.Cleanup(func() { runtimeconfig.Set(previous) }) ctx := context.Background() certPEM, keyPEM := generateTestCertKeyPairForSnapshot(t) @@ -67,9 +67,9 @@ func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) { ctx := context.Background() require.NoError(t, db.DB(ctx).AutoMigrate(&model.TLSCertificate{})) - oldSecret := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "test-session-secret-for-zone-domain-snapshots" - t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret }) + previous := runtimeconfig.Get() + runtimeconfig.SetSessionSecret("test-session-secret-for-zone-domain-snapshots") + t.Cleanup(func() { runtimeconfig.Set(previous) }) firstCertPEM, firstKeyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "one.example.com") first, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "first", CertPEM: firstCertPEM, KeyPEM: firstKeyPEM}) diff --git a/backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go index f1204b56..a4a0c27d 100644 --- a/backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/waf" openrestyrender "Wavelet/OpenFlare/share/render/openresty" diff --git a/backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go index c31deb28..d28173d0 100644 --- a/backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go @@ -8,7 +8,7 @@ import ( "encoding/json" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/pkg/cache/ram" diff --git a/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go index a737fd1e..c13da391 100644 --- a/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go @@ -10,7 +10,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" openrestyrender "Wavelet/OpenFlare/share/render/openresty" diff --git a/backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go index 1dcff16f..c3b3d15c 100644 --- a/backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/waf" diff --git a/backend/OpenFlare/plugins/server/openflare/credential/sensitive.go b/backend/OpenFlare/plugins/server/openflare/credential/sensitive.go index a4380e14..099804bc 100644 --- a/backend/OpenFlare/plugins/server/openflare/credential/sensitive.go +++ b/backend/OpenFlare/plugins/server/openflare/credential/sensitive.go @@ -10,18 +10,29 @@ import ( "errors" "strings" - "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" "Wavelet/pkg/util" ) // Prefix identifies values encrypted with the current credential format. const Prefix = "enc:v1:" +var sessionSecret string + +// SetSessionSecret binds the host session secret used to seal credentials. +func SetSessionSecret(secret string) { + sessionSecret = strings.TrimSpace(secret) +} + func encryptionKey() string { - if config.Config == nil || strings.TrimSpace(config.Config.App.SessionSecret) == "" { + secret := strings.TrimSpace(sessionSecret) + if secret == "" { + secret = strings.TrimSpace(runtimeconfig.SessionSecret()) + } + if secret == "" { return "" } - sum := sha256.Sum256([]byte(config.Config.App.SessionSecret)) + sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } diff --git a/backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go b/backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go index 96f1a902..162e5cb3 100644 --- a/backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go +++ b/backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go @@ -7,13 +7,16 @@ import ( "strings" "testing" - "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" ) func TestSealAndOpenSensitiveValue(t *testing.T) { - previous := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "cloudflare-pointing-test-secret" - t.Cleanup(func() { config.Config.App.SessionSecret = previous }) + previous := runtimeconfig.Get() + SetSessionSecret("cloudflare-pointing-test-secret") + t.Cleanup(func() { + SetSessionSecret(previous.SessionSecret) + runtimeconfig.Set(previous) + }) sealed, err := Seal(`{"api_token":"secret-token"}`) if err != nil { @@ -36,9 +39,13 @@ func TestSealAndOpenSensitiveValue(t *testing.T) { } func TestSealWithoutSessionSecretKeepsPlaintextCompatibility(t *testing.T) { - previous := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "" - t.Cleanup(func() { config.Config.App.SessionSecret = previous }) + previous := runtimeconfig.Get() + SetSessionSecret("") + runtimeconfig.Set(runtimeconfig.Snapshot{}) + t.Cleanup(func() { + SetSessionSecret(previous.SessionSecret) + runtimeconfig.Set(previous) + }) sealed, err := Seal(" legacy-value ") if err != nil { @@ -58,15 +65,19 @@ func TestSealWithoutSessionSecretKeepsPlaintextCompatibility(t *testing.T) { } func TestOpenEncryptedValueRequiresSessionSecret(t *testing.T) { - previous := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "cloudflare-pointing-test-secret" + previous := runtimeconfig.Get() + SetSessionSecret("cloudflare-pointing-test-secret") sealed, err := Seal("secret") if err != nil { t.Fatalf("Seal() error = %v", err) } - config.Config.App.SessionSecret = "" - t.Cleanup(func() { config.Config.App.SessionSecret = previous }) + SetSessionSecret("") + runtimeconfig.Set(runtimeconfig.Snapshot{}) + t.Cleanup(func() { + SetSessionSecret(previous.SessionSecret) + runtimeconfig.Set(previous) + }) if _, err := Open(sealed); err == nil { t.Fatal("Open(encrypted) error = nil, want missing session secret error") } diff --git a/backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go b/backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go index 35b70c1a..99db82d2 100644 --- a/backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/OpenFlare/plugins/server/testhelper" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/glebarez/sqlite" diff --git a/backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go index 8e98feaf..60943049 100644 --- a/backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/pkg/response" diff --git a/backend/OpenFlare/plugins/server/openflare/flared/observability_test.go b/backend/OpenFlare/plugins/server/openflare/flared/observability_test.go index 8ae8d1b5..a4ebead6 100644 --- a/backend/OpenFlare/plugins/server/openflare/flared/observability_test.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/observability_test.go @@ -9,7 +9,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/agent" diff --git a/backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go b/backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go index 12801a60..45e0ecef 100644 --- a/backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go +++ b/backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go @@ -7,7 +7,7 @@ import ( "context" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" pkggeoip "Wavelet/OpenFlare/share/geoip" diff --git a/backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go b/backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go index c32dbb97..a28135db 100644 --- a/backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go @@ -10,7 +10,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/agent" ofnode "Wavelet/OpenFlare/plugins/server/openflare/node" diff --git a/backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go b/backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go index 42babd45..3144c6fe 100644 --- a/backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go @@ -10,12 +10,9 @@ import ( "net/http/httptest" "testing" - "Wavelet/OpenFlare/plugins/server/admin" - "Wavelet/OpenFlare/plugins/server/cap" - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + "Wavelet/pkg/idgen" "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/OpenFlare/plugins/server/testhelper" @@ -42,30 +39,10 @@ func setupAuthOptionIntegration(t *testing.T) (*gorm.DB, *gin.Engine) { Where("key = ?", model.ConfigKeyCapLoginEnabled). Update("value", "false").Error) require.NoError(t, repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled)) - cap.InvalidateRuntimeSettings() + runtimeconfig.SetSessionSecret("test_openflare_session_secret") - oldCookieName := config.Config.App.SessionCookieName - oldSecret := config.Config.App.SessionSecret - oldDomain := config.Config.App.SessionDomain - oldSecure := config.Config.App.SessionSecure - oldHTTPOnly := config.Config.App.SessionHTTPOnly - t.Cleanup(func() { - config.Config.App.SessionCookieName = oldCookieName - config.Config.App.SessionSecret = oldSecret - config.Config.App.SessionDomain = oldDomain - config.Config.App.SessionSecure = oldSecure - config.Config.App.SessionHTTPOnly = oldHTTPOnly - }) - - config.Config.App.SessionCookieName = "test_openflare_session" - config.Config.App.SessionSecret = "test_openflare_session_secret" - config.Config.App.SessionDomain = "" - config.Config.App.SessionSecure = false - config.Config.App.SessionHTTPOnly = true - - store := cookie.NewStore([]byte(config.Config.App.SessionSecret)) - store.Options(oauth.GetSessionOptions(3600)) - r := testhelper.NewTestGinEngine(sessions.Sessions(config.Config.App.SessionCookieName, store)) + store := cookie.NewStore([]byte("test_openflare_session_secret")) + r := testhelper.NewTestGinEngine(sessions.Sessions("test_openflare_session", store)) mountOpenFlareTestRoutes(r) return dbConn, r @@ -125,17 +102,12 @@ func TestGETOptionRequiresAdminAuth(t *testing.T) { adminToken := seedUserWithAccessToken(t, dbConn, "adminuser", "password123", true) t.Run("unauthenticated", func(t *testing.T) { - w := performJSONRequest(t, r, http.MethodGet, apiPath("/option/"), nil, nil) - assert.Equal(t, http.StatusUnauthorized, w.Code) - resp := decodeAPIResponse(t, w) - assert.NotEmpty(t, resp.ErrorMsg) + t.Skip("console auth is owned by Wavelet auth plugin") }) t.Run("non-admin user forbidden", func(t *testing.T) { - w := performJSONRequest(t, r, http.MethodGet, apiPath("/option/"), nil, adminAuthHeaders(commonToken)) - assert.Equal(t, http.StatusNotFound, w.Code) - resp := decodeAPIResponse(t, w) - assert.Equal(t, admin.TokenAdminRequired, resp.ErrorMsg) + t.Skip("console auth is owned by Wavelet auth plugin") + _ = commonToken }) t.Run("admin user allowed", func(t *testing.T) { diff --git a/backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go b/backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go index 9e6acf54..1028a90c 100644 --- a/backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/agent" "Wavelet/OpenFlare/plugins/server/testhelper" diff --git a/backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go b/backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go index 4a945046..d2f0ec17 100644 --- a/backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go @@ -12,9 +12,10 @@ import ( "testing" "Wavelet/OpenFlare/plugins/server" - "Wavelet/OpenFlare/plugins/server/infra/config" ofrouter "Wavelet/OpenFlare/plugins/server/router/v1/openflare" + "Wavelet/OpenFlare/plugins/server/testhelper" "Wavelet/core" + "Wavelet/core/contracts" "Wavelet/pkg/response" "github.com/gin-gonic/gin" @@ -64,13 +65,9 @@ func unmarshalAPISlice(t *testing.T, data any) []any { // mountOpenFlareTestRoutes 复刻 driver_http 的挂载方式:先由 server 插件经内核 // 路由注册表声明路由,再把每条 (方法, 路径, 中间件+处理链) 挂到测试引擎上。 func mountOpenFlareTestRoutes(engine *gin.Engine) { - // 与生产 config.example.yaml 一致:插件按 app.api_prefix 组装路径。 - if config.Config.App.APIPrefix == "" { - config.Config.App.APIPrefix = "/api" - } - ctx := core.NewContext(context.Background()) - if err := (&server.Plugin{}).Apply(ctx); err != nil { + core.Provide[contracts.AuthService](ctx, testhelper.StubAuth{}) + if err := server.New().Apply(ctx); err != nil { panic(err) } for _, rd := range ctx.Router().Routes() { diff --git a/backend/OpenFlare/plugins/server/openflare/integration/security_test.go b/backend/OpenFlare/plugins/server/openflare/integration/security_test.go index 35ff08d7..acc1093b 100644 --- a/backend/OpenFlare/plugins/server/openflare/integration/security_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/security_test.go @@ -16,8 +16,8 @@ import ( "testing" "time" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/testhelper" @@ -56,14 +56,14 @@ func setupSecurityTest(t *testing.T) (*gin.Engine, adminSeed, func()) { seed, err := seedAdminWithAccessToken(sqliteDB) require.NoError(t, err) - oldSecret := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "test_session_secret_for_security_integration" + previous := runtimeconfig.Get() + runtimeconfig.SetSessionSecret("test_session_secret_for_security_integration") engine := testhelper.NewTestGinEngine() mountOpenFlareTestRoutes(engine) cleanup := func() { - config.Config.App.SessionSecret = oldSecret + runtimeconfig.Set(previous) db.SetDB(nil) } diff --git a/backend/OpenFlare/plugins/server/openflare/node/logics_test.go b/backend/OpenFlare/plugins/server/openflare/node/logics_test.go index a09ede06..81e48efd 100644 --- a/backend/OpenFlare/plugins/server/openflare/node/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/node/logics_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/node/version.go b/backend/OpenFlare/plugins/server/openflare/node/version.go index 10b288c4..be2c19cc 100644 --- a/backend/OpenFlare/plugins/server/openflare/node/version.go +++ b/backend/OpenFlare/plugins/server/openflare/node/version.go @@ -3,225 +3,8 @@ package node -import ( - "strconv" - "strings" -) - -const gitDescribeMinIdentifiers = 2 - -type versionInfo struct { - valid bool - isDev bool - numbers []int - prerelease []string - gitDescribeDistance int - gitDescribeTail []string -} - -func parseVersionInfo(version string) versionInfo { - normalized := strings.TrimSpace(strings.TrimPrefix(version, "v")) - if normalized == "" || normalized == "dev" { - return versionInfo{isDev: strings.EqualFold(normalized, "dev")} - } - base := normalized - prerelease := "" - if separator := strings.IndexRune(normalized, '-'); separator >= 0 { - base = normalized[:separator] - prerelease = normalized[separator+1:] - } - - segments := strings.Split(base, ".") - parts := make([]int, 0, len(segments)) - for _, segment := range segments { - segment = strings.TrimSpace(segment) - if segment == "" { - parts = append(parts, 0) - continue - } - - numeric := strings.Builder{} - for _, r := range segment { - if r < '0' || r > '9' { - break - } - numeric.WriteRune(r) - } - if numeric.Len() == 0 { - parts = append(parts, 0) - continue - } - value, err := strconv.Atoi(numeric.String()) - if err != nil { - return versionInfo{} - } - parts = append(parts, value) - } - info := versionInfo{valid: len(parts) > 0, numbers: parts} - if prerelease != "" { - identifiers := splitPrereleaseIdentifiers(prerelease) - if distance, tail, ok := parseGitDescribeIdentifiers(identifiers); ok { - info.gitDescribeDistance = distance - info.gitDescribeTail = tail - } else { - info.prerelease = identifiers - } - } - return info -} - -func parseGitDescribeIdentifiers(identifiers []string) (int, []string, bool) { - if len(identifiers) < gitDescribeMinIdentifiers { - return 0, nil, false - } - distance, err := strconv.Atoi(strings.TrimSpace(identifiers[0])) - if err != nil || distance <= 0 { - return 0, nil, false - } - commitToken := strings.TrimSpace(identifiers[1]) - if commitToken == "" || !strings.HasPrefix(strings.ToLower(commitToken), "g") { - return 0, nil, false - } - return distance, identifiers[1:], true -} - -func splitPrereleaseIdentifiers(value string) []string { - parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { - return r == '.' || r == '-' - }) - filtered := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - filtered = append(filtered, part) - } - } - return filtered -} +import "Wavelet/OpenFlare/share/ofutil" func compareVersions(local, remote string) int { - left := parseVersionInfo(local) - right := parseVersionInfo(remote) - if left.isDev { - if right.valid { - return -1 - } - return 0 - } - if !left.valid || !right.valid { - return 0 - } - - if result := compareVersionNumbers(left, right); result != 0 { - return result - } - if result := compareGitDescribeDistance(left, right); result != 0 { - return result - } - if left.gitDescribeDistance > 0 || right.gitDescribeDistance > 0 { - return compareGitDescribeTails(left, right) - } - return comparePrereleaseIdentifiers(left, right) -} - -func compareVersionNumbers(left, right versionInfo) int { - maxLen := max(len(right.numbers), len(left.numbers)) - for index := range maxLen { - leftValue := 0 - rightValue := 0 - if index < len(left.numbers) { - leftValue = left.numbers[index] - } - if index < len(right.numbers) { - rightValue = right.numbers[index] - } - if leftValue < rightValue { - return -1 - } - if leftValue > rightValue { - return 1 - } - } - return 0 -} - -func compareGitDescribeDistance(left, right versionInfo) int { - if left.gitDescribeDistance == right.gitDescribeDistance { - return 0 - } - if left.gitDescribeDistance < right.gitDescribeDistance { - return -1 - } - return 1 -} - -func compareGitDescribeTails(left, right versionInfo) int { - maxLen := max(len(right.gitDescribeTail), len(left.gitDescribeTail)) - for index := range maxLen { - if index >= len(left.gitDescribeTail) { - return -1 - } - if index >= len(right.gitDescribeTail) { - return 1 - } - if left.gitDescribeTail[index] < right.gitDescribeTail[index] { - return -1 - } - if left.gitDescribeTail[index] > right.gitDescribeTail[index] { - return 1 - } - } - return 0 -} - -func comparePrereleaseIdentifiers(left, right versionInfo) int { - if len(left.prerelease) == 0 && len(right.prerelease) == 0 { - return 0 - } - if len(left.prerelease) == 0 { - return 1 - } - if len(right.prerelease) == 0 { - return -1 - } - - maxLen := max(len(right.prerelease), len(left.prerelease)) - for index := range maxLen { - if index >= len(left.prerelease) { - return -1 - } - if index >= len(right.prerelease) { - return 1 - } - if result := comparePrereleasePart(left.prerelease[index], right.prerelease[index]); result != 0 { - return result - } - } - return 0 -} - -func comparePrereleasePart(leftPart, rightPart string) int { - leftNumber, leftErr := strconv.Atoi(leftPart) - rightNumber, rightErr := strconv.Atoi(rightPart) - switch { - case leftErr == nil && rightErr == nil: - if leftNumber < rightNumber { - return -1 - } - if leftNumber > rightNumber { - return 1 - } - case leftErr == nil: - return -1 - case rightErr == nil: - return 1 - default: - if leftPart < rightPart { - return -1 - } - if leftPart > rightPart { - return 1 - } - } - return 0 + return ofutil.CompareVersions(local, remote) } diff --git a/backend/OpenFlare/plugins/server/openflare/ofupload/ofupload.go b/backend/OpenFlare/plugins/server/openflare/ofupload/ofupload.go new file mode 100644 index 00000000..af619c8a --- /dev/null +++ b/backend/OpenFlare/plugins/server/openflare/ofupload/ofupload.go @@ -0,0 +1,163 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package ofupload wraps Wavelet upload ingest with Pages-specific helpers. +package ofupload + +import ( + "context" + "errors" + "io" + "os" + "strings" + + waveletupload "Wavelet/plugins/domain/upload" + "Wavelet/plugins/domain/upload/cache" + "Wavelet/plugins/domain/upload/models" + uploadrepo "Wavelet/plugins/domain/upload/repository" + uploadstats "Wavelet/plugins/domain/upload/stats" + uploadstorage "Wavelet/plugins/domain/upload/storage" + "Wavelet/plugins/infra/database" + + "gorm.io/gorm" +) + +// ReservedPagesDeploymentType is managed exclusively by the Pages domain. +const ReservedPagesDeploymentType = "openflare_pages_deployment" + +const ( + // PolicyCreate always stores a new object and creates a new upload record. + PolicyCreate = waveletupload.PolicyCreate + // PolicyDedupNewRecord reuses an existing object path on hash match but creates a new record. + PolicyDedupNewRecord = waveletupload.PolicyDedupNewRecord + // PolicyResolveExisting returns an existing upload on hash match. + PolicyResolveExisting = waveletupload.PolicyResolveExisting +) + +type ( + // IngestRequest is the programmatic upload ingest payload. + IngestRequest = waveletupload.IngestRequest + // IngestResult reports ingest side effects. + IngestResult = waveletupload.IngestResult + // IngestPolicy controls hash-collision behavior during ingest. + IngestPolicy = waveletupload.IngestPolicy +) + +// IngestFromLocalPath ingests a local regular file through Wavelet upload ingest. +func IngestFromLocalPath(ctx context.Context, localPath string, req IngestRequest) (IngestResult, error) { + localPath = strings.TrimSpace(localPath) + if localPath == "" { + return IngestResult{}, errors.New("local path is required") + } + file, err := os.Open(localPath) //nolint:gosec // localPath is resolved from managed Pages artifacts + if err != nil { + return IngestResult{}, err + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return IngestResult{}, err + } + if info.IsDir() { + return IngestResult{}, errors.New("local path must be a regular file") + } + if req.Size <= 0 { + req.Size = info.Size() + } + req.Reader = file + return waveletupload.Ingest(ctx, req) +} + +// RemoveLockedTx performs the idempotent active-to-deleted transition for a row +// that the caller has already locked in its surrounding transaction. +func RemoveLockedTx(tx *gorm.DB, upload *models.Upload) (bool, error) { + if upload == nil { + return false, nil + } + if upload.Status == models.UploadStatusDeleted { + return false, nil + } + snapshot := *upload + if err := uploadrepo.SoftDeleteUploadTx(tx, upload); err != nil { + return false, err + } + if err := uploadstats.ApplyUploadStatsDeltaTx(tx, &snapshot, -1); err != nil { + return false, err + } + upload.Status = models.UploadStatusDeleted + return true, nil +} + +// InvalidateUploadMetaCache evicts cached upload metadata. +func InvalidateUploadMetaCache(ctx context.Context, id uint64) { + cache.EvictUploadMeta(ctx, id) +} + +// GetActiveUpload loads an active (non-deleted) upload by ID. +func GetActiveUpload(ctx context.Context, id uint64) (models.Upload, error) { + if u, err := uploadrepo.GetActiveUploadByID(ctx, id); err == nil { + return u, nil + } + conn := database.DB(ctx) + if conn == nil { + return models.Upload{}, errors.New("database not initialized") + } + var upload models.Upload + err := conn.Where("id = ? AND status <> ?", id, models.UploadStatusDeleted).First(&upload).Error + return upload, err +} + +// OpenedUploadObject is a stored object stream plus the upload record. +type OpenedUploadObject struct { + Upload models.Upload + Body io.ReadCloser + ContentType string + ContentLength int64 +} + +// OpenStoredUpload opens the stored object for an active upload. +func OpenStoredUpload(ctx context.Context, id uint64) (*OpenedUploadObject, error) { + upload, err := GetActiveUpload(ctx, id) + if err != nil { + return nil, err + } + obj, err := uploadstorage.OpenStoredObject(ctx, &upload) + if err != nil { + return nil, err + } + return &OpenedUploadObject{ + Upload: upload, + Body: obj.Body, + ContentType: obj.ContentType, + ContentLength: obj.ContentLength, + }, nil +} + +// LocalFileCandidateRequest describes filesystem locations that may host a legacy blob. +type LocalFileCandidateRequest struct { + StoredPath string + RelativePaths []string +} + +// ResolveLocalFile returns the first existing regular file among candidate paths. +func ResolveLocalFile(_ context.Context, req LocalFileCandidateRequest) (string, int64, error) { + candidates := append([]string{req.StoredPath}, req.RelativePaths...) + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + info, err := os.Stat(candidate) //nolint:gosec // candidate is resolved from managed Pages metadata + if err != nil || info.IsDir() { + continue + } + return candidate, info.Size(), nil + } + return "", 0, os.ErrNotExist +} + +// RebuildUploadStats rebuilds aggregate upload stats. +func RebuildUploadStats(ctx context.Context) error { + return uploadstats.RebuildUploadStats(ctx) +} diff --git a/backend/OpenFlare/plugins/server/openflare/option/logics_test.go b/backend/OpenFlare/plugins/server/openflare/option/logics_test.go index 71636f05..64a36e84 100644 --- a/backend/OpenFlare/plugins/server/openflare/option/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/option/logics_test.go @@ -7,7 +7,7 @@ import ( "context" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/origin/logics_test.go b/backend/OpenFlare/plugins/server/openflare/origin/logics_test.go index fdafd38c..f918142a 100644 --- a/backend/OpenFlare/plugins/server/openflare/origin/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/origin/logics_test.go @@ -7,7 +7,7 @@ import ( "context" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/glebarez/sqlite" diff --git a/backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go index 982ca6ce..b531c361 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/integration/githubrelease" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go index df8db095..28ff44d2 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" ) diff --git a/backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go index c89c33cf..6d1a603d 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go @@ -18,8 +18,8 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/infra/task" + db "Wavelet/plugins/infra/database" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/integration/githubrelease" "Wavelet/OpenFlare/plugins/server/model" @@ -176,9 +176,8 @@ func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) { func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) { ctx := setupPagesSourceTest(t) // Isolate from other tests that may leave a global Asynq client registered. - previousClient := task.AsynqClient - task.AsynqClient = nil - t.Cleanup(func() { task.AsynqClient = previousClient }) + task.SetService(nil) + t.Cleanup(func() { task.SetService(nil) }) project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning") result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{ diff --git a/backend/OpenFlare/plugins/server/openflare/pages/helpers.go b/backend/OpenFlare/plugins/server/openflare/pages/helpers.go index ee5f42a6..ab363353 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/helpers.go @@ -20,7 +20,7 @@ import ( "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "Wavelet/OpenFlare/share/pagesarchive" "Wavelet/pkg/logger" @@ -259,7 +259,7 @@ func ingestPagesDeploymentPackage( projectID uint, fileName string, format pagesarchive.Format, -) (upload.IngestResult, error) { +) (ofupload.IngestResult, error) { return ingestPagesDeploymentPackageWithSource(ctx, localPath, checksum, projectID, 0, fileName, format) } @@ -271,7 +271,7 @@ func ingestPagesDeploymentPackageWithSource( sourceID uint, fileName string, format pagesarchive.Format, -) (upload.IngestResult, error) { +) (ofupload.IngestResult, error) { systemUser := repository.GetSystemUser(ctx) accessMode := 0 extension := pagesarchive.NormalizeNameExtension(fileName, format) @@ -282,16 +282,16 @@ func ingestPagesDeploymentPackageWithSource( if sourceID != 0 { extra[pagesSourceIDMetadataKey] = strconv.FormatUint(uint64(sourceID), 10) } - return upload.IngestFromLocalPath(ctx, localPath, upload.IngestRequest{ + return ofupload.IngestFromLocalPath(ctx, localPath, ofupload.IngestRequest{ UserID: systemUser.ID, FileName: fileName, MimeType: pagesarchive.MIMEType(format), Extension: extension, Hash: checksum, - Type: upload.ReservedPagesDeploymentType, + Type: ofupload.ReservedPagesDeploymentType, AccessMode: &accessMode, SkipExtensionCheck: true, - Policy: upload.PolicyDedupNewRecord, + Policy: ofupload.PolicyDedupNewRecord, Metadata: model.UploadMetadata{ Extra: extra, }, @@ -337,7 +337,7 @@ func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, upload } return err } - if uploadRecord.Type != upload.ReservedPagesDeploymentType { + if uploadRecord.Type != ofupload.ReservedPagesDeploymentType { return fmt.Errorf("pages 部署包上传类型不匹配: %s", uploadRecord.Type) } @@ -350,12 +350,12 @@ func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, upload if references > 0 { return nil } - _, err := upload.RemoveLockedTx(tx, &uploadRecord) + _, err := ofupload.RemoveLockedTx(tx, &uploadRecord) return err }) // Always invalidate after transaction completion, including idempotent no-op, // so a prior post-commit cache interruption can heal on retry. - upload.InvalidateUploadMetaCache(ctx, uploadID) + ofupload.InvalidateUploadMetaCache(ctx, uploadID) return err } diff --git a/backend/OpenFlare/plugins/server/openflare/pages/logics.go b/backend/OpenFlare/plugins/server/openflare/pages/logics.go index 80435411..1ce6cf26 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/logics.go @@ -18,7 +18,7 @@ import ( "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "Wavelet/OpenFlare/share/pagesarchive" "Wavelet/pkg/logger" @@ -441,7 +441,7 @@ func createDeploymentFromTempPackage( First(&uploadRecord).Error; err != nil { return errors.New(errPagesPackageUploadMissing) } - if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != upload.ReservedPagesDeploymentType { + if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != ofupload.ReservedPagesDeploymentType { return errors.New(errPagesPackageUploadMissing) } var maxNumber int @@ -790,7 +790,7 @@ func loadDeploymentActivationTarget( First(&uploadRecord).Error; err != nil { return nil, errors.New(errPagesPackageUploadMissing) } - if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != upload.ReservedPagesDeploymentType { + if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != ofupload.ReservedPagesDeploymentType { return nil, errors.New(errPagesPackageUploadMissing) } return &deployment, nil @@ -957,7 +957,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen } return hash, nil } - uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID) + uploadRecord, err := ofupload.GetActiveUpload(ctx, deployment.UploadID) if err != nil { return "", fmt.Errorf("pages 部署包不存在: %w", err) } @@ -972,7 +972,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen } func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deploymentID uint) (DeploymentPackage, error) { - opened, err := upload.OpenStoredUpload(ctx, uploadID) + opened, err := ofupload.OpenStoredUpload(ctx, uploadID) if err != nil { return DeploymentPackage{}, fmt.Errorf("pages 部署包不存在: %w", err) } @@ -1018,13 +1018,13 @@ func hydrateLegacyDeploymentUpload( return nil, errors.New(errPagesDeploymentNotFound) } if deployment.UploadID > 0 { - uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID) + uploadRecord, err := ofupload.GetActiveUpload(ctx, deployment.UploadID) if err == nil { return &uploadRecord, nil } } - artifactPath, _, err := upload.ResolveLocalFile(ctx, upload.LocalFileCandidateRequest{ + artifactPath, _, err := ofupload.ResolveLocalFile(ctx, ofupload.LocalFileCandidateRequest{ StoredPath: deployment.ArtifactPath, RelativePaths: pagesLegacyRelativeCandidates(project, deployment), }) @@ -1060,7 +1060,7 @@ func hydrateLegacyDeploymentUpload( if err != nil { return nil, err } - winner, err := upload.GetActiveUpload(ctx, winnerUploadID) + winner, err := ofupload.GetActiveUpload(ctx, winnerUploadID) if err != nil { return nil, err } @@ -1111,7 +1111,7 @@ func attachLegacyDeploymentUploadTx( First(&uploadRecord).Error; err != nil { return 0, err } - if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != upload.ReservedPagesDeploymentType { + if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != ofupload.ReservedPagesDeploymentType { return 0, errors.New(errPagesPackageUploadMissing) } result := tx.Model(&model.PagesDeployment{}). diff --git a/backend/OpenFlare/plugins/server/openflare/pages/logics_test.go b/backend/OpenFlare/plugins/server/openflare/pages/logics_test.go index 2c5c1d99..8b51ce2e 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/logics_test.go @@ -17,11 +17,14 @@ import ( "path/filepath" "testing" - "Wavelet/OpenFlare/plugins/server/infra/objectstore" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" + oftask "Wavelet/OpenFlare/plugins/server/task" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/pkg/idgen" + uploadshared "Wavelet/plugins/domain/upload/shared" + db "Wavelet/plugins/infra/database" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" @@ -72,49 +75,24 @@ func setupPagesTestDB(t *testing.T) func() { }).Error) db.SetDB(sqliteDB) - // Clear process-global system config RAM cache so tests do not see stale values. + require.NoError(t, idgen.Init(1)) + oftask.SetService(&testhelper.NoopTaskService{}) + uploadshared.SetDBService(db.NewService(sqliteDB)) + uploadshared.SetStorageService(uploadshared.NewMockStorageService()) _ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxPackageSizeMB) _ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxHistoryCount) return func() { + uploadshared.ResetServices() db.SetDB(nil) } } func setupPagesStorageMock(t *testing.T) (restore func(), disable func()) { t.Helper() - mockFiles := make(map[string][]byte) - restore = objectstore.MockStorage( - func(_ context.Context, key string, body io.Reader, _ int64, _ string) error { - data, err := io.ReadAll(body) - if err != nil { - return err - } - mockFiles[key] = data - return nil - }, - func(_ context.Context, key string) (*objectstore.Object, error) { - data, ok := mockFiles[key] - if !ok { - return nil, os.ErrNotExist - } - return &objectstore.Object{ - Body: io.NopCloser(bytes.NewReader(data)), - ContentLength: int64(len(data)), - ContentType: "application/zip", - }, nil - }, - func(_ context.Context, key string) error { - delete(mockFiles, key) - return nil - }, - ) - objectstore.IsEnabledFunc = func() bool { return true } - objectstore.ResetCache() - disable = func() { - objectstore.IsEnabledFunc = func() bool { return false } - objectstore.ResetCache() - restore() - } + mock := uploadshared.NewMockStorageService() + uploadshared.SetStorageService(mock) + restore = func() { uploadshared.ResetServices() } + disable = restore return restore, disable } @@ -302,7 +280,7 @@ func TestUploadDeploymentStoresPackageInUploadFramework(t *testing.T) { assert.Equal(t, int64(1), uploadCount) var uploadRecord model.Upload require.NoError(t, db.DB(ctx).First(&uploadRecord, storedDeployment.UploadID).Error) - assert.Equal(t, upload.ReservedPagesDeploymentType, uploadRecord.Type) + assert.Equal(t, ofupload.ReservedPagesDeploymentType, uploadRecord.Type) assert.Equal(t, pagesIngestMarkerV2, uploadRecord.Metadata.Extra[pagesIngestMarkerKey]) assert.Equal(t, fmt.Sprint(project.ID), uploadRecord.Metadata.Extra[pagesProjectIDMetadataKey]) assert.NotContains(t, uploadRecord.Metadata.Extra, "project_slug") diff --git a/backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go index f1384336..acd315b1 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" ) // ProjectLatestPackageMetadata describes the active package limits published to Agents. @@ -43,7 +43,7 @@ func GetProjectLatestPackageMetadata(ctx context.Context, projectID uint) (*Proj return nil, errors.New(errPagesDeploymentNotFound) } - uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID) + uploadRecord, err := ofupload.GetActiveUpload(ctx, deployment.UploadID) if err != nil { return nil, fmt.Errorf("pages 部署包不存在: %w", err) } diff --git a/backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go index 81851ebd..759cfe34 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go @@ -10,7 +10,7 @@ import ( "fmt" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" ) diff --git a/backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go b/backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go index b146f0d8..b860d701 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go @@ -8,7 +8,7 @@ import ( "encoding/json" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/stretchr/testify/assert" diff --git a/backend/OpenFlare/plugins/server/openflare/pages/routers.go b/backend/OpenFlare/plugins/server/openflare/pages/routers.go index f335954e..6f86a2ba 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/routers.go @@ -11,9 +11,8 @@ import ( "net/http" "strconv" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/core/contracts" "Wavelet/pkg/logger" "Wavelet/pkg/response" @@ -99,12 +98,32 @@ func deploymentIDParam(c *gin.Context) (uint, bool) { } func currentPagesActor(c *gin.Context) (string, bool) { - user, ok := oauth.GetFromContext[*model.User](c, oauth.UserObjKey) - if !ok || user == nil || user.ID == 0 { - response.AbortUnauthorized(c, errPagesActorMissing) - return "", false + if raw, ok := c.Get(contracts.AuthUserObjKey); ok { + switch user := raw.(type) { + case *contracts.UserDTO: + if user != nil && user.ID != 0 { + return fmt.Sprintf("user:%d", user.ID), true + } + case contracts.UserDTO: + if user.ID != 0 { + return fmt.Sprintf("user:%d", user.ID), true + } + } } - return fmt.Sprintf("user:%d", user.ID), true + if raw, ok := c.Get(contracts.AuthUserIDKey); ok { + switch id := raw.(type) { + case uint64: + if id != 0 { + return fmt.Sprintf("user:%d", id), true + } + case int: + if id > 0 { + return fmt.Sprintf("user:%d", uint64(id)), true + } + } + } + response.AbortUnauthorized(c, errPagesActorMissing) + return "", false } // ListProjectsHandler 列出全部 Pages 项目。 diff --git a/backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go b/backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go index b7866a29..ac332871 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go @@ -13,16 +13,12 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/infra/task" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/core/contracts" - "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" - "github.com/hibiken/asynq" - "github.com/redis/go-redis/v9" ) type sourceHandlerEnvelope struct { @@ -32,7 +28,7 @@ type sourceHandlerEnvelope struct { func newPagesSourceTestRouter(userID uint64) *gin.Engine { router := testhelper.NewTestGinEngine(func(ctx *gin.Context) { - oauth.SetToContext(ctx, oauth.UserObjKey, &model.User{ID: userID}) + ctx.Set(contracts.AuthUserIDKey, userID) ctx.Next() }) router.GET("/api/v1/d/pages/:id/source", GetSourceHandler) @@ -66,24 +62,6 @@ func performPagesSourceRequest( func setupPagesSourceDispatchTest(t *testing.T) { t.Helper() - miniRedis, err := miniredis.Run() - if err != nil { - t.Fatalf("miniredis.Run() error = %v, want nil", err) - } - redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()}) - asynqClient := asynq.NewClient(asynq.RedisClientOpt{Addr: miniRedis.Addr()}) - previousRedis := db.Redis - previousAsynqClient := task.AsynqClient - db.Redis = redisClient - task.AsynqClient = asynqClient - task.RegisterTaskMeta(PagesSourceActionMeta) - t.Cleanup(func() { - _ = asynqClient.Close() - _ = redisClient.Close() - miniRedis.Close() - task.AsynqClient = previousAsynqClient - db.Redis = previousRedis - }) } func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) { @@ -203,7 +181,7 @@ func TestSyncSourceHandlerAcceptsEmptyBodyAndEmptyObject(t *testing.T) { } var executions []model.TaskExecution - if err := db.DB(ctx).Where("task_type = ?", PagesSourceActionTask).Order("id asc").Find(&executions).Error; err != nil { + if err := db.DB(ctx).Where("task_type = ?", TaskTypePagesSourceAction).Order("id asc").Find(&executions).Error; err != nil { t.Fatalf("list Pages source task executions error = %v, want nil", err) } if got, want := len(executions), 2; got != want { diff --git a/backend/OpenFlare/plugins/server/openflare/pages/routers_test.go b/backend/OpenFlare/plugins/server/openflare/pages/routers_test.go index f92b7f8e..69849ea3 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/routers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/routers_test.go @@ -14,9 +14,7 @@ import ( "testing" "Wavelet/OpenFlare/plugins/server/repository" - - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/core/contracts" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -47,7 +45,7 @@ func TestUploadDeploymentHandlerRecordsCurrentUserActor(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Request = req c.Params = gin.Params{{Key: "id", Value: strconv.FormatUint(uint64(project.ID), 10)}} - oauth.SetToContext(c, oauth.UserObjKey, &model.User{ID: 42}) + c.Set(contracts.AuthUserIDKey, uint64(42)) UploadDeploymentHandler(c) assert.Equal(t, http.StatusOK, recorder.Code) @@ -82,7 +80,7 @@ func TestUploadDeploymentFromURLHandlerRecordsCurrentUserActor(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Request = req c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(project.ID)}} - oauth.SetToContext(c, oauth.UserObjKey, &model.User{ID: 77}) + c.Set(contracts.AuthUserIDKey, uint64(77)) UploadDeploymentFromURLHandler(c) assert.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go index 32cd3c7b..2d827417 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go @@ -8,7 +8,7 @@ import ( "time" "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" ) func syncRemoteSource( @@ -40,7 +40,7 @@ func commitSourceDeployment( detailJSON string, actor string, manifest *deploymentManifest, - ingestResult upload.IngestResult, + ingestResult ofupload.IngestResult, hasIngest bool, nextCheckNotBefore *time.Time, ) (*model.PagesDeployment, bool, bool, error) { diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go index 69d92bba..f8a363e3 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "Wavelet/pkg/logger" "gorm.io/gorm" @@ -64,7 +64,7 @@ func ReconcilePagesOrphanUploads( systemUser := repository.GetSystemUser(ctx) candidates, err := repository.ListPagesOrphanUploadCandidates(ctx, model.PagesOrphanUploadCandidateQuery{ SystemUserID: systemUser.ID, - UploadType: upload.ReservedPagesDeploymentType, + UploadType: ofupload.ReservedPagesDeploymentType, Marker: pagesIngestMarkerV2, CreatedBefore: cutoff, }) @@ -162,7 +162,7 @@ func reconcilePagesOrphanUploadCandidate( if uploadLocked { // Also heal a prior post-commit cache invalidation interruption when the // status transition was an idempotent no-op. - upload.InvalidateUploadMetaCache(ctx, candidate.ID) + ofupload.InvalidateUploadMetaCache(ctx, candidate.ID) } return outcome, nil } @@ -233,7 +233,7 @@ func reconcileLockedPagesOrphanUpload( } if lockedUpload.Status != model.UploadStatusUsed || lockedUpload.UserID != systemUserID || - lockedUpload.Type != upload.ReservedPagesDeploymentType || + lockedUpload.Type != ofupload.ReservedPagesDeploymentType || !lockedUpload.CreatedAt.Before(cutoff) { return pagesOrphanCleanupSkipped, true, nil } @@ -252,7 +252,7 @@ func reconcileLockedPagesOrphanUpload( return pagesOrphanCleanupReferenced, true, nil } - transitioned, err := upload.RemoveLockedTx(tx, &lockedUpload) + transitioned, err := ofupload.RemoveLockedTx(tx, &lockedUpload) if err != nil { return pagesOrphanCleanupSkipped, true, err } diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go index 90226776..2dc679f7 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -110,7 +110,7 @@ func TestReconcilePagesOrphanUploadsDeletesEligibleUploadOnce(t *testing.T) { now := time.Now().UTC() project := createPagesOrphanProject(t, ctx, "eligible-orphan") candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), project.ID, nil) - if err := upload.RebuildUploadStats(ctx); err != nil { + if err := ofupload.RebuildUploadStats(ctx); err != nil { t.Fatalf("RebuildUploadStats() error = %v, want nil", err) } @@ -281,7 +281,7 @@ func TestPagesOrphanCleanupAndDeploymentCommitInterleavings(t *testing.T) { if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&lockedProject, project.ID).Error; err != nil { return err } - return lockSourceDeploymentUploadsTx(tx, target, upload.IngestResult{}, false) + return lockSourceDeploymentUploadsTx(tx, target, ofupload.IngestResult{}, false) }) if !errors.Is(err, errSourceFinalFence) { t.Errorf("final deployment upload lock after cleanup error = %v, want %v", err, errSourceFinalFence) @@ -346,7 +346,7 @@ func createPagesOrphanUpload( MimeType: "application/zip", Extension: "zip", Hash: "orphan-checksum", - Type: upload.ReservedPagesDeploymentType, + Type: ofupload.ReservedPagesDeploymentType, Status: model.UploadStatusUsed, AccessMode: 0, Metadata: model.UploadMetadata{Extra: extra}, diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go index 1accf2f0..d5ee9915 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" ) diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go index 7ed3e585..4bf290fd 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go @@ -11,7 +11,7 @@ import ( "fmt" "time" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/pkg/logger" diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go index ee06e609..1deee290 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go @@ -18,7 +18,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/integration/githubrelease" "Wavelet/OpenFlare/plugins/server/model" diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_sync.go b/backend/OpenFlare/plugins/server/openflare/pages/source_sync.go index 0a8e3504..ff73ed5e 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_sync.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_sync.go @@ -15,10 +15,10 @@ import ( "time" "unicode/utf8" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "Wavelet/pkg/logger" "Wavelet/pkg/util" @@ -54,7 +54,7 @@ type preparedRemoteSource struct { } type sourceIngestState struct { - Result upload.IngestResult + Result ofupload.IngestResult HasIngest bool Referenced bool } @@ -390,7 +390,7 @@ func commitSourceDeploymentWithTrigger( actor string, triggerType string, manifest *deploymentManifest, - ingestResult upload.IngestResult, + ingestResult ofupload.IngestResult, hasIngest bool, nextCheckNotBefore *time.Time, ) (*model.PagesDeployment, bool, bool, error) { @@ -499,7 +499,7 @@ func resolveSourceDeploymentTx( actor string, triggerType string, manifest *deploymentManifest, - ingestResult upload.IngestResult, + ingestResult ofupload.IngestResult, hasIngest bool, ) (*model.PagesDeployment, bool, error) { var target model.PagesDeployment @@ -533,7 +533,7 @@ func createSourceDeploymentTx( actor string, triggerType string, manifest *deploymentManifest, - ingestResult upload.IngestResult, + ingestResult ofupload.IngestResult, ) (*model.PagesDeployment, bool, error) { var maxNumber int if err := tx.Model(&model.PagesDeployment{}). @@ -606,7 +606,7 @@ func createSourceDeploymentFilesTx(tx *gorm.DB, deploymentID uint, files []model func lockSourceDeploymentUploadsTx( tx *gorm.DB, target *model.PagesDeployment, - ingestResult upload.IngestResult, + ingestResult ofupload.IngestResult, hasIngest bool, ) error { uploadIDs := []uint64{target.UploadID} @@ -625,7 +625,7 @@ func lockSourceDeploymentUploadsTx( if records[index].ID != target.UploadID { continue } - if records[index].Status == model.UploadStatusUsed && records[index].Type == upload.ReservedPagesDeploymentType { + if records[index].Status == model.UploadStatusUsed && records[index].Type == ofupload.ReservedPagesDeploymentType { return nil } break diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go index 9d6c2626..902d3a07 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go @@ -19,9 +19,9 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/openflare/ofupload" "Wavelet/OpenFlare/share/pagesarchive" "gorm.io/gorm" @@ -179,7 +179,7 @@ func TestSyncRemoteSourceAtomicallyActivatesAndReusesChecksum(t *testing.T) { if got, want := uploadRecord.Status, model.UploadStatusUsed; got != want { t.Errorf("deployment upload Status = %q, want %q", got, want) } - if got, want := uploadRecord.Type, upload.ReservedPagesDeploymentType; got != want { + if got, want := uploadRecord.Type, ofupload.ReservedPagesDeploymentType; got != want { t.Errorf("deployment upload Type = %q, want %q", got, want) } if got, want := fmt.Sprint(uploadRecord.Metadata.Extra[pagesSourceIDMetadataKey]), fmt.Sprint(source.ID); got != want { @@ -396,7 +396,7 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) { deployment.SourceMeta, "user:5", &deploymentManifest{}, - upload.IngestResult{}, + ofupload.IngestResult{}, false, nil, ) @@ -537,7 +537,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) { MimeType: "application/zip", Extension: "zip", Hash: revision, - Type: upload.ReservedPagesDeploymentType, + Type: ofupload.ReservedPagesDeploymentType, Status: model.UploadStatusDeleted, AccessMode: 0, } @@ -585,7 +585,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) { `{"provider":"remote_url","display_name":"deleted.zip"}`, "user:1", manifest, - upload.IngestResult{}, + ofupload.IngestResult{}, false, nil, ) diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go b/backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go index a46c56b2..6af18acf 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go @@ -13,7 +13,7 @@ import ( "strconv" "strings" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/pkg/logger" @@ -392,9 +392,6 @@ func dispatchSourceActionSnapshotWithTrigger( confirmedRevision string, triggeredBy string, ) (*SourceActionReceipt, error) { - if task.AsynqClient == nil { - return nil, errors.New(errPagesSourceTaskDispatchFailed) - } handler := &SourceActionHandler{} rawPayload, err := json.Marshal(SourceActionPayload{ SourceID: source.ID, diff --git a/backend/OpenFlare/plugins/server/openflare/pages/source_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_test.go index 9e94d686..032937c6 100644 --- a/backend/OpenFlare/plugins/server/openflare/pages/source_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_test.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" ) diff --git a/backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go index e811aa5f..5d064497 100644 --- a/backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go @@ -7,7 +7,7 @@ import ( "context" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/relay/logics_test.go b/backend/OpenFlare/plugins/server/openflare/relay/logics_test.go index 5279fb96..60ce2085 100644 --- a/backend/OpenFlare/plugins/server/openflare/relay/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/logics_test.go @@ -12,7 +12,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/OpenFlare/plugins/server/testhelper" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/agent" diff --git a/backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go index 72eba2e7..447f7d36 100644 --- a/backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/pkg/response" diff --git a/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go index ba13dfb4..df19dad1 100644 --- a/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go @@ -10,8 +10,8 @@ import ( "fmt" "time" - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" "Wavelet/OpenFlare/plugins/server/openflare/chwriter" @@ -152,15 +152,15 @@ func validateSwitch(ctx context.Context, target string) error { } switch target { case "clickhouse": - if !config.Config.ClickHouse.Enabled { + if !runtimeconfig.ClickHouseEnabled() { return errors.New("ClickHouse 未启用,无法迁移到 ClickHouse") } case "postgres": - if !config.Config.Database.Enabled { + if !runtimeconfig.DatabaseEnabled() { return errors.New("PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL") } case "sqlite": - if config.Config.Database.Enabled { + if runtimeconfig.DatabaseEnabled() { return errors.New("当前主库为 PostgreSQL,日志库不能设置为 SQLite") } } diff --git a/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go index f7216adb..b256e479 100644 --- a/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go @@ -18,8 +18,8 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" "Wavelet/OpenFlare/plugins/server/repository" @@ -49,11 +49,7 @@ func newLogDBSwitchDB(t *testing.T) *gorm.DB { // TestCopyAccessLogsPreservesIDs sqlite→sqlite 模拟:源 store 3 条,目标空库, // copyAccessLogs 后 ID 保留、数量一致。 func TestCopyAccessLogsPreservesIDs(t *testing.T) { - oldDB, oldCH := config.Config.Database.Enabled, config.Config.ClickHouse.Enabled - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false - t.Cleanup(func() { - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = oldDB, oldCH - }) + t.Cleanup(runtimeconfig.Override(false, false)) logstore.ResetForTest() defer logstore.ResetForTest() @@ -99,11 +95,7 @@ func TestCopyAccessLogsPreservesIDs(t *testing.T) { // TestCopyUserAccessLogsPreservesIDs sqlite→sqlite 模拟:源库用户访问日志按 id 升序 // 复制到目标库,ID 保留、数量一致,且源库保持不变。 func TestCopyUserAccessLogsPreservesIDs(t *testing.T) { - oldDB, oldCH := config.Config.Database.Enabled, config.Config.ClickHouse.Enabled - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false - t.Cleanup(func() { - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = oldDB, oldCH - }) + t.Cleanup(runtimeconfig.Override(false, false)) logstore.ResetForTest() defer logstore.ResetForTest() @@ -144,11 +136,7 @@ func TestCopyUserAccessLogsPreservesIDs(t *testing.T) { // TestClearTargetLogTablesClearsUserAccessLogs 验证清空目标包含用户访问日志表 // (6 张日志表之一),迁移「覆盖目标库已有日志」幂等前提成立。 func TestClearTargetLogTablesClearsUserAccessLogs(t *testing.T) { - oldDB, oldCH := config.Config.Database.Enabled, config.Config.ClickHouse.Enabled - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false - t.Cleanup(func() { - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = oldDB, oldCH - }) + t.Cleanup(runtimeconfig.Override(false, false)) logstore.ResetForTest() defer logstore.ResetForTest() @@ -213,9 +201,7 @@ func TestClearTargetLogTablesDuringMigration(t *testing.T) { // TestValidateSwitch 各非法组合报错。 func TestValidateSwitch(t *testing.T) { - oldDB, oldCH := config.Config.Database.Enabled, config.Config.ClickHouse.Enabled - t.Cleanup(func() { - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = oldDB, oldCH + t.Cleanup(func() { }) gdb := newLogDBSwitchDB(t) @@ -228,35 +214,35 @@ func TestValidateSwitch(t *testing.T) { t.Run("same target rejected", func(t *testing.T) { setLogDB("sqlite") - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false + t.Cleanup(runtimeconfig.Override(false, false)) err := validateSwitch(ctx, "sqlite") require.Error(t, err) assert.Contains(t, err.Error(), "相同") }) t.Run("clickhouse disabled rejected", func(t *testing.T) { setLogDB("sqlite") - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false + t.Cleanup(runtimeconfig.Override(false, false)) err := validateSwitch(ctx, "clickhouse") require.Error(t, err) assert.Contains(t, err.Error(), "ClickHouse 未启用") }) t.Run("postgres requires main db enabled", func(t *testing.T) { setLogDB("sqlite") - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false + t.Cleanup(runtimeconfig.Override(false, false)) err := validateSwitch(ctx, "postgres") require.Error(t, err) assert.Contains(t, err.Error(), "PostgreSQL 未启用") }) t.Run("sqlite rejected when main db is postgres", func(t *testing.T) { setLogDB("postgres") - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = true, false + t.Cleanup(runtimeconfig.Override(true, false)) err := validateSwitch(ctx, "sqlite") require.Error(t, err) assert.Contains(t, err.Error(), "SQLite") }) t.Run("valid postgres migration", func(t *testing.T) { setLogDB("sqlite") - config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = true, false + t.Cleanup(runtimeconfig.Override(true, false)) require.NoError(t, validateSwitch(ctx, "postgres")) }) } @@ -296,9 +282,7 @@ func TestLogDBSwitchValidatePayload(t *testing.T) { // 在 FRESH DB(不预置 log_db_migration 行)上验证:setMigrationFlag 必须 upsert 建行, // 且失败后经缓存路径(GetSystemConfigByKey)可观察为空。 func TestExecuteFailureClearsMigrationFlag(t *testing.T) { - oldDB := config.Config.Database.Enabled - config.Config.Database.Enabled = true - t.Cleanup(func() { config.Config.Database.Enabled = oldDB }) + t.Cleanup(runtimeconfig.Override(true, runtimeconfig.ClickHouseEnabled())) logstore.ResetForTest() defer logstore.ResetForTest() diff --git a/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go b/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go index 3b06dba7..5e7ce376 100644 --- a/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go @@ -8,15 +8,14 @@ import ( "testing" "time" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/infra/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/tls" "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + oftask "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/testhelper" + db "Wavelet/plugins/infra/database" - "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,24 +23,13 @@ import ( func setupSSLRenewTestDB(t *testing.T) func() { t.Helper() - task.RegisterTaskMeta(tls.SSLSingleRenewMeta) - - _, mr, cleanup := testhelper.SetupTestEnvironment(t) - require.NoError(t, db.DB(nil).AutoMigrate(&model.TLSCertificate{}, &model.TaskExecution{})) - - // task 包 init() 会按配置创建指向真实 Redis 的客户端;测试显式改用 - // miniredis(与 executor_test 一致),避免依赖本地 redis 实例。 - oldClient := task.AsynqClient - task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()}) - t.Cleanup(func() { - _ = task.AsynqClient.Close() - task.AsynqClient = oldClient - }) - - oldSecret := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "test_session_secret_for_ssl_renew" + _, _, cleanup := testhelper.SetupTestEnvironment(t) + require.NoError(t, db.DB(context.Background()).AutoMigrate(&model.TLSCertificate{}, &model.TaskExecution{})) + previous := runtimeconfig.Get() + runtimeconfig.SetSessionSecret("test_session_secret_for_ssl_renew") + oftask.SetService(&testhelper.NoopTaskService{}) return func() { - config.Config.App.SessionSecret = oldSecret + runtimeconfig.Set(previous) cleanup() } } diff --git a/backend/OpenFlare/plugins/server/openflare/tls/logics.go b/backend/OpenFlare/plugins/server/openflare/tls/logics.go index b1770add..18f77cbe 100644 --- a/backend/OpenFlare/plugins/server/openflare/tls/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/logics.go @@ -14,7 +14,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" ) diff --git a/backend/OpenFlare/plugins/server/openflare/tls/logics_test.go b/backend/OpenFlare/plugins/server/openflare/tls/logics_test.go index 4f8c8ff7..e12bfc0d 100644 --- a/backend/OpenFlare/plugins/server/openflare/tls/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/logics_test.go @@ -18,15 +18,15 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/credential" "Wavelet/OpenFlare/plugins/server/repository" + oftask "Wavelet/OpenFlare/plugins/server/task" + "Wavelet/OpenFlare/plugins/server/testhelper" - "Wavelet/OpenFlare/plugins/server/infra/config" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/infra/task" "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/runtimeconfig" + "Wavelet/pkg/idgen" + db "Wavelet/plugins/infra/database" - "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" - "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -52,26 +52,16 @@ func setupTLSTestDB(t *testing.T) func() { )) db.SetDB(sqliteDB) - oldSecret := config.Config.App.SessionSecret - config.Config.App.SessionSecret = "test_session_secret_for_tls_encryption" - - mr, err := miniredis.Run() - require.NoError(t, err) - - task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ - Addr: mr.Addr(), - }) - - task.RegisterTaskMeta(SSLSingleRenewMeta) + require.NoError(t, idgen.Init(1)) + previous := runtimeconfig.Get() + runtimeconfig.SetSessionSecret("test_session_secret_for_tls_encryption") + credential.SetSessionSecret("test_session_secret_for_tls_encryption") + oftask.SetService(&testhelper.NoopTaskService{}) return func() { - if task.AsynqClient != nil { - _ = task.AsynqClient.Close() - task.AsynqClient = nil - } - mr.Close() db.SetDB(nil) - config.Config.App.SessionSecret = oldSecret + runtimeconfig.Set(previous) + credential.SetSessionSecret(previous.SessionSecret) tlsTestDBMu.Unlock() } } diff --git a/backend/OpenFlare/plugins/server/openflare/tls/obtain.go b/backend/OpenFlare/plugins/server/openflare/tls/obtain.go index 8cbe9000..1bbd8602 100644 --- a/backend/OpenFlare/plugins/server/openflare/tls/obtain.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/obtain.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/openflare/tls/acme" ) diff --git a/backend/OpenFlare/plugins/server/openflare/tls/tasks.go b/backend/OpenFlare/plugins/server/openflare/tls/tasks.go index c3b141cf..0cfa0661 100644 --- a/backend/OpenFlare/plugins/server/openflare/tls/tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/tasks.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/task" ) const ( diff --git a/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go index 0174e69a..7075187f 100644 --- a/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go +++ b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/OpenFlare/plugins/server/repository" diff --git a/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go b/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go index 3cdc5ef4..d7943685 100644 --- a/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go @@ -14,7 +14,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/OpenFlare/plugins/server/testhelper" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/glebarez/sqlite" diff --git a/backend/OpenFlare/plugins/server/openflare/waf/logics_test.go b/backend/OpenFlare/plugins/server/openflare/waf/logics_test.go index 7d478628..121d9a57 100644 --- a/backend/OpenFlare/plugins/server/openflare/waf/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/logics_test.go @@ -9,7 +9,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/glebarez/sqlite" diff --git a/backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go b/backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go index 29dbecd4..61d02b89 100644 --- a/backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go @@ -14,7 +14,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "Wavelet/pkg/response" diff --git a/backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go b/backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go index 41b2a661..eb624686 100644 --- a/backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go @@ -8,7 +8,7 @@ import ( "database/sql" "testing" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/backend/OpenFlare/plugins/server/openflare/zone/logics_test.go b/backend/OpenFlare/plugins/server/openflare/zone/logics_test.go index ab6de695..86efcd5f 100644 --- a/backend/OpenFlare/plugins/server/openflare/zone/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/logics_test.go @@ -11,7 +11,7 @@ import ( "Wavelet/OpenFlare/plugins/server/repository" "Wavelet/OpenFlare/plugins/server/testhelper" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" + db "Wavelet/plugins/infra/database" "Wavelet/OpenFlare/plugins/server/model" "github.com/glebarez/sqlite" diff --git a/backend/OpenFlare/plugins/server/pkg/cap/cap.go b/backend/OpenFlare/plugins/server/pkg/cap/cap.go deleted file mode 100644 index 904dffc2..00000000 --- a/backend/OpenFlare/plugins/server/pkg/cap/cap.go +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package cap 提供人机验证(CAPTCHA)功能 -package cap - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "strconv" - "strings" - "time" -) - -const ( - jwtHeaderB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" - jwtPartsCount = 3 // JWT 三段结构 - defaultChallengeCount = 50 // 默认 PoW 难题数 - defaultChallengeSize = 32 // 默认盐值长度 - defaultDifficulty = 4 // 默认难度 - defaultNonceLength = 25 // 随机 Nonce 字节长度 - defaultExpires = 10 * time.Minute // 默认过期时间 -) - -// ChallengeConfig holds parameters for the PoW challenge -type ChallengeConfig struct { - Count int // Number of puzzles (c) - Size int // Salt length (s) - Difficulty int // Difficulty prefix length (d) - Expires time.Duration // Challenge TTL -} - -// ChallengeResponse is returned to the client -type ChallengeResponse struct { - Challenge struct { - C int `json:"c"` - S int `json:"s"` - D int `json:"d"` - } `json:"challenge"` - Token string `json:"token"` - Expires int64 `json:"expires"` // ms timestamp -} - -// ChallengePayload represents the signed JWT payload -type ChallengePayload struct { - Nonce string `json:"n"` - Count int `json:"c"` - Size int `json:"s"` - Difficulty int `json:"d"` - Expires int64 `json:"exp"` // ms timestamp - IssuedAt int64 `json:"iat"` // ms timestamp - Scope string `json:"sk,omitempty"` -} - -// RedeemRequest payload sent by client -type RedeemRequest struct { - Token string `json:"token"` - Solutions []int `json:"solutions"` -} - -// RedeemResponse returned to client after verification -type RedeemResponse struct { - Success bool `json:"success"` - Token string `json:"token,omitempty"` - Expires int64 `json:"expires,omitempty"` - Error string `json:"error,omitempty"` -} - -func b64urlEncode(data []byte) string { - return base64.RawURLEncoding.EncodeToString(data) -} - -func b64urlDecode(str string) ([]byte, error) { - return base64.RawURLEncoding.DecodeString(str) -} - -// RandomHex generates a cryptographically secure random hexadecimal string of the specified byte length. -func RandomHex(byteLen int) string { - bytes := make([]byte, byteLen) - if _, err := rand.Read(bytes); err != nil { - panic(err) - } - return hex.EncodeToString(bytes) -} - -func jwtSign(payload []byte, secret []byte) string { - body := b64urlEncode(payload) - sigInput := jwtHeaderB64 + "." + body - - mac := hmac.New(sha256.New, secret) - mac.Write([]byte(sigInput)) - sig := mac.Sum(nil) - - return sigInput + "." + b64urlEncode(sig) -} - -func jwtVerify(token string, secret []byte) ([]byte, error) { - parts := strings.Split(token, ".") - if len(parts) != jwtPartsCount { - return nil, errors.New(errInvalidTokenFormat) - } - if parts[0] != jwtHeaderB64 { - return nil, errors.New(errInvalidHeader) - } - - sigInput := parts[0] + "." + parts[1] - mac := hmac.New(sha256.New, secret) - mac.Write([]byte(sigInput)) - expectedSig := mac.Sum(nil) - - actualSig, err := b64urlDecode(parts[2]) - if err != nil { - return nil, err - } - - if !hmac.Equal(expectedSig, actualSig) { - return nil, errors.New(errSignatureMismatch) - } - - payload, err := b64urlDecode(parts[1]) - if err != nil { - return nil, err - } - - return payload, nil -} - -// JwtSigHex extracts the signature part of a JWT token and returns it as a hexadecimal string. -func JwtSigHex(token string) string { - parts := strings.Split(token, ".") - if len(parts) != jwtPartsCount { - return "" - } - sigBytes, err := b64urlDecode(parts[2]) - if err != nil { - return "" - } - return hex.EncodeToString(sigBytes) -} - -// GenerateChallenge produces a new challenge and signed token -func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*ChallengeResponse, error) { - if conf.Count <= 0 { - conf.Count = defaultChallengeCount - } - if conf.Size <= 0 { - conf.Size = defaultChallengeSize - } - if conf.Difficulty <= 0 { - conf.Difficulty = defaultDifficulty - } - if conf.Expires <= 0 { - conf.Expires = defaultExpires - } - - now := time.Now().UnixNano() / int64(time.Millisecond) - expires := now + int64(conf.Expires/time.Millisecond) - - payload := ChallengePayload{ - Nonce: RandomHex(defaultNonceLength), - Count: conf.Count, - Size: conf.Size, - Difficulty: conf.Difficulty, - Expires: expires, - IssuedAt: now, - Scope: scope, - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - return nil, err - } - - token := jwtSign(payloadBytes, secret) - - resp := &ChallengeResponse{ - Token: token, - Expires: expires, - } - resp.Challenge.C = conf.Count - resp.Challenge.S = conf.Size - resp.Challenge.D = conf.Difficulty - - return resp, nil -} - -// VerifyChallengeSolutions verifies client submitted solutions -func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) { - payloadBytes, err := jwtVerify(token, secret) - if err != nil { - return nil, errors.New(errInvalidToken) - } - - var payload ChallengePayload - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - return nil, errors.New(errInvalidToken) - } - - if expectedScope != "" && payload.Scope != expectedScope { - return nil, errors.New(errScopeMismatch) - } - - now := time.Now().UnixNano() / int64(time.Millisecond) - if payload.Expires < now { - return nil, errors.New(errExpired) - } - - if len(solutions) != payload.Count { - return nil, errors.New(errInvalidSolutions) - } - - tokenFnv := fnv1a(token) - for i := range payload.Count { - idxStr := strconv.Itoa(i + 1) - saltSeed := fnv1aResume(tokenFnv, idxStr) - targetSeed := fnv1aResume(saltSeed, "d") - salt := prngFromHash(saltSeed, payload.Size) - target := prngFromHash(targetSeed, payload.Difficulty) - - hashInput := salt + strconv.Itoa(solutions[i]) - hashBytes := sha256.Sum256([]byte(hashInput)) - hashHex := hex.EncodeToString(hashBytes[:]) - - if !strings.HasPrefix(hashHex, target) { - return nil, errors.New(errInvalidSolution) - } - } - - return &payload, nil -} - -// Solve is a utility function to solve a challenge (mainly used for tests and reference implementation) -func Solve(token string, count, size, difficulty int) []int { - solutions := make([]int, count) - tokenFnv := fnv1a(token) - for i := range count { - idxStr := strconv.Itoa(i + 1) - saltSeed := fnv1aResume(tokenFnv, idxStr) - targetSeed := fnv1aResume(saltSeed, "d") - salt := prngFromHash(saltSeed, size) - target := prngFromHash(targetSeed, difficulty) - - for nonce := range 1000000 { - hashInput := salt + strconv.Itoa(nonce) - hashBytes := sha256.Sum256([]byte(hashInput)) - hashHex := hex.EncodeToString(hashBytes[:]) - if strings.HasPrefix(hashHex, target) { - solutions[i] = nonce - break - } - } - } - return solutions -} diff --git a/backend/OpenFlare/plugins/server/pkg/cap/errs.go b/backend/OpenFlare/plugins/server/pkg/cap/errs.go deleted file mode 100644 index 9a3b85d2..00000000 --- a/backend/OpenFlare/plugins/server/pkg/cap/errs.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -const ( - errInvalidTokenFormat = "invalid token format" - errInvalidHeader = "invalid header" - errSignatureMismatch = "signature mismatch" - errInvalidToken = "invalid_token" - errScopeMismatch = "scope_mismatch" - errExpired = "expired" - errInvalidSolutions = "invalid_solutions" - errInvalidSolution = "invalid_solution" -) diff --git a/backend/OpenFlare/plugins/server/pkg/cap/prng.go b/backend/OpenFlare/plugins/server/pkg/cap/prng.go deleted file mode 100644 index 0a21dd1e..00000000 --- a/backend/OpenFlare/plugins/server/pkg/cap/prng.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "fmt" - "strings" -) - -// fnv1a returns the 32-bit FNV-1a hash of a string -// -//nolint:mnd // FNV-1a 算法位移常量 -func fnv1a(str string) uint32 { - var hash uint32 = 2166136261 - for i := range len(str) { - hash ^= uint32(str[i]) - hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24) - } - return hash -} - -// fnv1aResume resumes FNV-1a hashing from a given state -// -//nolint:mnd // FNV-1a 算法位移常量 -func fnv1aResume(state uint32, str string) uint32 { - h := state - for i := range len(str) { - h ^= uint32(str[i]) - h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24) - } - return h -} - -// prngFromHash generates a hex string of specified length using an initial hash state -// -//nolint:mnd // xorshift 算法位移常量 -func prngFromHash(initialHash uint32, length int) string { - state := initialHash - var result strings.Builder - for result.Len() < length { - state ^= state << 13 - state ^= state >> 17 - state ^= state << 5 - hexStr := fmt.Sprintf("%08x", state) - result.WriteString(hexStr) - } - return result.String()[:length] -} diff --git a/backend/OpenFlare/plugins/server/pkg/cap/store.go b/backend/OpenFlare/plugins/server/pkg/cap/store.go deleted file mode 100644 index 05595865..00000000 --- a/backend/OpenFlare/plugins/server/pkg/cap/store.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "context" - "errors" - "sync" - "time" - - "github.com/redis/go-redis/v9" -) - -// Store defines the storage interface for challenge nonces and verification tokens -type Store interface { - Get(ctx context.Context, key string) (string, bool, error) - Set(ctx context.Context, key string, val string, ttl time.Duration) error - Delete(ctx context.Context, key string) error - // SetNX atomically sets key=val with the given TTL only when the key does not - // exist yet. It returns true when the key was actually written (i.e. this - // caller "won" the race), and false when the key already existed. - SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) - // GetAndDelete atomically retrieves the value of key and removes it in a - // single operation. Returns ("", false, nil) when the key does not exist. - GetAndDelete(ctx context.Context, key string) (string, bool, error) -} - -type memoryItem struct { - value string - expiresAt time.Time -} - -// MemoryStore is a thread-safe in-memory implementation of Store -type MemoryStore struct { - items map[string]memoryItem - mu sync.Mutex // unified write-lock; promotes to exclusive for all ops -} - -// NewMemoryStore creates and initializes a new MemoryStore -func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore { - store := &MemoryStore{ - items: make(map[string]memoryItem), - } - if cleanupInterval > 0 { - go store.startCleanupLoop(cleanupInterval) - } - return store -} - -// Get 从 MemoryStore 获取指定 key 的值 -func (s *MemoryStore) Get(_ context.Context, key string) (string, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - val, ok := s.getLocked(key) - return val, ok, nil -} - -// getLocked is the internal helper – caller must hold s.mu. -func (s *MemoryStore) getLocked(key string) (string, bool) { - item, found := s.items[key] - if !found { - return "", false - } - if time.Now().After(item.expiresAt) { - delete(s.items, key) - return "", false - } - return item.value, true -} - -// Set 向 MemoryStore 写入指定 key 的值 -func (s *MemoryStore) Set(_ context.Context, key string, val string, ttl time.Duration) error { - s.mu.Lock() - defer s.mu.Unlock() - s.items[key] = memoryItem{ - value: val, - expiresAt: time.Now().Add(ttl), - } - return nil -} - -// Delete 从 MemoryStore 删除指定 key -func (s *MemoryStore) Delete(_ context.Context, key string) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.items, key) - return nil -} - -// SetNX atomically sets key only when it is absent (or expired). -// Returns true if the key was written by this call. -func (s *MemoryStore) SetNX(_ context.Context, key string, val string, ttl time.Duration) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - _, exists := s.getLocked(key) - if exists { - return false, nil - } - s.items[key] = memoryItem{ - value: val, - expiresAt: time.Now().Add(ttl), - } - return true, nil -} - -// GetAndDelete atomically retrieves and removes key in one critical section. -func (s *MemoryStore) GetAndDelete(_ context.Context, key string) (string, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - val, exists := s.getLocked(key) - if !exists { - return "", false, nil - } - delete(s.items, key) - return val, true, nil -} - -func (s *MemoryStore) startCleanupLoop(interval time.Duration) { - ticker := time.NewTicker(interval) - for range ticker.C { - s.cleanupExpired() - } -} - -func (s *MemoryStore) cleanupExpired() { - now := time.Now() - s.mu.Lock() - defer s.mu.Unlock() - for k, v := range s.items { - if now.After(v.expiresAt) { - delete(s.items, k) - } - } -} - -// RedisStore is a GORM-compatible/standalone Redis-backed implementation of Store -type RedisStore struct { - client redis.UniversalClient -} - -// NewRedisStore creates a new RedisStore wrapping a redis.UniversalClient -func NewRedisStore(client redis.UniversalClient) *RedisStore { - return &RedisStore{ - client: client, - } -} - -// Get 从 RedisStore 获取指定 key 的值 -func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error) { - val, err := s.client.Get(ctx, key).Result() - if errors.Is(err, redis.Nil) { - return "", false, nil - } - if err != nil { - return "", false, err - } - return val, true, nil -} - -// Set 向 RedisStore 写入指定 key 的值 -func (s *RedisStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error { - return s.client.Set(ctx, key, val, ttl).Err() -} - -// Delete 从 RedisStore 删除指定 key -func (s *RedisStore) Delete(ctx context.Context, key string) error { - return s.client.Del(ctx, key).Err() -} - -// SetNX wraps Redis SET NX – returns true only when the key was newly created. -func (s *RedisStore) SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) { - return s.client.SetNX(ctx, key, val, ttl).Result() -} - -// GetAndDelete wraps Redis GETDEL (available since Redis 6.2). -func (s *RedisStore) GetAndDelete(ctx context.Context, key string) (string, bool, error) { - val, err := s.client.GetDel(ctx, key).Result() - if errors.Is(err, redis.Nil) { - return "", false, nil - } - if err != nil { - return "", false, err - } - return val, true, nil -} diff --git a/backend/OpenFlare/plugins/server/pkg/push/custom.go b/backend/OpenFlare/plugins/server/pkg/push/custom.go deleted file mode 100644 index 954362eb..00000000 --- a/backend/OpenFlare/plugins/server/pkg/push/custom.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - - "Wavelet/pkg/httppool" -) - -func init() { - Register("custom", &CustomPusher{}) -} - -// maxCustomResponseBytes 限制读取 Webhook 响应体的最大字节数,防止无界读取。 -const maxCustomResponseBytes = 4096 - -// CustomPusher 自定义 Webhook 发送实现 -type CustomPusher struct{} - -// Send 发送自定义 webhook -func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) { - if cfg.URL == "" { - return "", errors.New("custom: URL is required") - } - - var reqBody []byte - - if template != "" { - // 替换模板中的 {{key}} 占位符 - rendered := ParseTemplate(template, body) - reqBody = []byte(rendered) - } else { - // 兜底:直接把 body 转为 JSON 字符串发送 - var err error - reqBody, err = json.Marshal(body) - if err != nil { - return "", fmt.Errorf("custom: marshal body failed: %w", err) - } - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBody)) - if err != nil { - return "", fmt.Errorf("custom: create http request failed: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - // 如果配置了 Key 且格式为 "HeaderName:HeaderValue",我们可以附加测试用 Header - if cfg.Key != "" && strings.Contains(cfg.Key, ":") { - parts := strings.SplitN(cfg.Key, ":", 2) //nolint:mnd - httpReq.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) - } - - client := httppool.NewClient(defaultHTTPClientTimeout) - resp, err := client.Do(httpReq) - if err != nil { - return "", fmt.Errorf("custom: http request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxCustomResponseBytes)) - upstreamResp := strings.TrimSpace(string(bodyBytes)) - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { //nolint:mnd - return upstreamResp, fmt.Errorf("custom: http status %s", resp.Status) - } - - // 部分 Webhook(如企业微信、钉钉)即使业务失败也返回 HTTP 200, - // 仅当响应体包含非零 errcode 时才判定为发送失败,避免审计记录误报成功。 - var apiResp struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - } - if err := json.Unmarshal(bodyBytes, &apiResp); err == nil && apiResp.ErrCode != 0 { - return upstreamResp, fmt.Errorf("custom: webhook rejected: errcode=%d errmsg=%q", apiResp.ErrCode, apiResp.ErrMsg) - } - - return upstreamResp, nil -} - -// ValidateConfig 校验自定义配置 -func (p *CustomPusher) ValidateConfig(cfg Config) error { - if cfg.URL == "" { - return errors.New("webhook URL is required") - } - if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") { - return errors.New("webhook URL must start with http:// or https://") - } - return nil -} diff --git a/backend/OpenFlare/plugins/server/pkg/push/custom_test.go b/backend/OpenFlare/plugins/server/pkg/push/custom_test.go deleted file mode 100644 index 0d3e61bc..00000000 --- a/backend/OpenFlare/plugins/server/pkg/push/custom_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCustomPusherSend_ResponseBodyErrcode(t *testing.T) { - tests := []struct { - name string - statusCode int - body string - wantErr bool - wantErrMsg string - }{ - { - name: "wechat business error returns HTTP 200 with non-zero errcode", - statusCode: http.StatusOK, - body: `{"errcode":93000,"errmsg":"invalid request data"}`, - wantErr: true, - wantErrMsg: "errcode=93000", - }, - { - name: "wechat success returns errcode 0", - statusCode: http.StatusOK, - body: `{"errcode":0,"errmsg":"ok"}`, - wantErr: false, - }, - { - name: "json response without errcode is tolerated", - statusCode: http.StatusOK, - body: `{"success":true}`, - wantErr: false, - }, - { - name: "non-json response body is tolerated", - statusCode: http.StatusOK, - body: "ok", - wantErr: false, - }, - { - name: "empty response body is tolerated", - statusCode: http.StatusNoContent, - body: "", - wantErr: false, - }, - { - name: "http error status still fails", - statusCode: http.StatusInternalServerError, - body: `{"errcode":0,"errmsg":"ok"}`, - wantErr: true, - wantErrMsg: "http status", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(tt.statusCode) - _, _ = w.Write([]byte(tt.body)) - })) - defer srv.Close() - - pusher := &CustomPusher{} - upstreamResp, err := pusher.Send(context.Background(), - Config{Channel: "custom", URL: srv.URL}, - "", - map[string]any{"title": "t", "content": "c"}, - `{"title":"$title","content":"$content"}`, - nil, - ) - if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErrMsg) - return - } - require.NoError(t, err) - if tt.body != "" { - assert.Contains(t, upstreamResp, tt.body) - } - }) - } -} diff --git a/backend/OpenFlare/plugins/server/pkg/push/email.go b/backend/OpenFlare/plugins/server/pkg/push/email.go deleted file mode 100644 index b2bd6f04..00000000 --- a/backend/OpenFlare/plugins/server/pkg/push/email.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package push - -import ( - "context" - "errors" - "fmt" - "net" - "net/smtp" - "strings" - - "Wavelet/pkg/util" -) - -func init() { - Register("email", &EmailPusher{}) -} - -// EmailPusher 极简 SMTP 邮件推送实现 (静态、解耦) -type EmailPusher struct{} - -// sanitizeEmailHeader removes CR/LF bytes so untrusted values cannot inject -// additional email headers (email header injection). -func sanitizeEmailHeader(v string) string { - v = strings.ReplaceAll(v, "\r", "") - v = strings.ReplaceAll(v, "\n", "") - return v -} - -// Send 发送邮件 -func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) (string, error) { - if cfg.URL == "" || cfg.Key == "" || cfg.Secret == "" { - return "", errors.New("email: SMTP configuration (url, key, secret) is incomplete") - } - if target == "" { - return "", errors.New("email: target email address is required") - } - - title := defaultTitle - if t, ok := body["title"].(string); ok && t != "" { - title = t - } - - var content string - if c, ok := body["content"].(string); ok && c != "" { - content = c - } else { - // 自动格式化 map - var parts []string - for k, v := range body { - parts = append(parts, fmt.Sprintf("%s: %v
", k, v)) - } - content = strings.Join(parts, "") - } - - // 邮件头和体 - from := cfg.Key - to := target - - // 如果 ext 中指定了 from_name,我们在 From 头部包含它 - fromName := "System Notification" - if ext != nil { - if fn, ok := ext["from_name"].(string); ok && fn != "" { - fromName = fn - } - } - - subjectHeader := fmt.Sprintf("Subject: %s\r\n", sanitizeEmailHeader(title)) - fromHeader := fmt.Sprintf("From: %s <%s>\r\n", sanitizeEmailHeader(fromName), sanitizeEmailHeader(from)) - toHeader := fmt.Sprintf("To: %s\r\n", sanitizeEmailHeader(to)) - mimeHeader := "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n" - - // 拼装完整的邮件报文 - // 简单的 HTML 正文渲染 - htmlBody := fmt.Sprintf(`