From 72285ed638eeb7a6593ad0fad413e584e1228367 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 19 Jun 2026 18:36:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B6=85=E6=97=B6=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E9=87=8D=E7=BD=AE=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/jobs/ordertimeout/job.go | 45 ++++- .../internal/jobs/ordertimeout/job_test.go | 97 ++++++++++ .../internal/modules/order/admin_actions.go | 84 ++++++++- backend/internal/modules/order/checkout.go | 2 + backend/internal/modules/order/constants.go | 27 +-- backend/internal/modules/order/dto.go | 10 + .../internal/modules/order/handler_error.go | 2 +- backend/internal/modules/order/presenter.go | 15 ++ .../order/repository_integration_test.go | 174 ++++++++++++++++++ .../admin/views/AdminOrderDetailView.vue | 29 ++- frontend/src/features/orders/api/orders.ts | 10 + 11 files changed, 449 insertions(+), 46 deletions(-) diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index f8887dc..0f031d7 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -347,6 +347,8 @@ func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg if cfg.RenterConfirmTimeoutMinutes <= 0 { return 0, nil } + deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute) + var rows []model.RentalOrder err := j.db.WithContext(ctx). Where("status = ? AND handoff_status = ?", "pending_handoff", "pending_renter_confirm"). @@ -356,15 +358,42 @@ func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg if err != nil { return 0, err } + if len(rows) == 0 { + return 0, nil + } + + // 批量查询所有订单的最新交接记录,减少 N+1 查询 + orderIDs := make([]uint64, len(rows)) + for i, row := range rows { + orderIDs[i] = row.ID + } + var handoffs []model.HandoffRecord + if err := j.db.WithContext(ctx). + Where("order_id IN ? AND type = ?", orderIDs, "owner_handoff"). + Order("order_id ASC, id DESC"). + Find(&handoffs).Error; err != nil { + return 0, err + } + // 构建 order_id -> 最新交接记录的映射 + latestHandoff := make(map[uint64]model.HandoffRecord, len(handoffs)) + for _, h := range handoffs { + if _, exists := latestHandoff[h.OrderID]; !exists { + latestHandoff[h.OrderID] = h + } + } + count := 0 - deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute) for _, row := range rows { - var handoff model.HandoffRecord - err := j.db.WithContext(ctx). - Where("order_id = ? AND type = ?", row.ID, "owner_handoff"). - Order("id DESC"). - First(&handoff).Error - if err != nil || handoff.CreatedAt.After(deadline) { + handoff, exists := latestHandoff[row.ID] + if !exists { + continue + } + // 判断阶段开始时间:优先使用 HandoffStartedAt(管理员重置后刷新的时间) + stageStart := handoff.CreatedAt + if row.HandoffStartedAt != nil && row.HandoffStartedAt.After(handoff.CreatedAt) { + stageStart = *row.HandoffStartedAt + } + if stageStart.After(deadline) { continue } if err := j.updateOrder(ctx, row.ID, "order.timeout.renter_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { @@ -460,7 +489,7 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time } var rows []model.RentalOrder err := j.db.WithContext(ctx). - Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)). + Where("status = ? AND handoff_status = ? AND COALESCE(handoff_started_at, updated_at) <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)). Order("id ASC"). Limit(100). Find(&rows).Error diff --git a/backend/internal/jobs/ordertimeout/job_test.go b/backend/internal/jobs/ordertimeout/job_test.go index 696265c..215190c 100644 --- a/backend/internal/jobs/ordertimeout/job_test.go +++ b/backend/internal/jobs/ordertimeout/job_test.go @@ -23,6 +23,8 @@ func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB { } if err := db.AutoMigrate( &model.RentalOrder{}, + &model.HandoffRecord{}, + &model.Notification{}, &model.ChatConversation{}, &model.ChatParticipant{}, &model.ChatMessage{}, @@ -152,6 +154,101 @@ func TestHandleEndedOrderRenterRetention(t *testing.T) { } } +func TestHandleRenterConfirmTimeoutUsesRefreshedStageTime(t *testing.T) { + db := setupOrderTimeoutTestDB(t) + job := New(db, nil, zap.NewNop()) + now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) + oldHandoffAt := now.Add(-2 * time.Hour) + refreshedAt := now.Add(-5 * time.Minute) + + order := model.RentalOrder{ + OrderNo: "ORDER-RENTER-CONFIRM-RESET", + ListingID: 101, + AccountID: 201, + OwnerID: 301, + RenterID: 401, + Status: "pending_handoff", + HandoffStatus: "pending_renter_confirm", + HandoffStartedAt: &refreshedAt, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: order.OwnerID, + ToUserID: order.RenterID, + Type: "owner_handoff", + Content: "交接说明", + CreatedAt: oldHandoffAt, + } + if err := db.Create(&record).Error; err != nil { + t.Fatalf("创建交接记录失败: %v", err) + } + + count, err := job.handleRenterConfirmTimeout(t.Context(), now, thresholds{ + RenterConfirmTimeoutMinutes: 30, + }) + if err != nil { + t.Fatalf("确认收号超时任务失败: %v", err) + } + if count != 0 { + t.Fatalf("处理数量 = %d, want 0", count) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if saved.Status != "pending_handoff" || saved.HandoffStatus != "pending_renter_confirm" { + t.Fatalf("订单状态 = %s/%s, want pending_handoff/pending_renter_confirm", saved.Status, saved.HandoffStatus) + } +} + +func TestHandleOwnerReturnConfirmTimeoutUsesStageTime(t *testing.T) { + db := setupOrderTimeoutTestDB(t) + job := New(db, nil, zap.NewNop()) + now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) + oldUpdatedAt := now.Add(-2 * time.Hour) + refreshedAt := now.Add(-5 * time.Minute) + + order := model.RentalOrder{ + OrderNo: "ORDER-CHECKOUT-CONFIRM-RESET", + ListingID: 102, + AccountID: 202, + OwnerID: 302, + RenterID: 402, + Status: "pending_checkout_confirm", + HandoffStatus: "pending_owner_checkout", + HandoffStartedAt: &refreshedAt, + UpdatedAt: oldUpdatedAt, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + if err := db.Model(&model.RentalOrder{}).Where("id = ?", order.ID).Update("updated_at", oldUpdatedAt).Error; err != nil { + t.Fatalf("回写旧更新时间失败: %v", err) + } + + count, err := job.handleOwnerReturnConfirmTimeout(t.Context(), now, thresholds{ + OwnerReturnConfirmTimeoutMinutes: 30, + }) + if err != nil { + t.Fatalf("号主确认结账超时任务失败: %v", err) + } + if count != 0 { + t.Fatalf("处理数量 = %d, want 0", count) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if saved.Status != "pending_checkout_confirm" || saved.HandoffStatus != "pending_owner_checkout" { + t.Fatalf("订单状态 = %s/%s, want pending_checkout_confirm/pending_owner_checkout", saved.Status, saved.HandoffStatus) + } +} + func TestClampRenterRetentionDays(t *testing.T) { tests := []struct { value int diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index f7a36b1..6f151aa 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -163,45 +163,64 @@ func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, orde }) } -// AdminResetHandoff 将号主交接超时(owner_timeout)的订单重置回待号主交接,并刷新交接计时, -// 让客服可以给号主再次提交交接说明的机会,避免订单卡死在超时状态。 +type adminResetTarget struct { + OrderStatus string + HandoffStatus string + Label string + OwnerTitle string + OwnerContent string + RenterTitle string + RenterContent string + AuditAction string + RefreshStageTime bool +} + +// AdminResetHandoff 将超时卡住的订单恢复到对应待办阶段,并刷新阶段计时, +// 让客服可以给责任方再次处理机会,避免订单卡死在异常状态。 func (r *Repository) AdminResetHandoff(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err } - if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusOwnerTimeout { + target, ok := resetTargetForOrder(order) + if !ok { return ErrOrderCannotResetHandoff } + beforeOrderStatus := order.Status beforeHandoffStatus := order.HandoffStatus now := time.Now() - order.HandoffStatus = handoffStatusPendingOwner - order.HandoffStartedAt = &now + order.Status = target.OrderStatus + order.HandoffStatus = target.HandoffStatus + if target.RefreshStageTime { + order.HandoffStartedAt = &now + } if err := notification.Append(tx, notification.Entry{ UserID: order.OwnerID, Type: "order_admin", - Title: "请重新提交交接说明", - Content: "客服已重置交接状态,请尽快提交交接说明,避免再次超时。原因:" + req.Reason, + Title: target.OwnerTitle, + Content: target.OwnerContent + "原因:" + req.Reason, BizType: "order", BizID: &order.ID, }, notification.Entry{ UserID: order.RenterID, Type: "order_admin", - Title: "订单交接已重置", - Content: "客服已重置交接状态,正在等待号主重新提交交接说明。原因:" + req.Reason, + Title: target.RenterTitle, + Content: target.RenterContent + "原因:" + req.Reason, BizType: "order", BizID: &order.ID, }, ); err != nil { return err } - if err := appendAuditLog(tx, adminID, "order.reset_handoff", "order", order.ID, meta, map[string]any{ + if err := appendAuditLog(tx, adminID, target.AuditAction, "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, "reason": req.Reason, + "before_order_status": beforeOrderStatus, + "after_order_status": order.Status, "before_handoff_status": beforeHandoffStatus, "after_handoff_status": order.HandoffStatus, }); err != nil { @@ -211,6 +230,51 @@ func (r *Repository) AdminResetHandoff(ctx context.Context, adminID uint64, orde }) } +func resetTargetForOrder(order model.RentalOrder) (adminResetTarget, bool) { + if order.Status == orderStatusPendingHandoff && order.HandoffStatus == handoffStatusOwnerTimeout { + return adminResetTarget{ + OrderStatus: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingOwner, + Label: "重置交接", + OwnerTitle: "请重新提交交接说明", + OwnerContent: "客服已重置交接状态,请尽快提交交接说明,避免再次超时。", + RenterTitle: "订单交接已重置", + RenterContent: "客服已重置交接状态,正在等待号主重新提交交接说明。", + AuditAction: "order.reset_handoff", + RefreshStageTime: true, + }, true + } + if order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout { + return adminResetTarget{ + OrderStatus: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingRenterConfirm, + Label: "重置确认收号", + OwnerTitle: "订单确认收号已重置", + OwnerContent: "客服已重置确认收号状态,正在等待租客重新确认收号。", + RenterTitle: "请重新确认收号", + RenterContent: "客服已重置确认收号状态,请尽快检查账号并确认收号,避免再次超时。", + AuditAction: "order.reset_renter_confirm", + RefreshStageTime: true, + }, true + } + if order.Status == orderStatusAbnormal && + (order.HandoffStatus == handoffStatusOwnerCheckoutConfirmTimeout || + order.HandoffStatus == handoffStatusOwnerReturnConfirmTimeout) { + return adminResetTarget{ + OrderStatus: orderStatusPendingCheckoutConfirm, + HandoffStatus: handoffStatusPendingOwnerCheckout, + Label: "重置结账确认", + OwnerTitle: "请重新确认结账", + OwnerContent: "客服已重置结账确认状态,请尽快核对账号和结账金额,避免再次超时。", + RenterTitle: "结账确认已重置", + RenterContent: "客服已重置结账确认状态,正在等待号主重新确认结账。", + AuditAction: "order.reset_checkout_confirm", + RefreshStageTime: true, + }, true + } + return adminResetTarget{}, false +} + // AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。 func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) { var order model.RentalOrder diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index 7c7f8fd..2241d65 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -51,6 +51,8 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID order.Status = orderStatusPendingCheckoutConfirm order.HandoffStatus = handoffStatusPendingOwnerCheckout order.SettlementStatus = settlementStatusPending + now := time.Now() + order.HandoffStartedAt = &now orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.OwnerID, diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index f519613..a6bb424 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -13,18 +13,21 @@ const ( orderStatusClosed = "closed" orderStatusAbnormal = "abnormal" - handoffStatusNone = "none" - handoffStatusPendingOwner = "pending_owner" - handoffStatusPendingRenterConfirm = "pending_renter_confirm" - handoffStatusReceived = "received" - handoffStatusReturnOverdue = "return_overdue" - handoffStatusPendingOwnerCheckout = "pending_owner_checkout" - handoffStatusPendingRenterCheckout = "pending_renter_checkout" - handoffStatusReturned = "returned" - handoffStatusOwnerTimeout = "owner_timeout" - handoffStatusCancelled = "cancelled" - handoffStatusAdminClosed = "admin_closed" - handoffStatusAdminAbnormal = "admin_abnormal" + handoffStatusNone = "none" + handoffStatusPendingOwner = "pending_owner" + handoffStatusPendingRenterConfirm = "pending_renter_confirm" + handoffStatusReceived = "received" + handoffStatusReturnOverdue = "return_overdue" + handoffStatusPendingOwnerCheckout = "pending_owner_checkout" + handoffStatusPendingRenterCheckout = "pending_renter_checkout" + handoffStatusReturned = "returned" + handoffStatusOwnerTimeout = "owner_timeout" + handoffStatusRenterConfirmTimeout = "renter_confirm_timeout" + handoffStatusOwnerReturnConfirmTimeout = "owner_return_confirm_timeout" + handoffStatusOwnerCheckoutConfirmTimeout = "owner_checkout_confirm_timeout" + handoffStatusCancelled = "cancelled" + handoffStatusAdminClosed = "admin_closed" + handoffStatusAdminAbnormal = "admin_abnormal" settlementStatusUnsettled = "unsettled" settlementStatusPending = "pending" diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 8cfbdfc..27ae838 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -38,11 +38,21 @@ type OrderDTO struct { SettlementStatus string `json:"settlement_status"` ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"` Checkout *CheckoutDTO `json:"checkout,omitempty"` + AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"` PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +type AdminActionsDTO struct { + ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"` +} + +type AdminActionDTO struct { + Enabled bool `json:"enabled"` + Label string `json:"label"` +} + type ActiveDisputeDTO struct { ID uint64 `json:"id"` InitiatorID uint64 `json:"initiator_id"` diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index 79f84aa..a9aec55 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -30,7 +30,7 @@ func writeOrderError(c *gin.Context, err error) { case errors.Is(err, ErrOrderCannotHandoff): response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接") case errors.Is(err, ErrOrderCannotResetHandoff): - response.Error(c, http.StatusConflict, "order_cannot_reset_handoff", "仅号主交接超时的订单可重置交接") + response.Error(c, http.StatusConflict, "order_cannot_reset_handoff", "仅超时卡住的订单可重置到对应待办阶段") case errors.Is(err, ErrOrderCannotReceive): response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号") case errors.Is(err, ErrOrderCannotReturn): diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 3cc14c6..c3eef67 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -8,6 +8,19 @@ import ( "gorm.io/datatypes" ) +func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO { + target, ok := resetTargetForOrder(order) + if !ok { + return nil + } + return &AdminActionsDTO{ + ResetHandoff: &AdminActionDTO{ + Enabled: true, + Label: target.Label, + }, + } +} + func (row orderRow) toAdminDTO() OrderDTO { rentedAt := row.RentedAt durationHours := orderDurationHours(row.RentalOrder) @@ -43,6 +56,7 @@ func (row orderRow) toAdminDTO() OrderDTO { Status: row.Status, HandoffStatus: row.HandoffStatus, SettlementStatus: row.SettlementStatus, + AdminActions: adminActionsForOrder(row.RentalOrder), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } @@ -50,6 +64,7 @@ func (row orderRow) toAdminDTO() OrderDTO { func (row orderRow) toDTOForUser(userID uint64) OrderDTO { dto := row.toAdminDTO() + dto.AdminActions = nil applyOrderPriceView(&dto, row.RentalOrder, userID) return dto } diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 0b0a5ab..dddde45 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -30,6 +30,7 @@ func setupOrderTestDB(t *testing.T) *gorm.DB { &model.Notification{}, &model.HandoffRecord{}, &model.OrderCheckout{}, + &model.AuditLog{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) } @@ -555,3 +556,176 @@ func TestCancelClosesPendingPaymentOrder(t *testing.T) { t.Fatalf("payment status = %q, want closed", latestPayment.Status) } } + +func TestAdminResetCheckoutConfirmTimeout(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800003001"} + renter := model.User{Phone: "13900003001"} + 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-RESET-CHECKOUT-001", + ListingID: 1, + AccountID: 1, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout, + SettlementStatus: settlementStatusPending, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + err := repo.AdminResetHandoff(t.Context(), 1, order.ID, AdminActionRequest{Reason: "号主已联系上"}, AuditMeta{}) + if err != nil { + t.Fatalf("AdminResetHandoff() 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 != orderStatusPendingCheckoutConfirm { + t.Fatalf("status = %q, want %q", saved.Status, orderStatusPendingCheckoutConfirm) + } + if saved.HandoffStatus != handoffStatusPendingOwnerCheckout { + t.Fatalf("handoff status = %q, want %q", saved.HandoffStatus, handoffStatusPendingOwnerCheckout) + } + if saved.HandoffStartedAt == nil { + t.Fatal("handoff started at should be refreshed") + } +} + +func TestAdminOrderDTOIncludesResetAction(t *testing.T) { + order := model.RentalOrder{ + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout, + } + dto := orderRow{RentalOrder: order}.toAdminDTO() + if dto.AdminActions == nil || dto.AdminActions.ResetHandoff == nil { + t.Fatal("reset handoff action should be present") + } + if !dto.AdminActions.ResetHandoff.Enabled { + t.Fatal("reset handoff action should be enabled") + } + if dto.AdminActions.ResetHandoff.Label != "重置结账确认" { + t.Fatalf("label = %q, want 重置结账确认", dto.AdminActions.ResetHandoff.Label) + } +} + +func TestUserOrderDTODoesNotIncludeAdminActions(t *testing.T) { + order := model.RentalOrder{ + OwnerID: 1, + RenterID: 2, + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusOwnerCheckoutConfirmTimeout, + } + dto := orderRow{RentalOrder: order}.toDTOForUser(order.RenterID) + if dto.AdminActions != nil { + t.Fatal("user order dto should not include admin actions") + } +} + +func TestAdminResetRenterConfirmTimeoutRefreshesStageTime(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800003002"} + renter := model.User{Phone: "13900003002"} + 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-RESET-RECEIVE-001", + ListingID: 1, + AccountID: 1, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusRenterConfirmTimeout, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + err := repo.AdminResetHandoff(t.Context(), 1, order.ID, AdminActionRequest{Reason: "租客申请重新确认"}, AuditMeta{}) + if err != nil { + t.Fatalf("AdminResetHandoff() 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 != orderStatusPendingHandoff { + t.Fatalf("status = %q, want %q", saved.Status, orderStatusPendingHandoff) + } + if saved.HandoffStatus != handoffStatusPendingRenterConfirm { + t.Fatalf("handoff status = %q, want %q", saved.HandoffStatus, handoffStatusPendingRenterConfirm) + } + if saved.HandoffStartedAt == nil { + t.Fatal("handoff started at should be refreshed") + } +} + +func TestSubmitCheckoutRefreshesStageTime(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800003003"} + renter := model.User{Phone: "13900003003"} + 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) + } + + oldStageTime := time.Now().Add(-2 * time.Hour) + order := model.RentalOrder{ + OrderNo: "ORD-CHECKOUT-STAGE-001", + ListingID: 1, + AccountID: 1, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusRenting, + HandoffStatus: handoffStatusReceived, + HandoffStartedAt: &oldStageTime, + RentAmountCent: 1000, + OwnerRentAmountCent: 800, + DepositAmountCent: 1000, + PlatformFeeCent: 200, + EstimatedDurationHours: 24, + AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000,"asset_summary":{"price_breakdown":{"buyer_coin_base_price":10,"seller_coin_base_price":8}}}`)), + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + if _, err := repo.SubmitCheckout(t.Context(), renter.ID, order.ID, SubmitCheckoutRequest{Content: "申请结账"}); err != nil { + t.Fatalf("SubmitCheckout() 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 != orderStatusPendingCheckoutConfirm || saved.HandoffStatus != handoffStatusPendingOwnerCheckout { + t.Fatalf("status = %s/%s, want pending_checkout_confirm/pending_owner_checkout", saved.Status, saved.HandoffStatus) + } + if saved.HandoffStartedAt == nil || !saved.HandoffStartedAt.After(oldStageTime) { + t.Fatalf("handoff started at = %#v, should be refreshed after old stage time", saved.HandoffStartedAt) + } +} diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 28fad20..ac6a003 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -62,20 +62,19 @@ const returnLabel = computed(() => const refunding = ref(false) const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2)) -const actionTitle = computed(() => { - if (actionType.value === 'close') return '客服关闭订单' - if (actionType.value === 'reset') return '重置交接(给号主再次机会)' - return '标记订单异常' -}) const canOperate = computed( () => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status) ) -const canResetHandoff = computed( - () => - !!order.value && - order.value.status === 'pending_handoff' && - order.value.handoff_status === 'owner_timeout' -) +const resetAction = computed(() => order.value?.admin_actions?.reset_handoff) +const resetActionLabel = computed(() => { + return resetAction.value?.label || '重置' +}) +const canResetHandoff = computed(() => resetAction.value?.enabled === true) +const actionTitle = computed(() => { + if (actionType.value === 'close') return '客服关闭订单' + if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)` + return '标记订单异常' +}) onMounted(loadOrder) @@ -113,7 +112,7 @@ async function submitAction() { ElMessage.success('订单已关闭') } else if (actionType.value === 'reset') { await adminResetHandoff(order.value.id, reason.value) - ElMessage.success('交接已重置,等待号主重新提交') + ElMessage.success(`${resetActionLabel.value}成功`) } else { await adminMarkOrderAbnormal(order.value.id, reason.value) ElMessage.success('订单已标记异常') @@ -241,9 +240,9 @@ function formatHandoffRecordType(type: string) { {{ returnLabel }} - 重置交接 + + {{ resetActionLabel }} + 标记异常 diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 7730ba0..f4fb15a 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -36,11 +36,21 @@ export interface Order { settlement_status: SettlementStatus active_dispute?: ActiveDispute checkout?: Checkout + admin_actions?: AdminActions payment_deadline_at?: string created_at: string updated_at: string } +export interface AdminActions { + reset_handoff?: AdminAction +} + +export interface AdminAction { + enabled: boolean + label: string +} + export interface ActiveDispute { id: number initiator_id: number