package config import ( "context" "fmt" "os" "strconv" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/redis/go-redis/v9" "github.com/spf13/viper" ) type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` Cache CacheConfig `mapstructure:"cache"` S3 S3Config `mapstructure:"s3"` JWT JWTConfig `mapstructure:"jwt"` Log LogConfig `mapstructure:"log"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` Upload UploadConfig `mapstructure:"upload"` Gorse GorseConfig `mapstructure:"gorse"` OpenAI OpenAIConfig `mapstructure:"openai"` Email EmailConfig `mapstructure:"email"` } type ServerConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Mode string `mapstructure:"mode"` } type DatabaseConfig struct { Type string `mapstructure:"type"` SQLite SQLiteConfig `mapstructure:"sqlite"` Postgres PostgresConfig `mapstructure:"postgres"` MaxIdleConns int `mapstructure:"max_idle_conns"` MaxOpenConns int `mapstructure:"max_open_conns"` LogLevel string `mapstructure:"log_level"` SlowThresholdMs int `mapstructure:"slow_threshold_ms"` IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"` ParameterizedQueries bool `mapstructure:"parameterized_queries"` } type SQLiteConfig struct { Path string `mapstructure:"path"` } type PostgresConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` DBName string `mapstructure:"dbname"` SSLMode string `mapstructure:"sslmode"` } func (d PostgresConfig) DSN() string { return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, ) } type RedisConfig struct { Type string `mapstructure:"type"` Redis RedisServerConfig `mapstructure:"redis"` Miniredis MiniredisConfig `mapstructure:"miniredis"` PoolSize int `mapstructure:"pool_size"` } type CacheConfig struct { Enabled bool `mapstructure:"enabled"` KeyPrefix string `mapstructure:"key_prefix"` DefaultTTL int `mapstructure:"default_ttl"` NullTTL int `mapstructure:"null_ttl"` JitterRatio float64 `mapstructure:"jitter_ratio"` DisableFlushDB bool `mapstructure:"disable_flushdb"` Modules CacheModuleTTL `mapstructure:"modules"` } type CacheModuleTTL struct { PostList int `mapstructure:"post_list_ttl"` Conversation int `mapstructure:"conversation_ttl"` UnreadCount int `mapstructure:"unread_count_ttl"` GroupMembers int `mapstructure:"group_members_ttl"` } type RedisServerConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Password string `mapstructure:"password"` DB int `mapstructure:"db"` } func (r RedisServerConfig) Addr() string { return fmt.Sprintf("%s:%d", r.Host, r.Port) } type MiniredisConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` } type S3Config struct { Endpoint string `mapstructure:"endpoint"` AccessKey string `mapstructure:"access_key"` SecretKey string `mapstructure:"secret_key"` Bucket string `mapstructure:"bucket"` UseSSL bool `mapstructure:"use_ssl"` Region string `mapstructure:"region"` Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin } type JWTConfig struct { Secret string `mapstructure:"secret"` AccessTokenExpire time.Duration `mapstructure:"access_token_expire"` RefreshTokenExpire time.Duration `mapstructure:"refresh_token_expire"` } type LogConfig struct { Level string `mapstructure:"level"` Encoding string `mapstructure:"encoding"` OutputPaths []string `mapstructure:"output_paths"` } type RateLimitConfig struct { Enabled bool `mapstructure:"enabled"` RequestsPerMinute int `mapstructure:"requests_per_minute"` } type UploadConfig struct { MaxFileSize int64 `mapstructure:"max_file_size"` AllowedTypes []string `mapstructure:"allowed_types"` } type GorseConfig struct { Address string `mapstructure:"address"` APIKey string `mapstructure:"api_key"` Enabled bool `mapstructure:"enabled"` Dashboard string `mapstructure:"dashboard"` ImportPassword string `mapstructure:"import_password"` EmbeddingAPIKey string `mapstructure:"embedding_api_key"` EmbeddingURL string `mapstructure:"embedding_url"` EmbeddingModel string `mapstructure:"embedding_model"` } type OpenAIConfig struct { Enabled bool `mapstructure:"enabled"` BaseURL string `mapstructure:"base_url"` APIKey string `mapstructure:"api_key"` ModerationModel string `mapstructure:"moderation_model"` ModerationMaxImagesPerRequest int `mapstructure:"moderation_max_images_per_request"` RequestTimeout int `mapstructure:"request_timeout"` StrictModeration bool `mapstructure:"strict_moderation"` } type EmailConfig struct { Enabled bool `mapstructure:"enabled"` Host string `mapstructure:"host"` Port int `mapstructure:"port"` Username string `mapstructure:"username"` Password string `mapstructure:"password"` FromAddress string `mapstructure:"from_address"` FromName string `mapstructure:"from_name"` UseTLS bool `mapstructure:"use_tls"` InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` Timeout int `mapstructure:"timeout"` } func Load(configPath string) (*Config, error) { viper.SetConfigFile(configPath) viper.SetConfigType("yaml") // 启用环境变量支持 viper.SetEnvPrefix("APP") viper.AutomaticEnv() // 允许环境变量使用下划线或连字符 viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) // Set default values viper.SetDefault("server.port", 8080) viper.SetDefault("server.mode", "debug") viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("database.type", "sqlite") viper.SetDefault("database.sqlite.path", "./data/carrot_bbs.db") viper.SetDefault("database.max_idle_conns", 10) viper.SetDefault("database.max_open_conns", 100) viper.SetDefault("database.log_level", "warn") viper.SetDefault("database.slow_threshold_ms", 200) viper.SetDefault("database.ignore_record_not_found", true) viper.SetDefault("database.parameterized_queries", true) viper.SetDefault("redis.type", "miniredis") viper.SetDefault("redis.redis.host", "localhost") viper.SetDefault("redis.redis.port", 6379) viper.SetDefault("redis.redis.password", "") viper.SetDefault("redis.redis.db", 0) viper.SetDefault("redis.miniredis.host", "localhost") viper.SetDefault("redis.miniredis.port", 6379) viper.SetDefault("redis.pool_size", 10) viper.SetDefault("cache.enabled", true) viper.SetDefault("cache.key_prefix", "") viper.SetDefault("cache.default_ttl", 30) viper.SetDefault("cache.null_ttl", 5) viper.SetDefault("cache.jitter_ratio", 0.1) viper.SetDefault("cache.disable_flushdb", true) viper.SetDefault("cache.modules.post_list_ttl", 30) viper.SetDefault("cache.modules.conversation_ttl", 60) viper.SetDefault("cache.modules.unread_count_ttl", 30) viper.SetDefault("cache.modules.group_members_ttl", 120) viper.SetDefault("jwt.secret", "your-jwt-secret-key-change-in-production") viper.SetDefault("jwt.access_token_expire", 86400) viper.SetDefault("jwt.refresh_token_expire", 604800) viper.SetDefault("log.level", "info") viper.SetDefault("log.encoding", "json") viper.SetDefault("log.output_paths", []string{"stdout", "./logs/app.log"}) viper.SetDefault("rate_limit.enabled", true) viper.SetDefault("rate_limit.requests_per_minute", 60) viper.SetDefault("upload.max_file_size", 10485760) viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"}) viper.SetDefault("s3.endpoint", "") viper.SetDefault("s3.access_key", "") viper.SetDefault("s3.secret_key", "") viper.SetDefault("s3.bucket", "") viper.SetDefault("s3.use_ssl", true) viper.SetDefault("s3.region", "us-east-1") viper.SetDefault("s3.domain", "") viper.SetDefault("sensitive.enabled", true) viper.SetDefault("sensitive.replace_str", "***") viper.SetDefault("audit.enabled", false) viper.SetDefault("audit.provider", "local") viper.SetDefault("gorse.enabled", false) viper.SetDefault("gorse.address", "http://localhost:8087") viper.SetDefault("gorse.api_key", "") viper.SetDefault("gorse.dashboard", "http://localhost:8088") viper.SetDefault("gorse.import_password", "") viper.SetDefault("gorse.embedding_api_key", "") viper.SetDefault("gorse.embedding_url", "https://api.littlelan.cn/v1/embeddings") viper.SetDefault("gorse.embedding_model", "BAAI/bge-m3") viper.SetDefault("openai.enabled", true) viper.SetDefault("openai.base_url", "https://api.littlelan.cn/") viper.SetDefault("openai.api_key", "") viper.SetDefault("openai.moderation_model", "qwen3.5-122b") viper.SetDefault("openai.moderation_max_images_per_request", 1) viper.SetDefault("openai.request_timeout", 30) viper.SetDefault("openai.strict_moderation", false) viper.SetDefault("email.enabled", false) viper.SetDefault("email.host", "") viper.SetDefault("email.port", 587) viper.SetDefault("email.username", "") viper.SetDefault("email.password", "") viper.SetDefault("email.from_address", "") viper.SetDefault("email.from_name", "Carrot BBS") viper.SetDefault("email.use_tls", true) viper.SetDefault("email.insecure_skip_verify", false) viper.SetDefault("email.timeout", 15) if err := viper.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read config: %w", err) } var cfg Config if err := viper.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } // Convert seconds to duration cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second // 环境变量覆盖(显式处理敏感配置) cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret) cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path) cfg.Database.Postgres.Host = getEnvOrDefault("APP_DATABASE_POSTGRES_HOST", cfg.Database.Postgres.Host) cfg.Database.Postgres.Port, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_POSTGRES_PORT", fmt.Sprintf("%d", cfg.Database.Postgres.Port))) cfg.Database.Postgres.User = getEnvOrDefault("APP_DATABASE_POSTGRES_USER", cfg.Database.Postgres.User) cfg.Database.Postgres.Password = getEnvOrDefault("APP_DATABASE_POSTGRES_PASSWORD", cfg.Database.Postgres.Password) cfg.Database.Postgres.DBName = getEnvOrDefault("APP_DATABASE_POSTGRES_DBNAME", cfg.Database.Postgres.DBName) cfg.Database.LogLevel = getEnvOrDefault("APP_DATABASE_LOG_LEVEL", cfg.Database.LogLevel) cfg.Database.SlowThresholdMs, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_SLOW_THRESHOLD_MS", fmt.Sprintf("%d", cfg.Database.SlowThresholdMs))) cfg.Database.IgnoreRecordNotFound, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_IGNORE_RECORD_NOT_FOUND", fmt.Sprintf("%t", cfg.Database.IgnoreRecordNotFound))) cfg.Database.ParameterizedQueries, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_PARAMETERIZED_QUERIES", fmt.Sprintf("%t", cfg.Database.ParameterizedQueries))) cfg.Redis.Redis.Host = getEnvOrDefault("APP_REDIS_REDIS_HOST", cfg.Redis.Redis.Host) cfg.Redis.Redis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Redis.Port))) cfg.Redis.Redis.Password = getEnvOrDefault("APP_REDIS_REDIS_PASSWORD", cfg.Redis.Redis.Password) cfg.Redis.Redis.DB, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_DB", fmt.Sprintf("%d", cfg.Redis.Redis.DB))) cfg.Redis.Miniredis.Host = getEnvOrDefault("APP_REDIS_MINIREDIS_HOST", cfg.Redis.Miniredis.Host) cfg.Redis.Miniredis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_MINIREDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Miniredis.Port))) cfg.Redis.Type = getEnvOrDefault("APP_REDIS_TYPE", cfg.Redis.Type) cfg.Cache.KeyPrefix = getEnvOrDefault("APP_CACHE_KEY_PREFIX", cfg.Cache.KeyPrefix) cfg.Cache.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_ENABLED", fmt.Sprintf("%t", cfg.Cache.Enabled))) cfg.Cache.DisableFlushDB, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_DISABLE_FLUSHDB", fmt.Sprintf("%t", cfg.Cache.DisableFlushDB))) cfg.Cache.DefaultTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_DEFAULT_TTL", fmt.Sprintf("%d", cfg.Cache.DefaultTTL))) cfg.Cache.NullTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_NULL_TTL", fmt.Sprintf("%d", cfg.Cache.NullTTL))) cfg.Cache.JitterRatio, _ = strconv.ParseFloat(getEnvOrDefault("APP_CACHE_JITTER_RATIO", fmt.Sprintf("%.2f", cfg.Cache.JitterRatio)), 64) cfg.Cache.Modules.PostList, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_POST_LIST_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.PostList))) cfg.Cache.Modules.Conversation, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_CONVERSATION_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.Conversation))) cfg.Cache.Modules.UnreadCount, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_UNREAD_COUNT_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.UnreadCount))) cfg.Cache.Modules.GroupMembers, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_GROUP_MEMBERS_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.GroupMembers))) cfg.S3.Endpoint = getEnvOrDefault("APP_S3_ENDPOINT", cfg.S3.Endpoint) cfg.S3.AccessKey = getEnvOrDefault("APP_S3_ACCESS_KEY", cfg.S3.AccessKey) cfg.S3.SecretKey = getEnvOrDefault("APP_S3_SECRET_KEY", cfg.S3.SecretKey) cfg.S3.Bucket = getEnvOrDefault("APP_S3_BUCKET", cfg.S3.Bucket) cfg.S3.Domain = getEnvOrDefault("APP_S3_DOMAIN", cfg.S3.Domain) cfg.Server.Host = getEnvOrDefault("APP_SERVER_HOST", cfg.Server.Host) cfg.Server.Port, _ = strconv.Atoi(getEnvOrDefault("APP_SERVER_PORT", fmt.Sprintf("%d", cfg.Server.Port))) cfg.Server.Mode = getEnvOrDefault("APP_SERVER_MODE", cfg.Server.Mode) cfg.Gorse.Address = getEnvOrDefault("APP_GORSE_ADDRESS", cfg.Gorse.Address) cfg.Gorse.APIKey = getEnvOrDefault("APP_GORSE_API_KEY", cfg.Gorse.APIKey) cfg.Gorse.Dashboard = getEnvOrDefault("APP_GORSE_DASHBOARD", cfg.Gorse.Dashboard) cfg.Gorse.ImportPassword = getEnvOrDefault("APP_GORSE_IMPORT_PASSWORD", cfg.Gorse.ImportPassword) cfg.Gorse.EmbeddingAPIKey = getEnvOrDefault("APP_GORSE_EMBEDDING_API_KEY", cfg.Gorse.EmbeddingAPIKey) cfg.Gorse.EmbeddingURL = getEnvOrDefault("APP_GORSE_EMBEDDING_URL", cfg.Gorse.EmbeddingURL) cfg.Gorse.EmbeddingModel = getEnvOrDefault("APP_GORSE_EMBEDDING_MODEL", cfg.Gorse.EmbeddingModel) cfg.OpenAI.BaseURL = getEnvOrDefault("APP_OPENAI_BASE_URL", cfg.OpenAI.BaseURL) cfg.OpenAI.APIKey = getEnvOrDefault("APP_OPENAI_API_KEY", cfg.OpenAI.APIKey) cfg.OpenAI.ModerationModel = getEnvOrDefault("APP_OPENAI_MODERATION_MODEL", cfg.OpenAI.ModerationModel) cfg.OpenAI.ModerationMaxImagesPerRequest, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_MODERATION_MAX_IMAGES_PER_REQUEST", fmt.Sprintf("%d", cfg.OpenAI.ModerationMaxImagesPerRequest))) cfg.OpenAI.RequestTimeout, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_REQUEST_TIMEOUT", fmt.Sprintf("%d", cfg.OpenAI.RequestTimeout))) cfg.OpenAI.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_ENABLED", fmt.Sprintf("%t", cfg.OpenAI.Enabled))) cfg.OpenAI.StrictModeration, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_STRICT_MODERATION", fmt.Sprintf("%t", cfg.OpenAI.StrictModeration))) cfg.Email.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_ENABLED", fmt.Sprintf("%t", cfg.Email.Enabled))) cfg.Email.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host) cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port))) cfg.Email.Username = getEnvOrDefault("APP_EMAIL_USERNAME", cfg.Email.Username) cfg.Email.Password = getEnvOrDefault("APP_EMAIL_PASSWORD", cfg.Email.Password) cfg.Email.FromAddress = getEnvOrDefault("APP_EMAIL_FROM_ADDRESS", cfg.Email.FromAddress) cfg.Email.FromName = getEnvOrDefault("APP_EMAIL_FROM_NAME", cfg.Email.FromName) cfg.Email.UseTLS, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_USE_TLS", fmt.Sprintf("%t", cfg.Email.UseTLS))) cfg.Email.InsecureSkipVerify, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_INSECURE_SKIP_VERIFY", fmt.Sprintf("%t", cfg.Email.InsecureSkipVerify))) cfg.Email.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_TIMEOUT", fmt.Sprintf("%d", cfg.Email.Timeout))) return &cfg, nil } // getEnvOrDefault 获取环境变量值,如果未设置则返回默认值 func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // NewRedis 创建Redis客户端(真实Redis) func NewRedis(cfg *RedisConfig) (*redis.Client, error) { client := redis.NewClient(&redis.Options{ Addr: cfg.Redis.Addr(), Password: cfg.Redis.Password, DB: cfg.Redis.DB, PoolSize: cfg.PoolSize, }) ctx := context.Background() if err := client.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("failed to connect to redis: %w", err) } return client, nil } // NewS3 创建S3客户端 func NewS3(cfg *S3Config) (*minio.Client, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() client, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: cfg.UseSSL, }) if err != nil { return nil, fmt.Errorf("failed to create S3 client: %w", err) } exists, err := client.BucketExists(ctx, cfg.Bucket) if err != nil { return nil, fmt.Errorf("failed to check bucket: %w", err) } if !exists { if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{ Region: cfg.Region, }); err != nil { return nil, fmt.Errorf("failed to create bucket: %w", err) } } return client, nil }