重构获取公共参数

This commit is contained in:
ryan
2026-06-09 15:54:16 +08:00
parent bff09241d3
commit 40e8a7cfa3
18 changed files with 313 additions and 315 deletions
+18 -1
View File
@@ -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
]
}
}
},
+18 -1
View File
@@ -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
]
}
}
},
+13 -1
View File
@@ -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:
+14 -9
View File
@@ -36,6 +36,11 @@ function persistRedirectTarget(searchParams: ReturnType<typeof useSearchParams>)
}
}
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() {
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={publicConfigQuery.data?.email_register_verification_enabled ? "电子邮箱" : "电子邮箱(可选)"}
placeholder={emailRegisterEnabled ? "电子邮箱" : "电子邮箱(可选)"}
autoComplete="email"
/>
{publicConfigQuery.data?.email_register_verification_enabled && (
{emailRegisterEnabled && (
<div className="flex gap-2">
<Input
value={code}
@@ -124,6 +124,16 @@ function SystemConfigDetailPanel({
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">公共可见</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
onCheckedChange={(checked) => {
onEditDataChange('visibility', checked ? 1 : 0)
}}
/>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
@@ -173,6 +183,7 @@ export function SystemConfigs() {
const getInitialEditData = (config: SystemConfig) => ({
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) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "公共可见", cell: (item) => <span>{item.visibility === 1 ? "可见" : "不可见"}</span>, width: "80px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
+5 -5
View File
@@ -234,17 +234,17 @@ export const apiSections: PolicySection[] = [
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="4-1-public-config" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">4.1 公共系统配置</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/config/public</code></p>
<p><strong>说明:</strong>无感获取当前系统的公开业务设置(如注册是否开启、密码登录是否开启)。供前端页面动态渲染使用。</p>
<p><strong>说明:</strong>无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。</p>
<p className="mt-2">返回数据结构样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "Wavelet",
"registration_enabled": true,
"password_login_enabled": true,
"password_register_enabled": true,
"oidc_login_enabled": true
"registration_enabled": "true",
"password_login_enabled": "true",
"password_register_enabled": "true",
"oidc_login_enabled": "true"
}
}`}
language="json"
+1 -1
View File
@@ -6,7 +6,7 @@ export function RobotsMeta() {
const { config } = usePublicConfig()
// Default to noindex, nofollow if config is loading or if it's explicitly disabled
const enabled = config?.search_engine_indexing_enabled === true
const enabled = config?.search_engine_indexing_enabled === "true"
return (
<meta
+20 -3
View File
@@ -84,6 +84,23 @@ const data = {
],
}
function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean> {
if (!raw) return {}
try {
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [key, value]) => {
if (typeof value === "boolean") {
result[key] = value
}
return result
}, {})
} catch {
return {}
}
}
/**
* 应用侧边栏组件
* 显示应用侧边栏
@@ -145,17 +162,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
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])
+4 -1
View File
@@ -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<AdminContextState | null>(null)
* @param {React.ReactNode} children - Admin Provider 的子元素
*/
export function AdminProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient()
const [systemConfigs, setSystemConfigs] = useState<SystemConfig[]>([])
const [systemConfigsLoading, setSystemConfigsLoading] = useState(false)
const [systemConfigsError, setSystemConfigsError] = useState<Error | null>(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,
+6
View File
@@ -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;
}
+2 -31
View File
@@ -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<string, boolean>;
/** 是否允许搜索引擎检索 */
search_engine_indexing_enabled?: boolean;
}
/** 公共配置响应:key 为系统配置键,value 为配置表中保存的字符串值 */
export type PublicConfigResponse = Record<string, string | undefined>;
@@ -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
@@ -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) {
+7 -105
View File
@@ -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))
+82
View File
@@ -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)
}
}
+41 -155
View File
@@ -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 {
+23
View File
@@ -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
+28
View File
@@ -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)
}
}