package refundretry import ( "testing" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/payment" "go.uber.org/zap" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) func setupRefundRetryTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("创建测试数据库失败: %v", err) } if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db } func TestRetryBackoffCapsAtOneHour(t *testing.T) { cases := []struct { retryCount int want time.Duration }{ {retryCount: 1, want: 5 * time.Minute}, {retryCount: 2, want: 10 * time.Minute}, {retryCount: 3, want: 20 * time.Minute}, {retryCount: 4, want: 40 * time.Minute}, {retryCount: 5, want: time.Hour}, {retryCount: 10, want: time.Hour}, } for _, tc := range cases { if got := retryBackoff(tc.retryCount); got != tc.want { t.Fatalf("retryBackoff(%d) = %s, want %s", tc.retryCount, got, tc.want) } } } func TestWarnMaxRetryRefundsThrottlesByLastRetryAt(t *testing.T) { db := setupRefundRetryTestDB(t) job := New(db, nil, zap.NewNop(), nil) now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) old := now.Add(-25 * time.Hour) row := model.PaymentOrder{ PaymentNo: "PAY202607110001", OrderID: 9, OrderNo: "ORD202607110001", UserID: 2, Provider: "mock", ThirdOrderID: "REF202607110001", BizType: "admin_refund", Status: "failed", RetryCount: maxRetryCount, LastRetryAt: &old, } if err := db.Create(&row).Error; err != nil { t.Fatalf("创建退款单失败: %v", err) } job.warnMaxRetryRefunds(t.Context(), now) var latest model.PaymentOrder if err := db.First(&latest, row.ID).Error; err != nil { t.Fatalf("查询退款单失败: %v", err) } if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) { t.Fatalf("LastRetryAt 应更新为 now 以节流,got %v", latest.LastRetryAt) } // 立即再扫不应匹配(last_retry_at 未超过 24h) var count int64 if err := db.Model(&model.PaymentOrder{}). Where("retry_count >= ? AND (last_retry_at IS NULL OR last_retry_at <= ?)", maxRetryCount, now.Add(-manualWarnThreshold)). Count(&count).Error; err != nil { t.Fatalf("count: %v", err) } if count != 0 { t.Fatalf("throttled rows count = %d, want 0", count) } } func TestMarkRetryFailedStopsAtMaxCount(t *testing.T) { db := setupRefundRetryTestDB(t) job := New(db, nil, zap.NewNop(), nil) now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) row := model.PaymentOrder{ PaymentNo: "PAY202606140101", OrderID: 1, OrderNo: "ORD202606140101", UserID: 2, Provider: "mock", ThirdOrderID: "REF202606140101", BizType: "admin_refund", Status: "failed", RetryCount: maxRetryCount - 1, } if err := db.Create(&row).Error; err != nil { t.Fatalf("创建退款单失败: %v", err) } if err := job.markRetryFailed(t.Context(), row, now); err != nil { t.Fatalf("markRetryFailed() error = %v", err) } var latest model.PaymentOrder if err := db.First(&latest, row.ID).Error; err != nil { t.Fatalf("查询退款单失败: %v", err) } if latest.RetryCount != maxRetryCount { t.Fatalf("RetryCount = %d, want %d", latest.RetryCount, maxRetryCount) } if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) { t.Fatalf("LastRetryAt = %v, want %v", latest.LastRetryAt, now) } if latest.NextRetryAt != nil { t.Fatalf("NextRetryAt = %v, want nil after max retry", latest.NextRetryAt) } } func TestResetRetryClearsRetryFields(t *testing.T) { db := setupRefundRetryTestDB(t) job := New(db, nil, zap.NewNop(), nil) now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) next := now.Add(time.Hour) row := model.PaymentOrder{ PaymentNo: "PAY202606140102", OrderID: 1, OrderNo: "ORD202606140102", UserID: 2, Provider: "mock", ThirdOrderID: "REF202606140102", BizType: "admin_refund", Status: "refunded", RetryCount: 3, LastRetryAt: &now, NextRetryAt: &next, } if err := db.Create(&row).Error; err != nil { t.Fatalf("创建退款单失败: %v", err) } if err := job.resetRetry(t.Context(), row.ID); err != nil { t.Fatalf("resetRetry() error = %v", err) } var latest model.PaymentOrder if err := db.First(&latest, row.ID).Error; err != nil { t.Fatalf("查询退款单失败: %v", err) } if latest.RetryCount != 0 || latest.LastRetryAt != nil || latest.NextRetryAt != nil { t.Fatalf("retry fields = count:%d last:%v next:%v, want zero/nil", latest.RetryCount, latest.LastRetryAt, latest.NextRetryAt) } } // createArbitrationOrphanFixture 构造一个仲裁孤儿订单:已支付原单 + 仲裁已结案但退款单缺失。 func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { t.Helper() order := model.RentalOrder{ OrderNo: "ORD" + suffix, ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 2, RentAmountCent: 1000, DepositAmountCent: 500, Status: "closed", HandoffStatus: "arbitrated", SettlementStatus: settlementStatus, RefundStatus: "pending", RefundAmountCent: 800, UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 } if err := db.Create(&order).Error; err != nil { t.Fatalf("create order failed: %v", err) } originalPayment := model.PaymentOrder{ PaymentNo: "PAY" + suffix, OrderID: order.ID, OrderNo: order.OrderNo, UserID: 2, Provider: "mock", ThirdOrderID: "TPAY" + suffix, AmountCent: 1500, BizType: "order_pay", Status: "paid", } if err := db.Create(&originalPayment).Error; err != nil { t.Fatalf("create original payment failed: %v", err) } return order } // TestAutoRebuildArbitrationOrphan 验证仲裁孤儿订单被自动补建退款单。 func TestAutoRebuildArbitrationOrphan(t *testing.T) { db := setupRefundRetryTestDB(t) repo := payment.NewRepository(db, nil, nil) job := New(db, nil, zap.NewNop(), repo) order := createArbitrationOrphanFixture(t, db, "ARB001", "arbitrated") now := time.Now() missing, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now) if err != nil { t.Fatalf("warnMissingRefundOrders() error = %v", err) } if missing != 1 { t.Fatalf("missing = %d, want 1", missing) } if rebuilt != 1 { t.Fatalf("rebuilt = %d, want 1", rebuilt) } // 断言:payment_orders 新增一条 arbitration_refund 退款单 var refunds []model.PaymentOrder if err := db.Where("order_id = ? AND biz_type = ?", order.ID, "arbitration_refund").Find(&refunds).Error; err != nil { t.Fatalf("query refund failed: %v", err) } if len(refunds) != 1 { t.Fatalf("arbitration_refund records = %d, want 1", len(refunds)) } if refunds[0].AmountCent != 800 { t.Fatalf("refund amount = %d, want 800", refunds[0].AmountCent) } } // TestNoRebuildForNonArbitrationOrphan 验证非仲裁孤儿(settlement_status 非 arbitrated)不补建,仅告警。 func TestNoRebuildForNonArbitrationOrphan(t *testing.T) { db := setupRefundRetryTestDB(t) repo := payment.NewRepository(db, nil, nil) job := New(db, nil, zap.NewNop(), repo) order := createArbitrationOrphanFixture(t, db, "ARB002", "settled") // 非仲裁结算 now := time.Now() missing, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now) if err != nil { t.Fatalf("warnMissingRefundOrders() error = %v", err) } if missing != 1 { t.Fatalf("missing = %d, want 1", missing) } if rebuilt != 0 { t.Fatalf("rebuilt = %d, want 0 (non-arbitration orphan should not rebuild)", rebuilt) } var refunds []model.PaymentOrder if err := db.Where("order_id = ? AND biz_type = ?", order.ID, "arbitration_refund").Find(&refunds).Error; err != nil { t.Fatalf("query refund failed: %v", err) } if len(refunds) != 0 { t.Fatalf("arbitration_refund records = %d, want 0 (no rebuild)", len(refunds)) } } // TestRebuildArbitrationOrphanIdempotent 验证连续两次扫描的幂等性。 // mock 模式下首次补建直接置 refunded,订单 refund_status 变更后第二次扫描不再命中, // 退款单始终只有一条。真实非 mock 场景同理:首次补建留下 payment 记录, // 第二次扫描 count>0 直接跳过,不会重复建单。 func TestRebuildArbitrationOrphanIdempotent(t *testing.T) { db := setupRefundRetryTestDB(t) repo := payment.NewRepository(db, nil, nil) job := New(db, nil, zap.NewNop(), repo) order := createArbitrationOrphanFixture(t, db, "ARB003", "arbitrated") now := time.Now() // 第一次:补建成功 if _, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now); err != nil || rebuilt != 1 { t.Fatalf("first run: rebuilt=%d err=%v, want rebuilt=1", rebuilt, err) } // 第二次:mock 模式下第一次补建已置 refunded,订单 refund_status 变更, // warnMissingRefundOrders 的扫描条件(refund_status IN pending/refunding)不再命中 → missing=0 missing, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now) if err != nil { t.Fatalf("second run error = %v", err) } if missing != 0 { t.Fatalf("second run missing = %d, want 0 (order no longer orphan after rebuild)", missing) } if rebuilt != 0 { t.Fatalf("second run rebuilt = %d, want 0", rebuilt) } // 断言:退款单仍只有一条 arbitration_refund var count int64 if err := db.Model(&model.PaymentOrder{}).Where("order_id = ? AND biz_type = ?", order.ID, "arbitration_refund").Count(&count).Error; err != nil { t.Fatalf("count refund failed: %v", err) } if count != 1 { t.Fatalf("arbitration_refund count = %d, want 1 (idempotent)", count) } }