修复取消订单误释放商品

This commit is contained in:
yml2213
2026-08-02 16:38:02 +08:00
parent 921654e5b0
commit 0256bfd276
5 changed files with 237 additions and 2 deletions
+19
View File
@@ -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"
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
@@ -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
+44
View File
@@ -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
+15 -1
View File
@@ -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 {
@@ -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)