From e75a39b03ad94d20246c56a40520283dacd5212c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 16 Jun 2026 22:22:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B6=88=E6=81=AF=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用 chat_participants.last_read_at 推导已读,无需新表/迁移。 - 后端:MessageDTO 增加 is_read,toMessageDTOs 按「全部已读」语义计算 (所有其他参与者均已读才算已读,1对1 即对方已读);MarkRead 成功后 推送 conversation_read 事件(含 reader 与 read_at),NotifyConversation 与 NotifyAllAdmins 双通道通知,使发送方/客服实时刷新已读 - 前端:ChatMessage 增加 is_read,ChatView/AdminChatsView/MobileChatView 在自己的消息下渲染「已读/未读」;收到 conversation_read 且本端仍有 未获回执的自己消息时才重载,避免无谓请求 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/modules/chat/dto.go | 1 + backend/internal/modules/chat/message.go | 25 ++++++- backend/internal/modules/chat/participant.go | 68 ++++++++++++++++++- backend/internal/modules/chathub/hub.go | 6 +- .../features/admin/views/AdminChatsView.vue | 23 +++++++ frontend/src/features/chats/api/chats.ts | 1 + .../features/chats/composables/useChatSSE.ts | 14 +++- .../src/features/chats/views/ChatView.vue | 21 ++++++ .../features/chats/views/MobileChatView.vue | 21 ++++++ 9 files changed, 174 insertions(+), 6 deletions(-) diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index 2c5e161..823241d 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -45,6 +45,7 @@ type MessageDTO struct { 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"` diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index f422074..ae2bd05 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -160,7 +160,9 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve return &items[0], nil } func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversationID uint64) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + updated := false + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 管理员可以不是 participant,直接返回成功 if principal.Type == "admin" { // 尝试查找 participant 记录,如果有就更新 @@ -169,7 +171,7 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa conversationID, principal.Type, principal.ID).First(&participant).Error if err == nil { // 有 participant 记录,更新已读时间 - now := time.Now() + updated = true return tx.Model(&participant).Update("last_read_at", now).Error } else if errors.Is(err, gorm.ErrRecordNotFound) { // 没有 participant 记录,直接返回成功(管理员无需记录已读) @@ -183,7 +185,24 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa if err != nil { return err } - now := time.Now() + updated = true return tx.Model(participant).Update("last_read_at", now).Error }) + if err != nil { + return err + } + // 已读时间发生变化时,通知会话内其他参与者刷新「已读」状态。 + if updated && r.hub != nil { + event := &chathub.ChatEvent{ + Type: "conversation_read", + ConversationID: conversationID, + ReaderType: principal.Type, + ReaderID: principal.ID, + ReadAt: now.Format(time.RFC3339), + } + r.hub.NotifyConversation(conversationID, event) + // 同时广播给所有在线客服,覆盖未分配会话中非参与者客服的「已读」刷新。 + r.hub.NotifyAllAdmins(event) + } + return nil } diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index b8e640e..37cda7b 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -105,6 +105,7 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { userIDs := make([]uint64, 0) adminIDs := make([]uint64, 0) + conversationIDSet := make(map[uint64]struct{}) for _, row := range rows { if row.SenderType == "user" && row.SenderID > 0 { userIDs = append(userIDs, row.SenderID) @@ -112,6 +113,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row if row.SenderType == "admin" && row.SenderID > 0 { adminIDs = append(adminIDs, row.SenderID) } + conversationIDSet[row.ConversationID] = struct{}{} } userNames, userAvatars, err := r.userNames(ctx, userIDs) if err != nil { @@ -121,6 +123,11 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row if err != nil { return nil, err } + // 加载相关会话的参与者已读时间,用于推导「自己发出的消息」是否已被对方读取。 + readParticipants, err := r.conversationReadParticipants(ctx, conversationIDSet) + if err != nil { + return nil, err + } items := make([]MessageDTO, 0, len(rows)) for _, row := range rows { name := "系统" @@ -136,6 +143,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row name = name + " (管理员)" } } + isSelf := row.SenderType == principal.Type && row.SenderID == principal.ID items = append(items, MessageDTO{ ID: row.ID, ConversationID: row.ConversationID, @@ -144,7 +152,9 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row SenderRole: row.SenderRole, SenderName: fallbackName(row.SenderType, row.SenderID, name), SenderAvatar: avatar, - IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID, + IsSelf: isSelf, + // 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。 + IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), ContentType: row.ContentType, Content: row.Content, AttachmentURLS: decodeStringList(row.AttachmentURLS), @@ -153,6 +163,62 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row } return items, nil } + +type readParticipant struct { + ParticipantType string + ParticipantID uint64 + LastReadAt *time.Time +} + +// conversationReadParticipants 批量加载若干会话的参与者已读时间。 +func (r *Repository) conversationReadParticipants(ctx context.Context, ids map[uint64]struct{}) (map[uint64][]readParticipant, error) { + result := make(map[uint64][]readParticipant) + if len(ids) == 0 { + return result, nil + } + idList := make([]uint64, 0, len(ids)) + for id := range ids { + idList = append(idList, id) + } + type row struct { + ConversationID uint64 + ParticipantType string + ParticipantID uint64 + LastReadAt *time.Time + } + var rows []row + if err := r.db.WithContext(ctx).Table("chat_participants"). + Select("conversation_id, participant_type, participant_id, last_read_at"). + Where("conversation_id IN ?", idList). + Find(&rows).Error; err != nil { + return nil, err + } + for _, rw := range rows { + result[rw.ConversationID] = append(result[rw.ConversationID], readParticipant{ + ParticipantType: rw.ParticipantType, + ParticipantID: rw.ParticipantID, + LastReadAt: rw.LastReadAt, + }) + } + return result, nil +} + +// messageReadByOthers 采用「全部已读」语义:所有其他参与者的 last_read_at 均不早于该消息时间。 +// 1对1 即对方已读;群聊需所有其他成员都已读。无其他参与者时返回 false。 +func messageReadByOthers(participants []readParticipant, msg model.ChatMessage) bool { + others := 0 + for _, p := range participants { + // 排除发送者本人 + if p.ParticipantType == msg.SenderType && p.ParticipantID == msg.SenderID { + continue + } + others++ + if p.LastReadAt == nil || p.LastReadAt.Before(msg.CreatedAt) { + return false + } + } + return others > 0 +} func (r *Repository) participantNames(ctx context.Context, rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) { userIDs := make([]uint64, 0) adminIDs := make([]uint64, 0) diff --git a/backend/internal/modules/chathub/hub.go b/backend/internal/modules/chathub/hub.go index 4f33090..22f1d11 100644 --- a/backend/internal/modules/chathub/hub.go +++ b/backend/internal/modules/chathub/hub.go @@ -9,9 +9,13 @@ import ( // ChatEvent 是通过 SSE 推送给客户端的事件。 type ChatEvent struct { - Type string `json:"type"` // "new_message" | "conversation_updated" + Type string `json:"type"` // "new_message" | "conversation_updated" | "conversation_read" ConversationID uint64 `json:"conversation_id"` Message *MessageData `json:"message,omitempty"` + // 以下字段仅在 conversation_read 事件中使用,标识谁在何时读取了会话。 + ReaderType string `json:"reader_type,omitempty"` + ReaderID uint64 `json:"reader_id,omitempty"` + ReadAt string `json:"read_at,omitempty"` } // MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。 diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index 0e17a9a..c91e0b8 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -71,6 +71,15 @@ function handleSSEEvent(event: ChatEvent) { if (event.type === 'conversation_updated') { loadConversations(false) } + if (event.type === 'conversation_read') { + // 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态 + if (active.value && event.conversation_id === active.value.id) { + const isSelfReader = event.reader_type === 'admin' && event.reader_id === currentAdminId + if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) { + loadMessages(active.value.id, false) + } + } + } if (event.type === 'new_message') { const msg = event.message if (!msg) return @@ -91,6 +100,7 @@ function handleSSEEvent(event: ChatEvent) { sender_name: msg.sender_name, sender_avatar: '', is_self: isSelf, + is_read: false, content_type: msg.content_type as ChatMessage['content_type'], content: msg.content, attachment_urls: msg.attachment_urls || [], @@ -441,6 +451,9 @@ function getSupportName(item: ChatConversation) { admin /> + + {{ item.is_read ? '已读' : '未读' }} + @@ -729,6 +742,16 @@ function getSupportName(item: ChatConversation) { color: #8a94a6; } +.read-status { + margin-top: 4px; + margin-bottom: 0; + font-size: 11px; +} + +.read-status.read { + color: #67c23a; +} + .message-row p { display: inline-block; margin: 0; diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index a90afb6..574fe16 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -39,6 +39,7 @@ export interface ChatMessage { sender_name: string sender_avatar: string is_self: boolean + is_read: boolean content_type: 'text' | 'system' content: string attachment_urls: string[] diff --git a/frontend/src/features/chats/composables/useChatSSE.ts b/frontend/src/features/chats/composables/useChatSSE.ts index 4f65fce..c8922b0 100644 --- a/frontend/src/features/chats/composables/useChatSSE.ts +++ b/frontend/src/features/chats/composables/useChatSSE.ts @@ -16,9 +16,12 @@ export interface SSEMessage { } export interface ChatEvent { - type: 'new_message' | 'conversation_updated' + type: 'new_message' | 'conversation_updated' | 'conversation_read' conversation_id: number message?: SSEMessage + reader_type?: string + reader_id?: number + read_at?: string } type EventHandler = (event: ChatEvent) => void @@ -69,6 +72,15 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { } }) + source.addEventListener('conversation_read', e => { + try { + const data = JSON.parse((e as MessageEvent).data) as ChatEvent + handlers.forEach(h => h(data)) + } catch { + /* ignore */ + } + }) + source.onerror = async () => { connected.value = false source?.close() diff --git a/frontend/src/features/chats/views/ChatView.vue b/frontend/src/features/chats/views/ChatView.vue index 03f7638..b645eb8 100644 --- a/frontend/src/features/chats/views/ChatView.vue +++ b/frontend/src/features/chats/views/ChatView.vue @@ -58,6 +58,7 @@ function handleSSEEvent(event: ChatEvent) { sender_name: msg.sender_name, sender_avatar: '', is_self: isSelf, + is_read: false, content_type: msg.content_type as ChatMessage['content_type'], content: msg.content, attachment_urls: msg.attachment_urls || [], @@ -76,6 +77,13 @@ function handleSSEEvent(event: ChatEvent) { if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) { loadConversation() } + if (event.type === 'conversation_read' && event.conversation_id === conversationID.value) { + // 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态 + const isSelfReader = event.reader_type === 'user' && event.reader_id === currentUserId + if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) { + loadMessages(false) + } + } } const { onEvent } = useChatSSE('user', '/api/chats/events') @@ -305,6 +313,9 @@ function handleKeydown(e: KeyboardEvent) { {{ formatDateMinute(item.created_at) }} + + {{ item.is_read ? '已读' : '未读' }} + @@ -538,6 +549,16 @@ function handleKeydown(e: KeyboardEvent) { font-size: 11px; } +.read-status { + margin-top: 2px; + color: #a1a8b4; + font-size: 11px; +} + +.read-status.read { + color: #67c23a; +} + .system-message { max-width: 80%; padding: 6px 12px; diff --git a/frontend/src/features/chats/views/MobileChatView.vue b/frontend/src/features/chats/views/MobileChatView.vue index 692fd57..3b7e9c2 100644 --- a/frontend/src/features/chats/views/MobileChatView.vue +++ b/frontend/src/features/chats/views/MobileChatView.vue @@ -62,6 +62,7 @@ function handleSSEEvent(event: ChatEvent) { sender_name: msg.sender_name, sender_avatar: '', is_self: isSelf, + is_read: false, content_type: msg.content_type as ChatMessage['content_type'], content: msg.content, attachment_urls: msg.attachment_urls || [], @@ -80,6 +81,13 @@ function handleSSEEvent(event: ChatEvent) { if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) { loadConversation() } + if (event.type === 'conversation_read' && event.conversation_id === conversationID.value) { + // 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态 + const isSelfReader = event.reader_type === 'user' && event.reader_id === currentUserId + if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) { + loadMessages(false) + } + } } const { onEvent } = useChatSSE('user', '/api/chats/events') @@ -284,6 +292,9 @@ function senderLabel(message: ChatMessage) { {{ formatDateMinute(item.created_at) }} + + {{ item.is_read ? '已读' : '未读' }} + @@ -499,6 +510,16 @@ function senderLabel(message: ChatMessage) { font-size: 10px; } +.read-status { + margin-top: 2px; + color: #a1a8b4; + font-size: 10px; +} + +.read-status.read { + color: #67c23a; +} + .system-message { max-width: 82%; padding: 5px 9px;