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;