414 lines
15 KiB
Go
414 lines
15 KiB
Go
// 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 PostgreSQL 连接与连接池配置。
|
||
type DatabaseConfig struct {
|
||
Host string `yaml:"host"`
|
||
Port string `yaml:"port"`
|
||
User string `yaml:"user"`
|
||
Password string `yaml:"password"`
|
||
Name string `yaml:"name"`
|
||
LogLevel string `yaml:"log_level"`
|
||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||
MaxOpenConns int `yaml:"max_open_conns"`
|
||
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
||
// ConnMaxIdleTime 空闲连接回收时间(秒)。避免长时间持有被服务端回收的死连接,
|
||
// 下一次查询报 "invalid connection" / "bad connection"。
|
||
ConnMaxIdleTime int `yaml:"conn_max_idle_time"`
|
||
}
|
||
|
||
// DSN 组装 PostgreSQL 连接串(pgx 驱动,本地开发禁用 SSL)。
|
||
func (d DatabaseConfig) DSN() string {
|
||
return fmt.Sprintf(
|
||
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||
d.User, d.Password, d.Host, d.Port, d.Name,
|
||
)
|
||
}
|
||
|
||
// 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: "5432",
|
||
User: "fashion",
|
||
Password: "fashion_dev_2026",
|
||
Name: "fashion",
|
||
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"
|
||
}
|
||
}
|