update
This commit is contained in:
@ -22,6 +22,8 @@ type Config struct {
|
||||
Upload UploadConfig `yaml:"upload"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
ClientSign ClientSignConfig `yaml:"client_sign"`
|
||||
// RateLimit 按客户端 IP 的速率限制(兜底防刷,与 client_sign 配合)。
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
|
||||
Ingest IngestConfig `yaml:"ingest"`
|
||||
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||
@ -171,6 +173,13 @@ type ClientSignConfig struct {
|
||||
TTLSeconds int `yaml:"ttl_seconds"`
|
||||
}
|
||||
|
||||
// RateLimitConfig 按客户端 IP 的速率限制配置(令牌桶)。
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
RPS int `yaml:"rps"` // 每秒补充令牌数(平均允许 QPS)
|
||||
Burst int `yaml:"burst"` // 突发容量(瞬间允许的最大请求数)
|
||||
}
|
||||
|
||||
// defaultConfig 返回内置默认值,保证 yml 缺字段时服务仍可启动。
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
@ -215,6 +224,11 @@ func defaultConfig() *Config {
|
||||
Secret: "",
|
||||
TTLSeconds: 30,
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
Enabled: false,
|
||||
RPS: 20,
|
||||
Burst: 40,
|
||||
},
|
||||
Ingest: IngestConfig{
|
||||
Secret: "",
|
||||
TTLSeconds: 300,
|
||||
@ -332,6 +346,20 @@ func (c *Config) applyEnv() {
|
||||
envStr("CLIENT_SIGN_SECRET", &c.ClientSign.Secret)
|
||||
envInt("CLIENT_SIGN_TTL", &c.ClientSign.TTLSeconds)
|
||||
|
||||
// CORS 允许的来源(逗号分隔),覆盖 yml 的 allow_origins,便于容器注入具体域名收紧跨域。
|
||||
if v := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGINS")); v != "" {
|
||||
parts := strings.Split(v, ",")
|
||||
c.CORS.AllowOrigins = make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
c.CORS.AllowOrigins = append(c.CORS.AllowOrigins, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
envBool("RATE_LIMIT_ENABLED", &c.RateLimit.Enabled)
|
||||
envInt("RATE_LIMIT_RPS", &c.RateLimit.RPS)
|
||||
envInt("RATE_LIMIT_BURST", &c.RateLimit.Burst)
|
||||
|
||||
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
||||
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
||||
|
||||
@ -383,6 +411,12 @@ func (c *Config) normalize() {
|
||||
if c.JWT.RefreshExpireHours <= 0 {
|
||||
c.JWT.RefreshExpireHours = 720
|
||||
}
|
||||
if c.RateLimit.RPS <= 0 {
|
||||
c.RateLimit.RPS = 20
|
||||
}
|
||||
if c.RateLimit.Burst <= 0 {
|
||||
c.RateLimit.Burst = 40
|
||||
}
|
||||
if c.Upload.Dir == "" {
|
||||
c.Upload.Dir = "./uploads"
|
||||
}
|
||||
|
||||
212
internal/config/prompt.go
Normal file
212
internal/config/prompt.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user