Files
sundynix-agentix/sundynix-gateway/internal/payment/wechat.go
T
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

138 lines
5.1 KiB
Go

// Package payment 是充值渠道适配层(设计见 PAYMENT_DESIGN.md §3/§5)。
// P5.1 的兑换码不走这里(无「待支付」态,核销即入账);本包面向真渠道:
// 下单出支付凭据 → 回调/查单确认 → 上层 MarkOrderPaid 幂等入账。
package payment
import (
"context"
"crypto/rsa"
"errors"
"fmt"
"net/http"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// Wechat 微信支付 Native(扫码)适配器。配置来源见 Config(DB 优先、env 兜底,由 Manager 装配)。
// 本地开发收不到公网回调没关系:前端轮询的 GET /billing/orders/:id 会主动查单确认,
// 回调只是生产环境更快的到账通道,两条路都汇入同一个幂等入账闸。
type Wechat struct {
mchID string
appID string
notifyURL string
apiv3Key string
client *core.Client
svc native.NativeApiService
pubKey *rsa.PublicKey // 微信支付公钥(验签回调用;构造时必填)
pubKeyID string
}
// New 按完整配置装配微信渠道(商户私钥/微信支付公钥都从磁盘路径加载;调用方保证字段齐全)。
// 验签体系钉死「微信支付公钥」模式(WithWechatPayPublicKeyAuthCipher):本项目商户
// 2025-09 开户,只有公钥体系;老商户的平台证书模式(AutoAuthCipher)不支持。
func New(ctx context.Context, c Config) (*Wechat, error) {
priv, err := utils.LoadPrivateKeyWithPath(c.PrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("商户私钥加载失败(%s): %w", c.PrivateKeyPath, err)
}
pub, err := utils.LoadPublicKeyWithPath(c.PublicKeyPath)
if err != nil {
return nil, fmt.Errorf("微信支付公钥加载失败(%s): %w", c.PublicKeyPath, err)
}
client, err := core.NewClient(ctx,
option.WithWechatPayPublicKeyAuthCipher(c.MchID, c.CertSerial, priv, c.PublicKeyID, pub))
if err != nil {
return nil, fmt.Errorf("客户端初始化失败: %w", err)
}
return &Wechat{
mchID: c.MchID, appID: c.AppID, notifyURL: c.NotifyURL, apiv3Key: c.APIv3Key,
client: client, svc: native.NativeApiService{Client: client},
pubKey: pub, pubKeyID: c.PublicKeyID,
}, nil
}
// CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。
func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (string, error) {
resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{
Appid: core.String(w.appID),
Mchid: core.String(w.mchID),
Description: core.String(description),
OutTradeNo: core.String(orderID),
NotifyUrl: core.String(w.notifyURL),
Amount: &native.Amount{Total: core.Int64(amountFen), Currency: core.String("CNY")},
})
if err != nil {
return "", err
}
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
return "", errors.New("微信未返回 code_url")
}
return *resp.CodeUrl, nil
}
// QueryResult 查单/回调解析后的统一结果。
type QueryResult struct {
OrderID string // out_trade_no
ChannelTxn string // transaction_id
Paid bool // TradeState == SUCCESS
Closed bool // CLOSED/REVOKED/PAYERROR 等终态失败
AmountFen int64 // 用户实付(分);回调/查单都带,供金额核对
}
func fromTransaction(t *payments.Transaction) QueryResult {
r := QueryResult{}
if t.OutTradeNo != nil {
r.OrderID = *t.OutTradeNo
}
if t.TransactionId != nil {
r.ChannelTxn = *t.TransactionId
}
if t.Amount != nil && t.Amount.PayerTotal != nil {
r.AmountFen = *t.Amount.PayerTotal
} else if t.Amount != nil && t.Amount.Total != nil {
r.AmountFen = *t.Amount.Total
}
if t.TradeState != nil {
switch *t.TradeState {
case "SUCCESS":
r.Paid = true
case "CLOSED", "REVOKED", "PAYERROR":
r.Closed = true
}
}
return r
}
// QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。
func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (QueryResult, error) {
t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(orderID),
Mchid: core.String(w.mchID),
})
if err != nil {
return QueryResult{}, err
}
return fromTransaction(t), nil
}
// VerifyCallback 验签 + 解密支付回调(微信支付公钥验签 + APIv3 密钥 AES-GCM 解密资源)。
// 验签失败一律拒绝——回调路由是公开的,签名是唯一的门。
func (w *Wechat) VerifyCallback(req *http.Request) (QueryResult, error) {
h, err := notify.NewRSANotifyHandler(w.apiv3Key,
verifiers.NewSHA256WithRSAPubkeyVerifier(w.pubKeyID, *w.pubKey))
if err != nil {
return QueryResult{}, fmt.Errorf("回调处理器初始化失败: %w", err)
}
txn := new(payments.Transaction)
if _, err := h.ParseNotifyRequest(req.Context(), req, txn); err != nil {
return QueryResult{}, err
}
return fromTransaction(txn), nil
}