diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 1204bea..35211f9 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -275,7 +275,7 @@ func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg t } var rows []model.RentalOrder err := j.db.WithContext(ctx). - Where("status = ? AND handoff_status = ? AND created_at <= ?", "pending_handoff", "pending_owner", now.Add(-time.Duration(cfg.OwnerSubmitTimeoutMinutes)*time.Minute)). + Where("status = ? AND handoff_status = ? AND COALESCE(handoff_started_at, created_at) <= ?", "pending_handoff", "pending_owner", now.Add(-time.Duration(cfg.OwnerSubmitTimeoutMinutes)*time.Minute)). Order("id ASC"). Limit(100). Find(&rows).Error diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 76355f6..31bd476 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -14,6 +14,7 @@ type RentalOrder struct { OwnerID uint64 `gorm:"not null;index" json:"owner_id"` RenterID uint64 `gorm:"not null;index" json:"renter_id"` RentedAt *time.Time `json:"rented_at"` + HandoffStartedAt *time.Time `json:"handoff_started_at"` EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` RentAmountCent int64 `gorm:"not null;default:0" json:"-"` OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index ce15c79..f7a36b1 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -8,6 +8,7 @@ import ( "hfb_sys/backend/internal/modules/notification" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { @@ -162,6 +163,54 @@ func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, orde }) } +// AdminResetHandoff 将号主交接超时(owner_timeout)的订单重置回待号主交接,并刷新交接计时, +// 让客服可以给号主再次提交交接说明的机会,避免订单卡死在超时状态。 +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 { + return ErrOrderCannotResetHandoff + } + beforeHandoffStatus := order.HandoffStatus + now := time.Now() + order.HandoffStatus = handoffStatusPendingOwner + order.HandoffStartedAt = &now + if err := notification.Append(tx, + notification.Entry{ + UserID: order.OwnerID, + Type: "order_admin", + Title: "请重新提交交接说明", + Content: "客服已重置交接状态,请尽快提交交接说明,避免再次超时。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + notification.Entry{ + UserID: order.RenterID, + Type: "order_admin", + Title: "订单交接已重置", + Content: "客服已重置交接状态,正在等待号主重新提交交接说明。原因:" + 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{ + "order_id": order.ID, + "order_no": order.OrderNo, + "reason": req.Reason, + "before_handoff_status": beforeHandoffStatus, + "after_handoff_status": order.HandoffStatus, + }); err != nil { + return err + } + return tx.Save(&order).Error + }) +} + // AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。 func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) { var order model.RentalOrder diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index bffce7a..f519613 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -21,6 +21,7 @@ const ( handoffStatusPendingOwnerCheckout = "pending_owner_checkout" handoffStatusPendingRenterCheckout = "pending_renter_checkout" handoffStatusReturned = "returned" + handoffStatusOwnerTimeout = "owner_timeout" handoffStatusCancelled = "cancelled" handoffStatusAdminClosed = "admin_closed" handoffStatusAdminAbnormal = "admin_abnormal" diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index 92845fb..11dda71 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -53,6 +53,10 @@ func (h *Handler) AdminMarkAbnormal(c *gin.Context) { h.adminAction(c, h.service.AdminMarkAbnormal, gin.H{"abnormal": true}) } +func (h *Handler) AdminResetHandoff(c *gin.Context) { + h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true}) +} + func (h *Handler) AdminRefund(c *gin.Context) { orderID, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index e33f6b6..79f84aa 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -29,6 +29,8 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消") 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", "仅号主交接超时的订单可重置交接") 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/handoff.go b/backend/internal/modules/order/handoff.go index 1df6377..f9c7b39 100644 --- a/backend/internal/modules/order/handoff.go +++ b/backend/internal/modules/order/handoff.go @@ -20,9 +20,12 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u if order.OwnerID != userID { return ErrPermissionDenied } - if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingOwner { + // 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。 + if order.Status != orderStatusPendingHandoff || + (order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) { return ErrOrderCannotHandoff } + lateSubmit := order.HandoffStatus == handoffStatusOwnerTimeout record := model.HandoffRecord{ OrderID: order.ID, FromUserID: order.OwnerID, @@ -35,11 +38,15 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u } order.HandoffStatus = handoffStatusPendingRenterConfirm orderID := order.ID + renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。" + if lateSubmit { + renterContent = "号主已补交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。" + } if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "handoff", Title: "号主已提交交接说明", - Content: "请查看交接记录,确认账号可正常登录后点击确认收号。", + Content: renterContent, BizType: "order", BizID: &orderID, }); err != nil { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 70263e8..bb31bd9 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -177,8 +177,10 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint // 租客已通过外部渠道付款,这里不写租客钱包流水。 orderID = order.ID + now := time.Now() order.Status = orderStatusPendingHandoff order.HandoffStatus = handoffStatusPendingOwner + order.HandoffStartedAt = &now markAssetsRented(listing, account) conv, err := chat.EnsureOrderConversation(tx, *order) if err != nil { diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index d9d0368..5aec901 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -6,23 +6,24 @@ import ( ) var ( - ErrDependencyUnavailable = errors.New("dependency unavailable") - ErrInvalidRentHours = errors.New("invalid rent hours") - ErrListingUnavailable = errors.New("listing unavailable") - ErrCannotRentOwnListing = errors.New("cannot rent own listing") - ErrInsufficientBalance = errors.New("insufficient balance") - ErrOrderCannotPay = errors.New("order cannot pay") - ErrChannelPaymentRequired = errors.New("channel payment required") - ErrOrderCannotCancel = errors.New("order cannot cancel") - ErrOrderCannotHandoff = errors.New("order cannot handoff") - ErrOrderCannotReceive = errors.New("order cannot receive") - ErrOrderCannotReturn = errors.New("order cannot return") - ErrOrderCannotComplete = errors.New("order cannot complete") - ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") - ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") - ErrCheckoutCannotCounter = errors.New("checkout cannot counter") - ErrInvalidCheckoutAmount = errors.New("invalid checkout amount") - ErrPermissionDenied = errors.New("permission denied") + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidRentHours = errors.New("invalid rent hours") + ErrListingUnavailable = errors.New("listing unavailable") + ErrCannotRentOwnListing = errors.New("cannot rent own listing") + ErrInsufficientBalance = errors.New("insufficient balance") + ErrOrderCannotPay = errors.New("order cannot pay") + ErrChannelPaymentRequired = errors.New("channel payment required") + ErrOrderCannotCancel = errors.New("order cannot cancel") + ErrOrderCannotHandoff = errors.New("order cannot handoff") + ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff") + ErrOrderCannotReceive = errors.New("order cannot receive") + ErrOrderCannotReturn = errors.New("order cannot return") + ErrOrderCannotComplete = errors.New("order cannot complete") + ErrCheckoutCannotSubmit = errors.New("checkout cannot submit") + ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm") + ErrCheckoutCannotCounter = errors.New("checkout cannot counter") + ErrInvalidCheckoutAmount = errors.New("invalid checkout amount") + ErrPermissionDenied = errors.New("permission denied") ) const internalOrderHours = 24 @@ -185,6 +186,16 @@ func (s *Service) AdminMarkAbnormal(ctx context.Context, adminID uint64, orderID return s.repo.AdminMarkAbnormal(ctx, adminID, orderID, req, meta) } +func (s *Service) AdminResetHandoff(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrOrderCannotResetHandoff + } + return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta) +} + func (s *Service) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 1cc4903..43e1fcf 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -487,6 +487,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose) adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal) + adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff) adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund) adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus) adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin) diff --git a/backend/migrations/000006_order_handoff_started_at.sql b/backend/migrations/000006_order_handoff_started_at.sql new file mode 100644 index 0000000..127dbc9 --- /dev/null +++ b/backend/migrations/000006_order_handoff_started_at.sql @@ -0,0 +1,15 @@ +-- +goose Up + +-- 新增进入“待号主交接”的时间,用于精确计算号主交接超时(以支付完成时刻为基准,而非订单创建时刻)。 +ALTER TABLE rental_orders + ADD COLUMN handoff_started_at DATETIME NULL COMMENT '进入待号主交接的时间,用于计算交接超时' AFTER rented_at; + +-- 历史数据兜底:已进入交接及之后阶段的订单,用 created_at 回填,避免基准缺失。 +UPDATE rental_orders +SET handoff_started_at = created_at +WHERE handoff_started_at IS NULL + AND status NOT IN ('pending_payment', 'cancelled'); + +-- +goose Down + +ALTER TABLE rental_orders DROP COLUMN handoff_started_at; diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index ee2d6a4..a4ba560 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -9,6 +9,7 @@ import { adminMarkOrderAbnormal, adminRefundOrder, adminRefundStatus, + adminResetHandoff, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, @@ -28,7 +29,7 @@ const submitting = ref(false) const order = ref(null) const handoffRecords = ref([]) const paymentRecords = ref([]) -const actionType = ref<'close' | 'abnormal' | ''>('') +const actionType = ref<'close' | 'abnormal' | 'reset' | ''>('') const reason = ref('') const refundStatus = ref(null) const listingCode = computed(() => @@ -37,10 +38,20 @@ const listingCode = computed(() => const refunding = ref(false) const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2)) -const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常')) +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' +) onMounted(loadOrder) @@ -64,7 +75,7 @@ async function loadRefundStatus() { } } -function openAction(type: 'close' | 'abnormal') { +function openAction(type: 'close' | 'abnormal' | 'reset') { actionType.value = type reason.value = '' } @@ -76,6 +87,9 @@ async function submitAction() { if (actionType.value === 'close') { await adminCloseOrder(order.value.id, reason.value) ElMessage.success('订单已关闭') + } else if (actionType.value === 'reset') { + await adminResetHandoff(order.value.id, reason.value) + ElMessage.success('交接已重置,等待号主重新提交') } else { await adminMarkOrderAbnormal(order.value.id, reason.value) ElMessage.success('订单已标记异常') @@ -197,6 +211,9 @@ function formatHandoffRecordType(type: string) { 返回列表 + 重置交接 标记异常 diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index f6b7711..08bc3a4 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -321,6 +321,13 @@ export async function adminMarkOrderAbnormal(id: number, reason: string) { return data.data } +export async function adminResetHandoff(id: number, reason: string) { + const { data } = await apiClient.post>(`/admin/orders/${id}/reset-handoff`, { + reason, + }) + return data.data +} + export async function adminRefundStatus(id: number) { const { data } = await apiClient.get>( `/admin/orders/${id}/refund-status`