From ae4297fec7a03f89bae2eb103a35036c8771a2a0 Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 14 Jun 2026 16:17:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=B2=E8=A3=81=E9=80=80=E6=AC=BE=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=A1=A5=E5=81=BF:=20refundretry=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A1=A5=E5=BB=BA=E4=BB=B2=E8=A3=81=E5=AD=A4=E5=84=BF=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: dispute/arbitration.go 事务提交后 startRefundBestEffort 失败只 log.Printf 吞错, 号主收入已入账但租客退款单未建, 订单卡在 refund_status=pending 的孤儿状态。原 refundretry 的 warnMissingRefundOrders 只告警不补建, 导致资金口径不平需人工兜底。 修复: 升级 warnMissingRefundOrders, 对 settlement_status="arbitrated" 的孤儿订单自动调 StartRefund 补建 (biz_type 固定 arbitration_refund, 金额用 order.RefundAmountCent)。 幂等保证: 复用 StartRefund 内部 prepareRefundOrder 的 (order_id, biz_type) 查重作为天然幂等闸, 重复触发走 existing 分支只同步状态不重复建单。渠道失败 → markRefundFailed 留记录 + 订单 refund_status=failed → 下一轮不再视为孤儿。 范围限定: 仅仲裁孤儿自动补建 (biz_type 无歧义)。非仲裁孤儿 (取消/客服关闭/结账) biz_type 无法从订单状态可靠区分, 维持只告警。 新增测试: - TestAutoRebuildArbitrationOrphan: 仲裁孤儿补建成功 - TestNoRebuildForNonArbitrationOrphan: 非仲裁孤儿不补建 - TestRebuildArbitrationOrphanIdempotent: 连续扫描幂等 防御: payments==nil 时跳过补建并告警, 避免单测/未来构造 Job 时 panic。 go build / vet / test 通过。 --- backend/internal/jobs/refundretry/job.go | 44 +++++- backend/internal/jobs/refundretry/job_test.go | 140 ++++++++++++++++++ 2 files changed, 178 insertions(+), 6 deletions(-) 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) + } +}