package dispute import ( "strings" "testing" "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" "gorm.io/gorm" ) func setupDisputeTestDB(t *testing.T) *gorm.DB { t.Helper() db := database.NewTestDB() if err := database.MigrateRentalDisputeTestSchema(db); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db } func createDisputeOrderFixture(t *testing.T, db *gorm.DB, order model.RentalOrder) (model.User, model.User, model.RentalOrder) { t.Helper() owner := model.User{Phone: "13800000001"} if err := db.Create(&owner).Error; err != nil { t.Fatalf("创建号主失败: %v", err) } renter := model.User{Phone: "13800000002"} if err := db.Create(&renter).Error; err != nil { t.Fatalf("创建租客失败: %v", err) } account := model.GameAccount{ OwnerID: owner.ID, Title: "测试账号", ServerRegion: "国服", LoginPlatform: "安卓", } if err := db.Create(&account).Error; err != nil { t.Fatalf("创建账号失败: %v", err) } listing := model.RentalListing{ AccountID: account.ID, OwnerID: owner.ID, PriceCent: 1000, Status: "rented", ReviewStatus: "approved", InTransaction: true, } if err := db.Create(&listing).Error; err != nil { t.Fatalf("创建商品失败: %v", err) } order.OrderNo = "RO202606130001" order.ListingID = listing.ID order.AccountID = account.ID order.OwnerID = owner.ID order.RenterID = renter.ID if err := db.Create(&order).Error; err != nil { t.Fatalf("创建订单失败: %v", err) } return owner, renter, order } func TestRepositoryCancelRestoresNormalDisputeOrderStatus(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db) _, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ Status: "renting", HandoffStatus: "received", SettlementStatus: "unsettled", }) created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ Type: "cannot_login", Description: "无法登录账号", }) if err != nil { t.Fatalf("创建申诉失败: %v", err) } if created.Status != "open" { t.Fatalf("申诉状态 = %s, want open", created.Status) } cancelled, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID) if err != nil { t.Fatalf("取消申诉失败: %v", err) } if cancelled.Status != "cancelled" { t.Fatalf("取消后申诉状态 = %s, want cancelled", cancelled.Status) } var restored model.RentalOrder if err := db.First(&restored, order.ID).Error; err != nil { t.Fatalf("读取订单失败: %v", err) } if restored.Status != "renting" || restored.HandoffStatus != "received" || restored.SettlementStatus != "unsettled" { t.Fatalf("订单状态未恢复: status=%s handoff=%s settlement=%s", restored.Status, restored.HandoffStatus, restored.SettlementStatus) } var records []model.HandoffRecord if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil { t.Fatalf("读取交接记录失败: %v", err) } if len(records) != 2 { t.Fatalf("交接记录数量 = %d, want 2", len(records)) } if records[0].Type != "dispute_opened" || records[1].Type != "dispute_cancelled" { t.Fatalf("交接记录类型 = %s/%s, want dispute_opened/dispute_cancelled", records[0].Type, records[1].Type) } } func TestRepositoryCancelRestoresCheckoutDisputeStatus(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db) now := time.Now() _, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ Status: "pending_checkout_accept", HandoffStatus: "pending_renter_checkout", SettlementStatus: "pending", }) checkout := model.OrderCheckout{ OrderID: order.ID, InitiatedBy: renter.ID, Status: "countered", OwnerAdjustmentReason: "补扣押金", OwnerAdjustedAt: &now, } if err := db.Create(&checkout).Error; err != nil { t.Fatalf("创建结账记录失败: %v", err) } if _, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ Type: "checkout_amount", Description: "不同意结账修正", }); err != nil { t.Fatalf("创建结账争议失败: %v", err) } if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err != nil { t.Fatalf("取消结账争议失败: %v", err) } var restored model.RentalOrder if err := db.First(&restored, order.ID).Error; err != nil { t.Fatalf("读取订单失败: %v", err) } if restored.Status != "pending_checkout_accept" || restored.HandoffStatus != "pending_renter_checkout" || restored.SettlementStatus != "pending" { t.Fatalf("订单状态未恢复: status=%s handoff=%s settlement=%s", restored.Status, restored.HandoffStatus, restored.SettlementStatus) } var restoredCheckout model.OrderCheckout if err := db.First(&restoredCheckout, checkout.ID).Error; err != nil { t.Fatalf("读取结账记录失败: %v", err) } if restoredCheckout.Status != "countered" { t.Fatalf("结账状态 = %s, want countered", restoredCheckout.Status) } if restoredCheckout.RenterRejectedAt != nil { t.Fatalf("取消争议后 renter_rejected_at 应被清空") } var records []model.HandoffRecord if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil { t.Fatalf("读取交接记录失败: %v", err) } if len(records) != 2 { t.Fatalf("交接记录数量 = %d, want 2", len(records)) } if records[0].Type != "checkout_dispute_opened" || records[1].Type != "checkout_dispute_cancelled" { t.Fatalf("交接记录类型 = %s/%s, want checkout_dispute_opened/checkout_dispute_cancelled", records[0].Type, records[1].Type) } } func TestAdminCreateByOrderCreatesAdminInitiatedDispute(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db) adminID := uint64(88) owner, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ Status: "renting", HandoffStatus: "received", SettlementStatus: "unsettled", }) created, err := repo.AdminCreateByOrder(t.Context(), adminID, order.ID, AdminCreateRequest{ Type: "cannot_login", TargetRole: "owner", Description: "客服核查后主动发起申诉", }, AuditMeta{}) if err != nil { t.Fatalf("后台发起申诉失败: %v", err) } if created.InitiatorType != disputeInitiatorAdmin || created.InitiatorAdminID == nil || *created.InitiatorAdminID != adminID { t.Fatalf("发起方 = %s/%v, want admin/%d", created.InitiatorType, created.InitiatorAdminID, adminID) } if created.InitiatorID != 0 || created.TargetUserID != owner.ID { t.Fatalf("initiator/target = %d/%d, want 0/%d", created.InitiatorID, created.TargetUserID, owner.ID) } var saved model.RentalOrder if err := db.First(&saved, order.ID).Error; err != nil { t.Fatalf("读取订单失败: %v", err) } if saved.Status != "disputing" || saved.HandoffStatus != "received" || saved.SettlementStatus != "unsettled" { t.Fatalf("订单状态 = %s/%s/%s, want disputing/received/unsettled", saved.Status, saved.HandoffStatus, saved.SettlementStatus) } if _, err := repo.FindForUser(t.Context(), renter.ID, created.ID); err != nil { t.Fatalf("租客应能看到客服发起的申诉: %v", err) } if _, err := repo.FindForUser(t.Context(), owner.ID, created.ID); err != nil { t.Fatalf("号主应能看到客服发起的申诉: %v", err) } if _, err := repo.CancelByOrder(t.Context(), renter.ID, order.ID); err == nil { t.Fatal("客服发起的申诉不应允许租客取消") } var record model.HandoffRecord if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_dispute_opened").First(&record).Error; err != nil { t.Fatalf("读取客服发起申诉记录失败: %v", err) } if record.FromUserID != adminID || record.ToUserID != owner.ID { t.Fatalf("record from/to = %d/%d, want %d/%d", record.FromUserID, record.ToUserID, adminID, owner.ID) } } func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) { db := setupDisputeTestDB(t) repo := NewRepository(db) adminID := uint64(77) owner, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{ Status: "renting", HandoffStatus: "received", SettlementStatus: "unsettled", HandoffMode: "platform", SettlementMode: "platform_managed", ManagedAdminID: &adminID, RentAmountCent: 10000, OwnerRentAmountCent: 8000, PlatformFeeCent: 2000, OfflineSettlementStatus: "none", OfflineSettlementAmountCent: 0, }) created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ Type: "cannot_login", Description: "外部上传账号需要客服处理", }) if err != nil { t.Fatalf("创建申诉失败: %v", err) } var ownerNotificationCount int64 if err := db.Model(&model.Notification{}).Where("user_id = ?", owner.ID).Count(&ownerNotificationCount).Error; err != nil { t.Fatalf("统计号主通知失败: %v", err) } if ownerNotificationCount != 0 { t.Fatalf("owner notification count = %d, want 0", ownerNotificationCount) } var adminNotificationCount int64 if err := db.Model(&model.AdminNotification{}).Where("admin_user_id = ?", adminID).Count(&adminNotificationCount).Error; err != nil { t.Fatalf("统计客服通知失败: %v", err) } if adminNotificationCount == 0 { t.Fatal("platform managed dispute should notify managed admin") } if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{ Result: "release_deposit", Remark: "确认租金给卖家,线下结算", }, AuditMeta{}); err != nil { t.Fatalf("仲裁失败: %v", err) } var saved model.RentalOrder if err := db.First(&saved, order.ID).Error; err != nil { t.Fatalf("读取订单失败: %v", err) } if saved.OfflineSettlementStatus != "pending" { t.Fatalf("offline settlement status = %q, want pending", saved.OfflineSettlementStatus) } if saved.OfflineSettlementAmountCent != 8000 { t.Fatalf("offline settlement amount = %d, want 8000", saved.OfflineSettlementAmountCent) } if saved.OwnerSettledAt != nil { t.Fatalf("owner settled at = %#v, want nil before offline settlement", saved.OwnerSettledAt) } var ownerLedgerCount int64 if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", owner.ID).Count(&ownerLedgerCount).Error; err != nil { t.Fatalf("统计钱包流水失败: %v", err) } if ownerLedgerCount != 0 { t.Fatalf("owner wallet ledger count = %d, want 0", ownerLedgerCount) } } func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) { order := model.RentalOrder{ ID: 11, OrderNo: "RO202606080001", RenterID: 101, OwnerID: 202, RentAmountCent: 20000, OwnerRentAmountCent: 18000, DepositAmountCent: 10000, } settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "release_deposit", Remark: "测试裁决", }, 0) if err != nil { t.Fatalf("buildArbitrationSettlement() error = %v", err) } for _, entry := range settlement.Entries { if entry.BizType == "arbitration_release_frozen" { t.Fatalf("不应在无冻结余额时生成解冻流水: %+v", entry) } } if settlement.OwnerIncomeAmountCent != 18000 { t.Fatalf("OwnerIncomeAmountCent = %d, want 18000", settlement.OwnerIncomeAmountCent) } if settlement.RenterRefundAmountCent != 10000 { t.Fatalf("RenterRefundAmountCent = %d, want 10000", settlement.RenterRefundAmountCent) } for _, entry := range settlement.Entries { if entry.UserID == order.RenterID && entry.BizType == "arbitration_renter_refund" { t.Fatalf("外部支付订单不应生成租客钱包退款流水: %+v", entry) } } } func TestBuildArbitrationSettlementLimitsFrozenReleaseToExistingBalance(t *testing.T) { order := model.RentalOrder{ ID: 12, OrderNo: "RO202606080002", RenterID: 101, OwnerID: 202, RentAmountCent: 20000, OwnerRentAmountCent: 18000, DepositAmountCent: 10000, } settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "order_close", Remark: "测试裁决", }, 12000) if err != nil { t.Fatalf("buildArbitrationSettlement() error = %v", err) } releaseEntryCount := 0 for _, entry := range settlement.Entries { if entry.BizType != "arbitration_release_frozen" { continue } releaseEntryCount++ if entry.AmountCent != 12000 { t.Fatalf("release frozen amount cent = %d, want 12000", entry.AmountCent) } } if releaseEntryCount != 1 { t.Fatalf("release frozen entry count = %d, want 1", releaseEntryCount) } } func TestBuildArbitrationHandoffContent(t *testing.T) { content := buildArbitrationHandoffContent(ArbitrateRequest{ Result: "partial_refund", Remark: "账号异常,退还部分租金。", }, arbitrationSettlement{ RenterRefundAmountCent: 8000, OwnerIncomeAmountCent: 12000, }) wantParts := []string{ "客服仲裁结果:部分退款", "处理说明:账号异常,退还部分租金。", "退款给租客:¥80.0", "结算给号主:¥120.0", } for _, part := range wantParts { if !strings.Contains(content, part) { t.Fatalf("handoff content = %q, want include %q", content, part) } } } // 复现线上单:预收 94.7(号主 66.7 + 平台 28),部分退 50 时,号主不应吃掉平台加价。 func TestPartialRefundReservesPlatformFee(t *testing.T) { order := model.RentalOrder{ ID: 30, OrderNo: "RO-ARBIT-PARTIAL-001", RenterID: 9233, OwnerID: 7463, RentAmountCent: 9470, OwnerRentAmountCent: 6670, DepositAmountCent: 0, } settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "partial_refund", AmountCent: 5000, Remark: "没打完 号主登录不上", }, 0) if err != nil { t.Fatalf("buildArbitrationSettlement() error = %v", err) } if settlement.RenterRefundAmountCent != 5000 { t.Fatalf("RenterRefundAmountCent = %d, want 5000", settlement.RenterRefundAmountCent) } // 未退租金 44.7:平台按比例预留 round(28 * 44.7/94.7)=13.22 → 号主 31.48 wantOwner := partialRefundOwnerIncomeCent(9470, 6670, 0, 5000) if settlement.OwnerIncomeAmountCent != wantOwner { t.Fatalf("OwnerIncomeAmountCent = %d, want %d", settlement.OwnerIncomeAmountCent, wantOwner) } if settlement.OwnerIncomeAmountCent >= 4470 { t.Fatalf("号主收入 %d 不应吞掉整笔剩余 4470(含平台费)", settlement.OwnerIncomeAmountCent) } platformKeep := 9470 - settlement.RenterRefundAmountCent - settlement.OwnerIncomeAmountCent if platformKeep <= 0 { t.Fatalf("平台留存 = %d, 部分退款后应保留平台加价份额", platformKeep) } } func TestPartialRefundFullRentRetainedGivesOwnerFullOwnerRent(t *testing.T) { // 只退押金:号主拿满预收号主租金,平台留满预收平台费 order := model.RentalOrder{ RentAmountCent: 9470, OwnerRentAmountCent: 6670, DepositAmountCent: 20000, } settlement, err := buildArbitrationSettlement(order, ArbitrateRequest{ Result: "partial_refund", AmountCent: 20000, }, 0) if err != nil { t.Fatalf("buildArbitrationSettlement() error = %v", err) } if settlement.OwnerIncomeAmountCent != 6670 { t.Fatalf("OwnerIncomeAmountCent = %d, want 6670", settlement.OwnerIncomeAmountCent) } if settlement.RenterRefundAmountCent != 20000 { t.Fatalf("RenterRefundAmountCent = %d, want 20000", settlement.RenterRefundAmountCent) } platformKeep := 9470 + 20000 - settlement.RenterRefundAmountCent - settlement.OwnerIncomeAmountCent if platformKeep != 2800 { t.Fatalf("platformKeep = %d, want 2800", platformKeep) } } func TestPartialRefundOwnerIncomeCentUnit(t *testing.T) { // 无平台费时,未退租金全部给号主 if got := partialRefundOwnerIncomeCent(10000, 10000, 0, 3000); got != 7000 { t.Fatalf("no platform fee: got %d, want 7000", got) } // 全额退:号主 0 if got := partialRefundOwnerIncomeCent(9470, 6670, 0, 9470); got != 0 { t.Fatalf("full refund: got %d, want 0", got) } }