新增消息已读状态

复用 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) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-16 22:22:05 +08:00
co-authored by Claude Opus 4.8
parent cc1c0d33de
commit e75a39b03a
9 changed files with 174 additions and 6 deletions
+1
View File
@@ -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"`
+22 -3
View File
@@ -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
}
+67 -1
View File
@@ -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)