352 lines
10 KiB
Go
352 lines
10 KiB
Go
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
|
||
}
|