修复超时订单重置流程

This commit is contained in:
yml2213
2026-06-19 18:36:46 +08:00
parent 2df935f74c
commit 72285ed638
11 changed files with 449 additions and 46 deletions
+74 -10
View File
@@ -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
@@ -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,
+15 -12
View File
@@ -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"
+10
View File
@@ -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"`
@@ -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):
@@ -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
}
@@ -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)
}
}