fix(order): 修复超时与异常过渡态下的状态机流转及权限断层
This commit is contained in:
@@ -32,7 +32,8 @@ func (r *Repository) Create(ctx context.Context, userID uint64, orderID uint64,
|
||||
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
||||
return ErrInvalidDispute
|
||||
}
|
||||
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
|
||||
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept" ||
|
||||
(order.Status == "abnormal" && (order.HandoffStatus == "owner_checkout_confirm_timeout" || order.HandoffStatus == "owner_return_confirm_timeout" || order.HandoffStatus == "pending_owner_checkout"))
|
||||
var count int64
|
||||
if err := tx.Model(&model.Dispute{}).
|
||||
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||
|
||||
@@ -112,7 +112,15 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID
|
||||
if order.OwnerID != userID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout {
|
||||
isOwnerPending := order.Status == orderStatusPendingCheckoutConfirm ||
|
||||
(order.Status == orderStatusAbnormal &&
|
||||
(order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusPendingOwnerCheckout))
|
||||
if !isOwnerPending ||
|
||||
(order.HandoffStatus != handoffStatusPendingOwnerCheckout &&
|
||||
order.HandoffStatus != handoffStatusOwnerCheckoutConfirmTimeout &&
|
||||
order.HandoffStatus != handoffStatusOwnerReturnConfirmTimeout) {
|
||||
return ErrCheckoutCannotConfirm
|
||||
}
|
||||
before := orderState(order)
|
||||
@@ -162,7 +170,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
if !isOwner && !isRenter {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept {
|
||||
isOwnerPending := order.Status == orderStatusPendingCheckoutConfirm ||
|
||||
(order.Status == orderStatusAbnormal &&
|
||||
(order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusPendingOwnerCheckout))
|
||||
isRenterPending := order.Status == orderStatusPendingCheckoutAccept
|
||||
if !isOwnerPending && !isRenterPending {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
before := orderState(order)
|
||||
@@ -171,10 +185,10 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID
|
||||
return err
|
||||
}
|
||||
turn := normalizeCheckoutTurn(checkout)
|
||||
if isOwner && (turn != checkoutTurnOwner || order.Status != orderStatusPendingCheckoutConfirm) {
|
||||
if isOwner && (turn != checkoutTurnOwner || !isOwnerPending) {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
if isRenter && (turn != checkoutTurnRenter || order.Status != orderStatusPendingCheckoutAccept) {
|
||||
if isRenter && (turn != checkoutTurnRenter || !isRenterPending) {
|
||||
return ErrCheckoutCannotCounter
|
||||
}
|
||||
round := checkout.RoundCount
|
||||
|
||||
@@ -15,20 +15,22 @@ import (
|
||||
|
||||
func canAdminForceHandoff(order model.RentalOrder) bool {
|
||||
if isPlatformHandoffOrder(order) {
|
||||
if order.Status == orderStatusPendingHandoff {
|
||||
return order.HandoffStatus == handoffStatusPendingRenterConfirm
|
||||
if order.Status == orderStatusPendingHandoff || order.Status == orderStatusAbnormal {
|
||||
return order.HandoffStatus == handoffStatusPendingRenterConfirm ||
|
||||
order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||
}
|
||||
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||
return false
|
||||
}
|
||||
if isPlatformSettlementOrder(order) {
|
||||
return false
|
||||
}
|
||||
if order.Status == orderStatusPendingHandoff {
|
||||
if order.Status == orderStatusPendingHandoff || order.Status == orderStatusAbnormal {
|
||||
return order.HandoffStatus == handoffStatusPendingOwner ||
|
||||
order.HandoffStatus == handoffStatusOwnerTimeout ||
|
||||
order.HandoffStatus == handoffStatusPendingRenterConfirm
|
||||
order.HandoffStatus == handoffStatusPendingRenterConfirm ||
|
||||
order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||
}
|
||||
return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout
|
||||
return false
|
||||
}
|
||||
|
||||
func forceHandoffRecordType(order model.RentalOrder) string {
|
||||
|
||||
@@ -45,7 +45,7 @@ func canAdminPlatformHandoff(order model.RentalOrder) bool {
|
||||
if !isPlatformHandoffOrder(order) {
|
||||
return false
|
||||
}
|
||||
if order.Status != orderStatusPendingHandoff {
|
||||
if order.Status != orderStatusPendingHandoff && order.Status != orderStatusAbnormal {
|
||||
return false
|
||||
}
|
||||
return order.HandoffStatus == handoffStatusPendingOwner ||
|
||||
@@ -61,7 +61,9 @@ func canAdminPlatformCheckoutConfirm(order model.RentalOrder) bool {
|
||||
return true
|
||||
}
|
||||
return order.Status == orderStatusAbnormal &&
|
||||
order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout
|
||||
(order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusPendingOwnerCheckout)
|
||||
}
|
||||
|
||||
func canAdminMarkOfflineSettlement(order model.RentalOrder) bool {
|
||||
@@ -98,6 +100,7 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
order.Status = orderStatusPendingHandoff
|
||||
order.HandoffStatus = handoffStatusPendingRenterConfirm
|
||||
order.HandoffStartedAt = &now
|
||||
if err := appendOrderEvent(tx, order, "handoff", "platform_handoff_submitted", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, req.Content, req.Reason, nil, before, nil); err != nil {
|
||||
|
||||
@@ -374,7 +374,13 @@ func applyCheckoutActionFlags(dto *CheckoutDTO, checkout model.OrderCheckout, or
|
||||
if dto == nil {
|
||||
return
|
||||
}
|
||||
negotiating := order.Status == orderStatusPendingCheckoutConfirm || order.Status == orderStatusPendingCheckoutAccept
|
||||
isOwnerPending := order.Status == orderStatusPendingCheckoutConfirm ||
|
||||
(order.Status == orderStatusAbnormal &&
|
||||
(order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout ||
|
||||
order.HandoffStatus == handoffStatusPendingOwnerCheckout))
|
||||
isRenterPending := order.Status == orderStatusPendingCheckoutAccept
|
||||
negotiating := isOwnerPending || isRenterPending
|
||||
if !negotiating || (checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered) {
|
||||
return
|
||||
}
|
||||
@@ -382,11 +388,11 @@ func applyCheckoutActionFlags(dto *CheckoutDTO, checkout model.OrderCheckout, or
|
||||
round := dto.RoundCount
|
||||
isOwner := userID == order.OwnerID
|
||||
isRenter := userID == order.RenterID
|
||||
if isOwner && turn == checkoutTurnOwner && order.Status == orderStatusPendingCheckoutConfirm {
|
||||
if isOwner && turn == checkoutTurnOwner && isOwnerPending {
|
||||
dto.CanAccept = dto.ShortfallCent <= 0
|
||||
dto.CanCounter = round < checkoutMaxRounds
|
||||
}
|
||||
if isRenter && turn == checkoutTurnRenter && order.Status == orderStatusPendingCheckoutAccept {
|
||||
if isRenter && turn == checkoutTurnRenter && isRenterPending {
|
||||
dto.CanAccept = dto.ShortfallCent <= 0
|
||||
dto.CanCounter = round < checkoutMaxRounds
|
||||
}
|
||||
|
||||
@@ -1385,6 +1385,145 @@ func TestAdminForceHandoffStartsNormalOwnerOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminForceHandoffAbnormalOwnerTimeout(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
|
||||
t.Fatalf("migrate disputes failed: %v", err)
|
||||
}
|
||||
repo := NewRepository(db)
|
||||
|
||||
owner := model.User{Phone: "13800003051"}
|
||||
renter := model.User{Phone: "13900003051"}
|
||||
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)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-FORCE-ABNORMAL-001",
|
||||
ListingID: 1,
|
||||
AccountID: 1,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusAbnormal,
|
||||
HandoffStatus: handoffStatusOwnerTimeout,
|
||||
HandoffMode: handoffModeOwner,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
|
||||
if !canAdminForceHandoff(order) {
|
||||
t.Fatal("expected canAdminForceHandoff to be true for abnormal owner_timeout")
|
||||
}
|
||||
|
||||
err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{
|
||||
Content: "客服核实账号已给租客",
|
||||
Reason: "超时后人工介入交接",
|
||||
}, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("AdminForceHandoff() error = %v", err)
|
||||
}
|
||||
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("load order failed: %v", err)
|
||||
}
|
||||
if saved.Status != orderStatusRenting || saved.HandoffStatus != handoffStatusReceived {
|
||||
t.Fatalf("status = %s/%s, want renting/received", saved.Status, saved.HandoffStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerConfirmCheckoutAfterTimeout(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
owner := model.User{Phone: "13800003052"}
|
||||
renter := model.User{Phone: "13900003052"}
|
||||
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)
|
||||
}
|
||||
|
||||
account := model.GameAccount{
|
||||
OwnerID: owner.ID,
|
||||
ServerRegion: "国服",
|
||||
LoginPlatform: "steam",
|
||||
Title: "测试超时结账账号",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("create account failed: %v", err)
|
||||
}
|
||||
|
||||
listing := model.RentalListing{
|
||||
OwnerID: owner.ID,
|
||||
AccountID: account.ID,
|
||||
Status: listingStatusRented,
|
||||
ReviewStatus: listingReviewStatusApproved,
|
||||
PriceCent: 1000,
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("create listing failed: %v", err)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-CHECKOUT-TIMEOUT-001",
|
||||
ListingID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusAbnormal,
|
||||
HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout,
|
||||
SettlementStatus: settlementStatusPending,
|
||||
RentAmountCent: 1000,
|
||||
DepositAmountCent: 0,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
InitiatedBy: renter.ID,
|
||||
ProposedBy: renter.ID,
|
||||
Turn: checkoutTurnOwner,
|
||||
RoundCount: 1,
|
||||
Status: checkoutStatusSubmitted,
|
||||
DepositAmountCent: 0,
|
||||
RentAmountCent: 1000,
|
||||
OwnerIncomeAmountCent: 1000,
|
||||
RenterRefundAmountCent: 0,
|
||||
ShortfallCent: 0,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("create checkout failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证前端 flags 在超时状态下仍正确开放
|
||||
checkoutDTO := toCheckoutDTOForUser(checkout, owner.ID, order)
|
||||
if !checkoutDTO.CanAccept {
|
||||
t.Fatal("expected CanAccept to be true for owner after timeout")
|
||||
}
|
||||
|
||||
// 验证号主超时后补确认结账
|
||||
err := repo.ConfirmCheckout(t.Context(), owner.ID, order.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmCheckout() error = %v", err)
|
||||
}
|
||||
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("load order failed: %v", err)
|
||||
}
|
||||
if saved.Status != orderStatusCompleted {
|
||||
t.Fatalf("order status = %s, want completed", saved.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrderBeforeHandoff(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
if err := db.AutoMigrate(&model.Dispute{}); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user