kra-new/internal/integration/payment/qq.go

324 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq"
)
type qqAdapter struct{}
func (a *qqAdapter) client(c map[string]any) (*gopayQQ.Client, error) {
mch := firstAny(c, "merchant_id", "mch_id")
if mch == "" || text(c, "api_key") == "" {
return nil, errors.New("QQ 支付缺少 merchant_id 或 api_key")
}
client := gopayQQ.NewClient(mch, text(c, "api_key"))
return client, nil
}
// qqSignType resolves the algorithm used both for request signing and for
// validating synchronous API responses. QQ defaults to MD5 when sign_type is
// omitted, but an explicit unsupported value must never silently downgrade to
// a weaker or different algorithm.
func qqSignType(extra, config map[string]any) (string, error) {
value := firstAny(extra, "sign_type", "signature_type", "signType")
if value == "" {
value = firstAny(config, "sign_type", "signature_type", "signType")
}
if value == "" {
return gopayQQ.SignType_MD5, nil
}
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "_", "-"))
switch normalized {
case gopayQQ.SignType_MD5:
return gopayQQ.SignType_MD5, nil
case gopayQQ.SignType_HMAC_SHA256:
return gopayQQ.SignType_HMAC_SHA256, nil
default:
return "", fmt.Errorf("QQ 支付不支持的签名类型: %s", value)
}
}
func verifyQQResponse(apiKey, signType string, response any) error {
if strings.TrimSpace(apiKey) == "" {
return errors.New("QQ 支付响应验签缺少 api_key")
}
ok, err := gopayQQ.VerifySign(apiKey, signType, response)
if err != nil {
return fmt.Errorf("QQ 支付响应签名校验失败: %w", err)
}
if !ok {
return errors.New("QQ 支付响应签名校验失败")
}
return nil
}
func (a *qqAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("QQ 支付下单请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
signType, err := qqSignType(req.Extra, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("nonce_str", nonce()).Set("body", req.Subject).Set("out_trade_no", req.TradeNo).Set("total_fee", req.Amount).Set("spbill_create_ip", req.ClientIP).Set("notify_url", req.NotifyURL).Set("sign_type", signType)
tradeType, err := qqCreateMethod(req.Extra, c)
if err != nil {
return nil, err
}
if tradeType == gopayQQ.TradeType_MicroPay {
bm.Set("device_info", firstAnyOr(req.Extra, firstAnyOr(c, "WEB", "device_info"), "device_info"))
authCode := firstAny(req.Extra, "auth_code", "authcode", "barcode", "pay_code")
if strings.TrimSpace(authCode) == "" {
return nil, errors.New("QQ 付款码支付缺少 auth_code")
}
bm.Set("auth_code", strings.TrimSpace(authCode))
mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "device_info", "auth_code", "authcode", "mch_id", "sign_type")
rsp, callErr := client.MicroPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if callErr = verifyQQResponse(text(c, "api_key"), signType, rsp); callErr != nil {
return nil, callErr
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 付款码支付失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != req.TradeNo {
return nil, errors.New("QQ 付款码支付响应的 out_trade_no 不匹配")
}
status := normalizeQQState(rsp.TradeState)
if status == "pending" && strings.EqualFold(rsp.ResultCode, "SUCCESS") && strings.TrimSpace(rsp.TransactionId) != "" {
status = "success"
}
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: status, TradeNo: firstNonEmptyQQ(rsp.OutTradeNo, req.TradeNo), ProviderTradeNo: strings.TrimSpace(rsp.TransactionId), Payload: mustJSON(rsp)}, nil
}
bm.Set("trade_type", tradeType)
mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "mch_id", "sign_type")
rsp, err := client.UnifiedOrder(ctx, bm)
if err != nil {
return nil, err
}
if err = verifyQQResponse(text(c, "api_key"), signType, rsp); err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付下单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "created", TradeNo: req.TradeNo, Payload: mustJSON(rsp)}
return result, nil
}
func qqCreateMethod(extra, config map[string]any) (string, error) {
value := firstAny(extra, "trade_type", "pay_type", "method", "pay_method")
if value == "" {
value = firstAny(config, "trade_type", "pay_type", "method", "pay_method")
}
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "-", "_"))
switch normalized {
case "", "NATIVE", "QR", "QRCODE":
return gopayQQ.TradeType_Native, nil
case "MICROPAY", "MICRO_PAY", "BARCODE", "BARCODE_PAY":
return gopayQQ.TradeType_MicroPay, nil
case "JSAPI", "JS_API":
return gopayQQ.TradeType_JsApi, nil
case "MINIAPP", "MINI_APP", "MINI_PROGRAM", "MINIPROGRAM", "MINI":
return gopayQQ.TradeType_Mini, nil
case "APP", "APP_PAY":
return gopayQQ.TradeType_App, nil
default:
return "", fmt.Errorf("QQ 支付不支持的下单方式: %s", value)
}
}
func firstNonEmptyQQ(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
tradeNo = strings.TrimSpace(tradeNo)
if tradeNo == "" {
return nil, errors.New("QQ 支付查单缺少 out_trade_no")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
signType, err := qqSignType(nil, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo, "sign_type": signType}
mergeGoPayConfigExtras(bm, c, "query_extra", "nonce_str", "out_trade_no", "mch_id", "sign_type")
rsp, err := client.OrderQuery(ctx, bm)
if err != nil {
return nil, err
}
if err = verifyQQResponse(text(c, "api_key"), signType, rsp); err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付查单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: normalizeQQState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.TransactionId, Payload: mustJSON(rsp)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
} else if strings.TrimSpace(result.TradeNo) != tradeNo {
return nil, errors.New("QQ 支付查单响应的 out_trade_no 不匹配")
}
result.Currency = strings.ToUpper(rsp.FeeType)
if result.Currency == "" {
result.Currency = "CNY"
}
if rsp.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
if err != nil {
return nil, err
}
}
if rsp.CashFee != "" {
result.PayerPaidAmount, err = parseIntegerAmount(rsp.CashFee)
if err != nil {
return nil, fmt.Errorf("解析 QQ 支付实付金额: %w", err)
}
result.CashPaidAmount = result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
return result, nil
}
func (a *qqAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("QQ 支付退款请求为空")
}
tradeNo := strings.TrimSpace(req.TradeNo)
refundNo := strings.TrimSpace(req.RefundNo)
if tradeNo == "" || refundNo == "" {
return nil, errors.New("QQ 支付退款缺少 out_trade_no 或 out_refund_no")
}
if req.Amount <= 0 || req.TotalAmount < 0 || req.TotalAmount > 0 && req.Amount > req.TotalAmount {
return nil, errors.New("QQ 支付退款金额无效")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
signType, err := qqSignType(nil, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo, "out_refund_no": refundNo, "refund_fee": req.Amount, "op_user_id": firstAny(c, "op_user_id", "merchant_id", "mch_id"), "op_user_passwd": text(c, "op_user_passwd"), "sign_type": signType}
if req.TotalAmount > 0 {
bm.Set("total_fee", req.TotalAmount)
}
if req.Currency != "" {
bm.Set("refund_fee_type", strings.ToUpper(req.Currency))
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "nonce_str", "out_trade_no", "out_refund_no", "refund_fee", "total_fee", "op_user_id", "op_user_passwd", "mch_id", "sign_type")
cert, key, p12 := anyConfig(c, "cert_file", "cert_path"), anyConfig(c, "key_file", "key_path"), anyConfig(c, "pkcs12_file", "p12_file")
if (cert == nil || key == nil) && p12 == nil {
if text(c, "cert_content") != "" || text(c, "key_content") != "" || text(c, "pkcs12_content") != "" {
cert, key, p12 = []byte(text(c, "cert_content")), []byte(text(c, "key_content")), []byte(text(c, "pkcs12_content"))
}
}
if (cert == nil || key == nil) && p12 == nil {
return nil, errors.New("QQ 支付退款缺少 cert_file、key_file、pkcs12_file或 *_content")
}
rsp, err := client.Refund(ctx, bm, cert, key, p12)
if err != nil {
return nil, err
}
if err = verifyQQResponse(text(c, "api_key"), signType, rsp); err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付退款失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
if err = validateQQRefundIdentity(tradeNo, refundNo, rsp); err != nil {
return nil, err
}
refundAmount := req.Amount
if strings.TrimSpace(rsp.RefundFee) != "" {
refundAmount, err = parseIntegerAmount(rsp.RefundFee)
if err != nil {
return nil, fmt.Errorf("解析 QQ 支付退款金额: %w", err)
}
if refundAmount != req.Amount {
return nil, errors.New("QQ 支付退款响应金额不匹配")
}
}
returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo)
if returnedTradeNo == "" {
returnedTradeNo = tradeNo
}
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "pending", TradeNo: returnedTradeNo, ProviderTradeNo: strings.TrimSpace(rsp.RefundId), Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
}
func validateQQRefundIdentity(tradeNo, refundNo string, rsp *gopayQQ.RefundResponse) error {
if rsp == nil {
return errors.New("QQ 支付退款响应为空")
}
if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != tradeNo {
return errors.New("QQ 支付退款响应的 out_trade_no 不匹配")
}
if returnedRefundNo := strings.TrimSpace(rsp.OutRefundNo); returnedRefundNo == "" {
return errors.New("QQ 支付退款响应缺少 out_refund_no")
} else if returnedRefundNo != refundNo {
return errors.New("QQ 支付退款响应的 out_refund_no 不匹配")
}
if strings.TrimSpace(rsp.RefundId) == "" {
return errors.New("QQ 支付退款响应缺少 refund_id")
}
return nil
}
func (a *qqAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("QQ 支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
bm, err := gopayQQ.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
signType, err := qqSignType(nil, c)
if err != nil {
return nil, err
}
ok, err := gopayQQ.VerifySign(text(c, "api_key"), signType, cloneGoPayBodyMap(bm))
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("QQ 支付回调签名校验失败")
}
values := fromGoPayBodyMap(bm)
status := "pending"
if strings.EqualFold(values["return_code"], "SUCCESS") && strings.EqualFold(values["result_code"], "SUCCESS") && strings.EqualFold(values["trade_state"], "SUCCESS") {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: status, TradeNo: values["out_trade_no"], ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}