From 44e35fbf844059c9937c8d910f3da5fdfe0810dc Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 15 Oct 2026 21:38:57 +0800 Subject: [PATCH] fix(payment): finalize failed pending refunds atomically - Claim REFUND_PENDING -> REFUND_FAILED, return the held deduction and write the audit in one transaction. If the deduction cannot be returned, the order is still marked failed together with a REFUND_ROLLBACK_FAILED audit so a retried refund skips the deduction. - Return an error when the REFUND_PENDING audit cannot be loaded or parsed instead of falling back to the legacy finalization path. --- backend/internal/service/payment_refund.go | 134 +++++++++++++++--- .../payment_refund_pending_hold_test.go | 115 ++++++++++++++- .../internal/service/payment_refund_test.go | 3 +- 3 files changed, 231 insertions(+), 21 deletions(-) diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index c26ae0e5b6..f5d5bedd7a 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -446,7 +446,10 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually") } - pendingDetail := s.latestRefundPendingDetail(ctx, oid) + pendingDetail, err := s.latestRefundPendingDetail(ctx, oid) + if err != nil { + return nil, err + } finishProviderCall := servertiming.ObserveDependency(ctx, "payment") resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{ TradeNo: o.PaymentTradeNo, @@ -573,28 +576,114 @@ func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *Refun return nil } +// finalizeRefundFailed 将 REFUND_PENDING 收敛为 REFUND_FAILED,并归还进入 pending 时保留的扣减。 +// 认领状态、归还扣减与审计在同一事务内完成:两者分开提交时,进程在中间退出会留下 +// 已失败但扣减未归还、且没有 REFUND_ROLLBACK_FAILED 标记的订单,重试退款会再扣一次。 func (s *PaymentService) finalizeRefundFailed(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) { - // 先认领 REFUND_PENDING 再归还扣减,避免并发查询重复归还。 - now := time.Now() - claimed, err := s.entClient.PaymentOrder.Update(). + warning := "gateway refund failed: " + psErrMsg(gErr) + returnErr, err := s.finalizeRefundFailedTx(ctx, p, gErr, nil) + if err != nil { + return nil, err + } + if returnErr == nil { + if p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { + s.invalidateRefundBalanceCache(ctx, p.Order.UserID) + } + return &RefundResult{Success: false, Warning: warning}, nil + } + + // 归还失败(整个事务已回滚,订单仍为 REFUND_PENDING):仍收敛为 REFUND_FAILED, + // 并同事务写入 REFUND_ROLLBACK_FAILED,使重试退款跳过扣减,避免二次扣减。 + slog.Error("[CRITICAL] refund failed but held deduction could not be returned", "orderID", p.OrderID, "error", returnErr) + if _, err := s.finalizeRefundFailedTx(ctx, p, gErr, returnErr); err != nil { + return nil, err + } + return &RefundResult{Success: false, Warning: warning + "; refund deduction rollback failed"}, nil +} + +// finalizeRefundFailedTx 在单个事务内认领 REFUND_PENDING → REFUND_FAILED。rollbackErr 为 nil 时同事务 +// 归还保留的扣减,归还失败则回滚整个事务并通过 returnErr 返回;rollbackErr 非 nil 时不再归还, +// 改为同事务记录 REFUND_ROLLBACK_FAILED。 +func (s *PaymentService) finalizeRefundFailedTx(ctx context.Context, p *RefundPlan, gErr, rollbackErr error) (returnErr error, err error) { + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin refund failure finalization: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + txCtx := dbent.NewTxContext(ctx, tx) + + claimed, err := tx.PaymentOrder.Update(). Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)). SetStatus(OrderStatusRefundFailed). - SetFailedAt(now). + SetFailedAt(time.Now()). SetFailedReason(psErrMsg(gErr)). - Save(ctx) + Save(txCtx) if err != nil { return nil, fmt.Errorf("mark refund failed: %w", err) } if claimed == 0 { return nil, infraerrors.Conflict("CONFLICT", "order status changed") } - warning := "gateway refund failed: " + psErrMsg(gErr) - // 未归还成功时 RollbackRefund 会写 REFUND_ROLLBACK_FAILED,重试退款将跳过重复扣减。 - if p.deductionHeld && !s.RollbackRefund(ctx, p, gErr) { - warning += "; refund deduction rollback failed" + + if p.deductionHeld && rollbackErr == nil { + if err := s.returnHeldRefundDeduction(txCtx, p); err != nil { + return err, nil + } } - s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)}) - return &RefundResult{Success: false, Warning: warning}, nil + if rollbackErr != nil { + if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_ROLLBACK_FAILED", map[string]any{ + "gatewayError": psErrMsg(gErr), + "rollbackError": psErrMsg(rollbackErr), + "balanceDeducted": p.BalanceToDeduct, + "subDaysDeducted": p.SubDaysToDeduct, + }); err != nil { + return nil, err + } + } + if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_FAILED", map[string]any{"detail": psErrMsg(gErr)}); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit refund failure finalization: %w", err) + } + committed = true + return nil, nil +} + +// returnHeldRefundDeduction 归还进入 REFUND_PENDING 时保留的扣减;ctx 携带事务时随事务提交或回滚。 +func (s *PaymentService) returnHeldRefundDeduction(ctx context.Context, p *RefundPlan) error { + if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { + if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { + return fmt.Errorf("return held balance deduction: %w", err) + } + } + if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 { + if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil { + return fmt.Errorf("return held subscription deduction: %w", err) + } + } + return nil +} + +func createPaymentAuditLogTx(ctx context.Context, client *dbent.Client, oid int64, action string, detail map[string]any) error { + raw, err := json.Marshal(detail) + if err != nil { + return fmt.Errorf("marshal %s audit: %w", action, err) + } + if _, err := client.PaymentAuditLog.Create(). + SetOrderID(strconv.FormatInt(oid, 10)). + SetAction(action). + SetDetail(string(raw)). + SetOperator("admin"). + Save(ctx); err != nil { + return fmt.Errorf("write %s audit: %w", action, err) + } + return nil } // refundPendingAuditDetail 记录在 REFUND_PENDING 审计日志中。DeductionHeld 为 true 表示 @@ -623,18 +712,25 @@ func (d refundPendingAuditDetail) applyTo(p *RefundPlan) { p.SubscriptionID = d.SubscriptionID } -func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail { +// latestRefundPendingDetail 读取最近一次 REFUND_PENDING 审计。没有记录视为旧订单(扣减已在 +// pending 时归还);读取或解析失败必须返回错误:误判为旧记录会让保留的扣减被重复扣减或不再归还。 +func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) (refundPendingAuditDetail, error) { + detail := refundPendingAuditDetail{DeductionRollbackOK: true} logEntry, err := s.entClient.PaymentAuditLog.Query(). Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")). - Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())). + Order(paymentauditlog.ByCreatedAt(sql.OrderDesc()), paymentauditlog.ByID(sql.OrderDesc())). First(ctx) - if err != nil || logEntry == nil { - return refundPendingAuditDetail{DeductionRollbackOK: true} + if err != nil { + if dbent.IsNotFound(err) { + return detail, nil + } + return refundPendingAuditDetail{}, fmt.Errorf("load refund pending audit: %w", err) + } + if err := json.Unmarshal([]byte(logEntry.Detail), &detail); err != nil { + return refundPendingAuditDetail{}, fmt.Errorf("parse refund pending audit: %w", err) } - detail := refundPendingAuditDetail{DeductionRollbackOK: true} - _ = json.Unmarshal([]byte(logEntry.Detail), &detail) detail.RefundID = strings.TrimSpace(detail.RefundID) - return detail + return detail, nil } // getRefundProvider creates a provider using the order's original instance config. diff --git a/backend/internal/service/payment_refund_pending_hold_test.go b/backend/internal/service/payment_refund_pending_hold_test.go index 7b010da739..ae09957e81 100644 --- a/backend/internal/service/payment_refund_pending_hold_test.go +++ b/backend/internal/service/payment_refund_pending_hold_test.go @@ -4,12 +4,14 @@ package service import ( "context" + "errors" "math" "strconv" "testing" "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" @@ -126,7 +128,9 @@ func TestFinalizeRefundFailedRestoresHeldDeductionOnce(t *testing.T) { wallet := &refundHoldWallet{} svc := &PaymentService{entClient: client, userRepo: wallet.repo(order.UserID)} plan := svc.refundFinalizePlan(order) - svc.latestRefundPendingDetail(ctx, order.ID).applyTo(plan) + detail, err := svc.latestRefundPendingDetail(ctx, order.ID) + require.NoError(t, err) + detail.applyTo(plan) first, err := svc.finalizeRefundFailed(ctx, plan, context.DeadlineExceeded) require.NoError(t, err) @@ -158,6 +162,115 @@ func TestQueryAndFinalizeRefundHeldDeductionWithoutBalanceDeduction(t *testing.T require.Equal(t, 100.0, wallet.balance) } +type successRefundProviderTestDouble struct { + refundProviderTestDouble + refundCalls int +} + +func (p *successRefundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) { + p.refundCalls++ + return &payment.RefundResponse{RefundID: "rf_retry", Status: payment.ProviderStatusSuccess}, nil +} + +func countRefundAuditsForTest(t *testing.T, ctx context.Context, client *dbent.Client, orderID int64, action string) int { + t.Helper() + n, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10)), paymentauditlog.ActionEQ(action)). + Count(ctx) + require.NoError(t, err) + return n +} + +func TestFinalizeRefundFailedRecordsRollbackFailureWhenHeldDeductionCannotBeReturned(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-return-fails", + `{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`) + + wallet := &refundHoldWallet{balance: 40} + repo := wallet.repo(order.UserID) + repo.updateBalanceFn = func(context.Context, int64, float64) error { + wallet.restoreCalls++ + return errors.New("db unavailable") + } + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusFailed}) + defer restore() + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: repo} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.False(t, result.Success) + require.Contains(t, result.Warning, "refund deduction rollback failed") + require.Equal(t, 1, wallet.restoreCalls) + require.Equal(t, 40.0, wallet.balance) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundFailed, reloaded.Status) + require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_ROLLBACK_FAILED")) + require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_FAILED")) + + // 扣减仍被保留:重试退款必须跳过扣减,否则同一笔退款会扣两次。 + retryProvider := &successRefundProviderTestDouble{} + restoreRetry := replacePaymentProviderFactoryForTest(t, retryProvider) + defer restoreRetry() + plan, early, err := svc.PrepareRefund(ctx, order.ID, 0, "retry", true, true) + require.NoError(t, err) + require.Nil(t, early) + retried, err := svc.ExecuteRefund(ctx, plan) + require.NoError(t, err) + require.True(t, retried.Success) + require.Zero(t, wallet.deductCalls) + require.Equal(t, 40.0, wallet.balance) + require.Equal(t, 1, retryProvider.refundCalls) +} + +func TestFinalizeRefundFailedReturnsHeldDeductionAtomically(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-return-atomic", + `{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`) + + wallet := &refundHoldWallet{} + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusFailed}) + defer restore() + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.False(t, result.Success) + require.NotContains(t, result.Warning, "rollback failed") + require.Equal(t, 1, wallet.restoreCalls) + require.Equal(t, 60.0, wallet.balance) + require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_FAILED")) + require.Zero(t, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_ROLLBACK_FAILED")) +} + +func TestQueryAndFinalizeRefundRejectsUnreadablePendingAudit(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + // 审计无法解析时不能退化为旧逻辑:成功会二次扣减,失败会吞掉保留的扣减。 + order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-bad-audit", `not-json`) + + for _, queryStatus := range []string{payment.ProviderStatusSuccess, payment.ProviderStatusFailed} { + wallet := &refundHoldWallet{balance: 100} + restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: queryStatus}) + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)} + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + restore() + require.Error(t, err, queryStatus) + require.Nil(t, result) + require.Zero(t, wallet.deductCalls) + require.Zero(t, wallet.restoreCalls) + require.Equal(t, 100.0, wallet.balance) + } + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundPending, reloaded.Status) +} + func createCompletedRefundableOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder { t.Helper() user, err := client.User.Create(). diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index 55ed34e837..51a7603384 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -373,7 +373,8 @@ func TestFinishRefundPendingMarksOrderPendingAndHoldsDeduction(t *testing.T) { Count(ctx) require.NoError(t, err) require.Equal(t, 1, pendingAudits) - detail := svc.latestRefundPendingDetail(ctx, order.ID) + detail, err := svc.latestRefundPendingDetail(ctx, order.ID) + require.NoError(t, err) require.True(t, detail.DeductionHeld) require.Equal(t, payment.DeductionTypeBalance, detail.DeductionType) require.Equal(t, 40.0, detail.BalanceDeducted)