Files
hfb_sys/backend/internal/jobs/refundretry/job_test.go
T
yml2213 2efcede800 优化生产日志体积:访问降噪、压缩清理与退款告警节流
跳过轮询/封面等热路径成功请求,按日 gzip 并保留可配天数,退款 max-retry 汇总告警。
2026-07-13 22:02:20 +08:00

302 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{}); 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)
}
}