新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
727 lines
22 KiB
Go
727 lines
22 KiB
Go
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)
|
||
}
|
||
}
|