| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- package provider
- import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "sync"
- "github.com/Wei-Shaw/sub2api/internal/payment"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "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"
- )
- 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
- }
- const wxpayAPIv3KeyLength = 32
- func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
- // All fields are required. Platform-certificate mode is intentionally unsupported —
- // WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
- // and newly-provisioned merchants cannot download platform certificates at all.
- required := []string{"appId", "mchId", "privateKey", "apiV3Key", "certSerial", "publicKey", "publicKeyId"}
- for _, k := range required {
- if config[k] == "" {
- return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
- WithMetadata(map[string]string{"key": k})
- }
- }
- if len(config["apiV3Key"]) != wxpayAPIv3KeyLength {
- return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY_LENGTH", "invalid_key_length").
- WithMetadata(map[string]string{
- "key": "apiV3Key",
- "expected": strconv.Itoa(wxpayAPIv3KeyLength),
- "actual": strconv.Itoa(len(config["apiV3Key"])),
- })
- }
- // Parse PEMs eagerly so malformed keys surface at save time, not at order creation.
- if _, err := utils.LoadPrivateKey(formatPEM(config["privateKey"], "PRIVATE KEY")); err != nil {
- return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
- WithMetadata(map[string]string{"key": "privateKey"})
- }
- if _, err := utils.LoadPublicKey(formatPEM(config["publicKey"], "PUBLIC KEY")); err != nil {
- return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
- WithMetadata(map[string]string{"key": "publicKey"})
- }
- 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, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
- if err != nil {
- return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
- WithMetadata(map[string]string{"key": "privateKey"})
- }
- publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
- if err != nil {
- return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
- WithMetadata(map[string]string{"key": "publicKey"})
- }
- verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
- client, err := core.NewClient(context.Background(),
- option.WithMerchantCredential(w.config["mchId"], w.config["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) 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:
- return w.prepayH5(ctx, client, req, notifyURL, totalFen)
- 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
- outRefundNo := wxpayRefundID(req.OrderID, req.Amount)
- res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
- OutTradeNo: core.String(req.OrderID),
- OutRefundNo: core.String(outRefundNo),
- 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)
- }
- st := payment.ProviderStatusPending
- if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
- st = payment.ProviderStatusSuccess
- }
- return &payment.RefundResponse{RefundID: outRefundNo, Status: st}, nil
- }
- func (w *Wxpay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
- c, err := w.ensureClient()
- if err != nil {
- return nil, err
- }
- outRefundNo := strings.TrimSpace(req.RefundID)
- if outRefundNo == "" {
- outRefundNo = wxpayRefundID(req.OrderID, req.Amount)
- }
- if outRefundNo == "" {
- return nil, fmt.Errorf("wxpay query refund: missing refund id")
- }
- rs := refunddomestic.RefundsApiService{Client: c}
- res, _, err := rs.QueryByOutRefundNo(ctx, refunddomestic.QueryByOutRefundNoRequest{
- OutRefundNo: core.String(outRefundNo),
- })
- if err != nil {
- return nil, fmt.Errorf("wxpay query refund: %w", err)
- }
- status := payment.ProviderStatusPending
- if res != nil && res.Status != nil {
- switch *res.Status {
- case refunddomestic.STATUS_SUCCESS:
- status = payment.ProviderStatusSuccess
- case refunddomestic.STATUS_CLOSED, refunddomestic.STATUS_ABNORMAL:
- status = payment.ProviderStatusFailed
- default:
- status = payment.ProviderStatusPending
- }
- }
- return &payment.RefundResponse{RefundID: outRefundNo, Status: status}, nil
- }
- func wxpayRefundID(orderID, amount string) string {
- orderID = strings.TrimSpace(orderID)
- if orderID == "" {
- return ""
- }
- amount = strings.NewReplacer(".", "", "-", "").Replace(strings.TrimSpace(amount))
- if amount == "" {
- return orderID + "-refund"
- }
- return orderID + "-refund-" + amount
- }
- 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)
- )
|