599 lines
21 KiB
Go
599 lines
21 KiB
Go
package payment
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
bizpayment "kra/internal/biz/payment"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-pay/gopay"
|
|
"github.com/go-pay/gopay/pkg/xhttp"
|
|
gopayWechat "github.com/go-pay/gopay/wechat"
|
|
)
|
|
|
|
type wechatV2Adapter struct{}
|
|
|
|
type wechatV2EndpointTransport struct {
|
|
endpoint *url.URL
|
|
next http.RoundTripper
|
|
}
|
|
|
|
func (t *wechatV2EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
clone := request.Clone(request.Context())
|
|
target := *t.endpoint
|
|
clone.URL = &target
|
|
clone.Host = target.Host
|
|
clone.RequestURI = ""
|
|
return t.next.RoundTrip(clone)
|
|
}
|
|
|
|
func wechatV2SignType(value string) string {
|
|
if strings.EqualFold(strings.TrimSpace(value), gopayWechat.SignType_HMAC_SHA256) {
|
|
return gopayWechat.SignType_HMAC_SHA256
|
|
}
|
|
return gopayWechat.SignType_MD5
|
|
}
|
|
|
|
func wechatV2Sign(values map[string]string, key, signType string) string {
|
|
body := toGoPayBodyMap(values)
|
|
body.Remove("sign")
|
|
return gopayWechat.GetReleaseSign(key, wechatV2SignType(signType), body)
|
|
}
|
|
|
|
func (a *wechatV2Adapter) Create(ctx context.Context, req *bizpayment.PaymentRequest, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
if req == nil {
|
|
return nil, errors.New("微信支付 v2 下单请求为空")
|
|
}
|
|
client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tradeType, err := wechatV2CreateMethod(req.Extra, c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := map[string]string{
|
|
"appid": client.AppId,
|
|
"mch_id": client.MchId,
|
|
"nonce_str": nonce(),
|
|
"body": req.Subject,
|
|
"out_trade_no": req.TradeNo,
|
|
"total_fee": fmt.Sprint(req.Amount),
|
|
"fee_type": strings.ToUpper(req.Currency),
|
|
"spbill_create_ip": req.ClientIP,
|
|
"notify_url": req.NotifyURL,
|
|
"trade_type": tradeType,
|
|
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
|
|
}
|
|
if values["spbill_create_ip"] == "" {
|
|
values["spbill_create_ip"] = firstAny(c, "client_ip", "spbill_create_ip")
|
|
}
|
|
if values["spbill_create_ip"] == "" {
|
|
values["spbill_create_ip"] = "127.0.0.1"
|
|
}
|
|
if tradeType == "MICROPAY" {
|
|
return a.createMicropay(ctx, client, req, c, values)
|
|
}
|
|
mergeWechatV2Extras(values, req.Extra)
|
|
values["trade_type"] = tradeType
|
|
rsp, err := client.UnifiedOrder(ctx, toGoPayBodyMap(values))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resultValues, err := wechatV2ResponseValues(rsp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateWechatV2Response(resultValues); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
|
|
return nil, err
|
|
}
|
|
return &bizpayment.PaymentResult{
|
|
Provider: bizpayment.PaymentWechatV2,
|
|
Status: "created",
|
|
TradeNo: req.TradeNo,
|
|
Payload: wechatV2Payload(resultValues),
|
|
}, nil
|
|
}
|
|
|
|
func (a *wechatV2Adapter) createMicropay(ctx context.Context, client *gopayWechat.Client, req *bizpayment.PaymentRequest, c map[string]any, values map[string]string) (*bizpayment.PaymentResult, error) {
|
|
authCode := firstAny(req.Extra, "auth_code", "authcode", "barcode", "pay_code", "payment_code")
|
|
if authCode == "" {
|
|
authCode = firstAny(c, "auth_code", "authcode", "barcode", "pay_code", "payment_code")
|
|
}
|
|
if strings.TrimSpace(authCode) == "" {
|
|
return nil, errors.New("微信支付 v2 付款码支付缺少 auth_code")
|
|
}
|
|
delete(values, "trade_type")
|
|
delete(values, "notify_url")
|
|
mergeWechatV2Extras(values, req.Extra)
|
|
values["auth_code"] = strings.TrimSpace(authCode)
|
|
rsp, err := client.Micropay(ctx, toGoPayBodyMap(values))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rsp == nil {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应为空")
|
|
}
|
|
resultValues, err := wechatV2ResponseValues(rsp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
|
|
return nil, err
|
|
}
|
|
if resultValues["return_code"] != "SUCCESS" {
|
|
return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "return_msg", "err_code_des", "err_code"))
|
|
}
|
|
if resultValues["result_code"] != "SUCCESS" && !strings.EqualFold(resultValues["err_code"], "USERPAYING") {
|
|
return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "err_code_des", "return_msg", "err_code"))
|
|
}
|
|
if resultValues["out_trade_no"] == "" {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应缺少 out_trade_no")
|
|
}
|
|
if resultValues["out_trade_no"] != req.TradeNo {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应的 out_trade_no 不匹配")
|
|
}
|
|
if strings.EqualFold(resultValues["err_code"], "USERPAYING") {
|
|
return &bizpayment.PaymentResult{Provider: bizpayment.PaymentWechatV2, Status: "pending", TradeNo: req.TradeNo, Payload: wechatV2Payload(resultValues)}, nil
|
|
}
|
|
providerTradeNo := strings.TrimSpace(resultValues["transaction_id"])
|
|
if providerTradeNo == "" {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应缺少 transaction_id")
|
|
}
|
|
result := &bizpayment.PaymentResult{
|
|
Provider: bizpayment.PaymentWechatV2, Status: "success", TradeNo: req.TradeNo,
|
|
ProviderTradeNo: providerTradeNo, Payload: wechatV2Payload(resultValues),
|
|
}
|
|
amount := strings.TrimSpace(resultValues["total_fee"])
|
|
if amount == "" {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应缺少 total_fee")
|
|
}
|
|
result.Amount, err = parseIntegerAmount(amount)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析微信支付 v2 付款码金额: %w", err)
|
|
}
|
|
if result.Amount != req.Amount {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应金额不匹配")
|
|
}
|
|
result.Currency = strings.ToUpper(resultValues["fee_type"])
|
|
requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
if result.Currency == "" {
|
|
result.Currency = requestCurrency
|
|
} else if requestCurrency != "" && result.Currency != requestCurrency {
|
|
return nil, errors.New("微信支付 v2 付款码支付响应币种不匹配")
|
|
}
|
|
if err = populateWechatV2Breakdown(result, resultValues); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func wechatV2CreateMethod(extra, config map[string]any) (string, error) {
|
|
value := firstAny(extra, "trade_type", "pay_type", "method", "pay_method", "channel")
|
|
if value == "" {
|
|
value = firstAny(config, "trade_type", "pay_type", "method", "pay_method", "channel")
|
|
}
|
|
normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
|
|
switch normalized {
|
|
case "", "jsapi", "js_api", "mini", "miniapp", "mini_program", "miniprogram", "applet":
|
|
return gopayWechat.TradeType_JsApi, nil
|
|
case "app", "app_pay":
|
|
return gopayWechat.TradeType_App, nil
|
|
case "native", "qr", "qrcode":
|
|
return gopayWechat.TradeType_Native, nil
|
|
case "h5", "h5_pay", "mweb", "wap", "wap_pay":
|
|
return gopayWechat.TradeType_H5, nil
|
|
case "micropay", "micro_pay", "barcode", "barcode_pay", "pay_code", "payment_code":
|
|
return "MICROPAY", nil
|
|
default:
|
|
return "", fmt.Errorf("微信支付 v2 不支持的下单方式: %s", value)
|
|
}
|
|
}
|
|
|
|
func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
tradeNo = strings.TrimSpace(tradeNo)
|
|
if tradeNo == "" {
|
|
return nil, errors.New("微信支付 v2 查单缺少 out_trade_no")
|
|
}
|
|
client, err := newWechatV2Client(c, firstAny(c, "query_url", "order_query_url"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body := gopay.BodyMap{
|
|
"nonce_str": nonce(),
|
|
"out_trade_no": tradeNo,
|
|
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
|
|
}
|
|
_, response, err := client.QueryOrder(ctx, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := fromGoPayBodyMap(response)
|
|
if err = validateWechatV2Response(values); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = verifyWechatV2Response(values, client.ApiKey, body.GetString("sign_type")); err != nil {
|
|
return nil, err
|
|
}
|
|
result := &bizpayment.PaymentResult{
|
|
Provider: bizpayment.PaymentWechatV2,
|
|
Status: normalizePaymentStatus(values["trade_state"], "pending"),
|
|
TradeNo: first(values, "out_trade_no"),
|
|
Payload: wechatV2Payload(values),
|
|
}
|
|
if result.TradeNo == "" {
|
|
result.TradeNo = tradeNo
|
|
} else if result.TradeNo != tradeNo {
|
|
return nil, errors.New("微信支付 v2 查询响应的 out_trade_no 不匹配")
|
|
}
|
|
if result.Status != "success" {
|
|
return result, nil
|
|
}
|
|
result.ProviderTradeNo = values["transaction_id"]
|
|
if result.ProviderTradeNo == "" {
|
|
return nil, errors.New("微信支付 v2 查询响应缺少 transaction_id")
|
|
}
|
|
result.Amount, err = parseIntegerAmount(values["total_fee"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析微信支付 v2 订单金额: %w", err)
|
|
}
|
|
result.Currency = strings.ToUpper(values["fee_type"])
|
|
if result.Currency == "" {
|
|
result.Currency = "CNY"
|
|
}
|
|
if err = populateWechatV2Breakdown(result, values); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (a *wechatV2Adapter) Refund(ctx context.Context, req *bizpayment.PaymentRefundRequest, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
if req == nil {
|
|
return nil, errors.New("微信支付 v2 退款请求为空")
|
|
}
|
|
tradeNo := strings.TrimSpace(req.TradeNo)
|
|
refundNo := strings.TrimSpace(req.RefundNo)
|
|
if tradeNo == "" || refundNo == "" {
|
|
return nil, errors.New("微信支付 v2 退款缺少 out_trade_no 或 out_refund_no")
|
|
}
|
|
if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount {
|
|
return nil, errors.New("微信支付 v2 退款金额无效")
|
|
}
|
|
client, err := newWechatV2Client(c, firstAny(c, "refund_url", "refund_apply_url"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := map[string]string{
|
|
"appid": client.AppId,
|
|
"mch_id": client.MchId,
|
|
"nonce_str": nonce(),
|
|
"out_trade_no": tradeNo,
|
|
"out_refund_no": refundNo,
|
|
"total_fee": fmt.Sprint(req.TotalAmount),
|
|
"refund_fee": fmt.Sprint(req.Amount),
|
|
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
|
|
}
|
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
if currency != "" {
|
|
values["refund_fee_type"] = currency
|
|
}
|
|
rsp, response, err := client.Refund(ctx, toGoPayBodyMap(values))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resultValues := fromGoPayBodyMap(response)
|
|
if rsp == nil {
|
|
return nil, errors.New("微信支付 v2 退款响应为空")
|
|
}
|
|
if err = validateWechatV2Response(resultValues); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateWechatV2RefundIdentity(tradeNo, refundNo, rsp); err != nil {
|
|
return nil, err
|
|
}
|
|
result := &bizpayment.PaymentResult{
|
|
Provider: bizpayment.PaymentWechatV2,
|
|
Status: "created",
|
|
TradeNo: tradeNo,
|
|
ProviderTradeNo: strings.TrimSpace(rsp.RefundId),
|
|
Currency: currency,
|
|
Payload: wechatV2Payload(resultValues),
|
|
}
|
|
if rsp.RefundFee != "" {
|
|
result.Amount, err = parseIntegerAmount(rsp.RefundFee)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析微信支付 v2 退款金额: %w", err)
|
|
}
|
|
if result.Amount != req.Amount {
|
|
return nil, errors.New("微信支付 v2 退款响应金额不匹配")
|
|
}
|
|
} else {
|
|
result.Amount = req.Amount
|
|
}
|
|
if responseCurrency := strings.ToUpper(strings.TrimSpace(rsp.FeeType)); responseCurrency != "" {
|
|
if currency != "" && responseCurrency != currency {
|
|
return nil, errors.New("微信支付 v2 退款响应币种不匹配")
|
|
}
|
|
result.Currency = responseCurrency
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateWechatV2RefundIdentity(tradeNo, refundNo string, rsp *gopayWechat.RefundResponse) error {
|
|
if rsp == nil {
|
|
return errors.New("微信支付 v2 退款响应为空")
|
|
}
|
|
if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != tradeNo {
|
|
return errors.New("微信支付 v2 退款响应的 out_trade_no 不匹配")
|
|
}
|
|
if returnedRefundNo := strings.TrimSpace(rsp.OutRefundNo); returnedRefundNo == "" {
|
|
return errors.New("微信支付 v2 退款响应缺少 out_refund_no")
|
|
} else if returnedRefundNo != refundNo {
|
|
return errors.New("微信支付 v2 退款响应的 out_refund_no 不匹配")
|
|
}
|
|
if strings.TrimSpace(rsp.RefundId) == "" {
|
|
return errors.New("微信支付 v2 退款响应缺少 refund_id")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *wechatV2Adapter) Callback(_ context.Context, callback *bizpayment.PaymentCallback, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
if callback == nil || len(callback.Body) == 0 {
|
|
return nil, errors.New("微信支付 v2 回调为空")
|
|
}
|
|
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := gopayWechat.ParseNotifyToBodyMap(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := fromGoPayBodyMap(body)
|
|
if err = verifyWechatV2(values, firstAny(c, "mch_key", "api_key", "merchant_key")); err != nil {
|
|
return nil, err
|
|
}
|
|
if values["appid"] != firstAny(c, "app_id", "appid") || values["mch_id"] != firstAny(c, "merchant_id", "mch_id") {
|
|
return nil, errors.New("微信支付 v2 回调商户配置不匹配")
|
|
}
|
|
status := "pending"
|
|
if values["return_code"] == "SUCCESS" && values["result_code"] == "SUCCESS" && values["trade_state"] != "REFUND" {
|
|
status = "success"
|
|
}
|
|
payload, _ := json.Marshal(values)
|
|
return &bizpayment.PaymentResult{Provider: bizpayment.PaymentWechatV2, Status: status, TradeNo: first(values, "out_trade_no"), ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
|
|
}
|
|
|
|
func newWechatV2Client(c map[string]any, endpoint string) (*gopayWechat.Client, error) {
|
|
appID := firstAny(c, "app_id", "appid")
|
|
merchantID := firstAny(c, "merchant_id", "mch_id")
|
|
mchKey := firstAny(c, "mch_key", "api_key", "merchant_key")
|
|
if appID == "" || merchantID == "" || mchKey == "" {
|
|
return nil, errors.New("微信支付 v2 缺少 app_id、merchant_id 或 mch_key")
|
|
}
|
|
|
|
// The previous adapter sent the configured amount unchanged, including to
|
|
// sandbox or test endpoints. GoPay's sandbox mode rewrites total_fee, so use
|
|
// its production signing flow and redirect the high-level call when a custom
|
|
// endpoint is configured.
|
|
client := gopayWechat.NewClient(appID, merchantID, mchKey, true)
|
|
regular := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(4)
|
|
if err := setWechatV2Endpoint(regular, endpoint); err != nil {
|
|
return nil, err
|
|
}
|
|
client.SetHttpClient(regular)
|
|
|
|
tlsHTTPClient, err := paymentHTTPClient(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tlsClient := xhttp.NewClient().SetBodySize(4)
|
|
tlsClient.HttpClient = tlsHTTPClient
|
|
if err = setWechatV2Endpoint(tlsClient, endpoint); err != nil {
|
|
return nil, err
|
|
}
|
|
client.SetTLSHttpClient(tlsClient)
|
|
return client, nil
|
|
}
|
|
|
|
func setWechatV2Endpoint(client *xhttp.Client, endpoint string) error {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if endpoint == "" {
|
|
return nil
|
|
}
|
|
target, err := url.Parse(endpoint)
|
|
if err != nil || target.Scheme == "" || target.Host == "" {
|
|
return fmt.Errorf("微信支付 v2 接口地址无效: %s", endpoint)
|
|
}
|
|
next := client.HttpClient.Transport
|
|
if next == nil {
|
|
next = http.DefaultTransport
|
|
}
|
|
client.SetTransport(&wechatV2EndpointTransport{endpoint: target, next: next})
|
|
return nil
|
|
}
|
|
|
|
func wechatV2ResponseValues(value any) (map[string]string, error) {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var body gopay.BodyMap
|
|
if err = json.Unmarshal(raw, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
return fromGoPayBodyMap(body), nil
|
|
}
|
|
|
|
func validateWechatV2Response(values map[string]string) error {
|
|
if strings.EqualFold(strings.TrimSpace(values["return_code"]), "SUCCESS") &&
|
|
strings.EqualFold(strings.TrimSpace(values["result_code"]), "SUCCESS") {
|
|
return nil
|
|
}
|
|
message := first(values, "err_code_des", "return_msg", "err_code")
|
|
if message == "" {
|
|
message = "未知错误"
|
|
}
|
|
return fmt.Errorf("微信支付 v2 错误: %s", message)
|
|
}
|
|
|
|
func verifyWechatV2Response(values map[string]string, key, fallbackSignType string) error {
|
|
if values["sign"] == "" {
|
|
return errors.New("微信支付 v2 响应缺少签名")
|
|
}
|
|
signType := values["sign_type"]
|
|
if signType == "" {
|
|
signType = fallbackSignType
|
|
}
|
|
return verifyWechatV2WithSignType(values, key, signType)
|
|
}
|
|
|
|
func wechatV2Payload(values map[string]string) []byte {
|
|
return []byte(gopayWechat.GenerateXml(toGoPayBodyMap(values)))
|
|
}
|
|
|
|
func populateWechatV2Breakdown(result *bizpayment.PaymentResult, values map[string]string) error {
|
|
if result == nil || result.Status != "success" {
|
|
return nil
|
|
}
|
|
// WeChat v2 reports total_fee/cash_fee/coupon_fee in the smallest currency
|
|
// unit. cash_fee is the payer cash amount; coupon_fee is the total coupon
|
|
// discount. v2 does not reliably identify coupon funding parties, so those
|
|
// fields remain zero rather than being misclassified.
|
|
cash, cashOK, err := parseWechatInteger(values, "cash_fee")
|
|
if err != nil {
|
|
return fmt.Errorf("解析微信支付 v2 cash_fee: %w", err)
|
|
}
|
|
if !cashOK {
|
|
return nil
|
|
}
|
|
coupon, couponOK, err := parseWechatInteger(values, "coupon_fee")
|
|
if err != nil {
|
|
return fmt.Errorf("解析微信支付 v2 coupon_fee: %w", err)
|
|
}
|
|
if !couponOK {
|
|
coupon = result.Amount - cash
|
|
}
|
|
if cash < 0 || coupon < 0 || cash > result.Amount || cash+coupon != result.Amount {
|
|
return errors.New("微信支付 v2 总额、现金实付和优惠金额不守恒")
|
|
}
|
|
result.PayerPaidAmount = cash
|
|
result.CashPaidAmount = cash
|
|
result.DiscountAmount = coupon
|
|
if settlement, ok, settlementErr := parseWechatInteger(values, "settlement_total_fee"); settlementErr != nil {
|
|
return fmt.Errorf("解析微信支付 v2 settlement_total_fee: %w", settlementErr)
|
|
} else if ok {
|
|
result.SettlementAmount = settlement
|
|
}
|
|
// coupon_fee_n is a per-coupon detail in v2. Its aggregate still has no
|
|
// authoritative merchant/provider funding split, so preserve the raw list
|
|
// in Payload instead of assigning it to either party.
|
|
_ = couponOK
|
|
result.AmountBreakdownKnown = true
|
|
return nil
|
|
}
|
|
|
|
func parseWechatInteger(values map[string]string, key string) (int64, bool, error) {
|
|
value, exists := values[key]
|
|
if !exists || strings.TrimSpace(value) == "" {
|
|
return 0, false, nil
|
|
}
|
|
amount, err := parseIntegerAmount(value)
|
|
return amount, true, err
|
|
}
|
|
|
|
func mergeWechatV2Extras(dst map[string]string, extra map[string]any) {
|
|
protected := map[string]struct{}{
|
|
"appid": {}, "mch_id": {}, "nonce_str": {}, "body": {}, "out_trade_no": {},
|
|
"total_fee": {}, "fee_type": {}, "spbill_create_ip": {}, "notify_url": {}, "trade_type": {},
|
|
"auth_code": {}, "sign": {},
|
|
}
|
|
for key, value := range stringMap(extra) {
|
|
if _, exists := protected[key]; exists {
|
|
continue
|
|
}
|
|
dst[key] = value
|
|
}
|
|
}
|
|
func verifyWechatV2(values map[string]string, key string) error {
|
|
return verifyWechatV2WithSignType(values, key, values["sign_type"])
|
|
}
|
|
|
|
func verifyWechatV2WithSignType(values map[string]string, key, signType string) error {
|
|
if key == "" {
|
|
return errors.New("微信支付 v2 未配置 mch_key")
|
|
}
|
|
ok, err := gopayWechat.VerifySign(key, wechatV2SignType(signType), cloneGoPayBodyMap(toGoPayBodyMap(values)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("微信支付 v2 回调签名校验失败")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func paymentHTTPClient(c map[string]any) (*http.Client, error) {
|
|
certPEM := firstAny(c, "client_cert", "cert_pem", "apiclient_cert")
|
|
keyPEM := firstAny(c, "client_key", "key_pem", "apiclient_key")
|
|
if certPEM == "" && keyPEM == "" {
|
|
return &http.Client{Timeout: 20 * time.Second}, nil
|
|
}
|
|
if certPEM == "" || keyPEM == "" {
|
|
return nil, errors.New("微信支付 v2 client_cert 和 client_key 必须同时配置")
|
|
}
|
|
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pool *x509.CertPool
|
|
if ca := firstAny(c, "ca_cert", "root_cert"); ca != "" {
|
|
pool = x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM([]byte(ca)) {
|
|
return nil, errors.New("微信支付 v2 ca_cert 格式错误")
|
|
}
|
|
}
|
|
return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil
|
|
}
|
|
|
|
// nonce returns a compact request token. Clock-only values can collide when
|
|
// several payment requests are prepared in the same scheduler tick.
|
|
func nonce() string {
|
|
var raw [8]byte
|
|
if _, err := rand.Read(raw[:]); err == nil {
|
|
return hex.EncodeToString(raw[:])
|
|
}
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
func stringOr(extra map[string]any, key, fallback string) string {
|
|
if value, ok := extra[key].(string); ok {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
func stringMap(values map[string]any) map[string]string {
|
|
result := map[string]string{}
|
|
for key, value := range values {
|
|
if text, ok := value.(string); ok {
|
|
result[key] = text
|
|
}
|
|
}
|
|
return result
|
|
}
|