支付相关
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
|
||||
"github.com/go-pay/gopay"
|
||||
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
|
||||
)
|
||||
|
||||
type wechatV3Adapter struct{}
|
||||
|
||||
func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
|
||||
client, err := newWechatV3Client(c, "create_url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bm := gopay.BodyMap{
|
||||
"appid": firstAny(c, "app_id", "appid"),
|
||||
"mchid": firstAny(c, "merchant_id", "mch_id", "mchid"),
|
||||
"description": req.Subject,
|
||||
"out_trade_no": req.TradeNo,
|
||||
"notify_url": req.NotifyURL,
|
||||
}
|
||||
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
|
||||
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
|
||||
})
|
||||
mergeGoPayExtras(bm, req.Extra,
|
||||
"appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer",
|
||||
"trade_type", "pay_type", "method", "openid", "open_id",
|
||||
)
|
||||
|
||||
tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "method"))
|
||||
if tradeType == "" {
|
||||
tradeType = strings.ToLower(firstAny(c, "trade_type", "pay_type", "method"))
|
||||
}
|
||||
switch tradeType {
|
||||
case "", "jsapi", "mini", "miniprogram", "mini_program", "applet":
|
||||
if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" {
|
||||
bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("openid", openID) })
|
||||
}
|
||||
rsp, callErr := client.V3TransactionJsapi(ctx, bm)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 下单响应为空")
|
||||
}
|
||||
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), tradeType, req.TradeNo, rsp)
|
||||
case "app":
|
||||
rsp, callErr := client.V3TransactionApp(ctx, bm)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 下单响应为空")
|
||||
}
|
||||
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), "app", req.TradeNo, rsp)
|
||||
case "native", "qr", "qrcode":
|
||||
rsp, callErr := client.V3TransactionNative(ctx, bm)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 下单响应为空")
|
||||
}
|
||||
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
|
||||
case "h5", "mweb":
|
||||
rsp, callErr := client.V3TransactionH5(ctx, bm)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 下单响应为空")
|
||||
}
|
||||
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
|
||||
default:
|
||||
return nil, fmt.Errorf("微信支付 v3 不支持交易类型 %q", tradeType)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
|
||||
client, err := newWechatV3Client(c, "query_url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp, err := client.V3TransactionQueryOrder(ctx, gopayWechatV3.OutTradeNo, tradeNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 查单响应为空")
|
||||
}
|
||||
if rsp.Code != gopayWechatV3.Success {
|
||||
return nil, wechatV3APIError("查单", rsp.Code, rsp.ErrResponse, rsp.Error)
|
||||
}
|
||||
if rsp.Response == nil {
|
||||
return nil, errors.New("微信支付 v3 查单响应缺少订单")
|
||||
}
|
||||
|
||||
order := rsp.Response
|
||||
payload := wechatV3ResponsePayload(rsp.SignInfo, order)
|
||||
result := &biz.PaymentResult{
|
||||
Provider: biz.PaymentWechatV3,
|
||||
Status: normalizePaymentStatus(order.TradeState, "pending"),
|
||||
TradeNo: order.OutTradeNo,
|
||||
ProviderTradeNo: order.TransactionId,
|
||||
Payload: payload,
|
||||
}
|
||||
if result.TradeNo == "" {
|
||||
result.TradeNo = tradeNo
|
||||
}
|
||||
if result.Status != "success" {
|
||||
return result, nil
|
||||
}
|
||||
if result.ProviderTradeNo == "" {
|
||||
return nil, errors.New("微信支付 v3 查询响应缺少 transaction_id")
|
||||
}
|
||||
if order.Amount == nil || order.Amount.Total <= 0 {
|
||||
return nil, errors.New("微信支付 v3 查询响应缺少 amount.total")
|
||||
}
|
||||
result.Amount = int64(order.Amount.Total)
|
||||
result.Currency = strings.ToUpper(order.Amount.Currency)
|
||||
if result.Currency == "" {
|
||||
return nil, errors.New("微信支付 v3 查询响应缺少 amount.currency")
|
||||
}
|
||||
if err := populateWechatV3Breakdown(result, jsonObject(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("微信支付 v3 退款请求为空")
|
||||
}
|
||||
client, err := newWechatV3Client(c, "refund_url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo}
|
||||
bm.SetBodyMap("amount", func(values gopay.BodyMap) {
|
||||
values.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
|
||||
})
|
||||
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount")
|
||||
rsp, err := client.V3Refund(ctx, bm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 退款响应为空")
|
||||
}
|
||||
if rsp.Code != gopayWechatV3.Success {
|
||||
return nil, wechatV3APIError("退款", rsp.Code, rsp.ErrResponse, rsp.Error)
|
||||
}
|
||||
if rsp.Response == nil {
|
||||
return nil, errors.New("微信支付 v3 退款响应缺少退款单")
|
||||
}
|
||||
|
||||
refund := rsp.Response
|
||||
result := &biz.PaymentResult{
|
||||
Provider: biz.PaymentWechatV3,
|
||||
Status: normalizeRefundState(refund.Status),
|
||||
TradeNo: refund.OutTradeNo,
|
||||
ProviderTradeNo: refund.RefundId,
|
||||
Payload: wechatV3ResponsePayload(rsp.SignInfo, refund),
|
||||
}
|
||||
if result.TradeNo == "" {
|
||||
result.TradeNo = req.TradeNo
|
||||
}
|
||||
if refund.Amount != nil {
|
||||
result.Amount = int64(refund.Amount.Refund)
|
||||
result.Currency = strings.ToUpper(refund.Amount.Currency)
|
||||
}
|
||||
if result.Amount == 0 {
|
||||
result.Amount = req.Amount
|
||||
}
|
||||
if result.Currency == "" {
|
||||
result.Currency = strings.ToUpper(req.Currency)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *wechatV3Adapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
|
||||
if callback == nil {
|
||||
return nil, errors.New("微信支付 v3 回调为空")
|
||||
}
|
||||
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
|
||||
if apiV3Key == "" {
|
||||
return nil, errors.New("微信支付 v3 缺少 api_v3_key")
|
||||
}
|
||||
client, err := newWechatV3Client(c, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://callback.local", bytes.NewReader(callback.Body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range callback.Headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
notify, err := gopayWechatV3.V3ParseNotify(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notify.SignInfo == nil {
|
||||
return nil, errors.New("微信支付 v3 回调缺少签名信息")
|
||||
}
|
||||
platformKeys := client.WxPublicKeyMap()
|
||||
if _, exists := platformKeys[notify.SignInfo.HeaderSerial]; !exists {
|
||||
for serial, key := range platformKeys {
|
||||
if strings.EqualFold(serial, notify.SignInfo.HeaderSerial) {
|
||||
platformKeys[notify.SignInfo.HeaderSerial] = key
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = notify.VerifySignByPKMap(platformKeys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pay, err := notify.DecryptPayCipherText(apiV3Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if callback.Query == nil {
|
||||
callback.Query = map[string]string{}
|
||||
}
|
||||
callback.Query["event_id"] = notify.Id
|
||||
status := "pending"
|
||||
if pay.TradeState == gopayWechatV3.TradeStateSuccess {
|
||||
status = "success"
|
||||
}
|
||||
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
|
||||
if pay.Mchid != "" && pay.Mchid != merchantID {
|
||||
return nil, errors.New("微信支付 v3 回调商户号不匹配")
|
||||
}
|
||||
appID := firstAny(c, "app_id", "appid")
|
||||
if pay.Appid != "" && pay.Appid != appID {
|
||||
return nil, errors.New("微信支付 v3 回调 appid 不匹配")
|
||||
}
|
||||
plain, _ := json.Marshal(pay)
|
||||
return &biz.PaymentResult{
|
||||
Provider: biz.PaymentWechatV3,
|
||||
Status: status,
|
||||
TradeNo: pay.OutTradeNo,
|
||||
ProviderTradeNo: pay.TransactionId,
|
||||
Payload: plain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.ClientV3, error) {
|
||||
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
|
||||
serialNo := firstAny(c, "serial_no", "merchant_serial_no", "certificate_serial_no")
|
||||
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
|
||||
privateKey := firstAny(c, "private_key", "merchant_private_key")
|
||||
if merchantID == "" || serialNo == "" || apiV3Key == "" || privateKey == "" {
|
||||
return nil, errors.New("微信支付 v3 缺少 merchant_id、serial_no、api_v3_key 或 private_key")
|
||||
}
|
||||
client, err := gopayWechatV3.NewClientV3(merchantID, serialNo, apiV3Key, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
|
||||
if platformCert == "" {
|
||||
return nil, errors.New("微信支付 v3 缺少 platform_cert")
|
||||
}
|
||||
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no")
|
||||
if platformSerial == "" {
|
||||
platformSerial, err = wechatV3PlatformSerial(platformCert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.SetBodySize(8)
|
||||
client.GetHttpClient().SetTimeout(20 * time.Second)
|
||||
|
||||
if endpointKey != "" {
|
||||
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
|
||||
parsed, parseErr := url.Parse(endpoint)
|
||||
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("微信支付 v3 %s 配置无效", endpointKey)
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("微信支付 v3 %s 只能配置 scheme 和 host", endpointKey)
|
||||
}
|
||||
httpClient := client.GetHttpClient()
|
||||
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
|
||||
parsed, parseErr := url.Parse(baseURL)
|
||||
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, errors.New("微信支付 v3 base_url 配置无效")
|
||||
}
|
||||
client.SetProxyHost(baseURL)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type wechatV3EndpointTransport struct {
|
||||
base http.RoundTripper
|
||||
endpoint *url.URL
|
||||
}
|
||||
|
||||
func (t *wechatV3EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
base := t.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
clone := request.Clone(request.Context())
|
||||
target := *request.URL
|
||||
target.Scheme = t.endpoint.Scheme
|
||||
target.Host = t.endpoint.Host
|
||||
target.User = t.endpoint.User
|
||||
clone.URL = &target
|
||||
clone.Host = ""
|
||||
return base.RoundTrip(clone)
|
||||
}
|
||||
|
||||
func wechatV3PlatformSerial(certificate string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certificate))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", errors.New("微信支付 v3 platform_cert 格式错误")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析微信支付 v3 platform_cert: %w", err)
|
||||
}
|
||||
return strings.ToUpper(cert.SerialNumber.Text(16)), nil
|
||||
}
|
||||
|
||||
func wechatV3CreateResult(tradeNo string, code int, signInfo *gopayWechatV3.SignInfo, response any, errResponse gopayWechatV3.ErrResponse, rawError string) (*biz.PaymentResult, error) {
|
||||
if code != gopayWechatV3.Success {
|
||||
return nil, wechatV3APIError("下单", code, errResponse, rawError)
|
||||
}
|
||||
if response == nil {
|
||||
return nil, errors.New("微信支付 v3 下单响应缺少预支付信息")
|
||||
}
|
||||
return &biz.PaymentResult{
|
||||
Provider: biz.PaymentWechatV3,
|
||||
Status: "created",
|
||||
TradeNo: tradeNo,
|
||||
Payload: wechatV3ResponsePayload(signInfo, response),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func wechatV3PrepayCreateResult(client *gopayWechatV3.ClientV3, appID, tradeType, tradeNo string, rsp *gopayWechatV3.PrepayRsp) (*biz.PaymentResult, error) {
|
||||
if rsp == nil {
|
||||
return nil, errors.New("微信支付 v3 下单响应为空")
|
||||
}
|
||||
result, err := wechatV3CreateResult(tradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rsp.Response == nil || strings.TrimSpace(rsp.Response.PrepayId) == "" {
|
||||
return nil, errors.New("微信支付 v3 下单响应缺少 prepay_id")
|
||||
}
|
||||
|
||||
var payParams any
|
||||
switch strings.ToLower(strings.TrimSpace(tradeType)) {
|
||||
case "app":
|
||||
payParams, err = client.PaySignOfApp(appID, rsp.Response.PrepayId)
|
||||
case "mini", "miniprogram", "mini_program", "applet":
|
||||
payParams, err = client.PaySignOfApplet(appID, rsp.Response.PrepayId)
|
||||
default:
|
||||
payParams, err = client.PaySignOfJSAPI(appID, rsp.Response.PrepayId)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成微信支付 v3 客户端调起参数: %w", err)
|
||||
}
|
||||
result.Payload = mustJSON(payParams)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wechatV3APIError(operation string, code int, response gopayWechatV3.ErrResponse, raw string) error {
|
||||
message := strings.TrimSpace(response.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(raw)
|
||||
}
|
||||
if message == "" {
|
||||
return fmt.Errorf("微信支付 v3 %s HTTP %d", operation, code)
|
||||
}
|
||||
return fmt.Errorf("微信支付 v3 %s HTTP %d: %s", operation, code, message)
|
||||
}
|
||||
|
||||
func wechatV3ResponsePayload(signInfo *gopayWechatV3.SignInfo, response any) []byte {
|
||||
if signInfo != nil && strings.TrimSpace(signInfo.SignBody) != "" {
|
||||
return []byte(signInfo.SignBody)
|
||||
}
|
||||
payload, _ := json.Marshal(response)
|
||||
return payload
|
||||
}
|
||||
|
||||
func populateWechatV3Breakdown(result *biz.PaymentResult, object map[string]any) error {
|
||||
if result == nil || result.Status != "success" {
|
||||
return nil
|
||||
}
|
||||
amount, ok := valueAtPath(object, "amount").(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
payerTotal, payerOK, err := parseJSONInteger(amount, "payer_total")
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析微信支付 v3 amount.payer_total: %w", err)
|
||||
}
|
||||
if !payerOK {
|
||||
return nil
|
||||
}
|
||||
if payerTotal < 0 || payerTotal > result.Amount {
|
||||
return errors.New("微信支付 v3 用户实付金额无效")
|
||||
}
|
||||
result.PayerPaidAmount = payerTotal
|
||||
result.CashPaidAmount = payerTotal
|
||||
result.DiscountAmount = result.Amount - payerTotal
|
||||
if settlement, ok, settlementErr := parseJSONInteger(amount, "settlement_amount"); settlementErr != nil {
|
||||
return fmt.Errorf("解析微信支付 v3 amount.settlement_amount: %w", settlementErr)
|
||||
} else if ok {
|
||||
result.SettlementAmount = settlement
|
||||
}
|
||||
// promotion_detail contains the authoritative promotion breakdown, but its
|
||||
// funding fields vary by promotion type. We only expose the aggregate
|
||||
// discount here; the original JSON remains in Payload for audit.
|
||||
if result.DiscountAmount < 0 {
|
||||
return errors.New("微信支付 v3 优惠金额无效")
|
||||
}
|
||||
result.AmountBreakdownKnown = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONInteger(object map[string]any, key string) (int64, bool, error) {
|
||||
raw, exists := object[key]
|
||||
if !exists || raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || fmt.Sprint(raw) == "<nil>" {
|
||||
return 0, false, nil
|
||||
}
|
||||
amount, err := parseIntegerAmount(fmt.Sprint(raw))
|
||||
return amount, true, err
|
||||
}
|
||||
|
||||
func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("微信支付 v3 api_v3_key 必须为 32 字节")
|
||||
}
|
||||
return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key)
|
||||
}
|
||||
Reference in New Issue
Block a user