Files
iqudo-top1/internal/config/config.go
2026-08-19 22:01:54 +08:00

215 lines
8.5 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ilyakaznacheev/cleanenv"
)
type Config struct {
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
Seed SeedConfig `yaml:"seed"`
MultiTable MultiTableConfig `yaml:"multitable"`
}
type AppConfig struct {
Name string `yaml:"name" env:"APP_NAME" env-default:"iqudo-top1"`
Env string `yaml:"env" env:"APP_ENV" env-default:"development"`
}
type ServerConfig struct {
Host string `yaml:"host" env:"APP_SERVER_HOST" env-default:"127.0.0.1"`
Port int `yaml:"port" env:"APP_SERVER_PORT" env-default:"8080"`
ReadTimeout time.Duration `yaml:"read_timeout" env:"APP_SERVER_READ_TIMEOUT" env-default:"10s"`
WriteTimeout time.Duration `yaml:"write_timeout" env:"APP_SERVER_WRITE_TIMEOUT" env-default:"20s"`
ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"APP_SERVER_SHUTDOWN_TIMEOUT" env-default:"10s"`
}
func (c ServerConfig) Address() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
type DatabaseConfig struct {
Host string `yaml:"host" env:"APP_DATABASE_HOST" env-default:"127.0.0.1"`
Port int `yaml:"port" env:"APP_DATABASE_PORT" env-default:"3306"`
Name string `yaml:"name" env:"APP_DATABASE_NAME"`
User string `yaml:"user" env:"APP_DATABASE_USER"`
Password string `yaml:"password" env:"APP_DATABASE_PASSWORD"`
MaxIdleConnections int `yaml:"max_idle_connections" env:"APP_DATABASE_MAX_IDLE_CONNECTIONS" env-default:"10"`
MaxOpenConnections int `yaml:"max_open_connections" env:"APP_DATABASE_MAX_OPEN_CONNECTIONS" env-default:"40"`
ConnectionMaxLifetime time.Duration `yaml:"connection_max_lifetime" env:"APP_DATABASE_CONNECTION_MAX_LIFETIME" env-default:"30m"`
}
func (c DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true", c.User, c.Password, c.Host, c.Port, c.Name)
}
type AuthConfig struct {
JWTSecret string `yaml:"jwt_secret" env:"APP_AUTH_JWT_SECRET"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl" env:"APP_AUTH_ACCESS_TOKEN_TTL" env-default:"2h"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl" env:"APP_AUTH_REFRESH_TOKEN_TTL" env-default:"168h"`
}
type SeedConfig struct {
AdminUsername string `yaml:"admin_username" env:"APP_SEED_ADMIN_USERNAME"`
AdminPassword string `yaml:"admin_password" env:"APP_SEED_ADMIN_PASSWORD"`
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"`
}
type MultiTableConfig struct {
Enabled bool `yaml:"enabled" env:"APP_MULTITABLE_ENABLED" env-default:"false"`
BaseURL string `yaml:"base_url" env:"APP_MULTITABLE_BASE_URL" env-default:"https://table.iwork-ai.com/open/v1"`
APIKey string `yaml:"api_key" env:"APP_MULTITABLE_API_KEY"`
SyncInterval time.Duration `yaml:"sync_interval" env:"APP_MULTITABLE_SYNC_INTERVAL" env-default:"5s"`
RequestTimeout time.Duration `yaml:"request_timeout" env:"APP_MULTITABLE_REQUEST_TIMEOUT" env-default:"10s"`
BatchSize int `yaml:"batch_size" env:"APP_MULTITABLE_BATCH_SIZE" env-default:"20"`
MaxAttempts int `yaml:"max_attempts" env:"APP_MULTITABLE_MAX_ATTEMPTS" env-default:"12"`
Tables MultiTableTables `yaml:"tables"`
}
type MultiTableTables struct {
Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"`
ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"`
ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"`
SOPVersions uint64 `yaml:"sop_versions" env:"APP_MULTITABLE_TABLES_SOP_VERSIONS"`
SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"`
SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"`
KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"`
KnowledgeRelations uint64 `yaml:"knowledge_relations" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS"`
// KnowledgeVersions is retained only for replaying historical projection events.
KnowledgeVersions uint64 `yaml:"knowledge_versions" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS"`
Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"`
Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"`
}
type LoadOptions struct {
Environment string
ConfigDir string
ConfigFile string
}
func Load(options LoadOptions) (Config, error) {
configDir := options.ConfigDir
if configDir == "" {
configDir = "configs"
}
environment := normalizeEnvironment(options.Environment)
if environment == "" {
environment = normalizeEnvironment(os.Getenv("APP_ENV"))
}
if options.ConfigFile != "" && environment == "" {
environment = normalizeEnvironment(environmentFromConfigFile(options.ConfigFile))
}
var cfg Config
basePath := filepath.Join(configDir, "config.yml")
if err := cleanenv.ReadConfig(basePath, &cfg); err != nil {
return Config{}, fmt.Errorf("load base config %s: %w", basePath, err)
}
if options.ConfigFile != "" {
profilePath := options.ConfigFile
if !filepath.IsAbs(profilePath) {
profilePath = filepath.Join(configDir, profilePath)
}
if err := cleanenv.ReadConfig(profilePath, &cfg); err != nil {
return Config{}, fmt.Errorf("load config file %s: %w", profilePath, err)
}
} else if environment == "test" || environment == "prod" {
profilePath := filepath.Join(configDir, "config."+environment+".yml")
if err := cleanenv.ReadConfig(profilePath, &cfg); err != nil {
return Config{}, fmt.Errorf("load profile config %s: %w", profilePath, err)
}
}
if err := cleanenv.ReadEnv(&cfg); err != nil {
return Config{}, fmt.Errorf("load environment variables: %w", err)
}
if environment != "" {
cfg.App.Env = environment
}
if cfg.App.Env == "prod" {
if strings.TrimSpace(os.Getenv("APP_DATABASE_PASSWORD")) == "" {
return Config{}, errors.New("APP_DATABASE_PASSWORD must be set in production")
}
if strings.TrimSpace(os.Getenv("APP_AUTH_JWT_SECRET")) == "" {
return Config{}, errors.New("APP_AUTH_JWT_SECRET must be set in production")
}
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func environmentFromConfigFile(path string) string {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
return strings.TrimPrefix(name, "config.")
}
func normalizeEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "production", "prod":
return "prod"
case "testing", "test":
return "test"
case "development", "dev", "local":
return "development"
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func (c Config) Validate() error {
if c.Database.Name == "" || c.Database.User == "" {
return errors.New("database name and user are required")
}
if len(c.Auth.JWTSecret) < 16 {
return errors.New("auth.jwt_secret must contain at least 16 characters")
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return errors.New("server.port must be between 1 and 65535")
}
if c.App.Env == "prod" && c.Database.Password == "" {
return errors.New("database password is required in production")
}
if c.MultiTable.Enabled {
if !strings.HasPrefix(c.MultiTable.BaseURL, "https://") {
return errors.New("multitable.base_url must use https when multitable is enabled")
}
if strings.TrimSpace(c.MultiTable.APIKey) == "" {
return errors.New("APP_MULTITABLE_API_KEY is required when multitable is enabled")
}
if c.MultiTable.SyncInterval <= 0 || c.MultiTable.RequestTimeout <= 0 || c.MultiTable.BatchSize < 1 || c.MultiTable.MaxAttempts < 1 {
return errors.New("multitable retry and timeout settings must be positive")
}
ids := []uint64{
c.MultiTable.Tables.Scenarios,
c.MultiTable.Tables.ScenarioFields,
c.MultiTable.Tables.ScenarioRules,
c.MultiTable.Tables.SOPVersions,
c.MultiTable.Tables.SOPNodes,
c.MultiTable.Tables.SOPEdges,
c.MultiTable.Tables.KnowledgeItems,
c.MultiTable.Tables.KnowledgeRelations,
c.MultiTable.Tables.Runs,
c.MultiTable.Tables.Feedback,
}
for _, id := range ids {
if id == 0 {
return errors.New("all multitable table IDs are required when multitable is enabled")
}
}
}
return nil
}