// Package config 负责应用配置的加载与校验。 // // 配置以 yml 文件为唯一来源,环境变量仅作为覆盖手段(便于容器部署注入敏感值)。 // 加载优先级:环境变量 > yml 文件 > 代码内置默认值。 package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "github.com/goccy/go-yaml" ) // Config 应用总配置,结构与 configs/config.yml 一一对应。 type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` JWT JWTConfig `yaml:"jwt"` Upload UploadConfig `yaml:"upload"` CORS CORSConfig `yaml:"cors"` ClientSign ClientSignConfig `yaml:"client_sign"` // loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。 // 小写不导出,yml 无法覆盖它。 loadedFrom string } // ServerConfig HTTP 服务配置。 type ServerConfig struct { Port string `yaml:"port"` Mode string `yaml:"mode"` ShutdownTimeout int `yaml:"shutdown_timeout"` // SSGPort 是「仅供构建期(astro build)使用的内部端口」,与对外公开的 Port 完全隔离。 // 该端口只挂 /api/ssg/* 路由,由 docker-compose 绑定到 127.0.0.1(回环), // nginx 也绝不反代它,因此外部网络根本不可达,SSG 全量/全 ID 数据不会外泄。 // 为空时 normalize 兜底为 "8091"。 SSGPort string `yaml:"ssg_port"` // SSGToken 是 SSG 端口的可选访问令牌(防御纵深)。 // 为空表示不校验(依赖回环绑定即可);非空时 /api/ssg/* 必须带 ?token= 或 X-SSG-Token 头。 // 通过环境变量 SSG_TOKEN 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。 SSGToken string `yaml:"ssg_token"` // HashIDSecret 是「公开 ID 混淆」的部署级盐值。 // 公开接口对外只暴露编码后的无序串(如 xK9mP2)而非自增主键,防止爬虫顺序枚举; // 该盐值决定编码结果,不同部署应使用不同随机串,避免被反向推导。 // 为空时使用内置默认盐(仅防顺序枚举,不算安全),生产务必通过环境变量注入随机值。 HashIDSecret string `yaml:"hashid_secret"` } // DatabaseConfig MySQL 连接与连接池配置。 type DatabaseConfig struct { Host string `yaml:"host"` Port string `yaml:"port"` User string `yaml:"user"` Password string `yaml:"password"` Name string `yaml:"name"` Charset string `yaml:"charset"` LogLevel string `yaml:"log_level"` MaxIdleConns int `yaml:"max_idle_conns"` MaxOpenConns int `yaml:"max_open_conns"` ConnMaxLifetime int `yaml:"conn_max_lifetime"` // ConnMaxIdleTime 空闲连接回收时间(秒)。必须 < MySQL wait_timeout,否则 MySQL 回收空闲 // 连接后,连接池仍持有死连接,下一次查询会报 "invalid connection" / "bad connection"。 ConnMaxIdleTime int `yaml:"conn_max_idle_time"` } // DSN 组装 MySQL 连接串。 func (d DatabaseConfig) DSN() string { charset := d.Charset if charset == "" { charset = "utf8mb4" } return fmt.Sprintf( "%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local&timeout=10s", d.User, d.Password, d.Host, d.Port, d.Name, charset, ) } // Addr 返回 host:port 形式的数据库地址,用于日志展示。 func (d DatabaseConfig) Addr() string { return d.Host + ":" + d.Port + "/" + d.Name } // JWTConfig 令牌签发配置。 type JWTConfig struct { Secret string `yaml:"secret"` ExpireHours int `yaml:"expire_hours"` // access token 有效期(小时) RefreshExpireHours int `yaml:"refresh_expire_hours"` // refresh token 有效期(小时) } // UploadConfig 图片静态资源配置。 type UploadConfig struct { Dir string `yaml:"dir"` URLPrefix string `yaml:"url_prefix"` } // CORSConfig 跨域配置。 type CORSConfig struct { AllowOrigins []string `yaml:"allow_origins"` AllowMethods []string `yaml:"allow_methods"` AllowHeaders []string `yaml:"allow_headers"` MaxAge int `yaml:"max_age"` } // ClientSignConfig 公开接口「前端 JS 签名」配置。 // // 给 /runway-looks(列表)与 /brands 两个批量查询接口加一层请求签名校验, // 用于识别「请求大概率由前端 JS 构造」、抬高 casual 爬虫的批量抓取成本。 // // 安全定位(务必知悉): // - 这是「提高成本」而非「加密」——Secret 必须出现在前端 bundle 才能签名,对浏览器可见; // 有决心的爬虫可反编译 JS 复刻签名逻辑。它用于拖慢 casual 爬虫,不能作为唯一防线。 // - 真正有效的组合是:本签名(识别大概率真前端)+ 按 IP 限流(兜住总量)。 // - Enabled=false 或 Secret 为空时中间件为 noop(不拦截),便于灰度与回滚。 type ClientSignConfig struct { // Enabled 开关:false 时中间件不拦截,便于灰度上线与紧急回滚。 Enabled bool `yaml:"enabled"` // Secret 签名密钥,必须与前端的 CLIENT_SIGN_SECRET 完全一致。生产经环境变量 CLIENT_SIGN_SECRET 注入随机长串。 Secret string `yaml:"secret"` // TTLSeconds 签名时间戳容忍窗口(秒),用于防重放。默认 30。 TTLSeconds int `yaml:"ttl_seconds"` } // defaultConfig 返回内置默认值,保证 yml 缺字段时服务仍可启动。 func defaultConfig() *Config { return &Config{ Server: ServerConfig{ Port: "8090", Mode: "debug", ShutdownTimeout: 10, SSGPort: "8091", SSGToken: "", HashIDSecret: "", }, Database: DatabaseConfig{ Host: "127.0.0.1", Port: "3306", User: "root", Password: "root", Name: "db_dev", Charset: "utf8mb4", LogLevel: "warn", MaxIdleConns: 10, MaxOpenConns: 100, ConnMaxLifetime: 3600, ConnMaxIdleTime: 60, }, JWT: JWTConfig{ Secret: "dev-secret-change-me-fashion-2026", ExpireHours: 2, // access token 2 小时(短命,泄漏窗口小) RefreshExpireHours: 720, // refresh token 30 天(长命,落库可吊销) }, Upload: UploadConfig{ Dir: "./uploads", URLPrefix: "/uploads", }, CORS: CORSConfig{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "X-Sign", "X-Sign-Ts", "X-Sign-Nonce"}, MaxAge: 86400, }, ClientSign: ClientSignConfig{ Enabled: false, Secret: "", TTLSeconds: 30, }, } } // candidatePaths 返回配置文件的查找顺序。 // 显式指定(-config 参数 / CONFIG_PATH 环境变量)优先,其次按约定路径查找, // 这样无论从项目根目录还是从 cmd 子目录启动都能定位到配置。 func candidatePaths(explicit string) []string { if explicit != "" { return []string{explicit} } if p := os.Getenv("CONFIG_PATH"); p != "" { return []string{p} } return []string{ "configs/config.yml", "config.yml", filepath.Join("..", "..", "configs", "config.yml"), } } // Load 加载配置:读取 yml → 应用环境变量覆盖 → 归一化校验。 // // explicit 为显式指定的配置文件路径,传空字符串则按约定路径查找。 // 找不到配置文件不视为错误(返回默认值 + 环境变量),便于纯环境变量的容器部署。 func Load(explicit string) (*Config, error) { cfg := defaultConfig() var loadedFrom string for _, p := range candidatePaths(explicit) { data, err := os.ReadFile(p) if err != nil { continue } if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("解析配置文件 %s 失败: %w", p, err) } abs, _ := filepath.Abs(p) loadedFrom = abs break } if loadedFrom == "" && explicit != "" { return nil, fmt.Errorf("配置文件不存在: %s", explicit) } cfg.loadedFrom = loadedFrom cfg.applyEnv() cfg.normalize() return cfg, nil } // LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。 func (c *Config) LoadedFrom() string { return c.loadedFrom } // applyEnv 用环境变量覆盖 yml 中的值,便于容器部署注入敏感配置。 func (c *Config) applyEnv() { envStr("SERVER_PORT", &c.Server.Port) envStr("GIN_MODE", &c.Server.Mode) envStr("SSG_PORT", &c.Server.SSGPort) envStr("SSG_TOKEN", &c.Server.SSGToken) envStr("HASHID_SECRET", &c.Server.HashIDSecret) envStr("DB_HOST", &c.Database.Host) envStr("DB_PORT", &c.Database.Port) envStr("DB_USER", &c.Database.User) // 同时兼容 DB_PASSWORD 与 DB_PASS: // 原项目代码只读 DB_PASS,而 docker-compose.yml 注入的是 DB_PASSWORD, // 二者不一致导致容器里的密码配置实际未生效(仅因默认值恰好相同而未暴露)。 // 这里两个都支持,DB_PASSWORD 优先,修正该隐患。 envStr("DB_PASS", &c.Database.Password) envStr("DB_PASSWORD", &c.Database.Password) envStr("DB_NAME", &c.Database.Name) envStr("DB_LOG_LEVEL", &c.Database.LogLevel) envStr("JWT_SECRET", &c.JWT.Secret) envInt("JWT_EXPIRE_HOURS", &c.JWT.ExpireHours) envInt("JWT_REFRESH_EXPIRE_HOURS", &c.JWT.RefreshExpireHours) envBool("CLIENT_SIGN_ENABLED", &c.ClientSign.Enabled) envStr("CLIENT_SIGN_SECRET", &c.ClientSign.Secret) envInt("CLIENT_SIGN_TTL", &c.ClientSign.TTLSeconds) envStr("UPLOAD_DIR", &c.Upload.Dir) envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix) } // normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。 func (c *Config) normalize() { if c.Server.Port == "" { c.Server.Port = "8090" } if c.Server.SSGPort == "" { c.Server.SSGPort = "8091" } switch c.Server.Mode { case "debug", "release", "test": default: c.Server.Mode = "debug" } if c.Server.ShutdownTimeout <= 0 { c.Server.ShutdownTimeout = 10 } if c.Database.MaxIdleConns <= 0 { c.Database.MaxIdleConns = 10 } if c.Database.MaxOpenConns <= 0 { c.Database.MaxOpenConns = 100 } if c.JWT.ExpireHours <= 0 { c.JWT.ExpireHours = 168 } if c.JWT.RefreshExpireHours <= 0 { c.JWT.RefreshExpireHours = 720 } if c.Upload.Dir == "" { c.Upload.Dir = "./uploads" } // 静态前缀必须以 / 开头且不以 / 结尾,保证与数据库中图片路径拼接一致 prefix := strings.TrimSpace(c.Upload.URLPrefix) if prefix == "" { prefix = "/uploads" } if !strings.HasPrefix(prefix, "/") { prefix = "/" + prefix } c.Upload.URLPrefix = strings.TrimRight(prefix, "/") if len(c.CORS.AllowOrigins) == 0 { c.CORS.AllowOrigins = []string{"*"} } } func envStr(key string, dst *string) { if v := strings.TrimSpace(os.Getenv(key)); v != "" { *dst = v } } func envInt(key string, dst *int) { if v := strings.TrimSpace(os.Getenv(key)); v != "" { if n, err := strconv.Atoi(v); err == nil { *dst = n } } } func envBool(key string, dst *bool) { if v := strings.TrimSpace(strings.ToLower(os.Getenv(key))); v != "" { *dst = v == "1" || v == "true" || v == "yes" } }