fix(chat): 撤回消息时回退未读角标,避免当成新消息
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 管理端私聊双方摘要
|
||||||
|
|||||||
@@ -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] = {
|
next[idx] = {
|
||||||
...cur,
|
...cur,
|
||||||
unread_count: (cur.unread_count || 0) + (data.delta || 1),
|
unread_count: Math.max(0, (cur.unread_count || 0) + delta),
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
next[idx] = {
|
||||||
|
...cur,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user