feat: 完善后端脚手架基础能力

This commit is contained in:
2026-08-09 01:06:21 +08:00
parent e311f416f2
commit f95311a107
61 changed files with 5630 additions and 521 deletions
+17 -3
View File
@@ -17,17 +17,21 @@ logger:
compress: false
database:
driver: "postgres" # postgres, mysql, sqlite
dsn: "" # 可选;填写后覆盖下方自动拼接的连接参数
host: "127.0.0.1"
port: 2000
port: 0 # 0 表示按 driver 使用默认端口
username: "root"
password: "123456"
dbname: "skeleton"
sslmode: "disable"
max_idle_conns: 10
max_open_conns: 100
sqlite_path: "data/skeleton.db"
max_idle_conns: 0 # 0 表示使用 driver 默认值
max_open_conns: 0 # SQLite 默认 1,服务端数据库默认 100
conn_max_lifetime: 60
redis:
enabled: false
host: "127.0.0.1"
port: 2001
password: "123456"
@@ -39,4 +43,14 @@ redis:
jwt:
secret: "change-this-secret-key-in-production"
expire_hours: 24
access_expire_minutes: 15
refresh_expire_hours: 168
issuer: "HeTianXia"
observability:
metrics_enabled: true
metrics_path: "/metrics"
tracing_enabled: false
tracing_service: "skeleton"
tracing_endpoint: "localhost:4318"
tracing_insecure: true
+166 -22
View File
@@ -3,6 +3,8 @@ package config
import (
"fmt"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
@@ -28,18 +30,22 @@ type LoggerConfig struct {
}
type DatabaseConfig struct {
Driver string `yaml:"driver"`
DSN string `yaml:"dsn"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
DBName string `yaml:"dbname"`
SSLMode string `yaml:"sslmode"`
SQLitePath string `yaml:"sqlite_path"`
MaxIdleConns int `yaml:"max_idle_conns"`
MaxOpenConns int `yaml:"max_open_conns"`
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
}
type RedisConfig struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Password string `yaml:"password"`
@@ -50,21 +56,41 @@ type RedisConfig struct {
}
type JWTConfig struct {
Secret string `yaml:"secret"`
ExpireHours int `yaml:"expire_hours"`
Issuer string `yaml:"issuer"`
Secret string `yaml:"secret"`
ExpireHours int `yaml:"expire_hours"` // 兼容旧配置,作为 access token 默认值
AccessExpireMinutes int `yaml:"access_expire_minutes"`
RefreshExpireHours int `yaml:"refresh_expire_hours"`
Issuer string `yaml:"issuer"`
}
type ObservabilityConfig struct {
MetricsEnabled bool `yaml:"metrics_enabled"`
MetricsPath string `yaml:"metrics_path"`
TracingEnabled bool `yaml:"tracing_enabled"`
TracingService string `yaml:"tracing_service"`
TracingEndpoint string `yaml:"tracing_endpoint"`
TracingInsecure bool `yaml:"tracing_insecure"`
}
type Config struct {
App AppConfig `yaml:"app"`
Logger LoggerConfig `yaml:"logger"`
Database DatabaseConfig `yaml:"database"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
App AppConfig `yaml:"app"`
Logger LoggerConfig `yaml:"logger"`
Database DatabaseConfig `yaml:"database"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
Observability ObservabilityConfig `yaml:"observability"`
}
func Load() (*Config, error) {
data, err := os.ReadFile("config/app.yaml")
path := os.Getenv("SKELETON_CONFIG")
if path == "" {
path = "config/app.yaml"
}
return LoadFrom(path)
}
func LoadFrom(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
@@ -74,10 +100,94 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
cfg.applyEnvironment()
cfg.setDefaults()
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) validate() error {
switch c.Database.Driver {
case "postgres", "mysql", "sqlite":
default:
return fmt.Errorf("不支持的数据库驱动 %q", c.Database.Driver)
}
if strings.EqualFold(c.App.Environment, "production") &&
(c.JWT.Secret == "change-this-secret-key-in-production" || len(c.JWT.Secret) < 32) {
return fmt.Errorf("生产环境 JWT_SECRET 必须修改且至少 32 个字符")
}
return nil
}
func (c *Config) applyEnvironment() {
stringEnv("APP_HOST", &c.App.Host)
intEnv("APP_PORT", &c.App.Port)
stringEnv("APP_VERSION", &c.App.Version)
boolEnv("APP_DEBUG", &c.App.Debug)
stringEnv("APP_TIMEZONE", &c.App.Timezone)
stringEnv("APP_ENVIRONMENT", &c.App.Environment)
stringEnv("LOG_LEVEL", &c.Logger.Level)
stringEnv("LOG_FORMAT", &c.Logger.Format)
stringEnv("LOG_OUTPUT", &c.Logger.Output)
stringEnv("LOG_FILENAME", &c.Logger.Filename)
stringEnv("DATABASE_DRIVER", &c.Database.Driver)
stringEnv("DATABASE_DSN", &c.Database.DSN)
stringEnv("DATABASE_HOST", &c.Database.Host)
intEnv("DATABASE_PORT", &c.Database.Port)
stringEnv("DATABASE_USERNAME", &c.Database.Username)
stringEnv("DATABASE_PASSWORD", &c.Database.Password)
stringEnv("DATABASE_NAME", &c.Database.DBName)
stringEnv("DATABASE_SSLMODE", &c.Database.SSLMode)
stringEnv("DATABASE_SQLITE_PATH", &c.Database.SQLitePath)
intEnv("DATABASE_MAX_IDLE_CONNS", &c.Database.MaxIdleConns)
intEnv("DATABASE_MAX_OPEN_CONNS", &c.Database.MaxOpenConns)
intEnv("DATABASE_CONN_MAX_LIFETIME", &c.Database.ConnMaxLifetime)
boolEnv("REDIS_ENABLED", &c.Redis.Enabled)
stringEnv("REDIS_HOST", &c.Redis.Host)
intEnv("REDIS_PORT", &c.Redis.Port)
stringEnv("REDIS_PASSWORD", &c.Redis.Password)
intEnv("REDIS_DATABASE", &c.Redis.Database)
stringEnv("JWT_SECRET", &c.JWT.Secret)
intEnv("JWT_ACCESS_EXPIRE_MINUTES", &c.JWT.AccessExpireMinutes)
intEnv("JWT_REFRESH_EXPIRE_HOURS", &c.JWT.RefreshExpireHours)
stringEnv("JWT_ISSUER", &c.JWT.Issuer)
boolEnv("METRICS_ENABLED", &c.Observability.MetricsEnabled)
stringEnv("METRICS_PATH", &c.Observability.MetricsPath)
boolEnv("TRACING_ENABLED", &c.Observability.TracingEnabled)
stringEnv("OTEL_SERVICE_NAME", &c.Observability.TracingService)
stringEnv("OTEL_EXPORTER_OTLP_ENDPOINT", &c.Observability.TracingEndpoint)
boolEnv("OTEL_EXPORTER_OTLP_INSECURE", &c.Observability.TracingInsecure)
}
func stringEnv(key string, target *string) {
if value, ok := os.LookupEnv(key); ok {
*target = value
}
}
func intEnv(key string, target *int) {
if value, ok := os.LookupEnv(key); ok {
if parsed, err := strconv.Atoi(value); err == nil {
*target = parsed
}
}
}
func boolEnv(key string, target *bool) {
if value, ok := os.LookupEnv(key); ok {
if parsed, err := strconv.ParseBool(value); err == nil {
*target = parsed
}
}
}
func (c *Config) setDefaults() {
if c.App.Host == "" {
c.App.Host = "0.0.0.0"
@@ -114,14 +224,43 @@ func (c *Config) setDefaults() {
c.Logger.MaxBackups = 3
}
if c.Database.Driver == "" {
c.Database.Driver = "postgres"
}
c.Database.Driver = strings.ToLower(strings.TrimSpace(c.Database.Driver))
switch c.Database.Driver {
case "postgresql":
c.Database.Driver = "postgres"
case "sqlite3":
c.Database.Driver = "sqlite"
}
if c.Database.Port == 0 {
switch c.Database.Driver {
case "postgres":
c.Database.Port = 5432
case "mysql":
c.Database.Port = 3306
}
}
if c.Database.SSLMode == "" {
c.Database.SSLMode = "disable"
}
if c.Database.SQLitePath == "" {
c.Database.SQLitePath = "data/skeleton.db"
}
if c.Database.MaxIdleConns == 0 {
c.Database.MaxIdleConns = 10
if c.Database.Driver == "sqlite" {
c.Database.MaxIdleConns = 1
} else {
c.Database.MaxIdleConns = 10
}
}
if c.Database.MaxOpenConns == 0 {
c.Database.MaxOpenConns = 100
if c.Database.Driver == "sqlite" {
c.Database.MaxOpenConns = 1
} else {
c.Database.MaxOpenConns = 100
}
}
if c.Database.ConnMaxLifetime == 0 {
c.Database.ConnMaxLifetime = 60
@@ -152,22 +291,27 @@ func (c *Config) setDefaults() {
if c.JWT.ExpireHours == 0 {
c.JWT.ExpireHours = 24
}
if c.JWT.AccessExpireMinutes == 0 {
c.JWT.AccessExpireMinutes = c.JWT.ExpireHours * 60
}
if c.JWT.RefreshExpireHours == 0 {
c.JWT.RefreshExpireHours = 24 * 7
}
if c.JWT.Issuer == "" {
c.JWT.Issuer = "HeTianXia"
}
if c.Observability.MetricsPath == "" {
c.Observability.MetricsPath = "/metrics"
}
if c.Observability.TracingService == "" {
c.Observability.TracingService = "skeleton"
}
if c.Observability.TracingEndpoint == "" {
c.Observability.TracingEndpoint = "localhost:4318"
}
}
func (c *Config) GetAddr() string {
return fmt.Sprintf("%s:%d", c.App.Host, c.App.Port)
}
func (c *Config) GetDSN() string {
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
c.Database.Host,
c.Database.Port,
c.Database.Username,
c.Database.Password,
c.Database.DBName,
c.Database.SSLMode,
)
}
+53
View File
@@ -0,0 +1,53 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFromEnvironmentOverridesYAML(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.yaml")
if err := os.WriteFile(path, []byte("database:\n driver: postgres\nredis:\n enabled: false\n"), 0600); err != nil {
t.Fatal(err)
}
t.Setenv("DATABASE_DRIVER", "mysql")
t.Setenv("DATABASE_PORT", "3307")
t.Setenv("REDIS_ENABLED", "true")
t.Setenv("JWT_ACCESS_EXPIRE_MINUTES", "30")
cfg, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom() error = %v", err)
}
if cfg.Database.Driver != "mysql" || cfg.Database.Port != 3307 {
t.Fatalf("database override failed: %+v", cfg.Database)
}
if !cfg.Redis.Enabled || cfg.JWT.AccessExpireMinutes != 30 {
t.Fatalf("environment override failed: redis=%v jwt=%+v", cfg.Redis.Enabled, cfg.JWT)
}
}
func TestSQLiteDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.yaml")
if err := os.WriteFile(path, []byte("database:\n driver: sqlite\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := LoadFrom(path)
if err != nil {
t.Fatal(err)
}
if cfg.Database.SQLitePath == "" || cfg.Database.MaxOpenConns != 1 {
t.Fatalf("unexpected SQLite defaults: %+v", cfg.Database)
}
}
func TestProductionRejectsDefaultJWTSecret(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.yaml")
if err := os.WriteFile(path, []byte("app:\n environment: production\ndatabase:\n driver: sqlite\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := LoadFrom(path); err == nil {
t.Fatal("production config accepted the default JWT secret")
}
}