feat(settings): migrate to typed app_settings with legacy fallback

This commit is contained in:
Mohamadhosein Moazennia 2026-02-20 11:19:48 +03:30
parent 6b6d92e67e
commit f3ac4bef4c
7 changed files with 582 additions and 158 deletions

View 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
}

View 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")
}
}

View file

@ -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 {

View 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())
}
}

View file

@ -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 {

View 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")
}
}

View 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)
}