diff --git a/backend/internal/integrations/payment/saobei/client.go b/backend/internal/integrations/payment/saobei/client.go index be30485..6bd0374 100644 --- a/backend/internal/integrations/payment/saobei/client.go +++ b/backend/internal/integrations/payment/saobei/client.go @@ -160,7 +160,7 @@ func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (* "terminal_trace": req.ThirdOrderID, "terminal_time": now.Format("20060102150405"), "total_fee": strconv.FormatInt(req.AmountCent, 10), - "order_body": sanitizeText(req.Body, 128), + "order_body": sanitizeOrderBody(req.Body), "notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL), "attach": sanitizeText(req.Attach, 128), "timeout_express": strconv.Itoa(expireSeconds), @@ -175,8 +175,12 @@ func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (* if err != nil { return nil, err } - if err := c.verifyResponse(raw); err != nil { - return nil, err + if err := c.verifyResponseOrChannelError(raw); err != nil { + return &CreatePaymentResponse{ + ErrorMessage: responseMessage(raw), + Raw: raw, + RawRequest: copyMap(params), + }, err } return &CreatePaymentResponse{ OK: responseOK(raw), @@ -219,7 +223,7 @@ func (c *Client) QueryPayment(ctx context.Context, req QueryPaymentRequest) (*Qu if err != nil { return nil, err } - if err := c.verifyResponse(raw); err != nil { + if err := c.verifyResponseOrChannelError(raw); err != nil { return nil, err } return &QueryPaymentResponse{ @@ -269,7 +273,7 @@ func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*Cr if err != nil { return nil, err } - if err := c.verifyResponse(raw); err != nil { + if err := c.verifyResponseOrChannelError(raw); err != nil { return nil, err } return &CreateRefundResponse{ @@ -313,7 +317,7 @@ func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*Quer if err != nil { return nil, err } - if err := c.verifyResponse(raw); err != nil { + if err := c.verifyResponseOrChannelError(raw); err != nil { return nil, err } return &QueryRefundResponse{ @@ -468,6 +472,18 @@ func (c *Client) verifyResponse(raw map[string]string) error { return nil } +func (c *Client) verifyResponseOrChannelError(raw map[string]string) error { + if err := c.verifyResponse(raw); err == nil { + return nil + } + // 扫呗的失败响应通常不带 key_sign。失败响应只会阻断支付或触发退款查询, + // 可以安全地保留为业务错误;成功响应仍必须严格验签。 + if responseCode(raw) != "01" { + return nil + } + return ErrVerifyFailed +} + func responseCode(raw map[string]string) string { return strings.TrimSpace(raw["return_code"]) } @@ -573,6 +589,22 @@ func sanitizeText(value string, max int) string { return value } +func sanitizeOrderBody(value string) string { + value = strings.Join(strings.Fields(value), "-") + value = strings.NewReplacer( + "+", "-", + "/", "-", + "?", "", + "%", "", + "#", "", + "&", "", + "=", "", + "【", "[", + "】", "]", + ).Replace(value) + return sanitizeText(value, 128) +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/backend/internal/integrations/payment/saobei/client_test.go b/backend/internal/integrations/payment/saobei/client_test.go index 1f9d642..7eaf76e 100644 --- a/backend/internal/integrations/payment/saobei/client_test.go +++ b/backend/internal/integrations/payment/saobei/client_test.go @@ -67,7 +67,7 @@ func TestCreatePaymentPostsQRPayAndVerifiesResponse(t *testing.T) { resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{ ThirdOrderID: "PAY1", AmountCent: 100, - Body: "租号订单", + Body: "租号订单 ORD1", Attach: "ORD1", }) if err != nil { @@ -82,6 +82,33 @@ func TestCreatePaymentPostsQRPayAndVerifiesResponse(t *testing.T) { if captured["repeated_trace"] != "3" || captured["auto_pay"] != "1" { t.Fatalf("qrpay safety fields = %#v", captured) } + if captured["order_body"] != "租号订单-ORD1" { + t.Fatalf("order_body = %q, want safe characters only", captured["order_body"]) + } +} + +func TestCreatePaymentTreatsUnsignedFailureAsBusinessError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"return_code":"02","return_msg":"签名未通过校验!","result_code":"02"}`)) + })) + defer server.Close() + + client := NewClient(Config{ + GatewayURL: server.URL, + MerchantID: "M1", + TerminalID: "T1", + AccessToken: "token-1", + }) + resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{ + ThirdOrderID: "PAY1", + AmountCent: 100, + }) + if err != nil { + t.Fatalf("CreatePayment() error = %v, want nil business error", err) + } + if resp.OK || resp.ErrorMessage == "" || resp.Raw["return_code"] != "02" { + t.Fatalf("CreatePayment() response = %+v", resp) + } } func TestQueryPaymentUsesTraceTimeAndMapsSuccess(t *testing.T) { diff --git a/backend/internal/modules/payment/channel_adapter.go b/backend/internal/modules/payment/channel_adapter.go index 950db48..0a14716 100644 --- a/backend/internal/modules/payment/channel_adapter.go +++ b/backend/internal/modules/payment/channel_adapter.go @@ -497,6 +497,13 @@ func (c saobeiChannel) CreatePayment(ctx context.Context, req channelCreatePayme Attach: req.Attach, }) if err != nil { + if resp != nil { + return &channelCreatePaymentResponse{ + ErrorMessage: resp.ErrorMessage, + RawRequest: resp.RawRequest, + Raw: resp.Raw, + }, err + } return nil, err } return &channelCreatePaymentResponse{ diff --git a/backend/internal/modules/payment/mohong_payment.go b/backend/internal/modules/payment/mohong_payment.go index 496286a..dad2059 100644 --- a/backend/internal/modules/payment/mohong_payment.go +++ b/backend/internal/modules/payment/mohong_payment.go @@ -68,11 +68,15 @@ func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uin NotifyURL: runtimeConfig.NotifyURL, JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, - Body: "撞车订单 " + orderRow.OrderNo, + Body: "撞车订单-" + orderRow.OrderNo, Attach: orderRow.OrderNo, }) if err != nil { - _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) + var raw map[string]string + if resp != nil { + raw = resp.Raw + } + _ = r.markPaymentFailed(ctx, payment.ID, raw, err.Error()) r.log().Warn("魔哄支付下单失败", paymentLogFields(ctx, appendFields( paymentOrderFields(payment), runtimeConfigFields(runtimeConfig), diff --git a/backend/internal/modules/payment/payment_start.go b/backend/internal/modules/payment/payment_start.go index 3010a8a..bab5f40 100644 --- a/backend/internal/modules/payment/payment_start.go +++ b/backend/internal/modules/payment/payment_start.go @@ -64,11 +64,15 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r NotifyURL: runtimeConfig.NotifyURL, JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, - Body: "租号订单 " + orderRow.OrderNo, + Body: "租号订单-" + orderRow.OrderNo, Attach: orderRow.OrderNo, }) if err != nil { - _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) + var raw map[string]string + if resp != nil { + raw = resp.Raw + } + _ = r.markPaymentFailed(ctx, payment.ID, raw, err.Error()) r.log().Warn("支付下单请求失败", paymentLogFields(ctx, appendFields( paymentOrderFields(payment), runtimeConfigFields(runtimeConfig), diff --git a/frontend/src/features/orders/OrderActionPanel.tsx b/frontend/src/features/orders/OrderActionPanel.tsx index bb0e924..3bfe3df 100644 --- a/frontend/src/features/orders/OrderActionPanel.tsx +++ b/frontend/src/features/orders/OrderActionPanel.tsx @@ -76,6 +76,7 @@ export function OrderActionPanel({ const [busy, setBusy] = useState(null) const [payURL, setPayURL] = useState('') const autoPayTriggered = useRef(false) + const payPolling = useRef(null) const actions = getOrderActionAvailability(order, user?.id) const checkout = order.checkout @@ -144,6 +145,42 @@ export function OrderActionPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoPay, actions.canPay]) + useEffect(() => { + if (dialog !== 'pay' || !payURL) return + + let cancelled = false + let attempts = 0 + const poll = async () => { + if (cancelled) return + attempts += 1 + try { + const payment = await queryOrderPayment(order.id) + if (cancelled) return + if (payment.paid) { + message.success('支付成功,订单已进入待交接') + setDialog(null) + setPayURL('') + await onChanged() + return + } + } catch { + // 自动轮询不打断用户;保留手动检查作为明确反馈。 + } + if (!cancelled && attempts < 200) { + payPolling.current = window.setTimeout(() => void poll(), 3000) + } + } + + payPolling.current = window.setTimeout(() => void poll(), 3000) + return () => { + cancelled = true + if (payPolling.current !== null) { + window.clearTimeout(payPolling.current) + payPolling.current = null + } + } + }, [dialog, message, onChanged, order.id, payURL]) + const run = async (key: string, fn: () => Promise) => { setBusy(key) try { @@ -174,7 +211,7 @@ export function OrderActionPanel({ const url = payment.jspay_url || payment.td_code || payment.jspay_info || '' if (url) { setPayURL(url) - message.info('支付单已创建,请打开支付页面完成付款') + message.info('支付单已创建,系统会自动检查支付结果') } else { message.warning('支付单已创建,但渠道没有返回支付链接,请稍后检查支付状态') } @@ -493,7 +530,7 @@ export function OrderActionPanel({ type="info" showIcon message="选择支付方式" - description="支付成功后才进入交接流程;支付完成后回到本页点击「检查支付状态」。" + description="支付成功后才进入交接流程;二维码创建后系统会自动检查支付结果。" />
@@ -511,7 +548,7 @@ export function OrderActionPanel({ description={ - 请使用微信、支付宝或云闪付扫码付款,完成后回到本页检查支付状态。 + 请使用微信、支付宝或云闪付扫码付款,支付成功后本页会自动刷新。