6a1bd07320
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
218 lines
12 KiB
Go
218 lines
12 KiB
Go
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_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
|
||
}
|
||
}
|