Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
117 lines
4.5 KiB
Go
117 lines
4.5 KiB
Go
// Package wxpay integrates direct-merchant API v3 mini-program payment.
|
|
package wxpay
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"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/jsapi"
|
|
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
|
"past/internal/config"
|
|
)
|
|
|
|
type PrepayOrder struct {
|
|
Number, Description, OpenID, Attach string
|
|
Amount int64
|
|
ExpiresAt time.Time
|
|
}
|
|
type Gateway interface {
|
|
Prepay(context.Context, PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error)
|
|
Query(context.Context, string) (*payments.Transaction, error)
|
|
ParseNotify(context.Context, *http.Request) (*payments.Transaction, error)
|
|
}
|
|
type Client struct {
|
|
cfg config.WechatPayConfig
|
|
service jsapi.JsapiApiService
|
|
handler *notify.Handler
|
|
}
|
|
|
|
func New(cfg config.WechatPayConfig) (*Client, error) {
|
|
if cfg.AppID == "" || cfg.MchID == "" || cfg.CertificateSerial == "" || cfg.PublicKeyID == "" || len(cfg.APIv3Key) != 32 {
|
|
return nil, errors.New("微信支付 AppID、商户号、证书序列号、公钥 ID 及 32 字节 APIv3 密钥必须配置")
|
|
}
|
|
if err := cfg.ValidateNotifyURL(); err != nil {
|
|
return nil, err
|
|
}
|
|
privateKey, err := utils.LoadPrivateKeyWithPath(cfg.PrivateKeyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("加载微信支付商户私钥: %w", err)
|
|
}
|
|
publicKey, err := utils.LoadPublicKeyWithPath(cfg.PublicKeyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("加载微信支付公钥: %w", err)
|
|
}
|
|
if err := validateCertificate(cfg, privateKey); err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := core.NewClient(context.Background(), option.WithWechatPayPublicKeyAuthCipher(cfg.MchID, cfg.CertificateSerial, privateKey, cfg.PublicKeyID, publicKey), option.WithHTTPClient(&http.Client{Timeout: 15 * time.Second}))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
handler, err := notify.NewRSANotifyHandler(cfg.APIv3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(cfg.PublicKeyID, *publicKey))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Client{cfg: cfg, service: jsapi.JsapiApiService{Client: client}, handler: handler}, nil
|
|
}
|
|
|
|
func validateCertificate(cfg config.WechatPayConfig, key *rsa.PrivateKey) error {
|
|
if cfg.CertificatePath == "" {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(cfg.CertificatePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
block, _ := pem.Decode(data)
|
|
if block == nil {
|
|
return errors.New("无效的商户证书")
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
|
if !ok || pub.N.Cmp(key.N) != 0 || pub.E != key.E {
|
|
return errors.New("商户证书与私钥不匹配")
|
|
}
|
|
if fmt.Sprintf("%X", cert.SerialNumber) != cfg.CertificateSerial || cert.Subject.CommonName != cfg.MchID {
|
|
return errors.New("商户证书序列号或商户号与配置不匹配")
|
|
}
|
|
if time.Now().Before(cert.NotBefore) || time.Now().After(cert.NotAfter) {
|
|
return errors.New("商户证书不在有效期内")
|
|
}
|
|
return nil
|
|
}
|
|
func (c *Client) Prepay(ctx context.Context, o PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) {
|
|
res, _, err := c.service.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{Appid: core.String(c.cfg.AppID), Mchid: core.String(c.cfg.MchID), Description: core.String(o.Description), OutTradeNo: core.String(o.Number), NotifyUrl: core.String(c.cfg.NotifyURL), Attach: core.String(o.Attach), TimeExpire: core.Time(o.ExpiresAt), Amount: &jsapi.Amount{Total: core.Int64(o.Amount), Currency: core.String("CNY")}, Payer: &jsapi.Payer{Openid: core.String(o.OpenID)}})
|
|
return res, err
|
|
}
|
|
func (c *Client) Query(ctx context.Context, number string) (*payments.Transaction, error) {
|
|
res, _, err := c.service.QueryOrderByOutTradeNo(ctx, jsapi.QueryOrderByOutTradeNoRequest{Mchid: core.String(c.cfg.MchID), OutTradeNo: core.String(number)})
|
|
return res, err
|
|
}
|
|
func (c *Client) ParseNotify(ctx context.Context, r *http.Request) (*payments.Transaction, error) {
|
|
tx := new(payments.Transaction)
|
|
req, err := c.handler.ParseNotifyRequest(ctx, r, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.EventType != "TRANSACTION.SUCCESS" || req.Resource == nil || req.Resource.OriginalType != "transaction" {
|
|
return nil, errors.New("不支持的微信支付通知类型")
|
|
}
|
|
return tx, nil
|
|
}
|