224 lines
9.2 KiB
Go
224 lines
9.2 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
bizpayment "kra/internal/biz/payment"
|
|
"kra/internal/paymentkit"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-pay/gopay"
|
|
"github.com/go-pay/gopay/saobei"
|
|
)
|
|
|
|
type saobeiClient interface {
|
|
MiniPay(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error)
|
|
BarcodePay(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error)
|
|
Query(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error)
|
|
Refund(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error)
|
|
}
|
|
|
|
type saobeiAdapter struct {
|
|
newClient func(map[string]any) (saobeiClient, error)
|
|
}
|
|
|
|
func saobeiBarcodeOK(returnCode, resultCode string) bool {
|
|
return saobeiResultOK(returnCode, resultCode)
|
|
}
|
|
|
|
func (a *saobeiAdapter) client(c map[string]any) (saobeiClient, error) {
|
|
if a.newClient != nil {
|
|
return a.newClient(c)
|
|
}
|
|
return saobei.NewClient(text(c, "inst_no"), text(c, "key"), text(c, "merchant_no"), text(c, "terminal_id"), text(c, "access_token"), !strings.EqualFold(text(c, "environment"), "sandbox"))
|
|
}
|
|
|
|
func (a *saobeiAdapter) Create(ctx context.Context, req *bizpayment.PaymentRequest, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
if req == nil {
|
|
return nil, errors.New("扫呗支付下单请求为空")
|
|
}
|
|
method, err := saobeiCreateMethod(req.Extra, c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := a.client(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().Format("20060102150405")
|
|
bm := gopay.BodyMap{"pay_type": firstAnyOr(req.Extra, "010", "pay_type", "payment_type"), "terminal_ip": req.ClientIP, "terminal_trace": req.TradeNo, "terminal_time": now, "total_fee": req.Amount}
|
|
mergeGoPayExtras(bm, req.Extra, "pay_type", "payment_type", "terminal_ip", "terminal_trace", "terminal_time", "total_fee", "method", "pay_method", "trade_type")
|
|
if method == "barcode" {
|
|
authNo := strings.TrimSpace(firstAny(req.Extra, "auth_no", "auth_code", "authcode", "barcode", "pay_code"))
|
|
if authNo == "" {
|
|
return nil, errors.New("扫呗付款码支付缺少 auth_no")
|
|
}
|
|
bm.Set("auth_no", authNo)
|
|
rsp, callErr := client.BarcodePay(ctx, bm)
|
|
if callErr != nil {
|
|
return nil, callErr
|
|
}
|
|
if rsp == nil || !saobeiBarcodeOK(rsp.ReturnCode, rsp.ResultCode) {
|
|
return nil, fmt.Errorf("扫呗付款码支付失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"]))
|
|
}
|
|
if strings.TrimSpace(rsp.OutTradeNo) == "" {
|
|
return nil, errors.New("扫呗支付下单响应缺少 out_trade_no")
|
|
}
|
|
status := "created"
|
|
if strings.TrimSpace(rsp.ResultCode) == saobei.ResultCodePaying {
|
|
status = "pending"
|
|
}
|
|
return &bizpayment.PaymentResult{Provider: bizpayment.PaymentSaobei, Status: status, TradeNo: req.TradeNo, QueryID: strings.TrimSpace(rsp.OutTradeNo), Payload: mustJSON(rsp)}, nil
|
|
}
|
|
rsp, err := client.MiniPay(ctx, bm)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rsp == nil || !saobeiOK(rsp.ReturnCode, rsp.ResultCode) {
|
|
return nil, fmt.Errorf("扫呗支付下单失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"]))
|
|
}
|
|
if strings.TrimSpace(rsp.OutTradeNo) == "" {
|
|
return nil, errors.New("扫呗支付下单响应缺少 out_trade_no")
|
|
}
|
|
return &bizpayment.PaymentResult{Provider: bizpayment.PaymentSaobei, Status: "client_pending", TradeNo: req.TradeNo, QueryID: strings.TrimSpace(rsp.OutTradeNo), Payload: mustJSON(rsp)}, nil
|
|
}
|
|
|
|
func saobeiCreateMethod(extra, config map[string]any) (string, error) {
|
|
value := firstAny(extra, "method", "pay_method", "trade_type")
|
|
if value == "" {
|
|
value = firstAny(config, "method", "pay_method", "trade_type")
|
|
}
|
|
normalized := paymentkit.NormalizePaymentMethod(value)
|
|
switch normalized {
|
|
case "", "mini", "mini_pay", "miniapp", "mini_app", "mini_program", "miniprogram", "jsapi", "js_api":
|
|
return "mini", nil
|
|
case "barcode", "barcode_pay", "micropay", "micro_pay", "scan", "scan_pay", "pay_code", "payment_code":
|
|
return "barcode", nil
|
|
default:
|
|
return "", fmt.Errorf("扫呗支付不支持的下单方式: %s", value)
|
|
}
|
|
}
|
|
|
|
func (a *saobeiAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
tradeNo = strings.TrimSpace(tradeNo)
|
|
if tradeNo == "" {
|
|
return nil, errors.New("扫呗支付查单缺少 out_trade_no")
|
|
}
|
|
client, err := a.client(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// terminal_trace is the query request's own trace ID. The durable platform
|
|
// order identity is carried by out_trade_no and comes from PaymentResult.QueryID.
|
|
bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": nonce(), "terminal_time": time.Now().Format("20060102150405"), "out_trade_no": tradeNo}
|
|
mergeGoPayConfigExtras(bm, c, "query_extra", "pay_type", "terminal_trace", "terminal_time", "out_trade_no")
|
|
rsp, err := client.Query(ctx, bm)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rsp == nil {
|
|
return nil, errors.New("扫呗支付查单响应为空")
|
|
}
|
|
if !saobeiResultOK(rsp.ReturnCode, rsp.ResultCode) {
|
|
return nil, fmt.Errorf("扫呗支付查单失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"]))
|
|
}
|
|
queryID := strings.TrimSpace(tradeNo)
|
|
if responseQueryID := strings.TrimSpace(rsp.OutTradeNo); responseQueryID == "" {
|
|
return nil, errors.New("扫呗支付查单响应缺少 out_trade_no")
|
|
} else if responseQueryID != queryID {
|
|
return nil, errors.New("扫呗支付查单平台订单号不匹配")
|
|
}
|
|
tradeNoResult := strings.TrimSpace(rsp.TerminalTrace)
|
|
if tradeNoResult == "" {
|
|
tradeNoResult = strings.TrimSpace(rsp.PayTrace)
|
|
}
|
|
if tradeNoResult == "" {
|
|
return nil, errors.New("扫呗支付查单响应缺少商户订单号")
|
|
}
|
|
status := normalizeSaobeiState(rsp.TradeState)
|
|
if strings.TrimSpace(rsp.ResultCode) == saobei.ResultCodePaying {
|
|
status = "pending"
|
|
}
|
|
result := &bizpayment.PaymentResult{Provider: bizpayment.PaymentSaobei, Status: status, TradeNo: tradeNoResult, ProviderTradeNo: strings.TrimSpace(rsp.ChannelTradeNo), QueryID: queryID, Payload: mustJSON(rsp), Currency: strings.ToUpper(firstAnyOr(c, "CNY", "currency", "default_currency"))}
|
|
if rsp.TotalFee != "" {
|
|
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析扫呗订单金额: %w", err)
|
|
}
|
|
}
|
|
if rsp.BuyerPayFee != "" {
|
|
result.PayerPaidAmount, err = parseIntegerAmount(rsp.BuyerPayFee)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析扫呗买家实付金额: %w", err)
|
|
}
|
|
result.CashPaidAmount = result.PayerPaidAmount
|
|
if result.Amount >= result.PayerPaidAmount {
|
|
result.DiscountAmount = result.Amount - result.PayerPaidAmount
|
|
result.AmountBreakdownKnown = true
|
|
}
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func (a *saobeiAdapter) Refund(ctx context.Context, req *bizpayment.PaymentRefundRequest, c map[string]any) (*bizpayment.PaymentResult, error) {
|
|
if req == nil {
|
|
return nil, errors.New("扫呗支付退款请求为空")
|
|
}
|
|
tradeNo := strings.TrimSpace(req.TradeNo)
|
|
refundNo := strings.TrimSpace(req.RefundNo)
|
|
if tradeNo == "" || refundNo == "" {
|
|
return nil, errors.New("扫呗支付退款缺少商户订单号或退款单号")
|
|
}
|
|
if req.Amount <= 0 {
|
|
return nil, errors.New("扫呗支付退款金额无效")
|
|
}
|
|
client, err := a.client(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outTradeNo := strings.TrimSpace(req.QueryID)
|
|
if outTradeNo == "" {
|
|
outTradeNo = tradeNo
|
|
}
|
|
bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": refundNo, "terminal_time": time.Now().Format("20060102150405"), "refund_fee": req.Amount, "out_trade_no": outTradeNo}
|
|
mergeGoPayConfigExtras(bm, c, "refund_extra", "pay_type", "terminal_trace", "terminal_time", "refund_fee", "out_trade_no", "out_refund_no")
|
|
rsp, err := client.Refund(ctx, bm)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rsp == nil {
|
|
return nil, errors.New("扫呗支付退款响应为空")
|
|
}
|
|
if !saobeiResultOK(rsp.ReturnCode, rsp.ResultCode) {
|
|
return nil, fmt.Errorf("扫呗支付退款失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"]))
|
|
}
|
|
if responseOrderID := strings.TrimSpace(rsp.OutTradeNo); responseOrderID != "" && responseOrderID != outTradeNo {
|
|
return nil, errors.New("扫呗支付退款响应平台订单号不匹配")
|
|
}
|
|
returnedTrace := strings.TrimSpace(rsp.TerminalTrace)
|
|
if returnedTrace == "" {
|
|
return nil, errors.New("扫呗支付退款响应缺少 terminal_trace")
|
|
}
|
|
if returnedTrace != refundNo {
|
|
return nil, errors.New("扫呗支付退款响应的 terminal_trace 不匹配")
|
|
}
|
|
providerRefundID := strings.TrimSpace(rsp.OutRefundNo)
|
|
if providerRefundID == "" {
|
|
return nil, errors.New("扫呗支付退款响应缺少 out_refund_no")
|
|
}
|
|
refundAmount, err := parseIntegerAmount(rsp.RefundFee)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析扫呗退款金额: %w", err)
|
|
}
|
|
if refundAmount != req.Amount {
|
|
return nil, errors.New("扫呗支付退款响应金额不匹配")
|
|
}
|
|
return &bizpayment.PaymentResult{Provider: bizpayment.PaymentSaobei, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: providerRefundID, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
|
|
}
|
|
|
|
func (a *saobeiAdapter) Callback(_ context.Context, _ *bizpayment.PaymentCallback, _ map[string]any) (*bizpayment.PaymentResult, error) {
|
|
return nil, errors.New("扫呗支付回调没有可复用的 GoPay 验签器,请改用主动查单")
|
|
}
|