仲裁退款失败补偿: 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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user