diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index d2dde8f..2f3f218 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -80,6 +80,7 @@ type OrderDTO struct { type AdminActionsDTO struct { ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"` PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"` + ForceHandoff *AdminActionDTO `json:"force_handoff,omitempty"` PlatformCheckoutConfirm *AdminActionDTO `json:"platform_checkout_confirm,omitempty"` PlatformCheckoutCounter *AdminActionDTO `json:"platform_checkout_counter,omitempty"` PlatformCheckoutDispute *AdminActionDTO `json:"platform_checkout_dispute,omitempty"` @@ -138,6 +139,12 @@ type PlatformHandoffRequest struct { Reason string `json:"reason" binding:"required"` } +// ForceHandoffRequest 客服确认普通号主订单已完成线下交接。 +type ForceHandoffRequest struct { + Content string `json:"content"` + Reason string `json:"reason" binding:"required"` +} + type OfflineSettlementRequest struct { Remark string `json:"remark"` } diff --git a/backend/internal/modules/order/force_handoff.go b/backend/internal/modules/order/force_handoff.go new file mode 100644 index 0000000..1fb08a5 --- /dev/null +++ b/backend/internal/modules/order/force_handoff.go @@ -0,0 +1,125 @@ +package order + +import ( + "context" + "strings" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func canAdminForceHandoff(order model.RentalOrder) bool { + if isPlatformHandoffOrder(order) || isPlatformSettlementOrder(order) { + return false + } + if order.Status == orderStatusPendingHandoff { + return order.HandoffStatus == handoffStatusPendingOwner || + order.HandoffStatus == handoffStatusOwnerTimeout || + order.HandoffStatus == handoffStatusPendingRenterConfirm + } + return order.Status == orderStatusAbnormal && order.HandoffStatus == handoffStatusRenterConfirmTimeout +} + +func forceHandoffNeedsContent(order model.RentalOrder) bool { + return order.HandoffStatus == handoffStatusPendingOwner || order.HandoffStatus == handoffStatusOwnerTimeout +} + +// AdminForceHandoff 由客服确认普通号主订单已完成线下交接,并立即开始租期。 +func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, 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 !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" { + return ErrOrderCannotForceHandoff + } + if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview { + return ErrOrderCannotForceHandoff + } + + var activeDisputeCount int64 + if err := tx.Model(&model.Dispute{}). + Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}). + Count(&activeDisputeCount).Error; err != nil { + return err + } + if activeDisputeCount > 0 { + return ErrOrderCannotForceHandoff + } + + needsContent := forceHandoffNeedsContent(order) + content := strings.TrimSpace(req.Content) + if needsContent && content == "" { + return ErrOrderCannotForceHandoff + } + if !needsContent { + var handoffCount int64 + if err := tx.Model(&model.HandoffRecord{}). + Where("order_id = ? AND type = ?", order.ID, "owner_handoff"). + Count(&handoffCount).Error; err != nil { + return err + } + if handoffCount == 0 { + return ErrOrderCannotForceHandoff + } + content = "客服已确认双方完成交接,订单已进入使用中。" + } + + beforeOrderStatus := order.Status + beforeHandoffStatus := order.HandoffStatus + now := time.Now() + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: adminID, + ToUserID: order.RenterID, + Type: "admin_force_handoff", + Content: content, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + order.Status = orderStatusRenting + order.HandoffStatus = handoffStatusReceived + order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) + order.RentedAt = &now + orderID := order.ID + if err := notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "order_admin", + Title: "客服已确认交接", + Content: "客服已确认双方完成交接,订单已进入使用中,租期已开始计算。", + BizType: "order", + BizID: &orderID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "order_admin", + Title: "客服已确认交接", + Content: "客服已确认双方完成交接,订单已进入使用中,租期已开始计算。", + BizType: "order", + BizID: &orderID, + }, + ); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, "order.force_handoff", "order", order.ID, meta, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "reason": strings.TrimSpace(req.Reason), + "content_length": len(content), + "before_order_status": beforeOrderStatus, + "after_order_status": order.Status, + "before_handoff_status": beforeHandoffStatus, + "after_handoff_status": order.HandoffStatus, + }); err != nil { + return err + } + return tx.Save(&order).Error + }) +} diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index bef88f3..0571af7 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -99,6 +99,28 @@ func (h *Handler) AdminPlatformHandoff(c *gin.Context) { response.Created(c, record) } +func (h *Handler) AdminForceHandoff(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req ForceHandoffRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "操作原因不能为空") + return + } + if err := h.service.AdminForceHandoff(c.Request.Context(), adminID, id, req, auditMeta(c)); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"received": true}) +} + func (h *Handler) AdminPlatformCheckoutConfirm(c *gin.Context) { h.adminAction(c, h.service.AdminPlatformCheckoutConfirm, gin.H{"confirmed": true}) } diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index 9eb45c2..f8a8c02 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -30,6 +30,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, ErrOrderCannotForceHandoff): + response.Error(c, http.StatusConflict, "order_cannot_force_handoff", "当前订单不能客服一键交接") case errors.Is(err, ErrOrderCannotResetHandoff): response.Error(c, http.StatusConflict, "order_cannot_reset_handoff", "仅超时卡住的订单可重置到对应待办阶段") case errors.Is(err, ErrOrderCannotReceive): diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index c96cdf5..3c3108f 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -23,6 +23,12 @@ func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO { Label: "客服代交接", } } + if canAdminForceHandoff(order) { + actions.ForceHandoff = &AdminActionDTO{ + Enabled: true, + Label: "客服一键交接", + } + } if canAdminPlatformCheckoutConfirm(order) { actions.PlatformCheckoutConfirm = &AdminActionDTO{ Enabled: true, @@ -43,7 +49,7 @@ func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO { Label: "确认线下结算", } } - if actions.ResetHandoff == nil && actions.PlatformHandoff == nil && + if actions.ResetHandoff == nil && actions.PlatformHandoff == nil && actions.ForceHandoff == nil && actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil && actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil { return nil diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 0c30cb9..02770ee 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -1011,6 +1011,155 @@ func TestAdminResetRenterConfirmTimeoutRefreshesStageTime(t *testing.T) { } } +func TestAdminForceHandoffStartsNormalOwnerOrder(t *testing.T) { + db := setupOrderTestDB(t) + if err := db.AutoMigrate(&model.Dispute{}); err != nil { + t.Fatalf("migrate disputes failed: %v", err) + } + repo := NewRepository(db) + + owner := model.User{Phone: "13800003006"} + renter := model.User{Phone: "13900003006"} + 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-FORCE-HANDOFF-001", + ListingID: 1, + AccountID: 1, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingOwner, + HandoffMode: handoffModeOwner, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "daily_loss_m": 50 + }`)), + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{ + Content: "账号已通过群聊完成交接", + Reason: "已核实双方均可正常使用", + }, AuditMeta{}) + if err != nil { + t.Fatalf("AdminForceHandoff() 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 != orderStatusRenting || saved.HandoffStatus != handoffStatusReceived { + t.Fatalf("status = %s/%s, want renting/received", saved.Status, saved.HandoffStatus) + } + if saved.RentedAt == nil || saved.EstimatedDurationHours != 48 { + t.Fatalf("rented at/duration = %#v/%d, want set/48", saved.RentedAt, saved.EstimatedDurationHours) + } + var record model.HandoffRecord + if err := db.Where("order_id = ? AND type = ?", order.ID, "admin_force_handoff").First(&record).Error; err != nil { + t.Fatalf("find force handoff record failed: %v", err) + } + if record.Content != "账号已通过群聊完成交接" { + t.Fatalf("record content = %q", record.Content) + } + var notificationCount int64 + if err := db.Model(&model.Notification{}).Where("biz_type = ? AND biz_id = ?", "order", order.ID).Count(¬ificationCount).Error; err != nil { + t.Fatalf("count notifications failed: %v", err) + } + if notificationCount != 2 { + t.Fatalf("notification count = %d, want 2", notificationCount) + } +} + +func TestAdminForceHandoffUsesExistingOwnerRecordAndRejectsPlatformOrder(t *testing.T) { + db := setupOrderTestDB(t) + if err := db.AutoMigrate(&model.Dispute{}); err != nil { + t.Fatalf("migrate disputes failed: %v", err) + } + repo := NewRepository(db) + + owner := model.User{Phone: "13800003007"} + renter := model.User{Phone: "13900003007"} + 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-FORCE-HANDOFF-002", + ListingID: 2, + AccountID: 2, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusAbnormal, + HandoffStatus: handoffStatusRenterConfirmTimeout, + HandoffMode: handoffModeOwner, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + ownerRecord := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: owner.ID, + ToUserID: renter.ID, + Type: "owner_handoff", + Content: "号主已提交交接说明", + } + if err := db.Create(&ownerRecord).Error; err != nil { + t.Fatalf("create owner handoff record failed: %v", err) + } + + err := repo.AdminForceHandoff(t.Context(), 99, order.ID, ForceHandoffRequest{ + Reason: "已联系双方确认完成交接", + }, AuditMeta{}) + if err != nil { + t.Fatalf("AdminForceHandoff() error = %v", err) + } + if err := db.First(&order, order.ID).Error; err != nil { + t.Fatalf("load order failed: %v", err) + } + if order.Status != orderStatusRenting || order.HandoffStatus != handoffStatusReceived { + t.Fatalf("status = %s/%s, want renting/received", order.Status, order.HandoffStatus) + } + var savedOwnerRecord model.HandoffRecord + if err := db.First(&savedOwnerRecord, ownerRecord.ID).Error; err != nil { + t.Fatalf("load owner handoff record failed: %v", err) + } + if savedOwnerRecord.ConfirmedByRenterAt != nil { + t.Fatal("owner handoff record must not be marked as renter confirmed") + } + + platformOrder := model.RentalOrder{ + OrderNo: "ORD-FORCE-HANDOFF-003", + ListingID: 3, + AccountID: 3, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingOwner, + HandoffMode: handoffModePlatform, + } + if err := db.Create(&platformOrder).Error; err != nil { + t.Fatalf("create platform order failed: %v", err) + } + if err := repo.AdminForceHandoff(t.Context(), 99, platformOrder.ID, ForceHandoffRequest{ + Content: "不应允许", + Reason: "测试", + }, AuditMeta{}); err != ErrOrderCannotForceHandoff { + t.Fatalf("platform order error = %v, want %v", err, ErrOrderCannotForceHandoff) + } +} + func TestSubmitCheckoutRefreshesStageTime(t *testing.T) { db := setupOrderTestDB(t) repo := NewRepository(db) diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 274488e..1f8643f 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -15,6 +15,7 @@ var ( ErrChannelPaymentRequired = errors.New("channel payment required") ErrOrderCannotCancel = errors.New("order cannot cancel") ErrOrderCannotHandoff = errors.New("order cannot handoff") + ErrOrderCannotForceHandoff = errors.New("order cannot force handoff") ErrOrderCannotResetHandoff = errors.New("order cannot reset handoff") ErrOrderCannotReceive = errors.New("order cannot receive") ErrOrderCannotReturn = errors.New("order cannot return") @@ -233,6 +234,16 @@ func (s *Service) AdminPlatformHandoff(ctx context.Context, adminID uint64, orde return s.repo.AdminPlatformHandoff(ctx, adminID, orderID, req, meta) } +func (s *Service) AdminForceHandoff(ctx context.Context, adminID uint64, orderID uint64, req ForceHandoffRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrOrderCannotForceHandoff + } + return s.repo.AdminForceHandoff(ctx, adminID, orderID, req, meta) +} + func (s *Service) AdminPlatformCheckoutConfirm(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 481c66f..2999c9f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -578,6 +578,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { 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/platform-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformHandoff) + adminRoutes.POST("/orders/:id/force-handoff", requirePerm("order:force_handoff"), orderHandler.AdminForceHandoff) adminRoutes.POST("/orders/:id/platform-checkout/confirm", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutConfirm) adminRoutes.POST("/orders/:id/platform-checkout/counter", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutCounter) adminRoutes.POST("/orders/:id/platform-checkout/dispute", requirePerm("order:mark_abnormal"), orderHandler.AdminPlatformCheckoutDispute) diff --git a/backend/migrations/000048_order_force_handoff_permission.sql b/backend/migrations/000048_order_force_handoff_permission.sql new file mode 100644 index 0000000..48d9410 --- /dev/null +++ b/backend/migrations/000048_order_force_handoff_permission.sql @@ -0,0 +1,23 @@ +-- +goose Up + +INSERT INTO permissions (code, name, resource, action) +VALUES ('order:force_handoff', '客服一键交接', 'order', 'force_handoff') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code = 'order:force_handoff' +WHERE r.code IN ('cs', 'ops'); + +-- +goose Down + +DELETE rp +FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'order:force_handoff'; + +DELETE FROM permissions WHERE code = 'order:force_handoff'; diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 9991d3f..9c0a5ce 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -6,6 +6,7 @@ import { useRoute } from 'vue-router' import { adminCloseOrder, + adminForceHandoff, adminHoldDeposit, adminMarkOfflineSettlement, adminMarkOrderAbnormal, @@ -66,6 +67,9 @@ const refundStatus = ref(null) const platformHandoffVisible = ref(false) const platformHandoffContent = ref('') const platformHandoffReason = ref('') +const forceHandoffVisible = ref(false) +const forceHandoffContent = ref('') +const forceHandoffReason = ref('') const platformCheckoutCounterVisible = ref(false) const platformCheckoutCounterForm = ref({ consumableAmountYuan: 0, @@ -126,6 +130,7 @@ const canOperate = computed( ) const resetAction = computed(() => order.value?.admin_actions?.reset_handoff) const platformHandoffAction = computed(() => order.value?.admin_actions?.platform_handoff) +const forceHandoffAction = computed(() => order.value?.admin_actions?.force_handoff) const platformCheckoutConfirmAction = computed( () => order.value?.admin_actions?.platform_checkout_confirm ) @@ -143,6 +148,12 @@ const resetActionLabel = computed(() => { }) const canResetHandoff = computed(() => resetAction.value?.enabled === true) const canPlatformHandoff = computed(() => platformHandoffAction.value?.enabled === true) +const canForceHandoff = computed(() => forceHandoffAction.value?.enabled === true) +const forceHandoffNeedsContent = computed( + () => + order.value?.handoff_status === 'pending_owner' || + order.value?.handoff_status === 'owner_timeout' +) const canPlatformCheckoutConfirm = computed( () => platformCheckoutConfirmAction.value?.enabled === true ) @@ -498,6 +509,39 @@ async function submitPlatformHandoff() { } } +function openForceHandoff() { + forceHandoffContent.value = '' + forceHandoffReason.value = '' + forceHandoffVisible.value = true +} + +async function submitForceHandoff() { + if (!order.value) return + if (!forceHandoffReason.value.trim()) { + ElMessage.warning('请填写客服操作原因') + return + } + if (forceHandoffNeedsContent.value && !forceHandoffContent.value.trim()) { + ElMessage.warning('当前尚无交接说明,请填写交接内容') + return + } + platformSubmitting.value = true + try { + await adminForceHandoff( + order.value.id, + forceHandoffContent.value.trim(), + forceHandoffReason.value.trim() + ) + ElMessage.success('客服已确认交接,订单进入使用中') + forceHandoffVisible.value = false + await loadOrder() + } catch (error) { + ElMessage.error(readError(error, '客服一键交接失败')) + } finally { + platformSubmitting.value = false + } +} + function openPlatformCheckoutCounter() { const checkout = order.value?.checkout platformCheckoutCounterForm.value = { @@ -880,6 +924,9 @@ function paymentPaidAt(record: AdminPayment) { > {{ platformHandoffAction?.label || '客服代交接' }} + + {{ forceHandoffAction?.label || '客服一键交接' }} + + +
+

+ {{ order.order_no }} · 商品编号 {{ listingCode }} · {{ order.title }} +

+ + + +
+ +
+

diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 445a216..35a63d3 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -78,6 +78,7 @@ export interface Order { export interface AdminActions { reset_handoff?: AdminAction platform_handoff?: AdminAction + force_handoff?: AdminAction platform_checkout_confirm?: AdminAction platform_checkout_counter?: AdminAction platform_checkout_dispute?: AdminAction @@ -418,6 +419,14 @@ export async function adminPlatformHandoff(id: number, content: string, reason: return data.data } +export async function adminForceHandoff(id: number, content: string, reason: string) { + const { data } = await apiClient.post>( + `/admin/orders/${id}/force-handoff`, + { content, reason } + ) + return data.data +} + export async function adminPlatformCheckoutConfirm(id: number, reason: string) { const { data } = await apiClient.post>( `/admin/orders/${id}/platform-checkout/confirm`, diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts index 33fc874..f10ce26 100644 --- a/frontend/src/features/orders/composables/useOrderSnapshot.ts +++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts @@ -242,6 +242,7 @@ export function formatHandoffRecordType(type: string) { const typeMap: Record = { owner_handoff: '卖家交接', platform_handoff: '客服代交接', + admin_force_handoff: '客服一键交接', renter_checkout: '买家结账', owner_counter_checkout: '卖家反驳结账', platform_checkout_counter: '客服修改结账',