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