Files
Blizzard 5d7eca5de3 fix(billing): 微信支付钉死「微信支付公钥」验签体系 —— 商户 2025-09 开户没有平台证书
用户拿旧项目代码对出来的真问题:我此前用 WithWechatPayAutoAuthCipher(平台证书
模式,APIv3 密钥自动下载平台证书验签),但 2024 起新注册商户只发「微信支付公钥」
(PUB_KEY_ID_ 开头)、没有平台证书——在该商户号上初始化/回调验签都会挂。

- 改 WithWechatPayPublicKeyAuthCipher(商户私钥+公钥ID+公钥文件);回调验签用
  NewSHA256WithRSAPubkeyVerifier;平台证书模式不留双模式赘肉(YAGNI)。
- Config 增 public_key_path/public_key_id(必填,公钥文件同样只存路径);
  admin 卡片补两字段;env 兜底加 WECHAT_PUBLIC_KEY(_ID)。
- 顺手修 live 撞出的真 bug:sundynix_setting.value 是 varchar(255),
  支付配置 JSON(含加密密钥)一条就超(SQLSTATE 22001)→ 改 text。
live:列类型已迁 text;缺公钥两项报「配置不全,缺: public_key_path,
public_key_id」;GET 回显含新字段。go 6 包测试+tsc+41 vitest 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:12:26 +08:00

122 lines
4.2 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 payment
import (
"context"
"errors"
"log"
"os"
"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 持有当前微信渠道实例,支持 admin 改配置后热重载(学 prompt 控制面:改完即生效,
// 不重启 gateway)。所有业务路径经 Current() 取用——nil 即渠道隐藏。
type Manager struct {
mu sync.RWMutex
w *Wechat
reason string // 未启用原因(给 admin 状态面看)
}
func NewManager() *Manager {
return &Manager{reason: "未配置"}
}
// Reload 按给定配置重建渠道实例。失败只降级为隐藏并记录原因,绝不 panic/拖垮服务。
func (m *Manager) Reload(ctx context.Context, c Config) error {
w, reason, err := build(ctx, c)
m.mu.Lock()
m.w = w
m.reason = reason
m.mu.Unlock()
if w != nil {
log.Printf("[payment] 微信支付 Native 渠道已启用 (mchid=%s)", c.MchID)
} else if !c.Empty() {
log.Printf("[payment] 微信支付渠道未启用: %s", reason)
}
return err
}
// Current 当前渠道实例;nil = 未配置/配置失败(渠道隐藏)。
func (m *Manager) Current() *Wechat {
m.mu.RLock()
defer m.mu.RUnlock()
return m.w
}
// Status 给 admin 状态面:是否启用 + 未启用原因。
func (m *Manager) Status() (bool, string) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.w != nil, m.reason
}
// build 装配渠道实例;返回 (实例, 未启用原因, 错误)。配置为空不算错。
func build(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
}