Merge pull request #6550 from weiness/fix/alipay-payment-reconciliation

修复支付宝回调延迟或丢失,已支付订单可能长时间停留在待支付状态的问题
This commit is contained in:
Wesley Liddick
2026-09-05 15:08:56 +08:00
committed by GitHub
5 changed files with 97 additions and 12 deletions
@@ -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)
@@ -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
@@ -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)
@@ -393,7 +393,7 @@ function saveQRCode() {
}
async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder> {
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()
@@ -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')