Files

830 lines
31 KiB
Go

package dispute
import (
"context"
"errors"
"math"
"strings"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func int64Ptr(value int64) *int64 { return &value }
func disputePureCoinOrder() model.RentalOrder {
return model.RentalOrder{
Status: "renting",
HandoffStatus: "received",
SettlementStatus: "unsettled",
RentAmountCent: 38037,
OwnerRentAmountCent: 35300,
DepositAmountCent: 15000,
RentOriginalAmountCent: 38300,
RentDiscountAmountCent: 263,
PureCoinOriginalAmountCent: 26300,
ExtraItemOriginalAmountCent: 12000,
RenterDiscountBps: 9900,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"asset_summary": {
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120
}
}
}`)),
}
}
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")
}
actualCoinConsumedM := 0.0
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认租金给卖家,线下结算",
ActualCoinConsumedM: &actualCoinConsumedM,
}, 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 TestPlatformManagedCheckoutArbitrationKeepsExistingDepositCompensation(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
adminID := uint64(78)
_, renter, order := createDisputeOrderFixture(t, db, model.RentalOrder{
Status: "pending_checkout_confirm",
HandoffStatus: "pending_owner_checkout",
SettlementStatus: "pending",
HandoffMode: "platform",
SettlementMode: "platform_managed",
ManagedAdminID: &adminID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
PlatformFeeCent: 2000,
DepositAmountCent: 5000,
})
checkout := model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
CoinConsumedM: 0,
DepositDeductAmountCent: 3000,
OtherAmountCent: 3000,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err)
}
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "checkout_amount",
Description: "结账押金损耗争议",
})
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
if created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 {
t.Fatalf("争议未带出押金赔付金额: %v", created.CheckoutDepositDeductAmountCent)
}
actualCoinConsumedM := 0.0
actualConsumableAmountCent := int64(0)
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
Result: "actual_settlement",
Remark: "沿用原结账押金赔付",
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 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.OfflineSettlementAmountCent != 11000 {
t.Fatalf("仲裁后待打款 = %d, want 11000", saved.OfflineSettlementAmountCent)
}
}
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
_, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder())
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "haf_coin_dispute",
Description: "核对最终哈夫币消耗",
})
if err != nil {
t.Fatalf("创建申诉失败: %v", err)
}
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "actual_settlement",
Remark: "确认完成订单",
}, AuditMeta{})
if !errors.Is(err, ErrInvalidDispute) {
t.Fatalf("缺少实际消耗 M 的仲裁错误 = %v, want ErrInvalidDispute", err)
}
actualCoinConsumedM := 50.0
actualConsumableAmountCent := int64(12000)
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "actual_settlement",
Remark: "确认实际消耗 50M",
ActualCoinConsumedM: &actualCoinConsumedM,
}, AuditMeta{})
if !errors.Is(err, ErrInvalidDispute) {
t.Fatalf("缺少实际额外物品金额的仲裁错误 = %v, want ErrInvalidDispute", err)
}
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "actual_settlement",
Remark: "确认实际消耗 50M 和额外物品",
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, AuditMeta{}); err != nil {
t.Fatalf("带实际消耗 M 仲裁失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.ActualExtraItemAmountCent != 12000 || saved.GrowthPointsAwarded != 131 {
t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 50/13150/12000/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded)
}
}
func TestActualSettlementArbitrationUsesOwnerCompensationAmount(t *testing.T) {
order := disputePureCoinOrder()
actualCoinConsumedM := 50.0
actualConsumableAmountCent := int64(12000)
settlement, actualSettlement, _, depositDeductAmountCent, err := buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{
Result: "actual_settlement",
Remark: "按实际消耗结算",
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 0)
if err != nil {
t.Fatalf("不填押金赔付的实际结算失败: %v", err)
}
if depositDeductAmountCent != 0 || actualSettlement.DepositCompensationCent != 0 || settlement.DepositDeductAmountCent != 0 {
t.Fatalf("不填押金赔付不应默认扣全押金: input=%d actual=%d settlement=%d", depositDeductAmountCent, actualSettlement.DepositCompensationCent, settlement.DepositDeductAmountCent)
}
compensationCent := int64(3000)
settlement, actualSettlement, _, depositDeductAmountCent, err = buildActualCheckoutArbitrationSettlement(nil, model.Dispute{}, order, ArbitrateRequest{
Result: "actual_settlement",
Remark: "赔付号主",
AmountCent: &compensationCent,
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 0)
if err != nil {
t.Fatalf("填写押金赔付的实际结算失败: %v", err)
}
if depositDeductAmountCent != compensationCent || actualSettlement.DepositCompensationCent != compensationCent || settlement.DepositDeductAmountCent != compensationCent {
t.Fatalf("押金赔付 = %d/%d/%d, want %d", depositDeductAmountCent, actualSettlement.DepositCompensationCent, settlement.DepositDeductAmountCent, compensationCent)
}
}
func TestActualSettlementArbitrationInheritsCheckoutDepositCompensation(t *testing.T) {
order := disputePureCoinOrder()
checkout := &model.OrderCheckout{DepositDeductAmountCent: 3000}
amount, err := arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement"})
if err != nil || amount != 3000 {
t.Fatalf("omitted arbitration compensation = %d/%v, want 3000/nil", amount, err)
}
zero := int64(0)
amount, err = arbitrationDepositDeductAmountCent(order, checkout, ArbitrateRequest{Result: "actual_settlement", AmountCent: &zero})
if err != nil || amount != 0 {
t.Fatalf("explicit zero arbitration compensation = %d/%v, want 0/nil", amount, err)
}
}
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
inputOrder := disputePureCoinOrder()
inputOrder.Status = "pending_checkout_confirm"
inputOrder.HandoffStatus = "pending_owner_checkout"
inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
ConsumableAmountCent: 7000,
CoinConsumedM: 25,
DepositDeductAmountCent: 3000,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err)
}
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "checkout_amount",
Description: "结账金额有争议",
})
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 || created.CheckoutDepositDeductAmountCent == nil || *created.CheckoutDepositDeductAmountCent != 3000 {
t.Fatalf("关联结账/M/额外物品/押金赔付 = %v/%v/%v/%v, want %d/25/7000/3000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, created.CheckoutDepositDeductAmountCent, checkout.ID)
}
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "沿用双方结账消耗",
}, AuditMeta{}); err != nil {
t.Fatalf("仲裁复用结账 M 失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.ActualExtraItemAmountCent != 7000 || saved.GrowthPointsAwarded != 65 {
t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 25/6575/7000/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded)
}
var savedCheckout model.OrderCheckout
if err := db.First(&savedCheckout, checkout.ID).Error; err != nil {
t.Fatalf("读取结账记录失败: %v", err)
}
if savedCheckout.ConsumableAmountCent != 7000 || savedCheckout.RentAmountCent != 13509 || savedCheckout.RenterRefundAmountCent != 39528 {
t.Fatalf("仲裁后结账消耗品/租金/退款 = %d/%d/%d, want 7000/13509/39528", savedCheckout.ConsumableAmountCent, savedCheckout.RentAmountCent, savedCheckout.RenterRefundAmountCent)
}
}
func TestArbitrationActualSettlementAwardsSellerRebateOnActualProfit(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
owner, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder())
inviter := model.User{Phone: "13800000003", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&inviter).Error; err != nil {
t.Fatalf("创建邀请人失败: %v", err)
}
verifiedAt := time.Now().AddDate(0, 0, -1).Truncate(time.Second)
if err := db.Create(&model.UserInvitation{
InviterID: inviter.ID, InviteeID: owner.ID, Status: "active", VerifiedAt: &verifiedAt,
}).Error; err != nil {
t.Fatalf("创建邀请关系失败: %v", err)
}
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "haf_coin_dispute",
Description: "按实际消耗仲裁",
})
if err != nil {
t.Fatalf("创建申诉失败: %v", err)
}
actualCoinConsumedM := 50.0
actualConsumableAmountCent := int64(12000)
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "actual_settlement",
Remark: "按实际消耗结算",
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, 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)
}
actual := ordermodule.CalculateActualCheckoutSettlement(saved, actualConsumableAmountCent, actualCoinConsumedM, 0)
expectedBasis := actual.PlatformFeeCent / 2
expectedRebate := int64(math.Round(float64(expectedBasis) * 0.6))
var ledger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&ledger).Error; err != nil {
t.Fatalf("查询仲裁返利流水失败: %v", err)
}
if ledger.OrderProfitBasisCent != actual.PlatformFeeCent || ledger.SingleSideBasisCent != expectedBasis || ledger.RebateAmountCent != expectedRebate {
t.Fatalf("仲裁返利基数/单边/金额 = %d/%d/%d, want %d/%d/%d",
ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent,
actual.PlatformFeeCent, expectedBasis, expectedRebate)
}
}
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: int64Ptr(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: int64Ptr(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)
}
}
func TestApplyAdminListFiltersSmartRouting(t *testing.T) {
db := database.NewDryRunDB()
tests := []struct {
name string
query AdminListQuery
wantSQLFrag string
}{
{
name: "11位手机号路由至users(phone)",
query: AdminListQuery{Keyword: "13800138000"},
wantSQLFrag: "owner.phone = ? OR renter.phone = ?",
},
{
name: "RO前缀订单号路由至rental_orders(order_no)",
query: AdminListQuery{Keyword: "RO202609051000"},
wantSQLFrag: "o.order_no = ?",
},
{
name: "L前缀商品编号路由至rental_listings(listing_no)",
query: AdminListQuery{Keyword: "L20260905100"},
wantSQLFrag: "l.listing_no = ?",
},
{
name: "短数字ID路由至主键及关联ID",
query: AdminListQuery{Keyword: "12345"},
wantSQLFrag: "d.id = ? OR d.order_id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?",
},
{
name: "12位时间计算的纯数字商品编号路由至rental_listings(listing_no)",
query: AdminListQuery{Keyword: "202609040184"},
wantSQLFrag: "l.listing_no = ?",
},
{
name: "普通文字退回多列LIKE模糊匹配",
query: AdminListQuery{Keyword: "测试账号"},
wantSQLFrag: "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ?",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt := applyAdminListFilters(db.Table("disputes AS d"), tt.query).Find(&[]struct{}{}).Statement
sql := stmt.SQL.String()
if !strings.Contains(sql, tt.wantSQLFrag) {
t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql)
}
})
}
}
func TestStampOwnerIncomeSiteUsesListingSite(t *testing.T) {
siteID := uint64(3)
orderID := uint64(9)
settlement := arbitrationSettlement{Entries: []wallet.Entry{
{UserID: 1, OrderID: &orderID, Direction: "in", AmountCent: 100, BalanceType: "available", BizType: "arbitration_owner_income"},
{UserID: 2, OrderID: &orderID, Direction: "out", AmountCent: 50, BalanceType: "frozen", BizType: "arbitration_release_frozen"},
}}
stampOwnerIncomeSite(&settlement, &model.RentalListing{DistributionSiteID: &siteID})
if settlement.Entries[0].DistributionSiteID != 3 {
t.Fatalf("号主入账站点 = %d, want 3", settlement.Entries[0].DistributionSiteID)
}
if settlement.Entries[1].DistributionSiteID != 0 {
t.Fatalf("租客解冻站点 = %d, want 0", settlement.Entries[1].DistributionSiteID)
}
}