2026-07-13 21:28:41 +08:00

252 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 历史事件的小批量定期删除。
// 只清理 deliveredpending/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
}