2026-07-07 18:05:02 +08:00

827 lines
32 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 (
"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",
}
}