Files
sundynix-agentix/sundynix-gateway/internal/payment/manager.go
T
Blizzard 3a4e1d53a5 refactor(payment): 渠道抽象成 Channel 接口 —— 接新渠道=加 adapter 不动骨架
PAYMENT_DESIGN §3 承诺的 internal/payment/channel.go 适配器接口此前不存在,微信硬编码在
manager/handler 里。补齐抽象:

- channel.go:Channel 接口(Name/CreatePay/QueryOrder/VerifyCallback)+ 统一 PayIntent/PayResult
  + 渠道名常量。入参用基本类型不吃 *store.PaymentOrder,payment 包不反依赖 store。
- Wechat 实现 Channel(编译期断言 var _ Channel);QueryResult 归一为 PayResult;CreatePay 返回 PayIntent。
- Manager 从「持一个 *Wechat」改为渠道注册表:Get(name)/Available()/Status(name)/ReloadWechat;
  按渠道名持有已装配实例,热重载不变。
- 回调路由收敛 /billing/callback/wechat → /billing/callback/:channel 按名路由(旧 notify URL 仍匹配);
  查单/掉单补偿据 order.Channel 路由,不再写死微信。下单支持可选 channel(缺省 wechat)。
- 支付宝/Stripe 现在真·只差一个 adapter+注册。唯一未泛化:回调 ack 应答格式(现微信态,注释标明)。
- payment 包首个测试:Manager 注册表 4 用例(空/注册摘除/空配置/配置不全)。build/vet/test/lint 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:56:24 +08:00

153 lines
5.3 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 payment
import (
"context"
"errors"
"log"
"os"
"sort"
"strings"
"sync"
)
// Config 微信支付渠道配置。来源两级:DB 设置(admin 控制面,热生效)优先,env 兜底
// ——与 TokensPerCredit 的「DB 优先 → env → 默认」同一约定。
// APIv3Key 在库里是 AES-GCM 密文(与模型 API Key 同一把 SUNDYNIX_SECRET_KEY),
// 这里拿到的是解密后的明文;PrivateKeyPath 只是服务器磁盘路径,私钥文件本身不进库。
type Config struct {
MchID string `json:"mchid"`
CertSerial string `json:"cert_serial"`
PrivateKeyPath string `json:"private_key_path"`
APIv3Key string `json:"apiv3_key"`
AppID string `json:"appid"`
NotifyURL string `json:"notify_url"`
// 微信支付公钥验签体系(2024 起新注册商户只有这个;本项目商户 2025-09 开户,钉死此模式,
// 老商户的平台证书模式不做)。
PublicKeyPath string `json:"public_key_path"` // 微信支付公钥文件路径(服务器磁盘)
PublicKeyID string `json:"public_key_id"` // 公钥 IDPUB_KEY_ID_ 开头)
}
// Empty 完全未配置(一个字段都没填)。
func (c Config) Empty() bool {
return c.MchID == "" && c.CertSerial == "" && c.PrivateKeyPath == "" &&
c.APIv3Key == "" && c.AppID == "" && c.NotifyURL == "" &&
c.PublicKeyPath == "" && c.PublicKeyID == ""
}
// missing 返回缺失字段名(配置不全时给 admin 一句能看懂的原因)。八项全必填:
// APIv3 密钥用于回调资源解密,公钥两项用于验签。
func (c Config) missing() []string {
var out []string
for _, f := range []struct{ k, v string }{
{"mchid", c.MchID}, {"cert_serial", c.CertSerial}, {"private_key_path", c.PrivateKeyPath},
{"apiv3_key", c.APIv3Key}, {"appid", c.AppID}, {"notify_url", c.NotifyURL},
{"public_key_path", c.PublicKeyPath}, {"public_key_id", c.PublicKeyID},
} {
if strings.TrimSpace(f.v) == "" {
out = append(out, f.k)
}
}
return out
}
// ConfigFromEnv 从环境变量读配置(DB 未配置时的兜底,兼容 P5.2 的纯 env 用法)。
func ConfigFromEnv() Config {
return Config{
MchID: os.Getenv("WECHAT_MCHID"),
CertSerial: os.Getenv("WECHAT_MCH_CERT_SERIAL"),
PrivateKeyPath: os.Getenv("WECHAT_MCH_PRIVATE_KEY"),
APIv3Key: os.Getenv("WECHAT_APIV3_KEY"),
AppID: os.Getenv("WECHAT_APPID"),
NotifyURL: os.Getenv("WECHAT_NOTIFY_URL"),
PublicKeyPath: os.Getenv("WECHAT_PUBLIC_KEY"),
PublicKeyID: os.Getenv("WECHAT_PUBLIC_KEY_ID"),
}
}
// Manager 是充值渠道注册表:按渠道名持有已装配的 Channel 实例,支持 admin 改配置后热重载
// (学 prompt 控制面:改完即生效,不重启 gateway)。业务路径经 Get(name) 按订单渠道取用——
// 取到 nil 即该渠道未启用/隐藏。接新渠道(支付宝/Stripe= 加一个 ReloadXxx + 各自 Channel 实现。
type Manager struct {
mu sync.RWMutex
channels map[string]Channel // 渠道名 → 实例(仅含已成功装配的)
reasons map[string]string // 渠道名 → 未启用原因(给 admin 状态面看,含未启用的)
}
func NewManager() *Manager {
return &Manager{
channels: map[string]Channel{},
reasons: map[string]string{ChannelWechat: "未配置"},
}
}
// register 装配结果登记:成功则入表、失败则从表移除并记原因(内部用,调用方持有意图日志)。
func (m *Manager) register(name string, ch Channel, reason string) {
m.mu.Lock()
defer m.mu.Unlock()
if ch != nil {
m.channels[name] = ch
} else {
delete(m.channels, name)
}
m.reasons[name] = reason
}
// ReloadWechat 按给定配置重建微信渠道。失败只降级为隐藏并记录原因,绝不 panic/拖垮服务。
func (m *Manager) ReloadWechat(ctx context.Context, c Config) error {
w, reason, err := buildWechat(ctx, c)
var ch Channel
if w != nil {
ch = w
}
m.register(ChannelWechat, ch, reason)
if w != nil {
log.Printf("[payment] 微信支付 Native 渠道已启用 (mchid=%s)", c.MchID)
} else if !c.Empty() {
log.Printf("[payment] 微信支付渠道未启用: %s", reason)
}
return err
}
// Get 按渠道名取实例;nil = 未配置/配置失败(渠道隐藏)。
func (m *Manager) Get(name string) Channel {
m.mu.RLock()
defer m.mu.RUnlock()
return m.channels[name]
}
// Available 返回已启用的渠道名(供前端展示可选渠道)。
func (m *Manager) Available() []string {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.channels))
for name := range m.channels {
out = append(out, name)
}
sort.Strings(out)
return out
}
// Status 给 admin 状态面:某渠道是否启用 + 未启用原因。
func (m *Manager) Status(name string) (bool, string) {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.channels[name]
return ok, m.reasons[name]
}
// buildWechat 装配微信渠道实例;返回 (实例, 未启用原因, 错误)。配置为空不算错。
func buildWechat(ctx context.Context, c Config) (*Wechat, string, error) {
if c.Empty() {
return nil, "未配置", nil
}
if miss := c.missing(); len(miss) > 0 {
reason := "配置不全,缺: " + strings.Join(miss, ", ")
return nil, reason, errors.New(reason)
}
w, err := New(ctx, c)
if err != nil {
return nil, "初始化失败: " + err.Error(), err
}
return w, "", nil
}