feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
459
backend/handler/chat.go
Normal file
459
backend/handler/chat.go
Normal file
@@ -0,0 +1,459 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user