package payment import ( "context" "errors" "fmt" "strings" "time" "kra/internal/modules/system/biz" "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 *biz.PaymentRequest, c map[string]any) (*biz.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 &biz.PaymentResult{Provider: biz.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 &biz.PaymentResult{Provider: biz.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 := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(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) (*biz.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 := &biz.PaymentResult{Provider: biz.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 *biz.PaymentRefundRequest, c map[string]any) (*biz.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 &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: providerRefundID, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil } func (a *saobeiAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) { return nil, errors.New("扫呗支付回调没有可复用的 GoPay 验签器,请改用主动查单") }