Files
past-go/past-go/internal/wxpay/client.go
T
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
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.
2026-11-02 23:40:49 +08:00

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
}