409 lines
14 KiB
Go
409 lines
14 KiB
Go
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 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)
|
|
}
|
|
}
|