// Package config 负责读取/生成与可执行文件同级的 daili.ini。 // // 设计约定: // - 只放「部署类」参数;业务默认值(预警天数、大屏刷新秒数)仍在设置页(数据库)。 // - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。 // - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。 package config import ( "errors" "fmt" "net" "os" "path/filepath" "strconv" "strings" "time" // 内嵌时区库:Windows 上没有 /usr/share/zoneinfo,靠它才能 LoadLocation("Asia/Shanghai") _ "time/tzdata" ) // FileName 是默认配置文件名。 const FileName = "daili.ini" // Config 是最终生效的配置。 type Config struct { ListenIP string ListenPort int DatabasePath string BackupEnabled bool BackupDir string BackupKeep int Timezone string OpenBrowser bool ScreenPublic bool PortalPublic bool LogEnabled bool LogDir string // 运行时附加信息 Path string // 配置文件绝对路径 BaseDir string // 相对路径的基准目录(exe 所在目录) Created bool // 本次是否自动生成了默认文件 keyLine map[string]int // 各配置项出现的行号,用于校验报错定位 } // Defaults 返回内置默认值。 func Defaults() *Config { return &Config{ ListenIP: "0.0.0.0", ListenPort: 17777, DatabasePath: "daili.db", BackupEnabled: true, BackupDir: "backup", BackupKeep: 14, Timezone: "", OpenBrowser: true, ScreenPublic: true, PortalPublic: true, LogEnabled: true, LogDir: "logs", } } // Addr 返回 net.Listen 用的监听地址。 func (c *Config) Addr() string { return net.JoinHostPort(c.ListenIP, strconv.Itoa(c.ListenPort)) } // LoopbackOnly 表示只监听本机回环地址。 func (c *Config) LoopbackOnly() bool { ip := net.ParseIP(c.ListenIP) return ip != nil && ip.IsLoopback() } // PublicBind 表示监听在所有网卡(局域网可访问)。 func (c *Config) PublicBind() bool { ip := net.ParseIP(c.ListenIP) return ip != nil && (ip.IsUnspecified()) } // Load 读取配置;文件不存在时生成默认文件。baseDir 为空则取配置文件所在目录。 func Load(path, baseDir string) (*Config, error) { abs, err := filepath.Abs(path) if err != nil { return nil, err } if baseDir == "" { baseDir = filepath.Dir(abs) } c := Defaults() c.Path = abs c.BaseDir = baseDir data, err := os.ReadFile(abs) if errors.Is(err, os.ErrNotExist) { if mkErr := os.MkdirAll(filepath.Dir(abs), 0o755); mkErr != nil { return nil, fmt.Errorf("创建配置目录失败: %w", mkErr) } c.Created = true if wErr := os.WriteFile(abs, []byte(templateText(c)), 0o644); wErr != nil { return nil, fmt.Errorf("生成默认配置文件失败: %w", wErr) } return c.resolve(), nil } if err != nil { return nil, fmt.Errorf("读取配置文件失败: %w", err) } if err := c.parse(string(data)); err != nil { return nil, fmt.Errorf("%s\n修好后重新启动即可(原文件未被修改)", err) } return c.resolve(), nil } func (c *Config) resolve() *Config { c.DatabasePath = absolute(c.BaseDir, c.DatabasePath) c.BackupDir = absolute(c.BaseDir, c.BackupDir) c.LogDir = absolute(c.BaseDir, c.LogDir) return c } func absolute(base, p string) string { if p == "" { return "" } if filepath.IsAbs(p) { return filepath.Clean(p) } return filepath.Join(base, p) } func (c *Config) parse(text string) error { text = strings.TrimPrefix(text, "\ufeff") lines := strings.Split(text, "\n") seen := map[string]int{} c.keyLine = map[string]int{} for i, raw := range lines { line := strings.TrimSpace(strings.TrimSuffix(raw, "\r")) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if strings.HasPrefix(line, "[") { return fmt.Errorf("第 %d 行: 不支持分节([xxx]),请直接写 key = value", i+1) } eq := strings.Index(line, "=") if eq <= 0 { return fmt.Errorf("第 %d 行: 缺少等号,正确格式是 key = value", i+1) } key := strings.ToLower(strings.TrimSpace(line[:eq])) val := strings.TrimSpace(line[eq+1:]) if prev, ok := seen[key]; ok { return fmt.Errorf("第 %d 行: 配置项 %s 与第 %d 行重复", i+1, key, prev) } seen[key] = i + 1 c.keyLine[key] = i + 1 if err := c.set(key, val); err != nil { return fmt.Errorf("第 %d 行: %v", i+1, err) } } if err := c.validate(); err != nil { return err } return nil } func (c *Config) set(key, val string) error { switch key { case "listen_ip": c.ListenIP = val case "listen_port": n, err := strconv.Atoi(val) if err != nil { return fmt.Errorf("listen_port 必须是数字,当前是 %q", val) } c.ListenPort = n case "database_path": c.DatabasePath = val case "backup_enabled": b, err := parseBool(val) if err != nil { return fmt.Errorf("backup_enabled %v", err) } c.BackupEnabled = b case "backup_dir": c.BackupDir = val case "backup_keep": n, err := strconv.Atoi(val) if err != nil { return fmt.Errorf("backup_keep 必须是数字,当前是 %q", val) } c.BackupKeep = n case "timezone": c.Timezone = val case "open_browser": b, err := parseBool(val) if err != nil { return fmt.Errorf("open_browser %v", err) } c.OpenBrowser = b case "screen_public": b, err := parseBool(val) if err != nil { return fmt.Errorf("screen_public %v", err) } c.ScreenPublic = b case "portal_public": b, err := parseBool(val) if err != nil { return fmt.Errorf("portal_public %v", err) } c.PortalPublic = b case "log_enabled": b, err := parseBool(val) if err != nil { return fmt.Errorf("log_enabled %v", err) } c.LogEnabled = b case "log_dir": c.LogDir = val default: return fmt.Errorf("未知配置项 %s(可用项见文件内的注释)", key) } return nil } func (c *Config) validate() error { if c.ListenIP == "" { return c.at("listen_ip", "listen_ip 不能为空(0.0.0.0 表示所有网卡,127.0.0.1 表示仅本机)") } if net.ParseIP(c.ListenIP) == nil { return c.at("listen_ip", "listen_ip %q 不是合法 IP(请写 0.0.0.0 或 127.0.0.1)", c.ListenIP) } if c.ListenPort < 1 || c.ListenPort > 65535 { return c.at("listen_port", "listen_port 必须在 1-65535 之间,当前是 %d", c.ListenPort) } if strings.TrimSpace(c.DatabasePath) == "" { return c.at("database_path", "database_path 不能为空") } if c.BackupEnabled && strings.TrimSpace(c.BackupDir) == "" { return c.at("backup_dir", "backup_enabled 为 true 时 backup_dir 不能为空") } if c.BackupKeep < 1 || c.BackupKeep > 365 { return c.at("backup_keep", "backup_keep 必须在 1-365 之间,当前是 %d", c.BackupKeep) } if c.Timezone != "" { if _, err := time.LoadLocation(c.Timezone); err != nil { return c.at("timezone", "timezone %q 无法识别(示例:Asia/Shanghai;留空表示跟随系统)", c.Timezone) } } if c.LogEnabled && strings.TrimSpace(c.LogDir) == "" { return c.at("log_dir", "log_enabled 为 true 时 log_dir 不能为空") } return nil } // at 生成带行号的错误;没有行号信息(如默认值)时退化为普通错误。 func (c *Config) at(key, format string, args ...any) error { msg := fmt.Sprintf(format, args...) if n := c.keyLine[key]; n > 0 { return fmt.Errorf("第 %d 行: %s", n, msg) } return errors.New(msg) } // Validate 校验配置(命令行覆盖之后也会调用一次)。 func (c *Config) Validate() error { return c.validate() } func parseBool(v string) (bool, error) { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on", "y": return true, nil case "0", "false", "no", "off", "n": return false, nil } return false, fmt.Errorf("必须是 true/false(也接受 1/0、yes/no、on/off),当前是 %q", v) } func templateText(c *Config) string { return fmt.Sprintf(`# ============================================================ # 代打台 配置文件(daili.ini) # 与 daili.exe 放在同一目录;修改后需要重启程序生效。 # 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。 # 相对路径相对于 exe 所在目录,而不是当前工作目录。 # ============================================================ # ---------- 监听 ---------- # 0.0.0.0 = 所有网卡(同局域网可访问) # 127.0.0.1 = 只允许本机访问 listen_ip = %s listen_port = %d # ---------- 数据与备份 ---------- database_path = %s # 是否每日自动备份(另可在网页「设置 → 立即备份」手动备份) backup_enabled = %t # 备份目录;留空则使用 exe 同级 backup/ backup_dir = %s # 备份保留份数(1-365),超出的旧备份会自动清理 backup_keep = %d # ---------- 时间 ---------- # 留空 = 跟随系统时区;也可固定为 Asia/Shanghai timezone = %s # ---------- 界面 ---------- # 启动后自动打开浏览器 open_browser = %t # 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录) screen_public = %t # 客户自助查询 /customer 是否免登录(默认 true;不需要对外时可改成 false) portal_public = %t # ---------- 日志 ---------- # 运行日志同时输出到控制台与 log_dir 下按天切分的文件 log_enabled = %t log_dir = %s `, c.ListenIP, c.ListenPort, c.DatabasePath, c.BackupEnabled, c.BackupDir, c.BackupKeep, c.Timezone, c.OpenBrowser, c.ScreenPublic, c.PortalPublic, c.LogEnabled, c.LogDir, ) }