Files
backend_v2/internal/config/prompt.go
toom1996 361ffc01af update
2026-09-29 19:59:30 +08:00

213 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}