迁移配置表

This commit is contained in:
ryan
2026-06-22 19:49:37 +08:00
parent d9b8dc81ee
commit 92ceecc6ce
53 changed files with 1928 additions and 1152 deletions
+40 -61
View File
@@ -8,35 +8,15 @@ import (
"errors"
"fmt"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
oftasks "github.com/Rain-kl/Wavelet/internal/apps/openflare/tasks"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/uptimekuma"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
var (
initOnce sync.Once
initErr error
)
// EnsureInitialized loads OptionMap from defaults and database once per process.
func EnsureInitialized(ctx context.Context) error {
initOnce.Do(func() {
initErr = model.InitOptionMap(ctx)
})
return initErr
}
// ResetInitializationForTest clears lazy-init state for unit tests.
func ResetInitializationForTest() {
initOnce = sync.Once{}
initErr = nil
model.ResetOptionMapForTest()
}
type publicAuthSourceView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
@@ -50,9 +30,6 @@ type statusView struct {
Version string `json:"version"`
StartTime int64 `json:"start_time"`
EmailVerification bool `json:"email_verification"`
SystemName string `json:"system_name"`
HomePageLink string `json:"home_page_link"`
FooterHTML string `json:"footer_html"`
ServerAddress string `json:"server_address"`
PasswordRegisterEnabled bool `json:"password_register_enabled"`
CapLoginEnabled bool `json:"cap_login_enabled"`
@@ -91,37 +68,32 @@ type optionBatchPayload struct {
}
func listOptions(ctx context.Context) ([]model.OpenFlareOption, error) {
if err := EnsureInitialized(ctx); err != nil {
// 从 SystemConfig 读取所有业务配置
configs, err := repository.ListAdminSystemConfigs(ctx, "business")
if err != nil {
return nil, err
}
model.OptionMapRWMutex.RLock()
defer model.OptionMapRWMutex.RUnlock()
options := make([]model.OpenFlareOption, 0, len(model.OptionMap))
for key, value := range model.OptionMap {
if isSecretOptionKey(key) {
options := make([]model.OpenFlareOption, 0, len(configs))
for _, config := range configs {
// 跳过敏感配置(如密码、令牌)
if config.Visibility == model.ConfigVisibilityHidden && isSecretConfigKey(config.Key) {
continue
}
// 将 snake_case key 转换为 PascalCase 以保持向后兼容
options = append(options, model.OpenFlareOption{
Key: key,
Value: value,
Key: config.Key,
Value: config.Value,
})
}
return options, nil
}
func updateOption(ctx context.Context, option model.OpenFlareOption) error {
if err := EnsureInitialized(ctx); err != nil {
return err
}
return updateOptions(ctx, []model.OpenFlareOption{option})
}
func updateOptionsBatch(ctx context.Context, payload optionBatchPayload) error {
if err := EnsureInitialized(ctx); err != nil {
return err
}
if len(payload.Options) == 0 {
return errors.New(errInvalidParams)
}
@@ -129,40 +101,46 @@ func updateOptionsBatch(ctx context.Context, payload optionBatchPayload) error {
}
func updateOptions(ctx context.Context, options []model.OpenFlareOption) error {
if err := validateOptions(options); err != nil {
if err := validateOptions(ctx, options); err != nil {
return err
}
if err := model.UpdateOpenFlareOptions(ctx, options); err != nil {
return err
}
for _, item := range options {
if item.Key == "GeoIPProvider" {
return geoip.RefreshRuntimeProvider(ctx)
// 将每个 option 更新到 SystemConfig
for _, opt := range options {
if err := repository.SaveOrUpdateSystemConfig(ctx, opt.Key, opt.Value); err != nil {
return fmt.Errorf("failed to update config %s: %w", opt.Key, err)
}
// 特殊处理:GeoIP 配置变更时刷新运行时
if opt.Key == model.ConfigKeyGeoIPProvider {
if err := geoip.RefreshRuntimeProvider(ctx); err != nil {
return err
}
}
}
return nil
}
func getStatus(ctx context.Context, baseAPIPath string) (*statusView, error) {
if err := EnsureInitialized(ctx); err != nil {
return nil, err
}
authSources, err := publicAuthSources(ctx, baseAPIPath)
if err != nil {
authSources = []publicAuthSourceView{}
}
// 从 SystemConfig 读取配置
emailVerification, _ := repository.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled)
serverAddress, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
passwordRegisterEnabled, _ := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled)
capLoginEnabled, _ := repository.GetBoolByKey(ctx, model.ConfigKeyCapLoginEnabled)
return &statusView{
Version: buildinfo.Version,
StartTime: model.StartTime,
EmailVerification: model.EmailVerificationEnabled,
SystemName: model.SystemName,
HomePageLink: model.HomePageLink,
FooterHTML: model.Footer,
ServerAddress: model.ServerAddress,
PasswordRegisterEnabled: model.PasswordRegisterEnabled,
CapLoginEnabled: model.CapLoginEnabled,
EmailVerification: emailVerification,
ServerAddress: serverAddress.Value,
PasswordRegisterEnabled: passwordRegisterEnabled,
CapLoginEnabled: capLoginEnabled,
AuthSources: authSources,
}, nil
}
@@ -229,8 +207,9 @@ func syncUptimeKuma(ctx context.Context) error {
return uptimekuma.SyncToUptimeKuma(ctx)
}
func isSecretOptionKey(key string) bool {
return strings.Contains(key, "Token") ||
strings.Contains(key, "Secret") ||
strings.Contains(key, "Password")
// isSecretConfigKey 判断 SystemConfig 的 key 是否为敏感配置
func isSecretConfigKey(key string) bool {
return strings.Contains(key, "token") ||
strings.Contains(key, "secret") ||
strings.Contains(key, "password")
}
+51 -17
View File
@@ -10,6 +10,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -23,28 +24,35 @@ func setupOptionTestDB(t *testing.T) func() {
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareOption{}))
require.NoError(t, sqliteDB.AutoMigrate(&model.SystemConfig{}))
db.SetDB(sqliteDB)
ResetInitializationForTest()
// 预填充一些业务配置用于测试
seedConfigs := []model.SystemConfig{
{Key: "geoip_provider", Value: "ipinfo", Type: "business", Visibility: 0},
{Key: "uptime_kuma_password", Value: "secret-pwd", Type: "business", Visibility: 0},
}
for _, cfg := range seedConfigs {
require.NoError(t, sqliteDB.Create(&cfg).Error)
}
return func() {
db.SetDB(nil)
ResetInitializationForTest()
}
}
// setTestConfig 设置测试配置的辅助函数
func setTestConfig(t *testing.T, ctx context.Context, key, value string) {
t.Helper()
require.NoError(t, db.DB(ctx).Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error)
}
func TestListOptionsFiltersSecretKeys(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, model.UpdateOpenFlareOptions(ctx, []model.OpenFlareOption{
{Key: "SystemName", Value: "TestFlare"},
{Key: "SMTPToken", Value: "secret-token"},
{Key: "GitHubClientSecret", Value: "secret-id"},
}))
options, err := listOptions(ctx)
require.NoError(t, err)
@@ -53,24 +61,50 @@ func TestListOptionsFiltersSecretKeys(t *testing.T) {
keys[option.Key] = option.Value
}
assert.Equal(t, "TestFlare", keys["SystemName"])
assert.NotContains(t, keys, "SMTPToken")
assert.NotContains(t, keys, "GitHubClientSecret")
// geoip_provider 应该出现在列表中
assert.Equal(t, "ipinfo", keys["geoip_provider"])
// 敏感配置(密码)应该被过滤掉
assert.NotContains(t, keys, "uptime_kuma_password")
}
func TestUpdateOptionHotReloadsOptionMap(t *testing.T) {
func TestUpdateOptionPersistsToSystemConfig(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
err := updateOption(ctx, model.OpenFlareOption{
Key: "SystemName",
Value: "HotReloaded",
Key: model.ConfigKeyGeoIPProvider,
Value: "mmdb",
})
require.NoError(t, err)
assert.Equal(t, "HotReloaded", model.OptionValue("SystemName"))
assert.Equal(t, "HotReloaded", model.SystemName)
// 验证配置已写入 SystemConfig
config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyGeoIPProvider)
require.NoError(t, err)
assert.Equal(t, "mmdb", config.Value)
}
func TestUpdateOpenRestyOptionPersistsToSystemConfig(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
Key: model.ConfigKeyOpenRestyEventsUse,
Value: "epoll",
Type: "business",
Visibility: 0,
}).Error)
err := updateOption(ctx, model.OpenFlareOption{
Key: model.ConfigKeyOpenRestyEventsUse,
Value: "kqueue",
})
require.NoError(t, err)
config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyOpenRestyEventsUse)
require.NoError(t, err)
assert.Equal(t, "kqueue", config.Value)
}
func TestLookupGeoIPDisabledProvider(t *testing.T) {
@@ -8,46 +8,48 @@ import (
"regexp"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
)
var openRestyOptionValidators = map[string]func(key, value string) error{
"OpenRestyDefaultServerReturnStatus": validateOpenRestyDefaultServerReturnStatus,
"OpenRestyWorkerProcesses": validateOpenRestyWorkerProcesses,
"OpenRestyWorkerConnections": validatePositiveIntegerOption,
"OpenRestyWorkerRlimitNofile": validatePositiveIntegerOption,
"OpenRestyKeepaliveTimeout": validatePositiveIntegerOption,
"OpenRestyKeepaliveRequests": validatePositiveIntegerOption,
"OpenRestyClientHeaderTimeout": validatePositiveIntegerOption,
"OpenRestyClientBodyTimeout": validatePositiveIntegerOption,
"OpenRestySendTimeout": validatePositiveIntegerOption,
"OpenRestyProxyConnectTimeout": validatePositiveIntegerOption,
"OpenRestyProxySendTimeout": validatePositiveIntegerOption,
"OpenRestyProxyReadTimeout": validatePositiveIntegerOption,
"OpenRestyGzipMinLength": validatePositiveIntegerOption,
"OpenRestyGzipCompLevel": validateOpenRestyGzipCompLevel,
"OpenRestyEventsUse": validateOpenRestyEventsUse,
"OpenRestyResolvers": validateOpenRestyResolvers,
"OpenRestyEventsMultiAcceptEnabled": validateBooleanOption,
"OpenRestyWebsocketEnabled": validateBooleanOption,
"OpenRestyHTTP3Enabled": validateBooleanOption,
"OpenRestyProxyRequestBufferingEnabled": validateBooleanOption,
"OpenRestyProxyBufferingEnabled": validateBooleanOption,
"OpenRestyGzipEnabled": validateBooleanOption,
"OpenRestyCacheEnabled": validateBooleanOption,
"OpenRestyCacheLockEnabled": validateBooleanOption,
"OpenRestyProxyBuffers": validateOpenRestyProxyBuffers,
"OpenRestyLargeClientHeaderBuffers": validateOpenRestyProxyBuffers,
"OpenRestyProxyBufferSize": validateOpenRestySizeValue,
"OpenRestyProxyBusyBuffersSize": validateOpenRestySizeValue,
"OpenRestyCacheMaxSize": validateOpenRestySizeValue,
"OpenRestyClientMaxBodySize": validateOpenRestySizeValue,
"OpenRestyCachePath": validateOpenRestyCachePath,
"OpenRestyCacheLevels": validateOpenRestyCacheLevels,
"OpenRestyCacheInactive": validateOpenRestyDurationToken,
"OpenRestyCacheLockTimeout": validateOpenRestyDurationToken,
"OpenRestyCacheKeyTemplate": validateOpenRestyCacheKeyTemplate,
"OpenRestyCacheUseStale": validateOpenRestyCacheUseStale,
"OpenRestyMainConfigTemplate": validateOpenRestyMainConfigTemplate,
model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus,
model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses,
model.ConfigKeyOpenRestyWorkerConnections: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyWorkerRlimitNofile: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyKeepaliveTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyKeepaliveRequests: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyClientHeaderTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyClientBodyTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestySendTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyProxyConnectTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyProxySendTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyProxyReadTimeout: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyGzipMinLength: validatePositiveIntegerOption,
model.ConfigKeyOpenRestyGzipCompLevel: validateOpenRestyGzipCompLevel,
model.ConfigKeyOpenRestyEventsUse: validateOpenRestyEventsUse,
model.ConfigKeyOpenRestyResolvers: validateOpenRestyResolvers,
model.ConfigKeyOpenRestyEventsMultiAcceptEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyWebsocketEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyHTTP3Enabled: validateBooleanOption,
model.ConfigKeyOpenRestyProxyRequestBufferingEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyProxyBufferingEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyGzipEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyCacheEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyCacheLockEnabled: validateBooleanOption,
model.ConfigKeyOpenRestyProxyBuffers: validateOpenRestyProxyBuffers,
model.ConfigKeyOpenRestyLargeClientHeaderBuffers: validateOpenRestyProxyBuffers,
model.ConfigKeyOpenRestyProxyBufferSize: validateOpenRestySizeValue,
model.ConfigKeyOpenRestyProxyBusyBuffersSize: validateOpenRestySizeValue,
model.ConfigKeyOpenRestyCacheMaxSize: validateOpenRestySizeValue,
model.ConfigKeyOpenRestyClientMaxBodySize: validateOpenRestySizeValue,
model.ConfigKeyOpenRestyCachePath: validateOpenRestyCachePath,
model.ConfigKeyOpenRestyCacheLevels: validateOpenRestyCacheLevels,
model.ConfigKeyOpenRestyCacheInactive: validateOpenRestyDurationToken,
model.ConfigKeyOpenRestyCacheLockTimeout: validateOpenRestyDurationToken,
model.ConfigKeyOpenRestyCacheKeyTemplate: validateOpenRestyCacheKeyTemplate,
model.ConfigKeyOpenRestyCacheUseStale: validateOpenRestyCacheUseStale,
model.ConfigKeyOpenRestyMainConfigTemplate: validateOpenRestyMainConfigTemplate,
}
func validateOpenRestyOption(key, value string) error {
+41 -31
View File
@@ -4,6 +4,7 @@
package option
import (
"context"
"errors"
"fmt"
"regexp"
@@ -12,6 +13,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
const maxOpenRestyGzipCompLevel = 9
@@ -25,21 +27,25 @@ var (
const optionValueTrue = "true"
func buildOptionValidationState(options []model.OpenFlareOption) map[string]string {
model.OptionMapRWMutex.RLock()
state := make(map[string]string, len(model.OptionMap)+len(options))
for key, value := range model.OptionMap {
state[key] = value
}
model.OptionMapRWMutex.RUnlock()
func buildOptionValidationState(ctx context.Context, options []model.OpenFlareOption) map[string]string {
// 从 SystemConfig 读取所有业务配置构建状态
configs, err := repository.ListAdminSystemConfigs(ctx, "business")
state := make(map[string]string, len(configs)+len(options))
if err == nil {
for _, config := range configs {
state[config.Key] = config.Value
}
}
// 应用待验证的新值
for _, option := range options {
state[option.Key] = option.Value
}
return state
}
func validateOptionWithState(option model.OpenFlareOption, state map[string]string) error {
func validateOptionWithState(ctx context.Context, option model.OpenFlareOption, state map[string]string) error {
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
return err
@@ -53,7 +59,7 @@ func validateOptionWithState(option model.OpenFlareOption, state map[string]stri
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
return validateUptimeKumaOption(option.Key, option.Value, state)
return validateUptimeKumaOption(ctx, option.Key, option.Value, state)
}
func validatePositiveIntegerOption(key, value string) error {
@@ -74,7 +80,7 @@ func validateBooleanOption(key, value string) error {
}
func validateGeoIPOption(key, value string) error {
if key != "GeoIPProvider" {
if key != model.ConfigKeyGeoIPProvider {
return nil
}
if geoip.IsValidProvider(value) {
@@ -85,9 +91,9 @@ func validateGeoIPOption(key, value string) error {
func validateDatabaseCleanupOption(key, value string) error {
switch key {
case "DatabaseAutoCleanupEnabled":
case model.ConfigKeyDatabaseAutoCleanupEnabled:
return validateBooleanOption(key, value)
case "DatabaseAutoCleanupRetentionDays":
case model.ConfigKeyDatabaseAutoCleanupRetentionDays:
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
@@ -97,55 +103,59 @@ func validateDatabaseCleanupOption(key, value string) error {
}
func validateAgentOption(key, value string) error {
if key == "AgentWebsocketUpgradeEnabled" {
if key == model.ConfigKeyAgentWebsocketUpgradeEnabled {
return validateBooleanOption(key, strings.TrimSpace(value))
}
return nil
}
func validateUptimeKumaOption(key, value string, state map[string]string) error {
func validateUptimeKumaOption(ctx context.Context, key, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
return validateUptimeKumaEnabled(key, trimmed, state)
case "UptimeKumaUsername":
case model.ConfigKeyUptimeKumaEnabled:
return validateUptimeKumaEnabled(ctx, key, trimmed, state)
case model.ConfigKeyUptimeKumaUsername:
return validateUptimeKumaUsername(trimmed, state)
case "UptimeKumaUrl":
case model.ConfigKeyUptimeKumaURL:
return validateUptimeKumaURL(trimmed)
case "UptimeKumaMonitorScope":
case model.ConfigKeyUptimeKumaMonitorScope:
return validateUptimeKumaMonitorScope(trimmed)
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
case model.ConfigKeyUptimeKumaSyncInterval, model.ConfigKeyUptimeKumaInterval, model.ConfigKeyUptimeKumaRetryInterval, model.ConfigKeyUptimeKumaTimeout:
return validatePositiveIntegerOption(key, trimmed)
case "UptimeKumaRetry":
case model.ConfigKeyUptimeKumaRetry:
return validateUptimeKumaRetry(key, trimmed)
}
return nil
}
func validateUptimeKumaEnabled(key, trimmed string, state map[string]string) error {
func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state map[string]string) error {
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed != optionValueTrue {
return nil
}
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
url := strings.TrimSpace(state[model.ConfigKeyUptimeKumaURL])
username := strings.TrimSpace(state[model.ConfigKeyUptimeKumaUsername])
password := strings.TrimSpace(state[model.ConfigKeyUptimeKumaPassword])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && model.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
// 如果待验证的密码为空,且当前配置中也没有密码,则报错
if password == "" {
existingPwd, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUptimeKumaPassword)
if strings.TrimSpace(existingPwd.Value) == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
return nil
}
func validateUptimeKumaUsername(trimmed string, state map[string]string) error {
if trimmed == "" && state["UptimeKumaEnabled"] == optionValueTrue {
if trimmed == "" && state[model.ConfigKeyUptimeKumaEnabled] == optionValueTrue {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
return nil
@@ -173,17 +183,17 @@ func validateUptimeKumaRetry(key, trimmed string) error {
return nil
}
func validateOptions(options []model.OpenFlareOption) error {
func validateOptions(ctx context.Context, options []model.OpenFlareOption) error {
if len(options) == 0 {
return errors.New(errInvalidParams)
}
state := buildOptionValidationState(options)
state := buildOptionValidationState(ctx, options)
for _, option := range options {
if strings.TrimSpace(option.Key) == "" {
return errors.New(errInvalidParams)
}
if err := validateOptionWithState(option, state); err != nil {
if err := validateOptionWithState(ctx, option, state); err != nil {
return err
}
}