♻️ Refactoring configuration migration service

This commit is contained in:
2025-08-31 17:48:41 +08:00
parent fa72ff8061
commit 5f22ee3b1f
15 changed files with 520 additions and 300 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -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,
}, },
} }
} }

View File

@@ -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("", &currentConfig); 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)
}

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

View File

@@ -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()

View File

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

View File

@@ -0,0 +1,4 @@
package version
// Version 版本注入 Ldflags
var Version = "0.0.0"

73
scripts/version.bat Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
VERSION=1.3.5