// Package config 读取并校验 API 服务运行所需的环境变量。 package config import ( "fmt" "net/url" "os" "strconv" "strings" "github.com/joho/godotenv" ) // Config 汇总数据库、缓存、认证、腾讯云 COS 和本地媒体处理配置。 type Config struct { AppEnv string Debug bool ServerAddress string DatabaseURL string RedisAddress string RedisPassword string RedisDB int AsynqRedisDB int CORSOrigins []string StaticDirectory string JWTSecretKey string JWTAccessMinutes int JWTRefreshHours int BootstrapAdminUsername string BootstrapAdminPassword string Argon2Time uint32 Argon2Memory uint32 Argon2Parallelism uint8 Argon2HashLength uint32 Argon2SaltLength uint32 EncryptionKeyVersion string EncryptionKey string COSSecretID string COSSecretKey string COSBucket string COSRegion string COSEndpoint string COSPublicBaseURL string COSMaxImageSizeMB int COSMaxVideoSizeMB int COSMaxAudioSizeMB int AIWorkerConcurrency int AIPollIntervalSeconds int AIHTTPMaxConnections int FFmpegPath string FFprobePath string PythonPath string ASRScriptPath string } // Load 从本地环境文件和进程环境读取配置,并校验数值范围。 func Load() (Config, error) { _ = godotenv.Load() databasePort, err := envInt("DATABASE_PORT", 25432) if err != nil { return Config{}, err } redisPort, err := envInt("REDIS_PORT", 26379) if err != nil { return Config{}, err } redisDB, err := envInt("REDIS_DB", 0) if err != nil { return Config{}, err } asynqRedisDB, err := envInt("ASYNQ_REDIS_DB", 1) if err != nil { return Config{}, err } jwtAccessMinutes, err := envInt("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", 60) if err != nil { return Config{}, err } jwtRefreshHours, err := envInt("JWT_REFRESH_TOKEN_EXPIRE_HOURS", 168) if err != nil { return Config{}, err } argon2Time, err := envInt("ARGON2_TIME_COST", 2) if err != nil { return Config{}, err } argon2Memory, err := envInt("ARGON2_MEMORY_COST", 19456) if err != nil { return Config{}, err } argon2Parallelism, err := envInt("ARGON2_PARALLELISM", 1) if err != nil { return Config{}, err } argon2HashLength, err := envInt("ARGON2_HASH_LENGTH", 32) if err != nil { return Config{}, err } argon2SaltLength, err := envInt("ARGON2_SALT_LENGTH", 16) if err != nil { return Config{}, err } cosMaxImageSizeMB, err := envInt("COS_MAX_IMAGE_SIZE_MB", 20) if err != nil { return Config{}, err } cosMaxVideoSizeMB, err := envInt("COS_MAX_VIDEO_SIZE_MB", 100) if err != nil { return Config{}, err } cosMaxAudioSizeMB, err := envInt("COS_MAX_AUDIO_SIZE_MB", 30) if err != nil { return Config{}, err } aiWorkerConcurrency, err := envInt("AI_WORKER_CONCURRENCY", 100) if err != nil { return Config{}, err } aiPollIntervalSeconds, err := envInt("AI_POLL_INTERVAL_SECONDS", 10) if err != nil { return Config{}, err } aiHTTPMaxConnections, err := envInt("AI_HTTP_MAX_CONNECTIONS", 200) if err != nil { return Config{}, err } if cosMaxImageSizeMB < 1 || cosMaxVideoSizeMB < 1 || cosMaxAudioSizeMB < 1 { return Config{}, fmt.Errorf("COS 文件大小限制必须为正整数") } if aiWorkerConcurrency < 1 || aiWorkerConcurrency > 1000 { return Config{}, fmt.Errorf("AI_WORKER_CONCURRENCY 必须在 1 至 1000 之间") } if aiPollIntervalSeconds < 2 || aiPollIntervalSeconds > 300 { return Config{}, fmt.Errorf("AI_POLL_INTERVAL_SECONDS 必须在 2 至 300 之间") } if aiHTTPMaxConnections < 10 || aiHTTPMaxConnections > 5000 { return Config{}, fmt.Errorf("AI_HTTP_MAX_CONNECTIONS 必须在 10 至 5000 之间") } debug, err := strconv.ParseBool(env("DEBUG", "true")) if err != nil { return Config{}, fmt.Errorf("DEBUG 必须是布尔值: %w", err) } databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) if databaseURL == "" { databaseURL = buildDatabaseURL(databasePort) } else if parsed, parseErr := url.Parse(databaseURL); parseErr == nil { query := parsed.Query() if query.Get("TimeZone") == "" { query.Set("TimeZone", "Asia/Shanghai") } parsed.RawQuery = query.Encode() databaseURL = parsed.String() } return Config{ AppEnv: env("APP_ENV", "development"), Debug: debug, ServerAddress: env("SERVER_ADDRESS", ":8900"), DatabaseURL: databaseURL, RedisAddress: fmt.Sprintf("%s:%d", env("REDIS_HOST", "127.0.0.1"), redisPort), RedisPassword: os.Getenv("REDIS_PASSWORD"), RedisDB: redisDB, AsynqRedisDB: asynqRedisDB, CORSOrigins: splitCSV(env("CORS_ORIGINS", "http://localhost:5500,http://localhost:5501")), StaticDirectory: env("STATIC_DIRECTORY", "static"), JWTSecretKey: env("JWT_SECRET_KEY", "replace-with-a-strong-random-secret"), JWTAccessMinutes: jwtAccessMinutes, JWTRefreshHours: jwtRefreshHours, BootstrapAdminUsername: env("ADMIN_BOOTSTRAP_USERNAME", "admin"), BootstrapAdminPassword: os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"), Argon2Time: uint32(argon2Time), Argon2Memory: uint32(argon2Memory), Argon2Parallelism: uint8(argon2Parallelism), Argon2HashLength: uint32(argon2HashLength), Argon2SaltLength: uint32(argon2SaltLength), EncryptionKeyVersion: env("CONFIG_ENCRYPTION_KEY_VERSION", "v1"), EncryptionKey: os.Getenv("CONFIG_ENCRYPTION_KEY"), COSSecretID: os.Getenv("COS_SECRET_ID"), COSSecretKey: os.Getenv("COS_SECRET_KEY"), COSBucket: os.Getenv("COS_BUCKET"), COSRegion: env("COS_REGION", "ap-chengdu"), COSEndpoint: os.Getenv("COS_ENDPOINT"), COSPublicBaseURL: strings.TrimRight(os.Getenv("COS_PUBLIC_BASE_URL"), "/"), COSMaxImageSizeMB: cosMaxImageSizeMB, COSMaxVideoSizeMB: cosMaxVideoSizeMB, COSMaxAudioSizeMB: cosMaxAudioSizeMB, AIWorkerConcurrency: aiWorkerConcurrency, AIPollIntervalSeconds: aiPollIntervalSeconds, AIHTTPMaxConnections: aiHTTPMaxConnections, FFmpegPath: env("FFMPEG_PATH", "ffmpeg"), FFprobePath: env("FFPROBE_PATH", "ffprobe"), PythonPath: env("PYTHON_PATH", "python3"), ASRScriptPath: env("ASR_SCRIPT_PATH", "workers/transcribe.py"), }, nil } func buildDatabaseURL(port int) string { credentials := url.UserPassword( env("DATABASE_USER", "postgres"), env("DATABASE_PASSWORD", "juchuang_dev"), ).String() return fmt.Sprintf( "postgres://%s@%s:%d/%s?sslmode=disable&TimeZone=Asia/Shanghai", credentials, env("DATABASE_HOST", "127.0.0.1"), port, url.PathEscape(env("DATABASE_NAME", "juchuang_factory")), ) } func env(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func envInt(key string, fallback int) (int, error) { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback, nil } parsed, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("%s 必须是整数: %w", key, err) } return parsed, nil } func splitCSV(value string) []string { items := strings.Split(value, ",") result := make([]string, 0, len(items)) for _, item := range items { if item = strings.TrimSpace(item); item != "" { result = append(result, item) } } return result }