优化配置文件
This commit is contained in:
5 files changed
+592
-17
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# rill 服务端配置
|
||||
version: 1 # 配置版本,用于启动时自动补全缺失项,请勿手动修改
|
||||
|
||||
server:
|
||||
host: "0.0.0.0" # 监听地址,0.0.0.0 表示所有网卡
|
||||
port: 8080 #web 服务端口,为 0 则不使用 tcp html
|
||||
sock: "web.sock" # unix socket 文件路径,留空表示不启用
|
||||
mode: release # gin 运行模式: debug / release / test
|
||||
|
||||
|
||||
log:
|
||||
level: info # 日志级别: debug / info / warn / error
|
||||
access_log: true # 是否输出 HTTP 访问日志
|
||||
|
||||
static:
|
||||
dir: "./dist" # 前端构建产物目录
|
||||
|
||||
api:
|
||||
prefix: "/api" # API 路由前缀
|
||||
cors:
|
||||
enabled: true
|
||||
allow_origins:
|
||||
- "http://localhost:5173" # Vite 开发服务器地址
|
||||
allow_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
allow_headers: ["Origin", "Content-Type", "Accept", "Authorization"]
|
||||
allow_credentials: false
|
||||
max_age: "12h"
|
||||
|
||||
# 数据库(当前仅配置,服务暂不建立连接)
|
||||
database:
|
||||
driver: sqlite3 # sqlite3 / mysql
|
||||
connect_timeout: "10s" # 建立连接超时
|
||||
sqlite:
|
||||
path: "./data/rill.db"
|
||||
mysql:
|
||||
host: "127.0.0.1"
|
||||
port: 3306
|
||||
user: "root"
|
||||
password: "" # 允许留空
|
||||
database: "rill"
|
||||
charset: "utf8mb4"
|
||||
max_open_conns: 10
|
||||
max_idle_conns: 5
|
||||
conn_max_lifetime: "1h"
|
||||
@@ -0,0 +1,259 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
//go:embed config.default.yaml
|
||||
var defaultConfigYAML []byte
|
||||
|
||||
type Config struct {
|
||||
Version int `yaml:"version"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
Static StaticConfig `yaml:"static"`
|
||||
API APIConfig `yaml:"api"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Sock string `yaml:"sock"`
|
||||
Mode string `yaml:"mode"`
|
||||
}
|
||||
|
||||
// TCPEnabled 是否启用 TCP 监听(port 为 0 表示不启用)。
|
||||
func (s ServerConfig) TCPEnabled() bool {
|
||||
return s.Port > 0
|
||||
}
|
||||
|
||||
// SockEnabled 是否启用 unix socket 监听(sock 留空表示不启用)。
|
||||
func (s ServerConfig) SockEnabled() bool {
|
||||
return strings.TrimSpace(s.Sock) != ""
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
AccessLog bool `yaml:"access_log"`
|
||||
}
|
||||
|
||||
type StaticConfig struct {
|
||||
Dir string `yaml:"dir"`
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
Prefix string `yaml:"prefix"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
AllowOrigins []string `yaml:"allow_origins"`
|
||||
AllowMethods []string `yaml:"allow_methods"`
|
||||
AllowHeaders []string `yaml:"allow_headers"`
|
||||
AllowCredentials bool `yaml:"allow_credentials"`
|
||||
MaxAge string `yaml:"max_age"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver string `yaml:"driver"` // sqlite3 / mysql
|
||||
ConnectTimeout string `yaml:"connect_timeout"` // 建立连接超时
|
||||
SQLite SQLiteConfig `yaml:"sqlite"`
|
||||
MySQL MySQLConfig `yaml:"mysql"`
|
||||
}
|
||||
|
||||
type SQLiteConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Database string `yaml:"database"`
|
||||
Charset string `yaml:"charset"`
|
||||
MaxOpenConns int `yaml:"max_open_conns"`
|
||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||
ConnMaxLifetime string `yaml:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
Sock: "web.sock",
|
||||
Mode: "release",
|
||||
},
|
||||
Log: LogConfig{
|
||||
Level: "info",
|
||||
AccessLog: true,
|
||||
},
|
||||
Static: StaticConfig{
|
||||
Dir: "./dist",
|
||||
},
|
||||
API: APIConfig{
|
||||
Prefix: "/api",
|
||||
CORS: CORSConfig{
|
||||
Enabled: true,
|
||||
AllowOrigins: []string{"http://localhost:5173"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: "12h",
|
||||
},
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
ConnectTimeout: "10s",
|
||||
SQLite: SQLiteConfig{
|
||||
Path: "./data/rill.db",
|
||||
},
|
||||
MySQL: MySQLConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "",
|
||||
Database: "rill",
|
||||
Charset: "utf8mb4",
|
||||
MaxOpenConns: 10,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: "1h",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig 读取 YAML 配置文件,未指定的配置项使用默认值;文件不存在时自动生成默认配置文件。
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
data = applyConfigUpgrade(path, data)
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
if err := generateConfig(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = defaultConfigYAML
|
||||
default:
|
||||
return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func generateConfig(path string) error {
|
||||
if dir := filepath.Dir(path); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建配置目录 %s 失败: %w", dir, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, defaultConfigYAML, 0o644); err != nil {
|
||||
return fmt.Errorf("生成默认配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
slog.Info("配置文件不存在,已生成默认配置", "path", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
switch c.Server.Mode {
|
||||
case "debug", "release", "test":
|
||||
default:
|
||||
return fmt.Errorf("server.mode 无效: %q(可选: debug/release/test)", c.Server.Mode)
|
||||
}
|
||||
if c.Server.Port < 0 || c.Server.Port > 65535 {
|
||||
return fmt.Errorf("server.port 无效: %d(0 表示不启用 TCP)", c.Server.Port)
|
||||
}
|
||||
if !c.Server.TCPEnabled() && !c.Server.SockEnabled() {
|
||||
return fmt.Errorf("server.port 与 server.sock 至少需要启用一个")
|
||||
}
|
||||
if _, err := parseLogLevel(c.Log.Level); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := time.ParseDuration(c.API.CORS.MaxAge); err != nil {
|
||||
return fmt.Errorf("api.cors.max_age 无效: %w", err)
|
||||
}
|
||||
if err := c.validateDatabase(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validateDatabase() error {
|
||||
if _, err := time.ParseDuration(c.Database.ConnectTimeout); err != nil {
|
||||
return fmt.Errorf("database.connect_timeout 无效: %w", err)
|
||||
}
|
||||
|
||||
switch c.Database.Driver {
|
||||
case "sqlite3":
|
||||
if strings.TrimSpace(c.Database.SQLite.Path) == "" {
|
||||
return fmt.Errorf("database.sqlite.path 不能为空")
|
||||
}
|
||||
case "mysql":
|
||||
db := c.Database.MySQL
|
||||
if strings.TrimSpace(db.Host) == "" {
|
||||
return fmt.Errorf("database.mysql.host 不能为空")
|
||||
}
|
||||
if db.Port <= 0 || db.Port > 65535 {
|
||||
return fmt.Errorf("database.mysql.port 无效: %d", db.Port)
|
||||
}
|
||||
if strings.TrimSpace(db.Database) == "" {
|
||||
return fmt.Errorf("database.mysql.database 不能为空")
|
||||
}
|
||||
if db.MaxOpenConns < 0 || db.MaxIdleConns < 0 {
|
||||
return fmt.Errorf("database.mysql 连接池大小不能为负数")
|
||||
}
|
||||
if _, err := time.ParseDuration(db.ConnMaxLifetime); err != nil {
|
||||
return fmt.Errorf("database.mysql.conn_max_lifetime 无效: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("database.driver 无效: %q(可选: sqlite3/mysql)", c.Database.Driver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) LogLevel() slog.Level {
|
||||
level, _ := parseLogLevel(c.Log.Level)
|
||||
return level
|
||||
}
|
||||
|
||||
func (c *Config) CORSMaxAge() time.Duration {
|
||||
maxAge, _ := time.ParseDuration(c.API.CORS.MaxAge)
|
||||
return maxAge
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) (slog.Level, error) {
|
||||
switch level {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "info":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return slog.LevelInfo, fmt.Errorf("log.level 无效: %q(可选: debug/info/warn/error)", level)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
func TestUpgradeConfigFillsMissing(t *testing.T) {
|
||||
input := `server:
|
||||
host: "127.0.0.1" # 自定义监听地址
|
||||
port: 9000
|
||||
custom:
|
||||
keep: true
|
||||
`
|
||||
result, err := upgradeConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("upgradeConfig 失败: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("期望产生补全结果")
|
||||
}
|
||||
if result.Version != 0 {
|
||||
t.Fatalf("补全前版本 = %d, 期望 0", result.Version)
|
||||
}
|
||||
|
||||
out := string(result.Data)
|
||||
for _, want := range []string{
|
||||
"version: 1",
|
||||
`host: "127.0.0.1"`,
|
||||
"# 自定义监听地址",
|
||||
"sock:",
|
||||
`"web.sock"`,
|
||||
"# unix socket 文件路径",
|
||||
"mode: release",
|
||||
"custom:",
|
||||
"keep: true",
|
||||
"api:",
|
||||
`prefix: "/api"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("补全结果缺少 %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(result.Data, &cfg); err != nil {
|
||||
t.Fatalf("补全结果无法解析: %v\n%s", err, out)
|
||||
}
|
||||
if cfg.Version != ConfigVersion {
|
||||
t.Errorf("version = %d, 期望 %d", cfg.Version, ConfigVersion)
|
||||
}
|
||||
if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 9000 {
|
||||
t.Errorf("用户已有值被覆盖: %+v", cfg.Server)
|
||||
}
|
||||
if cfg.Server.Sock != "web.sock" || cfg.Server.Mode != "release" {
|
||||
t.Errorf("缺失项未按默认值补全: %+v", cfg.Server)
|
||||
}
|
||||
if len(result.Added) == 0 {
|
||||
t.Error("期望记录新增配置项路径")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeConfigIdempotent(t *testing.T) {
|
||||
first, err := upgradeConfig([]byte("server:\n port: 9000\n"))
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("首次补全失败: result=%v err=%v", first, err)
|
||||
}
|
||||
second, err := upgradeConfig(first.Data)
|
||||
if err != nil {
|
||||
t.Fatalf("二次检查失败: %v", err)
|
||||
}
|
||||
if second != nil {
|
||||
t.Errorf("版本已是最新,不应再次变更:\n%s", second.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeConfigSkipsCurrentVersion(t *testing.T) {
|
||||
input := "version: 1\nserver:\n host: \"0.0.0.0\"\n"
|
||||
result, err := upgradeConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("upgradeConfig 失败: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("版本一致时不应扫描补全:\n%s", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeConfigNewerVersion(t *testing.T) {
|
||||
input := "version: 99\nserver:\n port: 9000\n"
|
||||
result, err := upgradeConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("upgradeConfig 失败: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("高版本配置不应被改写:\n%s", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeConfigNullSection(t *testing.T) {
|
||||
input := "version: 0\napi:\nserver:\n host: \"0.0.0.0\"\n"
|
||||
result, err := upgradeConfig([]byte(input))
|
||||
if err != nil || result == nil {
|
||||
t.Fatalf("补全失败: result=%v err=%v", result, err)
|
||||
}
|
||||
|
||||
out := string(result.Data)
|
||||
for _, want := range []string{`prefix: "/api"`, `max_age: "12h"`, "allow_origins"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("空节未按默认子树补全,缺少 %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(result.Data, &cfg); err != nil {
|
||||
t.Fatalf("补全结果无法解析: %v", err)
|
||||
}
|
||||
if cfg.API.CORS.MaxAge != "12h" {
|
||||
t.Errorf("api.cors.max_age = %q, 期望 12h", cfg.API.CORS.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigUpgradeWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
original := "server:\n host: \"127.0.0.1\"\n port: 9000\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig 失败: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 9000 || cfg.Server.Sock != "web.sock" {
|
||||
t.Errorf("加载结果不符合预期: %+v", cfg.Server)
|
||||
}
|
||||
|
||||
updated, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(updated), "version: 1") {
|
||||
t.Errorf("磁盘配置未补全 version:\n%s", updated)
|
||||
}
|
||||
|
||||
bak, err := os.ReadFile(path + ".bak")
|
||||
if err != nil {
|
||||
t.Fatalf("未生成备份文件: %v", err)
|
||||
}
|
||||
if string(bak) != original {
|
||||
t.Errorf("备份内容与升级前不一致:\n%s", bak)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("文件权限 = %o, 期望 600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
if _, err := LoadConfig(path); err != nil {
|
||||
t.Fatalf("二次加载失败: %v", err)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(updated, after) {
|
||||
t.Errorf("版本已是最新,二次加载不应改写文件:\n--- before\n%s\n--- after\n%s", updated, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigGeneratesDefault(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "data", "config.yaml")
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig 失败: %v", err)
|
||||
}
|
||||
if cfg.Version != ConfigVersion || cfg.Server.Port != 8080 || cfg.Server.Sock != "web.sock" {
|
||||
t.Errorf("默认配置不符合预期: version=%d server=%+v", cfg.Version, cfg.Server)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("默认配置文件未生成: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "version: 1") {
|
||||
t.Errorf("默认配置文件缺少 version:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTemplateVersionMatchesConst(t *testing.T) {
|
||||
m, err := defaultMapping()
|
||||
if err != nil {
|
||||
t.Fatalf("解析默认模板失败: %v", err)
|
||||
}
|
||||
if version := versionOf(m); version != ConfigVersion {
|
||||
t.Fatalf("默认模板 version = %d, ConfigVersion = %d,请同步更新", version, ConfigVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/goccy/go-yaml/parser"
|
||||
)
|
||||
|
||||
// ConfigVersion 当前配置结构版本,新增配置项时递增。
|
||||
const ConfigVersion = 1
|
||||
|
||||
// upgradeResult 描述一次配置自动补全的结果。
|
||||
type upgradeResult struct {
|
||||
Data []byte
|
||||
Version int
|
||||
Added []string
|
||||
}
|
||||
|
||||
// upgradeConfig 比较配置版本,版本落后时按默认模板递归补全缺失项。
|
||||
// 返回 nil 表示无需变更。
|
||||
func upgradeConfig(data []byte) (*upgradeResult, error) {
|
||||
file, err := parser.ParseBytes(data, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
body, err := rootMapping(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
version := versionOf(body)
|
||||
if version > ConfigVersion {
|
||||
slog.Warn("配置文件版本高于当前程序,可能存在未知配置项,请升级程序",
|
||||
"version", version, "supported", ConfigVersion)
|
||||
return nil, nil
|
||||
}
|
||||
if version == ConfigVersion {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
defaults, err := defaultMapping()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
added := mergeMissing(body, defaults, "")
|
||||
setVersion(body, ConfigVersion)
|
||||
return &upgradeResult{Data: []byte(file.String()), Version: version, Added: added}, nil
|
||||
}
|
||||
|
||||
// mergeMissing 以用户配置为基底递归补入默认模板中缺失的键,已有值保持不变。
|
||||
// 返回新增配置项的路径列表。
|
||||
func mergeMissing(user, defaults *ast.MappingNode, prefix string) []string {
|
||||
var added []string
|
||||
|
||||
index := make(map[string]int, len(user.Values))
|
||||
for i, v := range user.Values {
|
||||
index[v.Key.String()] = i
|
||||
}
|
||||
delta := mappingKeyColumn(user) - mappingKeyColumn(defaults)
|
||||
insertAt := 0
|
||||
|
||||
for _, dv := range defaults.Values {
|
||||
key := dv.Key.String()
|
||||
full := joinPath(prefix, key)
|
||||
|
||||
i, exists := index[key]
|
||||
if !exists {
|
||||
node := dv
|
||||
node.AddColumn(delta)
|
||||
user.Values = slices.Insert(user.Values, insertAt, node)
|
||||
shiftIndex(index, insertAt)
|
||||
index[key] = insertAt
|
||||
insertAt++
|
||||
added = append(added, full)
|
||||
continue
|
||||
}
|
||||
if i+1 > insertAt {
|
||||
insertAt = i + 1
|
||||
}
|
||||
|
||||
uv := user.Values[i]
|
||||
switch {
|
||||
case isMapping(uv.Value) && isMapping(dv.Value):
|
||||
added = append(added, mergeMissing(
|
||||
uv.Value.(*ast.MappingNode), dv.Value.(*ast.MappingNode), full)...)
|
||||
case isNull(uv.Value) && isMapping(dv.Value):
|
||||
dm := dv.Value.(*ast.MappingNode)
|
||||
dm.AddColumn(uv.Key.GetToken().Position.Column - dv.Key.GetToken().Position.Column)
|
||||
uv.Value = dm
|
||||
added = append(added, leafPaths(dm, full)...)
|
||||
}
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// setVersion 将配置中的 version 更新为当前版本(保留原行内注释)。
|
||||
func setVersion(body *ast.MappingNode, version int) {
|
||||
for _, v := range body.Values {
|
||||
if v.Key.String() != "version" {
|
||||
continue
|
||||
}
|
||||
node, err := yaml.ValueToNode(version)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if comment := v.Value.GetComment(); comment != nil {
|
||||
_ = node.SetComment(comment)
|
||||
}
|
||||
v.Value = node
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// versionOf 读取配置中的 version,缺失或无效时视为 0。
|
||||
func versionOf(body *ast.MappingNode) int {
|
||||
for _, v := range body.Values {
|
||||
if v.Key.String() != "version" {
|
||||
continue
|
||||
}
|
||||
var version int
|
||||
if err := yaml.NodeToValue(v.Value, &version); err != nil {
|
||||
return 0
|
||||
}
|
||||
return version
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func defaultMapping() (*ast.MappingNode, error) {
|
||||
file, err := parser.ParseBytes(defaultConfigYAML, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析默认配置模板失败: %w", err)
|
||||
}
|
||||
return rootMapping(file)
|
||||
}
|
||||
|
||||
func rootMapping(file *ast.File) (*ast.MappingNode, error) {
|
||||
if len(file.Docs) == 0 || file.Docs[0].Body == nil {
|
||||
return nil, fmt.Errorf("配置内容为空")
|
||||
}
|
||||
body, ok := file.Docs[0].Body.(*ast.MappingNode)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("配置根节点必须是映射")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func mappingKeyColumn(m *ast.MappingNode) int {
|
||||
if len(m.Values) > 0 && m.Values[0].Key != nil {
|
||||
return m.Values[0].Key.GetToken().Position.Column
|
||||
}
|
||||
if m.Start != nil {
|
||||
return m.Start.Position.Column
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func joinPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
func leafPaths(m *ast.MappingNode, prefix string) []string {
|
||||
var paths []string
|
||||
for _, v := range m.Values {
|
||||
key := joinPath(prefix, v.Key.String())
|
||||
if child, ok := v.Value.(*ast.MappingNode); ok && len(child.Values) > 0 {
|
||||
paths = append(paths, leafPaths(child, key)...)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, key)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func isMapping(node ast.Node) bool {
|
||||
_, ok := node.(*ast.MappingNode)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isNull(node ast.Node) bool {
|
||||
if node == nil {
|
||||
return true
|
||||
}
|
||||
_, ok := node.(*ast.NullNode)
|
||||
return ok
|
||||
}
|
||||
|
||||
func shiftIndex(index map[string]int, from int) {
|
||||
for key, i := range index {
|
||||
if i >= from {
|
||||
index[key] = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyConfigUpgrade 执行配置检查与补全;失败时仅告警并使用原内容,不阻塞启动。
|
||||
func applyConfigUpgrade(path string, data []byte) []byte {
|
||||
result, err := upgradeConfig(data)
|
||||
if err != nil {
|
||||
slog.Warn("配置自动补全检查失败,将使用默认值补齐缺失项", "path", path, "err", err)
|
||||
return data
|
||||
}
|
||||
if result == nil {
|
||||
return data
|
||||
}
|
||||
|
||||
if err := backupConfig(path); err != nil {
|
||||
slog.Warn("备份配置文件失败", "path", path, "err", err)
|
||||
}
|
||||
if err := writeFileAtomic(path, result.Data); err != nil {
|
||||
slog.Warn("配置自动补全写回失败,将使用默认值补齐缺失项", "path", path, "err", err)
|
||||
return data
|
||||
}
|
||||
slog.Info("配置已自动补全",
|
||||
"path", path,
|
||||
"version", fmt.Sprintf("%d -> %d", result.Version, ConfigVersion),
|
||||
"added", strings.Join(result.Added, ", "))
|
||||
return result.Data
|
||||
}
|
||||
|
||||
func backupConfig(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path+".bak", data, fileMode(path))
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".rill-config-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, fileMode(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func fileMode(path string) fs.FileMode {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
return info.Mode().Perm()
|
||||
}
|
||||
return 0o644
|
||||
}
|
||||
Reference in New Issue
Block a user