优化号主交接超时卡死问题
- 号主交接超时(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)
|
||||
|
||||
Reference in New Issue
Block a user