From be5d322c10ccdcaeb8713678af10277fa0387596 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 29 Jun 2026 22:52:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=9C=8D=E5=B0=81=E5=AD=98?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=8A=9F=E8=83=BD=EF=BC=88=E5=8F=B7=E4=B8=BB?= =?UTF-8?q?=E5=A4=B1=E8=81=94=E5=9C=BA=E6=99=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增客服「封存订单」动作,用于号主失联、无法联系的场景:单事务内 完成关闭订单、全量原路退款、永久封存关联商品、解散发布群、双方通知 与审计日志。 - 新增 listing 终态 sealed(封存),区别于可恢复的 offline - listingLockedForOwnerMutation 锁定 sealed,号主无法再编辑/提审/上架 - 解散群聊按 listing_id 查发布群(listing_group)置 archived, 阻断所有成员发言并留系统消息 - 新增 order.AdminSeal 及 /admin/orders/:id/seal 路由 - 前端:卖家页 PC/移动端 sealed 视为终态禁用操作,客服后台新增 「封存订单」按钮与二次确认 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/modules/chat/adapter.go | 14 +++ backend/internal/modules/chat/support.go | 28 ++++++ backend/internal/modules/listing/mutation.go | 3 +- .../internal/modules/order/admin_actions.go | 94 +++++++++++++++++++ backend/internal/modules/order/assets.go | 7 ++ backend/internal/modules/order/constants.go | 3 + .../internal/modules/order/handler_admin.go | 4 + backend/internal/modules/order/repository.go | 9 ++ backend/internal/modules/order/service.go | 10 ++ backend/internal/router/router.go | 2 + .../admin/views/AdminOrderDetailView.vue | 36 ++++++- frontend/src/features/orders/api/orders.ts | 5 + .../seller/views/MobileSellerListingsView.vue | 1 + .../seller/views/SellerListingsView.vue | 3 +- frontend/src/shared/types/status.ts | 1 + frontend/src/shared/utils/statusLabels.ts | 1 + 16 files changed, 217 insertions(+), 4 deletions(-) diff --git a/backend/internal/modules/chat/adapter.go b/backend/internal/modules/chat/adapter.go index 1f6062c..0889d7a 100644 --- a/backend/internal/modules/chat/adapter.go +++ b/backend/internal/modules/chat/adapter.go @@ -22,3 +22,17 @@ func (a *ListingChatCreatorAdapter) EnsureListingConversation(tx *gorm.DB, listi } return conv.ID, nil } + +// OrderChatArchiverAdapter 适配器,实现 order.OrderChatArchiver 接口, +// 供订单封存时在同一事务内解散关联的发布群。 +type OrderChatArchiverAdapter struct{} + +func NewOrderChatArchiverAdapter() *OrderChatArchiverAdapter { + return &OrderChatArchiverAdapter{} +} + +// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散), +// 写入一条系统消息提示群聊已被客服解散。会话不存在时静默跳过。 +func (a *OrderChatArchiverAdapter) ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error { + return ArchiveListingConversation(tx, listingID, reason) +} diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 9ce124c..7686704 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -2,6 +2,7 @@ package chat import ( "context" + "errors" "fmt" "gorm.io/gorm" "hfb_sys/backend/internal/model" @@ -366,3 +367,30 @@ func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { return } } + +// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 +// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。 +// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。 +func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error { + var conversation model.ChatConversation + err := tx.Where("listing_id = ?", listingID).First(&conversation).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if conversation.Status == "archived" { + return nil + } + if err := tx.Model(&model.ChatConversation{}). + Where("id = ?", conversation.ID). + Update("status", "archived").Error; err != nil { + return err + } + content := "群聊已由客服解散,订单已封存。" + if trimmed := strings.TrimSpace(reason); trimmed != "" { + content += "原因:" + trimmed + } + return sendSystemMessage(tx, conversation.ID, content) +} diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index d3b01eb..d65bb30 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -370,5 +370,6 @@ func listingLockedForOwnerMutation(listing *model.RentalListing) bool { if listing == nil { return true } - return listing.Status == "rented" || listing.Status == "completed" || listing.InTransaction + return listing.Status == "rented" || listing.Status == "completed" || + listing.Status == "sealed" || listing.InTransaction } diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index 1931ff9..44a1cf2 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -440,6 +440,100 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti }) } +// AdminSeal 客服封存订单:关闭订单并全额原路退款,将关联商品/账号置为终态 sealed +// (号主不可再编辑、提审、上架),同时在同一事务内解散关联群聊。 +// 适用于号主失联、无法继续交接,需要终止并永久封存该商品的场景。 +func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + var refund *refundAction + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + assets, err := r.lockOrderAssets(tx, orderID) + if err != nil { + return err + } + order := assets.Order + listing := assets.Listing + account := assets.Account + if isTerminalStatus(order.Status) { + return ErrOrderCannotComplete + } + beforeOrderStatus := order.Status + beforeHandoffStatus := order.HandoffStatus + beforeSettlementStatus := order.SettlementStatus + beforeListingStatus := listing.Status + beforeAccountStatus := account.Status + now := time.Now() + order.Status = orderStatusClosed + order.HandoffStatus = handoffStatusAdminClosed + order.SettlementStatus = settlementStatusClosed + order.SettledAt = &now + sealAssets(listing, account) + if beforeOrderStatus != orderStatusPendingPayment { + totalCent := order.RentAmountCent + order.DepositAmountCent + action, err := r.prepareRefund(order, totalCent, refundBizAdminSeal, "客服封存订单原路退款") + if err != nil { + return err + } + refund = action + } + if r.chatArchiver != nil { + if err := r.chatArchiver.ArchiveListingConversation(tx, listing.ID, req.Reason); err != nil { + return err + } + } + if err := notification.Append(tx, + notification.Entry{ + UserID: order.RenterID, + Type: "order_admin", + Title: "订单已由客服封存", + Content: "客服已封存订单,退款将原路退回您的支付账户。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + notification.Entry{ + UserID: order.OwnerID, + Type: "order_admin", + Title: "订单已由客服封存", + Content: "客服已封存订单,关联商品已永久封存,不可再次编辑或上架。原因:" + req.Reason, + BizType: "order", + BizID: &order.ID, + }, + ); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, "order.admin_seal", "order", order.ID, meta, map[string]any{ + "order_id": order.ID, + "order_no": order.OrderNo, + "listing_id": order.ListingID, + "account_id": order.AccountID, + "reason": req.Reason, + "before_order_status": beforeOrderStatus, + "after_order_status": order.Status, + "before_handoff_status": beforeHandoffStatus, + "after_handoff_status": order.HandoffStatus, + "before_settlement_status": beforeSettlementStatus, + "after_settlement_status": order.SettlementStatus, + "before_listing_status": beforeListingStatus, + "after_listing_status": listing.Status, + "before_account_status": beforeAccountStatus, + "after_account_status": account.Status, + }); err != nil { + return err + } + if err := tx.Save(order).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + return tx.Save(account).Error + }) + if err != nil { + return err + } + r.startRefundBestEffort(ctx, refund) + return nil +} + func isTerminalStatus(status string) bool { return status == orderStatusCompleted || status == orderStatusCancelled || status == orderStatusClosed } diff --git a/backend/internal/modules/order/assets.go b/backend/internal/modules/order/assets.go index bb2dd05..1e112a8 100644 --- a/backend/internal/modules/order/assets.go +++ b/backend/internal/modules/order/assets.go @@ -63,6 +63,13 @@ func archiveAssets(listing *model.RentalListing, account *model.GameAccount) { account.Status = accountStatusOffline } +func sealAssets(listing *model.RentalListing, account *model.GameAccount) { + listing.Status = listingStatusSealed + listing.InTransaction = false + listing.PublishedAt = nil + account.Status = accountStatusSealed +} + func completeAssets(listing *model.RentalListing, account *model.GameAccount) { listing.Status = listingStatusCompleted listing.InTransaction = false diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go index 2877a78..fb74197 100644 --- a/backend/internal/modules/order/constants.go +++ b/backend/internal/modules/order/constants.go @@ -50,17 +50,20 @@ const ( listingStatusOffline = "offline" listingStatusCompleted = "completed" listingStatusAbnormal = "abnormal" + listingStatusSealed = "sealed" accountStatusPublished = "published" accountStatusRented = "rented" accountStatusOffline = "offline" accountStatusAbnormal = "abnormal" + accountStatusSealed = "sealed" walletBizOwnerIncome = "owner_income" walletBizDepositCompensation = "deposit_compensation" refundBizCancel = "cancel_refund" refundBizAdminClose = "admin_close_refund" + refundBizAdminSeal = "admin_seal_refund" refundBizAdmin = "admin_refund" refundBizCheckout = "checkout_refund" ) diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index 3707255..a457f4b 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -62,6 +62,10 @@ func (h *Handler) AdminClose(c *gin.Context) { h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) } +func (h *Handler) AdminSeal(c *gin.Context) { + h.adminAction(c, h.service.AdminSeal, gin.H{"sealed": true}) +} + func (h *Handler) AdminMarkAbnormal(c *gin.Context) { h.adminAction(c, h.service.AdminMarkAbnormal, gin.H{"abnormal": true}) } diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 7b56ac8..515b093 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -21,8 +21,15 @@ type OrderChatNotifier interface { NotifyNewConversation(conversationID uint64) } +// OrderChatArchiver 由 chat 模块适配实现,在订单封存时于同一事务内解散关联群聊。 +// 实际聊天群按 listing 关联(发布群 listing_group),故传 listingID。 +type OrderChatArchiver interface { + ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error +} + type Dependencies struct { ChatNotifier OrderChatNotifier + ChatArchiver OrderChatArchiver RefundStarter RefundStarter } @@ -36,6 +43,7 @@ type refundAction struct { type Repository struct { db *gorm.DB chatNotifier OrderChatNotifier + chatArchiver OrderChatArchiver refundStarter RefundStarter } @@ -45,6 +53,7 @@ func NewRepository(db *gorm.DB, deps ...Dependencies) *Repository { repo := &Repository{db: db} if len(deps) > 0 { repo.chatNotifier = deps[0].ChatNotifier + repo.chatArchiver = deps[0].ChatArchiver repo.refundStarter = deps[0].RefundStarter } return repo diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 9a4cdb8..e089b9d 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -196,6 +196,16 @@ func (s *Service) AdminMarkAbnormal(ctx context.Context, adminID uint64, orderID return s.repo.AdminMarkAbnormal(ctx, adminID, orderID, req, meta) } +func (s *Service) AdminSeal(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if orderID == 0 || req.Reason == "" { + return ErrOrderCannotComplete + } + return s.repo.AdminSeal(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 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 113f885..473038c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -188,6 +188,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { orderRepo = order.NewRepository(deps.DB, order.Dependencies{ ChatNotifier: chatRepo, + ChatArchiver: chat.NewOrderChatArchiverAdapter(), RefundStarter: order.RefundStarterFunc(func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) { if paymentRepo == nil { return "", order.ErrDependencyUnavailable @@ -518,6 +519,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) 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/seal", requirePerm("order:close"), orderHandler.AdminSeal) 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) diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 1202e4f..c0b4a93 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -10,6 +10,7 @@ import { adminRefundOrder, adminRefundStatus, adminResetHandoff, + adminSealOrder, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, @@ -40,7 +41,7 @@ const submitting = ref(false) const order = ref(null) const handoffRecords = ref([]) const paymentRecords = ref([]) -const actionType = ref<'close' | 'abnormal' | 'reset' | ''>('') +const actionType = ref<'close' | 'seal' | 'abnormal' | 'reset' | ''>('') const reason = ref('') const refundStatus = ref(null) @@ -89,10 +90,13 @@ const resetActionLabel = computed(() => { const canResetHandoff = computed(() => resetAction.value?.enabled === true) const actionTitle = computed(() => { if (actionType.value === 'close') return '客服关闭订单' + if (actionType.value === 'seal') return '封存订单' if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)` return '标记订单异常' }) const closeActionTip = '关闭订单并归档商品/账号;已支付订单会按租金+实付押金全量原路退款。' +const sealActionTip = + '封存订单:终止订单并全量原路退款,自动解散关联群聊,关联商品永久封存,号主无法再次编辑或上架。适用于号主失联、无法联系的场景。' const refundActionTip = '仅发起后台人工全量原路退款,不关闭订单或调整商品/账号状态。' const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded') const orderTotalCent = computed( @@ -201,7 +205,7 @@ async function loadRefundStatus() { } } -function openAction(type: 'close' | 'abnormal' | 'reset') { +function openAction(type: 'close' | 'seal' | 'abnormal' | 'reset') { actionType.value = type reason.value = '' } @@ -224,6 +228,24 @@ async function confirmCloseAction() { openAction('close') } +async function confirmSealAction() { + if (!order.value || !canOperate.value) return + try { + await ElMessageBox.confirm( + '封存会终止订单并全量原路退款,自动解散关联群聊,关联商品将被永久封存,号主无法再次编辑或上架。适用于号主失联场景。确认继续?', + '确认封存订单', + { + confirmButtonText: '继续封存', + cancelButtonText: '取消', + type: 'warning', + } + ) + } catch { + return + } + openAction('seal') +} + async function submitAction() { if (!order.value || !actionType.value) return submitting.value = true @@ -231,6 +253,9 @@ async function submitAction() { if (actionType.value === 'close') { await adminCloseOrder(order.value.id, reason.value) ElMessage.success('订单已关闭') + } else if (actionType.value === 'seal') { + await adminSealOrder(order.value.id, reason.value) + ElMessage.success('订单已封存,群聊已解散') } else if (actionType.value === 'reset') { await adminResetHandoff(order.value.id, reason.value) ElMessage.success(`${resetActionLabel.value}成功`) @@ -467,6 +492,13 @@ function paymentPaidAt(record: AdminPayment) { > + + + 封存订单 + + >(`/admin/orders/${id}/seal`, { reason }) + return data.data +} + export async function adminMarkOrderAbnormal(id: number, reason: string) { const { data } = await apiClient.post>(`/admin/orders/${id}/mark-abnormal`, { reason, diff --git a/frontend/src/features/seller/views/MobileSellerListingsView.vue b/frontend/src/features/seller/views/MobileSellerListingsView.vue index cbcf465..a189366 100644 --- a/frontend/src/features/seller/views/MobileSellerListingsView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingsView.vue @@ -162,6 +162,7 @@ function statusTone(status: string) { completed: 'success', rented: 'warning', abnormal: 'danger', + sealed: 'muted', } return tones[status] || 'info' } diff --git a/frontend/src/features/seller/views/SellerListingsView.vue b/frontend/src/features/seller/views/SellerListingsView.vue index f3065e0..783be88 100644 --- a/frontend/src/features/seller/views/SellerListingsView.vue +++ b/frontend/src/features/seller/views/SellerListingsView.vue @@ -152,6 +152,7 @@ function statusTone(status: string) { draft: 'info', offline: 'muted', completed: 'success', + sealed: 'danger', rented: 'warning', abnormal: 'danger', } @@ -177,7 +178,7 @@ function showReviewStatus(row: Listing) { } function isTerminalListing(row: Listing) { - return row.status === 'rented' || row.status === 'completed' + return row.status === 'rented' || row.status === 'completed' || row.status === 'sealed' } function isPendingReview(row: Listing) { diff --git a/frontend/src/shared/types/status.ts b/frontend/src/shared/types/status.ts index 38fb7c7..9ab209d 100644 --- a/frontend/src/shared/types/status.ts +++ b/frontend/src/shared/types/status.ts @@ -5,6 +5,7 @@ export const listingStatuses = [ 'offline', 'completed', 'abnormal', + 'sealed', ] as const export type ListingStatus = (typeof listingStatuses)[number] diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts index 69baf64..03c0bcb 100644 --- a/frontend/src/shared/utils/statusLabels.ts +++ b/frontend/src/shared/utils/statusLabels.ts @@ -20,6 +20,7 @@ const listingStatusMap: Record = { offline: '已下架', completed: '已完成', abnormal: '异常', + sealed: '已封存', } const listingReviewStatusMap: Record = {