mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user