Files

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
}