a42f30239f
微信是外部第三方、最可能慢/挂,而 wechatpay-go SDK 默认 http.Client 无 Timeout: 一次卡住的 Prepay/QueryOrder 会拖住请求 goroutine;尤其掉单补偿定时器用的是 context.Background()(无超时)→ 微信一挂那轮 tick 无限期卡死。 双保险:客户端级 HTTP 超时(WithHTTPClient Timeout=15s,belt) + CreatePay/QueryOrder 每次调用 ctx 超时(suspenders,兜住 SDK 忽略或背景 ctx 的情况)。 范围克制:只硬化微信这个真外部依赖。PG/Redis(连接池+降级)、NATS(无限重连)、mcp-go RAG 客户端(embed 30s/chat 60s/rerank 20s 已有超时)本就有韧性,不额外套熔断(那对本规模是过度设计)。 build+vet+test 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
5.7 KiB
Go
147 lines
5.7 KiB
Go
// Package payment 是充值渠道适配层(设计见 PAYMENT_DESIGN.md §3/§5)。
|
||
// P5.1 的兑换码不走这里(无「待支付」态,核销即入账);本包面向真渠道:
|
||
// 下单出支付凭据 → 回调/查单确认 → 上层 MarkOrderPaid 幂等入账。
|
||
package payment
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rsa"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
|
||
"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
|
||
}
|
||
|
||
// wechatAPITimeout 限制单次微信 API 往返。微信是外部第三方依赖、最可能慢/挂,
|
||
// SDK 默认 http.Client 无 Timeout:一次卡住的 Prepay/QueryOrder 会拖住请求 goroutine,
|
||
// 尤其掉单补偿定时器用的是 context.Background()(无超时)→ 微信一挂那轮 tick 无限期卡死。
|
||
// 双保险:客户端级 HTTP 超时(belt)+ 每次调用的 ctx 超时(suspenders,兜住 SDK 忽略/背景 ctx)。
|
||
const wechatAPITimeout = 15 * time.Second
|
||
|
||
// 编译期断言:Wechat 实现 Channel 接口。
|
||
var _ Channel = (*Wechat)(nil)
|
||
|
||
// Name 渠道名(用于按订单渠道路由查单/回调)。
|
||
func (w *Wechat) Name() string { return ChannelWechat }
|
||
|
||
// 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),
|
||
option.WithHTTPClient(&http.Client{Timeout: wechatAPITimeout})) // 客户端级超时兜底
|
||
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) (PayIntent, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, wechatAPITimeout)
|
||
defer cancel()
|
||
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 PayIntent{}, err
|
||
}
|
||
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
|
||
return PayIntent{}, errors.New("微信未返回 code_url")
|
||
}
|
||
return PayIntent{CodeURL: *resp.CodeUrl}, nil
|
||
}
|
||
|
||
func fromTransaction(t *payments.Transaction) PayResult {
|
||
r := PayResult{}
|
||
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) (PayResult, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, wechatAPITimeout)
|
||
defer cancel()
|
||
t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
||
OutTradeNo: core.String(orderID),
|
||
Mchid: core.String(w.mchID),
|
||
})
|
||
if err != nil {
|
||
return PayResult{}, err
|
||
}
|
||
return fromTransaction(t), nil
|
||
}
|
||
|
||
// VerifyCallback 验签 + 解密支付回调(微信支付公钥验签 + APIv3 密钥 AES-GCM 解密资源)。
|
||
// 验签失败一律拒绝——回调路由是公开的,签名是唯一的门。
|
||
func (w *Wechat) VerifyCallback(req *http.Request) (PayResult, error) {
|
||
h, err := notify.NewRSANotifyHandler(w.apiv3Key,
|
||
verifiers.NewSHA256WithRSAPubkeyVerifier(w.pubKeyID, *w.pubKey))
|
||
if err != nil {
|
||
return PayResult{}, fmt.Errorf("回调处理器初始化失败: %w", err)
|
||
}
|
||
txn := new(payments.Transaction)
|
||
if _, err := h.ParseNotifyRequest(req.Context(), req, txn); err != nil {
|
||
return PayResult{}, err
|
||
}
|
||
return fromTransaction(txn), nil
|
||
}
|