From de8d756afae96f74d46c08f7633eb7e186c25176 Mon Sep 17 00:00:00 2001 From: weiness <840833211@qq.com> Date: Thu, 3 Sep 2026 16:33:30 +0800 Subject: [PATCH] fix(payment): reconcile pending Alipay orders --- .../service/payment_order_expiry_service.go | 6 +- .../service/payment_order_lifecycle.go | 16 ++++-- .../service/payment_order_lifecycle_test.go | 57 ++++++++++++++++++- .../components/payment/PaymentStatusPanel.vue | 2 +- .../__tests__/PaymentStatusPanel.spec.ts | 28 +++++++++ 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/payment_order_expiry_service.go b/backend/internal/service/payment_order_expiry_service.go index b292ef89ae..ed66e200e6 100644 --- a/backend/internal/service/payment_order_expiry_service.go +++ b/backend/internal/service/payment_order_expiry_service.go @@ -98,12 +98,12 @@ func (s *PaymentOrderExpiryService) runOnce() { defer release() reconcileCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) - recovered, err := s.paymentSvc.ReconcilePendingWxpayOrders(reconcileCtx) + recovered, err := s.paymentSvc.ReconcilePendingPaymentOrders(reconcileCtx) cancel() if err != nil { - slog.Warn("[PaymentOrderExpiry] failed to reconcile pending wxpay orders", "error", err) + slog.Warn("[PaymentOrderExpiry] failed to reconcile pending payment orders", "error", err) } else if recovered > 0 { - slog.Info("[PaymentOrderExpiry] reconciled paid wxpay orders", "count", recovered) + slog.Info("[PaymentOrderExpiry] reconciled paid orders", "count", recovered) } expireCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index 46a2e00605..d534bf7b33 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -27,7 +27,7 @@ const ( checkPaidResultAlreadyPaid = "already_paid" checkPaidResultCancelled = "cancelled" - pendingWxpayReconcileLimit = 20 + pendingPaymentReconcileLimit = 20 ) type checkPaidOptions struct { @@ -303,9 +303,9 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo return o, nil } -// ReconcilePendingWxpayOrders actively checks recent pending WeChat orders so -// missed provider notifications do not wait until order expiry to fulfill. -func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, error) { +// ReconcilePendingPaymentOrders actively checks recent pending Alipay and WeChat +// orders so missed provider notifications do not wait until order expiry to fulfill. +func (s *PaymentService) ReconcilePendingPaymentOrders(ctx context.Context) (int, error) { now := time.Now() orders, err := s.entClient.PaymentOrder.Query(). Where( @@ -316,13 +316,17 @@ func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, paymentorder.PaymentTypeHasPrefix(payment.TypeWxpay+"_"), paymentorder.ProviderKeyEQ(payment.TypeWxpay), paymentorder.ProviderKeyHasPrefix(payment.TypeWxpay+"_"), + paymentorder.PaymentTypeEQ(payment.TypeAlipay), + paymentorder.PaymentTypeHasPrefix(payment.TypeAlipay+"_"), + paymentorder.ProviderKeyEQ(payment.TypeAlipay), + paymentorder.ProviderKeyHasPrefix(payment.TypeAlipay+"_"), ), ). Order(dbent.Asc(paymentorder.FieldCreatedAt)). - Limit(pendingWxpayReconcileLimit). + Limit(pendingPaymentReconcileLimit). All(ctx) if err != nil { - return 0, fmt.Errorf("query pending wxpay orders: %w", err) + return 0, fmt.Errorf("query pending payment orders: %w", err) } recovered := 0 diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 658a1806c6..c5a5f2fb9c 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -567,7 +567,7 @@ func TestCancelOrderStillClosesUnpaidUpstreamOrder(t *testing.T) { require.Equal(t, OrderStatusCancelled, reloaded.Status) } -func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { +func TestReconcilePendingPaymentOrdersBackfillsPaidOrder(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) @@ -655,7 +655,7 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { providersLoaded: true, } - recovered, err := svc.ReconcilePendingWxpayOrders(ctx) + recovered, err := svc.ReconcilePendingPaymentOrders(ctx) require.NoError(t, err) require.Equal(t, 1, recovered) require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) @@ -669,6 +669,59 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { require.Len(t, redeemRepo.useCalls, 1) } +func TestReconcilePendingPaymentOrdersQueriesAlipayOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("alipay-reconcile@example.com"). + SetPasswordHash("hash"). + SetUsername("alipay-reconcile-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(50). + SetPayAmount(50). + SetFeeRate(0). + SetRechargeCode("ALIPAY-RECONCILE"). + SetOutTradeNo("sub2_alipay_reconcile"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + key: payment.TypeAlipay, + resp: &payment.QueryOrderResponse{ + TradeNo: order.OutTradeNo, + Status: payment.ProviderStatusPending, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + providersLoaded: true, + } + + recovered, err := svc.ReconcilePendingPaymentOrders(ctx) + require.NoError(t, err) + require.Zero(t, recovered) + require.Equal(t, 1, provider.queryCalls) + require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) +} + func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 9b2b78e800..3400b89c69 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -393,7 +393,7 @@ function saveQRCode() { } async function tryRecoverPendingOrder(order: PaymentOrder): Promise { - if (!isWxpay.value && !isMobileAlipayDeepLink.value) return order + if (!isWxpay.value && !isAlipay.value) return order const outTradeNo = String(order.out_trade_no || '').trim() if (!outTradeNo) return order const normalizedStatus = String(order.status || '').trim().toUpperCase() diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts index 09c5067172..ce99922878 100644 --- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts @@ -226,6 +226,34 @@ describe('PaymentStatusPanel', () => { if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden) }) + it('actively verifies a pending desktop Alipay order', async () => { + pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) + verifyOrder.mockResolvedValue({ data: orderFactory('COMPLETED') }) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + amount: 88, + payAmount: 88, + qrCode: 'https://qr.alipay.com/desktop-order-42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'alipay', + orderType: 'balance', + outTradeNo: 'sub2_20260420abcd1234', + }, + global: { stubs: { Icon: true } }, + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(verifyOrder).toHaveBeenCalledWith('sub2_20260420abcd1234') + expect(wrapper.emitted('success')).toHaveLength(1) + + wrapper.unmount() + }) + it('keeps the QR fallback hidden until the Alipay app launch times out', async () => { const originalLocation = window.location const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')