From 0256bfd2761cdddadd6532807e9e92949a3b37c5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 2 Aug 2026 16:38:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=96=E6=B6=88=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=AF=AF=E9=87=8A=E6=94=BE=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/jobs/ordertimeout/job.go | 21 ++- .../internal/modules/order/admin_actions.go | 7 + backend/internal/modules/order/assets.go | 44 +++++ backend/internal/modules/order/lifecycle.go | 16 +- .../order/repository_integration_test.go | 151 ++++++++++++++++++ 5 files changed, 237 insertions(+), 2 deletions(-) diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 93de1f3..63ac7e0 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -230,6 +230,19 @@ func clampRenterRetentionDays(value int) int { return value } +func hasActiveSiblingOrder(tx *gorm.DB, order model.RentalOrder) (bool, error) { + var count int64 + err := tx.Model(&model.RentalOrder{}). + Where("id <> ? AND (listing_id = ? OR account_id = ?) AND status NOT IN ?", + order.ID, + order.ListingID, + order.AccountID, + []string{"completed", "cancelled", "closed"}, + ). + Count(&count).Error + return count > 0, err +} + func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { if cfg.PendingPaymentTimeoutMinutes <= 0 { return 0, nil @@ -260,7 +273,13 @@ func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cf } order.Status = "cancelled" order.HandoffStatus = "cancelled" - listing.InTransaction = false + active, err := hasActiveSiblingOrder(tx, order) + if err != nil { + return err + } + if !active { + listing.InTransaction = false + } orderID := order.ID if err := closePendingOrderPayments(tx, order.ID, "order_timeout"); err != nil { return err diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index a7a6ccf..969b077 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -447,6 +447,13 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti if order.Status != orderStatusCancelled { return errors.New("仅已取消的订单可以恢复") } + active, err := hasActiveSiblingOrder(tx, order) + if err != nil { + return err + } + if active { + return ErrListingUnavailable + } order.Status = orderStatusPendingHandoff if order.HandoffStatus == handoffStatusCancelled || order.HandoffStatus == handoffStatusRenterConfirmTimeout { order.HandoffStatus = handoffStatusPendingOwner diff --git a/backend/internal/modules/order/assets.go b/backend/internal/modules/order/assets.go index 1a5f611..4868523 100644 --- a/backend/internal/modules/order/assets.go +++ b/backend/internal/modules/order/assets.go @@ -61,6 +61,50 @@ func releaseAssetsForRental(tx *gorm.DB, listing *model.RentalListing, account * return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单取消恢复上架") } +func terminalOrderStatuses() []string { + return []string{orderStatusCompleted, orderStatusCancelled, orderStatusClosed} +} + +func hasActiveSiblingOrder(tx *gorm.DB, order *model.RentalOrder) (bool, error) { + if order == nil { + return false, nil + } + var count int64 + err := tx.Model(&model.RentalOrder{}). + Where("id <> ? AND (listing_id = ? OR account_id = ?) AND status NOT IN ?", + order.ID, + order.ListingID, + order.AccountID, + terminalOrderStatuses(), + ). + Count(&count).Error + return count > 0, err +} + +func hasActiveOrderForAssets(tx *gorm.DB, listingID uint64, accountID uint64, excludeOrderID uint64) (bool, error) { + var count int64 + err := tx.Model(&model.RentalOrder{}). + Where("id <> ? AND (listing_id = ? OR account_id = ?) AND status NOT IN ?", + excludeOrderID, + listingID, + accountID, + terminalOrderStatuses(), + ). + Count(&count).Error + return count > 0, err +} + +func releaseAssetsForRentalIfIdle(tx *gorm.DB, order *model.RentalOrder, listing *model.RentalListing, account *model.GameAccount) error { + active, err := hasActiveSiblingOrder(tx, order) + if err != nil { + return err + } + if active { + return nil + } + return releaseAssetsForRental(tx, listing, account) +} + func archiveAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { from := listing.Status listing.Status = listingStatusOffline diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 36292e1..50573b5 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -33,6 +33,13 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil { return err } + active, err := hasActiveOrderForAssets(tx, listing.ID, account.ID, 0) + if err != nil { + return err + } + if active { + return ErrListingUnavailable + } snapshot, err := makeAccountSnapshot(account, listing) if err != nil { return err @@ -219,6 +226,13 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint if listing.Status != listingStatusPublished || listing.ReviewStatus != listingReviewStatusApproved || !listing.InTransaction { return 0, ErrListingUnavailable } + active, err := hasActiveSiblingOrder(tx, order) + if err != nil { + return 0, err + } + if active { + return 0, ErrListingUnavailable + } // 租客已通过外部渠道付款,这里不写租客钱包流水。 orderID = order.ID @@ -354,7 +368,7 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) return err } } - if err := releaseAssetsForRental(tx, listing, account); err != nil { + if err := releaseAssetsForRentalIfIdle(tx, &order, listing, account); err != nil { return err } if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil { diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index c7320f8..0c30cb9 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -140,6 +140,63 @@ func TestRepositoryCreateOrderRejectsOwnListing(t *testing.T) { } } +func TestRepositoryCreateOrderRejectsActiveSiblingOrder(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800000011"} + renter := model.User{Phone: "13800000012"} + activeRenter := model.User{Phone: "13800000013"} + 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) + } + if err := db.Create(&activeRenter).Error; err != nil { + t.Fatalf("create active renter failed: %v", err) + } + + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "测试账号", + Status: accountStatusPublished, + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + listing := model.RentalListing{ + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 1000, + Status: listingStatusPublished, + ReviewStatus: listingReviewStatusApproved, + InTransaction: false, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + activeOrder := model.RentalOrder{ + OrderNo: "ORD-ACTIVE-SIBLING-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: activeRenter.ID, + Status: orderStatusRenting, + HandoffStatus: handoffStatusReceived, + } + if err := db.Create(&activeOrder).Error; err != nil { + t.Fatalf("create active order failed: %v", err) + } + + _, err := repo.Create(t.Context(), renter.ID, CreateRequest{ListingID: listing.ID}) + if err != ErrListingUnavailable { + t.Fatalf("Create() error = %v, want ErrListingUnavailable", err) + } +} + func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) @@ -664,6 +721,100 @@ func TestCancelClosesPendingPaymentOrder(t *testing.T) { } } +func TestCancelDoesNotReleaseAssetsWhenSiblingOrderActive(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800002001"} + activeRenter := model.User{Phone: "13800002002"} + cancelRenter := model.User{Phone: "13800002003"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + if err := db.Create(&activeRenter).Error; err != nil { + t.Fatalf("create active renter failed: %v", err) + } + if err := db.Create(&cancelRenter).Error; err != nil { + t.Fatalf("create cancel renter failed: %v", err) + } + + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "测试账号", + Status: accountStatusRented, + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + listing := model.RentalListing{ + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 1000, + Status: listingStatusRented, + ReviewStatus: listingReviewStatusApproved, + InTransaction: true, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + activeOrder := model.RentalOrder{ + OrderNo: "ORD-ACTIVE-SIBLING-002", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: activeRenter.ID, + Status: orderStatusRenting, + HandoffStatus: handoffStatusReceived, + } + if err := db.Create(&activeOrder).Error; err != nil { + t.Fatalf("create active order failed: %v", err) + } + cancelOrder := model.RentalOrder{ + OrderNo: "ORD-CANCEL-SIBLING-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: cancelRenter.ID, + RentAmountCent: 1000, + Status: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingOwner, + } + if err := db.Create(&cancelOrder).Error; err != nil { + t.Fatalf("create cancel order failed: %v", err) + } + + if err := repo.Cancel(t.Context(), cancelRenter.ID, cancelOrder.ID); err != nil { + t.Fatalf("Cancel() error = %v", err) + } + + var savedListing model.RentalListing + if err := db.First(&savedListing, listing.ID).Error; err != nil { + t.Fatalf("find listing failed: %v", err) + } + if savedListing.Status != listingStatusRented { + t.Fatalf("listing status = %q, want %q", savedListing.Status, listingStatusRented) + } + if !savedListing.InTransaction { + t.Fatal("listing InTransaction = false, want true") + } + var savedAccount model.GameAccount + if err := db.First(&savedAccount, account.ID).Error; err != nil { + t.Fatalf("find account failed: %v", err) + } + if savedAccount.Status != accountStatusRented { + t.Fatalf("account status = %q, want %q", savedAccount.Status, accountStatusRented) + } + var savedCancelOrder model.RentalOrder + if err := db.First(&savedCancelOrder, cancelOrder.ID).Error; err != nil { + t.Fatalf("find cancel order failed: %v", err) + } + if savedCancelOrder.Status != orderStatusCancelled { + t.Fatalf("cancel order status = %q, want %q", savedCancelOrder.Status, orderStatusCancelled) + } +} + func TestAdminResetCheckoutConfirmTimeout(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db)