feat: 完善后端脚手架基础能力
This commit is contained in:
+17
-3
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user