优化号主交接超时卡死问题

- 号主交接超时(owner_timeout)后允许补提交交接说明,避免临时延误导致订单卡死
- 后台新增"重置交接"动作,可将超时订单恢复为待号主交接并刷新计时
- 交接超时改以进入待交接时刻为基准计算,新增 handoff_started_at 字段

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-17 04:16:31 +08:00
co-authored by Claude Opus 4.8
parent ae11dd7b8d
commit 8192344027
13 changed files with 140 additions and 23 deletions
+1 -1
View File
@@ -275,7 +275,7 @@ func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg t
} }
var rows []model.RentalOrder var rows []model.RentalOrder
err := j.db.WithContext(ctx). 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"). Order("id ASC").
Limit(100). Limit(100).
Find(&rows).Error Find(&rows).Error
+1
View File
@@ -14,6 +14,7 @@ type RentalOrder struct {
OwnerID uint64 `gorm:"not null;index" json:"owner_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
RenterID uint64 `gorm:"not null;index" json:"renter_id"` RenterID uint64 `gorm:"not null;index" json:"renter_id"`
RentedAt *time.Time `json:"rented_at"` RentedAt *time.Time `json:"rented_at"`
HandoffStartedAt *time.Time `json:"handoff_started_at"`
EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"`
RentAmountCent int64 `gorm:"not null;default:0" json:"-"` RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
@@ -8,6 +8,7 @@ import (
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { 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 模块走渠道原路退回。 // AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。
func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) { func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
var order model.RentalOrder var order model.RentalOrder
@@ -21,6 +21,7 @@ const (
handoffStatusPendingOwnerCheckout = "pending_owner_checkout" handoffStatusPendingOwnerCheckout = "pending_owner_checkout"
handoffStatusPendingRenterCheckout = "pending_renter_checkout" handoffStatusPendingRenterCheckout = "pending_renter_checkout"
handoffStatusReturned = "returned" handoffStatusReturned = "returned"
handoffStatusOwnerTimeout = "owner_timeout"
handoffStatusCancelled = "cancelled" handoffStatusCancelled = "cancelled"
handoffStatusAdminClosed = "admin_closed" handoffStatusAdminClosed = "admin_closed"
handoffStatusAdminAbnormal = "admin_abnormal" handoffStatusAdminAbnormal = "admin_abnormal"
@@ -53,6 +53,10 @@ func (h *Handler) AdminMarkAbnormal(c *gin.Context) {
h.adminAction(c, h.service.AdminMarkAbnormal, gin.H{"abnormal": true}) 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) { func (h *Handler) AdminRefund(c *gin.Context) {
orderID, ok := parseID(c) orderID, ok := parseID(c)
if !ok { if !ok {
@@ -29,6 +29,8 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消") response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消")
case errors.Is(err, ErrOrderCannotHandoff): case errors.Is(err, ErrOrderCannotHandoff):
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接") 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): case errors.Is(err, ErrOrderCannotReceive):
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号") response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
case errors.Is(err, ErrOrderCannotReturn): case errors.Is(err, ErrOrderCannotReturn):
+9 -2
View File
@@ -20,9 +20,12 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u
if order.OwnerID != userID { if order.OwnerID != userID {
return ErrPermissionDenied return ErrPermissionDenied
} }
if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingOwner { // 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。
if order.Status != orderStatusPendingHandoff ||
(order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) {
return ErrOrderCannotHandoff return ErrOrderCannotHandoff
} }
lateSubmit := order.HandoffStatus == handoffStatusOwnerTimeout
record := model.HandoffRecord{ record := model.HandoffRecord{
OrderID: order.ID, OrderID: order.ID,
FromUserID: order.OwnerID, FromUserID: order.OwnerID,
@@ -35,11 +38,15 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u
} }
order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStatus = handoffStatusPendingRenterConfirm
orderID := order.ID orderID := order.ID
renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。"
if lateSubmit {
renterContent = "号主已补交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。"
}
if err := notification.Append(tx, notification.Entry{ if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID, UserID: order.RenterID,
Type: "handoff", Type: "handoff",
Title: "号主已提交交接说明", Title: "号主已提交交接说明",
Content: "请查看交接记录,确认账号可正常登录后点击确认收号。", Content: renterContent,
BizType: "order", BizType: "order",
BizID: &orderID, BizID: &orderID,
}); err != nil { }); err != nil {
@@ -177,8 +177,10 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
// 租客已通过外部渠道付款,这里不写租客钱包流水。 // 租客已通过外部渠道付款,这里不写租客钱包流水。
orderID = order.ID orderID = order.ID
now := time.Now()
order.Status = orderStatusPendingHandoff order.Status = orderStatusPendingHandoff
order.HandoffStatus = handoffStatusPendingOwner order.HandoffStatus = handoffStatusPendingOwner
order.HandoffStartedAt = &now
markAssetsRented(listing, account) markAssetsRented(listing, account)
conv, err := chat.EnsureOrderConversation(tx, *order) conv, err := chat.EnsureOrderConversation(tx, *order)
if err != nil { if err != nil {
+11
View File
@@ -15,6 +15,7 @@ var (
ErrChannelPaymentRequired = errors.New("channel payment required") ErrChannelPaymentRequired = errors.New("channel payment required")
ErrOrderCannotCancel = errors.New("order cannot cancel") ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotHandoff = errors.New("order cannot handoff") ErrOrderCannotHandoff = errors.New("order cannot handoff")
ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff")
ErrOrderCannotReceive = errors.New("order cannot receive") ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return") ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete") ErrOrderCannotComplete = errors.New("order cannot complete")
@@ -185,6 +186,16 @@ func (s *Service) AdminMarkAbnormal(ctx context.Context, adminID uint64, orderID
return s.repo.AdminMarkAbnormal(ctx, adminID, orderID, req, meta) 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) { func (s *Service) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
+1
View File
@@ -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.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose) 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/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.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus) adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin) adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin)
@@ -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;
@@ -9,6 +9,7 @@ import {
adminMarkOrderAbnormal, adminMarkOrderAbnormal,
adminRefundOrder, adminRefundOrder,
adminRefundStatus, adminRefundStatus,
adminResetHandoff,
fetchAdminHandoffRecords, fetchAdminHandoffRecords,
fetchAdminOrder, fetchAdminOrder,
type HandoffRecord, type HandoffRecord,
@@ -28,7 +29,7 @@ const submitting = ref(false)
const order = ref<Order | null>(null) const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([]) const handoffRecords = ref<HandoffRecord[]>([])
const paymentRecords = ref<AdminPayment[]>([]) const paymentRecords = ref<AdminPayment[]>([])
const actionType = ref<'close' | 'abnormal' | ''>('') const actionType = ref<'close' | 'abnormal' | 'reset' | ''>('')
const reason = ref('') const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null) const refundStatus = ref<RefundStatus | null>(null)
const listingCode = computed(() => const listingCode = computed(() =>
@@ -37,10 +38,20 @@ const listingCode = computed(() =>
const refunding = ref(false) const refunding = ref(false)
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2)) 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( const canOperate = computed(
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status) () => !!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) onMounted(loadOrder)
@@ -64,7 +75,7 @@ async function loadRefundStatus() {
} }
} }
function openAction(type: 'close' | 'abnormal') { function openAction(type: 'close' | 'abnormal' | 'reset') {
actionType.value = type actionType.value = type
reason.value = '' reason.value = ''
} }
@@ -76,6 +87,9 @@ async function submitAction() {
if (actionType.value === 'close') { if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value) await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭') ElMessage.success('订单已关闭')
} else if (actionType.value === 'reset') {
await adminResetHandoff(order.value.id, reason.value)
ElMessage.success('交接已重置,等待号主重新提交')
} else { } else {
await adminMarkOrderAbnormal(order.value.id, reason.value) await adminMarkOrderAbnormal(order.value.id, reason.value)
ElMessage.success('订单已标记异常') ElMessage.success('订单已标记异常')
@@ -197,6 +211,9 @@ function formatHandoffRecordType(type: string) {
<RouterLink :to="adminPath('orders')"> <RouterLink :to="adminPath('orders')">
<el-button>返回列表</el-button> <el-button>返回列表</el-button>
</RouterLink> </RouterLink>
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')"
>重置交接</el-button
>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')" <el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button >标记异常</el-button
> >
@@ -321,6 +321,13 @@ export async function adminMarkOrderAbnormal(id: number, reason: string) {
return data.data return data.data
} }
export async function adminResetHandoff(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Order>>(`/admin/orders/${id}/reset-handoff`, {
reason,
})
return data.data
}
export async function adminRefundStatus(id: number) { export async function adminRefundStatus(id: number) {
const { data } = await apiClient.get<ApiResponse<RefundStatus>>( const { data } = await apiClient.get<ApiResponse<RefundStatus>>(
`/admin/orders/${id}/refund-status` `/admin/orders/${id}/refund-status`