Files
jiang13-bbs/backend/handler/chat.go
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

727 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"net/http"
"strconv"
"time"
"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"
)
// chatErrToStatus 群聊业务错误 → HTTP 状态
func chatErrToStatus(err error) int {
switch {
case errors.Is(err, service.ErrChatNotFound), errors.Is(err, service.ErrChatMsgNotFound):
return http.StatusNotFound
case errors.Is(err, service.ErrChatNotMember), errors.Is(err, service.ErrChatMuted),
errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite),
errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave),
errors.Is(err, service.ErrChatDefaultDissolve), errors.Is(err, service.ErrChatDMDissolve),
errors.Is(err, service.ErrChatHallPinFixed), errors.Is(err, service.ErrChatSiteOwnerOnly),
errors.Is(err, service.ErrChatMuteDenied), errors.Is(err, service.ErrChatCannotMuteOwner),
errors.Is(err, service.ErrChatAdminAccess):
return http.StatusForbidden
case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone),
errors.Is(err, service.ErrChatInvalidRole):
return http.StatusBadRequest
default:
return http.StatusBadRequest
}
}
func chatRoomID(c *gin.Context) (uint, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "群 ID 无效"})
return 0, false
}
return uint(id), true
}
// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用
func formatMuteDurationCN(minutes int) string {
switch {
case minutes >= 30*24*60:
return "30 天"
case minutes >= 7*24*60:
return "7 天"
case minutes >= 3*24*60:
return "3 天"
case minutes >= 24*60:
return "1 天"
case minutes >= 12*60:
return "12 小时"
case minutes >= 60:
return "1 小时"
default:
return strconv.Itoa(minutes) + " 分钟"
}
}
func (h *Handlers) chatOversee(userID uint) bool {
actor, err := h.Auth.LoadActor(userID)
if err != nil || actor == nil {
return false
}
return service.CanOverseeChat(actor.Role)
}
// RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。
func (h *Handlers) RequireMessagesOpen(c *gin.Context) {
if h.Setting == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "消息暂时不可用"})
return
}
allow, err := h.Setting.AllowMessages()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "消息暂时不可用"})
return
}
if !allow {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "本站不支持消息"})
return
}
c.Next()
}
// CreateChatRoom 建群(可公开/私密,私密可带初始成员)
func (h *Handlers) CreateChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"is_private"`
MemberIDs []uint `json:"member_ids"`
MemberUsernames []string `json:"member_usernames"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
memberIDs := append([]uint{}, req.MemberIDs...)
for _, name := range req.MemberUsernames {
if uid := h.Auth.GetUserIDByUsername(name); uid > 0 && uid != claims.ID {
memberIDs = append(memberIDs, uid)
}
}
room, err := h.Chat.CreateRoom(claims.ID, req.Name, req.Description, req.IsPrivate, memberIDs)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
// 通知被拉入的初始成员
seen := map[uint]bool{}
for _, uid := range memberIDs {
if uid == 0 || uid == claims.ID || seen[uid] {
continue
}
seen[uid] = true
h.Hub.BroadcastUser(uid, realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": room.ID, "status": "invited"},
})
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// ListChatRooms 会话列表(默认 IM 侧栏仅已加入;scope=all 可浏览公开群)
func (h *Handlers) ListChatRooms(c *gin.Context) {
claims := middleware.CurrentUser(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
joinedOnly := c.DefaultQuery("scope", "joined") != "all"
oversee := h.chatOversee(claims.ID)
rooms, total, err := h.Chat.ListRooms(claims.ID, oversee, joinedOnly, c.Query("q"), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取会话列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"rooms": nonNilSlice(rooms), "total": total, "page": page, "size": size})
}
// GetChatRoom 群详情(公开非成员可预览;私密非成员 404)
func (h *Handlers) GetChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
oversee := h.chatOversee(claims.ID)
room, err := h.Chat.RoomDetail(claims.ID, roomID, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// UpdateChatRoom 群主改群名/简介
func (h *Handlers) UpdateChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
room, err := h.Chat.UpdateRoom(claims.ID, roomID, req.Name, req.Description)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// PinChatRoom 当前用户置顶/取消置顶会话
func (h *Handlers) PinChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
Pinned bool `json:"pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
room, err := h.Chat.SetRoomPinned(claims.ID, roomID, req.Pinned)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// DissolveChatRoom 群主/监管解散群
func (h *Handlers) DissolveChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
oversee := h.chatOversee(claims.ID)
members, err := h.Chat.ListMembers(claims.ID, roomID, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.Chat.Dissolve(claims.ID, roomID, oversee); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
roomName := realtime.RoomChat(roomID)
for _, m := range members {
h.Hub.RemoveUserRoom(m.UserID, roomName)
h.Hub.BroadcastUser(m.UserID, realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": roomID, "status": "dissolved"},
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// JoinChatRoom 加入公开群
func (h *Handlers) JoinChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
_, _, err := h.Chat.Join(claims.ID, roomID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// LeaveChatRoom 退出群
func (h *Handlers) LeaveChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
if err := h.Chat.Leave(claims.ID, roomID); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.RemoveUserRoom(claims.ID, realtime.RoomChat(roomID))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// InviteChatMembers 群主/监管邀请入群
func (h *Handlers) InviteChatMembers(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
UserIDs []uint `json:"user_ids"`
Usernames []string `json:"usernames"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"})
return
}
ids := append([]uint{}, req.UserIDs...)
for _, name := range req.Usernames {
if uid := h.Auth.GetUserIDByUsername(name); uid > 0 {
ids = append(ids, uid)
}
}
if len(ids) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"})
return
}
oversee := h.chatOversee(claims.ID)
added, err := h.Chat.InviteMembers(claims.ID, roomID, ids, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
for _, uid := range added {
h.Hub.BroadcastUser(uid, realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": roomID, "status": "invited"},
})
}
c.JSON(http.StatusOK, gin.H{"added": added})
}
// ListChatMembers 成员列表
func (h *Handlers) ListChatMembers(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
oversee := h.chatOversee(claims.ID)
list, err := h.Chat.ListMembers(claims.ID, roomID, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"members": nonNilSlice(list)})
}
// KickChatMember 群主踢人
func (h *Handlers) KickChatMember(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
if err := h.Chat.Kick(claims.ID, roomID, uint(targetID), h.chatOversee(claims.ID)); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.RemoveUserRoom(uint(targetID), realtime.RoomChat(roomID))
h.Hub.BroadcastUser(uint(targetID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": roomID, "status": "kicked"},
})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
var body struct {
Muted bool `json:"muted"`
DurationMinutes int `json:"duration_minutes"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
duration := time.Duration(body.DurationMinutes) * time.Minute
targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID))
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
// 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表
var mutedUntil *time.Time
if body.Muted && body.DurationMinutes > 0 {
t := time.Now().Add(duration)
mutedUntil = &t
}
// 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等)
var sysContent string
if body.Muted {
if body.DurationMinutes > 0 {
sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes)
} else {
sysContent = targetName + " 已被禁言 永久"
}
} else {
sysContent = targetName + " 的禁言已被解除"
}
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染);
// Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMemberMuted,
Data: gin.H{
"room_id": roomID,
"user_id": uint(targetID),
"muted": body.Muted,
"muted_until": mutedUntil,
"duration_minutes": body.DurationMinutes,
"target_name": targetName,
"operator_id": claims.ID,
},
})
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes})
}
// SetChatMemberRole 站长任命/撤销群管理员
func (h *Handlers) SetChatMemberRole(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
actor := h.loadActor(claims.ID)
role := model.RoleUser
if actor != nil {
role = actor.Role
}
if err := h.Chat.SetMemberRole(role, roomID, uint(targetID), body.Role); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "role": body.Role})
}
// ListChatMessages 历史消息
func (h *Handlers) ListChatMessages(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
beforeID, _ := strconv.ParseUint(c.Query("before_id"), 10, 64)
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
oversee := h.chatOversee(claims.ID)
msgs, hasMore, err := h.Chat.ListMessages(claims.ID, roomID, uint(beforeID), size, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"messages": nonNilSlice(msgs), "has_more": hasMore})
}
// SendChatMessage 发消息
func (h *Handlers) SendChatMessage(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
Content string `json:"content"`
ReplyToID uint `json:"reply_to_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
msg, _, err := h.Chat.SendMessage(claims.ID, roomID, req.Content, req.ReplyToID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: msg,
})
// 向未在房内订阅的成员推未读增量(Header / 会话列表实时角标)
if memberIDs, mErr := h.Chat.ListMemberIDs(roomID); mErr == nil {
preview := req.Content
runes := []rune(preview)
if len(runes) > 80 {
preview = string(runes[:80])
}
for _, uid := range memberIDs {
if uid == claims.ID {
continue
}
h.Hub.BroadcastUser(uid, realtime.Envelope{
Type: realtime.EventChatUnread,
Data: gin.H{
"room_id": roomID,
"message_id": msg.ID,
"sender_id": claims.ID,
"preview": preview,
"delta": 1,
},
})
}
}
// @ 通知的 WS 红点由 Notification.CreateMention → OnNotifyNew 统一推送
c.JSON(http.StatusOK, gin.H{"message": msg})
}
// RecallChatMessage 撤回消息
func (h *Handlers) RecallChatMessage(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
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)
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, newly)
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
if !oversee && msg.SenderID != claims.ID {
msg.Content = ""
}
c.JSON(http.StatusOK, gin.H{"message": msg})
}
// MarkChatRead 推进已读水位
func (h *Handlers) MarkChatRead(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
MessageID uint `json:"message_id"`
}
_ = c.ShouldBindJSON(&req)
if err := h.Chat.MarkRead(claims.ID, roomID, req.MessageID); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// OpenDirectChat 获取或创建与指定用户的私聊
func (h *Handlers) OpenDirectChat(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req struct {
UserID uint `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定用户"})
return
}
room, err := h.Chat.GetOrCreateDM(claims.ID, req.UserID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// SearchChatUsers 消息页搜索用户以发起私聊
func (h *Handlers) SearchChatUsers(c *gin.Context) {
claims := middleware.CurrentUser(c)
users, err := h.Chat.SearchUsersForChat(claims.ID, c.Query("q"), 10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
return
}
c.JSON(http.StatusOK, gin.H{"users": nonNilSlice(users)})
}
// ChatUnreadSummary 未读汇总
func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
claims := middleware.CurrentUser(c)
total, rooms, err := h.Chat.UnreadSummary(claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取未读失败"})
return
}
c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms})
}
// AdminChatMessages 管理端消息监管队列;room_type=group|direct
func (h *Handlers) AdminChatMessages(c *gin.Context) {
claims := middleware.CurrentUser(c)
actor := h.loadActor(claims.ID)
if actor == nil || !h.Chat.CanAccessAdminMessages(actor) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
q := c.Query("q")
roomType := c.Query("room_type")
msgs, total, err := h.Chat.AdminListMessages(actor, page, q, roomType)
if err != nil {
if errors.Is(err, service.ErrChatAdminAccess) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
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,
"can_oversee": service.CanOverseeChat(actor.Role),
"can_recall": true,
"site_messages": actor.HasSiteMessagePerm(),
})
}
// AdminRecallChatMessage 管理端撤回群聊消息
func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
claims := middleware.CurrentUser(c)
actor := h.loadActor(claims.ID)
if actor == nil || !h.Chat.CanAccessAdminMessages(actor) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"})
return
}
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 := service.CanOverseeChat(actor.Role)
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, 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, newly bool) {
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)
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览:
// 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage
// 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。
if h.Chat == nil {
return
}
if memberIDs, err := h.Chat.ListMemberIDs(roomID); err == nil {
for _, uid := range memberIDs {
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)
}
}