This commit is contained in:
toom1996
2026-09-29 19:59:30 +08:00
parent d15d2a4701
commit 361ffc01af
28 changed files with 896 additions and 47 deletions

212
internal/config/prompt.go Normal file
View File

@ -0,0 +1,212 @@
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())
}