From 40e8a7cfa3c7def8202ef6ff36eb29d0c097bb18 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 9 Jun 2026 15:54:16 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=8E=B7=E5=8F=96=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs.go | 19 +- docs/swagger.json | 19 +- docs/swagger.yaml | 14 +- frontend/components/auth/login-form.tsx | 23 +- frontend/components/common/admin/system.tsx | 13 ++ frontend/components/common/docs/api.tsx | 10 +- frontend/components/layout/robots-meta.tsx | 2 +- frontend/components/layout/sidebar.tsx | 23 +- frontend/contexts/admin-context.tsx | 5 +- frontend/lib/services/admin/types.ts | 6 + frontend/lib/services/config/types.ts | 33 +-- internal/apps/admin/system_config/routers.go | 7 + .../apps/admin/system_config/routers_test.go | 13 +- internal/apps/config/routers.go | 112 +--------- internal/apps/config/routers_test.go | 82 ++++++++ internal/db/migrator/migrator.go | 196 ++++-------------- internal/model/system_configs.go | 23 ++ internal/testhelper/test_helper.go | 28 +++ 18 files changed, 313 insertions(+), 315 deletions(-) create mode 100644 internal/apps/config/routers_test.go diff --git a/docs/docs.go b/docs/docs.go index 2be2799b..76490faa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2191,7 +2191,7 @@ const docTemplate = `{ }, "/api/v1/config/public": { "get": { - "description": "返回对前端公开的系统配置信息,如允许上传的文件类型、站点名称、是否开放注册等", + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", "consumes": [ "application/json" ], @@ -4008,6 +4008,9 @@ const docTemplate = `{ }, "value": { "type": "string" + }, + "visibility": { + "type": "integer" } } }, @@ -4465,6 +4468,13 @@ const docTemplate = `{ "value": { "type": "string", "maxLength": 255 + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] } } }, @@ -4525,6 +4535,13 @@ const docTemplate = `{ "value": { "type": "string", "maxLength": 255 + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 8741a303..de8406c3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2184,7 +2184,7 @@ }, "/api/v1/config/public": { "get": { - "description": "返回对前端公开的系统配置信息,如允许上传的文件类型、站点名称、是否开放注册等", + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", "consumes": [ "application/json" ], @@ -4001,6 +4001,9 @@ }, "value": { "type": "string" + }, + "visibility": { + "type": "integer" } } }, @@ -4458,6 +4461,13 @@ "value": { "type": "string", "maxLength": 255 + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] } } }, @@ -4518,6 +4528,13 @@ "value": { "type": "string", "maxLength": 255 + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f3dfbacc..1f18cea9 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -250,6 +250,8 @@ definitions: type: string value: type: string + visibility: + type: integer type: object model.TaskExecution: properties: @@ -558,6 +560,11 @@ definitions: value: maxLength: 255 type: string + visibility: + enum: + - 0 + - 1 + type: integer required: - key - type @@ -602,6 +609,11 @@ definitions: value: maxLength: 255 type: string + visibility: + enum: + - 0 + - 1 + type: integer required: - value type: object @@ -2206,7 +2218,7 @@ paths: get: consumes: - application/json - description: 返回对前端公开的系统配置信息,如允许上传的文件类型、站点名称、是否开放注册等 + description: 返回系统配置表中 visibility 为 1 的配置键值集合 produces: - application/json responses: diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index ad772249..f62dde42 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -36,6 +36,11 @@ function persistRedirectTarget(searchParams: ReturnType) } } +function configBool(value: string | undefined, fallback: boolean) { + if (value === undefined) return fallback + return value === "true" +} + export function LoginForm() { const router = useRouter() const searchParams = useSearchParams() @@ -80,7 +85,7 @@ export function LoginForm() { const authSourcesQuery = useQuery({ queryKey: ["auth-sources"], queryFn: () => services.auth.getAuthSources(), - enabled: publicConfigQuery.data?.oidc_login_enabled ?? true, + enabled: configBool(publicConfigQuery.data?.oidc_login_enabled, true), }) const redirectTarget = useMemo( @@ -88,8 +93,8 @@ export function LoginForm() { [searchParams], ) - const capEnabled = publicConfigQuery.data?.cap_login_enabled ?? false - const capAutoSolve = publicConfigQuery.data?.cap_auto_solve ?? true + const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false) + const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true) const loginMutation = useMutation({ mutationFn: (req: LoginRequest) => { @@ -201,7 +206,6 @@ export function LoginForm() { const handleRegister = () => { setErrorMessage("") - const emailRegisterEnabled = publicConfigQuery.data?.email_register_verification_enabled ?? false if (emailRegisterEnabled) { if (!email.trim() || !code.trim()) { toast.error("邮箱和验证码不能为空") @@ -241,10 +245,11 @@ export function LoginForm() { } const registrationEnabled = - (publicConfigQuery.data?.registration_enabled ?? true) && - (publicConfigQuery.data?.password_register_enabled ?? true) + configBool(publicConfigQuery.data?.registration_enabled, true) && + configBool(publicConfigQuery.data?.password_register_enabled, true) - const passwordLoginEnabled = publicConfigQuery.data?.password_login_enabled ?? true + const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true) + const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false) const authSources = authSourcesQuery.data ?? [] @@ -379,10 +384,10 @@ export function LoginForm() { setEmail(e.target.value)} - placeholder={publicConfigQuery.data?.email_register_verification_enabled ? "电子邮箱" : "电子邮箱(可选)"} + placeholder={emailRegisterEnabled ? "电子邮箱" : "电子邮箱(可选)"} autoComplete="email" /> - {publicConfigQuery.data?.email_register_verification_enabled && ( + {emailRegisterEnabled && (
+
+ + { + onEditDataChange('visibility', checked ? 1 : 0) + }} + /> +
+
({ value: config.value, + visibility: config.visibility, description: config.description }) @@ -181,6 +192,7 @@ export function SystemConfigs() { await updateSystemConfig(config.key, { value: editData.value ?? config.value, + visibility: editData.visibility ?? config.visibility, description: editData.description ?? config.description }) } @@ -208,6 +220,7 @@ export function SystemConfigs() { columns={[ { header: "配置键", cell: (item) => {item.key}, width: "200px" }, { header: "配置值", cell: (item) => {item.value}, width: "120px" }, + { header: "公共可见", cell: (item) => {item.visibility === 1 ? "可见" : "不可见"}, width: "80px" }, { header: "描述", cell: (item) => {item.description}, width: "200px" }, ]} renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => ( diff --git a/frontend/components/common/docs/api.tsx b/frontend/components/common/docs/api.tsx index a6fa7f33..e4501e67 100644 --- a/frontend/components/common/docs/api.tsx +++ b/frontend/components/common/docs/api.tsx @@ -234,17 +234,17 @@ export const apiSections: PolicySection[] = [

4.1 公共系统配置

接口:GET /api/v1/config/public

-

说明:无感获取当前系统的公开业务设置(如注册是否开启、密码登录是否开启)。供前端页面动态渲染使用。

+

说明:无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。

返回数据结构样例:

{ + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {} + + return Object.entries(parsed).reduce>((result, [key, value]) => { + if (typeof value === "boolean") { + result[key] = value + } + return result + }, {}) + } catch { + return {} + } +} + /** * 应用侧边栏组件 * 显示应用侧边栏 @@ -145,17 +162,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) { } const navMainFiltered = React.useMemo(() => { - const displayConfig = config?.menu_display_config || {} + const displayConfig = parseMenuDisplayConfig(config?.menu_display_config) return data.navMain.filter((item) => displayConfig[item.url] !== false) }, [config]) const adminFiltered = React.useMemo(() => { - const displayConfig = config?.menu_display_config || {} + const displayConfig = parseMenuDisplayConfig(config?.menu_display_config) return data.admin.filter((item) => displayConfig[item.url] !== false) }, [config]) const documentFiltered = React.useMemo(() => { - const displayConfig = config?.menu_display_config || {} + const displayConfig = parseMenuDisplayConfig(config?.menu_display_config) return data.document.filter((item) => displayConfig[item.url] !== false) }, [config]) diff --git a/frontend/contexts/admin-context.tsx b/frontend/contexts/admin-context.tsx index 9ab22f50..5aadca9a 100644 --- a/frontend/contexts/admin-context.tsx +++ b/frontend/contexts/admin-context.tsx @@ -2,6 +2,7 @@ import * as React from "react" import {createContext, useCallback, useContext, useRef, useState} from "react" +import {useQueryClient} from "@tanstack/react-query" import type {SystemConfig, UpdateSystemConfigRequest} from "@/lib/services" import services from "@/lib/services" @@ -32,6 +33,7 @@ const AdminContext = createContext(null) * @param {React.ReactNode} children - Admin Provider 的子元素 */ export function AdminProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient() const [systemConfigs, setSystemConfigs] = useState([]) const [systemConfigsLoading, setSystemConfigsLoading] = useState(false) const [systemConfigsError, setSystemConfigsError] = useState(null) @@ -70,12 +72,13 @@ export function AdminProvider({ children }: { children: React.ReactNode }) { const updateSystemConfig = useCallback(async (key: string, data: UpdateSystemConfigRequest) => { try { await services.admin.updateSystemConfig(key, data) + await queryClient.invalidateQueries({ queryKey: ['public-config'] }) await refetchSystemConfigs(lastConfigTypeRef.current) } catch (error) { handleContextError(error, '更新系统配置失败') throw error } - }, [refetchSystemConfigs]) + }, [queryClient, refetchSystemConfigs]) const value: AdminContextState = { systemConfigs, diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 5ea4118d..8f2ed0e7 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -10,6 +10,8 @@ export interface SystemConfig { value: string; /** 配置类型:'system' | 'business' */ type: 'system' | 'business'; + /** 是否对公共配置接口可见:0 不可见,1 可见 */ + visibility: 0 | 1; /** 配置描述 */ description: string; /** 创建时间 */ @@ -28,6 +30,8 @@ export interface CreateSystemConfigRequest { value: string; /** 配置类型:'system' | 'business' */ type: 'system' | 'business'; + /** 是否对公共配置接口可见:0 不可见,1 可见 */ + visibility?: 0 | 1; /** 配置描述(最大255字符,可选) */ description?: string; } @@ -38,6 +42,8 @@ export interface CreateSystemConfigRequest { export interface UpdateSystemConfigRequest { /** 配置值(最大255字符) */ value: string; + /** 是否对公共配置接口可见:0 不可见,1 可见 */ + visibility?: 0 | 1; /** 配置描述(最大255字符,可选) */ description?: string; } diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts index 49c3f52e..b85c4d32 100644 --- a/frontend/lib/services/config/types.ts +++ b/frontend/lib/services/config/types.ts @@ -1,31 +1,2 @@ -/** - * 公共配置响应 - */ -export interface PublicConfigResponse { - /** 允许上传的图片扩展名 */ - upload_allowed_extensions: string; - /** 站点名称 */ - site_name: string; - /** 是否允许密码登录 */ - password_login_enabled: boolean; - /** 是否允许注册 */ - registration_enabled: boolean; - /** 是否允许密码注册 */ - password_register_enabled: boolean; - /** 是否允许 OIDC 登录 */ - oidc_login_enabled: boolean; - /** 每个用户最大 API Key 数量 */ - max_api_keys_per_user: number; - /** 是否启用 Cap 人机验证 */ - cap_login_enabled: boolean; - /** 是否自动解题 */ - cap_auto_solve: boolean; - /** 是否启用邮箱登录验证 */ - email_login_verification_enabled: boolean; - /** 是否启用邮箱注册验证 */ - email_register_verification_enabled: boolean; - /** 目录显示配置 */ - menu_display_config?: Record; - /** 是否允许搜索引擎检索 */ - search_engine_indexing_enabled?: boolean; -} +/** 公共配置响应:key 为系统配置键,value 为配置表中保存的字符串值 */ +export type PublicConfigResponse = Record; diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index f214495f..12f69a71 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -34,12 +34,14 @@ type CreateSystemConfigRequest struct { Key string `json:"key" binding:"required,max=64"` Value string `json:"value" binding:"required,max=255"` 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,max=255"` + Visibility *int `json:"visibility" binding:"omitempty,oneof=0 1"` Description string `json:"description" binding:"max=255"` } @@ -78,6 +80,7 @@ func CreateSystemConfig(c *gin.Context) { Key: req.Key, Value: req.Value, Type: req.Type, + Visibility: req.Visibility, Description: req.Description, } @@ -206,6 +209,10 @@ func UpdateSystemConfig(c *gin.Context) { updates := map[string]interface{}{ "description": req.Description, } + if req.Visibility != nil { + updates["visibility"] = *req.Visibility + config.Visibility = *req.Visibility + } if key != model.ConfigKeySMTPPassword || req.Value != "******" { updates["value"] = req.Value config.Value = req.Value diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 8b60d57a..69a65008 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -73,6 +73,7 @@ func TestCreateSystemConfig(t *testing.T) { Key: "custom_key", Value: "custom_value", Type: "system", + Visibility: model.ConfigVisibilityVisible, Description: "desc", } body, _ := json.Marshal(payload) @@ -99,7 +100,10 @@ func TestCreateSystemConfig(t *testing.T) { t.Fatalf("failed to find system config in Redis: %v", err) } if redisConfig.Value != "custom_value" { - t.Errorf("expected value 'custom_value', got '%s'", redisConfig.Value) + t.Errorf("CreateSystemConfig(custom_key).Value = %q, want %q", redisConfig.Value, "custom_value") + } + if redisConfig.Visibility != model.ConfigVisibilityVisible { + t.Errorf("CreateSystemConfig(custom_key).Visibility = %d, want %d", redisConfig.Visibility, model.ConfigVisibilityVisible) } }) @@ -217,8 +221,10 @@ func TestUpdateSystemConfig(t *testing.T) { 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) @@ -234,7 +240,7 @@ func TestUpdateSystemConfig(t *testing.T) { // Verify database var cfg model.SystemConfig dbConn.Where("key = ?", model.ConfigKeySiteName).First(&cfg) - if cfg.Value != "Super Site Name" || cfg.Description != "Updated Description" { + if cfg.Value != "Super Site Name" || cfg.Description != "Updated Description" || cfg.Visibility != model.ConfigVisibilityHidden { t.Errorf("database values not updated: %+v", cfg) } @@ -244,6 +250,9 @@ func TestUpdateSystemConfig(t *testing.T) { if redisConfig.Value != "Super Site Name" { t.Errorf("redis cache value not updated, got '%s'", redisConfig.Value) } + if redisConfig.Visibility != model.ConfigVisibilityHidden { + t.Errorf("redis cache visibility = %d, want %d", redisConfig.Visibility, model.ConfigVisibilityHidden) + } }) t.Run("update non-existent config", func(t *testing.T) { diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go index 4c16739f..07b759eb 100644 --- a/internal/apps/config/routers.go +++ b/internal/apps/config/routers.go @@ -26,26 +26,9 @@ import ( "github.com/gin-gonic/gin" ) -// PublicConfigResponse 公共配置响应 -type PublicConfigResponse struct { - UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名 - SiteName string `json:"site_name"` // 站点名称 - PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录 - RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册 - PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册 - OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录 - MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量 - CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证 - CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算 - EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证 - EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证 - MenuDisplayConfig map[string]bool `json:"menu_display_config"` // 目录显示配置 - SearchEngineIndexingEnabled bool `json:"search_engine_indexing_enabled"` // 是否允许搜索引擎检索 -} - // GetPublicConfig 获取公共配置 // @Summary 获取公共配置 -// @Description 返回对前端公开的系统配置信息,如允许上传的文件类型、站点名称、是否开放注册等 +// @Description 返回系统配置表中 visibility 为 1 的配置键值集合 // @Tags config // @Accept json // @Produce json @@ -53,96 +36,15 @@ type PublicConfigResponse struct { // @Router /api/v1/config/public [get] func GetPublicConfig(c *gin.Context) { ctx := c.Request.Context() - var sc model.SystemConfig - - // 1. upload_allowed_extensions - var uploadExtensions string - if err := sc.GetByKey(ctx, model.ConfigKeyUploadAllowedExtensions); err == nil { - uploadExtensions = sc.Value - } - - // 2. site_name - var siteName string - if err := sc.GetByKey(ctx, model.ConfigKeySiteName); err == nil { - siteName = sc.Value - } - - // 3. registration_enabled - var registrationEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled); err == nil { - registrationEnabled = val - } - - // 3.1 password_login_enabled - var passwordLoginEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled); err == nil { - passwordLoginEnabled = val - } - - // 3.2 password_register_enabled - var passwordRegisterEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled); err == nil { - passwordRegisterEnabled = val - } - - // 3.3 oidc_login_enabled - var oidcLoginEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled); err == nil { - oidcLoginEnabled = val - } - - // 3.4 cap_login_enabled - var capLoginEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyCapLoginEnabled); err == nil { - capLoginEnabled = val - } - - // 3.5 cap_auto_solve - capAutoSolve := true // 默认自动开始 - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyCapAutoSolve); err == nil { - capAutoSolve = val - } - - // 4. max_api_keys_per_user - var maxAPIKeys int - if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil { - maxAPIKeys = val - } - - var emailLoginVerificationEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled); err == nil { - emailLoginVerificationEnabled = val - } - - var emailRegisterVerificationEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailRegisterVerificationEnabled); err == nil { - emailRegisterVerificationEnabled = val - } - - var searchEngineIndexingEnabled bool - if val, err := model.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled); err == nil { - searchEngineIndexingEnabled = val - } - - menuDisplayConfig, err := model.GetMenuDisplayConfig(ctx) + configs, err := model.ListVisibleSystemConfigs(ctx) if err != nil { - menuDisplayConfig = make(map[string]bool) + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return } - response := PublicConfigResponse{ - UploadAllowedExtensions: uploadExtensions, - SiteName: siteName, - PasswordLoginEnabled: passwordLoginEnabled, - RegistrationEnabled: registrationEnabled, - PasswordRegisterEnabled: passwordRegisterEnabled, - OIDCLoginEnabled: oidcLoginEnabled, - MaxAPIKeysPerUser: maxAPIKeys, - CapLoginEnabled: capLoginEnabled, - CapAutoSolve: capAutoSolve, - EmailLoginVerificationEnabled: emailLoginVerificationEnabled, - EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled, - MenuDisplayConfig: menuDisplayConfig, - SearchEngineIndexingEnabled: searchEngineIndexingEnabled, + response := make(map[string]string, len(configs)) + for _, config := range configs { + response[config.Key] = config.Value } c.JSON(http.StatusOK, util.OK(response)) diff --git a/internal/apps/config/routers_test.go b/internal/apps/config/routers_test.go new file mode 100644 index 00000000..030b4538 --- /dev/null +++ b/internal/apps/config/routers_test.go @@ -0,0 +1,82 @@ +/* +Copyright 2026 linux.do +Modified by Arctel.net, 2026 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/internal/util" + "github.com/gin-gonic/gin" +) + +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 util.ResponseAny + 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/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 6d34edef..e6e713ea 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -63,7 +63,7 @@ func Migrate() { } // ensureConfigKeyExists ensures a system config key exists in the database -func ensureConfigKeyExists(key, value, configType, description string) { +func ensureConfigKeyExists(key, value, configType, description string, visibility int) { tx := db.DB(context.Background()) var cfg model.SystemConfig if err := tx.Where("key = ?", key).First(&cfg).Error; err != nil { @@ -71,6 +71,7 @@ func ensureConfigKeyExists(key, value, configType, description string) { Key: key, Value: value, Type: configType, + Visibility: visibility, Description: description, } if err := tx.Create(&newConfig).Error; err != nil { @@ -92,164 +93,49 @@ func initSystemConfigs() { } if count > 0 { - ensureConfigKeyExists(model.ConfigKeyCapLoginEnabled, "false", "system", "是否启用登录人机验证(true/false)") - ensureConfigKeyExists(model.ConfigKeyCapAutoSolve, "true", "system", "打开页面后是否自动开始计算,关闭则需用户手动点击触发") - ensureConfigKeyExists(model.ConfigKeyCapChallengeCount, "1", "system", "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5") - ensureConfigKeyExists(model.ConfigKeyCapChallengeSize, "32", "system", "人机验证盐值长度") - ensureConfigKeyExists(model.ConfigKeyCapChallengeDifficulty, "4", "system", "人机验证 PoW 难度(目标前缀长度)") - ensureConfigKeyExists(model.ConfigKeyCapChallengeTTL, "600", "system", "人机验证难题有效时间(秒)") - ensureConfigKeyExists(model.ConfigKeyCapTokenTTL, "1200", "system", "人机验证兑换凭证有效时间(秒)") - ensureConfigKeyExists(model.ConfigKeyServerAddress, "", "system", "服务器地址(用于跨域源控制,不设定则允许任意源)") - ensureConfigKeyExists(model.ConfigKeySMTPHost, "", "system", "SMTP 服务器地址(例如 smtp.example.com)") - ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)") - ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)") - ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)") - ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)") - ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)") - ensureConfigKeyExists(model.ConfigKeyMenuDisplayConfig, "{}", "system", "目录显示配置(JSON 字符串,格式为 {url: enabled})") - ensureConfigKeyExists(model.ConfigKeySearchEngineIndexingEnabled, "false", "system", "是否允许搜索引擎爬取/检索该站点(true/false)") + ensureConfigKeyExists(model.ConfigKeyCapLoginEnabled, "false", "system", "是否启用登录人机验证(true/false)", model.ConfigVisibilityVisible) + ensureConfigKeyExists(model.ConfigKeyCapAutoSolve, "true", "system", "打开页面后是否自动开始计算,关闭则需用户手动点击触发", model.ConfigVisibilityVisible) + ensureConfigKeyExists(model.ConfigKeyCapChallengeCount, "1", "system", "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeyCapChallengeSize, "32", "system", "人机验证盐值长度", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeyCapChallengeDifficulty, "4", "system", "人机验证 PoW 难度(目标前缀长度)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeyCapChallengeTTL, "600", "system", "人机验证难题有效时间(秒)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeyCapTokenTTL, "1200", "system", "人机验证兑换凭证有效时间(秒)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeyServerAddress, "", "system", "服务器地址(用于跨域源控制,不设定则允许任意源)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeySMTPHost, "", "system", "SMTP 服务器地址(例如 smtp.example.com)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)", model.ConfigVisibilityHidden) + ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)", model.ConfigVisibilityVisible) + ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)", model.ConfigVisibilityVisible) + ensureConfigKeyExists(model.ConfigKeyMenuDisplayConfig, "{}", "system", "目录显示配置(JSON 字符串,格式为 {url: enabled})", model.ConfigVisibilityVisible) + ensureConfigKeyExists(model.ConfigKeySearchEngineIndexingEnabled, "false", "system", "是否允许搜索引擎爬取/检索该站点(true/false)", model.ConfigVisibilityVisible) return } defaultConfigs := []model.SystemConfig{ - { - Key: model.ConfigKeyCapLoginEnabled, - Value: "false", - Type: "system", - Description: "是否启用登录人机验证(true/false)", - }, - { - Key: model.ConfigKeyCapAutoSolve, - Value: "true", - Type: "system", - Description: "打开页面后是否自动开始计算,关闭则需用户手动点击触发", - }, - { - Key: model.ConfigKeyCapChallengeCount, - Value: "1", - Type: "system", - Description: "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5", - }, - { - Key: model.ConfigKeyCapChallengeSize, - Value: "32", - Type: "system", - Description: "人机验证盐值长度", - }, - { - Key: model.ConfigKeyCapChallengeDifficulty, - Value: "4", - Type: "system", - Description: "人机验证 PoW 难度(目标前缀长度)", - }, - { - Key: model.ConfigKeyCapChallengeTTL, - Value: "600", - Type: "system", - Description: "人机验证难题有效时间(秒)", - }, - { - Key: model.ConfigKeyCapTokenTTL, - Value: "1200", - Type: "system", - Description: "人机验证兑换凭证有效时间(秒)", - }, - { - Key: model.ConfigKeyServerAddress, - Value: "", - Type: "system", - Description: "服务器地址(用于跨域源控制,不设定则允许任意源)", - }, - { - Key: model.ConfigKeySMTPHost, - Value: "", - Type: "system", - Description: "SMTP 服务器地址(例如 smtp.example.com)", - }, - { - Key: model.ConfigKeySMTPPort, - Value: "587", - Type: "system", - Description: "SMTP 端口(例如 587 或 465)", - }, - { - Key: model.ConfigKeySMTPUsername, - Value: "", - Type: "system", - Description: "SMTP 账户(如 sender@example.com)", - }, - { - Key: model.ConfigKeySMTPPassword, - Value: "", - Type: "system", - Description: "SMTP 访问凭证(授权码/密码)", - }, - { - Key: model.ConfigKeyUploadAllowedExtensions, - Value: "jpg,png,webp", - Type: "system", - Description: "允许上传的图片扩展名(逗号分隔)", - }, - { - Key: model.ConfigKeySiteName, - Value: "Wavelet", - Type: "system", - Description: "系统平台的展示名称", - }, - { - Key: model.ConfigKeyPasswordLoginEnabled, - Value: "true", - Type: "system", - Description: "是否允许使用账号密码登录", - }, - { - Key: model.ConfigKeyRegistrationEnabled, - Value: "true", - Type: "system", - Description: "控制普通用户是否可以自主注册(true/false)", - }, - { - Key: model.ConfigKeyPasswordRegisterEnabled, - Value: "true", - Type: "system", - Description: "是否允许通过密码创建本地账号", - }, - { - Key: model.ConfigKeyOIDCLoginEnabled, - Value: "true", - Type: "system", - Description: "是否允许使用第三方 OIDC 认证源登录", - }, - { - Key: model.ConfigKeyMaxAPIKeysPerUser, - Value: "5", - Type: "business", - Description: "限制每个普通用户可以创建的 API Key 最大数量", - }, - { - Key: model.ConfigKeyEmailLoginVerificationEnabled, - Value: "false", - Type: "system", - Description: "是否开启邮箱登录验证(true/false)", - }, - { - Key: model.ConfigKeyEmailRegisterVerificationEnabled, - Value: "false", - Type: "system", - Description: "是否开启邮箱注册验证(true/false)", - }, - { - Key: model.ConfigKeyMenuDisplayConfig, - Value: "{}", - Type: "system", - Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})", - }, - { - Key: model.ConfigKeySearchEngineIndexingEnabled, - Value: "false", - Type: "system", - Description: "是否允许搜索引擎爬取/检索该站点(true/false)", - }, + {Key: model.ConfigKeyCapLoginEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否启用登录人机验证(true/false)"}, + {Key: model.ConfigKeyCapAutoSolve, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "打开页面后是否自动开始计算,关闭则需用户手动点击触发"}, + {Key: model.ConfigKeyCapChallengeCount, Value: "1", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5"}, + {Key: model.ConfigKeyCapChallengeSize, Value: "32", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证盐值长度"}, + {Key: model.ConfigKeyCapChallengeDifficulty, Value: "4", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证 PoW 难度(目标前缀长度)"}, + {Key: model.ConfigKeyCapChallengeTTL, Value: "600", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证难题有效时间(秒)"}, + {Key: model.ConfigKeyCapTokenTTL, Value: "1200", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证兑换凭证有效时间(秒)"}, + {Key: model.ConfigKeyServerAddress, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "服务器地址(用于跨域源控制,不设定则允许任意源)"}, + {Key: model.ConfigKeySMTPHost, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 服务器地址(例如 smtp.example.com)"}, + {Key: model.ConfigKeySMTPPort, Value: "587", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 端口(例如 587 或 465)"}, + {Key: model.ConfigKeySMTPUsername, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 账户(如 sender@example.com)"}, + {Key: model.ConfigKeySMTPPassword, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 访问凭证(授权码/密码)"}, + {Key: model.ConfigKeyUploadAllowedExtensions, Value: "jpg,png,webp", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "允许上传的图片扩展名(逗号分隔)"}, + {Key: model.ConfigKeySiteName, Value: "Wavelet", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "系统平台的展示名称"}, + {Key: model.ConfigKeyPasswordLoginEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许使用账号密码登录"}, + {Key: model.ConfigKeyRegistrationEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "控制普通用户是否可以自主注册(true/false)"}, + {Key: model.ConfigKeyPasswordRegisterEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许通过密码创建本地账号"}, + {Key: model.ConfigKeyOIDCLoginEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许使用第三方 OIDC 认证源登录"}, + {Key: model.ConfigKeyMaxAPIKeysPerUser, Value: "5", Type: "business", Visibility: model.ConfigVisibilityVisible, Description: "限制每个普通用户可以创建的 API Key 最大数量"}, + {Key: model.ConfigKeyEmailLoginVerificationEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否开启邮箱登录验证(true/false)"}, + {Key: model.ConfigKeyEmailRegisterVerificationEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否开启邮箱注册验证(true/false)"}, + {Key: model.ConfigKeyMenuDisplayConfig, Value: "{}", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})"}, + {Key: model.ConfigKeySearchEngineIndexingEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许搜索引擎爬取/检索该站点(true/false)"}, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index bf37ccb8..7528263e 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -63,11 +63,19 @@ const ( SystemConfigRedisHashKey = "system:system_configs" ) +const ( + // ConfigVisibilityHidden 表示配置不通过公共配置接口暴露 + ConfigVisibilityHidden = 0 + // ConfigVisibilityVisible 表示配置通过公共配置接口暴露 + ConfigVisibilityVisible = 1 +) + // SystemConfig 系统配置实体 type SystemConfig struct { Key string `json:"key" gorm:"primaryKey;size:64;not null"` Value string `json:"value" gorm:"size:255;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"` @@ -102,6 +110,21 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { return nil } +// ListVisibleSystemConfigs 查询所有可通过公共配置接口暴露的配置 +func ListVisibleSystemConfigs(ctx context.Context) ([]SystemConfig, error) { + database := db.DB(ctx) + if database == nil { + return nil, errors.New(errDatabaseNotInitialized) + } + + var configs []SystemConfig + if err := database.Where("visibility = ?", ConfigVisibilityVisible).Find(&configs).Error; err != nil { + return nil, err + } + + return configs, nil +} + // GetIntByKey 通过 key 查询配置并转换为 int 类型 func GetIntByKey(ctx context.Context, key string) (int, error) { var sc SystemConfig diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 2edf1ecf..d8cf1055 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -231,9 +231,37 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { t.Fatalf("failed to seed default system configs: %v", err) } + publicKeys := map[string]struct{}{ + model.ConfigKeyUploadAllowedExtensions: {}, + model.ConfigKeySiteName: {}, + model.ConfigKeyPasswordLoginEnabled: {}, + model.ConfigKeyRegistrationEnabled: {}, + model.ConfigKeyPasswordRegisterEnabled: {}, + model.ConfigKeyOIDCLoginEnabled: {}, + model.ConfigKeyMaxAPIKeysPerUser: {}, + model.ConfigKeyCapLoginEnabled: {}, + model.ConfigKeyCapAutoSolve: {}, + model.ConfigKeyEmailLoginVerificationEnabled: {}, + model.ConfigKeyEmailRegisterVerificationEnabled: {}, + model.ConfigKeyMenuDisplayConfig: {}, + model.ConfigKeySearchEngineIndexingEnabled: {}, + } + keys := make([]string, 0, len(publicKeys)) + for key := range publicKeys { + keys = append(keys, key) + } + if err := tx.Model(&model.SystemConfig{}). + Where("key IN ?", keys). + Update("visibility", model.ConfigVisibilityVisible).Error; err != nil { + t.Fatalf("failed to seed public system config visibility: %v", err) + } + // Also seed these in miniredis context if required, but they are stored in postgres first. // We'll write configs to miniredis in actual handlers. for _, config := range defaultConfigs { + if _, ok := publicKeys[config.Key]; ok { + config.Visibility = model.ConfigVisibilityVisible + } _ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, config.Key, &config) } }