2026-06-11 01:02:20 +08:00

465 lines
18 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"
"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",
}
}