Files
pay/config/config.go
T
wangjia 98b95dde19 fix(v2): 币种错误映射 409/503(不再误导 500 重试)+ DailyLimit 量纲注释
- handler: ErrCurrencyMismatch → 409, ErrNoSettleCurrency → 503
- test: TestV2RetryCurrencyMismatch409 验证重试不同币种渠道返回 409
- config: DailyLimit 添加量纲注释(minor units)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 14:27:39 +08:00

154 lines
7.1 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 (
"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"`
// 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"`
}
// 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 共享密钥
}
// 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"` // 只查创建时间在该分钟数内的待支付单
}
var C Config
func Load() {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
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.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)
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
}
}