Files
2026-09-11 01:41:52 +08:00

352 lines
10 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 wechat
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"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/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// payClient 微信支付客户端,基于官方 SDK(自动平台证书轮换 + 请求签名 + 应答/回调验签)。
type payClient struct {
appID string
mchID string
notifyURL string
refundNotify string
client *core.Client
notifyHandler *notify.Handler
}
var (
defaultPayClient *payClient
payClientMu sync.RWMutex
)
// InitPayClient 初始化全局微信支付客户端。
//
// 使用官方 SDK 的 WithWechatPayAutoAuthCipher:加载商户私钥后自动注册平台证书下载器
// (定期轮换),并具备请求签名、应答自动验签能力;同时构建回调处理器用于回调验签 + 解密。
func InitPayClient(s *setting.WechatPaySettingS) error {
if s == nil {
return errors.New("wechat pay setting is nil")
}
if s.AppID == "" || s.MchID == "" || s.SerialNo == "" || s.APIv3Key == "" || s.PrivateKeyPath == "" {
return errors.New("wechat pay setting incomplete")
}
if len(s.APIv3Key) != 32 {
return fmt.Errorf("wechat pay APIv3Key must be 32 bytes, got %d", len(s.APIv3Key))
}
privateKey, err := utils.LoadPrivateKeyWithPath(s.PrivateKeyPath)
if err != nil {
return fmt.Errorf("load merchant private key: %w", err)
}
ctx := context.Background()
// 创建客户端:内部注册平台证书自动下载器(轮换)、请求签名器与应答验签器。
client, err := core.NewClient(
ctx,
option.WithWechatPayAutoAuthCipher(s.MchID, s.SerialNo, privateKey, s.APIv3Key),
)
if err != nil {
return fmt.Errorf("new wechat pay client: %w", err)
}
// 回调处理器:使用平台证书访问器进行回调验签,APIv3Key 进行解密。
certVisitor := downloader.MgrInstance().GetCertificateVisitor(s.MchID)
notifyHandler, err := notify.NewRSANotifyHandler(
s.APIv3Key,
verifiers.NewSHA256WithRSAVerifier(certVisitor),
)
if err != nil {
return fmt.Errorf("new wechat notify handler: %w", err)
}
c := &payClient{
appID: s.AppID,
mchID: s.MchID,
notifyURL: s.NotifyURL,
refundNotify: s.RefundNotifyURL,
client: client,
notifyHandler: notifyHandler,
}
payClientMu.Lock()
defaultPayClient = c
payClientMu.Unlock()
return nil
}
// getPayClient 获取已初始化的支付客户端。
func getPayClient() (*payClient, error) {
payClientMu.RLock()
defer payClientMu.RUnlock()
if defaultPayClient == nil {
return nil, errors.New("wechat pay client not initialized")
}
return defaultPayClient, nil
}
// MiniPayParams 小程序调起支付所需参数(对应 dto.WechatPayParams)。
type MiniPayParams struct {
TimeStamp string
NonceStr string
Package string
SignType string
PaySign string
}
// JsapiPrepay 调用微信 JSAPI 下单,一步返回小程序调起支付所需的签名参数。
// - openid:支付用户在小程序 AppID 下的 openid
// - orderNo:商户订单号(out_trade_no)
// - description:商品描述
// - amount:应付金额(分)
func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64) (*MiniPayParams, error) {
c, err := getPayClient()
if err != nil {
return nil, err
}
if openid == "" {
return nil, errors.New("empty openid")
}
svc := jsapi.JsapiApiService{Client: c.client}
resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
Appid: core.String(c.appID),
Mchid: core.String(c.mchID),
Description: core.String(description),
OutTradeNo: core.String(orderNo),
NotifyUrl: core.String(c.notifyURL),
Amount: &jsapi.Amount{
Total: core.Int64(amount),
Currency: core.String("CNY"),
},
Payer: &jsapi.Payer{
Openid: core.String(openid),
},
})
if err != nil {
return nil, fmt.Errorf("jsapi prepay: %w", err)
}
return &MiniPayParams{
TimeStamp: strDeref(resp.TimeStamp),
NonceStr: strDeref(resp.NonceStr),
Package: strDeref(resp.Package),
SignType: strDeref(resp.SignType),
PaySign: strDeref(resp.PaySign),
}, nil
}
// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。
// - orderNo:商户订单号(out_trade_no)
func CloseOrder(ctx context.Context, orderNo string) error {
c, err := getPayClient()
if err != nil {
return err
}
if orderNo == "" {
return errors.New("empty order no")
}
svc := jsapi.JsapiApiService{Client: c.client}
if _, err := svc.CloseOrder(ctx, jsapi.CloseOrderRequest{
OutTradeNo: core.String(orderNo),
Mchid: core.String(c.mchID),
}); err != nil {
return fmt.Errorf("jsapi close order: %w", err)
}
return nil
}
// RefundResult 退款受理结果。
type RefundResult struct {
RefundID string // 微信退款单号
Status string // SUCCESS/CLOSED/PROCESSING/ABNORMAL
}
// Refund 申请退款。
// - orderNo:原商户订单号
// - refundNo:商户退款单号
// - total:原订单总金额(分)
// - refund:本次退款金额(分)
func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, reason string) (*RefundResult, error) {
c, err := getPayClient()
if err != nil {
return nil, err
}
req := refunddomestic.CreateRequest{
OutTradeNo: core.String(orderNo),
OutRefundNo: core.String(refundNo),
Amount: &refunddomestic.AmountReq{
Refund: core.Int64(refund),
Total: core.Int64(total),
Currency: core.String("CNY"),
},
}
if c.refundNotify != "" {
req.NotifyUrl = core.String(c.refundNotify)
}
if reason != "" {
req.Reason = core.String(reason)
}
svc := refunddomestic.RefundsApiService{Client: c.client}
resp, _, err := svc.Create(ctx, req)
if err != nil {
return nil, fmt.Errorf("refund create: %w", err)
}
res := &RefundResult{RefundID: strDeref(resp.RefundId)}
if resp.Status != nil {
res.Status = string(*resp.Status)
}
return res, nil
}
// ---- 回调验签 + 解密 ----
// PayNotifyData 支付成功回调解密后的关键字段。
type PayNotifyData struct {
Raw string // 回调原始报文(验签前捕获,用于审计持久化)
OutTradeNo string
TransactionID string
TradeState string // SUCCESS 表示支付成功
Amount struct {
Total int64
PayerTotal int64
Currency string
}
SuccessTime string
}
// RefundNotifyData 退款回调解密后的关键字段。
type RefundNotifyData struct {
Raw string // 回调原始报文(验签前捕获,用于审计持久化)
OutTradeNo string
OutRefundNo string
RefundID string
RefundStatus string // SUCCESS/CLOSED/ABNORMAL
SuccessTime string
Amount struct {
Total int64
Refund int64
}
}
// refundNotifyContent 退款回调解密后的原始结构(退款通知使用 refund_status 字段)。
type refundNotifyContent struct {
OutTradeNo string `json:"out_trade_no"`
OutRefundNo string `json:"out_refund_no"`
RefundID string `json:"refund_id"`
TransactionID string `json:"transaction_id"`
RefundStatus string `json:"refund_status"`
SuccessTime string `json:"success_time"`
Amount struct {
Total int64 `json:"total"`
Refund int64 `json:"refund"`
PayerTotal int64 `json:"payer_total"`
PayerRefund int64 `json:"payer_refund"`
} `json:"amount"`
}
// VerifyAndDecryptPayNotify 校验并解密支付回调请求,返回订单数据。
//
// 由官方 SDK 完成:校验签名头 → 平台证书验签 → APIv3Key 解密 → 反序列化,杜绝伪造/篡改回调。
func VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error) {
c, err := getPayClient()
if err != nil {
return nil, err
}
// 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。
raw, err := io.ReadAll(request.Body)
if err != nil {
return nil, fmt.Errorf("read pay notify body: %w", err)
}
request.Body = io.NopCloser(bytes.NewReader(raw))
tx := new(payments.Transaction)
if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, tx); err != nil {
return nil, fmt.Errorf("parse pay notify: %w", err)
}
d := &PayNotifyData{
Raw: string(raw),
OutTradeNo: strDeref(tx.OutTradeNo),
TransactionID: strDeref(tx.TransactionId),
TradeState: strDeref(tx.TradeState),
SuccessTime: strDeref(tx.SuccessTime),
}
if tx.Amount != nil {
d.Amount.Total = int64Deref(tx.Amount.Total)
d.Amount.PayerTotal = int64Deref(tx.Amount.PayerTotal)
d.Amount.Currency = strDeref(tx.Amount.Currency)
}
return d, nil
}
// VerifyAndDecryptRefundNotify 校验并解密退款回调请求。
func VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error) {
c, err := getPayClient()
if err != nil {
return nil, err
}
// 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。
raw, err := io.ReadAll(request.Body)
if err != nil {
return nil, fmt.Errorf("read refund notify body: %w", err)
}
request.Body = io.NopCloser(bytes.NewReader(raw))
content := new(refundNotifyContent)
if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, content); err != nil {
return nil, fmt.Errorf("parse refund notify: %w", err)
}
d := &RefundNotifyData{
Raw: string(raw),
OutTradeNo: content.OutTradeNo,
OutRefundNo: content.OutRefundNo,
RefundID: content.RefundID,
RefundStatus: content.RefundStatus,
SuccessTime: content.SuccessTime,
}
d.Amount.Total = content.Amount.Total
d.Amount.Refund = content.Amount.Refund
return d, nil
}
// ---- 内部辅助 ----
func strDeref(s *string) string {
if s == nil {
return ""
}
return *s
}
func int64Deref(i *int64) int64 {
if i == nil {
return 0
}
return *i
}