From d1155cbd6462a2029ac2f4696cdde26c6935eed3 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 17 Sep 2026 04:55:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E6=92=A4=E5=9B=9E=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=97=B6=E5=9B=9E=E9=80=80=E6=9C=AA=E8=AF=BB=E8=A7=92?= =?UTF-8?q?=E6=A0=87=EF=BC=8C=E9=81=BF=E5=85=8D=E5=BD=93=E6=88=90=E6=96=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/handler/chat.go | 41 +++++++++++++++++++++++++++++---- backend/service/chat.go | 21 +++++++++-------- frontend/app/chat/ChatShell.tsx | 11 ++++++++- frontend/lib/realtime.ts | 1 + 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/backend/handler/chat.go b/backend/handler/chat.go index b086e52..3264362 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -401,12 +401,12 @@ func (h *Handlers) RecallChatMessage(c *gin.Context) { return } oversee := h.chatOversee(claims.ID) - msg, err := h.Chat.RecallMessage(claims.ID, roomID, uint(mid), oversee) + msg, newly, err := h.Chat.RecallMessage(claims.ID, roomID, uint(mid), oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } - h.broadcastChatRecalled(roomID, msg) + h.broadcastChatRecalled(roomID, msg, newly) // 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传 if !oversee && msg.SenderID != claims.ID { msg.Content = "" @@ -504,19 +504,19 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) { } oversee := h.chatOversee(claims.ID) // 非监管 staff:仅允许撤回自己发送的(与前台一致) - msg, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee) + msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } - h.broadcastChatRecalled(uint(roomID), msg) + h.broadcastChatRecalled(uint(roomID), msg, newly) c.JSON(http.StatusOK, gin.H{ "ok": true, "id": msg.ID, "room_id": roomID, "recalled_at": msg.RecalledAt, }) } // broadcastChatRecalled 会话内撤回与管理端撤回共用:先落库再推 chat:{roomID} -func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage) { +func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, newly bool) { if h.Hub == nil || msg == nil { return } @@ -547,4 +547,35 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage) { h.Hub.BroadcastUser(uid, env) } } + // 发送时已给未读者 +1;撤回后必须回退,否则角标会停在一条已撤回的「新消息」上 + if newly { + h.broadcastChatUnreadDecrement(roomID, msg) + } +} + +// broadcastChatUnreadDecrement 与发信对称:给除发送者外的全体成员推 delta:-1。 +// 不能按 last_read 过滤——发信时 +1 也是全员推的;另一标签页已读后首页角标仍需回退。 +func (h *Handlers) broadcastChatUnreadDecrement(roomID uint, msg *model.ChatMessage) { + if h.Hub == nil || h.Chat == nil || msg == nil { + return + } + ids, err := h.Chat.ListMemberIDs(roomID) + if err != nil { + return + } + env := realtime.Envelope{ + Type: realtime.EventChatUnread, + Data: gin.H{ + "room_id": roomID, + "message_id": msg.ID, + "sender_id": msg.SenderID, + "delta": -1, + }, + } + for _, uid := range ids { + if uid == msg.SenderID { + continue + } + h.Hub.BroadcastUser(uid, env) + } } diff --git a/backend/service/chat.go b/backend/service/chat.go index 65776d1..b493445 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -918,30 +918,31 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over return msgs, hasMore, nil } -// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计 -func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, error) { +// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计。 +// newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。 +func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) { if _, err := s.getRoom(roomID); err != nil { - return nil, err + return nil, false, err } var msg model.ChatMessage if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrChatMsgNotFound + return nil, false, ErrChatMsgNotFound } - return nil, err + return nil, false, err } if msg.RecalledAt != nil { - return &msg, nil // 幂等 + return &msg, false, nil // 幂等 } if !oversee { m, err := s.membership(s.db, roomID, operatorID) if err != nil { - return nil, err + return nil, false, err } // 群主/成员仅可撤回自己的消息;监管者可撤任意 if msg.SenderID != operatorID { _ = m // 成员校验已通过 - return nil, ErrChatRecallDenied + return nil, false, ErrChatRecallDenied } } now := time.Now() @@ -950,12 +951,12 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee "recalled_at": now, "recalled_by": operatorID, }).Error; err != nil { - return nil, err + return nil, false, err } msg.RecalledAt = &now msg.RecalledBy = operatorID _ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID) - return &msg, nil + return &msg, true, nil } // AdminChatParticipant 管理端私聊双方摘要 diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx index 969f7d8..886a658 100644 --- a/frontend/app/chat/ChatShell.tsx +++ b/frontend/app/chat/ChatShell.tsx @@ -175,6 +175,7 @@ export default function ChatShell({ const off = realtime.on(RT_CHAT_UNREAD, (data) => { if (!data?.room_id) return; if (activeRoomId === data.room_id) return; + const delta = typeof data.delta === "number" ? data.delta : 1; setRooms((prev) => { const idx = prev.findIndex((r) => r.id === data.room_id); if (idx < 0) { @@ -183,9 +184,17 @@ export default function ChatShell({ } const next = [...prev]; const cur = next[idx]; + // 撤回回退:只改角标,预览由 chat:message_recalled 负责,避免覆盖「已撤回」 + if (delta < 0) { + next[idx] = { + ...cur, + unread_count: Math.max(0, (cur.unread_count || 0) + delta), + }; + return next; + } next[idx] = { ...cur, - unread_count: (cur.unread_count || 0) + (data.delta || 1), + unread_count: (cur.unread_count || 0) + delta, last_message_id: data.message_id, last_message: { id: data.message_id, diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index e80f938..3c8dbff 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -96,6 +96,7 @@ export interface RtChatRecalledData { } // 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标) +// delta 通常为 +1(新消息);撤回未读消息时为 -1,此时不含 preview,不得当新消息处理 export interface RtChatUnreadData { room_id: number; message_id: number;