370 lines
14 KiB
Go
370 lines
14 KiB
Go
package paymentplugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const wechatCheckoutLifetime = 2 * time.Hour
|
|
|
|
type WeChatProvider struct {
|
|
client *http.Client
|
|
baseURL string
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewWeChatProvider(client *http.Client, baseURL string) *WeChatProvider {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
return &WeChatProvider{client: client, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now}
|
|
}
|
|
|
|
func (p *WeChatProvider) Descriptor() Descriptor {
|
|
return Descriptor{ID: ProviderWeChatNative, PluginID: PluginWeChatNative, PluginVersion: "1.0.0", Name: "微信支付 Native", Icon: "brand:wechat-pay", CheckoutMode: "qr_code", IdentityFields: []string{"appId", "mchId"}, NotificationSuccess: NotificationResponse{Status: 204}, NotificationFailure: NotificationResponse{Status: 400}}
|
|
}
|
|
|
|
func (p *WeChatProvider) ValidateConfig(config Config) error {
|
|
for _, key := range []string{"appId", "mchId", "merchantSerialNo", "merchantPrivateKey", "apiV3Key", "wechatPayPublicKeyId", "wechatPayPublicKey"} {
|
|
if strings.TrimSpace(config[key]) == "" {
|
|
return fmt.Errorf("微信支付配置缺少 %s", key)
|
|
}
|
|
}
|
|
if len([]byte(config["apiV3Key"])) != 32 {
|
|
return errors.New("微信支付 APIv3 密钥必须为 32 字节")
|
|
}
|
|
if _, err := parseRSAPrivateKey(config["merchantPrivateKey"]); err != nil {
|
|
return fmt.Errorf("微信支付商户私钥无效:%w", err)
|
|
}
|
|
if _, err := parseRSAPublicKey(config["wechatPayPublicKey"]); err != nil {
|
|
return fmt.Errorf("微信支付公钥无效:%w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *WeChatProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Checkout{}, err
|
|
}
|
|
if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" {
|
|
return Checkout{}, errors.New("微信 Native 下单参数无效")
|
|
}
|
|
payload := map[string]any{
|
|
"appid": config["appId"],
|
|
"mchid": config["mchId"],
|
|
"description": truncateUTF8(request.Description, 127),
|
|
"out_trade_no": request.MerchantOrderNo,
|
|
"time_expire": request.ExpiresAt.Format(time.RFC3339),
|
|
"notify_url": request.NotifyURL,
|
|
"amount": map[string]any{"total": request.AmountFen, "currency": request.Currency},
|
|
}
|
|
var response struct {
|
|
CodeURL string `json:"code_url"`
|
|
}
|
|
if err := p.do(ctx, config, http.MethodPost, "/v3/pay/transactions/native", payload, &response); err != nil {
|
|
return Checkout{}, err
|
|
}
|
|
if strings.TrimSpace(response.CodeURL) == "" {
|
|
return Checkout{}, errors.New("微信 Native 下单未返回 code_url")
|
|
}
|
|
expiresAt := p.now().Add(wechatCheckoutLifetime)
|
|
if request.ExpiresAt.Before(expiresAt) {
|
|
expiresAt = request.ExpiresAt
|
|
}
|
|
return Checkout{Mode: "qr_code", Value: response.CodeURL, ExpiresAt: expiresAt}, nil
|
|
}
|
|
|
|
func (p *WeChatProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Result{}, err
|
|
}
|
|
path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(request.MerchantOrderNo) + "?mchid=" + url.QueryEscape(config["mchId"])
|
|
var response wechatTransaction
|
|
if err := p.do(ctx, config, http.MethodGet, path, nil, &response); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if response.AppID != config["appId"] || response.MchID != config["mchId"] || response.OutTradeNo != request.MerchantOrderNo || (response.TradeType != "" && response.TradeType != "NATIVE") {
|
|
return Result{}, errors.New("微信支付查单结果与商户订单不匹配")
|
|
}
|
|
return wechatResult(response), nil
|
|
}
|
|
|
|
func (p *WeChatProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Result{}, err
|
|
}
|
|
path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(request.MerchantOrderNo) + "/close"
|
|
if err := p.do(ctx, config, http.MethodPost, path, map[string]string{"mchid": config["mchId"]}, nil); err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "CLOSED", Currency: "CNY", Closed: true}, nil
|
|
}
|
|
|
|
func (p *WeChatProvider) VerifyNotification(_ context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return Notification{}, err
|
|
}
|
|
if err := p.verifyMessage(config, headers, rawBody); err != nil {
|
|
return Notification{}, err
|
|
}
|
|
var envelope struct {
|
|
ID string `json:"id"`
|
|
EventType string `json:"event_type"`
|
|
ResourceType string `json:"resource_type"`
|
|
Resource struct {
|
|
Algorithm string `json:"algorithm"`
|
|
Ciphertext string `json:"ciphertext"`
|
|
AssociatedData string `json:"associated_data"`
|
|
Nonce string `json:"nonce"`
|
|
} `json:"resource"`
|
|
}
|
|
if err := json.Unmarshal(rawBody, &envelope); err != nil {
|
|
return Notification{}, fmt.Errorf("解析微信支付通知:%w", err)
|
|
}
|
|
if envelope.ID == "" || envelope.EventType != "TRANSACTION.SUCCESS" || envelope.ResourceType != "encrypt-resource" || envelope.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
|
return Notification{}, errors.New("微信支付通知类型无效")
|
|
}
|
|
plaintext, err := decryptWeChatResource(config["apiV3Key"], envelope.Resource.Nonce, envelope.Resource.AssociatedData, envelope.Resource.Ciphertext)
|
|
if err != nil {
|
|
return Notification{}, err
|
|
}
|
|
var transaction wechatTransaction
|
|
if err := json.Unmarshal(plaintext, &transaction); err != nil {
|
|
return Notification{}, fmt.Errorf("解析微信支付通知资源:%w", err)
|
|
}
|
|
if transaction.AppID != config["appId"] || transaction.MchID != config["mchId"] || transaction.TradeType != "NATIVE" || transaction.TradeState != "SUCCESS" {
|
|
return Notification{}, errors.New("微信支付通知商户身份或交易状态不匹配")
|
|
}
|
|
return Notification{EventID: envelope.ID, Result: wechatResult(transaction)}, nil
|
|
}
|
|
|
|
func (p *WeChatProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) {
|
|
if err := p.ValidateConfig(config); err != nil {
|
|
return nil, err
|
|
}
|
|
path := "/v3/bill/tradebill?bill_date=" + url.QueryEscape(billDate.Format("2006-01-02")) + "&bill_type=SUCCESS"
|
|
var bill struct {
|
|
HashType string `json:"hash_type"`
|
|
HashValue string `json:"hash_value"`
|
|
DownloadURL string `json:"download_url"`
|
|
}
|
|
if err := p.do(ctx, config, http.MethodGet, path, nil, &bill); err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.EqualFold(bill.HashType, "SHA1") || strings.TrimSpace(bill.HashValue) == "" {
|
|
return nil, errors.New("微信交易账单摘要信息无效")
|
|
}
|
|
target, err := trustedDownloadURL(bill.DownloadURL, p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("Accept", "application/octet-stream")
|
|
request.Header.Set("Wechatpay-Serial", config["wechatPayPublicKeyId"])
|
|
request.Header.Set("Authorization", p.authorization(config, http.MethodGet, target.RequestURI(), nil))
|
|
response, err := clientWithTrustedRedirects(p.client, p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com").Do(request)
|
|
if err != nil {
|
|
return nil, &ProviderError{Code: "wechat_bill_download_error", Message: "下载微信交易账单失败", Temporary: true, Cause: err}
|
|
}
|
|
defer response.Body.Close()
|
|
if response.Request == nil {
|
|
return nil, errors.New("微信交易账单下载响应无效")
|
|
}
|
|
if _, err := trustedDownloadURL(response.Request.URL.String(), p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com"); err != nil {
|
|
return nil, err
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return nil, &ProviderError{Code: "wechat_bill_http_error", Message: "微信交易账单下载失败", Temporary: response.StatusCode >= 500}
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, maxTradeBillBytes+1))
|
|
if err != nil {
|
|
return nil, &ProviderError{Code: "wechat_bill_read_error", Message: "读取微信交易账单失败", Temporary: true, Cause: err}
|
|
}
|
|
if len(raw) > maxTradeBillBytes {
|
|
return nil, errors.New("微信交易账单超过安全限制")
|
|
}
|
|
digest := sha1.Sum(raw)
|
|
if !strings.EqualFold(hex.EncodeToString(digest[:]), strings.TrimSpace(bill.HashValue)) {
|
|
return nil, errors.New("微信交易账单摘要校验失败")
|
|
}
|
|
return parseWeChatTradeBill(raw)
|
|
}
|
|
|
|
type wechatTransaction struct {
|
|
AppID string `json:"appid"`
|
|
MchID string `json:"mchid"`
|
|
OutTradeNo string `json:"out_trade_no"`
|
|
TransactionID string `json:"transaction_id"`
|
|
TradeType string `json:"trade_type"`
|
|
TradeState string `json:"trade_state"`
|
|
TradeStateDesc string `json:"trade_state_desc"`
|
|
SuccessTime string `json:"success_time"`
|
|
Amount struct {
|
|
Total int64 `json:"total"`
|
|
Currency string `json:"currency"`
|
|
} `json:"amount"`
|
|
}
|
|
|
|
func wechatResult(transaction wechatTransaction) Result {
|
|
paidAt, _ := time.Parse(time.RFC3339, transaction.SuccessTime)
|
|
return Result{
|
|
MerchantOrderNo: transaction.OutTradeNo,
|
|
ProviderTradeNo: transaction.TransactionID,
|
|
ProviderStatus: transaction.TradeState,
|
|
AmountFen: transaction.Amount.Total,
|
|
Currency: transaction.Amount.Currency,
|
|
Paid: transaction.TradeState == "SUCCESS",
|
|
Closed: transaction.TradeState == "CLOSED",
|
|
PaidAt: paidAt,
|
|
}
|
|
}
|
|
|
|
func (p *WeChatProvider) do(ctx context.Context, config Config, method, path string, payload any, output any) error {
|
|
var body []byte
|
|
var err error
|
|
if payload != nil {
|
|
body, err = json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if payload != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Wechatpay-Serial", config["wechatPayPublicKeyId"])
|
|
request.Header.Set("Authorization", p.authorization(config, method, request.URL.RequestURI(), body))
|
|
response, err := p.client.Do(request)
|
|
if err != nil {
|
|
return &ProviderError{Code: "wechat_transport_error", Message: "微信支付网络请求失败", Temporary: true, Cause: err}
|
|
}
|
|
defer response.Body.Close()
|
|
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
|
if err != nil {
|
|
return &ProviderError{Code: "wechat_response_read_error", Message: "读取微信支付响应失败", Temporary: true, Cause: err}
|
|
}
|
|
if err := p.verifyMessage(config, response.Header, responseBody); err != nil {
|
|
return fmt.Errorf("微信支付响应验签失败:%w", err)
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
var detail struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
_ = json.Unmarshal(responseBody, &detail)
|
|
if detail.Code == "NO_STATEMENT_EXIST" {
|
|
return fmt.Errorf("%w: %s", ErrTradeBillNotFound, detail.Code)
|
|
}
|
|
if response.StatusCode == http.StatusNotFound {
|
|
return fmt.Errorf("%w: %s", ErrOrderNotFound, detail.Code)
|
|
}
|
|
return &ProviderError{Code: detail.Code, Message: "微信支付接口返回失败", Temporary: response.StatusCode >= 500}
|
|
}
|
|
if output == nil || len(responseBody) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(responseBody, output); err != nil {
|
|
return fmt.Errorf("解析微信支付响应:%w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *WeChatProvider) authorization(config Config, method, target string, body []byte) string {
|
|
timestamp := strconv.FormatInt(p.now().Unix(), 10)
|
|
nonce := randomHex(16)
|
|
message := method + "\n" + target + "\n" + timestamp + "\n" + nonce + "\n" + string(body) + "\n"
|
|
privateKey, _ := parseRSAPrivateKey(config["merchantPrivateKey"])
|
|
signature, _ := rsaSHA256Sign(privateKey, []byte(message))
|
|
return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`, config["mchId"], nonce, signature, timestamp, config["merchantSerialNo"])
|
|
}
|
|
|
|
func (p *WeChatProvider) verifyMessage(config Config, headers http.Header, body []byte) error {
|
|
timestamp := strings.TrimSpace(headers.Get("Wechatpay-Timestamp"))
|
|
nonce := headers.Get("Wechatpay-Nonce")
|
|
signature := headers.Get("Wechatpay-Signature")
|
|
serial := headers.Get("Wechatpay-Serial")
|
|
if timestamp == "" || nonce == "" || signature == "" || serial == "" {
|
|
return errors.New("微信支付签名请求头不完整")
|
|
}
|
|
if serial != config["wechatPayPublicKeyId"] {
|
|
return errors.New("微信支付公钥 ID 不匹配")
|
|
}
|
|
seconds, err := strconv.ParseInt(timestamp, 10, 64)
|
|
if err != nil || absDuration(p.now().Sub(time.Unix(seconds, 0))) > 5*time.Minute {
|
|
return errors.New("微信支付签名时间戳无效")
|
|
}
|
|
publicKey, err := parseRSAPublicKey(config["wechatPayPublicKey"])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
message := timestamp + "\n" + nonce + "\n" + string(body) + "\n"
|
|
return rsaSHA256Verify(publicKey, []byte(message), signature)
|
|
}
|
|
|
|
func decryptWeChatResource(apiV3Key, nonce, associatedData, ciphertext string) ([]byte, error) {
|
|
block, err := aes.NewCipher([]byte(apiV3Key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建微信支付解密器:%w", err)
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(ciphertext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("微信支付通知密文无效:%w", err)
|
|
}
|
|
plaintext, err := gcm.Open(nil, []byte(nonce), decoded, []byte(associatedData))
|
|
if err != nil {
|
|
return nil, errors.New("微信支付通知解密失败")
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
func randomHex(size int) string {
|
|
value := make([]byte, size)
|
|
if _, err := rand.Read(value); err != nil {
|
|
sum := sha256.Sum256([]byte(strconv.FormatInt(time.Now().UnixNano(), 10)))
|
|
return hex.EncodeToString(sum[:size])
|
|
}
|
|
return hex.EncodeToString(value)
|
|
}
|
|
|
|
func absDuration(value time.Duration) time.Duration {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func truncateUTF8(value string, limit int) string {
|
|
value = strings.TrimSpace(value)
|
|
for len([]byte(value)) > limit {
|
|
runes := []rune(value)
|
|
value = string(runes[:len(runes)-1])
|
|
}
|
|
return value
|
|
}
|