first commit
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user