diff --git a/backend/internal/jobs/refundretry/job.go b/backend/internal/jobs/refundretry/job.go index cba7d82..06a74c8 100644 --- a/backend/internal/jobs/refundretry/job.go +++ b/backend/internal/jobs/refundretry/job.go @@ -119,12 +119,16 @@ func (j *Job) run(ctx context.Context) { if err != nil { j.logger.Warn("refund retry job sync payments failed", zap.Error(err)) } - missing, err := j.warnMissingRefundOrders(ctx, now) + missing, rebuilt, err := j.warnMissingRefundOrders(ctx, now) if err != nil { j.logger.Warn("refund retry job scan missing refund orders failed", zap.Error(err)) } if processed > 0 || missing > 0 { - j.logger.Info("refund retry job finished", zap.Int("processed", processed), zap.Int("missing_refund_orders", missing)) + j.logger.Info("refund retry job finished", + zap.Int("processed", processed), + zap.Int("missing_refund_orders", missing), + zap.Int("rebuilt_arbitration_refunds", rebuilt), + ) } } @@ -232,7 +236,7 @@ func (j *Job) warnMaxRetryRefunds(ctx context.Context, now time.Time) { } } -func (j *Job) warnMissingRefundOrders(ctx context.Context, now time.Time) (int, error) { +func (j *Job) warnMissingRefundOrders(ctx context.Context, now time.Time) (int, int, error) { var rows []model.RentalOrder err := j.db.WithContext(ctx). Where("refund_status IN ? AND refund_amount_cent > 0 AND updated_at <= ?", []string{"pending", "refunding"}, now.Add(-10*time.Minute)). @@ -240,26 +244,54 @@ func (j *Job) warnMissingRefundOrders(ctx context.Context, now time.Time) (int, Limit(100). Find(&rows).Error if err != nil { - return 0, err + return 0, 0, err } missing := 0 + rebuilt := 0 for _, row := range rows { var count int64 if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}). Where("order_id = ? AND biz_type IN ?", row.ID, payment.RefundBizTypes()). Count(&count).Error; err != nil { - return missing, err + return missing, rebuilt, err } if count > 0 { continue } missing++ + // 仲裁孤儿:settlement_status="arbitrated" 是仲裁事务无条件写入的标志 + // (arbitration.go:61),biz_type 可确定还原为 arbitration_refund,自动补建。 + // 非仲裁孤儿 biz_type 无法从订单状态可靠区分,维持只告警。 + if row.SettlementStatus == "arbitrated" { + if j.payments == nil { + j.logger.Warn("auto rebuild arbitration refund skipped, payment repository unavailable", + zap.Uint64("order_id", row.ID), + zap.Int64("refund_amount_cent", row.RefundAmountCent), + ) + continue + } + if _, err := j.payments.StartRefund(ctx, row.ID, row.RefundAmountCent, "arbitration_refund", "仲裁退款补偿补建"); err != nil { + j.logger.Warn("auto rebuild arbitration refund failed, need manual check", + zap.Uint64("order_id", row.ID), + zap.Int64("refund_amount_cent", row.RefundAmountCent), + zap.Error(err), + ) + } else { + rebuilt++ + j.logger.Info("auto rebuild arbitration refund submitted", + zap.Uint64("order_id", row.ID), + zap.Int64("refund_amount_cent", row.RefundAmountCent), + ) + } + continue + } j.logger.Warn("refund order missing payment record, need manual check", zap.Uint64("order_id", row.ID), zap.String("order_no", row.OrderNo), zap.String("refund_status", row.RefundStatus), + zap.String("settlement_status", row.SettlementStatus), zap.Int64("refund_amount_cent", row.RefundAmountCent), ) } - return missing, nil + return missing, rebuilt, nil } diff --git a/backend/internal/jobs/refundretry/job_test.go b/backend/internal/jobs/refundretry/job_test.go index 9b7b118..0e5e1cd 100644 --- a/backend/internal/jobs/refundretry/job_test.go +++ b/backend/internal/jobs/refundretry/job_test.go @@ -5,6 +5,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/payment" "go.uber.org/zap" "gorm.io/driver/sqlite" @@ -115,3 +116,142 @@ func TestResetRetryClearsRetryFields(t *testing.T) { 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) + } +}