first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+351
View File
@@ -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
}