mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-27 20:53:01 +00:00
feat(settings): migrate to typed app_settings with legacy fallback
This commit is contained in:
parent
6b6d92e67e
commit
f3ac4bef4c
7 changed files with 582 additions and 158 deletions
66
database/app_settings_migration.go
Normal file
66
database/app_settings_migration.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// initAppSettings ensures the typed app_settings row exists and is seeded.
|
||||
// If missing, it migrates values from legacy key/value settings where available.
|
||||
func initAppSettings() error {
|
||||
var count int64
|
||||
if err := db.Model(&model.AppSettings{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := model.NewDefaultAppSettings("")
|
||||
var legacy []model.Setting
|
||||
if err := db.Model(&model.Setting{}).Find(&legacy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, setting := range legacy {
|
||||
if _, err := cfg.SetByKey(setting.Key, setting.Value); err != nil {
|
||||
log.Printf("Warning: failed to migrate legacy setting key=%s to app_settings: %v", setting.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return db.Create(cfg).Error
|
||||
}
|
||||
|
||||
// GetAppSettings returns the authoritative typed settings row.
|
||||
func GetAppSettings() (*model.AppSettings, error) {
|
||||
cfg := &model.AppSettings{}
|
||||
err := db.Model(&model.AppSettings{}).First(cfg).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetOrCreateAppSettings returns the typed settings row, creating one when missing.
|
||||
func GetOrCreateAppSettings(defaultXrayTemplate string) (*model.AppSettings, error) {
|
||||
cfg, err := GetAppSettings()
|
||||
if err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg = model.NewDefaultAppSettings(defaultXrayTemplate)
|
||||
if createErr := db.Create(cfg).Error; createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveAppSettings persists the typed settings row.
|
||||
func SaveAppSettings(cfg *model.AppSettings) error {
|
||||
return db.Save(cfg).Error
|
||||
}
|
||||
62
database/app_settings_migration_test.go
Normal file
62
database/app_settings_migration_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func seedLegacySettings(t *testing.T, dbPath string, rows []model.Setting) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatalf("migrate legacy setting table: %v", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := gdb.Create(&row).Error; err != nil {
|
||||
t.Fatalf("insert legacy row %s: %v", row.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDBMigratesLegacySettingsToAppSettings(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "migrate.db")
|
||||
seedLegacySettings(t, dbPath, []model.Setting{
|
||||
{Key: "webPort", Value: "8899"},
|
||||
{Key: "subPath", Value: "/legacy-sub/"},
|
||||
{Key: "tgBotEnable", Value: "true"},
|
||||
{Key: "xrayTemplateConfig", Value: `{"log":{}}`},
|
||||
})
|
||||
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := GetAppSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAppSettings failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.WebPort != 8899 {
|
||||
t.Fatalf("expected WebPort=8899, got %d", cfg.WebPort)
|
||||
}
|
||||
if cfg.SubPath != "/legacy-sub/" {
|
||||
t.Fatalf("expected SubPath migrated, got %q", cfg.SubPath)
|
||||
}
|
||||
if !cfg.TgBotEnable {
|
||||
t.Fatalf("expected TgBotEnable=true from legacy row")
|
||||
}
|
||||
if cfg.XrayTemplateConfig != `{"log":{}}` {
|
||||
t.Fatalf("expected xray template config migrated, got %q", cfg.XrayTemplateConfig)
|
||||
}
|
||||
if cfg.SessionMaxAge == 0 {
|
||||
t.Fatalf("expected default SessionMaxAge to be seeded")
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ func initModels() error {
|
|||
&model.Inbound{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.Setting{},
|
||||
&model.AppSettings{},
|
||||
&model.InboundClientIps{},
|
||||
&model.MasterClient{},
|
||||
&model.MasterClientInbound{},
|
||||
|
|
@ -148,6 +149,9 @@ func InitDB(dbPath string) error {
|
|||
if err := initModels(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initAppSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isUsersEmpty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
|
|
|
|||
266
database/model/app_settings.go
Normal file
266
database/model/app_settings.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||
)
|
||||
|
||||
// AppSettings is the authoritative typed settings row.
|
||||
// It mirrors the historical key/value settings keys via `setting` tags.
|
||||
type AppSettings struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement"`
|
||||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime:milli"`
|
||||
|
||||
XrayTemplateConfig string `gorm:"type:text" setting:"xrayTemplateConfig"`
|
||||
WebListen string `setting:"webListen"`
|
||||
WebDomain string `setting:"webDomain"`
|
||||
WebPort int `setting:"webPort"`
|
||||
WebCertFile string `setting:"webCertFile"`
|
||||
WebKeyFile string `setting:"webKeyFile"`
|
||||
Secret string `setting:"secret"`
|
||||
WebBasePath string `setting:"webBasePath"`
|
||||
SessionMaxAge int `setting:"sessionMaxAge"`
|
||||
PageSize int `setting:"pageSize"`
|
||||
ExpireDiff int `setting:"expireDiff"`
|
||||
TrafficDiff int `setting:"trafficDiff"`
|
||||
RemarkModel string `setting:"remarkModel"`
|
||||
TimeLocation string `setting:"timeLocation"`
|
||||
|
||||
TgBotEnable bool `setting:"tgBotEnable"`
|
||||
TgBotToken string `setting:"tgBotToken"`
|
||||
TgBotProxy string `setting:"tgBotProxy"`
|
||||
TgBotAPIServer string `setting:"tgBotAPIServer"`
|
||||
TgBotChatID string `setting:"tgBotChatId"`
|
||||
TgRunTime string `setting:"tgRunTime"`
|
||||
TgBotBackup bool `setting:"tgBotBackup"`
|
||||
TgBotLoginNotify bool `setting:"tgBotLoginNotify"`
|
||||
TgCPU int `setting:"tgCpu"`
|
||||
TgLang string `setting:"tgLang"`
|
||||
|
||||
TwoFactorEnable bool `setting:"twoFactorEnable"`
|
||||
TwoFactorToken string `setting:"twoFactorToken"`
|
||||
|
||||
SubEnable bool `setting:"subEnable"`
|
||||
SubJSONEnable bool `setting:"subJsonEnable"`
|
||||
SubTitle string `setting:"subTitle"`
|
||||
SubSupportURL string `setting:"subSupportUrl"`
|
||||
SubProfileURL string `setting:"subProfileUrl"`
|
||||
SubAnnounce string `setting:"subAnnounce"`
|
||||
SubEnableRouting bool `setting:"subEnableRouting"`
|
||||
SubRoutingRules string `gorm:"type:text" setting:"subRoutingRules"`
|
||||
SubListen string `setting:"subListen"`
|
||||
SubPort int `setting:"subPort"`
|
||||
SubPath string `setting:"subPath"`
|
||||
SubDomain string `setting:"subDomain"`
|
||||
SubCertFile string `setting:"subCertFile"`
|
||||
SubKeyFile string `setting:"subKeyFile"`
|
||||
SubUpdates string `setting:"subUpdates"`
|
||||
SubEncrypt bool `setting:"subEncrypt"`
|
||||
SubShowInfo bool `setting:"subShowInfo"`
|
||||
SubURI string `setting:"subURI"`
|
||||
SubJSONPath string `setting:"subJsonPath"`
|
||||
SubJSONURI string `setting:"subJsonURI"`
|
||||
SubJSONFragment string `setting:"subJsonFragment"`
|
||||
SubJSONNoises string `setting:"subJsonNoises"`
|
||||
SubJSONMux string `setting:"subJsonMux"`
|
||||
SubJSONRules string `setting:"subJsonRules"`
|
||||
Datepicker string `setting:"datepicker"`
|
||||
|
||||
Warp string `setting:"warp"`
|
||||
ExternalTrafficInformEnable bool `setting:"externalTrafficInformEnable"`
|
||||
ExternalTrafficInformURI string `setting:"externalTrafficInformURI"`
|
||||
XrayOutboundTestURL string `setting:"xrayOutboundTestUrl"`
|
||||
|
||||
LdapEnable bool `setting:"ldapEnable"`
|
||||
LdapHost string `setting:"ldapHost"`
|
||||
LdapPort int `setting:"ldapPort"`
|
||||
LdapUseTLS bool `setting:"ldapUseTLS"`
|
||||
LdapBindDN string `setting:"ldapBindDN"`
|
||||
LdapPassword string `setting:"ldapPassword"`
|
||||
LdapBaseDN string `setting:"ldapBaseDN"`
|
||||
LdapUserFilter string `setting:"ldapUserFilter"`
|
||||
LdapUserAttr string `setting:"ldapUserAttr"`
|
||||
LdapVlessField string `setting:"ldapVlessField"`
|
||||
LdapSyncCron string `setting:"ldapSyncCron"`
|
||||
LdapFlagField string `setting:"ldapFlagField"`
|
||||
LdapTruthyValues string `setting:"ldapTruthyValues"`
|
||||
LdapInvertFlag bool `setting:"ldapInvertFlag"`
|
||||
LdapInboundTags string `setting:"ldapInboundTags"`
|
||||
LdapAutoCreate bool `setting:"ldapAutoCreate"`
|
||||
LdapAutoDelete bool `setting:"ldapAutoDelete"`
|
||||
LdapDefaultTotalGB int `setting:"ldapDefaultTotalGB"`
|
||||
LdapDefaultExpiryDays int `setting:"ldapDefaultExpiryDays"`
|
||||
LdapDefaultLimitIP int `setting:"ldapDefaultLimitIP"`
|
||||
}
|
||||
|
||||
func (AppSettings) TableName() string {
|
||||
return "app_settings"
|
||||
}
|
||||
|
||||
var (
|
||||
appSettingsFieldMapOnce sync.Once
|
||||
appSettingsFieldMap map[string]int
|
||||
)
|
||||
|
||||
func buildAppSettingsFieldMap() {
|
||||
appSettingsFieldMap = make(map[string]int)
|
||||
t := reflect.TypeOf(AppSettings{})
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
key := f.Tag.Get("setting")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
appSettingsFieldMap[key] = i
|
||||
}
|
||||
}
|
||||
|
||||
func getAppSettingsFieldMap() map[string]int {
|
||||
appSettingsFieldMapOnce.Do(buildAppSettingsFieldMap)
|
||||
return appSettingsFieldMap
|
||||
}
|
||||
|
||||
// DefaultSettingValues returns canonical defaults for settings keys.
|
||||
func DefaultSettingValues(xrayTemplateConfig string) map[string]string {
|
||||
return map[string]string{
|
||||
"xrayTemplateConfig": xrayTemplateConfig,
|
||||
"webListen": "",
|
||||
"webDomain": "",
|
||||
"webPort": "2053",
|
||||
"webCertFile": "",
|
||||
"webKeyFile": "",
|
||||
"secret": random.Seq(32),
|
||||
"webBasePath": "/",
|
||||
"sessionMaxAge": "360",
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkModel": "-ieo",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
"tgBotProxy": "",
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotChatId": "",
|
||||
"tgRunTime": "@daily",
|
||||
"tgBotBackup": "false",
|
||||
"tgBotLoginNotify": "true",
|
||||
"tgCpu": "80",
|
||||
"tgLang": "en-US",
|
||||
"twoFactorEnable": "false",
|
||||
"twoFactorToken": "",
|
||||
"subEnable": "true",
|
||||
"subJsonEnable": "false",
|
||||
"subTitle": "",
|
||||
"subSupportUrl": "",
|
||||
"subProfileUrl": "",
|
||||
"subAnnounce": "",
|
||||
"subEnableRouting": "true",
|
||||
"subRoutingRules": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
"subPath": "/sub/",
|
||||
"subDomain": "",
|
||||
"subCertFile": "",
|
||||
"subKeyFile": "",
|
||||
"subUpdates": "12",
|
||||
"subEncrypt": "true",
|
||||
"subShowInfo": "true",
|
||||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
"subJsonFragment": "",
|
||||
"subJsonNoises": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
"ldapPort": "389",
|
||||
"ldapUseTLS": "false",
|
||||
"ldapBindDN": "",
|
||||
"ldapPassword": "",
|
||||
"ldapBaseDN": "",
|
||||
"ldapUserFilter": "(objectClass=person)",
|
||||
"ldapUserAttr": "mail",
|
||||
"ldapVlessField": "vless_enabled",
|
||||
"ldapSyncCron": "@every 1m",
|
||||
"ldapFlagField": "",
|
||||
"ldapTruthyValues": "true,1,yes,on",
|
||||
"ldapInvertFlag": "false",
|
||||
"ldapInboundTags": "",
|
||||
"ldapAutoCreate": "false",
|
||||
"ldapAutoDelete": "false",
|
||||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "0",
|
||||
"ldapDefaultLimitIP": "0",
|
||||
}
|
||||
}
|
||||
|
||||
// NewDefaultAppSettings creates a settings row initialized with default values.
|
||||
func NewDefaultAppSettings(xrayTemplateConfig string) *AppSettings {
|
||||
cfg := &AppSettings{}
|
||||
defaults := DefaultSettingValues(xrayTemplateConfig)
|
||||
for key, value := range defaults {
|
||||
_, _ = cfg.SetByKey(key, value)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GetByKey returns a string representation of a settings key value.
|
||||
func (s *AppSettings) GetByKey(key string) (string, bool, error) {
|
||||
idx, ok := getAppSettingsFieldMap()[key]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
v := reflect.ValueOf(s).Elem().Field(idx)
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String(), true, nil
|
||||
case reflect.Bool:
|
||||
return strconv.FormatBool(v.Bool()), true, nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(v.Int(), 10), true, nil
|
||||
default:
|
||||
return "", true, fmt.Errorf("unsupported settings field kind for key %s: %s", key, v.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// SetByKey sets a settings value by historical key name.
|
||||
func (s *AppSettings) SetByKey(key string, value string) (bool, error) {
|
||||
idx, ok := getAppSettingsFieldMap()[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
v := reflect.ValueOf(s).Elem().Field(idx)
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
v.SetString(value)
|
||||
return true, nil
|
||||
case reflect.Bool:
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
v.SetBool(parsed)
|
||||
return true, nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
v.SetInt(parsed)
|
||||
return true, nil
|
||||
default:
|
||||
return true, fmt.Errorf("unsupported settings field kind for key %s: %s", key, v.Kind())
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package service
|
|||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
|
|
@ -15,7 +14,6 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/reflect_util"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
|
|
@ -24,85 +22,7 @@ import (
|
|||
//go:embed config.json
|
||||
var xrayTemplateConfig string
|
||||
|
||||
var defaultValueMap = map[string]string{
|
||||
"xrayTemplateConfig": xrayTemplateConfig,
|
||||
"webListen": "",
|
||||
"webDomain": "",
|
||||
"webPort": "2053",
|
||||
"webCertFile": "",
|
||||
"webKeyFile": "",
|
||||
"secret": random.Seq(32),
|
||||
"webBasePath": "/",
|
||||
"sessionMaxAge": "360",
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkModel": "-ieo",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
"tgBotProxy": "",
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotChatId": "",
|
||||
"tgRunTime": "@daily",
|
||||
"tgBotBackup": "false",
|
||||
"tgBotLoginNotify": "true",
|
||||
"tgCpu": "80",
|
||||
"tgLang": "en-US",
|
||||
"twoFactorEnable": "false",
|
||||
"twoFactorToken": "",
|
||||
"subEnable": "true",
|
||||
"subJsonEnable": "false",
|
||||
"subTitle": "",
|
||||
"subSupportUrl": "",
|
||||
"subProfileUrl": "",
|
||||
"subAnnounce": "",
|
||||
"subEnableRouting": "true",
|
||||
"subRoutingRules": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
"subPath": "/sub/",
|
||||
"subDomain": "",
|
||||
"subCertFile": "",
|
||||
"subKeyFile": "",
|
||||
"subUpdates": "12",
|
||||
"subEncrypt": "true",
|
||||
"subShowInfo": "true",
|
||||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
"subJsonFragment": "",
|
||||
"subJsonNoises": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
"ldapPort": "389",
|
||||
"ldapUseTLS": "false",
|
||||
"ldapBindDN": "",
|
||||
"ldapPassword": "",
|
||||
"ldapBaseDN": "",
|
||||
"ldapUserFilter": "(objectClass=person)",
|
||||
"ldapUserAttr": "mail",
|
||||
"ldapVlessField": "vless_enabled",
|
||||
"ldapSyncCron": "@every 1m",
|
||||
"ldapFlagField": "",
|
||||
"ldapTruthyValues": "true,1,yes,on",
|
||||
"ldapInvertFlag": "false",
|
||||
"ldapInboundTags": "",
|
||||
"ldapAutoCreate": "false",
|
||||
"ldapAutoDelete": "false",
|
||||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "0",
|
||||
"ldapDefaultLimitIP": "0",
|
||||
}
|
||||
var defaultValueMap = model.DefaultSettingValues(xrayTemplateConfig)
|
||||
|
||||
// SettingService provides business logic for application settings management.
|
||||
// It handles configuration storage, retrieval, and validation for all system settings.
|
||||
|
|
@ -118,75 +38,38 @@ func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
|||
}
|
||||
|
||||
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
||||
db := database.GetDB()
|
||||
settings := make([]*model.Setting, 0)
|
||||
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allSetting := &entity.AllSetting{}
|
||||
t := reflect.TypeOf(allSetting).Elem()
|
||||
v := reflect.ValueOf(allSetting).Elem()
|
||||
fields := reflect_util.GetFields(t)
|
||||
|
||||
setSetting := func(key, value string) (err error) {
|
||||
defer func() {
|
||||
panicErr := recover()
|
||||
if panicErr != nil {
|
||||
err = errors.New(fmt.Sprint(panicErr))
|
||||
}
|
||||
}()
|
||||
|
||||
var found bool
|
||||
var field reflect.StructField
|
||||
for _, f := range fields {
|
||||
if f.Tag.Get("json") == key {
|
||||
field = f
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// Some settings are automatically generated, no need to return to the front end to modify the user
|
||||
return nil
|
||||
}
|
||||
|
||||
fieldV := v.FieldByName(field.Name)
|
||||
switch t := fieldV.Interface().(type) {
|
||||
case int:
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldV.SetInt(n)
|
||||
case string:
|
||||
fieldV.SetString(value)
|
||||
case bool:
|
||||
fieldV.SetBool(value == "true")
|
||||
default:
|
||||
return common.NewErrorf("unknown field %v type %v", key, t)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
keyMap := map[string]bool{}
|
||||
for _, setting := range settings {
|
||||
err := setSetting(setting.Key, setting.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyMap[setting.Key] = true
|
||||
}
|
||||
|
||||
for key, value := range defaultValueMap {
|
||||
if keyMap[key] {
|
||||
for _, field := range fields {
|
||||
key := field.Tag.Get("json")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
err := setSetting(key, value)
|
||||
value, err := s.getString(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fieldV := v.FieldByName(field.Name)
|
||||
switch fieldV.Kind() {
|
||||
case reflect.String:
|
||||
fieldV.SetString(value)
|
||||
case reflect.Bool:
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fieldV.SetBool(parsed)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fieldV.SetInt(parsed)
|
||||
default:
|
||||
return nil, common.NewErrorf("unknown field %v type %v", key, fieldV.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
return allSetting, nil
|
||||
|
|
@ -194,15 +77,16 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
|||
|
||||
func (s *SettingService) ResetSettings() error {
|
||||
db := database.GetDB()
|
||||
err := db.Where("1 = 1").Delete(model.Setting{}).Error
|
||||
if err != nil {
|
||||
if err := db.Where("1 = 1").Delete(model.Setting{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(model.User{}).
|
||||
Where("1 = 1").Error
|
||||
if err := db.Where("1 = 1").Delete(model.AppSettings{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
||||
func (s *SettingService) getLegacySetting(key string) (*model.Setting, error) {
|
||||
db := database.GetDB()
|
||||
setting := &model.Setting{}
|
||||
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
||||
|
|
@ -212,24 +96,45 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
|||
return setting, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) saveSetting(key string, value string) error {
|
||||
setting, err := s.getSetting(key)
|
||||
func (s *SettingService) saveLegacySetting(key string, value string) error {
|
||||
db := database.GetDB()
|
||||
if database.IsNotFound(err) {
|
||||
return db.Create(&model.Setting{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}).Error
|
||||
} else if err != nil {
|
||||
setting := &model.Setting{}
|
||||
return db.Where("key = ?", key).Assign(model.Setting{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}).FirstOrCreate(setting).Error
|
||||
}
|
||||
|
||||
func (s *SettingService) saveSetting(key string, value string) error {
|
||||
repo := settingsRepository{}
|
||||
recognized, err := repo.set(key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setting.Key = key
|
||||
setting.Value = value
|
||||
return db.Save(setting).Error
|
||||
// Keep shadow write for one release as compatibility fallback.
|
||||
if legacyErr := s.saveLegacySetting(key, value); legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
if recognized {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) getString(key string) (string, error) {
|
||||
setting, err := s.getSetting(key)
|
||||
repo := settingsRepository{}
|
||||
if value, recognized, err := repo.get(key); err == nil && recognized {
|
||||
if key == "xrayTemplateConfig" && value == "" {
|
||||
defaultTemplate := defaultValueMap[key]
|
||||
_ = s.saveSetting(key, defaultTemplate)
|
||||
return defaultTemplate, nil
|
||||
}
|
||||
return value, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
setting, err := s.getLegacySetting(key)
|
||||
if database.IsNotFound(err) {
|
||||
value, ok := defaultValueMap[key]
|
||||
if !ok {
|
||||
|
|
|
|||
84
web/service/setting_repository_test.go
Normal file
84
web/service/setting_repository_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func seedLegacySettingsForServiceTest(t *testing.T, dbPath string, rows []model.Setting) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatalf("migrate setting table: %v", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := gdb.Create(&row).Error; err != nil {
|
||||
t.Fatalf("insert legacy row %s: %v", row.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingServiceReadsMigratedTypedSettingsAndShadowWritesLegacy(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "setting-service.db")
|
||||
seedLegacySettingsForServiceTest(t, dbPath, []model.Setting{{Key: "webPort", Value: "8111"}})
|
||||
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
defer func() { _ = database.CloseDB() }()
|
||||
|
||||
svc := &SettingService{}
|
||||
port, err := svc.GetPort()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPort failed: %v", err)
|
||||
}
|
||||
if port != 8111 {
|
||||
t.Fatalf("expected migrated port 8111, got %d", port)
|
||||
}
|
||||
|
||||
if err := svc.SetPort(9001); err != nil {
|
||||
t.Fatalf("SetPort failed: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := database.GetAppSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAppSettings failed: %v", err)
|
||||
}
|
||||
if cfg.WebPort != 9001 {
|
||||
t.Fatalf("expected typed settings WebPort=9001, got %d", cfg.WebPort)
|
||||
}
|
||||
|
||||
var legacy model.Setting
|
||||
if err := database.GetDB().Model(&model.Setting{}).Where("key = ?", "webPort").First(&legacy).Error; err != nil {
|
||||
t.Fatalf("read legacy webPort row: %v", err)
|
||||
}
|
||||
if legacy.Value != "9001" {
|
||||
t.Fatalf("expected legacy shadow write webPort=9001, got %s", legacy.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingServiceXrayTemplateFallback(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "setting-template.db")
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
defer func() { _ = database.CloseDB() }()
|
||||
|
||||
svc := &SettingService{}
|
||||
template, err := svc.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfigTemplate failed: %v", err)
|
||||
}
|
||||
if template == "" {
|
||||
t.Fatalf("expected embedded xray template fallback, got empty string")
|
||||
}
|
||||
}
|
||||
37
web/service/settings_repository.go
Normal file
37
web/service/settings_repository.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
)
|
||||
|
||||
// settingsRepository encapsulates typed settings row access.
|
||||
type settingsRepository struct{}
|
||||
|
||||
func (r *settingsRepository) getOrCreate() (*model.AppSettings, error) {
|
||||
return database.GetOrCreateAppSettings(xrayTemplateConfig)
|
||||
}
|
||||
|
||||
func (r *settingsRepository) get(key string) (value string, recognized bool, err error) {
|
||||
cfg, err := r.getOrCreate()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
value, recognized, err = cfg.GetByKey(key)
|
||||
return value, recognized, err
|
||||
}
|
||||
|
||||
func (r *settingsRepository) set(key string, value string) (recognized bool, err error) {
|
||||
cfg, err := r.getOrCreate()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
recognized, err = cfg.SetByKey(key, value)
|
||||
if err != nil {
|
||||
return recognized, err
|
||||
}
|
||||
if !recognized {
|
||||
return false, nil
|
||||
}
|
||||
return true, database.SaveAppSettings(cfg)
|
||||
}
|
||||
Loading…
Reference in a new issue