// Package config 定义 room-service 的启动配置和 YAML 加载入口。 package config import ( "fmt" "net" "net/http" "strings" "time" "hyapp/pkg/configx" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/pkg/tencentrtc" ) const ( OutboxPublishModeDirect = "direct" OutboxPublishModeMQ = "mq" OutboxPublishModeDual = "dual" ) // Config 描述 room-service 进程启动所需的最小配置。 type Config struct { // ServiceName 是日志和部署识别使用的稳定服务名。 ServiceName string `yaml:"service_name"` // Environment 是 local/dev/staging/prod 等运行环境标签。 Environment string `yaml:"environment"` // NodeID 是当前 room-service 实例标识,Redis lease owner 使用它。 NodeID string `yaml:"node_id"` // GRPCAddr 是内部 gRPC 监听地址,gateway-service 通过它访问 room-service。 GRPCAddr string `yaml:"grpc_addr"` // AdvertiseAddr 是其他 room-service 实例转发到本节点时使用的私网地址,不能写负载均衡地址。 AdvertiseAddr string `yaml:"advertise_addr"` // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 HealthHTTPAddr string `yaml:"health_http_addr"` // NodeRegistryTTL 是 node_id -> advertise_addr 注册的有效期。 NodeRegistryTTL time.Duration `yaml:"node_registry_ttl"` // NodeRegistryHeartbeatInterval 是刷新节点注册的周期。 NodeRegistryHeartbeatInterval time.Duration `yaml:"node_registry_heartbeat_interval"` // OwnerForwardTimeout 是非 owner 节点转发到当前 owner 的单次 RPC 超时。 OwnerForwardTimeout time.Duration `yaml:"owner_forward_timeout"` // LeaseTTL 是 room owner 租约有效期,节点故障后必须等它过期才能接管。 LeaseTTL time.Duration `yaml:"lease_ttl"` // LuckyGiftSendLockTTL 是同一用户幸运礼物扣费、抽奖、同步返奖链路的 Redis 短锁 TTL。 LuckyGiftSendLockTTL time.Duration `yaml:"lucky_gift_send_lock_ttl"` // RankLimit 是房间本地礼物榜 top N。 RankLimit int `yaml:"rank_limit"` // SnapshotEveryN 控制按房间版本保存快照的频率。 SnapshotEveryN int64 `yaml:"snapshot_every_n"` // WalletServiceAddr 是 SendGift 同步扣费 gRPC 地址。 WalletServiceAddr string `yaml:"wallet_service_addr"` // ActivityServiceAddr 是房间事实事件投给 activity-service 的内部 gRPC 地址。 ActivityServiceAddr string `yaml:"activity_service_addr"` // LuckyGiftServiceAddr 是幸运礼物 owner service 的内部 gRPC 地址;不能复用 activity-service。 LuckyGiftServiceAddr string `yaml:"lucky_gift_service_addr"` // RobotServiceAddr 是真人房间机器人运行时读取全站机器人池的内部 gRPC 地址。 RobotServiceAddr string `yaml:"robot_service_addr"` // UserServiceAddr 是真人房间机器人运行时按用户资料分国家的内部 gRPC 地址。 UserServiceAddr string `yaml:"user_service_addr"` // TencentIM 是腾讯云 IM 群组和房间系统消息的外部集成配置。 TencentIM TencentIMConfig `yaml:"tencent_im"` // TencentRTC 是平台级封禁后移出实时音频房的外部集成配置。 TencentRTC TencentRTCConfig `yaml:"tencent_rtc"` // MySQLDSN 是 rooms、snapshot、command log 和 outbox 的持久化连接串。 MySQLDSN string `yaml:"mysql_dsn"` // MySQLMaxOpenConns 控制 MySQL 最大打开连接数。 MySQLMaxOpenConns int `yaml:"mysql_max_open_conns"` // MySQLMaxIdleConns 控制 MySQL 空闲连接数。 MySQLMaxIdleConns int `yaml:"mysql_max_idle_conns"` // MySQLAutoMigrate 控制启动时是否自动建表。 MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` // RedisAddr 是 room owner lease 目录地址。 RedisAddr string `yaml:"redis_addr"` // RedisPassword 是 Redis 鉴权密码,本地默认留空。 RedisPassword string `yaml:"redis_password"` // RedisDB 是 Redis logical DB。 RedisDB int `yaml:"redis_db"` // PresenceStaleAfter 是业务 presence 超过多久未刷新后被视为断线离房。 PresenceStaleAfter time.Duration `yaml:"presence_stale_after"` // PresenceStaleScanInterval 是本节点扫描已装载 Room Cell 的周期。 PresenceStaleScanInterval time.Duration `yaml:"presence_stale_scan_interval"` // MicPublishTimeout 是 MicUp 后等待客户端/RTC 发流确认的最长时间。 MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"` // MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。 MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"` // RoomRocketLaunchScanInterval 是本节点扫描到点发射火箭的周期。 RoomRocketLaunchScanInterval time.Duration `yaml:"room_rocket_launch_scan_interval"` // RoomListCacheRefreshInterval 是用 MySQL owner 表重建 Redis 发现页展示读模型的周期。 RoomListCacheRefreshInterval time.Duration `yaml:"room_list_cache_refresh_interval"` // RocketMQ 承载 room_outbox 事件分发和语音房火箭延迟发射唤醒。 RocketMQ RocketMQConfig `yaml:"rocketmq"` // OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。 OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` // Log 控制 stdout 结构化日志格式、等级和 access body 策略。 Log logx.Config `yaml:"log"` } // OutboxWorkerConfig 控制 room_outbox pending 事件的补偿投递循环。 type OutboxWorkerConfig struct { // Enabled 为 false 时不启动补偿 worker,测试或临时止血可用。 Enabled bool `yaml:"enabled"` // PublishMode 控制 room_outbox 发往 direct、mq 或 dual,避免 MQ 消费者与直接投递重复发同一条 IM。 PublishMode string `yaml:"publish_mode"` // PollInterval 是两轮 pending outbox 扫描之间的固定间隔。 PollInterval time.Duration `yaml:"poll_interval"` // BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。 BatchSize int `yaml:"batch_size"` // Concurrency 是本进程内并发抢占 outbox 的 worker 数;多机多 worker 依赖 MySQL 原子 claim 防重复。 Concurrency int `yaml:"concurrency"` // PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。 PublishTimeout time.Duration `yaml:"publish_timeout"` // RetryStrategy 当前只接受 exponential_backoff,避免配置错误静默改变投递语义。 RetryStrategy string `yaml:"retry_strategy"` // MaxRetryCount 是转入 failed 前允许的最大失败次数。 MaxRetryCount int `yaml:"max_retry_count"` // InitialBackoff 是首次失败后的等待时间。 InitialBackoff time.Duration `yaml:"initial_backoff"` // MaxBackoff 是指数退避的最大等待时间。 MaxBackoff time.Duration `yaml:"max_backoff"` // Retention 控制 delivered 历史事件的定期清理;它独立于 Enabled,临时关闭补偿投递不会停掉清理。 Retention OutboxRetentionConfig `yaml:"retention"` } // OutboxRetentionConfig 控制 room_outbox 中 delivered 历史事件的小批量定期删除。 // 只清理 delivered:pending/retryable/delivering 仍在投递生命周期内,failed 留给人工补偿。 type OutboxRetentionConfig struct { // Enabled 默认 false。statistics-service 的 replay-stat-tz/databi-real-flow-audit 和 // notice-service 离线修复工具都按时间窗直读 room_outbox 历史,开启前必须和数据侧确认 // MaxAge 覆盖所有重放/补数窗口(或先归档再删)。 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"` } // RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 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"` SendTimeout time.Duration `yaml:"send_timeout"` Retry int `yaml:"retry"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` } // RoomOutboxMQConfig 控制 room_outbox fanout topic。 type RoomOutboxMQConfig struct { Enabled bool `yaml:"enabled"` Topic string `yaml:"topic"` ProducerGroup string `yaml:"producer_group"` // TencentIMConsumerEnabled 已废弃:房间 UI 消息由 Room Cell 提交后直发腾讯 IM,room_outbox 只保留业务事实 fanout。 TencentIMConsumerEnabled bool `yaml:"tencent_im_consumer_enabled"` TencentIMConsumerGroup string `yaml:"tencent_im_consumer_group"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` // ConsumerGoroutines 控制 room_outbox RocketMQ push consumer 的并发回调数,消费方仍必须自行做业务幂等。 ConsumerGoroutines int `yaml:"consumer_goroutines"` // IMPublishConcurrency 已废弃,仅兼容旧配置文件;房间 IM 不再由 MQ consumer 桥接。 IMPublishConcurrency int `yaml:"im_publish_concurrency"` } // RocketLaunchMQConfig 控制火箭延迟发射唤醒 topic。 type RocketLaunchMQConfig struct { Enabled bool `yaml:"enabled"` Topic string `yaml:"topic"` ProducerGroup string `yaml:"producer_group"` ConsumerGroup string `yaml:"consumer_group"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` } // UserOutboxMQConfig 控制用户区域变更事实消费。 type UserOutboxMQConfig struct { Enabled bool `yaml:"enabled"` Topic string `yaml:"topic"` ConsumerGroup string `yaml:"consumer_group"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` } // TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。 type TencentIMConfig struct { // Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。 Enabled bool `yaml:"enabled"` // SDKAppID 是腾讯云 IM 控制台分配的应用 ID。 SDKAppID int64 `yaml:"sdk_app_id"` // SecretKey 用于生成 App 管理员 UserSig,不能写入公开配置。 SecretKey string `yaml:"secret_key"` // AdminIdentifier 是腾讯云 IM App 管理员账号。 AdminIdentifier string `yaml:"admin_identifier"` // AdminUserSigTTL 是 REST API 管理员票据有效期。 AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"` // Endpoint 是腾讯云 IM REST API 区域域名。 Endpoint string `yaml:"endpoint"` // GroupType 是语音房对应的腾讯云 IM 群组类型。 GroupType string `yaml:"group_type"` // RequestTimeout 是单次 REST API 调用超时。 RequestTimeout time.Duration `yaml:"request_timeout"` } // TencentRTCConfig 描述 room-service 调用腾讯云 TRTC 房间管理 API 的配置。 type TencentRTCConfig struct { // Enabled 控制是否启用服务端 RTC 踢人;关闭时仍会提交 Room Cell 驱逐。 Enabled bool `yaml:"enabled"` // SDKAppID 是 TRTC 控制台分配的应用 ID,必须和 gateway 签发 RTC UserSig 的应用一致。 SDKAppID int64 `yaml:"sdk_app_id"` // SecretID/SecretKey 是腾讯云 API 3.0 访问密钥,只能写在线上密钥系统或私有配置中。 SecretID string `yaml:"secret_id"` SecretKey string `yaml:"secret_key"` // Region 是腾讯云 API 公共参数;TRTC 房间管理接口当前使用 ap-guangzhou/ap-beijing。 Region string `yaml:"region"` // Endpoint 是腾讯云 API 域名,默认 trtc.tencentcloudapi.com。 Endpoint string `yaml:"endpoint"` // RequestTimeout 是单次公网调用超时。 RequestTimeout time.Duration `yaml:"request_timeout"` } // RESTConfig 把 YAML 配置转换成 pkg/tencentrtc 的 REST 客户端配置。 func (c TencentRTCConfig) RESTConfig() tencentrtc.RESTConfig { timeout := c.RequestTimeout if timeout <= 0 { timeout = 5 * time.Second } return tencentrtc.RESTConfig{ Enabled: c.Enabled, SDKAppID: c.SDKAppID, SecretID: c.SecretID, SecretKey: c.SecretKey, Region: c.Region, Endpoint: c.Endpoint, HTTPClient: &http.Client{Timeout: timeout}, } } // RESTConfig 把 YAML 配置转换成 pkg/tencentim 的 REST 客户端配置。 func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig { timeout := c.RequestTimeout if timeout <= 0 { // 腾讯云 REST 后台超时约 3 秒,本地客户端默认给 5 秒预算。 timeout = 5 * time.Second } return tencentim.RESTConfig{ SDKAppID: c.SDKAppID, SecretKey: c.SecretKey, AdminIdentifier: c.AdminIdentifier, AdminUserSigTTL: c.AdminUserSigTTL, Endpoint: c.Endpoint, GroupType: c.GroupType, HTTPClient: &http.Client{Timeout: timeout}, } } // Default 返回没有外部私有密钥时可启动的基础配置。 func Default() Config { // 默认端口遵守项目 13xxx 约束,MySQL/Redis 指向本地 Docker 暴露端口。 return Config{ ServiceName: "room-service", Environment: "local", NodeID: "room-node-local", GRPCAddr: ":13001", AdvertiseAddr: "127.0.0.1:13001", HealthHTTPAddr: ":13101", NodeRegistryTTL: 30 * time.Second, OwnerForwardTimeout: 2 * time.Second, LeaseTTL: 10 * time.Second, LuckyGiftSendLockTTL: 5 * time.Second, RankLimit: 20, SnapshotEveryN: 10, WalletServiceAddr: "127.0.0.1:13004", ActivityServiceAddr: "127.0.0.1:13006", LuckyGiftServiceAddr: "127.0.0.1:13013", RobotServiceAddr: "127.0.0.1:13011", UserServiceAddr: "127.0.0.1:13005", TencentIM: TencentIMConfig{ Enabled: false, AdminIdentifier: "administrator", AdminUserSigTTL: 24 * time.Hour, Endpoint: tencentim.DefaultEndpoint, GroupType: tencentim.DefaultGroupType, RequestTimeout: 5 * time.Second, }, TencentRTC: TencentRTCConfig{ Enabled: false, Region: tencentrtc.DefaultRESTRegion, Endpoint: tencentrtc.DefaultRESTEndpoint, RequestTimeout: 5 * time.Second, }, MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC", MySQLMaxOpenConns: 20, MySQLMaxIdleConns: 10, MySQLAutoMigrate: true, RedisAddr: "127.0.0.1:13379", RedisDB: 0, PresenceStaleAfter: 2 * time.Minute, PresenceStaleScanInterval: 30 * time.Second, MicPublishTimeout: 15 * time.Second, MicPublishScanInterval: time.Second, RoomRocketLaunchScanInterval: time.Second, RoomListCacheRefreshInterval: 5 * time.Minute, RocketMQ: defaultRocketMQConfig(), OutboxWorker: defaultOutboxWorkerConfig(), Log: logx.Config{ Level: "info", Format: "json", MaxPayloadBytes: 2048, }, } } func defaultOutboxWorkerConfig() OutboxWorkerConfig { // 默认启动补偿 worker,使用退避和死信避免外部故障刷屏。 return OutboxWorkerConfig{ Enabled: true, PublishMode: OutboxPublishModeDirect, PollInterval: time.Second, BatchSize: 100, Concurrency: 1, PublishTimeout: 3 * time.Second, RetryStrategy: "exponential_backoff", MaxRetryCount: 10, InitialBackoff: 5 * time.Second, MaxBackoff: 5 * time.Minute, // 清理默认关闭:保留窗口必须先和数据侧确认,见 OutboxRetentionConfig.Enabled。 Retention: OutboxRetentionConfig{ Enabled: false, MaxAge: 30 * 24 * time.Hour, ScanInterval: 5 * time.Minute, BatchSize: 500, }, } } func defaultRocketMQConfig() RocketMQConfig { return RocketMQConfig{ Enabled: false, SendTimeout: 3 * time.Second, Retry: 2, RoomOutbox: RoomOutboxMQConfig{ Enabled: false, Topic: "hyapp_room_outbox", ProducerGroup: "hyapp-room-outbox-producer", TencentIMConsumerEnabled: false, TencentIMConsumerGroup: "", ConsumerMaxReconsumeTimes: 16, IMPublishConcurrency: 1, }, RocketLaunch: RocketLaunchMQConfig{ Enabled: false, Topic: "hyapp_room_rocket_launch", ProducerGroup: "hyapp-room-rocket-launch-producer", ConsumerGroup: "hyapp-room-rocket-launch", ConsumerMaxReconsumeTimes: 16, }, UserOutbox: UserOutboxMQConfig{ Enabled: false, Topic: "hyapp_user_outbox", ConsumerGroup: "hyapp-room-user-region-sync", ConsumerMaxReconsumeTimes: 16, }, } } // Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。 func Normalize(cfg Config) (Config, error) { // 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。 cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) if cfg.ServiceName == "" { cfg.ServiceName = "room-service" } cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) if cfg.Environment == "" { cfg.Environment = "local" } cfg.NodeID = strings.TrimSpace(cfg.NodeID) if cfg.NodeID == "" { cfg.NodeID = cfg.ServiceName } cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr) if cfg.GRPCAddr == "" { cfg.GRPCAddr = ":13001" } cfg.AdvertiseAddr = strings.TrimSpace(cfg.AdvertiseAddr) if cfg.AdvertiseAddr == "" { cfg.AdvertiseAddr = defaultAdvertiseAddr(cfg.GRPCAddr) } cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) if cfg.HealthHTTPAddr == "" { cfg.HealthHTTPAddr = ":13101" } if cfg.NodeRegistryTTL <= 0 { cfg.NodeRegistryTTL = 30 * time.Second } if cfg.NodeRegistryHeartbeatInterval <= 0 { cfg.NodeRegistryHeartbeatInterval = cfg.NodeRegistryTTL / 3 } if cfg.NodeRegistryHeartbeatInterval <= 0 { cfg.NodeRegistryHeartbeatInterval = 10 * time.Second } if cfg.NodeRegistryHeartbeatInterval >= cfg.NodeRegistryTTL { cfg.NodeRegistryHeartbeatInterval = cfg.NodeRegistryTTL / 3 } if cfg.OwnerForwardTimeout <= 0 { cfg.OwnerForwardTimeout = 2 * time.Second } if cfg.LuckyGiftSendLockTTL <= 0 { cfg.LuckyGiftSendLockTTL = 5 * time.Second } if cfg.RoomListCacheRefreshInterval <= 0 { cfg.RoomListCacheRefreshInterval = 5 * time.Minute } cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr) if cfg.WalletServiceAddr == "" { cfg.WalletServiceAddr = "127.0.0.1:13004" } cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr) if cfg.ActivityServiceAddr == "" { cfg.ActivityServiceAddr = "127.0.0.1:13006" } cfg.LuckyGiftServiceAddr = strings.TrimSpace(cfg.LuckyGiftServiceAddr) if cfg.LuckyGiftServiceAddr == "" { cfg.LuckyGiftServiceAddr = "127.0.0.1:13013" } cfg.RobotServiceAddr = strings.TrimSpace(cfg.RobotServiceAddr) if cfg.RobotServiceAddr == "" { cfg.RobotServiceAddr = "127.0.0.1:13011" } cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr) if cfg.UserServiceAddr == "" { cfg.UserServiceAddr = "127.0.0.1:13005" } cfg.TencentRTC.SecretID = strings.TrimSpace(cfg.TencentRTC.SecretID) cfg.TencentRTC.SecretKey = strings.TrimSpace(cfg.TencentRTC.SecretKey) cfg.TencentRTC.Region = strings.TrimSpace(cfg.TencentRTC.Region) if cfg.TencentRTC.Region == "" { cfg.TencentRTC.Region = tencentrtc.DefaultRESTRegion } cfg.TencentRTC.Endpoint = strings.TrimSpace(cfg.TencentRTC.Endpoint) if cfg.TencentRTC.Endpoint == "" { cfg.TencentRTC.Endpoint = tencentrtc.DefaultRESTEndpoint } if cfg.TencentRTC.RequestTimeout <= 0 { cfg.TencentRTC.RequestTimeout = 5 * time.Second } outboxWorker, err := normalizeOutboxWorkerConfig(cfg.OutboxWorker) if err != nil { return Config{}, err } cfg.OutboxWorker = outboxWorker rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ, cfg.OutboxWorker.PublishMode) if err != nil { return Config{}, err } cfg.RocketMQ = rocketMQ return cfg, nil } func defaultAdvertiseAddr(grpcAddr string) string { host, port, err := net.SplitHostPort(grpcAddr) if err != nil { // 非 host:port 形态保持原值,由启动拨测暴露错误。 return grpcAddr } if host == "" || host == "0.0.0.0" || host == "::" { // 本地默认值只能服务单机开发;多机部署必须显式配置真实私网 IP 或主机名。 host = "127.0.0.1" } return net.JoinHostPort(host, port) } func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, error) { // Outbox worker 的所有时间和批量参数都必须有安全下限,避免配置缺省导致 busy loop。 defaults := defaultOutboxWorkerConfig() if cfg.PollInterval <= 0 { cfg.PollInterval = defaults.PollInterval } if cfg.BatchSize <= 0 { cfg.BatchSize = defaults.BatchSize } if cfg.Concurrency <= 0 { cfg.Concurrency = defaults.Concurrency } if cfg.PublishTimeout <= 0 { cfg.PublishTimeout = defaults.PublishTimeout } if cfg.MaxRetryCount <= 0 { cfg.MaxRetryCount = defaults.MaxRetryCount } if cfg.InitialBackoff <= 0 { cfg.InitialBackoff = defaults.InitialBackoff } if cfg.MaxBackoff <= 0 { cfg.MaxBackoff = defaults.MaxBackoff } if cfg.MaxBackoff < cfg.InitialBackoff { cfg.MaxBackoff = cfg.InitialBackoff } cfg.RetryStrategy = strings.TrimSpace(cfg.RetryStrategy) if cfg.RetryStrategy == "" { cfg.RetryStrategy = defaults.RetryStrategy } if cfg.RetryStrategy != defaults.RetryStrategy { // 当前只实现指数退避;未知策略直接启动失败,避免误以为退避或死信已生效。 return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker retry_strategy %q", cfg.RetryStrategy) } cfg.PublishMode = strings.ToLower(strings.TrimSpace(cfg.PublishMode)) if cfg.PublishMode == "" { cfg.PublishMode = defaults.PublishMode } switch cfg.PublishMode { case OutboxPublishModeDirect, OutboxPublishModeMQ, OutboxPublishModeDual: default: return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker publish_mode %q", cfg.PublishMode) } if cfg.Retention.MaxAge <= 0 { cfg.Retention.MaxAge = defaults.Retention.MaxAge } if cfg.Retention.ScanInterval <= 0 { cfg.Retention.ScanInterval = defaults.Retention.ScanInterval } if cfg.Retention.BatchSize <= 0 { cfg.Retention.BatchSize = defaults.Retention.BatchSize } if cfg.Retention.Enabled && cfg.Retention.MaxAge < 24*time.Hour { // 保留窗口小于一天大概率是单位写错(如 720 被当秒),直接拒绝启动,避免静默清掉重放依赖的历史。 return OutboxWorkerConfig{}, fmt.Errorf("outbox_worker retention max_age %s is below the 24h safety floor", cfg.Retention.MaxAge) } return cfg, nil } func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (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.SendTimeout <= 0 { cfg.SendTimeout = defaults.SendTimeout } if cfg.Retry < 0 { cfg.Retry = defaults.Retry } if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" { cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic } if cfg.RoomOutbox.ProducerGroup = strings.TrimSpace(cfg.RoomOutbox.ProducerGroup); cfg.RoomOutbox.ProducerGroup == "" { cfg.RoomOutbox.ProducerGroup = defaults.RoomOutbox.ProducerGroup } if cfg.RoomOutbox.TencentIMConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.TencentIMConsumerGroup); cfg.RoomOutbox.TencentIMConsumerGroup == "" { cfg.RoomOutbox.TencentIMConsumerGroup = defaults.RoomOutbox.TencentIMConsumerGroup } if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 { cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes } // 旧配置可能仍把 MQ -> Tencent IM bridge 打开;当前设计要求 UI 在 Room Cell 提交后直接 IM,outbox worker 不再承担 IM 投递。 cfg.RoomOutbox.TencentIMConsumerEnabled = false if cfg.RoomOutbox.ConsumerGoroutines < 0 { cfg.RoomOutbox.ConsumerGoroutines = 0 } if cfg.RoomOutbox.IMPublishConcurrency <= 0 { cfg.RoomOutbox.IMPublishConcurrency = defaults.RoomOutbox.IMPublishConcurrency } if cfg.RocketLaunch.Topic = strings.TrimSpace(cfg.RocketLaunch.Topic); cfg.RocketLaunch.Topic == "" { cfg.RocketLaunch.Topic = defaults.RocketLaunch.Topic } if cfg.RocketLaunch.ProducerGroup = strings.TrimSpace(cfg.RocketLaunch.ProducerGroup); cfg.RocketLaunch.ProducerGroup == "" { cfg.RocketLaunch.ProducerGroup = defaults.RocketLaunch.ProducerGroup } if cfg.RocketLaunch.ConsumerGroup = strings.TrimSpace(cfg.RocketLaunch.ConsumerGroup); cfg.RocketLaunch.ConsumerGroup == "" { cfg.RocketLaunch.ConsumerGroup = defaults.RocketLaunch.ConsumerGroup } if cfg.RocketLaunch.ConsumerMaxReconsumeTimes <= 0 { cfg.RocketLaunch.ConsumerMaxReconsumeTimes = defaults.RocketLaunch.ConsumerMaxReconsumeTimes } if cfg.UserOutbox.Topic = strings.TrimSpace(cfg.UserOutbox.Topic); cfg.UserOutbox.Topic == "" { cfg.UserOutbox.Topic = defaults.UserOutbox.Topic } if cfg.UserOutbox.ConsumerGroup = strings.TrimSpace(cfg.UserOutbox.ConsumerGroup); cfg.UserOutbox.ConsumerGroup == "" { cfg.UserOutbox.ConsumerGroup = defaults.UserOutbox.ConsumerGroup } if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 { cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes } if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled { cfg.Enabled = true } if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual { if !cfg.RoomOutbox.Enabled { return RocketMQConfig{}, fmt.Errorf("outbox_worker publish_mode %q requires rocketmq.room_outbox.enabled", publishMode) } } 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 } // Load 从独立 config.yaml 读取 room-service 配置。 func Load(path string) (Config, error) { cfg := Default() if path == "" { // 空路径代表显式使用默认配置,便于单元测试和最小启动。 return Normalize(cfg) } if err := configx.LoadYAML(path, &cfg); err != nil { // YAML 解析失败返回空 Config,避免调用方误用部分默认值启动。 return Config{}, err } return Normalize(cfg) }