Files
wangjia 6a1bd07320 fix(config): viper 加 /etc/pay-v2 config 搜索路径(pangolin1 部署)
systemd WorkingDirectory=/var/lib/pay-v2,但约定 config 落 /etc/pay-v2/config.yaml;
不加此路径 pay 读不到 config 会退默认值(accounts 空→渠道不注册)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 12:47:17 +08:00

218 lines
12 KiB
Go
Raw Permalink 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 (
"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.<name> 下声明即可接入,无需改代码。
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 运行时从环境变量
// `<CredentialEnvPrefix>_<KEY>` 读取。
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_<SYSTEM>_SECRET / BIZ_<SYSTEM>_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_<SYSTEM>_SECRET / BIZ_<SYSTEM>_CALLBACK_URLSYSTEM 为大写业务名,如 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
}
}