仲裁退款失败补偿: refundretry 自动补建仲裁孤儿退款单

问题: 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 通过。
This commit is contained in:
yml
2026-06-14 16:17:19 +08:00
parent 16b171442a
commit ae4297fec7
2 changed files with 178 additions and 6 deletions
+38 -6
View File
@@ -119,12 +119,16 @@ func (j *Job) run(ctx context.Context) {
if err != nil { if err != nil {
j.logger.Warn("refund retry job sync payments failed", zap.Error(err)) 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 { if err != nil {
j.logger.Warn("refund retry job scan missing refund orders failed", zap.Error(err)) j.logger.Warn("refund retry job scan missing refund orders failed", zap.Error(err))
} }
if processed > 0 || missing > 0 { 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 var rows []model.RentalOrder
err := j.db.WithContext(ctx). 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)). 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). Limit(100).
Find(&rows).Error Find(&rows).Error
if err != nil { if err != nil {
return 0, err return 0, 0, err
} }
missing := 0 missing := 0
rebuilt := 0
for _, row := range rows { for _, row := range rows {
var count int64 var count int64
if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}). if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}).
Where("order_id = ? AND biz_type IN ?", row.ID, payment.RefundBizTypes()). Where("order_id = ? AND biz_type IN ?", row.ID, payment.RefundBizTypes()).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return missing, err return missing, rebuilt, err
} }
if count > 0 { if count > 0 {
continue continue
} }
missing++ 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", j.logger.Warn("refund order missing payment record, need manual check",
zap.Uint64("order_id", row.ID), zap.Uint64("order_id", row.ID),
zap.String("order_no", row.OrderNo), zap.String("order_no", row.OrderNo),
zap.String("refund_status", row.RefundStatus), zap.String("refund_status", row.RefundStatus),
zap.String("settlement_status", row.SettlementStatus),
zap.Int64("refund_amount_cent", row.RefundAmountCent), zap.Int64("refund_amount_cent", row.RefundAmountCent),
) )
} }
return missing, nil return missing, rebuilt, nil
} }
@@ -5,6 +5,7 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/payment"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/driver/sqlite" "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) 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)
}
}