package config import ( "log" "os" "strings" "github.com/spf13/viper" ) type Config struct { Server ServerConfig Database DatabaseConfig AlipaySandbox AlipaySandboxConfig `mapstructure:"alipay_sandbox"` Wechat WechatConfig `mapstructure:"wechat"` QuerySync QuerySyncConfig `mapstructure:"query_sync"` Reconcile ReconcileConfig `mapstructure:"reconcile"` // Biz 通用:任意业务系统(jiu / dudu / …)在 config 的 biz. 下声明即可接入,无需改代码。 Biz map[string]BizSystemConfig `mapstructure:"biz"` // Accounts 多账户配置注册表(v2):凭证不写死配置,只存 env 前缀,真值运行时从环境变量取。 Accounts []AccountConfig `mapstructure:"accounts"` // Routing 路由策略:channel → 策略名(round_robin/weighted/limit_aware)。 // 缺省/未知一律当 round_robin(见 P5 计划 D3)。密钥无关,可写 config.yaml。 Routing map[string]string `mapstructure:"routing"` // RateLimit v2 改状态端点(下单/重试/取消)的 per-IP 限流(jiu 反馈波)。 RateLimit RateLimitConfig `mapstructure:"rate_limit"` // MockChannelEnabled 门控本地联调 mock 支付渠道工具链(dev-only):fake provider 注册 + // 内存 fake 账户注入 + seed 业务方(pangolin)商品 + POST /api/v2/dev/orders/:order_no/mark-paid // 端点。生产必须为 false(默认);走 env MOCK_CHANNEL_ENABLED,不写进 config.yaml。 MockChannelEnabled bool `mapstructure:"mock_channel_enabled"` // WebhookTickSeconds webhook 出站投递后台扫描间隔(秒)。默认 60;本地联调可设小(如 2) // 让「模拟付款成功」后近实时投递,不必等最多 60s。走 env WEBHOOK_TICK_SECONDS。 WebhookTickSeconds int `mapstructure:"webhook_tick_seconds"` } // RateLimitConfig v2 网关 per-IP 限流开关与速率。字段故意叫 Disabled 而非 Enabled—— // 零值(未在 config.yaml 配置该段,或测试里 config.C 只赋值了别的字段)语义须是 // "默认开",与 Go bool 零值 false 自然对齐;显式 disabled:true 才关闭。 // RequestsPerMin<=0(含零值)由 middleware.NewIPRateLimiter 兜底成 30/min。 type RateLimitConfig struct { Disabled bool `mapstructure:"disabled"` RequestsPerMin int `mapstructure:"requests_per_min"` } // AccountConfig 单个收款账户的配置(v2 多账户路由用)。凭证严禁写进 config.yaml, // 只存 CredentialEnvPrefix,真值由 internal/accounts.Registry 运行时从环境变量 // `_` 读取。 type AccountConfig struct { AccountID string `mapstructure:"account_id"` Channel string `mapstructure:"channel"` Weight int `mapstructure:"weight"` Enabled bool `mapstructure:"enabled"` // DailyLimit 单位为该账户所属渠道结算币种的最小单位(minor units)——渠道币种由 Capabilities().SettleCurrencies[0] 决定; // 跨币种账户的限额刻度互不可比,运营配置时按各自币种填。 DailyLimit int64 `mapstructure:"daily_limit"` Region string `mapstructure:"region"` Subject string `mapstructure:"subject"` CredentialEnvPrefix string `mapstructure:"credential_env_prefix"` } type ServerConfig struct { Port string `mapstructure:"port"` Mode string `mapstructure:"mode"` // debug | release BaseURL string `mapstructure:"base_url"` // 拼 notify_url / return_url 的公网根地址;沙箱回调需公网可达(内网穿透) } type DatabaseConfig struct { Driver string `mapstructure:"driver"` // sqlite | mysql DSN string `mapstructure:"dsn"` // sqlite 为文件路径;mysql 为完整 DSN } // AlipaySandboxConfig 启动时据此 upsert 一个支付宝(沙箱)商户,方便开箱联调。 // 生产/多商户请改为通过管理接口或 seed 写入 merchants 表。 type AlipaySandboxConfig struct { Enabled bool `mapstructure:"enabled"` Production bool `mapstructure:"production"` // false=沙箱网关;true=正式网关(真钱) MerchantCode string `mapstructure:"merchant_code"` // 业务标识,如 yanmei MerchantName string `mapstructure:"merchant_name"` AppID string `mapstructure:"app_id"` AppPrivateKey string `mapstructure:"app_private_key"` // 应用私钥(PKCS1/PKCS8 皆可,无 PEM 头亦可) AlipayPublicKey string `mapstructure:"alipay_public_key"` // 支付宝公钥(公钥模式) } // WechatConfig 启动时据此 upsert 一个微信商户(V3 Native)。 // 密钥(商户私钥 / APIv3 密钥)严禁写进 config.yaml,走环境变量(生产由 Bitwarden 经 rbw 灌入)。 type WechatConfig struct { Enabled bool `mapstructure:"enabled"` MerchantCode string `mapstructure:"merchant_code"` // 业务标识,如 yanmei-wx(与支付宝商户区分) MerchantName string `mapstructure:"merchant_name"` MchID string `mapstructure:"mch_id"` // 微信支付商户号 AppID string `mapstructure:"app_id"` // 绑定的 APPID(公众号/小程序/移动应用;Native 可用商户号绑定的任一) CertSerial string `mapstructure:"cert_serial"` // 商户 API 证书序列号 PrivateKey string `mapstructure:"private_key"` // 商户 API 私钥 apiclient_key.pem(走环境变量) APIv3Key string `mapstructure:"apiv3_key"` // APIv3 密钥(回调解密,走环境变量) } // BizSystemConfig 单个业务系统的对接配置。secret 双向共用(校验其下单请求签名 + 给回调 payload 签名)。 // 密钥/回调按约定从环境变量注入:BIZ__SECRET / BIZ__CALLBACK_URL,严禁写进 config.yaml。 type BizSystemConfig struct { CallbackURL string `mapstructure:"callback_url"` // 支付成功回调业务方接收器地址 Secret string `mapstructure:"secret"` // HMAC 共享密钥 // SupportedEvents 接入方显式声明支持的 webhook event_type 集合(设计 §「接入方显式 // 声明支持事件集」)。空 = 只收 payment.succeeded(向后兼容 v1/P2 接入方,不会突然 // 收到 subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确 // 匹配,Notifier 投递前过滤(见 internal/webhook/notifier.go::eventSupported)。 SupportedEvents []string `mapstructure:"supported_events"` } // BizByName 按业务系统名取配置(供下单鉴权 / webhook 回调用)。未声明或无密钥则视为未接入。 func (c *Config) BizByName(system string) (BizSystemConfig, bool) { cfg, ok := c.Biz[system] if !ok || cfg.Secret == "" { return BizSystemConfig{}, false } return cfg, true } // QuerySyncConfig 兜底主动查单:定时把待支付订单拿去 alipay.trade.query 核对,防回调丢失。 type QuerySyncConfig struct { Enabled bool `mapstructure:"enabled"` IntervalSec int `mapstructure:"interval_sec"` // 轮询间隔(秒) MaxAgeMin int `mapstructure:"max_age_min"` // 只查创建时间在该分钟数内的待支付单 } // ReconcileConfig v2 后台守护/对账周期任务开关与间隔。 type ReconcileConfig struct { Enabled bool `mapstructure:"enabled"` OrderTTLMin int `mapstructure:"order_ttl_min"` // pending 订单存活 TTL(分钟),超则关闭;须 ≫ crypto 支付窗 orderTTL(15min,internal/provider/crypto/crypto.go)——否则在途 crypto 支付可能被订单级过期误杀 ExpireEverySec int `mapstructure:"expire_every_sec"` // 过期清理间隔 SyncEverySec int `mapstructure:"sync_every_sec"` // 查单对账间隔 UsageEverySec int `mapstructure:"usage_every_sec"` // 用量快照刷新间隔 SpotCheckEverySec int `mapstructure:"spot_check_every_sec"` // 已付抽查间隔 SpotCheckWindowMin int `mapstructure:"spot_check_window_min"` // 抽查回溯窗(分钟) OrphanEverySec int `mapstructure:"orphan_every_sec"` // crypto 孤儿扫描间隔(Task 6) OrphanWindowMin int `mapstructure:"orphan_window_min"` // 孤儿扫描回溯窗(Task 6) // —— 以下两项为 P4 T3 opus review 追加义务(退款修复扫描 + 卡滞退款告警), // 本期归到本任务(对账主体)一起装配,brief 原表未列。 RefundApplyEverySec int `mapstructure:"refund_apply_every_sec"` // 退款修复扫描 + 卡滞告警 共用间隔 RefundStuckWarnMin int `mapstructure:"refund_stuck_warn_min"` // 退款卡滞 processing/manual_pending 告警阈值(分钟) // RefundApplyLookbackMin 退款修复扫描的候选回溯窗(分钟):只扫 completed_at/updated_at // 落在 [now-lookback, now] 内的候选,防 LIMIT 尾部饥饿(见 store.ListDistinctOutTradeNosByStatusSince // / ListOrdersByStatusSince)。默认 48 小时——自愈针对近期崩溃窗口,历史一致性由已收敛状态保证。 RefundApplyLookbackMin int `mapstructure:"refund_apply_lookback_min"` } var C Config func Load() { viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath(".") viper.AddConfigPath("./config") viper.AddConfigPath("/etc/pay-v2") // 生产部署(pangolin1)config 落 /etc/pay-v2/config.yaml;systemd WorkingDirectory 在 /var/lib/pay-v2,故显式加此搜索路径 viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() // 敏感项支持环境变量覆盖(部署时不写进 config.yaml) _ = viper.BindEnv("alipay_sandbox.app_private_key", "ALIPAY_APP_PRIVATE_KEY") _ = viper.BindEnv("alipay_sandbox.alipay_public_key", "ALIPAY_PUBLIC_KEY") _ = viper.BindEnv("wechat.private_key", "WECHAT_MCH_PRIVATE_KEY") _ = viper.BindEnv("wechat.apiv3_key", "WECHAT_APIV3_KEY") _ = viper.BindEnv("database.dsn", "DATABASE_DSN") _ = viper.BindEnv("mock_channel_enabled", "MOCK_CHANNEL_ENABLED") _ = viper.BindEnv("webhook_tick_seconds", "WEBHOOK_TICK_SECONDS") viper.SetDefault("server.port", "8080") viper.SetDefault("server.mode", "debug") viper.SetDefault("server.base_url", "http://localhost:8080") viper.SetDefault("database.driver", "sqlite") viper.SetDefault("database.dsn", "pay.db") viper.SetDefault("alipay_sandbox.enabled", false) viper.SetDefault("alipay_sandbox.merchant_code", "yanmei") viper.SetDefault("alipay_sandbox.merchant_name", "演么测试商户") viper.SetDefault("wechat.enabled", false) viper.SetDefault("wechat.merchant_code", "yanmei-wx") viper.SetDefault("wechat.merchant_name", "岩美(北京)技术有限公司") viper.SetDefault("query_sync.enabled", true) viper.SetDefault("query_sync.interval_sec", 30) viper.SetDefault("query_sync.max_age_min", 30) viper.SetDefault("reconcile.enabled", true) viper.SetDefault("reconcile.order_ttl_min", 60) viper.SetDefault("reconcile.expire_every_sec", 300) viper.SetDefault("reconcile.sync_every_sec", 30) viper.SetDefault("reconcile.usage_every_sec", 60) viper.SetDefault("reconcile.spot_check_every_sec", 300) viper.SetDefault("reconcile.spot_check_window_min", 180) viper.SetDefault("reconcile.orphan_every_sec", 300) viper.SetDefault("reconcile.orphan_window_min", 180) viper.SetDefault("reconcile.refund_apply_every_sec", 300) viper.SetDefault("reconcile.refund_stuck_warn_min", 30) viper.SetDefault("reconcile.refund_apply_lookback_min", 48*60) viper.SetDefault("rate_limit.disabled", false) viper.SetDefault("rate_limit.requests_per_min", 30) viper.SetDefault("mock_channel_enabled", false) viper.SetDefault("webhook_tick_seconds", 60) if err := viper.ReadInConfig(); err != nil { log.Println("[config] 未找到 config.yaml,使用默认值 + 环境变量") } if err := viper.Unmarshal(&C); err != nil { log.Fatalf("[config] 解析配置失败: %v", err) } // 各业务系统密钥/回调按约定从环境变量注入(不写进 config.yaml): // BIZ__SECRET / BIZ__CALLBACK_URL(SYSTEM 为大写业务名,如 BIZ_JIU_SECRET)。 for name, bc := range C.Biz { up := strings.ToUpper(name) if bc.Secret == "" { bc.Secret = os.Getenv("BIZ_" + up + "_SECRET") } if bc.CallbackURL == "" { bc.CallbackURL = os.Getenv("BIZ_" + up + "_CALLBACK_URL") } C.Biz[name] = bc } }