package config import ( "fmt" "strings" "time" "hyapp/pkg/configx" "hyapp/pkg/logx" ) // Config 描述 lucky-gift-service 的进程启动边界。 // 规则、RTP、奖池、应用级外部接入和 outbox 都属于本服务;room/activity/admin 只能通过 gRPC 访问。 type Config struct { ServiceName string `yaml:"service_name"` NodeID string `yaml:"node_id"` Environment string `yaml:"environment"` GRPCAddr string `yaml:"grpc_addr"` HealthHTTPAddr string `yaml:"health_http_addr"` MySQLDSN string `yaml:"mysql_dsn"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` WalletServiceAddr string `yaml:"wallet_service_addr"` LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"` // RocketMQ 只发布 lucky-gift-service 自有 outbox 事实;room/activity 不能直读幸运礼物数据库。 RocketMQ RocketMQConfig `yaml:"rocketmq"` Log logx.Config `yaml:"log"` } // RocketMQConfig 保存幸运礼物 owner topic 的连接身份。 type RocketMQConfig struct { Enabled bool `yaml:"enabled"` NameServers []string `yaml:"name_servers"` NameServerDomain string `yaml:"name_server_domain"` AccessKey string `yaml:"access_key"` SecretKey string `yaml:"secret_key"` SecurityToken string `yaml:"security_token"` Namespace string `yaml:"namespace"` VIPChannel bool `yaml:"vip_channel"` LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"` } // LuckyGiftOutboxMQConfig 控制 granted 中奖事实发布;地址和凭据来自运行环境,业务开关在可运行配置中默认开启。 type LuckyGiftOutboxMQConfig struct { Enabled bool `yaml:"enabled"` Topic string `yaml:"topic"` ProducerGroup string `yaml:"producer_group"` SendTimeout time.Duration `yaml:"send_timeout"` Retry int `yaml:"retry"` } // LuckyGiftWorkerConfig 控制 draw outbox 补偿和统计快照刷新。 // 外部 App 抽奖不走 HyApp wallet,但仍写 outbox 给审计/统计;内部房间抽奖中奖后由 worker 兜底返奖, // 并在 granted 收敛后把 owner 事实可靠发布给 room/activity 的独立消费者。 type LuckyGiftWorkerConfig struct { Enabled bool `yaml:"enabled"` WorkerPollInterval time.Duration `yaml:"worker_poll_interval"` WorkerBatchSize int `yaml:"worker_batch_size"` WorkerConcurrency int `yaml:"worker_concurrency"` WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"` WorkerMaxRetry int `yaml:"worker_max_retry"` PublishTimeout time.Duration `yaml:"publish_timeout"` StatsRefreshInterval time.Duration `yaml:"stats_refresh_interval"` StatsBatchSize int `yaml:"stats_batch_size"` // Retention 控制 delivered 历史事件的定期清理;它独立于 Enabled,临时关闭补偿投递不会停掉清理。 Retention OutboxRetentionConfig `yaml:"retention"` } // OutboxRetentionConfig 控制 lucky_gift_outbox 中 delivered 历史事件的小批量定期删除。 // 只清理 delivered:pending/retryable/delivering 仍在投递生命周期内,failed 留给人工补偿。 // 抽奖事实和审计都在 lucky_draw_records;下游只读 RocketMQ owner 事实,不直读本表,因此 delivered 行可定期清理。 type OutboxRetentionConfig struct { Enabled bool `yaml:"enabled"` // MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长,早于 now-MaxAge 的才会删除。 MaxAge time.Duration `yaml:"max_age"` // ScanInterval 是清空一轮可删事件后到下一轮扫描的间隔。 ScanInterval time.Duration `yaml:"scan_interval"` // BatchSize 是单条 DELETE 的行数上限,小批量避免大事务长时间持有行锁。 BatchSize int `yaml:"batch_size"` } func Default() Config { return Config{ ServiceName: "lucky-gift-service", NodeID: "lucky-gift-local", Environment: "local", GRPCAddr: ":13013", HealthHTTPAddr: ":13113", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_lucky_gift?parseTime=true&charset=utf8mb4&loc=UTC", MySQLAutoMigrate: true, WalletServiceAddr: "127.0.0.1:13004", LuckyGiftWorker: LuckyGiftWorkerConfig{ Enabled: true, WorkerPollInterval: time.Second, WorkerBatchSize: 100, WorkerConcurrency: 8, WorkerLockTTL: 30 * time.Second, WorkerMaxRetry: 8, PublishTimeout: 5 * time.Second, StatsRefreshInterval: 10 * time.Minute, StatsBatchSize: 5000, Retention: OutboxRetentionConfig{ Enabled: true, // 线上 delivered 约 9 万行/天且无下游读方;7 天足够排障,30 天会让表膨胀到近 300 万行。 MaxAge: 7 * 24 * time.Hour, ScanInterval: 5 * time.Minute, BatchSize: 500, }, }, RocketMQ: defaultRocketMQConfig(), Log: logx.Config{ Level: "info", Format: "json", MaxPayloadBytes: 2048, }, } } func defaultRocketMQConfig() RocketMQConfig { return RocketMQConfig{ Enabled: false, LuckyGiftOutbox: LuckyGiftOutboxMQConfig{ Enabled: false, Topic: "hyapp_lucky_gift_outbox", ProducerGroup: "hyapp-lucky-gift-outbox-producer", SendTimeout: 5 * time.Second, Retry: 2, }, } } func Load(path string) (Config, error) { cfg := Default() if strings.TrimSpace(path) == "" { return cfg, nil } if err := configx.LoadYAML(path, &cfg); err != nil { return Config{}, err } cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) if cfg.ServiceName == "" { cfg.ServiceName = "lucky-gift-service" } cfg.NodeID = strings.TrimSpace(cfg.NodeID) if cfg.NodeID == "" { cfg.NodeID = cfg.ServiceName } cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) if cfg.Environment == "" { cfg.Environment = "local" } cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr) if cfg.GRPCAddr == "" { cfg.GRPCAddr = ":13013" } cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) if cfg.HealthHTTPAddr == "" { cfg.HealthHTTPAddr = ":13113" } cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr) if cfg.WalletServiceAddr == "" { cfg.WalletServiceAddr = "127.0.0.1:13004" } if cfg.LuckyGiftWorker.WorkerPollInterval <= 0 { cfg.LuckyGiftWorker.WorkerPollInterval = time.Second } if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 { cfg.LuckyGiftWorker.WorkerBatchSize = 100 } if cfg.LuckyGiftWorker.WorkerConcurrency <= 0 { cfg.LuckyGiftWorker.WorkerConcurrency = 8 } if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 { cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second } if cfg.LuckyGiftWorker.WorkerMaxRetry <= 0 { cfg.LuckyGiftWorker.WorkerMaxRetry = 8 } if cfg.LuckyGiftWorker.PublishTimeout <= 0 { cfg.LuckyGiftWorker.PublishTimeout = 5 * time.Second } if cfg.LuckyGiftWorker.StatsRefreshInterval <= 0 { cfg.LuckyGiftWorker.StatsRefreshInterval = 10 * time.Minute } if cfg.LuckyGiftWorker.StatsBatchSize <= 0 { cfg.LuckyGiftWorker.StatsBatchSize = 5000 } defaults := Default().LuckyGiftWorker.Retention if cfg.LuckyGiftWorker.Retention.MaxAge <= 0 { cfg.LuckyGiftWorker.Retention.MaxAge = defaults.MaxAge } if cfg.LuckyGiftWorker.Retention.ScanInterval <= 0 { cfg.LuckyGiftWorker.Retention.ScanInterval = defaults.ScanInterval } if cfg.LuckyGiftWorker.Retention.BatchSize <= 0 { cfg.LuckyGiftWorker.Retention.BatchSize = defaults.BatchSize } if cfg.LuckyGiftWorker.Retention.Enabled && cfg.LuckyGiftWorker.Retention.MaxAge < 24*time.Hour { // 保留窗口低于一天基本可以断定是把单位写错(比如 30 写成 30ns/30s),宁可拒绝启动。 return Config{}, fmt.Errorf("lucky_gift_worker retention max_age %s is below the 24h safety floor", cfg.LuckyGiftWorker.Retention.MaxAge) } rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ) if err != nil { return Config{}, err } cfg.RocketMQ = rocketMQ return cfg, nil } func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { defaults := defaultRocketMQConfig() cfg.NameServers = normalizeStringSlice(cfg.NameServers) cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain) cfg.AccessKey = strings.TrimSpace(cfg.AccessKey) cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken) cfg.Namespace = strings.TrimSpace(cfg.Namespace) if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" { cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic } if cfg.LuckyGiftOutbox.ProducerGroup = strings.TrimSpace(cfg.LuckyGiftOutbox.ProducerGroup); cfg.LuckyGiftOutbox.ProducerGroup == "" { cfg.LuckyGiftOutbox.ProducerGroup = defaults.LuckyGiftOutbox.ProducerGroup } if cfg.LuckyGiftOutbox.SendTimeout <= 0 { cfg.LuckyGiftOutbox.SendTimeout = defaults.LuckyGiftOutbox.SendTimeout } if cfg.LuckyGiftOutbox.Retry < 0 { cfg.LuckyGiftOutbox.Retry = defaults.LuckyGiftOutbox.Retry } if cfg.LuckyGiftOutbox.Enabled { cfg.Enabled = true } if cfg.Enabled { if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" { return RocketMQConfig{}, fmt.Errorf("rocketmq name_servers or name_server_domain is required") } if (cfg.AccessKey == "") != (cfg.SecretKey == "") { return RocketMQConfig{}, fmt.Errorf("rocketmq access_key and secret_key must be configured together") } } return cfg, nil } func normalizeStringSlice(input []string) []string { out := make([]string, 0, len(input)) for _, value := range input { if value = strings.TrimSpace(value); value != "" { out = append(out, value) } } return out }