Files
sub2api/backend/internal/payment/provider/wxpay.go

526 lines
16 KiB
Go

package provider
import (
"bytes"
"context"
"crypto/rsa"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"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/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// WeChat Pay constants.
const (
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
wxpayResultPath = "/payment/result"
)
const (
wxpayMetadataAppID = "appid"
wxpayMetadataMerchantID = "mchid"
wxpayMetadataCurrency = "currency"
wxpayMetadataTradeState = "trade_state"
)
// WeChat Pay create-payment modes.
const (
wxpayModeNative = "native"
wxpayModeH5 = "h5"
wxpayModeJSAPI = "jsapi"
)
// WeChat Pay trade states.
const (
wxpayTradeStateSuccess = "SUCCESS"
wxpayTradeStateRefund = "REFUND"
wxpayTradeStateClosed = "CLOSED"
wxpayTradeStatePayError = "PAYERROR"
)
// WeChat Pay notification event types.
const (
wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS"
)
// WeChat Pay error codes.
const (
wxpayErrNoAuth = "NO_AUTH"
)
var (
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
return svc.Prepay(ctx, req)
}
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
return svc.Prepay(ctx, req)
}
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
return svc.PrepayWithRequestPayment(ctx, req)
}
)
type Wxpay struct {
instanceID string
config map[string]string
mu sync.Mutex
coreClient *core.Client
notifyHandler *notify.Handler
}
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "publicKey", "publicKeyId", "certSerial"}
for _, k := range required {
if config[k] == "" {
return nil, fmt.Errorf("wxpay config missing required key: %s", k)
}
}
if len(config["apiV3Key"]) != 32 {
return nil, fmt.Errorf("wxpay apiV3Key must be exactly 32 bytes, got %d", len(config["apiV3Key"]))
}
return &Wxpay{instanceID: instanceID, config: config}, nil
}
func (w *Wxpay) Name() string { return "Wxpay" }
func (w *Wxpay) ProviderKey() string { return payment.TypeWxpay }
func (w *Wxpay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeWxpay}
}
// ResolveWxpayJSAPIAppID returns the AppID that JSAPI prepay will use for a
// given provider config. A dedicated MP AppID takes precedence over the base
// merchant AppID.
func ResolveWxpayJSAPIAppID(config map[string]string) string {
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
return appID
}
return strings.TrimSpace(config["appId"])
}
func formatPEM(key, keyType string) string {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "-----BEGIN") {
return key
}
return fmt.Sprintf("-----BEGIN %s-----\n%s\n-----END %s-----", keyType, key, keyType)
}
func (w *Wxpay) ensureClient() (*core.Client, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.coreClient != nil {
return w.coreClient, nil
}
privateKey, publicKey, err := w.loadKeyPair()
if err != nil {
return nil, err
}
certSerial := w.config["certSerial"]
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(w.config["mchId"], certSerial, privateKey),
option.WithVerifier(verifier))
if err != nil {
return nil, fmt.Errorf("wxpay init client: %w", err)
}
handler, err := notify.NewRSANotifyHandler(w.config["apiV3Key"], verifier)
if err != nil {
return nil, fmt.Errorf("wxpay init notify handler: %w", err)
}
w.notifyHandler = handler
w.coreClient = client
return w.coreClient, nil
}
func (w *Wxpay) loadKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
if err != nil {
return nil, nil, fmt.Errorf("wxpay load private key: %w", err)
}
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
if err != nil {
return nil, nil, fmt.Errorf("wxpay load public key: %w", err)
}
return privateKey, publicKey, nil
}
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := w.ensureClient()
if err != nil {
return nil, err
}
// Request-first, config-fallback (consistent with EasyPay/Alipay)
notifyURL := req.NotifyURL
if notifyURL == "" {
notifyURL = w.config["notifyUrl"]
}
if notifyURL == "" {
return nil, fmt.Errorf("wxpay notifyUrl is required")
}
totalFen, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("wxpay create payment: %w", err)
}
mode, err := resolveWxpayCreateMode(req)
if err != nil {
return nil, err
}
switch mode {
case wxpayModeJSAPI:
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
case wxpayModeH5:
resp, err := w.prepayH5(ctx, client, req, notifyURL, totalFen)
if err == nil {
return resp, nil
}
if strings.Contains(err.Error(), wxpayErrNoAuth) {
return nil, fmt.Errorf("wxpay h5 payments are not authorized for this merchant: %w", err)
}
return nil, err
case wxpayModeNative:
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
default:
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
}
}
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := jsapi.JsapiApiService{Client: c}
cur := wxpayCurrency
appID := ResolveWxpayJSAPIAppID(w.config)
prepayReq := jsapi.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject),
OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &jsapi.Amount{Total: core.Int64(totalFen), Currency: &cur},
Payer: &jsapi.Payer{Openid: core.String(strings.TrimSpace(req.OpenID))},
}
if clientIP := strings.TrimSpace(req.ClientIP); clientIP != "" {
prepayReq.SceneInfo = &jsapi.SceneInfo{PayerClientIp: core.String(clientIP)}
}
resp, _, err := wxpayJSAPIPrepayWithRequestPayment(ctx, svc, prepayReq)
if err != nil {
return nil, fmt.Errorf("wxpay jsapi prepay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
ResultType: payment.CreatePaymentResultJSAPIReady,
JSAPI: &payment.WechatJSAPIPayload{
AppID: wxSV(resp.Appid),
TimeStamp: wxSV(resp.TimeStamp),
NonceStr: wxSV(resp.NonceStr),
Package: wxSV(resp.Package),
SignType: wxSV(resp.SignType),
PaySign: wxSV(resp.PaySign),
},
}, nil
}
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := native.NativeApiService{Client: c}
cur := wxpayCurrency
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &native.Amount{Total: core.Int64(totalFen), Currency: &cur},
})
if err != nil {
return nil, fmt.Errorf("wxpay native prepay: %w", err)
}
codeURL := ""
if resp.CodeUrl != nil {
codeURL = *resp.CodeUrl
}
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, QRCode: codeURL}, nil
}
func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := h5.H5ApiService{Client: c}
cur := wxpayCurrency
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &h5.Amount{Total: core.Int64(totalFen), Currency: &cur},
SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: buildWxpayH5Info(w.config)},
})
if err != nil {
return nil, fmt.Errorf("wxpay h5 prepay: %w", err)
}
h5URL := ""
if resp.H5Url != nil {
h5URL = *resp.H5Url
}
h5URL, err = appendWxpayRedirectURL(h5URL, req)
if err != nil {
return nil, err
}
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
}
func buildWxpayH5Info(config map[string]string) *h5.H5Info {
tp := wxpayH5Type
info := &h5.H5Info{Type: &tp}
if appName := strings.TrimSpace(config["h5AppName"]); appName != "" {
info.AppName = core.String(appName)
}
if appURL := strings.TrimSpace(config["h5AppUrl"]); appURL != "" {
info.AppUrl = core.String(appURL)
}
return info
}
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
if strings.TrimSpace(req.OpenID) != "" {
return wxpayModeJSAPI, nil
}
if req.IsMobile {
if strings.TrimSpace(req.ClientIP) == "" {
return "", fmt.Errorf("wxpay H5 payment requires client IP")
}
return wxpayModeH5, nil
}
return wxpayModeNative, nil
}
func appendWxpayRedirectURL(h5URL string, req payment.CreatePaymentRequest) (string, error) {
h5URL = strings.TrimSpace(h5URL)
returnURL := strings.TrimSpace(req.ReturnURL)
if h5URL == "" || returnURL == "" {
return h5URL, nil
}
redirectURL, err := buildWxpayResultURL(returnURL, req)
if err != nil {
return "", err
}
sep := "&"
if !strings.Contains(h5URL, "?") {
sep = "?"
}
return h5URL + sep + "redirect_url=" + url.QueryEscape(redirectURL), nil
}
func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (string, error) {
u, err := url.Parse(returnURL)
if err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
}
values := u.Query()
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
values.Set("payment_type", paymentType)
}
if strings.TrimSpace(u.Path) == "" {
u.Path = wxpayResultPath
}
u.RawPath = ""
u.RawQuery = values.Encode()
u.Fragment = ""
return u.String(), nil
}
func wxSV(s *string) string {
if s == nil {
return ""
}
return *s
}
func mapWxState(s string) string {
switch s {
case wxpayTradeStateSuccess:
return payment.ProviderStatusPaid
case wxpayTradeStateRefund:
return payment.ProviderStatusRefunded
case wxpayTradeStateClosed, wxpayTradeStatePayError:
return payment.ProviderStatusFailed
default:
return payment.ProviderStatusPending
}
}
func buildWxpayTransactionMetadata(tx *payments.Transaction) map[string]string {
if tx == nil {
return nil
}
metadata := map[string]string{}
if appID := wxSV(tx.Appid); appID != "" {
metadata[wxpayMetadataAppID] = appID
}
if merchantID := wxSV(tx.Mchid); merchantID != "" {
metadata[wxpayMetadataMerchantID] = merchantID
}
if tradeState := wxSV(tx.TradeState); tradeState != "" {
metadata[wxpayMetadataTradeState] = tradeState
}
if tx.Amount != nil {
if currency := wxSV(tx.Amount.Currency); currency != "" {
metadata[wxpayMetadataCurrency] = currency
}
}
if len(metadata) == 0 {
return nil
}
return metadata
}
func (w *Wxpay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
c, err := w.ensureClient()
if err != nil {
return nil, err
}
svc := native.NativeApiService{Client: c}
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
})
if err != nil {
return nil, fmt.Errorf("wxpay query order: %w", err)
}
var amt float64
if tx.Amount != nil && tx.Amount.Total != nil {
amt = payment.FenToYuan(*tx.Amount.Total)
}
id := tradeNo
if tx.TransactionId != nil {
id = *tx.TransactionId
}
pa := ""
if tx.SuccessTime != nil {
pa = *tx.SuccessTime
}
return &payment.QueryOrderResponse{
TradeNo: id,
Status: mapWxState(wxSV(tx.TradeState)),
Amount: amt,
PaidAt: pa,
Metadata: buildWxpayTransactionMetadata(tx),
}, nil
}
func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
if _, err := w.ensureClient(); err != nil {
return nil, err
}
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", io.NopCloser(bytes.NewBufferString(rawBody)))
if err != nil {
return nil, fmt.Errorf("wxpay construct request: %w", err)
}
for k, v := range headers {
r.Header.Set(k, v)
}
var tx payments.Transaction
nr, err := w.notifyHandler.ParseNotifyRequest(ctx, r, &tx)
if err != nil {
return nil, fmt.Errorf("wxpay verify notification: %w", err)
}
if nr.EventType != wxpayEventTransactionSuccess {
return nil, nil
}
var amt float64
if tx.Amount != nil && tx.Amount.Total != nil {
amt = payment.FenToYuan(*tx.Amount.Total)
}
st := payment.ProviderStatusFailed
if wxSV(tx.TradeState) == wxpayTradeStateSuccess {
st = payment.ProviderStatusSuccess
}
return &payment.PaymentNotification{
TradeNo: wxSV(tx.TransactionId), OrderID: wxSV(tx.OutTradeNo),
Amount: amt, Status: st, RawData: rawBody, Metadata: buildWxpayTransactionMetadata(&tx),
}, nil
}
func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
c, err := w.ensureClient()
if err != nil {
return nil, err
}
rf, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("wxpay refund amount: %w", err)
}
tf, err := w.queryOrderTotalFen(ctx, c, req.OrderID)
if err != nil {
return nil, err
}
rs := refunddomestic.RefundsApiService{Client: c}
cur := wxpayCurrency
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
OutTradeNo: core.String(req.OrderID),
OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())),
Reason: core.String(req.Reason),
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
})
if err != nil {
return nil, fmt.Errorf("wxpay refund: %w", err)
}
rid := wxSV(res.RefundId)
if rid == "" {
rid = fmt.Sprintf("%s-refund", req.OrderID)
}
st := payment.ProviderStatusPending
if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
st = payment.ProviderStatusSuccess
}
return &payment.RefundResponse{RefundID: rid, Status: st}, nil
}
func (w *Wxpay) queryOrderTotalFen(ctx context.Context, c *core.Client, orderID string) (int64, error) {
svc := native.NativeApiService{Client: c}
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(orderID), Mchid: core.String(w.config["mchId"]),
})
if err != nil {
return 0, fmt.Errorf("wxpay refund query order: %w", err)
}
var tf int64
if tx.Amount != nil && tx.Amount.Total != nil {
tf = *tx.Amount.Total
}
return tf, nil
}
func (w *Wxpay) CancelPayment(ctx context.Context, tradeNo string) error {
c, err := w.ensureClient()
if err != nil {
return err
}
svc := native.NativeApiService{Client: c}
_, err = svc.CloseOrder(ctx, native.CloseOrderRequest{
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
})
if err != nil {
return fmt.Errorf("wxpay cancel payment: %w", err)
}
return nil
}
var (
_ payment.Provider = (*Wxpay)(nil)
_ payment.CancelableProvider = (*Wxpay)(nil)
)