优化号主交接超时卡死问题
- 号主交接超时(owner_timeout)后允许补提交交接说明,避免临时延误导致订单卡死 - 后台新增"重置交接"动作,可将超时订单恢复为待号主交接并刷新计时 - 交接超时改以进入待交接时刻为基准计算,新增 handoff_started_at 字段 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae11dd7b8d
commit
8192344027
@@ -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
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
adminRefundOrder,
|
||||
adminRefundStatus,
|
||||
adminResetHandoff,
|
||||
fetchAdminHandoffRecords,
|
||||
fetchAdminOrder,
|
||||
type HandoffRecord,
|
||||
@@ -28,7 +29,7 @@ const submitting = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
const handoffRecords = ref<HandoffRecord[]>([])
|
||||
const paymentRecords = ref<AdminPayment[]>([])
|
||||
const actionType = ref<'close' | 'abnormal' | ''>('')
|
||||
const actionType = ref<'close' | 'abnormal' | 'reset' | ''>('')
|
||||
const reason = ref('')
|
||||
const refundStatus = ref<RefundStatus | null>(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) {
|
||||
<RouterLink :to="adminPath('orders')">
|
||||
<el-button>返回列表</el-button>
|
||||
</RouterLink>
|
||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')"
|
||||
>重置交接</el-button
|
||||
>
|
||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
||||
>标记异常</el-button
|
||||
>
|
||||
|
||||
@@ -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<ApiResponse<Order>>(`/admin/orders/${id}/reset-handoff`, {
|
||||
reason,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function adminRefundStatus(id: number) {
|
||||
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(
|
||||
`/admin/orders/${id}/refund-status`
|
||||
|
||||
Reference in New Issue
Block a user