This commit is contained in:
toom1996
2026-08-26 10:45:21 +08:00
parent 3cf1368e68
commit 30c9f21da4
48 changed files with 3442 additions and 0 deletions

281
internal/config/config.go Normal file
View File

@ -0,0 +1,281 @@
// 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"`
// 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"`
}
// 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"`
}
// 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"`
}
// 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",
Charset: "utf8mb4",
LogLevel: "warn",
MaxIdleConns: 10,
MaxOpenConns: 100,
ConnMaxLifetime: 3600,
},
JWT: JWTConfig{
Secret: "dev-secret-change-me-fashion-2026",
ExpireHours: 168,
},
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"},
MaxAge: 86400,
},
}
}
// 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)
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.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"
}
}