Files
jiang13-bbs/backend/handler/chat.go
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

460 lines
13 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"
"github.com/freefire/jiang13-bbs/middleware"
"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):
return http.StatusForbidden
case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone):
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
}
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)
}
// 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": 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": 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)); 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})
}
// 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": 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, err := h.Chat.RecallMessage(claims.ID, roomID, uint(mid), oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatRecalled,
Data: gin.H{
"id": msg.ID,
"room_id": roomID,
"sender_id": msg.SenderID,
"recalled_by": msg.RecalledBy,
"recalled_at": msg.RecalledAt,
},
})
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": 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})
}