Files
backend_v2/internal/config/config.go
toom1996 dbee704c95 update
2026-09-13 00:52:35 +08:00

420 lines
16 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 负责应用配置的加载与校验。
//
// 配置以 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"`
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
S4 S4Config `yaml:"s4"`
// 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 {
// Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。
Secret string `yaml:"secret"`
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
TTLSeconds int `yaml:"ttl_seconds"`
}
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
//
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传 S4 bucket,
// 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。
//
// 图片质量模型(见 internal/pkg/imgurl,2026-09-11 调整):VIP 与免费用户同质量,
// 统一走 CoreIX 公开样式 StyleDisplay(如 high,key!style:high 匿名可访问);付费墙已取消。
// - AK/SK 为缤纷云子账户密钥(须有 PutObject/DeleteObject/GetObject 权限);
// 生产经环境变量 S4_AK / S4_SK 注入。
// - Endpoint 默认 https://s3.bitiful.net;Region 任意(如 cn-east-1)。
// - BaseURL 为对外访问域名;留空自动推导 https://<bucket>.s3.bitiful.net(须与 Endpoint 同 host)。
// - StyleDisplay 全量展示图 CoreIX 样式名(如 high,控制台建公开样式)或查询串(如 w=1080&q=80&fmt=webp)。
// - StyleThumb 列表缩略图 CoreIX 样式名(如 thumb,控制台建公开样式),仅用于「列表/卡片」等
// 小尺寸场景(列表封面、卡片图条、首页热门位),详情页与大图灯箱仍走 StyleDisplay,兼顾清晰度与流量。
// 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致,不报错)。
type S4Config struct {
Enabled bool `yaml:"enabled"`
AK string `yaml:"ak"`
SK string `yaml:"sk"`
Bucket string `yaml:"bucket"`
Endpoint string `yaml:"endpoint"`
Region string `yaml:"region"`
BaseURL string `yaml:"base_url"`
StyleDisplay string `yaml:"style_display"`
StyleThumb string `yaml:"style_thumb"`
}
// 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{
Secret: "",
TTLSeconds: 300,
},
S4: S4Config{
Enabled: false,
Endpoint: "https://s3.bitiful.net",
Region: "cn-east-1",
BaseURL: "",
StyleDisplay: "high",
StyleThumb: "thumb",
},
}
}
// 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)
envStr("INGEST_SECRET", &c.Ingest.Secret)
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
envBoolFirst(&c.S4.Enabled, "S4_ENABLED", "QINIU_ENABLED")
envStrFirst(&c.S4.AK, "S4_AK", "QINIU_AK")
envStrFirst(&c.S4.SK, "S4_SK", "QINIU_SK")
envStrFirst(&c.S4.Bucket, "S4_BUCKET", "QINIU_BUCKET")
envStr("S4_ENDPOINT", &c.S4.Endpoint)
envStr("S4_REGION", &c.S4.Region)
envStrFirst(&c.S4.BaseURL, "S4_BASE_URL", "QINIU_BASE_URL")
envStrFirst(&c.S4.StyleDisplay, "S4_STYLE_DISPLAY", "QINIU_STYLE_NORMAL")
envStr("S4_STYLE_THUMB", &c.S4.StyleThumb)
}
// 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
}
}
// envStrFirst 按顺序取第一个非空的环境变量写入 dst(前者优先),用于平滑切换旧变量名。
func envStrFirst(dst *string, keys ...string) {
for _, k := range keys {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
*dst = v
return
}
}
}
// envBoolFirst 按顺序取第一个非空的环境变量写入 dst(前者优先)。
func envBoolFirst(dst *bool, keys ...string) {
for _, k := range keys {
if v := strings.TrimSpace(strings.ToLower(os.Getenv(k))); v != "" {
*dst = v == "1" || v == "true" || v == "yes"
return
}
}
}
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"
}
}