Files
backend_v2/internal/config/config.go
toom1996 74ba700598 update
2026-09-07 00:01:48 +08:00

415 lines
15 KiB
Go
Raw Permalink 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 负责应用配置的加载与校验。
//
// 配置以 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"`
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
Ingest IngestConfig `yaml:"ingest"`
// Qiniu 七牛云对象存储配置(爬虫入库图片上传目标)。
Qiniu QiniuConfig `yaml:"qiniu"`
// 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"`
// BackstagePort 是管理后台(Backstage)端口,承载后台 UI 与(后续)爬虫 ingest 内部接口。
// 与对外公开 Port / SSGPort 物理隔离,可独立绑定与限流;为空时兜底 "8092"。
BackstagePort string `yaml:"backstage_port"`
// 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"`
}
// IngestConfig 爬虫上报接口(:8092 /admin/internal/ingest)的 HMAC 验签配置。
//
// 爬虫与后台通过共享同一 INGEST_SECRET 对请求做 HMAC-SHA256 签名,
// 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。
// 密钥必须走环境变量 INGEST_SECRET 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
type IngestConfig struct {
// Enabled 开关:false 时 ingest 接口返回 503(尚未配置密钥),便于灰度。
Enabled bool `yaml:"enabled"`
// Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。
Secret string `yaml:"secret"`
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
TTLSeconds int `yaml:"ttl_seconds"`
}
// QiniuConfig 七牛云对象存储配置(爬虫入库图片上传目标)。
//
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传七牛 bucket,
// 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。
// - AK/SK 须与七牛控制台一致;生产经环境变量 QINIU_AK / QINIU_SK 注入。
// - Zone 必须匹配 bucket 创建区域,否则上传失败:z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡。
// - BaseURL 为 bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL。
// - StyleNormal 普通图七牛命名样式名(见 imgurl 包):详情页对免费/未登录用户拼
// base_url/key-StyleNormal;须与七牛控制台创建的样式名完全一致。
type QiniuConfig struct {
Enabled bool `yaml:"enabled"`
AK string `yaml:"ak"`
SK string `yaml:"sk"`
Bucket string `yaml:"bucket"`
Zone string `yaml:"zone"`
BaseURL string `yaml:"base_url"`
// StyleNormal 普通图七牛命名样式名(如 free 或 free.webp)。
StyleNormal string `yaml:"style_normal"`
// StyleVip VIP 高清七牛命名样式名(如 vip 或 vip.webp)。VIP tier 走此样式。
StyleVip string `yaml:"style_vip"`
// LocalBase 本地兜底图片前缀(通常空;前端用 toAbs 拼 host)。仅当静态服务不在同域时有用。
LocalBase string `yaml:"local_base"`
// SignTTLNormal 普通/压缩图(含命名样式 -free)的私有空间下载签名过期秒;长过期便于 SSG 静态页与 CDN 缓存。
SignTTLNormal int64 `yaml:"sign_ttl_normal"`
// SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。
SignTTLHD int64 `yaml:"sign_ttl_hd"`
}
// 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: "",
BackstagePort: "8092",
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,
},
Ingest: IngestConfig{
Enabled: false,
Secret: "",
TTLSeconds: 300,
},
Qiniu: QiniuConfig{
Enabled: false,
Zone: "z1",
BaseURL: "",
StyleNormal: "free",
StyleVip: "vip",
LocalBase: "",
SignTTLNormal: 31536000,
SignTTLHD: 3600,
},
}
}
// 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("BACKSTAGE_PORT", &c.Server.BackstagePort)
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)
envBool("INGEST_ENABLED", &c.Ingest.Enabled)
envStr("INGEST_SECRET", &c.Ingest.Secret)
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
envBool("QINIU_ENABLED", &c.Qiniu.Enabled)
envStr("QINIU_AK", &c.Qiniu.AK)
envStr("QINIU_SK", &c.Qiniu.SK)
envStr("QINIU_BUCKET", &c.Qiniu.Bucket)
envStr("QINIU_ZONE", &c.Qiniu.Zone)
envStr("QINIU_BASE_URL", &c.Qiniu.BaseURL)
envStr("QINIU_STYLE_NORMAL", &c.Qiniu.StyleNormal)
envStr("QINIU_STYLE_VIP", &c.Qiniu.StyleVip)
envStr("QINIU_LOCAL_BASE", &c.Qiniu.LocalBase)
envInt64("QINIU_SIGN_TTL_NORMAL", &c.Qiniu.SignTTLNormal)
envInt64("QINIU_SIGN_TTL_HD", &c.Qiniu.SignTTLHD)
}
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
func (c *Config) normalize() {
if c.Server.Port == "" {
c.Server.Port = "8090"
}
if c.Server.SSGPort == "" {
c.Server.SSGPort = "8091"
}
if c.Server.BackstagePort == "" {
c.Server.BackstagePort = "8092"
}
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 envInt64(key string, dst *int64) {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); 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"
}
}