feat(settings): 支持自定义并持久化日志目录

- 将日志目录配置整合到数据目录设置,支持选择、打开、恢复默认与保存
- 持久化日志目录并保留环境变量优先级,目录调整后重启生效
- 通过跨进程锁和原子写入保护数据目录与日志目录配置
- 移除重复标题并互斥目录修改操作,补齐六语言文案
- 补充桌面接口、浏览器模拟及并发回归测试
This commit is contained in:
Syngnat
2026-07-23 11:40:17 +08:00
parent 5a5fbf04fe
commit 97cffa7809
28 changed files with 1459 additions and 38 deletions

View File

@@ -6,14 +6,20 @@ import (
"os"
"path/filepath"
"strings"
"sync"
)
const bootstrapFileName = "storage_root.json"
const (
bootstrapFileName = "storage_root.json"
bootstrapLockFileName = bootstrapFileName + ".lock"
configuredLogFileName = "gonavi.log"
)
const dataRootEnvName = "GONAVI_DATA_ROOT"
var (
ErrSetActiveRootCreateDataDirectory = errors.New("create data directory failed")
ErrSetActiveRootCreateBootstrapDirectory = errors.New("create bootstrap directory failed")
bootstrapConfigMu sync.Mutex
)
type setActiveRootError struct {
@@ -57,7 +63,97 @@ func SetActiveRootErrorDetail(err error) error {
}
type bootstrapConfig struct {
DataRoot string `json:"dataRoot"`
DataRoot string `json:"dataRoot,omitempty"`
LogDirectory string `json:"logDirectory,omitempty"`
}
func readBootstrapConfig() (bootstrapConfig, error) {
data, err := os.ReadFile(BootstrapPath())
if err != nil {
if os.IsNotExist(err) {
return bootstrapConfig{}, nil
}
return bootstrapConfig{}, err
}
var cfg bootstrapConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return bootstrapConfig{}, err
}
return cfg, nil
}
func writeBootstrapConfig(cfg bootstrapConfig) error {
cfg.DataRoot = strings.TrimSpace(cfg.DataRoot)
cfg.LogDirectory = strings.TrimSpace(cfg.LogDirectory)
if cfg.DataRoot == "" && cfg.LogDirectory == "" {
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.MkdirAll(DefaultRoot(), 0o755); err != nil {
return err
}
payload, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return writeBootstrapConfigAtomic(payload)
}
func writeBootstrapConfigAtomic(payload []byte) error {
temporary, err := os.CreateTemp(DefaultRoot(), ".storage_root-*.tmp")
if err != nil {
return err
}
temporaryPath := temporary.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(payload); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := atomicReplaceBootstrapFile(temporaryPath, BootstrapPath()); err != nil {
return err
}
cleanup = false
return nil
}
func updateBootstrapConfig(update func(*bootstrapConfig)) (err error) {
bootstrapConfigMu.Lock()
defer bootstrapConfigMu.Unlock()
if err := os.MkdirAll(DefaultRoot(), 0o755); err != nil {
return err
}
fileLock, err := acquireBootstrapFileLock(BootstrapLockPath())
if err != nil {
return err
}
defer func() {
err = errors.Join(err, fileLock.Close())
}()
cfg, err := readBootstrapConfig()
if err != nil {
return err
}
update(&cfg)
return writeBootstrapConfig(cfg)
}
func configuredRootOverride() string {
@@ -76,6 +172,10 @@ func BootstrapPath() string {
return filepath.Join(DefaultRoot(), bootstrapFileName)
}
func BootstrapLockPath() string {
return filepath.Join(DefaultRoot(), bootstrapLockFileName)
}
func normalizeRoot(root string) (string, error) {
trimmed := strings.TrimSpace(root)
if trimmed == "" {
@@ -100,15 +200,10 @@ func ResolveActiveRoot() (string, error) {
if err != nil {
return "", err
}
data, err := os.ReadFile(BootstrapPath())
bootstrapConfigMu.Lock()
cfg, err := readBootstrapConfig()
bootstrapConfigMu.Unlock()
if err != nil {
if os.IsNotExist(err) {
return defaultRoot, nil
}
return "", err
}
var cfg bootstrapConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return "", err
}
if strings.TrimSpace(cfg.DataRoot) == "" {
@@ -145,21 +240,66 @@ func SetActiveRoot(root string) (string, error) {
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
return "", newSetActiveRootError(ErrSetActiveRootCreateDataDirectory, err)
}
if targetRoot == defaultRoot {
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
return "", err
if targetRoot != defaultRoot {
if err := os.MkdirAll(defaultRoot, 0o755); err != nil {
return "", newSetActiveRootError(ErrSetActiveRootCreateBootstrapDirectory, err)
}
return defaultRoot, nil
}
if err := os.MkdirAll(defaultRoot, 0o755); err != nil {
return "", newSetActiveRootError(ErrSetActiveRootCreateBootstrapDirectory, err)
}
payload, err := json.MarshalIndent(bootstrapConfig{DataRoot: targetRoot}, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(BootstrapPath(), payload, 0o644); err != nil {
if err := updateBootstrapConfig(func(cfg *bootstrapConfig) {
if targetRoot == defaultRoot {
cfg.DataRoot = ""
} else {
cfg.DataRoot = targetRoot
}
}); err != nil {
return "", err
}
return targetRoot, nil
}
func ResolveConfiguredLogDirectory() (string, error) {
bootstrapConfigMu.Lock()
cfg, err := readBootstrapConfig()
bootstrapConfigMu.Unlock()
if err != nil {
return "", err
}
directory := strings.TrimSpace(cfg.LogDirectory)
if directory == "" {
return "", nil
}
abs, err := filepath.Abs(directory)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
func SetConfiguredLogDirectory(directory string) (string, error) {
target := strings.TrimSpace(directory)
if target != "" {
abs, err := filepath.Abs(target)
if err != nil {
return "", err
}
target = filepath.Clean(abs)
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
probe, err := os.OpenFile(filepath.Join(target, configuredLogFileName), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return "", err
}
if err := probe.Close(); err != nil {
return "", err
}
}
if err := updateBootstrapConfig(func(cfg *bootstrapConfig) {
cfg.LogDirectory = target
}); err != nil {
return "", err
}
return target, nil
}