From 52dcb18c960c3b7b37a1218d6fa451f1c1e18944 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 25 Aug 2026 19:21:38 +0800 Subject: [PATCH] =?UTF-8?q?perf(chat):=20=E5=88=86=E9=A1=B5=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E4=BC=9A=E8=AF=9D=E5=B9=B6=E6=8B=86=E5=88=86=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/chat.go | 19 ++-- .../internal/modules/chat/admin_list_test.go | 5 +- backend/internal/modules/chat/conversation.go | 3 +- backend/internal/modules/chat/dto.go | 27 +++--- .../internal/modules/chat/handler_admin.go | 21 ++++ .../internal/modules/chat/listing_group.go | 19 ++-- backend/internal/modules/chat/message.go | 24 +++-- backend/internal/modules/chat/participant.go | 17 ++-- backend/internal/modules/chat/service.go | 7 ++ backend/internal/modules/chat/support.go | 42 +++++--- backend/internal/modules/chathub/hub.go | 21 ++-- backend/internal/router/router.go | 1 + .../000057_chat_attention_rules.sql | 37 +++++++ .../features/admin/views/AdminChatsView.vue | 97 ++++++++++++++++--- frontend/src/features/chats/api/chats.ts | 10 ++ .../features/chats/composables/useChatSSE.ts | 1 + 16 files changed, 264 insertions(+), 87 deletions(-) create mode 100644 backend/migrations/000057_chat_attention_rules.sql diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index d21de10..d4cf89d 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -60,15 +60,16 @@ func (ChatAdminConversationState) TableName() string { } type ChatMessage struct { - ID uint64 `gorm:"primaryKey" json:"id"` - ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` - SenderType string `gorm:"size:16;not null" json:"sender_type"` - SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` - SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` - ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` - Content string `json:"content"` - AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` + SenderType string `gorm:"size:16;not null" json:"sender_type"` + SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` + SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` + ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` + Content string `json:"content"` + AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` + AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"` + CreatedAt time.Time `json:"created_at"` } func (ChatMessage) TableName() string { diff --git a/backend/internal/modules/chat/admin_list_test.go b/backend/internal/modules/chat/admin_list_test.go index c4384f5..1341382 100644 --- a/backend/internal/modules/chat/admin_list_test.go +++ b/backend/internal/modules/chat/admin_list_test.go @@ -99,7 +99,10 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) { if item.UnreadCount != 1 { t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount) } - counts := result.Counts.(*AdminConversationCountsDTO) + counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "") + if err != nil { + t.Fatalf("查询客服统计失败: %v", err) + } if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 { t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages) } diff --git a/backend/internal/modules/chat/conversation.go b/backend/internal/modules/chat/conversation.go index af2442d..ed3cfe0 100644 --- a/backend/internal/modules/chat/conversation.go +++ b/backend/internal/modules/chat/conversation.go @@ -60,8 +60,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, var unreadCount int64 if err := db.Table("chat_messages AS cm"). Where("cm.conversation_id = ?", conversation.ID). - Where("cm.sender_type <> ?", "system"). - Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", "admin", principal.ID). + Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user"). Where("cm.id > ?", state.LastReadMessageID). Count(&unreadCount).Error; err != nil { return nil, err diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index 97595f3..dd8c6da 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -54,19 +54,20 @@ type ParticipantDTO struct { } type MessageDTO struct { - ID uint64 `json:"id"` - ConversationID uint64 `json:"conversation_id"` - SenderType string `json:"sender_type"` - SenderID uint64 `json:"sender_id"` - SenderRole string `json:"sender_role"` - SenderName string `json:"sender_name"` - SenderAvatar string `json:"sender_avatar"` - IsSelf bool `json:"is_self"` - IsRead bool `json:"is_read"` - ContentType string `json:"content_type"` - Content string `json:"content"` - AttachmentURLS []string `json:"attachment_urls"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + ConversationID uint64 `json:"conversation_id"` + SenderType string `json:"sender_type"` + SenderID uint64 `json:"sender_id"` + SenderRole string `json:"sender_role"` + SenderName string `json:"sender_name"` + SenderAvatar string `json:"sender_avatar"` + IsSelf bool `json:"is_self"` + IsRead bool `json:"is_read"` + ContentType string `json:"content_type"` + Content string `json:"content"` + AttachmentURLS []string `json:"attachment_urls"` + AdminAttentionType string `json:"admin_attention_type,omitempty"` + CreatedAt time.Time `json:"created_at"` } type UnreadCountDTO struct { diff --git a/backend/internal/modules/chat/handler_admin.go b/backend/internal/modules/chat/handler_admin.go index 1c5dfc2..20407a9 100644 --- a/backend/internal/modules/chat/handler_admin.go +++ b/backend/internal/modules/chat/handler_admin.go @@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) { response.OK(c, result) } +func (h *Handler) AdminCounts(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + principal := Principal{Type: "admin", ID: adminID} + result, err := h.service.AdminConversationCounts( + c.Request.Context(), + principal, + c.DefaultQuery("filter", "all"), + c.DefaultQuery("stage", "all"), + c.Query("keyword"), + ) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, result) +} + func (h *Handler) AdminDetail(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 308b1c4..2da4916 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint if handoffSupportID > 0 { message += ",卖号组客服已接入" } - return sendSystemMessage(tx, conv.ID, message) + return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid") } // RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。 @@ -248,13 +248,18 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string { } func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error { + return sendSystemMessageWithAttention(tx, conversationID, content, "") +} + +func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error { message := model.ChatMessage{ - ConversationID: conversationID, - SenderType: "system", - SenderRole: "system", - ContentType: "system", - Content: content, - AttachmentURLS: emptyJSONList(), + ConversationID: conversationID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: content, + AttachmentURLS: emptyJSONList(), + AdminAttentionType: attentionType, } if err := tx.Create(&message).Error; err != nil { diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index 86aefbf..015b51a 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -104,6 +104,9 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve Content: req.Content, AttachmentURLS: encodeStringList(req.AttachmentURLS), } + if principal.Type == "user" { + message.AdminAttentionType = "user_inquiry" + } if err := tx.Create(&message).Error; err != nil { return err } @@ -146,16 +149,17 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve Type: "new_message", ConversationID: conversationID, Message: &chathub.MessageData{ - ID: msg.ID, - ConversationID: msg.ConversationID, - SenderType: msg.SenderType, - SenderID: msg.SenderID, - SenderRole: msg.SenderRole, - SenderName: msg.SenderName, - ContentType: msg.ContentType, - Content: msg.Content, - AttachmentURLS: msg.AttachmentURLS, - CreatedAt: msg.CreatedAt.Format(time.RFC3339), + ID: msg.ID, + ConversationID: msg.ConversationID, + SenderType: msg.SenderType, + SenderID: msg.SenderID, + SenderRole: msg.SenderRole, + SenderName: msg.SenderName, + ContentType: msg.ContentType, + Content: msg.Content, + AttachmentURLS: msg.AttachmentURLS, + AdminAttentionType: msg.AdminAttentionType, + CreatedAt: msg.CreatedAt.Format(time.RFC3339), }, } r.hub.NotifyConversation(conversationID, event) diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index 3f1b659..b9117b0 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -45,9 +45,9 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal) SELECT COUNT(1) FROM chat_messages AS cm WHERE cm.conversation_id = c.id - AND cm.sender_type <> 'system' - AND NOT (cm.sender_type = ? AND cm.sender_id = ?) - AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) + AND cm.sender_type <> 'system' + AND NOT (cm.sender_type = ? AND cm.sender_id = ?) + AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) ) AS unread_count`, principal.Type, principal.ID). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) @@ -184,11 +184,12 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row SenderAvatar: avatar, IsSelf: isSelf, // 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。 - IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), - ContentType: row.ContentType, - Content: row.Content, - AttachmentURLS: decodeStringList(row.AttachmentURLS), - CreatedAt: row.CreatedAt, + IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), + ContentType: row.ContentType, + Content: row.Content, + AttachmentURLS: decodeStringList(row.AttachmentURLS), + AdminAttentionType: row.AdminAttentionType, + CreatedAt: row.CreatedAt, }) } return items, nil diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index a1930d2..43fca18 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -155,6 +155,13 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword) } +func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword) +} + func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index b587788..5b84b57 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -221,7 +221,7 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal } func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) { - counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword) + total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword) if err != nil { return nil, err } @@ -243,10 +243,9 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ SELECT COUNT(1) FROM chat_messages AS cm WHERE cm.conversation_id = c.id - AND cm.sender_type <> 'system' - AND NOT (cm.sender_type = ? AND cm.sender_id = ?) - AND cm.id > COALESCE(cas.last_read_message_id, 0) - ) AS unread_count`, principal.Type, principal.ID) + AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user') + AND cm.id > COALESCE(cas.last_read_message_id, 0) + ) AS unread_count`) applyAdminChatOwnershipFilter(queryDB, filter, principal) applyAdminChatStageFilter(queryDB, stage, principal) if err := queryDB. @@ -269,7 +268,26 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ for _, row := range rows { items = append(items, row.toDTO(participantsByConversation[row.ID])) } - return &PaginatedResult{Items: items, Total: counts.Total, Page: page, PageSize: pageSize, Counts: counts.DTO}, nil + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { + result, err := r.adminConversationCounts(ctx, principal, normalizeAdminChatFilter(filter), normalizeAdminChatStage(stage), strings.TrimSpace(keyword)) + if err != nil { + return nil, err + } + return result.DTO, nil +} + +func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) { + db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword)) + applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal) + applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal) + var total int64 + if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil { + return 0, err + } + return total, nil } func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB { @@ -396,17 +414,13 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)" switch stage { case adminChatStagePending: - return `( - lm.sender_type = 'user' - OR EXISTS ( + return `EXISTS ( SELECT 1 FROM chat_messages AS cm_pending WHERE cm_pending.conversation_id = c.id - AND cm_pending.sender_type <> 'system' - AND NOT (cm_pending.sender_type = 'admin' AND cm_pending.sender_id = ?) - AND cm_pending.id > COALESCE(cas.last_read_message_id, 0) - ) - )`, []interface{}{principal.ID} + AND (cm_pending.admin_attention_type <> '' OR cm_pending.sender_type = 'user') + AND cm_pending.id > COALESCE(cas.last_read_message_id, 0) + )`, nil case adminChatStageUnjoined: return latestID + " IS NULL", nil case adminChatStageHandoff: diff --git a/backend/internal/modules/chathub/hub.go b/backend/internal/modules/chathub/hub.go index a243ad1..a092b99 100644 --- a/backend/internal/modules/chathub/hub.go +++ b/backend/internal/modules/chathub/hub.go @@ -20,16 +20,17 @@ type ChatEvent struct { // MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。 type MessageData struct { - ID uint64 `json:"id"` - ConversationID uint64 `json:"conversation_id"` - SenderType string `json:"sender_type"` - SenderID uint64 `json:"sender_id"` - SenderRole string `json:"sender_role"` - SenderName string `json:"sender_name"` - ContentType string `json:"content_type"` - Content string `json:"content"` - AttachmentURLS []string `json:"attachment_urls"` - CreatedAt string `json:"created_at"` + ID uint64 `json:"id"` + ConversationID uint64 `json:"conversation_id"` + SenderType string `json:"sender_type"` + SenderID uint64 `json:"sender_id"` + SenderRole string `json:"sender_role"` + SenderName string `json:"sender_name"` + ContentType string `json:"content_type"` + Content string `json:"content"` + AttachmentURLS []string `json:"attachment_urls"` + AdminAttentionType string `json:"admin_attention_type,omitempty"` + CreatedAt string `json:"created_at"` } // principal 标识一个连接方。 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3edea5a..38fd854 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -726,6 +726,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents) } adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList) + adminRoutes.GET("/chats/counts", requirePerm("chat:view"), chatHandler.AdminCounts) adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail) adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages) adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend) diff --git a/backend/migrations/000057_chat_attention_rules.sql b/backend/migrations/000057_chat_attention_rules.sql new file mode 100644 index 0000000..a131e3e --- /dev/null +++ b/backend/migrations/000057_chat_attention_rules.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE chat_messages + ADD COLUMN admin_attention_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客服关注类型: user_inquiry用户咨询, order_paid支付成功' AFTER attachment_urls, + ADD KEY idx_chat_messages_attention (conversation_id, admin_attention_type, id); + +UPDATE chat_messages +SET admin_attention_type = 'user_inquiry' +WHERE sender_type = 'user' AND admin_attention_type = ''; + +INSERT INTO chat_admin_conversation_states ( + conversation_id, admin_user_id, remark, last_read_message_id, last_read_at +) +SELECT c.id, au.id, '', COALESCE(c.last_message_id, 0), c.last_message_at +FROM chat_conversations c +JOIN admin_users au ON au.status = 'active' +JOIN admin_user_roles aur ON aur.admin_user_id = au.id +JOIN roles r ON r.id = aur.role_id AND r.code = 'cs' +LEFT JOIN chat_admin_conversation_states cas + ON cas.conversation_id = c.id AND cas.admin_user_id = au.id +WHERE cas.id IS NULL; + +UPDATE chat_admin_conversation_states cas +JOIN chat_conversations c ON c.id = cas.conversation_id +SET cas.last_read_message_id = COALESCE(c.last_message_id, 0), + cas.last_read_at = c.last_message_at +WHERE cas.last_read_message_id = 0; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE chat_messages + DROP KEY idx_chat_messages_attention, + DROP COLUMN admin_attention_type; +-- +goose StatementEnd diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index e660a6b..c505570 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -1,11 +1,12 @@