新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
460 lines
13 KiB
Go
460 lines
13 KiB
Go
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})
|
||
}
|