♻️ Refactoring configuration migration service
This commit is contained in:
12
Taskfile.yml
12
Taskfile.yml
@@ -12,13 +12,25 @@ vars:
|
|||||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
version:
|
||||||
|
summary: Generate version information
|
||||||
|
cmds:
|
||||||
|
- '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}'
|
||||||
|
sources:
|
||||||
|
- scripts/version.bat
|
||||||
|
- scripts/version.sh
|
||||||
|
generates:
|
||||||
|
- version.txt
|
||||||
|
|
||||||
build:
|
build:
|
||||||
summary: Builds the application
|
summary: Builds the application
|
||||||
|
deps: [version]
|
||||||
cmds:
|
cmds:
|
||||||
- task: "{{OS}}:build"
|
- task: "{{OS}}:build"
|
||||||
|
|
||||||
package:
|
package:
|
||||||
summary: Packages a production build of the application
|
summary: Packages a production build of the application
|
||||||
|
deps: [version]
|
||||||
cmds:
|
cmds:
|
||||||
- task: "{{OS}}:package"
|
- task: "{{OS}}:package"
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ tasks:
|
|||||||
- task: common:build:frontend
|
- task: common:build:frontend
|
||||||
- task: common:generate:icons
|
- task: common:generate:icons
|
||||||
cmds:
|
cmds:
|
||||||
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.OUTPUT}}
|
||||||
vars:
|
vars:
|
||||||
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}'
|
||||||
|
VERSION_FLAGS:
|
||||||
|
sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"'
|
||||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ tasks:
|
|||||||
- task: common:build:frontend
|
- task: common:build:frontend
|
||||||
- task: common:generate:icons
|
- task: common:generate:icons
|
||||||
cmds:
|
cmds:
|
||||||
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
|
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}
|
||||||
vars:
|
vars:
|
||||||
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}'
|
||||||
|
VERSION_FLAGS:
|
||||||
|
sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"'
|
||||||
env:
|
env:
|
||||||
GOOS: linux
|
GOOS: linux
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ tasks:
|
|||||||
- task: common:generate:icons
|
- task: common:generate:icons
|
||||||
cmds:
|
cmds:
|
||||||
- task: generate:syso
|
- task: generate:syso
|
||||||
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
|
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
|
||||||
- cmd: powershell Remove-item *.syso
|
- cmd: powershell Remove-item *.syso
|
||||||
platforms: [windows]
|
platforms: [windows]
|
||||||
- cmd: rm -f *.syso
|
- cmd: rm -f *.syso
|
||||||
platforms: [linux, darwin]
|
platforms: [linux, darwin]
|
||||||
vars:
|
vars:
|
||||||
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s -H windowsgui{{else}}{{end}}'
|
||||||
|
VERSION_FLAGS:
|
||||||
|
sh: 'powershell -Command "(Get-Content version.txt) -replace ''VERSION='', ''-X voidraft/internal/version.Version=''"'
|
||||||
env:
|
env:
|
||||||
GOOS: windows
|
GOOS: windows
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
|
|||||||
return $typingPromise;
|
return $typingPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetConfigDir 获取配置目录
|
||||||
|
*/
|
||||||
|
export function GetConfigDir(): Promise<string> & { cancel(): void } {
|
||||||
|
let $resultPromise = $Call.ByID(2275626561) as any;
|
||||||
|
return $resultPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetSettingsPath 获取设置文件路径
|
||||||
|
*/
|
||||||
|
export function GetSettingsPath(): Promise<string> & { cancel(): void } {
|
||||||
|
let $resultPromise = $Call.ByID(2175583370) as any;
|
||||||
|
return $resultPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResetConfig 强制重置所有配置为默认值
|
* ResetConfig 强制重置所有配置为默认值
|
||||||
*/
|
*/
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -3,7 +3,6 @@ module voidraft
|
|||||||
go 1.24.4
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/semver/v3 v3.4.0
|
|
||||||
github.com/creativeprojects/go-selfupdate v1.5.0
|
github.com/creativeprojects/go-selfupdate v1.5.0
|
||||||
github.com/go-git/go-git/v5 v5.16.2
|
github.com/go-git/go-git/v5 v5.16.2
|
||||||
github.com/knadh/koanf/parsers/json v1.0.0
|
github.com/knadh/koanf/parsers/json v1.0.0
|
||||||
@@ -11,6 +10,7 @@ require (
|
|||||||
github.com/knadh/koanf/providers/structs v1.0.0
|
github.com/knadh/koanf/providers/structs v1.0.0
|
||||||
github.com/knadh/koanf/v2 v2.2.2
|
github.com/knadh/koanf/v2 v2.2.2
|
||||||
github.com/robertkrimen/otto v0.5.1
|
github.com/robertkrimen/otto v0.5.1
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.25
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.25
|
||||||
golang.org/x/net v0.43.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sys v0.35.0
|
golang.org/x/sys v0.35.0
|
||||||
@@ -22,12 +22,14 @@ require (
|
|||||||
code.gitea.io/sdk/gitea v0.21.0 // indirect
|
code.gitea.io/sdk/gitea v0.21.0 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/42wim/httpsig v1.2.3 // indirect
|
github.com/42wim/httpsig v1.2.3 // indirect
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
@@ -62,6 +64,7 @@ require (
|
|||||||
github.com/pjbgf/sha1cd v0.4.0 // indirect
|
github.com/pjbgf/sha1cd v0.4.0 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.51.0 // indirect
|
github.com/samber/lo v1.51.0 // indirect
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
"voidraft/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TabType 定义了制表符类型
|
// TabType 定义了制表符类型
|
||||||
@@ -178,7 +179,7 @@ func NewDefaultAppConfig() *AppConfig {
|
|||||||
SystemTheme: SystemThemeAuto,
|
SystemTheme: SystemThemeAuto,
|
||||||
},
|
},
|
||||||
Updates: UpdatesConfig{
|
Updates: UpdatesConfig{
|
||||||
Version: "1.3.0",
|
Version: version.Version,
|
||||||
AutoUpdate: true,
|
AutoUpdate: true,
|
||||||
PrimarySource: UpdateSourceGitea,
|
PrimarySource: UpdateSourceGitea,
|
||||||
BackupSource: UpdateSourceGithub,
|
BackupSource: UpdateSourceGithub,
|
||||||
@@ -207,7 +208,7 @@ func NewDefaultAppConfig() *AppConfig {
|
|||||||
},
|
},
|
||||||
Metadata: ConfigMetadata{
|
Metadata: ConfigMetadata{
|
||||||
LastUpdated: time.Now().Format(time.RFC3339),
|
LastUpdated: time.Now().Format(time.RFC3339),
|
||||||
Version: "1.2.0",
|
Version: version.Version,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,318 +1,195 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
"voidraft/internal/models"
|
|
||||||
|
|
||||||
"github.com/Masterminds/semver/v3"
|
|
||||||
jsonparser "github.com/knadh/koanf/parsers/json"
|
jsonparser "github.com/knadh/koanf/parsers/json"
|
||||||
"github.com/knadh/koanf/providers/structs"
|
"github.com/knadh/koanf/providers/structs"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// CurrentAppConfigVersion 当前应用配置版本
|
// BackupFilePattern backup file name pattern
|
||||||
CurrentAppConfigVersion = "1.3.0"
|
|
||||||
// BackupFilePattern 备份文件名模式
|
|
||||||
BackupFilePattern = "%s.backup.%s.json"
|
BackupFilePattern = "%s.backup.%s.json"
|
||||||
|
// MaxConfigFileSize maximum config file size
|
||||||
// 资源限制常量
|
|
||||||
MaxConfigFileSize = 10 * 1024 * 1024 // 10MB
|
MaxConfigFileSize = 10 * 1024 * 1024 // 10MB
|
||||||
MaxRecursionDepth = 50 // 最大递归深度
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Migratable 可迁移的配置接口
|
// ConfigMigrator elegant configuration migrator with automatic field detection
|
||||||
type Migratable interface {
|
type ConfigMigrator struct {
|
||||||
GetVersion() string // 获取当前版本
|
logger *log.LogService
|
||||||
SetVersion(string) // 设置版本
|
configDir string
|
||||||
SetLastUpdated(string) // 设置最后更新时间
|
configName string
|
||||||
GetDefaultConfig() any // 获取默认配置
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigMigrationService 配置迁移服务
|
// MigrationResult migration operation result
|
||||||
type ConfigMigrationService[T Migratable] struct {
|
|
||||||
logger *log.LogService
|
|
||||||
configDir string
|
|
||||||
configName string
|
|
||||||
targetVersion string
|
|
||||||
configPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrationResult 迁移结果
|
|
||||||
type MigrationResult struct {
|
type MigrationResult struct {
|
||||||
Migrated, ConfigUpdated bool
|
Migrated bool `json:"migrated"` // Whether migration was performed
|
||||||
FromVersion, ToVersion string
|
MissingFields []string `json:"missingFields"` // Fields that were missing
|
||||||
BackupPath string
|
BackupPath string `json:"backupPath"` // Path to backup file
|
||||||
|
Description string `json:"description"` // Description of migration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfigMigrationService 创建配置迁移服务
|
// NewConfigMigrator creates a new configuration migrator
|
||||||
func NewConfigMigrationService[T Migratable](
|
func NewConfigMigrator(
|
||||||
logger *log.LogService,
|
logger *log.LogService,
|
||||||
configDir string,
|
configDir string,
|
||||||
configName, targetVersion, configPath string,
|
configName, configPath string,
|
||||||
) *ConfigMigrationService[T] {
|
) *ConfigMigrator {
|
||||||
return &ConfigMigrationService[T]{
|
if logger == nil {
|
||||||
logger: orDefault(logger, log.New()),
|
logger = log.New()
|
||||||
configDir: configDir,
|
}
|
||||||
configName: configName,
|
return &ConfigMigrator{
|
||||||
targetVersion: targetVersion,
|
logger: logger,
|
||||||
configPath: configPath,
|
configDir: configDir,
|
||||||
|
configName: configName,
|
||||||
|
configPath: configPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateConfig 迁移配置文件
|
// AutoMigrate automatically detects and migrates missing configuration fields
|
||||||
func (cms *ConfigMigrationService[T]) MigrateConfig(existingConfig *koanf.Koanf) (*MigrationResult, error) {
|
func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) {
|
||||||
currentVersion := orDefault(existingConfig.String("metadata.version"), "0.0.0")
|
// Load default config into temporary koanf instance
|
||||||
result := &MigrationResult{
|
defaultKoanf := koanf.New(".")
|
||||||
FromVersion: currentVersion,
|
if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
|
||||||
ToVersion: cms.targetVersion,
|
return nil, fmt.Errorf("failed to load default config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsMigration, err := cms.needsMigration(currentVersion); err != nil {
|
// Detect missing fields
|
||||||
return result, fmt.Errorf("version comparison failed: %w", err)
|
missingFields := cm.detectMissingFields(currentConfig.All(), defaultKoanf.All())
|
||||||
} else if !needsMigration {
|
|
||||||
|
// Create result object
|
||||||
|
result := &MigrationResult{
|
||||||
|
MissingFields: missingFields,
|
||||||
|
Migrated: len(missingFields) > 0,
|
||||||
|
Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no missing fields, return early
|
||||||
|
if !result.Migrated {
|
||||||
|
cm.logger.Info("No missing configuration fields detected")
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 资源检查和备份
|
// Only create backup if we actually need to migrate (has missing fields)
|
||||||
if err := cms.checkResourceLimits(); err != nil {
|
if len(missingFields) > 0 {
|
||||||
return result, fmt.Errorf("resource limit check failed: %w", err)
|
if backupPath, err := cm.createBackup(); err != nil {
|
||||||
|
cm.logger.Error("Failed to create backup", "error", err)
|
||||||
|
} else {
|
||||||
|
result.BackupPath = backupPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if backupPath, err := cms.createBackupOptimized(); err != nil {
|
// Merge missing fields from default config
|
||||||
return result, fmt.Errorf("backup creation failed: %w", err)
|
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
|
||||||
} else {
|
return result, fmt.Errorf("failed to merge default fields: %w", err)
|
||||||
result.BackupPath = backupPath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动恢复检查
|
// Save updated config
|
||||||
cms.tryQuickRecovery(existingConfig)
|
if err := cm.saveConfig(currentConfig); err != nil {
|
||||||
|
return result, fmt.Errorf("failed to save updated config: %w", err)
|
||||||
// 执行迁移
|
|
||||||
if configUpdated, err := cms.performOptimizedMigration(existingConfig); err != nil {
|
|
||||||
return result, fmt.Errorf("migration failed: %w", err)
|
|
||||||
} else {
|
|
||||||
result.Migrated = true
|
|
||||||
result.ConfigUpdated = configUpdated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// needsMigration 检查是否需要迁移
|
// detectMissingFields detects missing configuration fields
|
||||||
func (cms *ConfigMigrationService[T]) needsMigration(current string) (bool, error) {
|
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
|
||||||
currentVer, err := semver.NewVersion(current)
|
var missingFields []string
|
||||||
if err != nil {
|
cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields)
|
||||||
return true, nil
|
return missingFields
|
||||||
}
|
|
||||||
targetVer, err := semver.NewVersion(cms.targetVersion)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("invalid target version: %s", cms.targetVersion)
|
|
||||||
}
|
|
||||||
return currentVer.LessThan(targetVer), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkResourceLimits 检查资源限制
|
// findMissingFieldsRecursive recursively finds missing fields
|
||||||
func (cms *ConfigMigrationService[T]) checkResourceLimits() error {
|
func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
|
||||||
if info, err := os.Stat(cms.configPath); err == nil && info.Size() > MaxConfigFileSize {
|
for key, defaultVal := range defaultMap {
|
||||||
return fmt.Errorf("config file size (%d bytes) exceeds limit (%d bytes)", info.Size(), MaxConfigFileSize)
|
fullKey := key
|
||||||
|
if prefix != "" {
|
||||||
|
fullKey = prefix + "." + key
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVal, exists := currentMap[key]
|
||||||
|
if !exists {
|
||||||
|
// Field is completely missing
|
||||||
|
*missing = append(*missing, fullKey)
|
||||||
|
} else {
|
||||||
|
// Check nested structures
|
||||||
|
if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
||||||
|
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
|
||||||
|
cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing)
|
||||||
|
} else {
|
||||||
|
// Current value is not a map but default is, structure mismatch
|
||||||
|
*missing = append(*missing, fullKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeDefaultFields merges default values for missing fields into current config
|
||||||
|
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
|
||||||
|
for _, field := range missingFields {
|
||||||
|
defaultValue := defaultConfig.Get(field)
|
||||||
|
if defaultValue != nil {
|
||||||
|
current.Set(field, defaultValue)
|
||||||
|
cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last modified timestamp
|
||||||
|
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createBackupOptimized 优化的备份创建
|
// createBackup creates a backup of the configuration file
|
||||||
func (cms *ConfigMigrationService[T]) createBackupOptimized() (string, error) {
|
func (cm *ConfigMigrator) createBackup() (string, error) {
|
||||||
if _, err := os.Stat(cms.configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := cms.configDir
|
|
||||||
timestamp := time.Now().Format("20060102150405")
|
timestamp := time.Now().Format("20060102150405")
|
||||||
newBackupPath := filepath.Join(configDir, fmt.Sprintf(BackupFilePattern, cms.configName, timestamp))
|
backupPath := filepath.Join(cm.configDir, fmt.Sprintf(BackupFilePattern, cm.configName, timestamp))
|
||||||
|
|
||||||
// 单次扫描:删除旧备份并创建新备份
|
data, err := os.ReadFile(cm.configPath)
|
||||||
pattern := filepath.Join(configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
|
|
||||||
if matches, err := filepath.Glob(pattern); err == nil {
|
|
||||||
for _, oldBackup := range matches {
|
|
||||||
if oldBackup != newBackupPath {
|
|
||||||
os.Remove(oldBackup) // 忽略删除错误,继续处理
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newBackupPath, copyFile(cms.configPath, newBackupPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryQuickRecovery 快速恢复检查
|
|
||||||
func (cms *ConfigMigrationService[T]) tryQuickRecovery(existingConfig *koanf.Koanf) {
|
|
||||||
var testConfig T
|
|
||||||
if existingConfig.Unmarshal("", &testConfig) != nil {
|
|
||||||
cms.logger.Info("Config appears corrupted, attempting quick recovery")
|
|
||||||
if backupPath := cms.findLatestBackupQuick(); backupPath != "" {
|
|
||||||
if data, err := os.ReadFile(backupPath); err == nil {
|
|
||||||
existingConfig.Delete("")
|
|
||||||
existingConfig.Load(&BytesProvider{data}, jsonparser.Parser())
|
|
||||||
cms.logger.Info("Quick recovery successful")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLatestBackupQuick 快速查找最新备份(优化排序)
|
|
||||||
func (cms *ConfigMigrationService[T]) findLatestBackupQuick() string {
|
|
||||||
pattern := filepath.Join(cms.configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err != nil || len(matches) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sort.Strings(matches) // 字典序排序,时间戳格式确保正确性
|
|
||||||
return matches[len(matches)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// performOptimizedMigration 优化的迁移执行
|
|
||||||
func (cms *ConfigMigrationService[T]) performOptimizedMigration(existingConfig *koanf.Koanf) (bool, error) {
|
|
||||||
// 直接从koanf实例获取配置,避免额外序列化
|
|
||||||
var currentConfig T
|
|
||||||
if err := existingConfig.Unmarshal("", ¤tConfig); err != nil {
|
|
||||||
return false, fmt.Errorf("unmarshal existing config failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig, ok := currentConfig.GetDefaultConfig().(T)
|
|
||||||
if !ok {
|
|
||||||
return false, fmt.Errorf("default config type mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
return cms.mergeInPlace(existingConfig, currentConfig, defaultConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeInPlace 就地合并配置
|
|
||||||
func (cms *ConfigMigrationService[T]) mergeInPlace(existingConfig *koanf.Koanf, currentConfig, defaultConfig T) (bool, error) {
|
|
||||||
// 创建临时合并实例
|
|
||||||
mergeKoanf := koanf.New(".")
|
|
||||||
|
|
||||||
// 使用快速加载链
|
|
||||||
if err := chainLoad(mergeKoanf,
|
|
||||||
func() error { return mergeKoanf.Load(structs.Provider(defaultConfig, "json"), nil) },
|
|
||||||
func() error {
|
|
||||||
return mergeKoanf.Load(structs.Provider(currentConfig, "json"), nil,
|
|
||||||
koanf.WithMergeFunc(cms.fastMerge))
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
return false, fmt.Errorf("config merge failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新元数据
|
|
||||||
mergeKoanf.Set("metadata.version", cms.targetVersion)
|
|
||||||
mergeKoanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
|
||||||
|
|
||||||
// 一次性序列化和原子写入
|
|
||||||
configBytes, err := mergeKoanf.Marshal(jsonparser.Parser())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("marshal config failed: %w", err)
|
return "", fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(backupPath, data, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create backup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.logger.Info("Configuration backup created", "path", backupPath)
|
||||||
|
return backupPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveConfig saves configuration to file
|
||||||
|
func (cm *ConfigMigrator) saveConfig(config *koanf.Koanf) error {
|
||||||
|
configBytes, err := config.Marshal(jsonparser.Parser())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(configBytes) > MaxConfigFileSize {
|
if len(configBytes) > MaxConfigFileSize {
|
||||||
return false, fmt.Errorf("merged config size exceeds limit")
|
return fmt.Errorf("config size (%d bytes) exceeds limit (%d bytes)", len(configBytes), MaxConfigFileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原子写入
|
// Atomic write
|
||||||
return true, cms.atomicWrite(existingConfig, configBytes)
|
tempPath := cm.configPath + ".tmp"
|
||||||
}
|
|
||||||
|
|
||||||
// atomicWrite 原子写入操作
|
|
||||||
func (cms *ConfigMigrationService[T]) atomicWrite(existingConfig *koanf.Koanf, configBytes []byte) error {
|
|
||||||
tempPath := cms.configPath + ".tmp"
|
|
||||||
|
|
||||||
if err := os.WriteFile(tempPath, configBytes, 0644); err != nil {
|
if err := os.WriteFile(tempPath, configBytes, 0644); err != nil {
|
||||||
return fmt.Errorf("write temp config failed: %w", err)
|
return fmt.Errorf("failed to write temp config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Rename(tempPath, cms.configPath); err != nil {
|
if err := os.Rename(tempPath, cm.configPath); err != nil {
|
||||||
os.Remove(tempPath)
|
os.Remove(tempPath)
|
||||||
return fmt.Errorf("atomic rename failed: %w", err)
|
return fmt.Errorf("failed to rename temp config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载到原实例
|
|
||||||
existingConfig.Delete("")
|
|
||||||
return existingConfig.Load(&BytesProvider{configBytes}, jsonparser.Parser())
|
|
||||||
}
|
|
||||||
|
|
||||||
// fastMerge 快速合并函数
|
|
||||||
func (cms *ConfigMigrationService[T]) fastMerge(src, dest map[string]interface{}) error {
|
|
||||||
return cms.fastMergeRecursive(src, dest, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fastMergeRecursive 快速递归合并
|
|
||||||
func (cms *ConfigMigrationService[T]) fastMergeRecursive(src, dest map[string]interface{}, depth int) error {
|
|
||||||
if depth > MaxRecursionDepth {
|
|
||||||
return fmt.Errorf("recursion depth exceeded")
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, srcVal := range src {
|
|
||||||
if destVal, exists := dest[key]; exists {
|
|
||||||
// 优先检查map类型(最常见情况)
|
|
||||||
if srcMap, srcOK := srcVal.(map[string]interface{}); srcOK {
|
|
||||||
if destMap, destOK := destVal.(map[string]interface{}); destOK {
|
|
||||||
if err := cms.fastMergeRecursive(srcMap, destMap, depth+1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 快速空值检查(避免反射)
|
|
||||||
if srcVal == nil || srcVal == "" || srcVal == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dest[key] = srcVal
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BytesProvider 轻量字节提供器
|
|
||||||
type BytesProvider struct{ data []byte }
|
|
||||||
|
|
||||||
func (bp *BytesProvider) ReadBytes() ([]byte, error) { return bp.data, nil }
|
|
||||||
func (bp *BytesProvider) Read() (map[string]interface{}, error) {
|
|
||||||
var result map[string]interface{}
|
|
||||||
return result, json.Unmarshal(bp.data, &result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
func orDefault[T any](value, defaultValue T) T {
|
|
||||||
var zero T
|
|
||||||
if reflect.DeepEqual(value, zero) {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
data, err := os.ReadFile(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(dst, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func chainLoad(k *koanf.Koanf, loaders ...func() error) error {
|
|
||||||
for _, loader := range loaders {
|
|
||||||
if err := loader(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工厂函数
|
|
||||||
func NewAppConfigMigrationService(logger *log.LogService, configDir, settingsPath string) *ConfigMigrationService[*models.AppConfig] {
|
|
||||||
return NewConfigMigrationService[*models.AppConfig](
|
|
||||||
logger, configDir, "settings", CurrentAppConfigVersion, settingsPath)
|
|
||||||
}
|
|
||||||
|
|||||||
167
internal/services/config_migrator_test.go
Normal file
167
internal/services/config_migrator_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
jsonparser "github.com/knadh/koanf/parsers/json"
|
||||||
|
"github.com/knadh/koanf/providers/file"
|
||||||
|
"github.com/knadh/koanf/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConfig represents a simplified config structure for testing
|
||||||
|
type TestConfig struct {
|
||||||
|
App struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
} `json:"app"`
|
||||||
|
User struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Settings struct {
|
||||||
|
AutoSave bool `json:"autoSave"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
NewSetting bool `json:"newSetting"` // This field will be missing in old config
|
||||||
|
NewSetting2 string `json:"newSetting2"` // This field will be missing in old config
|
||||||
|
} `json:"settings"`
|
||||||
|
} `json:"user"`
|
||||||
|
NewSection struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"newSection"` // This entire section will be missing in old config
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestConfig creates a test configuration file
|
||||||
|
func createTestConfig(t *testing.T, tempDir string) string {
|
||||||
|
// Old config without some fields
|
||||||
|
oldConfig := map[string]interface{}{
|
||||||
|
"app": map[string]interface{}{
|
||||||
|
"name": "TestApp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"theme": "dark",
|
||||||
|
},
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"settings": map[string]interface{}{
|
||||||
|
"autoSave": true,
|
||||||
|
"language": "en",
|
||||||
|
// Missing newSetting and newSetting2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Missing newSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config file
|
||||||
|
configPath := filepath.Join(tempDir, "config.json")
|
||||||
|
jsonData, err := json.MarshalIndent(oldConfig, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(configPath, jsonData, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrator_AutoMigrate tests the ConfigMigrator's AutoMigrate functionality
|
||||||
|
func TestConfigMigrator_AutoMigrate(t *testing.T) {
|
||||||
|
// Create temp directory for test
|
||||||
|
tempDir, err := os.MkdirTemp("", "config_migrator_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create test config file
|
||||||
|
configPath := createTestConfig(t, tempDir)
|
||||||
|
|
||||||
|
// Create logger
|
||||||
|
logger := log.New()
|
||||||
|
|
||||||
|
// Create config migrator
|
||||||
|
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
|
||||||
|
|
||||||
|
// Create koanf instance and load the config
|
||||||
|
k := koanf.New(".")
|
||||||
|
fileProvider := file.Provider(configPath)
|
||||||
|
jsonParser := jsonparser.Parser()
|
||||||
|
if err := k.Load(fileProvider, jsonParser); err != nil {
|
||||||
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default config with all fields
|
||||||
|
defaultConfig := TestConfig{}
|
||||||
|
defaultConfig.App.Name = "TestApp"
|
||||||
|
defaultConfig.App.Version = "1.0.0"
|
||||||
|
defaultConfig.App.Theme = "dark"
|
||||||
|
defaultConfig.User.Name = "Test User"
|
||||||
|
defaultConfig.User.Email = "test@example.com"
|
||||||
|
defaultConfig.User.Settings.AutoSave = true
|
||||||
|
defaultConfig.User.Settings.Language = "en"
|
||||||
|
defaultConfig.User.Settings.NewSetting = true // New field
|
||||||
|
defaultConfig.User.Settings.NewSetting2 = "value" // New field
|
||||||
|
defaultConfig.NewSection.Enabled = true // New section
|
||||||
|
defaultConfig.NewSection.Value = "new section" // New section
|
||||||
|
|
||||||
|
// Run auto migration
|
||||||
|
result, err := migrator.AutoMigrate(defaultConfig, k)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
assert.True(t, result.Migrated, "Migration should have been performed")
|
||||||
|
|
||||||
|
// 打印检测到的缺失字段,便于分析
|
||||||
|
t.Logf("Detected fields: %v", result.MissingFields)
|
||||||
|
|
||||||
|
// 验证检测到了正确数量的字段 - 实际检测到4个
|
||||||
|
assert.Equal(t, 4, len(result.MissingFields), "Should have detected 4 missing fields")
|
||||||
|
|
||||||
|
// 期望检测到的缺失字段
|
||||||
|
expectedFields := map[string]bool{
|
||||||
|
"user.settings.newSetting": true,
|
||||||
|
"user.settings.newSetting2": true,
|
||||||
|
"newSection.enabled": true,
|
||||||
|
"newSection.value": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有预期的字段都被检测到了
|
||||||
|
for _, field := range result.MissingFields {
|
||||||
|
_, expected := expectedFields[field]
|
||||||
|
assert.True(t, expected, "Field %s was detected but not expected", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有检测到的字段都在预期之内
|
||||||
|
for expectedField := range expectedFields {
|
||||||
|
found := false
|
||||||
|
for _, field := range result.MissingFields {
|
||||||
|
if field == expectedField {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Expected field %s was not detected", expectedField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the fields were actually added to the config
|
||||||
|
assert.True(t, k.Bool("user.settings.newSetting"), "newSetting should be added with correct value")
|
||||||
|
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "newSetting2 should be added with correct value")
|
||||||
|
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
|
||||||
|
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
|
||||||
|
|
||||||
|
// Check that backup was created
|
||||||
|
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list backup files: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, len(backupFiles), "One backup file should have been created")
|
||||||
|
}
|
||||||
@@ -288,25 +288,6 @@ func deepCopyValue(src, dst reflect.Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deepCopyConfig 保留原有的JSON深拷贝方法作为备用
|
|
||||||
func deepCopyConfig(src *models.AppConfig) *models.AppConfig {
|
|
||||||
if src == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(src)
|
|
||||||
if err != nil {
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
var dst models.AppConfig
|
|
||||||
if err := json.Unmarshal(jsonBytes, &dst); err != nil {
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
return &dst
|
|
||||||
}
|
|
||||||
|
|
||||||
// debounceNotify 防抖通知
|
// debounceNotify 防抖通知
|
||||||
func (cns *ConfigNotificationService) debounceNotify(listener *ConfigListener, oldConfig, newConfig *models.AppConfig) {
|
func (cns *ConfigNotificationService) debounceNotify(listener *ConfigListener, oldConfig, newConfig *models.AppConfig) {
|
||||||
listener.mu.Lock()
|
listener.mu.Lock()
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ type ConfigService struct {
|
|||||||
|
|
||||||
// 配置通知服务
|
// 配置通知服务
|
||||||
notificationService *ConfigNotificationService
|
notificationService *ConfigNotificationService
|
||||||
// 配置迁移服务
|
|
||||||
migrationService *ConfigMigrationService[*models.AppConfig]
|
// 配置迁移器
|
||||||
|
configMigrator *ConfigMigrator
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigError 配置错误
|
// ConfigError 配置错误
|
||||||
@@ -67,16 +68,18 @@ func NewConfigService(logger *log.LogService) *ConfigService {
|
|||||||
settingsPath := filepath.Join(configDir, "settings.json")
|
settingsPath := filepath.Join(configDir, "settings.json")
|
||||||
|
|
||||||
cs := &ConfigService{
|
cs := &ConfigService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
settingsPath: settingsPath,
|
settingsPath: settingsPath,
|
||||||
koanf: koanf.New("."),
|
koanf: koanf.New("."),
|
||||||
migrationService: NewAppConfigMigrationService(logger, configDir, settingsPath),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化配置通知服务
|
// 初始化配置通知服务
|
||||||
cs.notificationService = NewConfigNotificationService(cs.koanf, logger)
|
cs.notificationService = NewConfigNotificationService(cs.koanf, logger)
|
||||||
|
|
||||||
|
// 初始化配置迁移器
|
||||||
|
cs.configMigrator = NewConfigMigrator(logger, configDir, "settings", settingsPath)
|
||||||
|
|
||||||
cs.initConfig()
|
cs.initConfig()
|
||||||
|
|
||||||
// 启动配置文件监听
|
// 启动配置文件监听
|
||||||
@@ -106,23 +109,36 @@ func (cs *ConfigService) initConfig() error {
|
|||||||
return cs.createDefaultConfig()
|
return cs.createDefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置文件存在,先加载现有配置
|
// 检查并自动迁移配置
|
||||||
|
cs.checkConfigMigration()
|
||||||
|
|
||||||
|
// 配置文件存在,直接加载现有配置
|
||||||
cs.fileProvider = file.Provider(cs.settingsPath)
|
cs.fileProvider = file.Provider(cs.settingsPath)
|
||||||
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
|
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
|
||||||
return &ConfigError{Operation: "load_config_file", Err: err}
|
return &ConfigError{Operation: "load_config_file", Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查并执行配置迁移
|
return nil
|
||||||
if cs.migrationService != nil {
|
}
|
||||||
result, err := cs.migrationService.MigrateConfig(cs.koanf)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigError{Operation: "migrate_config", Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Migrated && result.ConfigUpdated {
|
// checkConfigMigration 检查配置迁移
|
||||||
// 迁移完成且配置已更新,重新创建文件提供器以监听新文件
|
func (cs *ConfigService) checkConfigMigration() error {
|
||||||
cs.fileProvider = file.Provider(cs.settingsPath)
|
if cs.configMigrator == nil {
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig := models.NewDefaultAppConfig()
|
||||||
|
result, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cs.logger.Error("Failed to check config migration", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && result.Migrated {
|
||||||
|
cs.logger.Info("Config migration performed",
|
||||||
|
"fields", result.MissingFields,
|
||||||
|
"backup", result.BackupPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -326,3 +342,13 @@ func (cs *ConfigService) ServiceShutdown() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfigDir 获取配置目录
|
||||||
|
func (cs *ConfigService) GetConfigDir() string {
|
||||||
|
return cs.configDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettingsPath 获取设置文件路径
|
||||||
|
func (cs *ConfigService) GetSettingsPath() string {
|
||||||
|
return cs.settingsPath
|
||||||
|
}
|
||||||
|
|||||||
4
internal/version/version.go
Normal file
4
internal/version/version.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
// Version 版本注入 Ldflags
|
||||||
|
var Version = "0.0.0"
|
||||||
73
scripts/version.bat
Normal file
73
scripts/version.bat
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
REM Simplified version management script - Windows version
|
||||||
|
REM Auto-increment patch version from git tags or use custom version
|
||||||
|
|
||||||
|
REM Configuration section - Set custom version here if needed
|
||||||
|
set "CUSTOM_VERSION="
|
||||||
|
REM Example: set "CUSTOM_VERSION=2.0.0"
|
||||||
|
|
||||||
|
set "VERSION_FILE=version.txt"
|
||||||
|
|
||||||
|
REM Check if custom version is set
|
||||||
|
if not "%CUSTOM_VERSION%"=="" (
|
||||||
|
echo [INFO] Using custom version: %CUSTOM_VERSION%
|
||||||
|
set "VERSION=%CUSTOM_VERSION%"
|
||||||
|
goto :save_version
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check if git is available
|
||||||
|
git --version >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Git is not installed or not in PATH
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check if in git repository
|
||||||
|
git rev-parse --git-dir >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Not in a git repository
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Get latest git tag
|
||||||
|
git describe --abbrev=0 --tags > temp_tag.txt 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] No git tags found in repository
|
||||||
|
if exist temp_tag.txt del temp_tag.txt
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set /p LATEST_TAG=<temp_tag.txt
|
||||||
|
del temp_tag.txt
|
||||||
|
|
||||||
|
if not defined LATEST_TAG (
|
||||||
|
echo [ERROR] Failed to read git tag
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Latest git tag: %LATEST_TAG%
|
||||||
|
|
||||||
|
REM Remove v prefix
|
||||||
|
set "CLEAN_VERSION=%LATEST_TAG:v=%"
|
||||||
|
|
||||||
|
REM Split version number and increment patch
|
||||||
|
for /f "tokens=1,2,3 delims=." %%a in ("%CLEAN_VERSION%") do (
|
||||||
|
set "MAJOR=%%a"
|
||||||
|
set "MINOR=%%b"
|
||||||
|
set /a "PATCH=%%c+1"
|
||||||
|
)
|
||||||
|
|
||||||
|
set "VERSION=%MAJOR%.%MINOR%.%PATCH%"
|
||||||
|
echo [INFO] Auto-incremented patch version: %VERSION%
|
||||||
|
|
||||||
|
:save_version
|
||||||
|
REM Output version information
|
||||||
|
echo [SUCCESS] Version resolved: %VERSION%
|
||||||
|
echo VERSION=%VERSION%
|
||||||
|
|
||||||
|
REM Save to file
|
||||||
|
echo VERSION=%VERSION% > %VERSION_FILE%
|
||||||
|
|
||||||
|
echo [INFO] Version information saved to %VERSION_FILE%
|
||||||
50
scripts/version.sh
Normal file
50
scripts/version.sh
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 配置区域 - 如需自定义版本,请在此处设置
|
||||||
|
CUSTOM_VERSION=""
|
||||||
|
# 示例: CUSTOM_VERSION="2.0.0"
|
||||||
|
|
||||||
|
VERSION_FILE="version.txt"
|
||||||
|
|
||||||
|
# 检查是否设置了自定义版本
|
||||||
|
if [ -n "$CUSTOM_VERSION" ]; then
|
||||||
|
echo "[INFO] Using custom version: $CUSTOM_VERSION"
|
||||||
|
VERSION="$CUSTOM_VERSION"
|
||||||
|
else
|
||||||
|
# 检查git是否可用
|
||||||
|
if ! command -v git &> /dev/null; then
|
||||||
|
echo "[ERROR] Git is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
elif ! git rev-parse --git-dir &> /dev/null; then
|
||||||
|
echo "[ERROR] Not in a git repository"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
# 获取最新的git标签
|
||||||
|
LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
|
echo "[ERROR] No git tags found in repository"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "[INFO] Latest git tag: $LATEST_TAG"
|
||||||
|
|
||||||
|
# 移除v前缀
|
||||||
|
CLEAN_VERSION=${LATEST_TAG#v}
|
||||||
|
|
||||||
|
# 分割版本号并递增patch版本
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN_VERSION"
|
||||||
|
PATCH=$((PATCH + 1))
|
||||||
|
|
||||||
|
VERSION="$MAJOR.$MINOR.$PATCH"
|
||||||
|
echo "[INFO] Auto-incremented patch version: $VERSION"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 输出版本信息
|
||||||
|
echo "VERSION=$VERSION"
|
||||||
|
|
||||||
|
# 保存到文件供其他脚本使用
|
||||||
|
echo "VERSION=$VERSION" > "$VERSION_FILE"
|
||||||
|
|
||||||
|
echo "[INFO] Version information saved to $VERSION_FILE"
|
||||||
1
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VERSION=1.3.5
|
||||||
Reference in New Issue
Block a user