fix(chat): 发送后切会话不再误标未读,管理端撤回实时同步到对话页
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -387,26 +388,7 @@ func (h *Handlers) RecallChatMessage(c *gin.Context) {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data := gin.H{
|
||||
"id": msg.ID,
|
||||
"room_id": roomID,
|
||||
"sender_id": msg.SenderID,
|
||||
"recalled_by": msg.RecalledBy,
|
||||
"recalled_at": msg.RecalledAt,
|
||||
}
|
||||
if msg.Recaller != nil && msg.Recaller.ID > 0 {
|
||||
data["recaller"] = gin.H{
|
||||
"id": msg.Recaller.ID,
|
||||
"username": msg.Recaller.Username,
|
||||
"nickname": msg.Recaller.Nickname,
|
||||
"avatar": msg.Recaller.Avatar,
|
||||
"role": msg.Recaller.Role,
|
||||
}
|
||||
}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||
Type: realtime.EventChatRecalled,
|
||||
Data: data,
|
||||
})
|
||||
h.broadcastChatRecalled(roomID, msg)
|
||||
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
|
||||
if !oversee && msg.SenderID != claims.ID {
|
||||
msg.Content = ""
|
||||
@@ -471,3 +453,80 @@ func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms})
|
||||
}
|
||||
|
||||
// AdminChatMessages 管理端消息监管队列;room_type=group|direct
|
||||
func (h *Handlers) AdminChatMessages(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
q := c.Query("q")
|
||||
roomType := c.Query("room_type")
|
||||
msgs, total, err := h.Chat.AdminListMessages(page, q, roomType)
|
||||
if err != nil {
|
||||
if err.Error() == "房间类型无效" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": msgs, "total": total, "page": page})
|
||||
}
|
||||
|
||||
// AdminRecallChatMessage 管理端撤回群聊消息(staff;监管者可撤任意)
|
||||
func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, err := strconv.ParseUint(c.Param("roomId"), 10, 64)
|
||||
if err != nil || roomID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "房间 ID 无效"})
|
||||
return
|
||||
}
|
||||
mid, err := strconv.ParseUint(c.Param("mid"), 10, 64)
|
||||
if err != nil || mid == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
|
||||
return
|
||||
}
|
||||
oversee := h.chatOversee(claims.ID)
|
||||
// 非监管 staff:仅允许撤回自己发送的(与前台一致)
|
||||
msg, 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)
|
||||
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) {
|
||||
if h.Hub == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
data := gin.H{
|
||||
"id": msg.ID,
|
||||
"room_id": roomID,
|
||||
"sender_id": msg.SenderID,
|
||||
"recalled_by": msg.RecalledBy,
|
||||
"recalled_at": msg.RecalledAt,
|
||||
}
|
||||
if msg.Recaller != nil && msg.Recaller.ID > 0 {
|
||||
data["recaller"] = gin.H{
|
||||
"id": msg.Recaller.ID,
|
||||
"username": msg.Recaller.Username,
|
||||
"nickname": msg.Recaller.Nickname,
|
||||
"avatar": msg.Recaller.Avatar,
|
||||
"role": msg.Recaller.Role,
|
||||
}
|
||||
}
|
||||
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
||||
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
|
||||
if h.Chat == nil {
|
||||
return
|
||||
}
|
||||
if memberIDs, err := h.Chat.ListMemberIDs(roomID); err == nil {
|
||||
for _, uid := range memberIDs {
|
||||
h.Hub.BroadcastUser(uid, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
|
||||
return s.membership(s.db, roomID, userID)
|
||||
}
|
||||
|
||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位即未读)
|
||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
|
||||
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||
out := make(map[uint]int64)
|
||||
if userID == 0 || len(roomIDs) == 0 {
|
||||
@@ -127,7 +127,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||
s.db.Table("chat_messages AS m").
|
||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs).
|
||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
|
||||
Group("m.room_id").
|
||||
Scan(&rows)
|
||||
for _, r := range rows {
|
||||
@@ -793,11 +793,17 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
|
||||
if err := tx.Create(msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||
if err := tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||
Updates(map[string]interface{}{
|
||||
"last_message_id": msg.ID,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 发送者同步推进已读水位,避免切房时把刚发出的消息算成未读
|
||||
return tx.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ? AND last_read_message_id < ?", roomID, senderID, msg.ID).
|
||||
Update("last_read_message_id", msg.ID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -952,6 +958,137 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// AdminChatParticipant 管理端私聊双方摘要
|
||||
type AdminChatParticipant struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// AdminChatMessageItem 管理端消息监管列表项(群聊 / 大厅 / 私聊)
|
||||
type AdminChatMessageItem struct {
|
||||
ID uint `json:"id"`
|
||||
RoomID uint `json:"room_id"`
|
||||
RoomName string `json:"room_name"`
|
||||
RoomType string `json:"room_type"` // group | direct
|
||||
IsDefault bool `json:"is_default"`
|
||||
SenderID uint `json:"sender_id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderAvatar string `json:"sender_avatar"`
|
||||
Participants []AdminChatParticipant `json:"participants,omitempty"` // 私聊双方
|
||||
Content string `json:"content"`
|
||||
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminListMessages 按房间类型分页:group=大厅+群聊,direct=私聊;支持正文/房间/参与者搜索
|
||||
func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
|
||||
return nil, 0, errors.New("房间类型无效")
|
||||
}
|
||||
const pageSize = 15
|
||||
q := s.db.Model(&model.ChatMessage{}).
|
||||
Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id").
|
||||
Joins("LEFT JOIN users ON users.id = chat_messages.sender_id").
|
||||
Where("chat_rooms.deleted_at IS NULL AND chat_messages.deleted_at IS NULL").
|
||||
Where("chat_rooms.room_type = ?", roomType)
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
q = q.Where(
|
||||
`chat_messages.content ILIKE ? OR chat_rooms.name ILIKE ? OR users.nickname ILIKE ? OR users.username ILIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM chat_room_members crm
|
||||
JOIN users mu ON mu.id = crm.user_id
|
||||
WHERE crm.room_id = chat_messages.room_id
|
||||
AND (mu.nickname ILIKE ? OR mu.username ILIKE ?)
|
||||
)`,
|
||||
like, like, like, like, like, like,
|
||||
)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var msgs []model.ChatMessage
|
||||
if err := q.Preload("Sender").
|
||||
Order("chat_messages.id DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&msgs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
roomIDs := make([]uint, 0, len(msgs))
|
||||
seen := map[uint]struct{}{}
|
||||
for _, m := range msgs {
|
||||
if _, ok := seen[m.RoomID]; !ok {
|
||||
seen[m.RoomID] = struct{}{}
|
||||
roomIDs = append(roomIDs, m.RoomID)
|
||||
}
|
||||
}
|
||||
roomMap := map[uint]model.ChatRoom{}
|
||||
if len(roomIDs) > 0 {
|
||||
var rooms []model.ChatRoom
|
||||
s.db.Where("id IN ?", roomIDs).Find(&rooms)
|
||||
for _, r := range rooms {
|
||||
roomMap[r.ID] = r
|
||||
}
|
||||
}
|
||||
// 私聊:预加载双方,供前端叠放头像与「甲 · 乙」会话条
|
||||
participantsByRoom := map[uint][]AdminChatParticipant{}
|
||||
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
||||
var members []model.ChatRoomMember
|
||||
s.db.Where("room_id IN ?", roomIDs).Preload("User").Find(&members)
|
||||
for i := range members {
|
||||
name := members[i].User.Nickname
|
||||
if name == "" {
|
||||
name = members[i].User.Username
|
||||
}
|
||||
if name == "" {
|
||||
name = "?"
|
||||
}
|
||||
participantsByRoom[members[i].RoomID] = append(participantsByRoom[members[i].RoomID], AdminChatParticipant{
|
||||
ID: members[i].UserID, Name: name, Avatar: members[i].User.Avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
out := make([]AdminChatMessageItem, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
r := roomMap[m.RoomID]
|
||||
name := r.Name
|
||||
parts := participantsByRoom[r.ID]
|
||||
if r.IsDefault {
|
||||
name = "全站大厅"
|
||||
} else if r.RoomType == model.ChatRoomTypeDirect {
|
||||
names := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if len(names) > 0 {
|
||||
name = strings.Join(names, " · ")
|
||||
} else if name == "" {
|
||||
name = "私聊"
|
||||
}
|
||||
}
|
||||
item := AdminChatMessageItem{
|
||||
ID: m.ID, RoomID: m.RoomID, RoomName: name, RoomType: r.RoomType, IsDefault: r.IsDefault,
|
||||
SenderID: m.SenderID, Content: m.Content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt,
|
||||
Participants: parts,
|
||||
}
|
||||
if m.Sender.ID > 0 {
|
||||
item.SenderName = m.Sender.Nickname
|
||||
if item.SenderName == "" {
|
||||
item.SenderName = m.Sender.Username
|
||||
}
|
||||
item.SenderAvatar = m.Sender.Avatar
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// MarkRead 把已读水位推进到指定消息(单调不减;messageID=0 或超过最新则取最新)
|
||||
func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
||||
room, err := s.getRoom(roomID)
|
||||
@@ -975,7 +1112,7 @@ func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
||||
err := s.db.Table("chat_messages AS m").
|
||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||
Where("m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL").
|
||||
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
|
||||
Group("m.room_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user