213 lines
6.8 KiB
Go
213 lines
6.8 KiB
Go
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())
|
||
}
|