package config import ( "errors" "strings" "time" "hyapp/pkg/configx" "hyapp/pkg/logx" ) // Config 描述 wallet-service 启动配置。 type Config struct { ServiceName string `yaml:"service_name"` NodeID string `yaml:"node_id"` Environment string `yaml:"environment"` GRPCAddr string `yaml:"grpc_addr"` // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 HealthHTTPAddr string `yaml:"health_http_addr"` // MySQLDSN 是账户、交易、分录和 outbox 的必需存储连接串。 MySQLDSN string `yaml:"mysql_dsn"` // ActivityServiceAddr 用于把 wallet 资源赠送 outbox 投影到 activity badge 展示读模型。 ActivityServiceAddr string `yaml:"activity_service_addr"` // RedPacketExpiryWorker 控制红包过期退款扫描。 RedPacketExpiryWorker RedPacketExpiryWorkerConfig `yaml:"red_packet_expiry_worker"` // RocketMQ 控制 wallet_outbox 事实发布。 RocketMQ RocketMQConfig `yaml:"rocketmq"` // GooglePlay 控制 Google Play Developer API 购买校验。 GooglePlay GooglePlayConfig `yaml:"google_play"` // MifaPay 控制 H5 MiFaPay 站外支付下单和回调验签。 MifaPay MifaPayConfig `yaml:"mifapay"` // TronGrid 控制 USDT-TRC20 tx_hash 链上校验。 TronGrid TronGridConfig `yaml:"tron_grid"` // ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。 ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"` // OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。 OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` // RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。 RealtimeOutboxWorker OutboxWorkerConfig `yaml:"realtime_outbox_worker"` // ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费。 ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"` // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` Log logx.Config `yaml:"log"` } // RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。 type RedPacketExpiryWorkerConfig struct { Enabled bool `yaml:"enabled"` AppCode string `yaml:"app_code"` PollInterval time.Duration `yaml:"poll_interval"` BatchSize int32 `yaml:"batch_size"` } // RocketMQConfig 描述 wallet-service 发布账务事实的 MQ 连接。 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"` WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` RealtimeOutbox WalletOutboxMQConfig `yaml:"realtime_outbox"` } // WalletOutboxMQConfig 保存 wallet_outbox topic 的生产者配置。 type WalletOutboxMQConfig struct { Enabled bool `yaml:"enabled"` Topic string `yaml:"topic"` ProducerGroup string `yaml:"producer_group"` } // OutboxWorkerConfig 控制 wallet_outbox pending 事件的 MQ fanout。 type OutboxWorkerConfig struct { Enabled bool `yaml:"enabled"` PollInterval time.Duration `yaml:"poll_interval"` BatchSize int `yaml:"batch_size"` Concurrency int `yaml:"concurrency"` PublishTimeout time.Duration `yaml:"publish_timeout"` MaxRetryCount int `yaml:"max_retry_count"` InitialBackoff time.Duration `yaml:"initial_backoff"` MaxBackoff time.Duration `yaml:"max_backoff"` EventTypes []string `yaml:"event_types"` } // ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。 type ProjectionWorkerConfig struct { Enabled bool `yaml:"enabled"` PollInterval time.Duration `yaml:"poll_interval"` // Deprecated: runtime projection is MQ-driven. BatchSize int `yaml:"batch_size"` LockTTL time.Duration `yaml:"lock_ttl"` ConsumerGroup string `yaml:"consumer_group"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` } // GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。 type GooglePlayConfig struct { Enabled bool `yaml:"enabled"` PackageName string `yaml:"package_name"` ServiceAccountFile string `yaml:"service_account_file"` ServiceAccountJSON string `yaml:"service_account_json"` APIBaseURL string `yaml:"api_base_url"` TokenURL string `yaml:"token_url"` HTTPTimeout time.Duration `yaml:"http_timeout"` ConsumeEnabled bool `yaml:"consume_enabled"` } // MifaPayConfig 保存 MiFaPay 商户签名、验签和网关配置。 type MifaPayConfig struct { Enabled bool `yaml:"enabled"` MerAccount string `yaml:"mer_account"` MerNo string `yaml:"mer_no"` MerchantPublicKey string `yaml:"merchant_public_key"` PrivateKey string `yaml:"private_key"` PlatformPublicKey string `yaml:"platform_public_key"` APIBaseURL string `yaml:"api_base_url"` HTTPTimeout time.Duration `yaml:"http_timeout"` } // TronGridConfig 保存 TRON HTTP 兼容接口配置;base_url 可替换成自有代理。 type TronGridConfig struct { Enabled bool `yaml:"enabled"` APIBaseURL string `yaml:"api_base_url"` APIKey string `yaml:"api_key"` USDTContractAddress string `yaml:"usdt_contract_address"` HTTPTimeout time.Duration `yaml:"http_timeout"` } // ExternalRechargeConfig 保存外部充值页面和支付回调所需的公开配置。 type ExternalRechargeConfig struct { USDTTRC20Enabled bool `yaml:"usdt_trc20_enabled"` USDTTRC20Address string `yaml:"usdt_trc20_address"` MifaPayNotifyURL string `yaml:"mifapay_notify_url"` MifaPayReturnURL string `yaml:"mifapay_return_url"` } // Default 返回本地开发默认配置。 func Default() Config { return Config{ ServiceName: "wallet-service", NodeID: "wallet-local", Environment: "local", GRPCAddr: ":13004", HealthHTTPAddr: ":13104", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", ActivityServiceAddr: "127.0.0.1:13006", RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{ Enabled: true, AppCode: "lalu", PollInterval: 5 * time.Second, BatchSize: 50, }, RocketMQ: defaultRocketMQConfig(), GooglePlay: GooglePlayConfig{ Enabled: false, APIBaseURL: "https://androidpublisher.googleapis.com", TokenURL: "https://oauth2.googleapis.com/token", HTTPTimeout: 10 * time.Second, ConsumeEnabled: true, }, MifaPay: MifaPayConfig{ Enabled: true, APIBaseURL: "https://platformtest.xqdmipay.com", HTTPTimeout: 10 * time.Second, }, TronGrid: TronGridConfig{ Enabled: true, APIBaseURL: "https://api.trongrid.io", USDTContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", HTTPTimeout: 10 * time.Second, }, ExternalRecharge: ExternalRechargeConfig{ USDTTRC20Enabled: true, }, OutboxWorker: OutboxWorkerConfig{ Enabled: false, PollInterval: time.Second, BatchSize: 100, Concurrency: 1, PublishTimeout: 3 * time.Second, MaxRetryCount: 10, InitialBackoff: 5 * time.Second, MaxBackoff: 5 * time.Minute, }, RealtimeOutboxWorker: OutboxWorkerConfig{ Enabled: false, PollInterval: time.Second, BatchSize: 50, Concurrency: 2, PublishTimeout: 3 * time.Second, MaxRetryCount: 10, InitialBackoff: 2 * time.Second, MaxBackoff: time.Minute, EventTypes: defaultRealtimeOutboxEventTypes(), }, ProjectionWorker: ProjectionWorkerConfig{ Enabled: false, PollInterval: 60 * time.Second, BatchSize: 10, LockTTL: 30 * time.Second, ConsumerGroup: "hyapp-wallet-projection", ConsumerMaxReconsumeTimes: 16, }, MySQLAutoMigrate: false, Log: logx.Config{ Level: "info", Format: "json", MaxPayloadBytes: 2048, }, } } func defaultRocketMQConfig() RocketMQConfig { return RocketMQConfig{ Enabled: false, SendTimeout: 3 * time.Second, Retry: 2, WalletOutbox: WalletOutboxMQConfig{ Enabled: false, Topic: "hyapp_wallet_outbox", ProducerGroup: "hyapp-wallet-outbox-producer", }, RealtimeOutbox: WalletOutboxMQConfig{ Enabled: false, Topic: "hyapp_wallet_realtime_outbox", ProducerGroup: "hyapp-wallet-realtime-outbox-producer", }, } } // Load 从独立 config.yaml 读取 wallet-service 配置。 func Load(path string) (Config, error) { cfg := Default() if 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 = "wallet-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.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) if cfg.HealthHTTPAddr == "" { cfg.HealthHTTPAddr = ":13104" } cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr) if cfg.ActivityServiceAddr == "" { cfg.ActivityServiceAddr = "127.0.0.1:13006" } cfg.RedPacketExpiryWorker.AppCode = strings.TrimSpace(cfg.RedPacketExpiryWorker.AppCode) if cfg.RedPacketExpiryWorker.AppCode == "" { cfg.RedPacketExpiryWorker.AppCode = Default().RedPacketExpiryWorker.AppCode } if cfg.RedPacketExpiryWorker.PollInterval <= 0 { cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval } if cfg.RedPacketExpiryWorker.BatchSize <= 0 { cfg.RedPacketExpiryWorker.BatchSize = Default().RedPacketExpiryWorker.BatchSize } rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ) if err != nil { return Config{}, err } cfg.RocketMQ = rocketMQ cfg.GooglePlay.PackageName = strings.TrimSpace(cfg.GooglePlay.PackageName) cfg.GooglePlay.ServiceAccountFile = strings.TrimSpace(cfg.GooglePlay.ServiceAccountFile) cfg.GooglePlay.ServiceAccountJSON = strings.TrimSpace(cfg.GooglePlay.ServiceAccountJSON) cfg.GooglePlay.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GooglePlay.APIBaseURL), "/") if cfg.GooglePlay.APIBaseURL == "" { cfg.GooglePlay.APIBaseURL = Default().GooglePlay.APIBaseURL } cfg.GooglePlay.TokenURL = strings.TrimSpace(cfg.GooglePlay.TokenURL) if cfg.GooglePlay.TokenURL == "" { cfg.GooglePlay.TokenURL = Default().GooglePlay.TokenURL } if cfg.GooglePlay.HTTPTimeout <= 0 { cfg.GooglePlay.HTTPTimeout = Default().GooglePlay.HTTPTimeout } if cfg.GooglePlay.Enabled { if cfg.GooglePlay.PackageName == "" { return Config{}, errors.New("google_play package_name is required") } if cfg.GooglePlay.ServiceAccountFile == "" && cfg.GooglePlay.ServiceAccountJSON == "" { return Config{}, errors.New("google_play service account is required") } } cfg.MifaPay.MerAccount = strings.TrimSpace(cfg.MifaPay.MerAccount) cfg.MifaPay.MerNo = strings.TrimSpace(cfg.MifaPay.MerNo) cfg.MifaPay.PrivateKey = strings.TrimSpace(cfg.MifaPay.PrivateKey) cfg.MifaPay.PlatformPublicKey = strings.TrimSpace(cfg.MifaPay.PlatformPublicKey) cfg.MifaPay.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.MifaPay.APIBaseURL), "/") if cfg.MifaPay.APIBaseURL == "" { cfg.MifaPay.APIBaseURL = Default().MifaPay.APIBaseURL } if cfg.MifaPay.HTTPTimeout <= 0 { cfg.MifaPay.HTTPTimeout = Default().MifaPay.HTTPTimeout } // MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。 cfg.TronGrid.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.TronGrid.APIBaseURL), "/") if cfg.TronGrid.APIBaseURL == "" { cfg.TronGrid.APIBaseURL = Default().TronGrid.APIBaseURL } cfg.TronGrid.APIKey = strings.TrimSpace(cfg.TronGrid.APIKey) cfg.TronGrid.USDTContractAddress = strings.TrimSpace(cfg.TronGrid.USDTContractAddress) if cfg.TronGrid.USDTContractAddress == "" { cfg.TronGrid.USDTContractAddress = Default().TronGrid.USDTContractAddress } if cfg.TronGrid.HTTPTimeout <= 0 { cfg.TronGrid.HTTPTimeout = Default().TronGrid.HTTPTimeout } cfg.ExternalRecharge.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address) cfg.ExternalRecharge.MifaPayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayNotifyURL) cfg.ExternalRecharge.MifaPayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayReturnURL) // USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。 cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker) if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled { return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled") } cfg.RealtimeOutboxWorker = normalizeOutboxWorkerConfig(cfg.RealtimeOutboxWorker, Default().RealtimeOutboxWorker) if len(cfg.RealtimeOutboxWorker.EventTypes) == 0 { cfg.RealtimeOutboxWorker.EventTypes = defaultRealtimeOutboxEventTypes() } if cfg.RealtimeOutboxWorker.Enabled && !cfg.RocketMQ.RealtimeOutbox.Enabled { return Config{}, errors.New("realtime_outbox_worker requires rocketmq.realtime_outbox.enabled") } if cfg.ProjectionWorker.PollInterval <= 0 { cfg.ProjectionWorker.PollInterval = Default().ProjectionWorker.PollInterval } if cfg.ProjectionWorker.BatchSize <= 0 { cfg.ProjectionWorker.BatchSize = Default().ProjectionWorker.BatchSize } if cfg.ProjectionWorker.BatchSize > 100 { cfg.ProjectionWorker.BatchSize = 100 } if cfg.ProjectionWorker.LockTTL <= 0 { cfg.ProjectionWorker.LockTTL = Default().ProjectionWorker.LockTTL } if cfg.ProjectionWorker.ConsumerGroup = strings.TrimSpace(cfg.ProjectionWorker.ConsumerGroup); cfg.ProjectionWorker.ConsumerGroup == "" { cfg.ProjectionWorker.ConsumerGroup = Default().ProjectionWorker.ConsumerGroup } if cfg.ProjectionWorker.ConsumerMaxReconsumeTimes <= 0 { cfg.ProjectionWorker.ConsumerMaxReconsumeTimes = Default().ProjectionWorker.ConsumerMaxReconsumeTimes } if cfg.ProjectionWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled { return Config{}, errors.New("projection_worker requires rocketmq.wallet_outbox.enabled") } 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.SendTimeout <= 0 { cfg.SendTimeout = defaults.SendTimeout } if cfg.Retry <= 0 { cfg.Retry = defaults.Retry } if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" { cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic } if cfg.WalletOutbox.ProducerGroup = strings.TrimSpace(cfg.WalletOutbox.ProducerGroup); cfg.WalletOutbox.ProducerGroup == "" { cfg.WalletOutbox.ProducerGroup = defaults.WalletOutbox.ProducerGroup } if cfg.RealtimeOutbox.Topic = strings.TrimSpace(cfg.RealtimeOutbox.Topic); cfg.RealtimeOutbox.Topic == "" { cfg.RealtimeOutbox.Topic = defaults.RealtimeOutbox.Topic } if cfg.RealtimeOutbox.ProducerGroup = strings.TrimSpace(cfg.RealtimeOutbox.ProducerGroup); cfg.RealtimeOutbox.ProducerGroup == "" { cfg.RealtimeOutbox.ProducerGroup = defaults.RealtimeOutbox.ProducerGroup } if cfg.WalletOutbox.Enabled || cfg.RealtimeOutbox.Enabled { cfg.Enabled = true } if cfg.Enabled { if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" { return RocketMQConfig{}, errors.New("rocketmq name_servers or name_server_domain is required") } if (cfg.AccessKey == "") != (cfg.SecretKey == "") { return RocketMQConfig{}, errors.New("rocketmq access_key and secret_key must be configured together") } } return cfg, nil } func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig, defaults OutboxWorkerConfig) OutboxWorkerConfig { if cfg.PollInterval <= 0 { cfg.PollInterval = defaults.PollInterval } if cfg.BatchSize <= 0 { cfg.BatchSize = defaults.BatchSize } if cfg.BatchSize > 500 { cfg.BatchSize = 500 } if cfg.Concurrency <= 0 { cfg.Concurrency = defaults.Concurrency } if cfg.Concurrency > 64 { cfg.Concurrency = 64 } 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.EventTypes = normalizeStringSlice(cfg.EventTypes) return cfg } func normalizeStringSlice(values []string) []string { normalized := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { if value = strings.TrimSpace(value); value != "" { if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} normalized = append(normalized, value) } } return normalized } func defaultRealtimeOutboxEventTypes() []string { return []string{ "WalletRedPacketCreated", "WalletRedPacketClaimed", "WalletRedPacketRefunded", } }