Files
jiang13-bbs/backend/handler/chat.go

533 lines
16 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/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):
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": 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)); 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": 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, 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)
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
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) {
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)
}
}
}