feat(payment): 接入扫呗普通支付2.0

This commit is contained in:
yml
2026-11-11 15:57:30 +08:00
parent 58d9f50bac
commit d403ffc734
30 changed files with 1337 additions and 111 deletions
@@ -9,13 +9,14 @@ import (
"hfb_jysh/backend/internal/integrations/payment/lakala"
"hfb_jysh/backend/internal/integrations/payment/leshua"
"hfb_jysh/backend/internal/integrations/payment/saobei"
"hfb_jysh/backend/internal/integrations/payment/shuncheng"
"hfb_jysh/backend/internal/modules/paymentconfig"
)
type channelClient interface {
CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error)
QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error)
QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error)
CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error)
QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error)
VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error)
@@ -47,6 +48,12 @@ type channelCreatePaymentResponse struct {
Raw map[string]string
}
type channelQueryPaymentRequest struct {
ThirdOrderID string
TraceTime string
ProviderOrderID string
}
type channelQueryPaymentResponse struct {
OK bool
ErrorMessage string
@@ -59,14 +66,15 @@ type channelQueryPaymentResponse struct {
}
type channelCreateRefundRequest struct {
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
RefundAmountCent int64
NotifyURL string
Attach string
Remark string
ClientIP string
ThirdOrderID string
OriginalTraceTime string
ProviderOrderID string
MerchantRefundID string
RefundAmountCent int64
NotifyURL string
Attach string
Remark string
ClientIP string
}
type channelCreateRefundResponse struct {
@@ -80,10 +88,11 @@ type channelCreateRefundResponse struct {
}
type channelQueryRefundRequest struct {
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
ProviderRefundID string
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
MerchantRefundTime string
ProviderRefundID string
}
type channelQueryRefundResponse struct {
@@ -144,8 +153,8 @@ func (c leshuaChannel) CreatePayment(ctx context.Context, req channelCreatePayme
}, nil
}
func (c leshuaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
func (c leshuaChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID)
if err != nil {
return nil, err
}
@@ -264,8 +273,8 @@ func (c lakalaChannel) CreatePayment(ctx context.Context, req channelCreatePayme
}, nil
}
func (c lakalaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
func (c lakalaChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID)
if err != nil {
return nil, err
}
@@ -392,8 +401,8 @@ func (c shunchengChannel) CreatePayment(ctx context.Context, req channelCreatePa
}, nil
}
func (c shunchengChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
func (c shunchengChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID)
if err != nil {
return nil, err
}
@@ -471,6 +480,117 @@ func (c shunchengChannel) VerifyNotify(params map[string]string, rawPayload stri
return result, nil
}
type saobeiChannel struct {
client *saobei.Client
}
func newSaobeiChannel(cfg saobei.Config) channelClient {
return saobeiChannel{client: saobei.NewClient(cfg)}
}
func (c saobeiChannel) CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) {
resp, err := c.client.CreatePayment(ctx, saobei.CreatePaymentRequest{
ThirdOrderID: req.ThirdOrderID,
AmountCent: req.AmountCent,
NotifyURL: req.NotifyURL,
Body: req.Body,
Attach: req.Attach,
})
if err != nil {
return nil, err
}
return &channelCreatePaymentResponse{
OK: resp.OK,
ErrorMessage: resp.ErrorMessage,
ProviderOrderID: resp.ProviderOrderID,
Status: resp.Status,
TDCode: resp.QRURL,
JSPayURL: resp.QRURL,
RawRequest: resp.RawRequest,
Raw: resp.Raw,
}, nil
}
func (c saobeiChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
resp, err := c.client.QueryPayment(ctx, saobei.QueryPaymentRequest{
ThirdOrderID: req.ThirdOrderID,
TerminalTime: req.TraceTime,
ProviderOrderID: req.ProviderOrderID,
})
if err != nil {
return nil, err
}
return &channelQueryPaymentResponse{
OK: resp.OK,
ErrorMessage: resp.ErrorMessage,
ProviderOrderID: resp.ProviderOrderID,
Status: resp.Status,
Amount: resp.Amount,
PayTime: resp.PayTime,
Raw: resp.Raw,
}, nil
}
func (c saobeiChannel) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) {
resp, err := c.client.CreateRefund(ctx, saobei.CreateRefundRequest{
ThirdOrderID: req.ThirdOrderID,
TerminalTime: req.OriginalTraceTime,
ProviderOrderID: req.ProviderOrderID,
MerchantRefundID: req.MerchantRefundID,
RefundAmountCent: req.RefundAmountCent,
Attach: req.Attach,
Remark: req.Remark,
})
if err != nil {
return nil, err
}
return &channelCreateRefundResponse{
OK: resp.OK,
ErrorMessage: resp.ErrorMessage,
ProviderRefundID: resp.ProviderRefundID,
Status: resp.Status,
RefundAmount: resp.RefundAmount,
RawRequest: resp.RawRequest,
Raw: resp.Raw,
}, nil
}
func (c saobeiChannel) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) {
resp, err := c.client.QueryRefund(ctx, saobei.QueryRefundRequest{
MerchantRefundID: req.MerchantRefundID,
MerchantRefundTime: req.MerchantRefundTime,
ProviderRefundID: req.ProviderRefundID,
})
if err != nil {
return nil, err
}
return &channelQueryRefundResponse{
OK: resp.OK,
ErrorMessage: resp.ErrorMessage,
ProviderRefundID: resp.ProviderRefundID,
Status: resp.Status,
RefundAmount: resp.RefundAmount,
RefundTime: resp.RefundTime,
Raw: resp.Raw,
}, nil
}
func (c saobeiChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
verify := c.client.VerifyNotify(params)
result := channelVerifyNotifyResult{
OK: verify.OK,
MatchedKey: verify.MatchedKey,
Got: verify.Got,
Expected: verify.Expected,
BaseString: verify.BaseString,
ParamKeys: verify.ParamKeys,
}
if !result.OK {
return result, ErrPaymentVerifyFailed
}
return result, nil
}
func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) {
switch strings.ToLower(strings.TrimSpace(dto.Provider)) {
case "leshua":
@@ -518,6 +638,16 @@ func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) {
RefundType: extraString(dto.ExtraConfig, "refund_type"),
TokenMargin: time.Duration(extraInt(dto.ExtraConfig, "token_refresh_margin_seconds")) * time.Second,
})), nil
case "saobei":
return withChannelBreaker("saobei", newSaobeiChannel(saobei.Config{
GatewayURL: dto.GatewayURL,
MerchantID: dto.MerchantID,
TerminalID: extraString(dto.ExtraConfig, "terminal_id"),
AccessToken: dto.SignKey,
NotifyURL: dto.NotifyURL,
DeviceNo: extraString(dto.ExtraConfig, "device_no"),
ExpireSecond: extraInt(dto.ExtraConfig, "expire_seconds"),
})), nil
case "mock":
return nil, nil
default:
@@ -45,9 +45,9 @@ func (c breakerChannel) CreatePayment(ctx context.Context, req channelCreatePaym
})
}
func (c breakerChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
func (c breakerChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
return c.queryPayment.Execute(func() (*channelQueryPaymentResponse, error) {
return c.next.QueryPayment(ctx, thirdOrderID, providerOrderID)
return c.next.QueryPayment(ctx, req)
})
}
@@ -61,7 +61,11 @@ func (r *Repository) probeChannelPaid(ctx context.Context, payment *model.Paymen
if runtimeConfig.isMockMode() || runtimeConfig.Channel == nil {
return nil
}
resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID)
resp, err := runtimeConfig.Channel.QueryPayment(ctx, channelQueryPaymentRequest{
ThirdOrderID: payment.ThirdOrderID,
TraceTime: paymentTraceTime(payment.RawRequest),
ProviderOrderID: payment.ProviderOrderID,
})
if err != nil {
return err
}
@@ -9,6 +9,7 @@ import (
"hfb_jysh/backend/internal/integrations/payment/lakala"
"hfb_jysh/backend/internal/integrations/payment/leshua"
"hfb_jysh/backend/internal/integrations/payment/saobei"
"hfb_jysh/backend/internal/integrations/payment/shuncheng"
"hfb_jysh/backend/internal/middleware"
"hfb_jysh/backend/internal/modules/order"
@@ -263,6 +264,29 @@ func (h *Handler) ShunchengNotify(c *gin.Context) {
c.String(http.StatusOK, result.Message)
}
func (h *Handler) SaobeiNotify(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
c.JSON(http.StatusOK, gin.H{"return_code": "02", "return_msg": "读取失败"})
return
}
params, err := saobei.ParsePayload(body)
if err != nil {
c.JSON(http.StatusOK, gin.H{"return_code": "02", "return_msg": "解析失败"})
return
}
rawPayload := string(body)
contentType := c.GetHeader("Content-Type")
result, err := h.service.HandleNotify(c.Request.Context(), "saobei", params, rawPayload, contentType, "")
if err != nil || result == nil || !result.OK {
h.log().Warn("扫呗支付回调处理失败", notifyLogFields(c.Request.Context(), "saobei", params, contentType, len(body), zap.Error(err))...)
c.JSON(http.StatusOK, gin.H{"return_code": "02", "return_msg": "失败"})
return
}
h.log().Info("扫呗支付回调处理完成", notifyLogFields(c.Request.Context(), "saobei", params, contentType, len(body))...)
c.JSON(http.StatusOK, gin.H{"return_code": "01", "return_msg": "success"})
}
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
@@ -229,7 +229,11 @@ func (r *Repository) QueryMohong(ctx context.Context, userID uint64, orderID uin
if runtimeConfig.Channel == nil {
return nil, ErrPaymentUnavailable
}
resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID)
resp, err := runtimeConfig.Channel.QueryPayment(ctx, channelQueryPaymentRequest{
ThirdOrderID: payment.ThirdOrderID,
TraceTime: paymentTraceTime(payment.RawRequest),
ProviderOrderID: payment.ProviderOrderID,
})
if err != nil {
return nil, err
}
+25 -7
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"hfb_jysh/backend/internal/integrations/payment/saobei"
"hfb_jysh/backend/internal/model"
"go.uber.org/zap"
@@ -22,7 +23,11 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m
return r.HandleRefundNotify(ctx, provider, params, rawPayload, contentType, authorization)
}
payment, err := r.findPaymentForNotify(ctx, params)
lookupParams := params
if provider == "saobei" {
lookupParams = saobei.CanonicalizePayload(params)
}
payment, err := r.findPaymentForNotify(ctx, lookupParams)
if err != nil {
return nil, err
}
@@ -35,7 +40,19 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m
return nil, err
}
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
processingParams := params
if provider == "saobei" {
processingParams = saobei.CanonicalizePayload(params)
}
if runtimeConfig.Provider == "saobei" {
if merchantNo := processingParams["merchant_no"]; merchantNo == "" || merchantNo != payment.MerchantID {
return nil, ErrPaymentVerifyFailed
}
if amount := parseCent(processingParams["amount"]); amount <= 0 || amount != payment.AmountCent {
return nil, ErrPaymentVerifyFailed
}
}
if amount := parseCent(processingParams["amount"]); amount > 0 && amount != payment.AmountCent {
if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil {
r.log().Warn("支付回调诊断保存失败", paymentLogFields(ctx, appendFields(
paymentOrderFields(payment),
@@ -49,9 +66,9 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m
}
return nil, ErrPaymentVerifyFailed
}
raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified")
channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"])
if err := r.applyChannelStatus(ctx, payment, channelStatus, params["pay_time"], raw, channelSourceNotify); err != nil {
raw := withNotifyDiagnostic(processingParams, rawPayload, contentType, verify, "verified")
channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, processingParams["status"])
if err := r.applyChannelStatus(ctx, payment, channelStatus, processingParams["pay_time"], raw, channelSourceNotify); err != nil {
// 入口处的 WARN 只有渠道参数,定位不到是哪个订单哪笔支付单,这里补齐上下文。
r.log().Warn("支付回调入账失败", paymentLogFields(ctx, appendFields(
paymentOrderFields(payment),
@@ -182,10 +199,11 @@ func redactNotifyParams(params map[string]string) map[string]string {
allowed := map[string]bool{
"service": true, "merchant_id": true, "third_order_id": true,
"provider_order_id": true, "leshua_order_id": true, "schc_order_id": true,
"terminal_trace": true, "out_trade_no": true,
"merchant_refund_id": true, "provider_refund_id": true,
"leshua_refund_id": true, "schc_refund_id": true,
"status": true, "amount": true, "refund_amount": true,
"pay_time": true, "refund_time": true,
"status": true, "amount": true, "total_fee": true, "refund_amount": true,
"pay_time": true, "end_time": true, "refund_time": true,
"code": true, "resp_code": true, "result_code": true, "error_code": true,
}
redacted := make(map[string]string, len(allowed))
@@ -102,6 +102,23 @@ func jsonString(value map[string]string) string {
}
return string(raw)
}
func paymentTraceTime(raw datatypes.JSON) string {
if len(raw) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return ""
}
for _, key := range []string{"terminal_time", "pay_time"} {
if value, ok := payload[key].(string); ok && value != "" {
return value
}
}
return ""
}
func newPaymentNo() (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
+5 -1
View File
@@ -29,7 +29,11 @@ func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) (
if runtimeConfig.Channel == nil {
return nil, ErrPaymentUnavailable
}
resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID)
resp, err := runtimeConfig.Channel.QueryPayment(ctx, channelQueryPaymentRequest{
ThirdOrderID: payment.ThirdOrderID,
TraceTime: paymentTraceTime(payment.RawRequest),
ProviderOrderID: payment.ProviderOrderID,
})
if err != nil {
return nil, err
}
+13 -11
View File
@@ -86,13 +86,14 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
return nil, ErrPaymentUnavailable
}
resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{
ThirdOrderID: originalPayment.ThirdOrderID,
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: refundOrder.ThirdOrderID,
RefundAmountCent: refundAmountCent,
NotifyURL: runtimeConfig.NotifyURL,
Attach: originalPayment.OrderNo,
Remark: remark,
ThirdOrderID: originalPayment.ThirdOrderID,
OriginalTraceTime: paymentTraceTime(originalPayment.RawRequest),
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: refundOrder.ThirdOrderID,
RefundAmountCent: refundAmountCent,
NotifyURL: runtimeConfig.NotifyURL,
Attach: originalPayment.OrderNo,
Remark: remark,
})
if err != nil {
var rawRequest map[string]string
@@ -380,10 +381,11 @@ func isRefundBizType(bizType string) bool {
}
func refundQueryRequest(payment model.PaymentOrder, originalPayment model.PaymentOrder) channelQueryRefundRequest {
return channelQueryRefundRequest{
ThirdOrderID: originalPayment.ThirdOrderID,
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: payment.ThirdOrderID,
ProviderRefundID: payment.ProviderOrderID,
ThirdOrderID: originalPayment.ThirdOrderID,
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: payment.ThirdOrderID,
MerchantRefundTime: paymentTraceTime(payment.RawRequest),
ProviderRefundID: payment.ProviderOrderID,
}
}
@@ -73,13 +73,14 @@ func (r *Repository) StartMohongRefund(ctx context.Context, orderID uint64, refu
return nil, ErrPaymentUnavailable
}
resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{
ThirdOrderID: originalPayment.ThirdOrderID,
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: refundOrder.ThirdOrderID,
RefundAmountCent: refundOrder.AmountCent,
NotifyURL: runtimeConfig.NotifyURL,
Attach: originalPayment.OrderNo,
Remark: remark,
ThirdOrderID: originalPayment.ThirdOrderID,
OriginalTraceTime: paymentTraceTime(originalPayment.RawRequest),
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
MerchantRefundID: refundOrder.ThirdOrderID,
RefundAmountCent: refundOrder.AmountCent,
NotifyURL: runtimeConfig.NotifyURL,
Attach: originalPayment.OrderNo,
Remark: remark,
})
if err != nil {
var rawRequest map[string]string
@@ -233,7 +233,7 @@ func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model
MerchantID: merchantID,
}, nil
}
if provider != "leshua" && provider != "lakala" && provider != "shuncheng" {
if provider != "leshua" && provider != "lakala" && provider != "shuncheng" && provider != "saobei" {
return &runtimePaymentConfig{
Provider: provider,
MerchantID: merchantID,