package config import ( "errors" "os" "path/filepath" "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"` // V5Pay 控制 H5 V5Pay 站外支付下单、查询和回调验签。 V5Pay V5PayConfig `yaml:"v5pay"` // TronGrid 控制 USDT-TRC20 tx_hash 链上校验。 TronGrid TronGridConfig `yaml:"tron_grid"` // BSCUSDT 控制 USDT-BEP20 tx_hash 链上只读校验;默认关闭,避免生产误依赖公共 RPC。 BSCUSDT BSCUSDTConfig `yaml:"bsc_usdt"` // Binance 控制 Binance 只读查单;配置键仍兼容现有 bian.lalu/aslan/yumi。 Binance BinanceConfig `yaml:"bian"` // ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。 ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"` // ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。 ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"` // 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"` Environment string `yaml:"environment"` EnvironmentTest MifaPayEndpointConfig `yaml:"environment_test"` EnvironmentProd MifaPayEndpointConfig `yaml:"environment_prod"` 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"` } // MifaPayEndpointConfig 保存某一个 MiFaPay 环境的商户和网关配置;密钥仍由配置文件或环境注入,不在代码里伪造。 type MifaPayEndpointConfig struct { 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"` } // V5PayConfig 保存 V5Pay 商户签名、验签和网关配置。 type V5PayConfig struct { Enabled bool `yaml:"enabled"` Environment string `yaml:"environment"` EnvironmentTest V5PayEndpointConfig `yaml:"environment_test"` EnvironmentProd V5PayEndpointConfig `yaml:"environment_prod"` MerchantNo string `yaml:"merchant_no"` MerNo string `yaml:"mer_no"` // Deprecated: 兼容早期截图里的 mer_no 写法。 AppKey string `yaml:"app_key"` SecretKey string `yaml:"secret_key"` APIBaseURL string `yaml:"api_base_url"` HTTPTimeout time.Duration `yaml:"http_timeout"` } // V5PayEndpointConfig 保存 V5Pay 某一环境的商户应用配置;secret_key 只能来自环境或配置注入。 type V5PayEndpointConfig struct { MerchantNo string `yaml:"merchant_no"` MerNo string `yaml:"mer_no"` // Deprecated: 兼容早期截图里的 mer_no 写法。 AppKey string `yaml:"app_key"` SecretKey string `yaml:"secret_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"` } // BSCUSDTConfig 保存 BSC JSON-RPC 只读配置;不包含私钥,也不发起转账。 type BSCUSDTConfig struct { Enabled bool `yaml:"enabled"` RPCURL string `yaml:"rpc_url"` USDTContractAddress string `yaml:"usdt_contract_address"` HTTPTimeout time.Duration `yaml:"http_timeout"` } // BinanceConfig 保存 Binance 只读查询配置;不同 App 独立 key,避免跨账套查错收款记录。 type BinanceConfig struct { Enabled bool `yaml:"enabled"` APIBaseURL string `yaml:"api_base_url"` RecvWindow int64 `yaml:"recv_window"` HTTPTimeout time.Duration `yaml:"http_timeout"` Lalu BinanceAccountConfig `yaml:"lalu"` Aslan BinanceAccountConfig `yaml:"aslan"` Yumi BinanceAccountConfig `yaml:"yumi"` } // BinanceAccountConfig 是单个 Binance 账号的只读 API 凭证;日志和错误都不能输出这些字段。 type BinanceAccountConfig struct { APIKey string `yaml:"api_key"` APISecretKey string `yaml:"api_secret_key"` } // Account 按 appCode 选择对应 Binance 账号;未知 app 明确返回 false,避免默认落到 lalu。 func (cfg BinanceConfig) Account(appCode string) (BinanceAccountConfig, bool) { switch strings.ToLower(strings.TrimSpace(appCode)) { case "lalu": return cfg.Lalu, binanceAccountReady(cfg.Lalu) case "aslan": return cfg.Aslan, binanceAccountReady(cfg.Aslan) case "yumi": return cfg.Yumi, binanceAccountReady(cfg.Yumi) default: return BinanceAccountConfig{}, false } } // HasAnyAccount 用于启动装配判断是否需要注入 Binance client;缺失某个 App key 时仍让业务返回明确配置错误。 func (cfg BinanceConfig) HasAnyAccount() bool { return binanceAccountReady(cfg.Lalu) || binanceAccountReady(cfg.Aslan) || binanceAccountReady(cfg.Yumi) } func binanceAccountReady(account BinanceAccountConfig) bool { return strings.TrimSpace(account.APIKey) != "" && strings.TrimSpace(account.APISecretKey) != "" } // ExternalRechargeConfig 保存外部充值页面和支付回调所需的公开配置。 type ExternalRechargeConfig struct { USDTTRC20Enabled bool `yaml:"usdt_trc20_enabled"` USDTTRC20Address string `yaml:"usdt_trc20_address"` USDTTRC20Addresses map[string]string `yaml:"usdt_trc20_addresses"` USDTBEP20Enabled bool `yaml:"usdt_bep20_enabled"` USDTBEP20Address string `yaml:"usdt_bep20_address"` USDTBEP20Addresses map[string]string `yaml:"usdt_bep20_addresses"` MifaPayNotifyURL string `yaml:"mifapay_notify_url"` MifaPayReturnURL string `yaml:"mifapay_return_url"` V5PayNotifyURL string `yaml:"v5pay_notify_url"` V5PayReturnURL string `yaml:"v5pay_return_url"` } // ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。 type ExternalRechargeReconcileWorkerConfig struct { Enabled bool `yaml:"enabled"` AppCode string `yaml:"app_code"` PollInterval time.Duration `yaml:"poll_interval"` BatchSize int `yaml:"batch_size"` } // 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, }, V5Pay: V5PayConfig{ Enabled: true, APIBaseURL: "https://api-uat.v5pay.com", HTTPTimeout: 10 * time.Second, }, TronGrid: TronGridConfig{ Enabled: true, APIBaseURL: "https://api.trongrid.io", USDTContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", HTTPTimeout: 10 * time.Second, }, BSCUSDT: BSCUSDTConfig{ Enabled: false, RPCURL: "https://bsc-dataseed.binance.org", USDTContractAddress: "0x55d398326f99059fF775485246999027B3197955", HTTPTimeout: 10 * time.Second, }, Binance: BinanceConfig{ Enabled: true, APIBaseURL: "https://api.binance.com", RecvWindow: 10000, HTTPTimeout: 10 * time.Second, }, ExternalRecharge: ExternalRechargeConfig{ USDTTRC20Enabled: true, USDTBEP20Enabled: false, }, ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{ Enabled: true, AppCode: "lalu", PollInterval: 30 * time.Second, BatchSize: 50, }, 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 := loadRuntimeEnv(path); err != nil { return Config{}, err } if err := configx.LoadYAMLWithEnv(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 = normalizeMifaPayConfig(cfg.MifaPay) // MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。 cfg.V5Pay = normalizeV5PayConfig(cfg.V5Pay) // V5Pay 功能开关默认打开;merchantNo/appKey/secretKey 是环境敏感配置,缺失时不注入真实网关。 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.BSCUSDT.RPCURL = strings.TrimRight(strings.TrimSpace(cfg.BSCUSDT.RPCURL), "/") if cfg.BSCUSDT.RPCURL == "" { cfg.BSCUSDT.RPCURL = Default().BSCUSDT.RPCURL } cfg.BSCUSDT.USDTContractAddress = strings.TrimSpace(cfg.BSCUSDT.USDTContractAddress) if cfg.BSCUSDT.USDTContractAddress == "" { cfg.BSCUSDT.USDTContractAddress = Default().BSCUSDT.USDTContractAddress } if cfg.BSCUSDT.HTTPTimeout <= 0 { cfg.BSCUSDT.HTTPTimeout = Default().BSCUSDT.HTTPTimeout } cfg.Binance = normalizeBinanceConfig(cfg.Binance) cfg.ExternalRecharge.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address) cfg.ExternalRecharge.USDTTRC20Addresses = normalizeExternalRechargeAddressMap(cfg.ExternalRecharge.USDTTRC20Addresses) cfg.ExternalRecharge.USDTBEP20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTBEP20Address) cfg.ExternalRecharge.USDTBEP20Addresses = normalizeExternalRechargeAddressMap(cfg.ExternalRecharge.USDTBEP20Addresses) cfg.ExternalRecharge.MifaPayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayNotifyURL) cfg.ExternalRecharge.MifaPayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayReturnURL) cfg.ExternalRecharge.V5PayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayNotifyURL) cfg.ExternalRecharge.V5PayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayReturnURL) // USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。 cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker) 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 loadRuntimeEnv(configPath string) error { seen := make(map[string]struct{}) for _, start := range runtimeEnvSearchRoots(configPath) { for _, path := range dotEnvCandidates(start) { if _, ok := seen[path]; ok { continue } seen[path] = struct{}{} if err := configx.LoadDotEnvFile(path); err != nil { return err } } } return nil } func runtimeEnvSearchRoots(configPath string) []string { roots := make([]string, 0, 2) if cwd, err := os.Getwd(); err == nil { roots = append(roots, cwd) } if absConfigPath, err := filepath.Abs(configPath); err == nil { roots = append(roots, filepath.Dir(absConfigPath)) } return roots } func dotEnvCandidates(start string) []string { start, err := filepath.Abs(start) if err != nil { return nil } candidates := make([]string, 0, 4) for { candidates = append(candidates, filepath.Join(start, ".env")) if _, err := os.Stat(filepath.Join(start, ".git")); err == nil { return candidates } parent := filepath.Dir(start) if parent == start { return candidates } start = parent } } func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig { if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" { cfg.AppCode = defaults.AppCode } if cfg.PollInterval <= 0 { cfg.PollInterval = defaults.PollInterval } if cfg.BatchSize <= 0 { cfg.BatchSize = defaults.BatchSize } if cfg.BatchSize > 500 { cfg.BatchSize = 500 } return cfg } func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig { cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) // 本地联调可以同时保留 test/prod 两套商户配置;显式 environment 决定真实注入哪套,避免测试网和正式网字段互相覆盖。 switch cfg.Environment { case "test": cfg = applyMifaPayEndpointConfig(cfg, cfg.EnvironmentTest) case "prod", "production": cfg.Environment = "prod" cfg = applyMifaPayEndpointConfig(cfg, cfg.EnvironmentProd) default: cfg.Environment = "" } cfg.MerAccount = strings.TrimSpace(cfg.MerAccount) cfg.MerNo = strings.TrimSpace(cfg.MerNo) cfg.MerchantPublicKey = strings.TrimSpace(cfg.MerchantPublicKey) cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey) cfg.PlatformPublicKey = strings.TrimSpace(cfg.PlatformPublicKey) cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/") if cfg.APIBaseURL == "" { cfg.APIBaseURL = Default().MifaPay.APIBaseURL } if cfg.HTTPTimeout <= 0 { cfg.HTTPTimeout = Default().MifaPay.HTTPTimeout } return cfg } func normalizeV5PayConfig(cfg V5PayConfig) V5PayConfig { cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) // V5Pay 和 MiFaPay 一样支持 test/prod 两套环境;只在环境块里真实配置了字段时覆盖 flat 字段。 switch cfg.Environment { case "test": cfg = applyV5PayEndpointConfig(cfg, cfg.EnvironmentTest) case "prod", "production": cfg.Environment = "prod" cfg = applyV5PayEndpointConfig(cfg, cfg.EnvironmentProd) default: cfg.Environment = "" } cfg.MerchantNo = firstNonEmpty(strings.TrimSpace(cfg.MerchantNo), strings.TrimSpace(cfg.MerNo)) cfg.MerNo = strings.TrimSpace(cfg.MerNo) cfg.AppKey = strings.TrimSpace(cfg.AppKey) cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/") if cfg.APIBaseURL == "" { cfg.APIBaseURL = Default().V5Pay.APIBaseURL } if cfg.HTTPTimeout <= 0 { cfg.HTTPTimeout = Default().V5Pay.HTTPTimeout } return cfg } func normalizeBinanceConfig(cfg BinanceConfig) BinanceConfig { defaults := Default().Binance cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/") if cfg.APIBaseURL == "" { cfg.APIBaseURL = defaults.APIBaseURL } if cfg.RecvWindow <= 0 { cfg.RecvWindow = defaults.RecvWindow } if cfg.HTTPTimeout <= 0 { cfg.HTTPTimeout = defaults.HTTPTimeout } cfg.Lalu = normalizeBinanceAccountConfig(cfg.Lalu) cfg.Aslan = normalizeBinanceAccountConfig(cfg.Aslan) cfg.Yumi = normalizeBinanceAccountConfig(cfg.Yumi) return cfg } func normalizeBinanceAccountConfig(account BinanceAccountConfig) BinanceAccountConfig { account.APIKey = strings.TrimSpace(account.APIKey) account.APISecretKey = strings.TrimSpace(account.APISecretKey) return account } func normalizeExternalRechargeAddressMap(values map[string]string) map[string]string { if len(values) == 0 { return nil } normalized := make(map[string]string, len(values)) for appCode, address := range values { appCode = strings.ToLower(strings.TrimSpace(appCode)) address = strings.TrimSpace(address) if appCode == "" || address == "" { continue } normalized[appCode] = address } if len(normalized) == 0 { return nil } return normalized } func applyV5PayEndpointConfig(cfg V5PayConfig, endpoint V5PayEndpointConfig) V5PayConfig { if value := firstNonEmpty(strings.TrimSpace(endpoint.MerchantNo), strings.TrimSpace(endpoint.MerNo)); value != "" { cfg.MerchantNo = value } if value := strings.TrimSpace(endpoint.MerNo); value != "" { cfg.MerNo = value } if value := strings.TrimSpace(endpoint.AppKey); value != "" { cfg.AppKey = value } if value := strings.TrimSpace(endpoint.SecretKey); value != "" { cfg.SecretKey = value } if value := strings.TrimSpace(endpoint.APIBaseURL); value != "" { cfg.APIBaseURL = value } if endpoint.HTTPTimeout > 0 { cfg.HTTPTimeout = endpoint.HTTPTimeout } return cfg } func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfig) MifaPayConfig { // 环境块只在字段真实配置时覆盖扁平字段;这样旧的 flat 配置仍能启动,新配置也能按 test/prod 切换。 if value := strings.TrimSpace(endpoint.MerAccount); value != "" { cfg.MerAccount = value } if value := strings.TrimSpace(endpoint.MerNo); value != "" { cfg.MerNo = value } if value := strings.TrimSpace(endpoint.MerchantPublicKey); value != "" { cfg.MerchantPublicKey = value } if value := strings.TrimSpace(endpoint.PrivateKey); value != "" { cfg.PrivateKey = value } if value := strings.TrimSpace(endpoint.PlatformPublicKey); value != "" { cfg.PlatformPublicKey = value } if value := strings.TrimSpace(endpoint.APIBaseURL); value != "" { cfg.APIBaseURL = value } if endpoint.HTTPTimeout > 0 { cfg.HTTPTimeout = endpoint.HTTPTimeout } return cfg } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } 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", } }