diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 3f56399..c97c1a5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -18,6 +18,7 @@ import ( "hfb_sys/backend/internal/jobs/refundretry" "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/modules/adminauth" + "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/payment" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/router" @@ -98,11 +99,17 @@ func main() { jobCtx, stopJobs := context.WithCancel(context.Background()) defer stopJobs() if deps.DB != nil { - ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx) + var paymentRepo *payment.Repository + if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil { + // 超时取消前的渠道核对要把收款推进到订单上,所以这里必须带真实的订单仓库, + // 否则入账会在依赖缺失处失败,支付单仍旧对不上。 + jobOrderRepo := order.NewRepository(deps.DB) + paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, jobOrderRepo, payment.WithLogger(logger)) + } + ordertimeout.New(deps.DB, deps.Redis, logger, paymentProbeOrNil(paymentRepo)).Start(jobCtx) fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx) distributionsettlement.New(deps.DB, deps.Redis, logger).Start(jobCtx) - if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil { - paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger)) + if paymentRepo != nil { refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx) } } @@ -146,6 +153,14 @@ func logAuthRuntimeIdentity(logger *zap.Logger, cfg config.Config) { ) } +// paymentProbeOrNil 避免把类型化的 nil 指针当成非空接口传给定时任务。 +func paymentProbeOrNil(repo *payment.Repository) ordertimeout.ChannelPaymentProbe { + if repo == nil { + return nil + } + return repo +} + func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *zap.Logger) *paymentconfig.Repository { encryptionKey := cfg.PaymentConfigEncryptionKey var encryptor paymentconfig.Encryptor diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 63ac7e0..e9e3a3c 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -29,12 +29,18 @@ const ( maxRenterRetentionDaysAfterOrderEnd = 7 ) +// ChannelPaymentProbe 由支付模块实现,用于取消前跟渠道核对订单是否已收款。 +type ChannelPaymentProbe interface { + ConfirmChannelPaidBeforeClose(ctx context.Context, orderID uint64) (paid bool, err error) +} + type Job struct { - db *gorm.DB - redis *redis.Client - logger *zap.Logger - interval time.Duration - instanceID string + db *gorm.DB + redis *redis.Client + logger *zap.Logger + paymentProbe ChannelPaymentProbe + interval time.Duration + instanceID string } type thresholds struct { @@ -46,13 +52,14 @@ type thresholds struct { RenterRetentionDaysAfterOrderEnd int } -func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job { +func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, paymentProbe ChannelPaymentProbe) *Job { return &Job{ - db: db, - redis: redisClient, - logger: logger, - interval: time.Minute, - instanceID: newInstanceID(), + db: db, + redis: redisClient, + logger: logger, + paymentProbe: paymentProbe, + interval: time.Minute, + instanceID: newInstanceID(), } } @@ -258,6 +265,11 @@ func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cf } count := 0 for _, row := range rows { + // 租客常在超时的最后几十秒完成付款,渠道结果却在取消之后才到。 + // 先跟渠道核对一次,已收款的就让它正常入账,这一轮不取消。 + if j.channelPaidBeforeTimeout(ctx, row) { + continue + } if err := j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.ID).Error; err != nil { @@ -314,6 +326,30 @@ func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cf return count, nil } +// channelPaidBeforeTimeout 超时取消前跟渠道核对收款,返回 true 表示已收款、本轮跳过取消。 +// 渠道异常时按未收款继续取消,否则渠道一抖所有超时订单都会堆积。 +func (j *Job) channelPaidBeforeTimeout(ctx context.Context, order model.RentalOrder) bool { + if j.paymentProbe == nil { + return false + } + paid, err := j.paymentProbe.ConfirmChannelPaidBeforeClose(ctx, order.ID) + if err != nil { + j.logger.Warn("支付超时取消前核对渠道失败", + zap.Uint64("order_id", order.ID), + zap.String("order_no", order.OrderNo), + zap.Error(err), + ) + return false + } + if paid { + j.logger.Warn("支付超时取消前核对到渠道已收款,放弃取消", + zap.Uint64("order_id", order.ID), + zap.String("order_no", order.OrderNo), + ) + } + return paid +} + // closePendingOrderPayments 在系统自动取消订单时同步关闭未完成支付单,保持订单和支付流水状态一致。 func closePendingOrderPayments(tx *gorm.DB, orderID uint64, source string) error { raw, err := json.Marshal(map[string]string{ diff --git a/backend/internal/jobs/ordertimeout/job_test.go b/backend/internal/jobs/ordertimeout/job_test.go index b82bfce..77838fa 100644 --- a/backend/internal/jobs/ordertimeout/job_test.go +++ b/backend/internal/jobs/ordertimeout/job_test.go @@ -33,7 +33,7 @@ func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB { func TestHandleEndedOrderRenterRetention(t *testing.T) { db := setupOrderTimeoutTestDB(t) - job := New(db, nil, zap.NewNop()) + job := New(db, nil, zap.NewNop(), nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) oldSettledAt := now.AddDate(0, 0, -6) @@ -151,7 +151,7 @@ func TestHandleEndedOrderRenterRetention(t *testing.T) { func TestHandleRenterConfirmTimeoutUsesRefreshedStageTime(t *testing.T) { db := setupOrderTimeoutTestDB(t) - job := New(db, nil, zap.NewNop()) + job := New(db, nil, zap.NewNop(), nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) oldHandoffAt := now.Add(-2 * time.Hour) refreshedAt := now.Add(-5 * time.Minute) @@ -202,7 +202,7 @@ func TestHandleRenterConfirmTimeoutUsesRefreshedStageTime(t *testing.T) { func TestHandleOwnerReturnConfirmTimeoutUsesStageTime(t *testing.T) { db := setupOrderTimeoutTestDB(t) - job := New(db, nil, zap.NewNop()) + job := New(db, nil, zap.NewNop(), nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) oldUpdatedAt := now.Add(-2 * time.Hour) refreshedAt := now.Add(-5 * time.Minute) diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index d70de9d..1428dec 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -14,6 +14,11 @@ import ( ) func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + // 客服常以「支付失败」关闭待支付订单,但渠道结果可能只是还没回来。 + // 先核对一次,已收款的订单直接推进,避免关单后钱收了却查无记录。 + if r.pendingPaymentOrderPaidAtChannelForAdmin(ctx, orderID) { + return ErrOrderPaidAtChannel + } var refund *refundAction err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { assets, err := r.lockOrderAssets(tx, orderID) @@ -48,6 +53,20 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin return err } refund = action + } else { + // 待支付订单照理没有款项,但渠道钱款迟到时支付单会被记成已支付。 + // 按「已收款减去已退款」退,漏掉就等于白收用户一笔钱。 + outstandingCent, err := outstandingPaidOrderPaymentCentTx(tx, order.ID) + if err != nil { + return err + } + if outstandingCent > 0 { + action, err := r.prepareRefund(order, outstandingCent, refundBizAdminClose, "客服关闭订单原路退款") + if err != nil { + return err + } + refund = action + } } if err := notification.Append(tx, notification.Entry{ diff --git a/backend/internal/modules/order/channel_paid_guard_test.go b/backend/internal/modules/order/channel_paid_guard_test.go new file mode 100644 index 0000000..03b38d2 --- /dev/null +++ b/backend/internal/modules/order/channel_paid_guard_test.go @@ -0,0 +1,252 @@ +package order + +import ( + "context" + "errors" + "testing" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// TestCancelAbortsWhenChannelAlreadyPaid 租客一边点取消、一边在渠道付成功时, +// 取消必须让位给支付,否则订单没了而钱已经到账。 +func TestCancelAbortsWhenChannelAlreadyPaid(t *testing.T) { + db := setupOrderTestDB(t) + probed := uint64(0) + repo := NewRepository(db, Dependencies{ + PaymentProbe: ChannelPaymentProbeFunc(func(_ context.Context, orderID uint64) (bool, error) { + probed = orderID + return true, nil + }), + }) + _, renter, order := createPendingPaymentOrderFixture(t, db, "CPG1") + + err := repo.Cancel(t.Context(), renter.ID, order.ID) + if !errors.Is(err, ErrOrderPaidAtChannel) { + t.Fatalf("Cancel() error = %v, want ErrOrderPaidAtChannel", err) + } + if probed != order.ID { + t.Fatalf("probed order id = %d, want %d", probed, order.ID) + } + + var saved model.RentalOrder + if findErr := db.First(&saved, order.ID).Error; findErr != nil { + t.Fatalf("find order failed: %v", findErr) + } + if saved.Status != orderStatusPendingPayment { + t.Fatalf("order status = %q, want pending_payment", saved.Status) + } +} + +// TestCancelProceedsWhenChannelProbeFails 渠道查不通不能让取消卡住, +// 否则渠道一抖所有待支付订单都取消不掉;真漏掉的由回调兜底和对账任务收尾。 +func TestCancelProceedsWhenChannelProbeFails(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + PaymentProbe: ChannelPaymentProbeFunc(func(context.Context, uint64) (bool, error) { + return false, errors.New("channel timeout") + }), + }) + _, renter, order := createPendingPaymentOrderFixture(t, db, "CPG2") + + if err := repo.Cancel(t.Context(), renter.ID, order.ID); err != nil { + t.Fatalf("Cancel() error = %v, want nil", err) + } + + var saved model.RentalOrder + if findErr := db.First(&saved, order.ID).Error; findErr != nil { + t.Fatalf("find order failed: %v", findErr) + } + if saved.Status != orderStatusCancelled { + t.Fatalf("order status = %q, want cancelled", saved.Status) + } +} + +// TestAdminCloseAbortsWhenChannelAlreadyPaid 客服常以「支付失败」关闭待支付订单, +// 但渠道结果可能只是还没回来。关单前核对到已收款就不能关。 +func TestAdminCloseAbortsWhenChannelAlreadyPaid(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db, Dependencies{ + PaymentProbe: ChannelPaymentProbeFunc(func(context.Context, uint64) (bool, error) { + return true, nil + }), + }) + _, _, order := createPendingPaymentOrderFixture(t, db, "CPG3") + + err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "支付失败"}, AuditMeta{}) + if !errors.Is(err, ErrOrderPaidAtChannel) { + t.Fatalf("AdminClose() error = %v, want ErrOrderPaidAtChannel", err) + } +} + +// TestAdminCloseRefundsAlreadyPaidPendingPaymentOrder 待支付订单照理没有款项, +// 所以原先关单一律不退款。但渠道钱款迟到时支付单会被记成已支付, +// 这正是本次 1358.70 元收了却查无退款记录的直接原因:关单必须按实际收款退。 +func TestAdminCloseRefundsAlreadyPaidPendingPaymentOrder(t *testing.T) { + db := setupOrderTestDB(t) + var startedAmount int64 + var startedBizType string + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(_ context.Context, _ uint64, refundAmountCent int64, bizType string, _ string) (string, error) { + startedAmount = refundAmountCent + startedBizType = bizType + return "refunding", nil + }), + }) + _, renter, order := createPendingPaymentOrderFixture(t, db, "CPG4") + createOrderPayment(t, db, order, renter.ID, "CPG4", "order_pay", "paid", 135870) + + if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "支付失败"}, AuditMeta{}); err != nil { + t.Fatalf("AdminClose() error = %v", err) + } + if startedAmount != 135870 { + t.Fatalf("refund amount = %d, want 135870", startedAmount) + } + if startedBizType != refundBizAdminClose { + t.Fatalf("refund biz type = %q, want %q", startedBizType, refundBizAdminClose) + } + + var saved model.RentalOrder + if findErr := db.First(&saved, order.ID).Error; findErr != nil { + t.Fatalf("find order failed: %v", findErr) + } + if saved.RefundAmountCent != 135870 { + t.Fatalf("order refund amount = %d, want 135870", saved.RefundAmountCent) + } +} + +// TestAdminCloseSkipsRefundWhenAlreadyRefunded 已经退过的订单,关单不能再退一次。 +func TestAdminCloseSkipsRefundWhenAlreadyRefunded(t *testing.T) { + db := setupOrderTestDB(t) + refundStarted := false + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + refundStarted = true + return "refunding", nil + }), + }) + _, renter, order := createPendingPaymentOrderFixture(t, db, "CPG5") + createOrderPayment(t, db, order, renter.ID, "CPG5A", "order_pay", "paid", 135870) + createOrderPayment(t, db, order, renter.ID, "CPG5B", "admin_refund", "refunded", 135870) + + if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "支付失败"}, AuditMeta{}); err != nil { + t.Fatalf("AdminClose() error = %v", err) + } + if refundStarted { + t.Fatal("refund started again, want no duplicate refund") + } +} + +// TestAdminCloseRefundsRemainderWhenPartiallyRefunded 已退一部分时只退差额。 +func TestAdminCloseRefundsRemainderWhenPartiallyRefunded(t *testing.T) { + db := setupOrderTestDB(t) + var startedAmount int64 + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(_ context.Context, _ uint64, refundAmountCent int64, _ string, _ string) (string, error) { + startedAmount = refundAmountCent + return "refunding", nil + }), + }) + _, renter, order := createPendingPaymentOrderFixture(t, db, "CPG7") + createOrderPayment(t, db, order, renter.ID, "CPG7A", "order_pay", "paid", 135870) + createOrderPayment(t, db, order, renter.ID, "CPG7B", "admin_refund", "refunded", 35870) + + if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "支付失败"}, AuditMeta{}); err != nil { + t.Fatalf("AdminClose() error = %v", err) + } + if startedAmount != 100000 { + t.Fatalf("refund amount = %d, want 100000", startedAmount) + } +} + +// TestAdminCloseKeepsNoRefundForUnpaidPendingPaymentOrder 没收到钱的待支付订单照旧不退款。 +func TestAdminCloseKeepsNoRefundForUnpaidPendingPaymentOrder(t *testing.T) { + db := setupOrderTestDB(t) + refundStarted := false + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + refundStarted = true + return "refunding", nil + }), + }) + _, renter, order := createPendingPaymentOrderFixture(t, db, "CPG6") + createOrderPayment(t, db, order, renter.ID, "CPG6", "order_pay", "paying", 135870) + + if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "支付失败"}, AuditMeta{}); err != nil { + t.Fatalf("AdminClose() error = %v", err) + } + if refundStarted { + t.Fatal("refund started for an unpaid order, want none") + } +} + +func createPendingPaymentOrderFixture(t *testing.T, db *gorm.DB, suffix string) (model.User, model.User, model.RentalOrder) { + t.Helper() + owner := model.User{Phone: "1380" + suffix + "01"} + renter := model.User{Phone: "1390" + suffix + "02"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter failed: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "测试账号" + suffix, + Status: "published", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + listing := model.RentalListing{ + ListingNo: "LST" + suffix, + OwnerID: owner.ID, + AccountID: account.ID, + Status: "published", + ReviewStatus: "approved", + InTransaction: true, + PriceCent: 1000, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + order := model.RentalOrder{ + OrderNo: "ORD" + suffix, + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + RentAmountCent: 125870, + DepositAmountCent: 10000, + Status: orderStatusPendingPayment, + HandoffStatus: "none", + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + return owner, renter, order +} + +func createOrderPayment(t *testing.T, db *gorm.DB, order model.RentalOrder, userID uint64, suffix string, bizType string, status string, amountCent int64) model.PaymentOrder { + t.Helper() + payment := model.PaymentOrder{ + PaymentNo: "PAY" + suffix, + OrderID: order.ID, + OrderNo: order.OrderNo, + UserID: userID, + Provider: "leshua", + ThirdOrderID: "THIRD" + suffix, + ProviderOrderID: "PROVIDER" + suffix, + AmountCent: amountCent, + BizType: bizType, + Status: status, + } + if err := db.Create(&payment).Error; err != nil { + t.Fatalf("create payment failed: %v", err) + } + return payment +} diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index 2ad899b..0418caa 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -30,6 +30,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "order_cannot_pay", "当前订单不能支付") case errors.Is(err, ErrOrderCannotCancel): response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消") + case errors.Is(err, ErrOrderPaidAtChannel): + response.Error(c, http.StatusConflict, "order_paid_at_channel", "该订单已支付成功,请刷新后查看最新状态") case errors.Is(err, ErrOrderCannotHide): response.Error(c, http.StatusConflict, "order_cannot_hide", "只有已结束且款项处理完毕的订单可以删除") case errors.Is(err, ErrOrderCannotHandoff): diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 3995574..31ebaf8 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -428,6 +428,11 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint } func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) error { + // 租客可能一边点取消一边在渠道完成付款。先核对渠道,已收款就让支付正常入账, + // 不要把订单取消掉,否则钱收了订单却没了。 + if r.pendingPaymentOrderPaidAtChannel(ctx, userID, orderID) { + return ErrOrderPaidAtChannel + } err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). diff --git a/backend/internal/modules/order/refund.go b/backend/internal/modules/order/refund.go index 7f7c104..aa96900 100644 --- a/backend/internal/modules/order/refund.go +++ b/backend/internal/modules/order/refund.go @@ -7,6 +7,7 @@ import ( "hfb_sys/backend/internal/model" "go.uber.org/zap" + "gorm.io/gorm" ) func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) { @@ -27,6 +28,45 @@ func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, b }, nil } +// outstandingPaidOrderPaymentCentTx 算出订单实际收到、尚未退回的下单款项。 +// 口径是已支付的下单流水合计减去已退/在退的退款流水合计,重复关单不会重复退。 +func outstandingPaidOrderPaymentCentTx(tx *gorm.DB, orderID uint64) (int64, error) { + var paidCent int64 + if err := tx.Model(&model.PaymentOrder{}). + Where("order_id = ? AND biz_type = ? AND status = ?", orderID, "order_pay", "paid"). + Select("COALESCE(SUM(amount_cent), 0)").Scan(&paidCent).Error; err != nil { + return 0, err + } + if paidCent <= 0 { + return 0, nil + } + var refundedCent int64 + if err := tx.Model(&model.PaymentOrder{}). + Where("order_id = ? AND biz_type <> ? AND status IN ?", orderID, "order_pay", []string{"refunding", "refunded"}). + Select("COALESCE(SUM(amount_cent), 0)").Scan(&refundedCent).Error; err != nil { + return 0, err + } + if refundedCent >= paidCent { + return 0, nil + } + return paidCent - refundedCent, nil +} + +// pendingPaymentOrderPaidAtChannelForAdmin 在客服关单前核对待支付订单的渠道收款情况。 +func (r *Repository) pendingPaymentOrderPaidAtChannelForAdmin(ctx context.Context, orderID uint64) bool { + if r.paymentProbe == nil { + return false + } + var status string + err := r.db.WithContext(ctx).Model(&model.RentalOrder{}). + Where("id = ?", orderID). + Pluck("status", &status).Error + if err != nil || status != orderStatusPendingPayment { + return false + } + return r.channelPaidBeforeClose(ctx, orderID) +} + func (r *Repository) startRefundBestEffort(ctx context.Context, action *refundAction) { if action == nil || r.refundStarter == nil { return diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 515b093..b471032 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -3,6 +3,10 @@ package order import ( "context" + "hfb_sys/backend/internal/logging" + "hfb_sys/backend/internal/model" + + "go.uber.org/zap" "gorm.io/gorm" ) @@ -27,10 +31,22 @@ type OrderChatArchiver interface { ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error } +// ChannelPaymentProbe 由支付模块适配实现,用于在取消或关单前跟渠道核对是否已收款。 +type ChannelPaymentProbe interface { + ConfirmChannelPaidBeforeClose(ctx context.Context, orderID uint64) (paid bool, err error) +} + +type ChannelPaymentProbeFunc func(ctx context.Context, orderID uint64) (bool, error) + +func (fn ChannelPaymentProbeFunc) ConfirmChannelPaidBeforeClose(ctx context.Context, orderID uint64) (bool, error) { + return fn(ctx, orderID) +} + type Dependencies struct { ChatNotifier OrderChatNotifier ChatArchiver OrderChatArchiver RefundStarter RefundStarter + PaymentProbe ChannelPaymentProbe } type refundAction struct { @@ -45,6 +61,7 @@ type Repository struct { chatNotifier OrderChatNotifier chatArchiver OrderChatArchiver refundStarter RefundStarter + paymentProbe ChannelPaymentProbe } const defaultPendingPaymentTimeoutMinutes = 15 @@ -55,10 +72,51 @@ func NewRepository(db *gorm.DB, deps ...Dependencies) *Repository { repo.chatNotifier = deps[0].ChatNotifier repo.chatArchiver = deps[0].ChatArchiver repo.refundStarter = deps[0].RefundStarter + repo.paymentProbe = deps[0].PaymentProbe } return repo } +// channelPaidBeforeClose 在关闭待支付订单前跟渠道核对一次,已收款则放弃关闭。 +// 核对必须在事务外做(含渠道 HTTP 调用),渠道异常时按「未收款」继续, +// 真漏掉的由支付回调兜底和对账任务收尾。 +func (r *Repository) channelPaidBeforeClose(ctx context.Context, orderID uint64) bool { + if r.paymentProbe == nil { + return false + } + paid, err := r.paymentProbe.ConfirmChannelPaidBeforeClose(ctx, orderID) + if err != nil { + logging.FromContext(ctx).Warn("关闭订单前核对渠道支付失败", + zap.String("module", "order"), + zap.Uint64("order_id", orderID), + zap.Error(err), + ) + return false + } + if paid { + logging.FromContext(ctx).Warn("关闭订单前核对到渠道已收款,放弃关闭", + zap.String("module", "order"), + zap.Uint64("order_id", orderID), + ) + } + return paid +} + +// pendingPaymentOrderPaidAtChannel 仅对租客自己的待支付订单做渠道核对,其余情况直接放行。 +func (r *Repository) pendingPaymentOrderPaidAtChannel(ctx context.Context, userID uint64, orderID uint64) bool { + if r.paymentProbe == nil { + return false + } + var status string + err := r.db.WithContext(ctx).Model(&model.RentalOrder{}). + Where("id = ? AND renter_id = ?", orderID, userID). + Pluck("status", &status).Error + if err != nil || status != orderStatusPendingPayment { + return false + } + return r.channelPaidBeforeClose(ctx, orderID) +} + func (r *Repository) NotifyNewConversation(conversationID uint64) { if conversationID > 0 && r.chatNotifier != nil { r.chatNotifier.NotifyNewConversation(conversationID) diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index cf81d44..f90d844 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -14,6 +14,7 @@ var ( ErrOrderCannotPay = errors.New("order cannot pay") ErrChannelPaymentRequired = errors.New("channel payment required") ErrOrderCannotCancel = errors.New("order cannot cancel") + ErrOrderPaidAtChannel = errors.New("order already paid at channel") ErrOrderCannotHandoff = errors.New("order cannot handoff") ErrOrderCannotForceHandoff = errors.New("order cannot force handoff") ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff") diff --git a/backend/internal/modules/payment/channel_probe.go b/backend/internal/modules/payment/channel_probe.go new file mode 100644 index 0000000..2ee6149 --- /dev/null +++ b/backend/internal/modules/payment/channel_probe.go @@ -0,0 +1,89 @@ +package payment + +import ( + "context" + + "hfb_sys/backend/internal/model" + + "go.uber.org/zap" +) + +// ConfirmChannelPaidBeforeClose 在取消或关闭订单前跟渠道核对未完成的下单支付单。 +// 返回 true 表示渠道确认已收款且订单已被推进,调用方必须放弃本次取消。 +// +// 待支付订单的 15 分钟超时和用户手动取消都可能正好撞上用户付款: +// 订单先没了,渠道结果几秒后才到,于是钱收了、订单却是已取消。 +// 先核对一次能把绝大多数这种竞态挡在发生之前——而且是让支付正常入账,不必退款。 +func (r *Repository) ConfirmChannelPaidBeforeClose(ctx context.Context, orderID uint64) (bool, error) { + if r == nil || r.db == nil || orderID == 0 { + return false, nil + } + payments, err := r.pendingOrderPayments(ctx, orderID) + if err != nil { + return false, err + } + advanced := false + for i := range payments { + if err := r.probeChannelPaid(ctx, &payments[i]); err != nil { + // 渠道不可用不能阻塞取消,否则渠道一挂订单就全卡住。 + // 真漏掉的由支付回调兜底记账并告警。 + r.log().Warn("取消前核对渠道支付状态失败", paymentLogFields(ctx, appendFields( + paymentOrderFields(&payments[i]), + []zap.Field{zap.Error(err)}, + )..., + )...) + continue + } + // 以订单是否真的被推进为准。钱到了但商品已被他人租走时,入账路径只会记账 + // 并告警等人工退款,不推进订单,这种情况仍然该让取消照常进行。 + if orderAdvancedPastPayment(r.orderStatus(ctx, orderID)) { + advanced = true + } + } + return advanced, nil +} + +// orderAdvancedPastPayment 判断订单是否已经靠这笔支付走出待支付阶段。 +func orderAdvancedPastPayment(status string) bool { + return status == "pending_handoff" || status == "renting" +} + +// probeChannelPaid 向渠道查询一笔支付单的真实状态并落库。 +func (r *Repository) probeChannelPaid(ctx context.Context, payment *model.PaymentOrder) error { + if payment.ProviderOrderID == "" { + // 没有渠道单号说明下单请求没被渠道受理,不可能收到钱。 + return nil + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) + if err != nil { + return err + } + if runtimeConfig.isMockMode() || runtimeConfig.Channel == nil { + return nil + } + resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + if err != nil { + return err + } + if resp.Status != "paid" { + return nil + } + r.log().Warn("渠道核对到已收款但系统未入账", paymentLogFields(ctx, appendFields( + paymentOrderFields(payment), + runtimeConfigFields(runtimeConfig), + []zap.Field{zap.String("order_status", r.orderStatus(ctx, payment.OrderID))}, + )..., + )...) + // 走统一入账路径:订单还能支付就正常推进,已经结束就只记账并告警等人工退款。 + return r.applyChannelStatus(ctx, payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery) +} + +func (r *Repository) pendingOrderPayments(ctx context.Context, orderID uint64) ([]model.PaymentOrder, error) { + var rows []model.PaymentOrder + err := r.db.WithContext(ctx). + Where("order_id = ? AND biz_type = ? AND status IN ? AND provider_order_id <> ''", + orderID, "order_pay", []string{"created", "paying"}). + Order("id DESC"). + Find(&rows).Error + return rows, err +} diff --git a/backend/internal/modules/payment/channel_status.go b/backend/internal/modules/payment/channel_status.go index 83f5eea..999d7b9 100644 --- a/backend/internal/modules/payment/channel_status.go +++ b/backend/internal/modules/payment/channel_status.go @@ -92,6 +92,9 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde return tx.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error }) if err != nil { + if settleErr, handled := r.settleUnpayableOrderPayment(ctx, payment, paidAt, raw, source, err); handled { + return settleErr + } return err } if newConvID > 0 && notifyFn != nil { diff --git a/backend/internal/modules/payment/late_paid.go b/backend/internal/modules/payment/late_paid.go new file mode 100644 index 0000000..046c097 --- /dev/null +++ b/backend/internal/modules/payment/late_paid.go @@ -0,0 +1,113 @@ +package payment + +import ( + "context" + "errors" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/order" + + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// isOrderUnpayable 判断订单侧的报错是否属于「钱已到渠道,但订单再也无法入账」。 +// 订单被取消或关单后回调才到属于前者,商品被他人抢租属于后者。 +func isOrderUnpayable(err error) bool { + return errors.Is(err, order.ErrOrderCannotPay) || errors.Is(err, order.ErrListingUnavailable) +} + +func unpayableReason(err error) string { + switch { + case errors.Is(err, order.ErrOrderCannotPay): + return "order_not_payable" + case errors.Is(err, order.ErrListingUnavailable): + return "listing_unavailable" + default: + return "unknown" + } +} + +// settleUnpayableOrderPayment 处理「渠道已收款、订单却无法入账」。 +// +// 钱已经进了商户账户,这个事实必须先落库:把支付单如实记成已支付。 +// 如果让订单侧的报错把整笔事务回滚,支付单会停在「支付中」且 paid_at 为空, +// 这时候既补不回入账(订单已不是待支付),也退不出去——后台人工退款要求 +// 原支付单是已支付,所以连客服都无能为力,钱就彻底对不上了。 +// +// 这里只记账并告警,不自动退款:退款由客服在后台确认后人工发起, +// 落账之后后台的人工退款就能正常走通。 +func (r *Repository) settleUnpayableOrderPayment(ctx context.Context, payment *model.PaymentOrder, paidAt time.Time, raw map[string]string, source string, cause error) (error, bool) { + if payment == nil || payment.BizType != "order_pay" || !isOrderUnpayable(cause) { + return nil, false + } + reason := unpayableReason(cause) + logFields := func(extra ...zap.Field) []zap.Field { + return paymentLogFields(ctx, appendFields( + paymentOrderFields(payment), + []zap.Field{ + zap.String("channel_source", source), + zap.String("unpayable_reason", reason), + zap.String("order_status", r.orderStatus(ctx, payment.OrderID)), + }, + extra, + )...) + } + + if err := r.markOrderPaymentPaid(ctx, payment, paidAt, withUnpayableDiagnostic(raw, reason), source); err != nil { + r.log().Error("渠道已收款但支付单落账失败", logFields(zap.Error(err))...) + return err, true + } + // 返回 nil 让渠道停止重试:重试解决不了问题,只会把同一条告警刷十遍。 + r.log().Error("渠道已收款但订单无法入账,需人工退款", logFields()...) + return nil, true +} + +// markOrderPaymentPaid 只更新支付单自身,不碰订单状态,用于订单已无法推进时如实记录收款。 +func (r *Repository) markOrderPaymentPaid(ctx context.Context, payment *model.PaymentOrder, paidAt time.Time, raw map[string]string, source string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var latest model.PaymentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&latest, payment.ID).Error; err != nil { + return err + } + if latest.Status == "paid" && latest.PaidAt != nil { + return nil + } + updates := map[string]any{ + "status": "paid", + "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["schc_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], latest.ProviderOrderID), + "raw_response": jsonMap(withRawSource(raw, source)), + "paid_at": paidAt, + } + if source == channelSourceNotify { + updates["notified_at"] = time.Now() + } + return tx.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error + }) +} + +// withUnpayableDiagnostic 在 raw_response 里留下排查标记,后台支付列表可以直接看到原因。 +func withUnpayableDiagnostic(raw map[string]string, reason string) map[string]string { + out := make(map[string]string, len(raw)+2) + for key, value := range raw { + out[key] = value + } + out["_order_unpayable_reason"] = reason + out["_manual_refund_required"] = "true" + return out +} + +func (r *Repository) orderStatus(ctx context.Context, orderID uint64) string { + if orderID == 0 { + return "" + } + var status string + if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}). + Where("id = ?", orderID). + Pluck("status", &status).Error; err != nil { + return "" + } + return status +} diff --git a/backend/internal/modules/payment/late_paid_test.go b/backend/internal/modules/payment/late_paid_test.go new file mode 100644 index 0000000..6e1e64d --- /dev/null +++ b/backend/internal/modules/payment/late_paid_test.go @@ -0,0 +1,163 @@ +package payment + +import ( + "errors" + "testing" + "time" + + "hfb_sys/backend/internal/model" + ordermodule "hfb_sys/backend/internal/modules/order" +) + +// TestConfirmPaidSettlesPaymentWhenOrderAlreadyCancelled 钉住本次事故的根因: +// 渠道回调带着「已支付」到达,而订单已经被取消。原先订单侧报 ErrOrderCannotPay 会把 +// 整笔事务回滚,支付单停在 paying 且 paid_at 为空,于是钱收了却既补不回入账 +// (订单不再是待支付),也退不出去——后台人工退款要求原支付单是已支付。 +// 现在必须如实把支付单记成已支付,人工退款才有的可退。 +func TestConfirmPaidSettlesPaymentWhenOrderAlreadyCancelled(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, ordermodule.NewRepository(db)) + fixture := createPayableOrderFixture(t, db, "LP01", true) + if err := db.Model(&model.RentalOrder{}).Where("id = ?", fixture.Order.ID). + Updates(map[string]any{"status": "cancelled", "handoff_status": "cancelled"}).Error; err != nil { + t.Fatalf("cancel order failed: %v", err) + } + + paidAt := time.Date(2026, 9, 15, 23, 22, 47, 0, time.UTC) + if err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{ + "provider_order_id": "PROVIDERLP01", + }, channelSourceNotify); err != nil { + t.Fatalf("confirmPaid() error = %v, want nil so the channel stops retrying", err) + } + + var payment model.PaymentOrder + if err := db.First(&payment, fixture.Payment.ID).Error; err != nil { + t.Fatalf("find payment failed: %v", err) + } + if payment.Status != "paid" { + t.Fatalf("payment status = %q, want paid", payment.Status) + } + if payment.PaidAt == nil { + t.Fatal("payment paid_at = nil, want the channel pay time") + } + if payment.ProviderOrderID != "PROVIDERLP01" { + t.Fatalf("provider order id = %q, want PROVIDERLP01", payment.ProviderOrderID) + } + + var order model.RentalOrder + if err := db.First(&order, fixture.Order.ID).Error; err != nil { + t.Fatalf("find order failed: %v", err) + } + if order.Status != "cancelled" { + t.Fatalf("order status = %q, want cancelled (payment must not revive it)", order.Status) + } +} + +// TestConfirmPaidDoesNotAutoRefundUnpayableOrder 退款一律由客服人工发起, +// 入账路径不能自己动钱。 +func TestConfirmPaidDoesNotAutoRefundUnpayableOrder(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, ordermodule.NewRepository(db)) + fixture := createPayableOrderFixture(t, db, "LP02", true) + if err := db.Model(&model.RentalOrder{}).Where("id = ?", fixture.Order.ID). + Update("status", "closed").Error; err != nil { + t.Fatalf("close order failed: %v", err) + } + + if err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", time.Now(), map[string]string{ + "provider_order_id": "PROVIDERLP02", + }, channelSourceNotify); err != nil { + t.Fatalf("confirmPaid() error = %v", err) + } + + var refundCount int64 + if err := db.Model(&model.PaymentOrder{}). + Where("order_id = ? AND biz_type <> ?", fixture.Order.ID, "order_pay"). + Count(&refundCount).Error; err != nil { + t.Fatalf("count refunds failed: %v", err) + } + if refundCount != 0 { + t.Fatalf("refund payment count = %d, want 0 (refund must stay manual)", refundCount) + } + + var order model.RentalOrder + if err := db.First(&order, fixture.Order.ID).Error; err != nil { + t.Fatalf("find order failed: %v", err) + } + if order.RefundStatus == "refunded" || order.RefundStatus == "refunding" { + t.Fatalf("order refund status = %q, want untouched", order.RefundStatus) + } +} + +// TestConfirmPaidSettlementIsIdempotent 乐刷会重试十次同一个回调,重复落账不能出错。 +func TestConfirmPaidSettlementIsIdempotent(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, ordermodule.NewRepository(db)) + fixture := createPayableOrderFixture(t, db, "LP03", true) + if err := db.Model(&model.RentalOrder{}).Where("id = ?", fixture.Order.ID). + Update("status", "cancelled").Error; err != nil { + t.Fatalf("cancel order failed: %v", err) + } + + paidAt := time.Date(2026, 9, 15, 23, 22, 47, 0, time.UTC) + for i := 0; i < 3; i++ { + if err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt.Add(time.Duration(i)*time.Minute), map[string]string{ + "provider_order_id": "PROVIDERLP03", + }, channelSourceNotify); err != nil { + t.Fatalf("confirmPaid() attempt %d error = %v", i+1, err) + } + } + + var payment model.PaymentOrder + if err := db.First(&payment, fixture.Payment.ID).Error; err != nil { + t.Fatalf("find payment failed: %v", err) + } + if payment.Status != "paid" { + t.Fatalf("payment status = %q, want paid", payment.Status) + } + // 首次落账的时间必须保留,后续重试不能把 paid_at 往后推。 + if payment.PaidAt == nil || !payment.PaidAt.UTC().Equal(paidAt) { + t.Fatalf("payment paid_at = %v, want %v", payment.PaidAt, paidAt) + } +} + +// TestConfirmPaidKeepsErrorForUnrelatedFailures 只有「订单再也无法入账」才走落账。 +// 其他错误仍要原样抛出,否则会把真正的故障当成已处理而静默掉。 +func TestConfirmPaidKeepsErrorForUnrelatedFailures(t *testing.T) { + db := setupPaymentTestDB(t) + repo := NewRepository(db, nil, nil) + fixture := createPayableOrderFixture(t, db, "LP04", true) + + err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", time.Now(), map[string]string{}, channelSourceNotify) + if !errors.Is(err, ErrDependencyUnavailable) { + t.Fatalf("confirmPaid() error = %v, want ErrDependencyUnavailable", err) + } + + var payment model.PaymentOrder + if findErr := db.First(&payment, fixture.Payment.ID).Error; findErr != nil { + t.Fatalf("find payment failed: %v", findErr) + } + if payment.Status != "paying" { + t.Fatalf("payment status = %q, want paying", payment.Status) + } +} + +func TestIsOrderUnpayable(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"订单已取消或已关单", ordermodule.ErrOrderCannotPay, true}, + {"商品已被他人租走", ordermodule.ErrListingUnavailable, true}, + {"依赖缺失", ErrDependencyUnavailable, false}, + {"无错误", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isOrderUnpayable(tc.err); got != tc.want { + t.Fatalf("isOrderUnpayable(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/backend/internal/modules/payment/notify.go b/backend/internal/modules/payment/notify.go index 0e9117b..704d1c9 100644 --- a/backend/internal/modules/payment/notify.go +++ b/backend/internal/modules/payment/notify.go @@ -50,7 +50,19 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m return nil, ErrPaymentVerifyFailed } raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") - if err := r.applyChannelStatus(ctx, payment, normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]), params["pay_time"], raw, channelSourceNotify); err != nil { + channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]) + if err := r.applyChannelStatus(ctx, payment, channelStatus, params["pay_time"], raw, channelSourceNotify); err != nil { + // 入口处的 WARN 只有渠道参数,定位不到是哪个订单哪笔支付单,这里补齐上下文。 + r.log().Warn("支付回调入账失败", paymentLogFields(ctx, appendFields( + paymentOrderFields(payment), + runtimeConfigFields(runtimeConfig), + []zap.Field{ + zap.String("channel_status", channelStatus), + zap.String("order_status", r.orderStatus(ctx, payment.OrderID)), + zap.Error(err), + }, + )..., + )...) return nil, err } return &NotifyResult{OK: true, Message: "000000"}, nil diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index ee85881..a0f4dfd 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -204,6 +204,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } return dto.Status, nil }), + PaymentProbe: order.ChannelPaymentProbeFunc(func(ctx context.Context, orderID uint64) (bool, error) { + if paymentRepo == nil { + return false, nil + } + return paymentRepo.ConfirmChannelPaidBeforeClose(ctx, orderID) + }), }) } orderService := order.NewService(orderRepo) diff --git a/frontend/src/features/orders/views/MobileOrdersView.vue b/frontend/src/features/orders/views/MobileOrdersView.vue index d1347e1..8b90d3f 100644 --- a/frontend/src/features/orders/views/MobileOrdersView.vue +++ b/frontend/src/features/orders/views/MobileOrdersView.vue @@ -230,7 +230,10 @@ async function handleHideOwnerOrder(order: UserOrderListItem) { try { await hideOwnerOrder(order.id) showToast({ message: '已从你的订单列表移除', icon: 'passed' }) - if (rentalOrders.value.filter(item => item.id !== order.id).length === 0 && rentalPage.value > 1) { + if ( + rentalOrders.value.filter(item => item.id !== order.id).length === 0 && + rentalPage.value > 1 + ) { rentalPage.value -= 1 } await loadOrders() diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index 78e2d91..b2a15ac 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -209,7 +209,10 @@ async function handleHideOwnerOrder(order: UserOrderListItem) { try { await hideOwnerOrder(order.id) ElMessage.success('已从你的订单列表移除') - if (rentalOrders.value.filter(item => item.id !== order.id).length === 0 && rentalPage.value > 1) { + if ( + rentalOrders.value.filter(item => item.id !== order.id).length === 0 && + rentalPage.value > 1 + ) { rentalPage.value -= 1 } await loadOrders()