fix(chat): 撤回消息时回退未读角标,避免当成新消息

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:55:17 +08:00
parent c9d27b2b0c
commit d1155cbd64
4 changed files with 58 additions and 16 deletions

View File

@@ -401,12 +401,12 @@ func (h *Handlers) RecallChatMessage(c *gin.Context) {
return return
} }
oversee := h.chatOversee(claims.ID) 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 { if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return return
} }
h.broadcastChatRecalled(roomID, msg) h.broadcastChatRecalled(roomID, msg, newly)
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传 // 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
if !oversee && msg.SenderID != claims.ID { if !oversee && msg.SenderID != claims.ID {
msg.Content = "" msg.Content = ""
@@ -504,19 +504,19 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
} }
oversee := h.chatOversee(claims.ID) oversee := h.chatOversee(claims.ID)
// 非监管 staff:仅允许撤回自己发送的(与前台一致) // 非监管 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 { if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return return
} }
h.broadcastChatRecalled(uint(roomID), msg) h.broadcastChatRecalled(uint(roomID), msg, newly)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"ok": true, "id": msg.ID, "room_id": roomID, "recalled_at": msg.RecalledAt, "ok": true, "id": msg.ID, "room_id": roomID, "recalled_at": msg.RecalledAt,
}) })
} }
// broadcastChatRecalled 会话内撤回与管理端撤回共用:先落库再推 chat:{roomID} // 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 { if h.Hub == nil || msg == nil {
return return
} }
@@ -547,4 +547,35 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage) {
h.Hub.BroadcastUser(uid, env) 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)
}
} }

View File

@@ -918,30 +918,31 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
return msgs, hasMore, nil return msgs, hasMore, nil
} }
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计 // RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计。
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, error) { // newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) {
if _, err := s.getRoom(roomID); err != nil { if _, err := s.getRoom(roomID); err != nil {
return nil, err return nil, false, err
} }
var msg model.ChatMessage var msg model.ChatMessage
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil { if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChatMsgNotFound return nil, false, ErrChatMsgNotFound
} }
return nil, err return nil, false, err
} }
if msg.RecalledAt != nil { if msg.RecalledAt != nil {
return &msg, nil // 幂等 return &msg, false, nil // 幂等
} }
if !oversee { if !oversee {
m, err := s.membership(s.db, roomID, operatorID) m, err := s.membership(s.db, roomID, operatorID)
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
// 群主/成员仅可撤回自己的消息;监管者可撤任意 // 群主/成员仅可撤回自己的消息;监管者可撤任意
if msg.SenderID != operatorID { if msg.SenderID != operatorID {
_ = m // 成员校验已通过 _ = m // 成员校验已通过
return nil, ErrChatRecallDenied return nil, false, ErrChatRecallDenied
} }
} }
now := time.Now() now := time.Now()
@@ -950,12 +951,12 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
"recalled_at": now, "recalled_at": now,
"recalled_by": operatorID, "recalled_by": operatorID,
}).Error; err != nil { }).Error; err != nil {
return nil, err return nil, false, err
} }
msg.RecalledAt = &now msg.RecalledAt = &now
msg.RecalledBy = operatorID msg.RecalledBy = operatorID
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID) _ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
return &msg, nil return &msg, true, nil
} }
// AdminChatParticipant 管理端私聊双方摘要 // AdminChatParticipant 管理端私聊双方摘要

View File

@@ -175,6 +175,7 @@ export default function ChatShell({
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => { const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
if (!data?.room_id) return; if (!data?.room_id) return;
if (activeRoomId === data.room_id) return; if (activeRoomId === data.room_id) return;
const delta = typeof data.delta === "number" ? data.delta : 1;
setRooms((prev) => { setRooms((prev) => {
const idx = prev.findIndex((r) => r.id === data.room_id); const idx = prev.findIndex((r) => r.id === data.room_id);
if (idx < 0) { if (idx < 0) {
@@ -183,9 +184,17 @@ export default function ChatShell({
} }
const next = [...prev]; const next = [...prev];
const cur = next[idx]; 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] = { next[idx] = {
...cur, ...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,
last_message: { last_message: {
id: data.message_id, id: data.message_id,

View File

@@ -96,6 +96,7 @@ export interface RtChatRecalledData {
} }
// 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标) // 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标)
// delta 通常为 +1(新消息);撤回未读消息时为 -1,此时不含 preview,不得当新消息处理
export interface RtChatUnreadData { export interface RtChatUnreadData {
room_id: number; room_id: number;
message_id: number; message_id: number;