package config import ( "bufio" "fmt" "os" "path/filepath" "strings" "github.com/goccy/go-yaml" "github.com/mattn/go-isatty" ) // requiredSpec 描述一个「必填 / 推荐」敏感配置项,以及取值、赋值与回写路径。 // // 解析顺序:环境变量 > config.yml > config.local.yml > 交互提示(仅 TTY)> 非 TTY 缺失则 fatal。 // 这样关键配置无需硬编码进仓库:仓库里的 config.yml 把这些项留空, // 本地开发在终端里交互填写(自动写回 config.local.yml,已被 .gitignore 忽略), // 容器 / CI 等无终端环境则通过环境变量注入,缺失必填项直接报错退出。 type requiredSpec struct { Name string // 展示名 EnvKey string // 对应环境变量名(非 TTY 报错提示 + 文档) Secret bool // 是否为密钥(仅影响提示文案) Optional bool // true=缺失只告警不阻断;false=缺失且非 TTY 时 fatal Get func(c *Config) string Set func(c *Config, v string) YAMLPath []string // 写回 config.local.yml 的嵌套路径 Cond func(c *Config) bool // 是否参与校验(如 S4 启用才要求 ak/sk) } func requiredSpecs() []requiredSpec { return []requiredSpec{ { Name: "JWT 签名密钥", EnvKey: "JWT_SECRET", Secret: true, Optional: false, Get: func(c *Config) string { return c.JWT.Secret }, Set: func(c *Config, v string) { c.JWT.Secret = v }, YAMLPath: []string{"jwt", "secret"}, }, { Name: "数据库密码", EnvKey: "DB_PASSWORD", Secret: true, Optional: false, Get: func(c *Config) string { return c.Database.Password }, Set: func(c *Config, v string) { c.Database.Password = v }, YAMLPath: []string{"database", "password"}, }, { Name: "爬虫上报 HMAC 密钥", EnvKey: "INGEST_SECRET", Secret: true, Optional: true, Get: func(c *Config) string { return c.Ingest.Secret }, Set: func(c *Config, v string) { c.Ingest.Secret = v }, YAMLPath: []string{"ingest", "secret"}, }, { Name: "公开 ID 混淆盐", EnvKey: "HASHID_SECRET", Secret: true, Optional: true, Get: func(c *Config) string { return c.Server.HashIDSecret }, Set: func(c *Config, v string) { c.Server.HashIDSecret = v }, YAMLPath: []string{"server", "hashid_secret"}, }, { Name: "S4 对象存储 AccessKey", EnvKey: "S4_AK", Secret: true, Optional: false, Cond: func(c *Config) bool { return c.S4.Enabled }, Get: func(c *Config) string { return c.S4.AK }, Set: func(c *Config, v string) { c.S4.AK = v }, YAMLPath: []string{"s4", "ak"}, }, { Name: "S4 对象存储 SecretKey", EnvKey: "S4_SK", Secret: true, Optional: false, Cond: func(c *Config) bool { return c.S4.Enabled }, Get: func(c *Config) string { return c.S4.SK }, Set: func(c *Config, v string) { c.S4.SK = v }, YAMLPath: []string{"s4", "sk"}, }, } } // PromptMissing 在配置加载完成后,对缺失的必填/推荐敏感配置做交互式补填。 // 交互补填的值会写回 config.local.yml(已被 .gitignore 忽略),下次启动免填。 // 非交互环境(容器 / CI,无终端输入)下,缺失的必填项直接返回 error,由调用方 fatal 退出, // 并打印应注入的环境变量名。 func PromptMissing(cfg *Config) error { var missing []requiredSpec for _, s := range requiredSpecs() { if s.Cond != nil && !s.Cond(cfg) { continue } if strings.TrimSpace(s.Get(cfg)) == "" { missing = append(missing, s) } } if len(missing) == 0 { return nil } if !isTerminal(os.Stdin) { var fatalLines, warnLines []string for _, s := range missing { if s.Optional { warnLines = append(warnLines, fmt.Sprintf(" ! %s 未设置(建议通过环境变量 %s 注入)", s.Name, s.EnvKey)) } else { fatalLines = append(fatalLines, fmt.Sprintf(" - %s(请设置环境变量 %s)", s.Name, s.EnvKey)) } } for _, l := range warnLines { fmt.Println(l) } if len(fatalLines) > 0 { return fmt.Errorf("缺少必填配置,无法启动:\n%s", strings.Join(fatalLines, "\n")) } return nil } fmt.Println("以下必填/推荐配置缺失,请交互式填写(将写入 config.local.yml,已被 .gitignore 忽略):") reader := bufio.NewReader(os.Stdin) changed := map[string]string{} for _, s := range missing { val, err := promptLine(reader, s) if err != nil { return err } s.Set(cfg, val) changed[strings.Join(s.YAMLPath, ".")] = val } return writeLocalOverlay(changed) } func promptLine(r *bufio.Reader, s requiredSpec) (string, error) { tag := "" if s.Secret { tag = "(密钥,输入不会回显保存,请自行保密)" } fmt.Printf(" %s [%s]%s: ", s.Name, s.EnvKey, tag) val, err := r.ReadString('\n') if err != nil && val == "" { return "", fmt.Errorf("读取 %s 失败: %w", s.Name, err) } val = strings.TrimRight(val, "\r\n") if strings.TrimSpace(val) == "" { return "", fmt.Errorf("%s 不能为空", s.Name) } return val, nil } // writeLocalOverlay 把交互补填的值合并写回 config.local.yml(已 gitignore)。 // 若文件已存在,保留其中未被覆盖的其它键(如 download_proxy)。 func writeLocalOverlay(changed map[string]string) error { candidates := []string{ "configs/config.local.yml", "config.local.yml", filepath.Join("..", "..", "configs", "config.local.yml"), } target := candidates[0] var m map[string]interface{} for _, p := range candidates { data, err := os.ReadFile(p) if err == nil { _ = yaml.Unmarshal(data, &m) target = p break } } if m == nil { m = map[string]interface{}{} } for dotted, val := range changed { setNested(m, strings.Split(dotted, "."), val) } out, err := yaml.Marshal(m) if err != nil { return fmt.Errorf("序列化 config.local.yml 失败: %w", err) } if err := os.WriteFile(target, out, 0o600); err != nil { return fmt.Errorf("写入 %s 失败: %w", target, err) } fmt.Printf("✓ 已写入本地私有配置: %s(已被 .gitignore 忽略,不会进版本库)\n", target) return nil } func setNested(m map[string]interface{}, path []string, val string) { for i := 0; i < len(path)-1; i++ { k := path[i] child, ok := m[k].(map[string]interface{}) if !ok { child = map[string]interface{}{} m[k] = child } m = child } m[path[len(path)-1]] = val } // isTerminal 判断文件描述符是否为真实终端,用于区分交互式启动与容器/CI 无输入环境。 // 使用 mattn/go-isatty 而非 Stat().Mode()&ModeCharDevice,因为后者会把 /dev/null、管道 // 等字符设备误判为终端,导致容器里错误地进入交互提示分支。 func isTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) }