package reconcile_test import ( "bytes" "context" "log" "strings" "testing" "time" "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/reconcile" "github.com/wangjia/pay/internal/store" ) // TestRefundApplyTaskSelfHealsStuckPaidOrder 钉住 P4 T3 review 的崩溃窗口: // MarkRefundStatus 把退款翻成 succeeded 后、调用方在调 ApplyRefundToOrder 前崩溃, // 订单卡在 paid。RefundApplyTask 应重算 succeeded 之和并重新 apply,自愈成 // refunded(全额)。 func TestRefundApplyTaskSelfHealsStuckPaidOrder(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "STUCK-1", AmountMinor: 10000, Currency: "CNY", Status: model.OrderPaidV2}); err != nil { t.Fatal(err) } completedAt := time.Now() if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-stuck-1", OutTradeNo: "STUCK-1", AttemptProviderRef: "STUCK-1", AmountMinor: 10000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &completedAt}); err != nil { t.Fatal(err) } task := reconcile.RefundApplyTask(orders, refunds, 48*time.Hour, time.Now, 200) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } o, err := orders.GetOrder("STUCK-1") if err != nil || o.Status != model.OrderRefundedV2 { t.Fatalf("应自愈为 refunded, got %+v, err=%v", o, err) } } // TestRefundApplyTaskPartialStaysPartial 部分退款(succeeded 之和 < 订单金额)应自愈为 // partially_refunded,而非误判 fully。 func TestRefundApplyTaskPartialStaysPartial(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "STUCK-2", AmountMinor: 10000, Currency: "CNY", Status: model.OrderPaidV2}); err != nil { t.Fatal(err) } completedAt := time.Now() if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-stuck-2", OutTradeNo: "STUCK-2", AttemptProviderRef: "STUCK-2", AmountMinor: 4000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &completedAt}); err != nil { t.Fatal(err) } task := reconcile.RefundApplyTask(orders, refunds, 48*time.Hour, time.Now, 200) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } o, err := orders.GetOrder("STUCK-2") if err != nil || o.Status != model.OrderPartRefundedV2 { t.Fatalf("应自愈为 partially_refunded, got %+v, err=%v", o, err) } } // TestRefundApplyTaskIdempotentNoOpOnRerun 幂等:自愈一次后重跑不应报错、不应再次 // "翻转"(状态已一致,ApplyRefundToOrder 不应被重复触发出错误的副作用)。 func TestRefundApplyTaskIdempotentNoOpOnRerun(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "STUCK-3", AmountMinor: 5000, Currency: "CNY", Status: model.OrderPaidV2}); err != nil { t.Fatal(err) } completedAt := time.Now() if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-stuck-3", OutTradeNo: "STUCK-3", AttemptProviderRef: "STUCK-3", AmountMinor: 5000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &completedAt}); err != nil { t.Fatal(err) } task := reconcile.RefundApplyTask(orders, refunds, 48*time.Hour, time.Now, 200) for i := 0; i < 3; i++ { if err := task(context.Background()); err != nil { t.Fatalf("run %d: %v", i, err) } } o, err := orders.GetOrder("STUCK-3") if err != nil || o.Status != model.OrderRefundedV2 { t.Fatalf("重跑后仍应 refunded, got %+v, err=%v", o, err) } } // TestRefundApplyTaskConsistentOrderUntouched 已一致(无 succeeded 退款,或订单已是 // 该退款对应的终态)的订单不应被误触发。 func TestRefundApplyTaskConsistentOrderUntouched(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "OK-1", AmountMinor: 1000, Currency: "CNY", Status: model.OrderPaidV2}); err != nil { t.Fatal(err) } // 有一笔 processing(未 succeeded)退款,不该触发自愈。 if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-ok-1", OutTradeNo: "OK-1", AttemptProviderRef: "OK-1", AmountMinor: 500, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business"}); err != nil { t.Fatal(err) } task := reconcile.RefundApplyTask(orders, refunds, 48*time.Hour, time.Now, 200) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } o, err := orders.GetOrder("OK-1") if err != nil || o.Status != model.OrderPaidV2 { t.Fatalf("无 succeeded 退款不应被翻转, got %+v, err=%v", o, err) } } // TestRefundApplyTaskLookbackWindowExcludesStaleSucceeded 钉住 LIMIT 尾部饥饿修复: // succeeded 退款侧的候选查询带回溯窗,completed_at 超窗的行不再入候选(窗外订单卡在 // paid 不被自愈),只有窗内的才被扫到并自愈。 func TestRefundApplyTaskLookbackWindowExcludesStaleSucceeded(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) lookback := 48 * time.Hour if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "WIN-OLD", AmountMinor: 10000, Currency: "CNY", Status: model.OrderPaidV2}); err != nil { t.Fatal(err) } staleCompleted := now.Add(-lookback - time.Hour) // 窗外 if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-win-old", OutTradeNo: "WIN-OLD", AttemptProviderRef: "WIN-OLD", AmountMinor: 10000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &staleCompleted}); err != nil { t.Fatal(err) } if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "WIN-NEW", AmountMinor: 10000, Currency: "CNY", Status: model.OrderPaidV2}); err != nil { t.Fatal(err) } freshCompleted := now.Add(-time.Hour) // 窗内 if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-win-new", OutTradeNo: "WIN-NEW", AttemptProviderRef: "WIN-NEW", AmountMinor: 10000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &freshCompleted}); err != nil { t.Fatal(err) } task := reconcile.RefundApplyTask(orders, refunds, lookback, func() time.Time { return now }, 200) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } oldOrder, err := orders.GetOrder("WIN-OLD") if err != nil || oldOrder.Status != model.OrderPaidV2 { t.Fatalf("窗外行不应入候选、不应被自愈, got %+v, err=%v", oldOrder, err) } newOrder, err := orders.GetOrder("WIN-NEW") if err != nil || newOrder.Status != model.OrderRefundedV2 { t.Fatalf("窗内行应被自愈为 refunded, got %+v, err=%v", newOrder, err) } } // TestRefundApplyTaskLookbackWindowExcludesStaleRefundingOrder 同上,针对 // refunding/partially_refunded 订单侧的候选查询:updated_at 超窗的订单不再入候选 // (即便它挂着可自愈的 succeeded 退款,只要两路查询都被窗口挡在外面就不会被扫到)。 func TestRefundApplyTaskLookbackWindowExcludesStaleRefundingOrder(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) lookback := 48 * time.Hour staleAt := now.Add(-lookback - time.Hour) // 窗外 freshAt := now.Add(-time.Hour) // 窗内 // 两笔订单的 succeeded 退款 completed_at 都设窗外,隔离掉「succeeded 侧候选」这条路径 // ——只让 refunding/partially_refunded 订单侧的时间窗单独决定是否入候选。 if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "ORD-OLD", AmountMinor: 10000, Currency: "CNY", Status: model.OrderRefundingV2}); err != nil { t.Fatal(err) } if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-ord-old", OutTradeNo: "ORD-OLD", AttemptProviderRef: "ORD-OLD", AmountMinor: 10000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &staleAt}); err != nil { t.Fatal(err) } if err := db.Model(&model.OrderV2{}).Where("out_trade_no = ?", "ORD-OLD").Update("updated_at", staleAt).Error; err != nil { t.Fatal(err) } if err := orders.CreateOrder(&model.OrderV2{OutTradeNo: "ORD-NEW", AmountMinor: 10000, Currency: "CNY", Status: model.OrderRefundingV2}); err != nil { t.Fatal(err) } if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-ord-new", OutTradeNo: "ORD-NEW", AttemptProviderRef: "ORD-NEW", AmountMinor: 10000, Currency: "CNY", Status: model.RefundSucceeded, InitiatedBy: "business", CompletedAt: &staleAt}); err != nil { t.Fatal(err) } if err := db.Model(&model.OrderV2{}).Where("out_trade_no = ?", "ORD-NEW").Update("updated_at", freshAt).Error; err != nil { t.Fatal(err) } task := reconcile.RefundApplyTask(orders, refunds, lookback, func() time.Time { return now }, 200) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } oldOrder, err := orders.GetOrder("ORD-OLD") if err != nil || oldOrder.Status != model.OrderRefundingV2 { t.Fatalf("窗外订单不应入候选、不应被自愈, got %+v, err=%v", oldOrder, err) } newOrder, err := orders.GetOrder("ORD-NEW") if err != nil || newOrder.Status != model.OrderRefundedV2 { t.Fatalf("窗内订单应被自愈为 refunded, got %+v, err=%v", newOrder, err) } } // TestRefundStuckAlertTaskLogsWarnWithoutChangingState 卡滞 processing/manual_pending // 超阈值只应打 WARN 日志,不改任何状态(观测型)。 func TestRefundStuckAlertTaskLogsWarnWithoutChangingState(t *testing.T) { db := model.OpenTestDB(t) refunds := store.NewRefundStore(db) now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-stall-1", OutTradeNo: "STALL-1", AmountMinor: 100, Currency: "CNY", Status: model.RefundProcessing}); err != nil { t.Fatal(err) } if err := db.Model(&model.Refund{}).Where("refund_id = ?", "rf-stall-1"). Update("updated_at", now.Add(-45*time.Minute)).Error; err != nil { t.Fatal(err) } var buf bytes.Buffer orig := log.Writer() log.SetOutput(&buf) defer log.SetOutput(orig) task := reconcile.RefundStuckAlertTask(refunds, 30*time.Minute, func() time.Time { return now }) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } if !strings.Contains(buf.String(), "rf-stall-1") || !strings.Contains(buf.String(), "WARN") { t.Fatalf("应打 WARN 日志含 refund_id, got: %s", buf.String()) } r, err := refunds.GetRefund("rf-stall-1") if err != nil || r.Status != model.RefundProcessing { t.Fatalf("告警不应改状态, got %+v, err=%v", r, err) } } // TestRefundStuckAlertTaskSkipsFreshAndTerminal 未超阈值 / 已终态的退款不应被告警。 func TestRefundStuckAlertTaskSkipsFreshAndTerminal(t *testing.T) { db := model.OpenTestDB(t) refunds := store.NewRefundStore(db) now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-fresh", OutTradeNo: "FRESH-1", AmountMinor: 100, Currency: "CNY", Status: model.RefundProcessing}); err != nil { t.Fatal(err) } if err := refunds.CreateRefund(&model.Refund{RefundID: "rf-done", OutTradeNo: "DONE-1", AmountMinor: 100, Currency: "CNY", Status: model.RefundSucceeded}); err != nil { t.Fatal(err) } if err := db.Model(&model.Refund{}).Where("refund_id = ?", "rf-done"). Update("updated_at", now.Add(-2*time.Hour)).Error; err != nil { t.Fatal(err) } var buf bytes.Buffer orig := log.Writer() log.SetOutput(&buf) defer log.SetOutput(orig) task := reconcile.RefundStuckAlertTask(refunds, 30*time.Minute, func() time.Time { return now }) if err := task(context.Background()); err != nil { t.Fatalf("task: %v", err) } if strings.Contains(buf.String(), "rf-fresh") || strings.Contains(buf.String(), "rf-done") { t.Fatalf("未超阈值/已终态不应告警, got: %s", buf.String()) } }