From 42c395a86482a7459b186f30393f5c66d9d4cdd9 Mon Sep 17 00:00:00 2001 From: freefire Date: Tue, 15 Sep 2026 22:58:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E6=97=B6=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=8E=E7=AE=A1=E7=90=86=E7=AB=AF=E8=BF=90=E8=90=A5=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor --- backend/go.mod | 1 + backend/go.sum | 2 + backend/handler/admin_user.go | 63 +- backend/handler/auth.go | 12 +- backend/handler/chat.go | 459 +++++ backend/handler/comment.go | 14 +- backend/handler/handlers.go | 46 + backend/handler/moderation.go | 58 +- backend/handler/post.go | 16 + backend/handler/realtime.go | 126 ++ backend/handler/setting.go | 10 +- backend/handler/user.go | 16 +- backend/model/db.go | 125 ++ backend/model/models.go | 94 +- backend/realtime/client.go | 206 +++ backend/realtime/hub.go | 233 +++ backend/router/router.go | 41 + backend/service/actor.go | 46 + backend/service/admin_user.go | 242 ++- backend/service/chat.go | 992 ++++++++++ backend/service/comment.go | 26 +- backend/service/moderation.go | 19 +- backend/service/notification.go | 37 +- backend/service/post.go | 55 +- backend/service/ratelimit.go | 2 + frontend/app/admin/AdminNav.tsx | 7 +- frontend/app/admin/page.tsx | 325 ++-- frontend/app/admin/users/UsersAdmin.tsx | 134 +- .../app/admin/users/[id]/UserAuditClient.tsx | 400 +++++ frontend/app/admin/users/[id]/page.tsx | 44 + frontend/app/board/[id]/page.tsx | 16 +- frontend/app/chat/ChatShell.tsx | 706 ++++++++ frontend/app/chat/[id]/ChatRoomClient.tsx | 1589 +++++++++++++++++ frontend/app/chat/[id]/page.tsx | 94 + frontend/app/chat/page.tsx | 34 + frontend/app/globals.css | 125 ++ frontend/app/layout.tsx | 13 +- .../app/notifications/NotificationsClient.tsx | 39 +- frontend/app/page.tsx | 9 + frontend/app/post/[id]/page.tsx | 4 +- frontend/app/u/[id]/StartDMButton.tsx | 36 + frontend/app/u/[id]/page.tsx | 11 +- frontend/components/CommentSection.tsx | 4 + frontend/components/ConfirmDialog.tsx | 84 + frontend/components/ContextMenu.tsx | 136 ++ frontend/components/Header.tsx | 99 +- frontend/components/NotificationBell.tsx | 55 +- frontend/components/PostRow.tsx | 1 + frontend/components/RealtimeProvider.tsx | 18 + frontend/components/ThemeSync.tsx | 26 +- frontend/components/home/FeedNewTip.tsx | 75 + frontend/lib/api.ts | 528 +++++- frontend/lib/realtime.ts | 336 ++++ frontend/lib/roles.ts | 7 +- frontend/lib/theme.ts | 57 +- 55 files changed, 7607 insertions(+), 346 deletions(-) create mode 100644 backend/handler/chat.go create mode 100644 backend/handler/realtime.go create mode 100644 backend/realtime/client.go create mode 100644 backend/realtime/hub.go create mode 100644 backend/service/chat.go create mode 100644 frontend/app/admin/users/[id]/UserAuditClient.tsx create mode 100644 frontend/app/admin/users/[id]/page.tsx create mode 100644 frontend/app/chat/ChatShell.tsx create mode 100644 frontend/app/chat/[id]/ChatRoomClient.tsx create mode 100644 frontend/app/chat/[id]/page.tsx create mode 100644 frontend/app/chat/page.tsx create mode 100644 frontend/app/u/[id]/StartDMButton.tsx create mode 100644 frontend/components/ConfirmDialog.tsx create mode 100644 frontend/components/ContextMenu.tsx create mode 100644 frontend/components/RealtimeProvider.tsx create mode 100644 frontend/components/home/FeedNewTip.tsx create mode 100644 frontend/lib/realtime.ts diff --git a/backend/go.mod b/backend/go.mod index 07d7a8a..6578095 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,6 +23,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8b226b2..64f0023 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -33,6 +33,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go index 0603cf8..fa86a84 100644 --- a/backend/handler/admin_user.go +++ b/backend/handler/admin_user.go @@ -105,6 +105,65 @@ func (h *Handlers) AdminUserLoginLogs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total, "page": page}) } +// AdminGetUserAudit 站长:用户内容档案摘要 +func (h *Handlers) AdminGetUserAudit(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + profile, err := h.AdminUser.GetAuditProfile(middleware.CurrentActor(c), id) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, profile) +} + +// AdminUserAuditPosts 站长:用户帖子(含驳回/软删) +func (h *Handlers) AdminUserAuditPosts(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + items, total, page, err := h.AdminUser.ListAuditPosts(middleware.CurrentActor(c), id, page, 20) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page}) +} + +// AdminUserAuditComments 站长:用户评论(含驳回/软删) +func (h *Handlers) AdminUserAuditComments(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + items, total, page, err := h.AdminUser.ListAuditComments(middleware.CurrentActor(c), id, page, 20) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page}) +} + +// AdminUserAuditMessages 站长:用户聊天消息(含撤回/软删) +func (h *Handlers) AdminUserAuditMessages(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + items, total, page, err := h.AdminUser.ListAuditMessages(middleware.CurrentActor(c), id, page, 20) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page}) +} + // parseAdminUserID 解析路径中的用户 ID,失败时直接写出 400 func parseAdminUserID(c *gin.Context) (uint, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) @@ -120,8 +179,8 @@ func respondAdminUserError(c *gin.Context, err error) { switch { case errors.Is(err, gorm.ErrRecordNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) - case errors.Is(err, service.ErrProtectedOwner): - // 站长保护属于权限边界而非参数错误 + case errors.Is(err, service.ErrProtectedOwner), + errors.Is(err, service.ErrOwnerOnly): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrAdminSelfAction), errors.Is(err, service.ErrInvalidRole), diff --git a/backend/handler/auth.go b/backend/handler/auth.go index ef31869..596106e 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -117,6 +117,7 @@ func (h *Handlers) Register(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + _ = h.Chat.EnsureDefaultMembership(user.ID) // 自动登录:走与 Login 相同的 token 签发链路 accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password) if err != nil { @@ -263,7 +264,7 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H { func (h *Handlers) Me(c *gin.Context) { claims := middleware.CurrentUser(c) if claims == nil { - resp := gin.H{"user": nil, "unread_count": 0} + resp := gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0} // OptionalAuth 识别到封禁:HTTP 仍为 200(/me 不阻断页面), // 但带 banned/code,客户端静默校正时据此强制下线并弹告知 if c.GetBool(middleware.AccountBannedKey) { @@ -275,17 +276,22 @@ func (h *Handlers) Me(c *gin.Context) { } user, err := h.Auth.GetUserByID(claims.ID) if err != nil { - c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0}) + c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0}) return } var unread int64 if n, err := h.Notification.UnreadCount(claims.ID); err == nil { unread = n } + chatUnread := h.Chat.TotalUnread(claims.ID) // 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染 var boardIDs []uint if user.Role == model.RoleBoardAdmin { boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID) } - c.JSON(http.StatusOK, gin.H{"user": meUserBody(user, boardIDs), "unread_count": unread}) + c.JSON(http.StatusOK, gin.H{ + "user": meUserBody(user, boardIDs), + "unread_count": unread, + "chat_unread_count": chatUnread, + }) } diff --git a/backend/handler/chat.go b/backend/handler/chat.go new file mode 100644 index 0000000..ebfd55c --- /dev/null +++ b/backend/handler/chat.go @@ -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}) +} diff --git a/backend/handler/comment.go b/backend/handler/comment.go index e192a00..b8a4860 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -23,17 +23,22 @@ func (h *Handlers) PostComments(c *gin.Context) { // 待审/被拒帖子的评论不对公众开放 var viewerID uint + var actor *service.Actor var loadActor func() *service.Actor if claims := middleware.CurrentUser(c); claims != nil { viewerID = claims.ID loadActor = h.actorLoader(claims.ID) + if a, err := h.Auth.LoadActor(claims.ID); err == nil { + actor = a + } } if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) return } + boardID, _ := h.Post.GetBoardID(uint(id)) - comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), page, size) + comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), boardID, viewerID, actor, page, size) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -86,6 +91,13 @@ func (h *Handlers) CreateComment(c *gin.Context) { // 子回复:通知父评论作者(Notification.Create 内部排除自我通知) h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content) } + if boardID, e := h.Post.GetBoardID(uint(id)); e == nil { + h.broadcastFeedChanged("comment", uint(id), boardID, claims.ID) + } + } else if comment.Status == model.ContentStatusPending { + if boardID, e := h.Post.GetBoardID(uint(id)); e == nil { + h.notifyPendingReview(boardID, claims.ID, uint(id), comment.ID, "有新评论待审核") + } } c.JSON(http.StatusOK, gin.H{"comment": comment}) } diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 438539c..67efaa4 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -2,12 +2,16 @@ package handler import ( "github.com/freefire/jiang13-bbs/config" + "github.com/freefire/jiang13-bbs/model" + "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" ) // Handlers 聚合所有服务引用 type Handlers struct { Cfg *config.Config + Hub *realtime.Hub Auth *service.AuthService Board *service.BoardService Post *service.PostService @@ -21,4 +25,46 @@ type Handlers struct { Setting *service.SettingService AdminUser *service.AdminUserService Moderation *service.ModerationService + Chat *service.ChatService +} + +// broadcastFeedChanged 帖子流有公开发布内容时全员广播(三期) +func (h *Handlers) broadcastFeedChanged(kind string, postID, boardID, actorID uint) { + if h.Hub == nil { + return + } + h.Hub.BroadcastAll(realtime.Envelope{ + Type: realtime.EventFeedChanged, + Data: gin.H{ + "kind": kind, + "post_id": postID, + "board_id": boardID, + "actor_id": actorID, + }, + }) +} + +// notifyPendingReview 待审内容通知该板可审者(WS 红点由 Notification.OnNotifyNew 统一推送) +func (h *Handlers) notifyPendingReview(boardID, actorID, postID, commentID uint, preview string) { + ids, err := h.Auth.ListBoardModeratorIDs(boardID) + if err != nil || len(ids) == 0 { + return + } + for _, uid := range ids { + if uid == actorID { + continue + } + h.Notification.Create(uid, actorID, model.NotificationTypePendingReview, postID, commentID, preview) + } + h.broadcastModerationChanged() +} + +// broadcastModerationChanged 通知管理团队待审队列有变化(角标由客户端按权限再拉一次) +func (h *Handlers) broadcastModerationChanged() { + if h.Hub == nil { + return + } + h.Hub.BroadcastRoom(realtime.RoomStaff, realtime.Envelope{ + Type: realtime.EventModerationChanged, + }) } diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index b988f02..c8f22f1 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -43,7 +43,20 @@ func (h *Handlers) AdminPendingCounts(c *gin.Context) { // AdminApprovePost 通过帖子 func (h *Handlers) AdminApprovePost(c *gin.Context) { - h.execModeration(c, h.Moderation.ApprovePost) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + actor := middleware.CurrentActor(c) + boardID, err := h.Moderation.ApprovePost(actor, uint(id)) + if err != nil { + h.writeModerationError(c, err) + return + } + h.broadcastFeedChanged("post", uint(id), boardID, actor.ID) + h.broadcastModerationChanged() + c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) } // AdminRejectPost 拒绝帖子 @@ -53,7 +66,20 @@ func (h *Handlers) AdminRejectPost(c *gin.Context) { // AdminApproveComment 通过评论 func (h *Handlers) AdminApproveComment(c *gin.Context) { - h.execModeration(c, h.Moderation.ApproveComment) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + actor := middleware.CurrentActor(c) + postID, boardID, err := h.Moderation.ApproveComment(actor, uint(id)) + if err != nil { + h.writeModerationError(c, err) + return + } + h.broadcastFeedChanged("comment", postID, boardID, actor.ID) + h.broadcastModerationChanged() + c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) } // AdminRejectComment 拒绝评论 @@ -61,7 +87,21 @@ func (h *Handlers) AdminRejectComment(c *gin.Context) { h.execModeration(c, h.Moderation.RejectComment) } -// execModeration 统一解析 :id 并映射审核业务错误 +// writeModerationError 映射审核业务错误为 HTTP 响应 +func (h *Handlers) writeModerationError(c *gin.Context, err error) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"}) + case errors.Is(err, service.ErrModerationForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrNotPending): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"}) + } +} + +// execModeration 统一解析 :id 并映射审核业务错误(拒绝等无额外副作用的操作) func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint) error) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -69,17 +109,9 @@ func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint) return } if err := fn(middleware.CurrentActor(c), uint(id)); err != nil { - switch { - case errors.Is(err, gorm.ErrRecordNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"}) - case errors.Is(err, service.ErrModerationForbidden): - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - case errors.Is(err, service.ErrNotPending): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"}) - } + h.writeModerationError(c, err) return } + h.broadcastModerationChanged() c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) } diff --git a/backend/handler/post.go b/backend/handler/post.go index cae7c0c..7a7416d 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -20,6 +20,15 @@ func (h *Handlers) Posts(c *gin.Context) { keyword := c.Query("keyword") recommended := c.Query("recommended") == "true" + var viewerID uint + var actor *service.Actor + if claims := middleware.CurrentUser(c); claims != nil { + viewerID = claims.ID + if a, err := h.Auth.LoadActor(claims.ID); err == nil { + actor = a + } + } + items, total, err := h.Post.List(service.PostListQuery{ BoardID: uint(boardID), Page: page, @@ -27,6 +36,8 @@ func (h *Handlers) Posts(c *gin.Context) { Sort: sort, Keyword: keyword, Recommended: recommended, + ViewerID: viewerID, + Actor: actor, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -109,6 +120,11 @@ func (h *Handlers) CreatePost(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if post.Status == model.ContentStatusPublished { + h.broadcastFeedChanged("post", post.ID, post.BoardID, claims.ID) + } else if post.Status == model.ContentStatusPending { + h.notifyPendingReview(post.BoardID, claims.ID, post.ID, 0, "有新帖待审核:"+post.Title) + } c.JSON(http.StatusOK, gin.H{"post": post}) } diff --git a/backend/handler/realtime.go b/backend/handler/realtime.go new file mode 100644 index 0000000..bf2e414 --- /dev/null +++ b/backend/handler/realtime.go @@ -0,0 +1,126 @@ +package handler + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/freefire/jiang13-bbs/realtime" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// parseWSChatRoom 解析 WS 房间名 chat:{roomID} +func parseWSChatRoom(room string) (uint, bool) { + idStr := strings.TrimPrefix(room, "chat:") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + return 0, false + } + return uint(id), true +} + +// 实时总线 WebSocket 入口:GET /api/ws +// +// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带), +// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。 +// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin, +// 防止跨站页面在用户浏览器中发起握手。 +func (h *Handlers) RealtimeWS(c *gin.Context) { + tokenStr, err := c.Cookie(service.CookieName) + if err != nil || tokenStr == "" { + if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + tokenStr = strings.TrimPrefix(auth, "Bearer ") + } + } + if tokenStr == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + claims, err := h.Auth.ParseToken(tokenStr) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态无效"}) + return + } + if _, err := h.Auth.ValidateClaims(claims); err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态已失效"}) + return + } + + upgrader := websocket.Upgrader{ + HandshakeTimeout: 5 * time.Second, + CheckOrigin: h.checkWSOrigin, + // 子协议不传:鉴权完全走 cookie,禁止 Sec-WebSocket-Protocol 携带 token + } + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return // Upgrade 内部已写错误响应 + } + + // 建连时取实时角色快照决定能否进入 staff 房间(角色变更后旧 JWT 会失效, + // 用户重连即按新角色重新入房) + isStaff := false + canOverseeChat := false + if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil { + isStaff = actor.IsStaff() + canOverseeChat = service.CanOverseeChat(actor.Role) + } + + client := realtime.NewClient( + h.Hub, + conn, + claims.ID, + isStaff, + func() bool { + _, verr := h.Auth.ValidateClaims(claims) + return verr == nil + }, + func() { h.Auth.TouchLastSeen(claims.ID) }, + // 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群 + func(room string) bool { + roomID, ok := parseWSChatRoom(room) + if !ok { + return false + } + if canOverseeChat { + return true + } + _, err := h.Chat.Membership(roomID, claims.ID) + return err == nil + }, + ) + client.Serve(func(cl *realtime.Client) { + // hello 帧:全员带 user_id/ts;staff 额外带在线用户快照用于校正绿点 + data := gin.H{ + "user_id": claims.ID, + "ts": time.Now().Unix(), + } + if isStaff { + data["online_user_ids"] = h.Hub.OnlineUserIDs() + } + cl.Send(realtime.Envelope{Type: realtime.EventHello, Data: data}) + }) +} + +// checkWSOrigin 校验握手 Origin: +// - 无 Origin(非浏览器/native 客户端)放行 +// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001) +// - 生产:Origin 主机必须与请求 Host 同源 +func (h *Handlers) checkWSOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil || u.Scheme != "http" && u.Scheme != "https" { + return false + } + if h.Cfg.DevMode { + host := u.Hostname() + return host == "localhost" || host == "127.0.0.1" + } + return u.Host == r.Host +} diff --git a/backend/handler/setting.go b/backend/handler/setting.go index e7acf2d..907fc0b 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -3,7 +3,9 @@ package handler import ( "errors" "net/http" + "strings" + "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -40,5 +42,11 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) return } - c.JSON(http.StatusOK, gin.H{"ok": true, "accent": req.Accent}) + // 归一化后的值实时广播:所有在线客户端立即热换肤,无需 F5 或等轮询 + saved := strings.ToLower(strings.TrimSpace(req.Accent)) + h.Hub.BroadcastAll(realtime.Envelope{ + Type: realtime.EventSettingsChanged, + Data: map[string]string{"accent": saved}, + }) + c.JSON(http.StatusOK, gin.H{"ok": true, "accent": saved}) } diff --git a/backend/handler/user.go b/backend/handler/user.go index 1e594c0..b880e84 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -6,6 +6,7 @@ import ( "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/model" + "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -62,18 +63,27 @@ func (h *Handlers) UserProfile(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - posts, postsTotal, err := h.Post.ListByUser(user.ID, page, size) + var viewerID uint + var actor *service.Actor + if claims := middleware.CurrentUser(c); claims != nil { + viewerID = claims.ID + if a, err := h.Auth.LoadActor(claims.ID); err == nil { + actor = a + } + } + + posts, postsTotal, err := h.Post.ListByUser(user.ID, viewerID, actor, page, size) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // 填充点赞状态(仅登录用户) - if claims := middleware.CurrentUser(c); claims != nil { + if viewerID > 0 { ids := make([]uint, 0, len(posts)) for _, p := range posts { ids = append(ids, p.ID) } - likedMap := h.Like.BatchHasLiked(ids, claims.ID) + likedMap := h.Like.BatchHasLiked(ids, viewerID) for i := range posts { posts[i].Liked = likedMap[posts[i].ID] } diff --git a/backend/model/db.go b/backend/model/db.go index 6b77963..4280bb7 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -3,6 +3,7 @@ package model import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "log" @@ -29,9 +30,15 @@ func InitDB(dsn string) error { return fmt.Errorf("refresh token 旧数据迁移失败: %w", err) } + // 清理前次失败迁移遗留的 FK 约束(constraint:- 标签防止新建,但不删旧的) + if err := dropStaleChatFKConstraints(db); err != nil { + return fmt.Errorf("清理遗留 FK 约束失败: %w", err) + } + if err := db.AutoMigrate( &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{}, + &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } @@ -54,7 +61,20 @@ func InitDB(dsn string) error { } DB = db + if err := db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_direct_key + ON chat_rooms (direct_key) + WHERE room_type = 'direct' AND deleted_at IS NULL AND direct_key <> '' + `).Error; err != nil { + return fmt.Errorf("私聊唯一索引创建失败: %w", err) + } seedDefaultBoards(db) + if err := seedDefaultChatRoom(db); err != nil { + return fmt.Errorf("默认群聊大厅初始化失败: %w", err) + } + if err := ensureDefaultChatMemberships(db); err != nil { + return fmt.Errorf("默认群聊入群补齐失败: %w", err) + } log.Println("[model] PostgreSQL 数据库初始化完成") return nil } @@ -71,6 +91,22 @@ func PingDB() error { return sqlDB.Ping() } +// dropStaleChatFKConstraints 清理前次迁移失败遗留的 FK 约束。 +// GORM AutoMigrate 先建表再加约束:首次迁移时 Notification.Room 的 FK 添加失败, +// 但 chat_rooms 表已建好且带 fk_chat_rooms_last_message 约束。 +// 模型已改用 constraint:- 防止新建,但 GORM 不会删除已存在的约束,需手动清理。 +func dropStaleChatFKConstraints(db *gorm.DB) error { + // 对每个遗留约束名,在可能的两张表上都尝试删除(IF EXISTS 保证幂等) + pairs := []struct{ table, constraint string }{ + {"chat_rooms", "fk_chat_rooms_last_message"}, + {"notifications", "fk_notifications_room"}, + } + for _, p := range pairs { + db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint)) + } + return nil +} + // prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列, // 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前: // 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败) @@ -193,3 +229,92 @@ func seedDefaultBoards(db *gorm.DB) { } } } + +// seedDefaultChatRoom 确保存在唯一的全站大厅(公开默认群) +func seedDefaultChatRoom(db *gorm.DB) error { + var n int64 + if err := db.Model(&ChatRoom{}).Where("is_default = ?", true).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return nil + } + var owner User + if err := db.Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error; err != nil { + // 尚无用户时跳过,等有站长后再由下次启动补齐 + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + // 尝试任意用户 + if err2 := db.Order("id ASC").First(&owner).Error; err2 != nil { + return nil + } + } + room := ChatRoom{ + Name: "全站大厅", + Description: "所有成员默认加入的公开群聊", + OwnerID: owner.ID, + RoomType: ChatRoomTypeGroup, + IsPrivate: false, + IsDefault: true, + MemberCount: 0, + } + if err := db.Create(&room).Error; err != nil { + return err + } + log.Println("[model] 已创建默认群聊「全站大厅」") + return nil +} + +// ensureDefaultChatMemberships 把未封禁用户补进默认大厅(幂等) +func ensureDefaultChatMemberships(db *gorm.DB) error { + var room ChatRoom + if err := db.Where("is_default = ?", true).First(&room).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + var userIDs []uint + if err := db.Model(&User{}). + Where("deleted_at IS NULL AND banned = ?", false). + Pluck("id", &userIDs).Error; err != nil { + return err + } + if len(userIDs) == 0 { + return nil + } + var existing []uint + if err := db.Model(&ChatRoomMember{}). + Where("room_id = ?", room.ID). + Pluck("user_id", &existing).Error; err != nil { + return err + } + have := make(map[uint]struct{}, len(existing)) + for _, id := range existing { + have[id] = struct{}{} + } + added := 0 + for _, uid := range userIDs { + if _, ok := have[uid]; ok { + continue + } + role := ChatRoleMember + if uid == room.OwnerID { + role = ChatRoleOwner + } + m := ChatRoomMember{RoomID: room.ID, UserID: uid, Role: role} + if err := db.Create(&m).Error; err != nil { + continue + } + added++ + } + if added > 0 { + _ = db.Model(&ChatRoom{}).Where("id = ?", room.ID). + UpdateColumn("member_count", gorm.Expr( + "(SELECT count(*) FROM chat_room_members WHERE room_id = ?)", room.ID, + )).Error + log.Printf("[model] 默认群聊补齐入群 %d 人", added) + } + return nil +} diff --git a/backend/model/models.go b/backend/model/models.go index e21061a..27d8ed4 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -169,27 +169,32 @@ type Checkin struct { // 通知类型 const ( - NotificationTypeComment = "comment" // 评论了你的帖子 - NotificationTypeReply = "reply" // 回复了你的评论 - NotificationTypeLike = "like" // 点赞了你的帖子 - NotificationTypeApproved = "approved" // 内容审核通过 - NotificationTypeRejected = "rejected" // 内容审核未通过 + NotificationTypeComment = "comment" // 评论了你的帖子 + NotificationTypeReply = "reply" // 回复了你的评论 + NotificationTypeLike = "like" // 点赞了你的帖子 + NotificationTypeApproved = "approved" // 内容审核通过 + NotificationTypeRejected = "rejected" // 内容审核未通过 + NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID) + NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者) ) // Notification 站内通知 type Notification struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` // 接收通知的用户 - ActorID uint `gorm:"not null" json:"actor_id"` // 触发通知的用户 - Type string `gorm:"size:16;not null;index" json:"type"` // comment | like - PostID uint `gorm:"index;not null" json:"post_id"` // 关联帖子 - CommentID uint `gorm:"index" json:"comment_id"` // 关联评论(点赞时为 0) - Content string `gorm:"size:256" json:"content"` // 内容预览 + UserID uint `gorm:"index;not null" json:"user_id"` // 接收通知的用户 + ActorID uint `gorm:"not null" json:"actor_id"` // 触发通知的用户 + Type string `gorm:"size:16;not null;index" json:"type"` // comment | like | mention ... + PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 关联帖子(mention 类为 0) + CommentID uint `gorm:"index" json:"comment_id"` // 关联评论(点赞时为 0) + RoomID uint `gorm:"index;not null;default:0" json:"room_id"` // 关联群聊(mention) + MessageID uint `gorm:"not null;default:0" json:"message_id"` // 关联群聊消息(mention) + Content string `gorm:"size:256" json:"content"` // 内容预览 IsRead bool `gorm:"default:false;index" json:"is_read"` CreatedAt time.Time `json:"created_at"` - Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"` - Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` + Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"` + Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` + Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"` } // Announcement 站点公告(后台文章管理:支持草稿、标签与标签预设色) @@ -252,3 +257,66 @@ type LoginLog struct { Success bool `gorm:"index;not null;default:false" json:"success"` CreatedAt time.Time `gorm:"index" json:"created_at"` } + +// 群聊成员角色 +const ( + ChatRoleOwner = "owner" // 群主(建群者,每群唯一):改名/解散/踢人/禁言 + ChatRoleMember = "member" // 普通成员 +) + +// ChatRoom 会话房间:群聊或一对一私聊(RoomType);IsDefault 为全站大厅 +type ChatRoom struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:64;not null;default:''" json:"name"` + Description string `gorm:"size:256;not null;default:''" json:"description"` + OwnerID uint `gorm:"index;not null" json:"owner_id"` + RoomType string `gorm:"size:16;not null;default:group;index" json:"room_type"` // group | direct + DirectKey string `gorm:"size:64;not null;default:'';index" json:"-"` // 私聊双方 ID 排序键 "min:max" + IsPrivate bool `gorm:"not null;default:false;index" json:"is_private"` + IsDefault bool `gorm:"not null;default:false;index" json:"is_default"` + MemberCount int `gorm:"not null;default:0" json:"member_count"` + LastMessageID uint `gorm:"index;not null;default:0" json:"last_message_id"` // 最后一条消息(列表排序/未读基准) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Owner User `gorm:"foreignKey:OwnerID" json:"owner,omitempty"` + LastMessage *ChatMessage `gorm:"foreignKey:LastMessageID;constraint:-" json:"last_message,omitempty"` +} + +const ( + ChatRoomTypeGroup = "group" + ChatRoomTypeDirect = "direct" +) + +// ChatRoomMember 群成员(房间+用户联合唯一;LastReadMessageID 为未读对账水位) +type ChatRoomMember struct { + ID uint `gorm:"primaryKey" json:"id"` + RoomID uint `gorm:"uniqueIndex:idx_chat_member;index;not null" json:"room_id"` + UserID uint `gorm:"uniqueIndex:idx_chat_member;not null" json:"user_id"` + Role string `gorm:"size:16;not null;default:member" json:"role"` + LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"` + Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言 + PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// ChatMessage 群聊消息(先落库再经 WS 广播,WS 不承担可靠投递;撤回保留行供审计) +type ChatMessage struct { + ID uint `gorm:"primaryKey" json:"id"` + RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"` + SenderID uint `gorm:"index;not null" json:"sender_id"` + Content string `gorm:"type:varchar(2000);not null" json:"content"` + ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用 + ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示 + MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用) + RecalledAt *time.Time `json:"recalled_at,omitempty"` + RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"` + CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"` +} diff --git a/backend/realtime/client.go b/backend/realtime/client.go new file mode 100644 index 0000000..431c54f --- /dev/null +++ b/backend/realtime/client.go @@ -0,0 +1,206 @@ +package realtime + +import ( + "encoding/json/v2" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +func uintToString(n uint) string { + return strconv.FormatUint(uint64(n), 10) +} + +const ( + // writeWait 单次写帧超时 + writeWait = 10 * time.Second + // pongWait 无 Pong 超时:浏览器自动响应协议层 Ping,超时即判定断连 + pongWait = 70 * time.Second + // pingPeriod 协议层 Ping 周期(须小于 pongWait) + pingPeriod = 30 * time.Second + // maxMessageSize 仅接收应用层心跳,限制 4KiB 足够 + maxMessageSize = 4 << 10 + // sendBufferSize 慢消费保护阈值 + sendBufferSize = 32 + // CloseAuthInvalid 鉴权失效(封禁/强制下线)自定义关闭码,前端不自动重连 + CloseAuthInvalid = 4401 +) + +// Client 一条 WebSocket 连接 +type Client struct { + hub *Hub + conn *websocket.Conn + userID uint + // staff 建连时按 DB 实时 Actor 判定,决定是否加入管理团队房间 + staff bool + // validate 应用层心跳时复查 token_version / 封禁状态,失效则立即断连 + validate func() bool + // onHeartbeat 心跳顺带刷新 last_seen_at(SQL 60s 限频), + // 让只挂着 WS、不发 HTTP 请求的标签页也保持在线统计准确 + onHeartbeat func() + // authorizeRoom 客户端动态订阅房间前的业务鉴权(如校验群成员身份); + // 仅 "chat:" 前缀房间允许订阅,user:/staff 等内部房间禁止 + authorizeRoom func(room string) bool + + send chan []byte + rooms map[string]struct{} + closed atomic.Bool + closeCh chan struct{} +} + +func NewClient( + hub *Hub, + conn *websocket.Conn, + userID uint, + staff bool, + validate func() bool, + onHeartbeat func(), + authorizeRoom func(room string) bool, +) *Client { + return &Client{ + hub: hub, + conn: conn, + userID: userID, + staff: staff, + validate: validate, + onHeartbeat: onHeartbeat, + authorizeRoom: authorizeRoom, + send: make(chan []byte, sendBufferSize), + rooms: make(map[string]struct{}), + closeCh: make(chan struct{}), + } +} + +// Serve 注册到 Hub 后阻塞运行读写泵,断开时注销(调用方在升级成功后调用一次)。 +func (c *Client) Serve(sendHello func(*Client)) { + cameOnline := c.hub.register(c) + sendHello(c) + if cameOnline { + c.hub.BroadcastPresence(c.userID, true) + } + + go c.writePump() + c.readPump() // 阻塞至断连 + + wentOffline := c.hub.unregister(c) + c.forceClose() + if wentOffline { + c.hub.BroadcastPresence(c.userID, false) + } +} + +// forceClose 幂等关闭连接并触发 closeCh +func (c *Client) forceClose() { + if c.closed.CompareAndSwap(false, true) { + _ = c.conn.Close() + close(c.closeCh) + } +} + +// clientInbound 浏览器→服务端帧:ping 心跳;join/leave 动态订阅/退订房间 +type clientInbound struct { + Type string `json:"type"` + Room string `json:"room,omitempty"` +} + +// readPump 单读泵:Pong 续期 + 应用层心跳鉴权复查 + 房间订阅 +func (c *Client) readPump() { + c.conn.SetReadLimit(maxMessageSize) + _ = c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + return c.conn.SetReadDeadline(time.Now().Add(pongWait)) + }) + + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + return + } + var msg clientInbound + if err := json.Unmarshal(raw, &msg); err != nil { + continue // 非法帧忽略,不踢连接 + } + switch msg.Type { + case "ping": + // 心跳鉴权复查:被封禁/改密/降级强制下线后 ~25s 内断开 + if c.validate != nil && !c.validate() { + _ = c.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(CloseAuthInvalid, "auth invalid"), + time.Now().Add(writeWait), + ) + return + } + if c.onHeartbeat != nil { + c.onHeartbeat() + } + c.enqueueMustMarshal(Envelope{Type: EventPong, Data: map[string]int64{ + "ts": time.Now().Unix(), + }}) + case "join", "leave": + c.handleRoomFrame(msg) + } + } +} + +// handleRoomFrame 处理房间订阅:只允许 chat:{id},且实时校验调用方业务身份 +func (c *Client) handleRoomFrame(msg clientInbound) { + room := msg.Room + if room == "" || len(room) > 32 || !strings.HasPrefix(room, "chat:") { + return + } + idStr := strings.TrimPrefix(room, "chat:") + roomID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || roomID == 0 { + return + } + if c.authorizeRoom != nil && !c.authorizeRoom(room) { + return + } + if msg.Type == "join" { + c.hub.JoinClientRoom(c, room) + } else { + c.hub.LeaveClientRoom(c, room) + } +} + +// writePump 单写泵:唯一持有连接写权限;协议层 Ping + 业务帧都从这里发出 +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + for { + select { + case payload := <-c.send: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-ticker.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-c.closeCh: + return + } + } +} + +// Send 业务帧入队(由唯一 writePump 发出);连接已关闭时静默丢弃 +func (c *Client) Send(env Envelope) { + c.enqueueMustMarshal(env) +} + +func (c *Client) enqueueMustMarshal(env Envelope) { + payload, err := json.Marshal(env) + if err != nil { + return + } + select { + case c.send <- payload: + case <-c.closeCh: + } +} diff --git a/backend/realtime/hub.go b/backend/realtime/hub.go new file mode 100644 index 0000000..2890020 --- /dev/null +++ b/backend/realtime/hub.go @@ -0,0 +1,233 @@ +// Package realtime 全站实时通信总线(一期)。 +// +// 设计: +// - 单条 WebSocket 连接(GET /api/ws,cookie 鉴权)复用承载所有实时事件, +// 后续群聊、通知、审核角标等都接入本 Hub,而不是各开连接。 +// - Hub 维护连接表、房间(主题订阅)表与每用户连接计数;房间用于定向广播 +// (user:{id} 个人房间、staff 管理团队房间)。 +// - 所有写操作投递到 client.send 缓冲通道,由唯一的 writePump 消费, +// 杜绝多 goroutine 并发写 WebSocket。 +// - 在线判定 = 拥有至少 1 条活跃 WS 连接,比 last_seen_at 5 分钟启发式精确; +// 上线/下线事件只推送给 staff 房间。 +package realtime + +import ( + "encoding/json/v2" + "sync" +) + +// 事件类型:前后端共享的单层 JSON 协议契约,禁止手拼 JSON / 双重编码 +const ( + EventHello = "hello" // 建连欢迎帧(含在线用户快照) + EventPong = "pong" // 应用层心跳应答 + EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播 + EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间 + EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID} + EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id} + EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id} + EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播 + EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID} + EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标) + EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标) +) + +// RoomStaff 管理团队房间(板块管理员及以上) +const RoomStaff = "staff" + +// RoomChat 群聊房间(仅群成员可通过 join 帧订阅) +func RoomChat(roomID uint) string { + return "chat:" + uintToString(roomID) +} + +func roomUser(userID uint) string { + return "user:" + uintToString(userID) +} + +// RoomUser 个人房间(导出供按用户定向推送) +func RoomUser(userID uint) string { + return roomUser(userID) +} + +// Envelope 实时消息统一信封 +type Envelope struct { + Type string `json:"type"` + Data interface{} `json:"data,omitempty"` +} + +// Hub 连接与房间注册中心(单实例内存版;多实例时由 PG LISTEN/NOTIFY 桥接) +type Hub struct { + mu sync.RWMutex + // 全部活跃连接 + clients map[*Client]struct{} + // 房间 -> 连接集合 + rooms map[string]map[*Client]struct{} + // 用户 -> 活跃连接数(同一用户多标签页) + userConnCnt map[uint]int +} + +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]struct{}), + rooms: make(map[string]map[*Client]struct{}), + userConnCnt: make(map[uint]int), + } +} + +// register 注册新连接。返回 cameOnline=true 表示该用户 0→1 首次上线。 +func (h *Hub) register(c *Client) (cameOnline bool) { + h.mu.Lock() + h.clients[c] = struct{}{} + n := h.userConnCnt[c.userID] + h.userConnCnt[c.userID] = n + 1 + h.joinLocked(c, roomUser(c.userID)) + if c.staff { + h.joinLocked(c, RoomStaff) + } + h.mu.Unlock() + return n == 0 +} + +// unregister 注销连接,返回 wentOffline=true 表示该用户最后一条连接断开。 +func (h *Hub) unregister(c *Client) (wentOffline bool) { + h.mu.Lock() + if _, ok := h.clients[c]; !ok { + h.mu.Unlock() + return false + } + delete(h.clients, c) + for room := range c.rooms { + h.leaveLocked(c, room) + } + clear(c.rooms) + n := h.userConnCnt[c.userID] - 1 + if n <= 0 { + delete(h.userConnCnt, c.userID) + wentOffline = true + } else { + h.userConnCnt[c.userID] = n + } + h.mu.Unlock() + return wentOffline +} + +func (h *Hub) joinLocked(c *Client, room string) { + m := h.rooms[room] + if m == nil { + m = make(map[*Client]struct{}) + h.rooms[room] = m + } + m[c] = struct{}{} + c.rooms[room] = struct{}{} +} + +func (h *Hub) leaveLocked(c *Client, room string) { + if m := h.rooms[room]; m != nil { + delete(m, c) + if len(m) == 0 { + delete(h.rooms, room) + } + } + delete(c.rooms, room) +} + +// BroadcastAll 向全部连接广播 +func (h *Hub) BroadcastAll(env Envelope) { + h.mu.RLock() + clients := make([]*Client, 0, len(h.clients)) + for c := range h.clients { + clients = append(clients, c) + } + h.mu.RUnlock() + h.dispatch(clients, env) +} + +// BroadcastRoom 向指定房间广播 +func (h *Hub) BroadcastRoom(room string, env Envelope) { + h.mu.RLock() + m := h.rooms[room] + clients := make([]*Client, 0, len(m)) + for c := range m { + clients = append(clients, c) + } + h.mu.RUnlock() + h.dispatch(clients, env) +} + +// JoinClientRoom 连接动态加入房间(群聊订阅;调用方必须先完成成员鉴权)。 +// 返回 false 表示连接已注销。 +func (h *Hub) JoinClientRoom(c *Client, room string) bool { + h.mu.Lock() + defer h.mu.Unlock() + if _, ok := h.clients[c]; !ok { + return false + } + h.joinLocked(c, room) + return true +} + +// LeaveClientRoom 连接动态离开房间 +func (h *Hub) LeaveClientRoom(c *Client, room string) { + h.mu.Lock() + defer h.mu.Unlock() + h.leaveLocked(c, room) +} + +// RemoveUserRoom 把某用户的全部活跃连接移出房间(群主踢人后立即停止实时投递) +func (h *Hub) RemoveUserRoom(userID uint, room string) { + h.mu.Lock() + var targets []*Client + for c := range h.rooms[room] { + if c.userID == userID { + targets = append(targets, c) + } + } + for _, c := range targets { + h.leaveLocked(c, room) + } + h.mu.Unlock() +} + +// BroadcastUser 向某用户的全部活跃连接(多标签页)单播 +func (h *Hub) BroadcastUser(userID uint, env Envelope) { + h.BroadcastRoom(roomUser(userID), env) +} + +// BroadcastPresence 上/下线事件推送给管理团队 +func (h *Hub) BroadcastPresence(userID uint, online bool) { + h.BroadcastRoom(RoomStaff, Envelope{ + Type: EventPresenceUpdate, + Data: map[string]interface{}{ + "user_id": userID, + "online": online, + }, + }) +} + +// OnlineUserIDs 当前在线用户快照(供 staff 建连时校正绿点) +func (h *Hub) OnlineUserIDs() []uint { + h.mu.RLock() + defer h.mu.RUnlock() + ids := make([]uint, 0, len(h.userConnCnt)) + for id := range h.userConnCnt { + ids = append(ids, id) + } + return ids +} + +func (h *Hub) dispatch(clients []*Client, env Envelope) { + if len(clients) == 0 { + return + } + payload, err := json.Marshal(env) + if err != nil { + return + } + for _, c := range clients { + select { + case c.send <- payload: + default: + // 慢消费者:缓冲已满直接关闭连接,等客户端指数退避重连补齐 + c.forceClose() + } + } +} diff --git a/backend/router/router.go b/backend/router/router.go index 688ca56..15d6d77 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -8,6 +8,7 @@ import ( "github.com/freefire/jiang13-bbs/handler" "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-contrib/cors" "github.com/gin-gonic/gin" @@ -51,6 +52,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { settingSvc := service.NewSettingService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB) moderationSvc := service.NewModerationService(model.DB, notifSvc) + chatSvc := service.NewChatService(model.DB, notifSvc) if err := uploadSvc.EnsureDir(); err != nil { return nil, err } @@ -58,6 +60,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { h := &handler.Handlers{ Cfg: cfg, + Hub: realtime.NewHub(), Auth: authSvc, Board: boardSvc, Post: postSvc, @@ -71,6 +74,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Setting: settingSvc, AdminUser: adminUserSvc, Moderation: moderationSvc, + Chat: chatSvc, + } + // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) + notifSvc.OnNotifyNew = func(userID uint) { + if h.Hub == nil { + return + } + h.Hub.BroadcastUser(userID, realtime.Envelope{ + Type: realtime.EventNotificationNew, + }) } authMW := middleware.NewAuthMiddleware(authSvc) @@ -83,6 +96,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/robots.txt", h.RobotsTxt) r.GET("/sitemap.xml", h.SitemapXML) + // 实时通信总线(WebSocket,cookie 鉴权 + Origin 校验,处理器内部完成鉴权升级) + r.GET("/api/ws", h.RealtimeWS) + // 公开 API(可选登录) pubAPI := r.Group("/api", authMW.OptionalAuth()) { @@ -131,6 +147,26 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.PUT("/avatar/use", h.UseAvatar) api.GET("/my/media", h.MyMedia) api.DELETE("/my/attachments/:id", h.DeleteAttachment) + + // 群聊(二期):建群/成员/消息/未读 + api.GET("/chat/rooms", h.ListChatRooms) + api.POST("/chat/rooms", h.CreateChatRoom) + api.POST("/chat/direct", h.OpenDirectChat) + api.GET("/chat/users", h.SearchChatUsers) + api.GET("/chat/rooms/:id", h.GetChatRoom) + api.PUT("/chat/rooms/:id", h.UpdateChatRoom) + api.PUT("/chat/rooms/:id/pin", h.PinChatRoom) + api.DELETE("/chat/rooms/:id", h.DissolveChatRoom) + api.POST("/chat/rooms/:id/join", h.JoinChatRoom) + api.POST("/chat/rooms/:id/leave", h.LeaveChatRoom) + api.GET("/chat/rooms/:id/members", h.ListChatMembers) + api.POST("/chat/rooms/:id/members", h.InviteChatMembers) + api.DELETE("/chat/rooms/:id/members/:uid", h.KickChatMember) + api.GET("/chat/rooms/:id/messages", h.ListChatMessages) + api.POST("/chat/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage) + api.DELETE("/chat/rooms/:id/messages/:mid", h.RecallChatMessage) + api.PUT("/chat/rooms/:id/read", h.MarkChatRead) + api.GET("/chat/unread-summary", h.ChatUnreadSummary) } // 管理后台 API:RequireStaff 基础鉴权(板块管理员及以上,Actor 实时从 DB 现取), @@ -166,6 +202,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole) usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan) usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs) + // 站长内容档案(handler 内再校验 owner) + usersAPI.GET("/users/:id", h.AdminGetUserAudit) + usersAPI.GET("/users/:id/posts", h.AdminUserAuditPosts) + usersAPI.GET("/users/:id/comments", h.AdminUserAuditComments) + usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages) } r.NoRoute(func(c *gin.Context) { diff --git a/backend/service/actor.go b/backend/service/actor.go index 4ec584a..4df237b 100644 --- a/backend/service/actor.go +++ b/backend/service/actor.go @@ -1,6 +1,8 @@ package service import ( + "strings" + "github.com/freefire/jiang13-bbs/model" ) @@ -107,9 +109,53 @@ func (s *AuthService) GetUserBoardIDs(id uint) ([]uint, error) { // GetUserIDByUsername 按用户名查 ID(登录失败审计用),用户不存在返回 0 func (s *AuthService) GetUserIDByUsername(username string) uint { + username = strings.TrimSpace(username) + if username == "" { + return 0 + } var u model.User if err := s.db.Select("id").Where("username = ?", username).First(&u).Error; err != nil { return 0 } return u.ID } + +// ListBoardModeratorIDs 可审核指定板块的用户 ID: +// 站长/超管/全站管理员 + 被授权该板的板块管理员(未封禁、未删除) +func (s *AuthService) ListBoardModeratorIDs(boardID uint) ([]uint, error) { + var ids []uint + // 全站可审:admin / super_admin / owner + if err := s.db.Model(&model.User{}). + Where("deleted_at IS NULL AND banned = ? AND role IN ?", false, + []string{string(model.RoleAdmin), string(model.RoleSuperAdmin), string(model.RoleOwner)}). + Pluck("id", &ids).Error; err != nil { + return nil, err + } + // 板块管理员(授权该板) + var boardAdminIDs []uint + if err := s.db.Model(&model.UserBoard{}). + Select("user_boards.user_id"). + Joins("JOIN users ON users.id = user_boards.user_id"). + Where("user_boards.board_id = ? AND users.deleted_at IS NULL AND users.banned = ? AND users.role = ?", + boardID, false, string(model.RoleBoardAdmin)). + Pluck("user_boards.user_id", &boardAdminIDs).Error; err != nil { + return nil, err + } + seen := make(map[uint]struct{}, len(ids)+len(boardAdminIDs)) + out := make([]uint, 0, len(ids)+len(boardAdminIDs)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, id := range boardAdminIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index d874560..1f86538 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -22,6 +22,8 @@ var ( ErrBoardRequired = errors.New("板块管理员至少需要授权一个板块") // ErrBoardNotFound 授权的板块不存在 ErrBoardNotFound = errors.New("部分板块不存在") + // ErrOwnerOnly 仅站长可访问用户内容档案 + ErrOwnerOnly = errors.New("仅站长可查看用户内容档案") ) // onlineThreshold last_seen_at 在该窗口内视为在线 @@ -197,9 +199,10 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem { commentCounts := map[uint]int64{} var postRows []countRow + // 列表计数统一为「已发布」口径,与评论一致;全量含驳回/软删见内容档案 s.db.Model(&model.Post{}). Select("user_id, COUNT(*) AS cnt"). - Where("user_id IN ?", ids). + Where("user_id IN ? AND status = ?", ids, model.ContentStatusPublished). Group("user_id").Scan(&postRows) for _, r := range postRows { postCounts[r.UserID] = r.Cnt @@ -433,3 +436,240 @@ func invalidateUserSessions(tx *gorm.DB, userID uint) error { return tx.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false). Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error } + +// ===== 站长用户内容档案(仅 owner) ===== + +func requireOwnerActor(actor *Actor) error { + if actor == nil || actor.Role != model.RoleOwner { + return ErrOwnerOnly + } + return nil +} + +func normalizeAuditPage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + return page, size +} + +// AdminUserAuditProfile 站长查看用户档案摘要 +type AdminUserAuditProfile struct { + User AdminUserItem `json:"user"` + PostsTotal int64 `json:"posts_total"` // 含各状态与软删 + CommentsTotal int64 `json:"comments_total"` // 含各状态与软删 + MessagesTotal int64 `json:"messages_total"` // 含撤回与软删 + PublishedPosts int64 `json:"published_posts"` + PublishedComments int64 `json:"published_comments"` +} + +// AdminAuditPostItem 审计帖子行 +type AdminAuditPostItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Status string `json:"status"` + Deleted bool `json:"deleted"` + BoardID uint `json:"board_id"` + BoardName string `json:"board_name"` + CreatedAt time.Time `json:"created_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +// AdminAuditCommentItem 审计评论行 +type AdminAuditCommentItem struct { + ID uint `json:"id"` + PostID uint `json:"post_id"` + Content string `json:"content"` + Status string `json:"status"` + Deleted bool `json:"deleted"` + CreatedAt time.Time `json:"created_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +// AdminAuditMessageItem 审计聊天消息行 +type AdminAuditMessageItem struct { + ID uint `json:"id"` + RoomID uint `json:"room_id"` + RoomName string `json:"room_name"` + RoomType string `json:"room_type"` + Content string `json:"content"` + Recalled bool `json:"recalled"` + Deleted bool `json:"deleted"` + CreatedAt time.Time `json:"created_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +// GetAuditProfile 站长:用户摘要 + 全量计数 +func (s *AdminUserService) GetAuditProfile(actor *Actor, userID uint) (*AdminUserAuditProfile, error) { + if err := requireOwnerActor(actor); err != nil { + return nil, err + } + item, err := s.getItem(s.db, userID) + if err != nil { + return nil, err + } + out := &AdminUserAuditProfile{User: *item} + s.db.Unscoped().Model(&model.Post{}).Where("user_id = ?", userID).Count(&out.PostsTotal) + s.db.Unscoped().Model(&model.Comment{}).Where("user_id = ?", userID).Count(&out.CommentsTotal) + s.db.Unscoped().Model(&model.ChatMessage{}).Where("sender_id = ?", userID).Count(&out.MessagesTotal) + s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&out.PublishedPosts) + s.db.Model(&model.Comment{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&out.PublishedComments) + return out, nil +} + +// ListAuditPosts 站长:用户全部帖子(含软删) +func (s *AdminUserService) ListAuditPosts(actor *Actor, userID uint, page, size int) ([]AdminAuditPostItem, int64, int, error) { + if err := requireOwnerActor(actor); err != nil { + return nil, 0, page, err + } + page, size = normalizeAuditPage(page, size) + if err := s.db.First(&model.User{}, userID).Error; err != nil { + return nil, 0, page, err + } + q := s.db.Unscoped().Model(&model.Post{}).Where("user_id = ?", userID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, page, err + } + var posts []model.Post + if err := q.Preload("Board").Order("id DESC"). + Offset((page - 1) * size).Limit(size).Find(&posts).Error; err != nil { + return nil, 0, page, err + } + items := make([]AdminAuditPostItem, 0, len(posts)) + for _, p := range posts { + it := AdminAuditPostItem{ + ID: p.ID, + Title: p.Title, + Content: truncateStr(p.Content, 240), + Status: p.Status, + BoardID: p.BoardID, + BoardName: p.Board.Name, + CreatedAt: p.CreatedAt, + } + if p.DeletedAt.Valid { + it.Deleted = true + t := p.DeletedAt.Time + it.DeletedAt = &t + } + items = append(items, it) + } + return items, total, page, nil +} + +// ListAuditComments 站长:用户全部评论(含软删) +func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, size int) ([]AdminAuditCommentItem, int64, int, error) { + if err := requireOwnerActor(actor); err != nil { + return nil, 0, page, err + } + page, size = normalizeAuditPage(page, size) + if err := s.db.First(&model.User{}, userID).Error; err != nil { + return nil, 0, page, err + } + q := s.db.Unscoped().Model(&model.Comment{}).Where("user_id = ?", userID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, page, err + } + var comments []model.Comment + if err := q.Order("id DESC"). + Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil { + return nil, 0, page, err + } + items := make([]AdminAuditCommentItem, 0, len(comments)) + for _, c := range comments { + it := AdminAuditCommentItem{ + ID: c.ID, + PostID: c.PostID, + Content: truncateStr(c.Content, 240), + Status: c.Status, + CreatedAt: c.CreatedAt, + } + if c.DeletedAt.Valid { + it.Deleted = true + t := c.DeletedAt.Time + it.DeletedAt = &t + } + items = append(items, it) + } + return items, total, page, nil +} + +// ListAuditMessages 站长:用户发出的全部聊天消息(含撤回/软删) +func (s *AdminUserService) ListAuditMessages(actor *Actor, userID uint, page, size int) ([]AdminAuditMessageItem, int64, int, error) { + if err := requireOwnerActor(actor); err != nil { + return nil, 0, page, err + } + page, size = normalizeAuditPage(page, size) + if err := s.db.First(&model.User{}, userID).Error; err != nil { + return nil, 0, page, err + } + q := s.db.Unscoped().Model(&model.ChatMessage{}).Where("sender_id = ?", userID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, page, err + } + var msgs []model.ChatMessage + if err := q.Order("id DESC"). + Offset((page - 1) * size).Limit(size).Find(&msgs).Error; err != nil { + return nil, 0, page, err + } + + roomIDs := make([]uint, 0, len(msgs)) + seen := map[uint]struct{}{} + for _, m := range msgs { + if _, ok := seen[m.RoomID]; ok { + continue + } + seen[m.RoomID] = struct{}{} + roomIDs = append(roomIDs, m.RoomID) + } + roomMap := map[uint]model.ChatRoom{} + if len(roomIDs) > 0 { + var rooms []model.ChatRoom + _ = s.db.Unscoped().Where("id IN ?", roomIDs).Find(&rooms) + for _, r := range rooms { + roomMap[r.ID] = r + } + } + + items := make([]AdminAuditMessageItem, 0, len(msgs)) + for _, m := range msgs { + room := roomMap[m.RoomID] + name := room.Name + if name == "" { + if room.RoomType == model.ChatRoomTypeDirect { + name = "私聊" + } else if room.IsDefault { + name = "全站大厅" + } else { + name = "群聊" + } + } + content := m.Content + recalled := m.RecalledAt != nil + if recalled && content == "" { + content = "(已撤回的消息)" + } + it := AdminAuditMessageItem{ + ID: m.ID, + RoomID: m.RoomID, + RoomName: name, + RoomType: room.RoomType, + Content: truncateStr(content, 240), + Recalled: recalled, + CreatedAt: m.CreatedAt, + } + if m.DeletedAt.Valid { + it.Deleted = true + t := m.DeletedAt.Time + it.DeletedAt = &t + } + items = append(items, it) + } + return items, total, page, nil +} diff --git a/backend/service/chat.go b/backend/service/chat.go new file mode 100644 index 0000000..05c5a36 --- /dev/null +++ b/backend/service/chat.go @@ -0,0 +1,992 @@ +// Package service 群聊业务(二期)。 +// +// 铁律:消息先落库再由 handler 经 WS 广播,WS 不承担可靠投递; +// 未读数以成员行 last_read_message_id 为水位做 SQL 对账,离线期间不丢消息。 +package service + +import ( + "errors" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +var ( + ErrChatNotFound = errors.New("会话不存在") + ErrChatNotMember = errors.New("不是会话成员") + ErrChatNameInvalid = errors.New("群名称需为 2-32 个字符") + ErrChatDescInvalid = errors.New("群简介不能超过 200 个字符") + ErrChatContentEmpty = errors.New("消息内容不能为空") + ErrChatContentTooLong = errors.New("单条消息不能超过 2000 个字符") + ErrChatMuted = errors.New("你已被群主禁言") + ErrChatOwnerOnly = errors.New("仅群主可执行该操作") + ErrChatOwnerLeave = errors.New("群主不能直接退出,请先解散该群") + ErrChatPrivateInvite = errors.New("私密群需邀请才能加入") + ErrChatDefaultLeave = errors.New("默认大厅不可退出") + ErrChatDefaultDissolve = errors.New("默认大厅不可解散") + ErrChatRecallDenied = errors.New("无权撤回该消息") + ErrChatMsgNotFound = errors.New("消息不存在") + ErrChatDMSelf = errors.New("不能与自己私聊") + ErrChatDMDissolve = errors.New("私聊会话不可解散") + ErrChatUserGone = errors.New("用户不存在或已封禁") + ErrChatHallPinFixed = errors.New("大厅已置顶且不可取消") +) + +// CanOverseeChat 站长/超管可旁路查看全部群并撤回任意消息 +func CanOverseeChat(role model.Role) bool { + return role == model.RoleOwner || role == model.RoleSuperAdmin +} + +const ( + chatNameMinRune = 2 + chatNameMaxRune = 32 + chatDescMaxRune = 200 + chatMsgMaxRune = 2000 + chatMentionMax = 20 +) + +// chatMentionRe @提醒匹配:用户名 3-32 位字母/数字/下划线/连字符; +// 是否有效最终以"群成员精确匹配"为准,名单外的 @ 原样当普通文本 +var chatMentionRe = regexp.MustCompile(`@([A-Za-z0-9_-]{3,32})`) + +// ChatService 群聊服务 +type ChatService struct { + db *gorm.DB + notif *NotificationService +} + +func NewChatService(db *gorm.DB, notif *NotificationService) *ChatService { + return &ChatService{db: db, notif: notif} +} + +// RoomView 会话列表/详情聚合视图 +type RoomView struct { + model.ChatRoom + Joined bool `json:"joined"` + MyRole string `json:"my_role"` + UnreadCount int64 `json:"unread_count"` + Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制) + PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消 + Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct) +} + +// UnreadRoom 单个会话的未读计数 +type UnreadRoom struct { + RoomID uint `gorm:"column:room_id" json:"room_id"` + Count int64 `gorm:"column:cnt" json:"count"` +} + +func directKey(a, b uint) string { + if a > b { + a, b = b, a + } + return strconv.FormatUint(uint64(a), 10) + ":" + strconv.FormatUint(uint64(b), 10) +} + +func (s *ChatService) getRoom(roomID uint) (*model.ChatRoom, error) { + var room model.ChatRoom + err := s.db.First(&room, roomID).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrChatNotFound + } + if err != nil { + return nil, err + } + return &room, nil +} + +func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatRoomMember, error) { + var m model.ChatRoomMember + err := tx.Where("room_id = ? AND user_id = ?", roomID, userID).First(&m).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrChatNotMember + } + if err != nil { + return nil, err + } + return &m, nil +} + +// Membership 供 WS 握手后动态订阅房间鉴权 +func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, error) { + return s.membership(s.db, roomID, userID) +} + +// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位即未读) +func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 { + out := make(map[uint]int64) + if userID == 0 || len(roomIDs) == 0 { + return out + } + var rows []UnreadRoom + s.db.Table("chat_messages AS m"). + Select("m.room_id AS room_id, count(*) AS cnt"). + Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID). + Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs). + Group("m.room_id"). + Scan(&rows) + for _, r := range rows { + out[r.RoomID] = r.Count + } + return out +} + +// toViews 批量填充 joined/角色/未读(避免 N+1) +func (s *ChatService) toViews(rooms []model.ChatRoom, userID uint) []RoomView { + ids := make([]uint, 0, len(rooms)) + for i := range rooms { + ids = append(ids, rooms[i].ID) + } + memberMap := make(map[uint]model.ChatRoomMember) + if userID != 0 && len(ids) > 0 { + var ms []model.ChatRoomMember + s.db.Where("user_id = ? AND room_id IN ?", userID, ids).Find(&ms) + for _, m := range ms { + memberMap[m.RoomID] = m + } + } + unread := s.unreadCounts(userID, ids) + views := make([]RoomView, 0, len(rooms)) + for _, r := range rooms { + v := RoomView{ChatRoom: r, UnreadCount: unread[r.ID]} + if r.IsDefault { + v.Pinned = true + v.PinForced = true + } + if m, ok := memberMap[r.ID]; ok { + v.Joined = true + v.MyRole = m.Role + if m.PinnedAt != nil { + v.Pinned = true + } + } + views = append(views, v) + } + s.attachPeers(views, userID) + sortRoomViews(views, memberMap) + return views +} + +// sortRoomViews:大厅强制置顶 > 个人置顶(pinned_at DESC) > last_message_id DESC +func sortRoomViews(views []RoomView, memberMap map[uint]model.ChatRoomMember) { + sort.SliceStable(views, func(i, j int) bool { + a, b := views[i], views[j] + if a.IsDefault != b.IsDefault { + return a.IsDefault + } + aPin, bPin := !a.IsDefault && a.Pinned, !b.IsDefault && b.Pinned + if aPin != bPin { + return aPin + } + if aPin && bPin { + var at, bt time.Time + if m, ok := memberMap[a.ID]; ok && m.PinnedAt != nil { + at = *m.PinnedAt + } + if m, ok := memberMap[b.ID]; ok && m.PinnedAt != nil { + bt = *m.PinnedAt + } + if !at.Equal(bt) { + return at.After(bt) + } + } + if a.LastMessageID != b.LastMessageID { + return a.LastMessageID > b.LastMessageID + } + return a.ID > b.ID + }) +} + +// attachPeers 为私聊会话填充对方用户信息 +func (s *ChatService) attachPeers(views []RoomView, viewerID uint) { + directIDs := make([]uint, 0) + for i := range views { + if views[i].RoomType == model.ChatRoomTypeDirect { + directIDs = append(directIDs, views[i].ID) + } + } + if len(directIDs) == 0 { + return + } + var members []model.ChatRoomMember + s.db.Where("room_id IN ? AND user_id <> ?", directIDs, viewerID).Preload("User").Find(&members) + peerByRoom := make(map[uint]*model.User, len(members)) + for i := range members { + u := members[i].User + peerByRoom[members[i].RoomID] = &u + } + for i := range views { + if p, ok := peerByRoom[views[i].ID]; ok { + views[i].Peer = p + if views[i].Name == "" && p != nil { + views[i].Name = p.Nickname + if views[i].Name == "" { + views[i].Name = p.Username + } + } + } + } +} + +// CreateRoom 建群:任意登录用户可建;isPrivate 时仅邀请可进;memberIDs 为初始成员(不含创建者) +func (s *ChatService) CreateRoom(creatorID uint, name, description string, isPrivate bool, memberIDs []uint) (*RoomView, error) { + name = strings.TrimSpace(name) + description = strings.TrimSpace(description) + if l := len([]rune(name)); l < chatNameMinRune || l > chatNameMaxRune { + return nil, ErrChatNameInvalid + } + if len([]rune(description)) > chatDescMaxRune { + return nil, ErrChatDescInvalid + } + // 去重、排除自己 + seen := map[uint]struct{}{creatorID: {}} + cleanIDs := make([]uint, 0, len(memberIDs)) + for _, id := range memberIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + cleanIDs = append(cleanIDs, id) + } + room := model.ChatRoom{ + Name: name, + Description: description, + OwnerID: creatorID, + RoomType: model.ChatRoomTypeGroup, + IsPrivate: isPrivate, + MemberCount: 1 + len(cleanIDs), + } + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&room).Error; err != nil { + return err + } + m := model.ChatRoomMember{RoomID: room.ID, UserID: creatorID, Role: model.ChatRoleOwner} + if err := tx.Create(&m).Error; err != nil { + return err + } + for _, uid := range cleanIDs { + mm := model.ChatRoomMember{RoomID: room.ID, UserID: uid, Role: model.ChatRoleMember} + if err := tx.Create(&mm).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + var full model.ChatRoom + if err := s.db.Preload("Owner").First(&full, room.ID).Error; err != nil { + return nil, err + } + v := RoomView{ChatRoom: full, Joined: true, MyRole: model.ChatRoleOwner} + return &v, nil +} + +// ListRooms 会话列表。joinedOnly=true 时仅已加入(IM 侧栏);否则公开群 + 已加入私密(站长旁路可见全部) +func (s *ChatService) ListRooms(userID uint, oversee, joinedOnly bool, q string, page, size int) ([]RoomView, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + query := s.db.Model(&model.ChatRoom{}) + if joinedOnly && userID > 0 { + query = query.Where("id IN (SELECT room_id FROM chat_room_members WHERE user_id = ?)", userID) + } else if !oversee { + if userID == 0 { + query = query.Where("is_private = ? AND room_type = ?", false, model.ChatRoomTypeGroup) + } else { + query = query.Where( + "(is_private = ? AND room_type = ?) OR id IN (SELECT room_id FROM chat_room_members WHERE user_id = ?)", + false, model.ChatRoomTypeGroup, userID, + ) + } + } + if q = strings.TrimSpace(q); q != "" { + esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(q) + query = query.Where(`name ILIKE ? ESCAPE '\'`, "%"+esc+"%") + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rooms []model.ChatRoom + // 已加入列表需按个人置顶排序:先全量取出再内存排序分页(会话量通常很小) + if joinedOnly && userID > 0 { + if err := query. + Preload("Owner").Preload("LastMessage.Sender"). + Find(&rooms).Error; err != nil { + return nil, 0, err + } + views := s.toViews(rooms, userID) + start := (page - 1) * size + if start >= len(views) { + return []RoomView{}, total, nil + } + end := start + size + if end > len(views) { + end = len(views) + } + return views[start:end], total, nil + } + if err := query. + Order("is_default DESC, last_message_id DESC, id DESC"). + Offset((page - 1) * size).Limit(size). + Preload("Owner").Preload("LastMessage.Sender"). + Find(&rooms).Error; err != nil { + return nil, 0, err + } + return s.toViews(rooms, userID), total, nil +} + +// SetRoomPinned 当前用户置顶/取消置顶(大厅强制置顶不可取消) +func (s *ChatService) SetRoomPinned(userID, roomID uint, pinned bool) (*RoomView, error) { + room, err := s.getRoom(roomID) + if err != nil { + return nil, err + } + if room.IsDefault { + return nil, ErrChatHallPinFixed + } + m, err := s.membership(s.db, roomID, userID) + if err != nil { + return nil, err + } + if pinned { + now := time.Now() + if err := s.db.Model(m).Update("pinned_at", now).Error; err != nil { + return nil, err + } + } else { + if err := s.db.Model(m).Update("pinned_at", nil).Error; err != nil { + return nil, err + } + } + return s.RoomDetail(userID, roomID, false) +} + +// GetOrCreateDM 获取或创建与对方的一对一私聊(幂等) +func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) { + if peerID == 0 || peerID == meID { + return nil, ErrChatDMSelf + } + var peer model.User + if err := s.db.Where("id = ? AND banned = ? AND deleted_at IS NULL", peerID, false).First(&peer).Error; err != nil { + return nil, ErrChatUserGone + } + key := directKey(meID, peerID) + var existing model.ChatRoom + err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error + if err == nil { + _ = s.ensureMember(existing.ID, meID) + _ = s.ensureMember(existing.ID, peerID) + return s.RoomDetail(meID, existing.ID, false) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + room := model.ChatRoom{ + Name: "", + OwnerID: meID, + RoomType: model.ChatRoomTypeDirect, + DirectKey: key, + IsPrivate: true, + MemberCount: 2, + } + err = s.db.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&room).Error; e != nil { + return e + } + ms := []model.ChatRoomMember{ + {RoomID: room.ID, UserID: meID, Role: model.ChatRoleMember}, + {RoomID: room.ID, UserID: peerID, Role: model.ChatRoleMember}, + } + return tx.Create(&ms).Error + }) + if err != nil { + if e2 := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error; e2 == nil { + return s.RoomDetail(meID, existing.ID, false) + } + return nil, err + } + return s.RoomDetail(meID, room.ID, false) +} + +func (s *ChatService) ensureMember(roomID, userID uint) error { + _, err := s.membership(s.db, roomID, userID) + if err == nil { + return nil + } + if !errors.Is(err, ErrChatNotMember) { + return err + } + m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember} + if e := s.db.Create(&m).Error; e != nil { + return e + } + return s.db.Model(&model.ChatRoom{}).Where("id = ?", roomID). + UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error +} + +// ListMemberIDs 房间全部成员 ID(发消息后推未读角标用) +func (s *ChatService) ListMemberIDs(roomID uint) ([]uint, error) { + var ids []uint + err := s.db.Model(&model.ChatRoomMember{}).Where("room_id = ?", roomID).Pluck("user_id", &ids).Error + return ids, err +} + +// SearchUsersForChat 按用户名/昵称搜索可发起私聊的用户(排除自己、封禁) +func (s *ChatService) SearchUsersForChat(meID uint, q string, limit int) ([]model.User, error) { + q = strings.TrimSpace(q) + if q == "" { + return nil, nil + } + if limit < 1 || limit > 20 { + limit = 10 + } + esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(q) + like := "%" + esc + "%" + var users []model.User + err := s.db.Select("id", "username", "nickname", "avatar", "role"). + Where("id <> ? AND banned = ? AND deleted_at IS NULL AND (username ILIKE ? ESCAPE '\\' OR nickname ILIKE ? ESCAPE '\\')", + meID, false, like, like). + Order("username ASC").Limit(limit).Find(&users).Error + return users, err +} + +// RoomDetail 群详情:成员或监管者可读;公开非成员返回 joined=false(可预览元数据) +func (s *ChatService) RoomDetail(userID, roomID uint, oversee bool) (*RoomView, error) { + room, err := s.getRoom(roomID) + if err != nil { + return nil, err + } + _, memErr := s.membership(s.db, roomID, userID) + isMember := memErr == nil + if !isMember && !oversee { + if room.IsPrivate { + return nil, ErrChatNotFound // 私密非成员伪装不存在 + } + // 公开非成员:允许预览 + } else if memErr != nil && !oversee && !errors.Is(memErr, ErrChatNotMember) { + return nil, memErr + } + if err := s.db.Preload("Owner").Preload("LastMessage.Sender").First(room, roomID).Error; err != nil { + return nil, err + } + views := s.toViews([]model.ChatRoom{*room}, userID) + v := views[0] + if oversee && !v.Joined { + v.Joined = true // 监管旁路按已加入展示,可看历史 + v.MyRole = "overseer" + } + return &v, nil +} + +// Join 加入公开群(私密/私聊拒绝;幂等) +func (s *ChatService) Join(userID, roomID uint) (member *model.ChatRoomMember, joined bool, err error) { + room, err := s.getRoom(roomID) + if err != nil { + return nil, false, err + } + if room.RoomType == model.ChatRoomTypeDirect { + return nil, false, ErrChatNotFound + } + if room.IsPrivate { + return nil, false, ErrChatPrivateInvite + } + if m, mErr := s.membership(s.db, roomID, userID); mErr == nil { + return m, false, nil + } else if !errors.Is(mErr, ErrChatNotMember) { + return nil, false, mErr + } + m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember} + err = s.db.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&m).Error; e != nil { + return e + } + return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). + UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error + }) + if err != nil { + if existing, qErr := s.membership(s.db, roomID, userID); qErr == nil { + return existing, false, nil + } + return nil, false, err + } + return &m, true, nil +} + +// InviteMembers 群主(或监管)拉人入群;返回实际新加入的用户 ID +func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) { + room, err := s.getRoom(roomID) + if err != nil { + return nil, err + } + if room.RoomType == model.ChatRoomTypeDirect { + return nil, ErrChatOwnerOnly + } + if !oversee { + op, err := s.membership(s.db, roomID, operatorID) + if err != nil { + return nil, err + } + if op.Role != model.ChatRoleOwner { + return nil, ErrChatOwnerOnly + } + } + added := make([]uint, 0) + for _, uid := range userIDs { + if uid == 0 { + continue + } + if _, mErr := s.membership(s.db, roomID, uid); mErr == nil { + continue + } + var u model.User + if err := s.db.Select("id").Where("id = ? AND banned = ? AND deleted_at IS NULL", uid, false).First(&u).Error; err != nil { + continue + } + m := model.ChatRoomMember{RoomID: roomID, UserID: uid, Role: model.ChatRoleMember} + if room.OwnerID == uid { + m.Role = model.ChatRoleOwner + } + err := s.db.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&m).Error; e != nil { + return e + } + return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). + UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error + }) + if err == nil { + added = append(added, uid) + } + } + return added, nil +} + +// EnsureDefaultMembership 将用户加入默认大厅(注册后调用,幂等) +func (s *ChatService) EnsureDefaultMembership(userID uint) error { + var room model.ChatRoom + if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil { + return nil // 尚无大厅则跳过 + } + if _, err := s.membership(s.db, room.ID, userID); err == nil { + return nil + } + m := model.ChatRoomMember{RoomID: room.ID, UserID: userID, Role: model.ChatRoleMember} + return s.db.Transaction(func(tx *gorm.DB) error { + if e := tx.Create(&m).Error; e != nil { + return e + } + return tx.Model(&model.ChatRoom{}).Where("id = ?", room.ID). + UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error + }) +} + +// Leave 普通成员退出群聊(群主不可退出;默认大厅不可退出;私聊可退出) +func (s *ChatService) Leave(userID, roomID uint) error { + room, err := s.getRoom(roomID) + if err != nil { + return err + } + if room.IsDefault { + return ErrChatDefaultLeave + } + m, err := s.membership(s.db, roomID, userID) + if err != nil { + return err + } + if room.RoomType != model.ChatRoomTypeDirect && m.Role == model.ChatRoleOwner { + return ErrChatOwnerLeave + } + return s.removeMember(roomID, userID) +} + +// removeMember 删除成员行并把群人数 -1(不小于 0) +func (s *ChatService) removeMember(roomID, userID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + res := tx.Where("room_id = ? AND user_id = ?", roomID, userID).Delete(&model.ChatRoomMember{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil + } + return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). + UpdateColumn("member_count", gorm.Expr("GREATEST(member_count - 1, 0)")).Error + }) +} + +// Dissolve 群主解散群(默认大厅/私聊不可解散) +func (s *ChatService) Dissolve(userID, roomID uint, oversee bool) error { + room, err := s.getRoom(roomID) + if err != nil { + return err + } + if room.IsDefault { + return ErrChatDefaultDissolve + } + if room.RoomType == model.ChatRoomTypeDirect { + return ErrChatDMDissolve + } + if !oversee { + m, err := s.membership(s.db, roomID, userID) + if err != nil { + return err + } + if m.Role != model.ChatRoleOwner { + return ErrChatOwnerOnly + } + } + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(&model.ChatRoom{}, roomID).Error; err != nil { + return err + } + return tx.Where("room_id = ?", roomID).Delete(&model.ChatRoomMember{}).Error + }) +} + +// UpdateRoom 群主修改群名称/简介 +func (s *ChatService) UpdateRoom(userID, roomID uint, name, description string) (*model.ChatRoom, error) { + name = strings.TrimSpace(name) + description = strings.TrimSpace(description) + if l := len([]rune(name)); l < chatNameMinRune || l > chatNameMaxRune { + return nil, ErrChatNameInvalid + } + if len([]rune(description)) > chatDescMaxRune { + return nil, ErrChatDescInvalid + } + room, err := s.getRoom(roomID) + if err != nil { + return nil, err + } + m, err := s.membership(s.db, roomID, userID) + if err != nil { + return nil, err + } + if m.Role != model.ChatRoleOwner { + return nil, ErrChatOwnerOnly + } + if err := s.db.Model(room).Updates(map[string]interface{}{ + "name": name, + "description": description, + }).Error; err != nil { + return nil, err + } + return s.getRoom(roomID) +} + +// ListMembers 成员名单:成员或监管者可查看 +func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.ChatRoomMember, error) { + if _, err := s.getRoom(roomID); err != nil { + return nil, err + } + if !oversee { + if _, err := s.membership(s.db, roomID, userID); err != nil { + return nil, err + } + } + var list []model.ChatRoomMember + err := s.db.Where("room_id = ?", roomID). + Order("CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at ASC"). + Preload("User"). + Find(&list).Error + if err != nil { + return nil, err + } + return list, nil +} + +// Kick 群主踢人(不能踢群主) +func (s *ChatService) Kick(operatorID, roomID, targetID uint) error { + if _, err := s.getRoom(roomID); err != nil { + return err + } + op, err := s.membership(s.db, roomID, operatorID) + if err != nil { + return err + } + if op.Role != model.ChatRoleOwner { + return ErrChatOwnerOnly + } + target, err := s.membership(s.db, roomID, targetID) + if err != nil { + return err + } + if target.Role == model.ChatRoleOwner { + return ErrChatOwnerOnly + } + return s.removeMember(roomID, targetID) +} + +// SendMessage 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。 +func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, nil, ErrChatContentEmpty + } + if len([]rune(content)) > chatMsgMaxRune { + return nil, nil, ErrChatContentTooLong + } + if _, err := s.getRoom(roomID); err != nil { + return nil, nil, err + } + m, err := s.membership(s.db, roomID, senderID) + if err != nil { + return nil, nil, err + } + if m.Muted { + return nil, nil, ErrChatMuted + } + + replySnap := "" + if replyToID > 0 { + var quoted model.ChatMessage + if err := s.db.Where("id = ? AND room_id = ?", replyToID, roomID). + Preload("Sender").First("ed).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, ErrChatMsgNotFound + } + return nil, nil, err + } + name := quoted.Sender.Nickname + if name == "" { + name = quoted.Sender.Username + } + if quoted.RecalledAt != nil { + replySnap = name + ": (已撤回的消息)" + } else { + snippet := quoted.Content + rs := []rune(snippet) + if len(rs) > 80 { + snippet = string(rs[:80]) + "…" + } + replySnap = name + ": " + snippet + } + } + + mentionIDs := s.resolveMentions(roomID, senderID, content) + + msg := &model.ChatMessage{ + RoomID: roomID, + SenderID: senderID, + Content: content, + ReplyToID: replyToID, + ReplySnap: replySnap, + MentionIDs: joinMentionIDs(mentionIDs), + } + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(msg).Error; err != nil { + return err + } + return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). + Updates(map[string]interface{}{ + "last_message_id": msg.ID, + "updated_at": time.Now(), + }).Error + }) + if err != nil { + return nil, nil, err + } + if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil { + return nil, nil, err + } + for _, uid := range mentionIDs { + s.notif.CreateMention(uid, senderID, roomID, msg.ID, content) + } + return msg, mentionIDs, nil +} + +// resolveMentions 文本中 @用户名 → 群成员用户 ID(去重、排除自己、上限 20) +func (s *ChatService) resolveMentions(roomID, senderID uint, content string) []uint { + matches := chatMentionRe.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + nameSet := make(map[string]struct{}, len(matches)) + names := make([]string, 0, len(matches)) + for _, mt := range matches { + name := strings.ToLower(mt[1]) + if _, ok := nameSet[name]; ok { + continue + } + nameSet[name] = struct{}{} + names = append(names, name) + } + if len(names) == 0 { + return nil + } + // 用户名大小写不敏感匹配(注册名唯一但比较时统一 lower) + var users []model.User + s.db.Where("LOWER(username) IN ? AND banned = ?", names, false).Find(&users) + if len(users) == 0 { + return nil + } + userIDs := make([]uint, 0, len(users)) + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + var memberIDs []uint + s.db.Model(&model.ChatRoomMember{}). + Where("room_id = ? AND user_id IN ?", roomID, userIDs). + Pluck("user_id", &memberIDs) + seen := make(map[uint]struct{}, len(memberIDs)) + out := make([]uint, 0, len(memberIDs)) + for _, id := range memberIDs { + if id == senderID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if len(out) >= chatMentionMax { + break + } + } + return out +} + +func joinMentionIDs(ids []uint) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, len(ids)) + for i, id := range ids { + parts[i] = strconv.FormatUint(uint64(id), 10) + } + return strings.Join(parts, ",") +} + +// ListMessages 历史消息分页:成员或监管者可取;已撤回消息仍返回(前端显示占位) +func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, oversee bool) ([]model.ChatMessage, bool, error) { + if size < 1 || size > 50 { + size = 30 + } + if _, err := s.getRoom(roomID); err != nil { + return nil, false, err + } + if !oversee { + if _, err := s.membership(s.db, roomID, userID); err != nil { + return nil, false, err + } + } + q := s.db.Where("room_id = ?", roomID) + if beforeID > 0 { + q = q.Where("id < ?", beforeID) + } + var msgs []model.ChatMessage + if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Find(&msgs).Error; err != nil { + return nil, false, err + } + hasMore := len(msgs) > size + if hasMore { + msgs = msgs[:size] + } + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + // 对非监管者清空已撤回正文 + if !oversee { + for i := range msgs { + if msgs[i].RecalledAt != nil { + msgs[i].Content = "" + } + } + } + return msgs, hasMore, nil +} + +// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt 不清行 +func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, error) { + if _, err := s.getRoom(roomID); err != nil { + return nil, err + } + var msg model.ChatMessage + if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrChatMsgNotFound + } + return nil, err + } + if msg.RecalledAt != nil { + return &msg, nil // 幂等 + } + if !oversee { + m, err := s.membership(s.db, roomID, operatorID) + if err != nil { + return nil, err + } + // 群主/成员仅可撤回自己的消息;监管者可撤任意 + if msg.SenderID != operatorID { + _ = m // 成员校验已通过 + return nil, ErrChatRecallDenied + } + } + now := time.Now() + if err := s.db.Model(&msg).Updates(map[string]interface{}{ + "recalled_at": now, + "recalled_by": operatorID, + "content": "", + }).Error; err != nil { + return nil, err + } + msg.RecalledAt = &now + msg.RecalledBy = operatorID + msg.Content = "" + _ = s.db.Preload("Sender").First(&msg, msg.ID) + return &msg, nil +} + +// MarkRead 把已读水位推进到指定消息(单调不减;messageID=0 或超过最新则取最新) +func (s *ChatService) MarkRead(userID, roomID, messageID uint) error { + room, err := s.getRoom(roomID) + if err != nil { + return err + } + if _, err := s.membership(s.db, roomID, userID); err != nil { + return err + } + if messageID == 0 || messageID > room.LastMessageID { + messageID = room.LastMessageID + } + return s.db.Model(&model.ChatRoomMember{}). + Where("room_id = ? AND user_id = ? AND last_read_message_id < ?", roomID, userID, messageID). + Update("last_read_message_id", messageID).Error +} + +// UnreadSummary 当前用户所有群的未读明细与总数 +func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) { + var rows []UnreadRoom + err := s.db.Table("chat_messages AS m"). + Select("m.room_id AS room_id, count(*) AS cnt"). + Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID). + Where("m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL"). + Group("m.room_id"). + Scan(&rows).Error + if err != nil { + return 0, nil, err + } + var total int64 + for _, r := range rows { + total += r.Count + } + return total, rows, nil +} + +// TotalUnread 供 /me SSR 一次调用(Header 红点直出) +func (s *ChatService) TotalUnread(userID uint) int64 { + total, _, _ := s.UnreadSummary(userID) + return total +} diff --git a/backend/service/comment.go b/backend/service/comment.go index c0511c6..1ae3df9 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -35,16 +35,16 @@ const maxReplyDepth = 10 // ListFloorPaged 楼层分页获取帖子评论树: // 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。 +// viewer 可为作者本人或该板可审者时并入 pending;游客仅 published。 // 返回:树根列表、楼层数、全部评论数(含回复)。 -func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentNode, int64, int64, error) { +func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *Actor, page, size int) ([]CommentNode, int64, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } - base := s.db.Model(&model.Comment{}). - Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished) + base := applyCommentListVisibility(s.db.Model(&model.Comment{}).Where("post_id = ?", postID), boardID, viewerID, actor) var floors, totalComments int64 if err := base.Session(&gorm.Session{}). @@ -75,11 +75,9 @@ func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentN for i, r := range roots { rootIDs[i] = r.ID } + replyQ := applyCommentListVisibility(s.db.Where("root_id IN (?)", rootIDs), boardID, viewerID, actor) var replies []model.Comment - if err := s.db.Where("root_id IN (?) AND status = ?", rootIDs, model.ContentStatusPublished). - Order("created_at ASC"). - Preload("User"). - Find(&replies).Error; err != nil { + if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil { return nil, 0, 0, err } @@ -120,6 +118,20 @@ func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentN return result, floors, totalComments, nil } +// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审) +func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB { + if viewerID == 0 || actor == nil { + return db.Where("status = ?", model.ContentStatusPublished) + } + if actor.CanModerateBoard(boardID) { + return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) + } + return db.Where( + "status = ? OR (status = ? AND user_id = ?)", + model.ContentStatusPublished, model.ContentStatusPending, viewerID, + ) +} + // Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。 // status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending; // pending 评论不计入 comment_count,审核通过时才 +1。 diff --git a/backend/service/moderation.go b/backend/service/moderation.go index 61ad13d..e1f724b 100644 --- a/backend/service/moderation.go +++ b/backend/service/moderation.go @@ -158,9 +158,9 @@ func (s *ModerationService) PendingCounts(actor *Actor) (posts int64, comments i return } -// ApprovePost 帖子审核通过 -func (s *ModerationService) ApprovePost(actor *Actor, id uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { +// ApprovePost 帖子审核通过;成功时返回板块 ID 供 feed 广播 +func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, err error) { + err = s.db.Transaction(func(tx *gorm.DB) error { var post model.Post if err := tx.First(&post, id).Error; err != nil { return err @@ -174,10 +174,12 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) error { if err := tx.Model(&post).Update("status", model.ContentStatusPublished).Error; err != nil { return err } + boardID = post.BoardID s.notif.Create(post.UserID, actor.ID, model.NotificationTypeApproved, post.ID, 0, "你的帖子《"+post.Title+"》已通过审核") return nil }) + return boardID, err } // RejectPost 帖子审核拒绝(标记 rejected,不物理删除;作者仍可在详情页看到结果) @@ -202,15 +204,15 @@ func (s *ModerationService) RejectPost(actor *Actor, id uint) error { }) } -// ApproveComment 评论审核通过(通过时才计入帖子评论数) -func (s *ModerationService) ApproveComment(actor *Actor, id uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { +// ApproveComment 评论审核通过(通过时才计入帖子评论数);成功时返回帖子 ID 与板块 ID +func (s *ModerationService) ApproveComment(actor *Actor, id uint) (postID, boardID uint, err error) { + err = s.db.Transaction(func(tx *gorm.DB) error { var cm model.Comment if err := tx.First(&cm, id).Error; err != nil { return err } var post model.Post - if err := tx.Select("id", "board_id", "title").First(&post, cm.PostID).Error; err != nil { + if err := tx.Select("id", "board_id", "title", "user_id").First(&post, cm.PostID).Error; err != nil { return err } if !actor.CanModerateBoard(post.BoardID) { @@ -226,6 +228,8 @@ func (s *ModerationService) ApproveComment(actor *Actor, id uint) error { UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil { return err } + postID = cm.PostID + boardID = post.BoardID s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeApproved, cm.PostID, cm.ID, "你在帖子《"+post.Title+"》下的评论已通过审核") // 评论此时才公开,补发送业务通知(以评论者为 actor): @@ -240,6 +244,7 @@ func (s *ModerationService) ApproveComment(actor *Actor, id uint) error { } return nil }) + return postID, boardID, err } // RejectComment 评论审核拒绝 diff --git a/backend/service/notification.go b/backend/service/notification.go index bd97858..f0e254d 100644 --- a/backend/service/notification.go +++ b/backend/service/notification.go @@ -8,6 +8,8 @@ import ( // NotificationService 通知服务 type NotificationService struct { db *gorm.DB + // OnNotifyNew 落库成功后回调(用于 WS 推送红点);可为 nil + OnNotifyNew func(userID uint) } func NewNotificationService(db *gorm.DB) *NotificationService { @@ -33,7 +35,38 @@ func (s *NotificationService) Create(userID, actorID uint, notifType string, pos IsRead: false, } // 通知创建失败不影响主流程,忽略错误 - _ = s.db.Create(n).Error + if err := s.db.Create(n).Error; err != nil { + return + } + if s.OnNotifyNew != nil { + s.OnNotifyNew(userID) + } +} + +// CreateMention 创建群聊 @ 提醒(PostID=0,用 RoomID/MessageID 关联)。 +// 返回创建出的通知 ID(0 表示未创建),供 handler 实时推送。 +func (s *NotificationService) CreateMention(userID, actorID, roomID, messageID uint, content string) uint { + if userID == 0 || actorID == 0 || userID == actorID || roomID == 0 { + return 0 + } + if runes := []rune(content); len(runes) > 100 { + content = string(runes[:100]) + } + n := &model.Notification{ + UserID: userID, + ActorID: actorID, + Type: model.NotificationTypeMention, + RoomID: roomID, + MessageID: messageID, + Content: content, + } + if err := s.db.Create(n).Error; err != nil { + return 0 + } + if s.OnNotifyNew != nil { + s.OnNotifyNew(userID) + } + return n.ID } // List 获取用户通知列表(分页,按时间倒序) @@ -52,7 +85,7 @@ func (s *NotificationService) List(userID uint, page, size int) ([]model.Notific var list []model.Notification offset := (page - 1) * size if err := query.Order("created_at DESC").Offset(offset).Limit(size). - Preload("Actor").Preload("Post").Find(&list).Error; err != nil { + Preload("Actor").Preload("Post").Preload("Room").Find(&list).Error; err != nil { return nil, 0, err } return list, total, nil diff --git a/backend/service/post.go b/backend/service/post.go index 0657c95..499d9b1 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -29,9 +29,11 @@ type PostListQuery struct { BoardID uint Page int Size int - Sort string // latest | hot + Sort string // latest | hot | new Keyword string // 搜索关键词 Recommended bool // 仅精华帖 + ViewerID uint // 当前查看者(0=游客) + Actor *Actor // 查看者权限快照;nil 仅 published } // toPostListItems 将 Post 模型批量转为不含正文的列表项 @@ -42,7 +44,7 @@ func toPostListItems(posts []model.Post) []PostListItem { ID: p.ID, BoardID: p.BoardID, UserID: p.UserID, Title: p.Title, Tags: p.Tags, PostType: p.PostType, Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount, - CommentCount: p.CommentCount, CreatedAt: p.CreatedAt, + CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt, Board: p.Board, User: p.User, }) } @@ -76,6 +78,7 @@ type PostListItem struct { LikeCount int `json:"like_count"` ViewCount int `json:"view_count"` CommentCount int `json:"comment_count"` + Status string `json:"status"` Liked bool `json:"liked"` CreatedAt time.Time `json:"created_at"` LastReply *LastReplyInfo `json:"last_reply,omitempty"` @@ -145,6 +148,26 @@ func (s *PostService) fillLastReply(items []PostListItem) { } } +// applyListVisibility 公开流可见性:published,或 pending 且(作者本人 / 该板可审者) +func applyListVisibility(db *gorm.DB, viewerID uint, actor *Actor) *gorm.DB { + if viewerID == 0 || actor == nil { + return db.Where("status = ?", model.ContentStatusPublished) + } + if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) { + return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) + } + if actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 { + return db.Where( + "status = ? OR (status = ? AND (user_id = ? OR board_id IN ?))", + model.ContentStatusPublished, model.ContentStatusPending, viewerID, actor.BoardIDs, + ) + } + return db.Where( + "status = ? OR (status = ? AND user_id = ?)", + model.ContentStatusPublished, model.ContentStatusPending, viewerID, + ) +} + // List 获取帖子列表 func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { if q.Page < 1 { @@ -154,7 +177,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { q.Size = 20 } - query := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished) + query := applyListVisibility(s.db.Model(&model.Post{}), q.ViewerID, q.Actor) if q.BoardID > 0 { query = query.Where("board_id = ?", q.BoardID) } @@ -196,15 +219,26 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { return items, total, nil } -// ListByUser 获取指定用户发布的帖子(公开已发布) -func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, int64, error) { +// ListByUser 获取指定用户发布的帖子。 +// viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。 +func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 20 } - query := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished) + query := s.db.Model(&model.Post{}).Where("user_id = ?", userID) + if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) { + query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) + } else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 { + query = query.Where( + "status = ? OR (status = ? AND board_id IN ?)", + model.ContentStatusPublished, model.ContentStatusPending, actor.BoardIDs, + ) + } else { + query = query.Where("status = ?", model.ContentStatusPublished) + } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err @@ -229,6 +263,15 @@ func (s *PostService) GetAuthorID(postID uint) (uint, error) { return post.UserID, nil } +// GetBoardID 获取帖子所属板块 ID(不增加浏览量) +func (s *PostService) GetBoardID(postID uint) (uint, error) { + var post model.Post + if err := s.db.Select("board_id").First(&post, postID).Error; err != nil { + return 0, err + } + return post.BoardID, nil +} + // CountByUser 统计用户发帖数 func (s *PostService) CountByUser(userID uint) (int64, error) { var total int64 diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index 9d823e1..7a4161e 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -66,6 +66,7 @@ const ( RateRegister = "register" RatePost = "post" RateComment = "comment" + RateChat = "chat" // 群聊发消息 ) // DefaultRateLimiter 创建默认速率限制器 @@ -75,5 +76,6 @@ func DefaultRateLimiter() *RateLimiter { rl.SetLimit(RateRegister, 10) // 注册 10/分钟 rl.SetLimit(RatePost, 10) // 发帖 10/分钟 rl.SetLimit(RateComment, 30) // 评论 30/分钟 + rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟 return rl } diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx index 76d2402..ebb0e65 100644 --- a/frontend/app/admin/AdminNav.tsx +++ b/frontend/app/admin/AdminNav.tsx @@ -21,6 +21,7 @@ import { isSuperOrOwner, type RoleUserLike, } from "@/lib/roles"; +import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; @@ -69,7 +70,7 @@ function isActive(pathname: string, href: string, exact?: boolean) { return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`); } -/** 待审核数量轮询(30 秒);仅内容审核入口可见时启用 */ +/** 待审核数量:首屏 + WS 推送 + 回前台校准(无定时轮询) */ function usePendingCounts(enabled: boolean) { const [counts, setCounts] = useState({ posts: 0, comments: 0 }); useEffect(() => { @@ -83,14 +84,14 @@ function usePendingCounts(enabled: boolean) { .catch(() => {}); }; load(); - const t = setInterval(load, 30_000); + const off = realtime.on(RT_MODERATION_CHANGED, () => load()); const onVisible = () => { if (document.visibilityState === "visible") load(); }; document.addEventListener("visibilitychange", onVisible); return () => { alive = false; - clearInterval(t); + off(); document.removeEventListener("visibilitychange", onVisible); }; }, [enabled]); diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index fffc111..016f2e7 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -2,21 +2,22 @@ import type { Metadata } from "next"; import Link from "next/link"; import { cookies } from "next/headers"; import { - Users, - PenLine, - MessageCircle, - Activity, - Megaphone, - Palette, - ChevronRight, UserPlus, LayoutGrid, ClipboardCheck, + ChevronRight, + MessageSquareText, + FileText, + Users, } from "lucide-react"; -import { fetchOverview, type OverviewResponse } from "@/lib/api"; +import { + fetchOverview, + fetchAdminPendingCounts, + type PendingCounts, +} from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import { getMeCached } from "@/lib/serverData"; -import { canModerateAny, isAdminOrAbove, isSuperOrOwner } from "@/lib/roles"; +import { canModerateAny, isSuperOrOwner } from "@/lib/roles"; import Avatar from "@/components/Avatar"; import { formatRelative } from "@/lib/format"; @@ -24,7 +25,6 @@ export const metadata: Metadata = { title: "管理仪表盘", }; -// 精确千分位(管理员视角需要真实数值,不用首页的紧凑 K/M) const fmt = (n: number) => n.toLocaleString("zh-CN"); function greeting(hour: number) { @@ -35,91 +35,6 @@ function greeting(hour: number) { return "晚上好"; } -// 统计 Bento 卡:总量 + 今日增量,语义色与全站令牌一致 -function StatCard({ - label, - value, - foot, - icon, - color, - soft, -}: { - label: string; - value: string; - foot: string; - icon: React.ReactNode; - color: string; - soft: string; -}) { - return ( -
-
-

- {label} -

- -
-

- {value} -

-

- {foot} -

-
- ); -} - -// 快捷管理入口:整卡可点(stretch-link),图标 + 标题 + 描述 + 箭头 -function QuickLink({ - href, - icon, - title, - desc, - soft, - color, -}: { - href: string; - icon: React.ReactNode; - title: string; - desc: string; - soft: string; - color: string; -}) { - return ( - -
- -
-

- {title} -

-

{desc}

-
- -
- - ); -} - function PanelHead({ title, extra }: { title: string; extra?: string }) { return (
+
+ +
+
+

+ 内容审核 +

+ {idle ? ( + + 暂无待审 + + ) : ( + + {fmt(total)} 项待处理 + + )} +
+
+ + + + +
+
+ +
+ + ); +} + export default async function AdminDashboardPage() { const cookie = authCookieHeader(await cookies()); - // 与 layout 的 /me 请求共享 React cache;概览失败降级,不阻塞仪表盘骨架 - const [me, overview] = await Promise.all([ - getMeCached(cookie || undefined), + const me = await getMeCached(cookie || undefined); + const canMod = canModerateAny(me.user); + const canUsers = isSuperOrOwner(me.user?.role); + + const [overview, pendingCounts] = await Promise.all([ fetchOverview(cookie).catch(() => null), + canMod + ? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 })) + : Promise.resolve(null), ]); const now = new Date(); @@ -162,100 +151,24 @@ export default async function AdminDashboardPage() { return (
- {/* 页头 */}
-

+

{greeting(now.getHours())},{me.user?.nickname}

-

{dateLabel} · 这里是站点运营全貌

+

{dateLabel} · 站点管理

- {/* 统计 Bento */} -
- } - color="var(--accent)" - soft="var(--accent-soft)" - /> - } - color="var(--clay)" - soft="var(--clay-soft)" - /> - } - color="var(--ok)" - soft="var(--ok-soft)" - /> - } - color="var(--gold)" - soft="var(--gold-soft)" - /> -
+ {canMod && pendingCounts && } - {/* 快捷入口(按角色权限过滤,与侧边栏一致;后端路由另有守卫) */} -

快捷管理

-
- {canModerateAny(me.user) && ( - } - title="内容审核" - desc="审核普通用户提交的帖子与评论,通过后内容公开可见" - soft="var(--ok-soft)" - color="var(--ok)" - /> - )} - {isAdminOrAbove(me.user?.role) && ( - } - title="公告管理" - desc="发布、编辑与下线站点公告,支持标签颜色与草稿状态" - soft="var(--clay-soft)" - color="var(--clay)" - /> - )} - {isSuperOrOwner(me.user?.role) && ( - } - title="用户权限" - desc="查看全站成员,授予管理角色与板块授权,封禁违规账号" - soft="var(--accent-soft)" - color="var(--accent)" - /> - )} - {isSuperOrOwner(me.user?.role) && ( - } - title="外观设置" - desc="配置全站主题强调色,实时预览后保存并对所有用户生效" - soft="var(--gold-soft)" - color="var(--gold)" - /> - )} -
- - {/* 成员与板块 */} -
- {/* 最新注册成员 */} +
- + {newUsers.length === 0 ? (

暂无成员数据

) : ( @@ -266,12 +179,22 @@ export default async function AdminDashboardPage() { className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]" style={{ borderTop: "1px solid var(--line)" }} > - + - + {u.nickname} - + @{u.username} @@ -285,9 +208,22 @@ export default async function AdminDashboardPage() { )) )} + {canUsers && ( + +
- {/* 板块分布 */}
{topBoards.length === 0 ? ( @@ -309,14 +245,19 @@ export default async function AdminDashboardPage() { - + {b.name} - + {fmt(b.post_count)} - {/* 相对占比迷你条(纯装饰) */} {}); }, []); + // 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变; + // 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确 + useEffect(() => { + const reconcile = (ids: ReadonlySet) => { + if (ids.size === 0) return; + setUsers((prev) => + prev.map((u) => (u.online === ids.has(u.id) ? u : { ...u, online: ids.has(u.id) })) + ); + }; + reconcile(realtime.getOnlineIds()); + const off = realtime.onPresence(({ user_id: id, online }) => { + setUsers((prev) => + prev.map((u) => (u.id === id && u.online !== online ? { ...u, online } : u)) + ); + }); + return off; + }, []); + const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => { const seq = ++seqRef.current; setLoading(true); @@ -319,10 +339,24 @@ export default function UsersAdmin({ ); const renderActions = (u: AdminUser, isSelf: boolean) => { - // 站长账号:任何人不可操作(后端 403 兜底),仅保留主页入口与站长标识 + const canAudit = isOwner(currentUserRole); + const auditBtn = canAudit ? ( + + + + ) : null; + + // 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案 if (u.role === ROLES.OWNER) { return (
+ {auditBtn} - 当前账号 - +
+ {auditBtn} + + 当前账号 + +
); } const busy = actingId === u.id; return (
+ {auditBtn}
-

+

帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}

@@ -671,37 +717,49 @@ export default function UsersAdmin({ {u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""}

- {!isSelf && u.role !== ROLES.OWNER && ( -
+
+ {isOwner(currentUserRole) && ( + + 内容档案 + + )} + {(isSelf || u.role === ROLES.OWNER) && ( 主页 - - -
- )} + )} + {!isSelf && u.role !== ROLES.OWNER && ( + <> + + 主页 + + + + + )} +
); diff --git a/frontend/app/admin/users/[id]/UserAuditClient.tsx b/frontend/app/admin/users/[id]/UserAuditClient.tsx new file mode 100644 index 0000000..1ec666e --- /dev/null +++ b/frontend/app/admin/users/[id]/UserAuditClient.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + ChevronLeft, + ChevronRight, + ExternalLink, + FileText, + Loader2, + MessageCircle, + MessagesSquare, +} from "lucide-react"; +import { + apiAdminUserAuditComments, + apiAdminUserAuditMessages, + apiAdminUserAuditPosts, + type AdminAuditCommentItem, + type AdminAuditMessageItem, + type AdminAuditPostItem, + type AdminUserAuditProfile, +} from "@/lib/api"; +import { formatDate, formatRelative } from "@/lib/format"; +import Avatar from "@/components/Avatar"; +import RoleBadge from "@/components/RoleBadge"; + +type TabKey = "posts" | "comments" | "messages"; + +const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [ + { key: "posts", label: "帖子", icon: }, + { key: "comments", label: "评论", icon: }, + { key: "messages", label: "私信与群消息", icon: }, +]; + +const PAGE_SIZE = 20; + +function StatusChip({ + status, + deleted, + recalled, +}: { + status?: string; + deleted?: boolean; + recalled?: boolean; +}) { + const chips: { label: string; color: string; soft: string }[] = []; + if (deleted) chips.push({ label: "已删除", color: "var(--danger)", soft: "var(--danger-soft)" }); + if (recalled) chips.push({ label: "已撤回", color: "var(--ink-3)", soft: "var(--tint)" }); + if (status === "pending") chips.push({ label: "待审", color: "var(--gold)", soft: "var(--gold-soft)" }); + if (status === "rejected") chips.push({ label: "已驳回", color: "var(--danger)", soft: "var(--danger-soft)" }); + if (status === "published" && !deleted) { + chips.push({ label: "已发布", color: "var(--ok)", soft: "var(--ok-soft)" }); + } + if (chips.length === 0 && !recalled) { + chips.push({ label: "正常", color: "var(--ink-3)", soft: "var(--tint)" }); + } + return ( + + {chips.map((c) => ( + + {c.label} + + ))} + + ); +} + +function Pager({ + page, + total, + onChange, + loading, +}: { + page: number; + total: number; + onChange: (p: number) => void; + loading?: boolean; +}) { + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + if (pages <= 1) return null; + return ( +
+ + + {page} / {pages} + + +
+ ); +} + +export default function UserAuditClient({ + userId, + initial, +}: { + userId: number; + initial: AdminUserAuditProfile; +}) { + const [profile] = useState(initial); + const [tab, setTab] = useState("posts"); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [posts, setPosts] = useState([]); + const [comments, setComments] = useState([]); + const [messages, setMessages] = useState([]); + const [total, setTotal] = useState(0); + + const u = profile.user; + + const load = useCallback( + async (t: TabKey, p: number) => { + setLoading(true); + setError(""); + try { + if (t === "posts") { + const res = await apiAdminUserAuditPosts(userId, p); + setPosts(res.items || []); + setTotal(res.total); + } else if (t === "comments") { + const res = await apiAdminUserAuditComments(userId, p); + setComments(res.items || []); + setTotal(res.total); + } else { + const res = await apiAdminUserAuditMessages(userId, p); + setMessages(res.items || []); + setTotal(res.total); + } + } catch (e) { + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + setLoading(false); + } + }, + [userId] + ); + + useEffect(() => { + load(tab, page); + }, [tab, page, load]); + + const switchTab = (t: TabKey) => { + setTab(t); + setPage(1); + }; + + const tabCount = (t: TabKey) => { + if (t === "posts") return profile.posts_total; + if (t === "comments") return profile.comments_total; + return profile.messages_total; + }; + + return ( +
+ + + 返回用户列表 + + +
+
+ +
+
+

+ {u.nickname} +

+ + {u.banned && ( + + 已封禁 + + )} +
+

@{u.username}

+

+ 注册于 {formatDate(u.created_at)} · 已发布帖 {profile.published_posts} / 评{" "} + {profile.published_comments} +

+
+ + + 主页 + +
+
+ {[ + { label: "全部帖子", value: profile.posts_total }, + { label: "全部评论", value: profile.comments_total }, + { label: "全部消息", value: profile.messages_total }, + ].map((c) => ( +
+

+ {c.value.toLocaleString("zh-CN")} +

+

{c.label}

+
+ ))} +
+
+ +
+ {TABS.map((t) => { + const active = tab === t.key; + return ( + + ); + })} +
+ +
+ {error && ( +

+ {error} +

+ )} + {!error && loading && posts.length + comments.length + messages.length === 0 && ( +
+ +
+ )} + {!error && !loading && total === 0 && ( +

该分类暂无内容

+ )} + + {tab === "posts" && + posts.map((p) => ( +
+
+
+
+ + {p.board_name && ( + {p.board_name} + )} +
+ {p.deleted ? ( +

+ {p.title || "(无标题)"} +

+ ) : ( + + {p.title || "(无标题)"} + + )} + {p.content && ( +

+ {p.content} +

+ )} +
+ + {formatRelative(p.created_at)} + +
+
+ ))} + + {tab === "comments" && + comments.map((c) => ( +
+
+
+
+ + {c.deleted ? ( + 帖子 #{c.post_id} + ) : ( + + 帖子 #{c.post_id} + + )} +
+

+ {c.content || "(空)"} +

+
+ + {formatRelative(c.created_at)} + +
+
+ ))} + + {tab === "messages" && + messages.map((m) => ( +
+
+
+
+ + + {m.room_name} + {m.room_type === "direct" ? " · 私聊" : m.room_type === "group" ? " · 群聊" : ""} + +
+

+ {m.content || "(空)"} +

+
+ + {formatRelative(m.created_at)} + +
+
+ ))} + + +
+
+ ); +} diff --git a/frontend/app/admin/users/[id]/page.tsx b/frontend/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..c39f1a1 --- /dev/null +++ b/frontend/app/admin/users/[id]/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { authCookieHeader } from "@/lib/cookies"; +import { fetchAdminUserAudit } from "@/lib/api"; +import { getMeCached } from "@/lib/serverData"; +import { isOwner, isSuperOrOwner } from "@/lib/roles"; +import Forbidden from "../../Forbidden"; +import UserAuditClient from "./UserAuditClient"; + +export const metadata: Metadata = { + title: "用户内容档案", +}; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function AdminUserAuditPage({ params }: PageProps) { + const { id: idRaw } = await params; + const userId = parseInt(idRaw, 10); + if (!Number.isFinite(userId) || userId <= 0) { + return ; + } + + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + + if (!me.user || !isSuperOrOwner(me.user.role)) { + return ; + } + if (!isOwner(me.user.role)) { + return ; + } + + let profile; + try { + profile = await fetchAdminUserAudit(userId, cookie || undefined); + } catch (e) { + const msg = e instanceof Error ? e.message : "获取用户档案失败"; + return ; + } + + return ; +} diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx index f07c913..59079a7 100644 --- a/frontend/app/board/[id]/page.tsx +++ b/frontend/app/board/[id]/page.tsx @@ -4,9 +4,11 @@ import { cookies } from "next/headers"; import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react"; import { fetchPosts, fetchBoards } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; +import { getMeCached } from "@/lib/serverData"; import Pagination from "@/components/Pagination"; import PostRow from "@/components/PostRow"; import PinnedPosts from "@/components/PinnedPosts"; +import FeedNewTip from "@/components/home/FeedNewTip"; interface PageProps { params: Promise<{ id: string }>; @@ -28,12 +30,14 @@ export default async function BoardPage({ params, searchParams }: PageProps) { const { page: pageStr } = await searchParams; const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); const cookie = authCookieHeader(await cookies()); - const [postsData, boardsData] = await Promise.all([ + const [postsData, boardsData, me] = await Promise.all([ fetchPosts(page, 20, Number(id), "latest", "", false, cookie), fetchBoards(), + getMeCached(cookie || undefined), ]); const boardIndex = boardsData.boards.findIndex((b) => b.id === Number(id)); const board = boardIndex >= 0 ? boardsData.boards[boardIndex] : null; + const user = me.user; // 第一页:置顶帖独立成区,列表只保留非置顶 const pinned = page === 1 ? postsData.posts.filter((p) => p.pinned > 0) : []; @@ -88,6 +92,16 @@ export default async function BoardPage({ params, searchParams }: PageProps) { 全部讨论 第 {page} 页
+ {user && ( +
+ +
+ )} {rows.map((post, i) => ( ))} diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx new file mode 100644 index 0000000..336c390 --- /dev/null +++ b/frontend/app/chat/ChatShell.tsx @@ -0,0 +1,706 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + Search, + Plus, + MessageSquare, + Users, + X, + Pin, +} from "lucide-react"; +import { + apiListChatRooms, + apiSearchChatUsers, + apiOpenDirectChat, + apiCreateChatRoom, + apiPinChatRoom, + type ChatRoomView, + type User, +} from "@/lib/api"; +import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime"; +import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime"; +import { toast } from "@/lib/toast"; +import Avatar from "@/components/Avatar"; +import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; + +function roomTitle(room: ChatRoomView) { + if (room.room_type === "direct") { + return room.peer?.nickname || room.peer?.username || room.name || "私聊"; + } + return room.name || "未命名群"; +} + +function roomPreview(room: ChatRoomView) { + const lm = room.last_message; + if (!lm) return room.description || "暂无消息"; + if (lm.recalled_at) { + if (lm.recalled_by && lm.recalled_by !== lm.sender_id) { + return "管理员撤回了此消息"; + } + return "此消息已撤回"; + } + const who = + room.room_type === "direct" + ? "" + : `${lm.sender?.nickname || lm.sender?.username || ""}:`; + return `${who}${lm.content || ""}`.slice(0, 48); +} + +function formatTime(dateStr?: string) { + if (!dateStr) return ""; + const d = new Date(dateStr); + const now = new Date(); + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (sameDay) { + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + } + return `${d.getMonth() + 1}/${d.getDate()}`; +} + +/** 与后端一致:大厅强制置顶 > 个人置顶 > last_message_id */ +function sortRooms(list: ChatRoomView[]): ChatRoomView[] { + return [...list].sort((a, b) => { + const aHall = !!a.is_default; + const bHall = !!b.is_default; + if (aHall !== bHall) return aHall ? -1 : 1; + const aPin = !aHall && !!a.pinned; + const bPin = !bHall && !!b.pinned; + if (aPin !== bPin) return aPin ? -1 : 1; + if (a.last_message_id !== b.last_message_id) { + return (b.last_message_id || 0) - (a.last_message_id || 0); + } + return b.id - a.id; + }); +} + +function isPinnedView(room: ChatRoomView) { + return !!room.is_default || !!room.pinned || !!room.pin_forced; +} + +/** + * PC 端飞书式消息壳:左会话列表 + 右会话区;移动端列表/会话互斥全屏。 + */ +export default function ChatShell({ + meId, + initialRooms, + activeRoomId = null, + children, +}: { + meId: number; + initialRooms: ChatRoomView[]; + activeRoomId?: number | null; + children?: React.ReactNode; +}) { + const router = useRouter(); + const [rooms, setRooms] = useState(initialRooms); + const [query, setQuery] = useState(""); + const [userHits, setUserHits] = useState([]); + const [searching, setSearching] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [ctxMenu, setCtxMenu] = useState<{ + x: number; + y: number; + room: ChatRoomView; + } | null>(null); + const longPressTimer = useRef | null>(null); + + useEffect(() => { + setRooms(sortRooms(initialRooms)); + }, [initialRooms]); + + const refreshRooms = useCallback(async () => { + try { + const res = await apiListChatRooms(1, 50, "", "joined"); + setRooms(sortRooms(res.rooms || [])); + } catch { + // 静默 + } + }, []); + + // 每次进入消息壳都用 HTTP 拉最新排序(避免首页 Link 预取/路由缓存导致新消息会话不置顶) + useEffect(() => { + refreshRooms(); + }, [refreshRooms, activeRoomId]); + + // 从其他标签页切回时也对账一次 + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible") refreshRooms(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, [refreshRooms]); + + // 实时未读:更新侧栏角标与预览,按置顶规则重排(不盲目置顶到最前) + useEffect(() => { + const off = realtime.on(RT_CHAT_UNREAD, (data) => { + if (!data?.room_id) return; + if (activeRoomId === data.room_id) return; + setRooms((prev) => { + const idx = prev.findIndex((r) => r.id === data.room_id); + if (idx < 0) { + refreshRooms(); + return prev; + } + const next = [...prev]; + const cur = next[idx]; + next[idx] = { + ...cur, + unread_count: (cur.unread_count || 0) + (data.delta || 1), + last_message_id: data.message_id, + last_message: { + id: data.message_id, + room_id: data.room_id, + sender_id: data.sender_id, + content: data.preview || "", + created_at: new Date().toISOString(), + sender: cur.last_message?.sender || ({ + id: data.sender_id, + username: "", + nickname: "", + avatar: "", + role: "user", + } as User), + }, + }; + return sortRooms(next); + }); + }); + return off; + }, [activeRoomId, refreshRooms]); + + useEffect(() => { + const off = realtime.on(RT_CHAT_MEMBERSHIP, (data) => { + if (!data) return; + if (data.status === "invited" || data.status === "dissolved" || data.status === "kicked") { + refreshRooms(); + } + }); + return off; + }, [refreshRooms]); + + // 进入会话后清零该会话未读 + useEffect(() => { + if (!activeRoomId) return; + setRooms((prev) => + prev.map((r) => (r.id === activeRoomId ? { ...r, unread_count: 0 } : r)) + ); + }, [activeRoomId]); + + // 搜索:会话本地过滤 + 用户远程搜索 + useEffect(() => { + const q = query.trim(); + if (!q) { + setUserHits([]); + return; + } + const t = setTimeout(async () => { + setSearching(true); + try { + const res = await apiSearchChatUsers(q); + setUserHits(res.users || []); + } catch { + setUserHits([]); + } finally { + setSearching(false); + } + }, 300); + return () => clearTimeout(t); + }, [query]); + + const filteredRooms = (() => { + const q = query.trim().toLowerCase(); + if (!q) return rooms; + return rooms.filter((r) => { + const title = roomTitle(r).toLowerCase(); + const uname = r.peer?.username?.toLowerCase() || ""; + return title.includes(q) || uname.includes(q) || r.name.toLowerCase().includes(q); + }); + })(); + + const openDM = async (uid: number) => { + try { + const { room } = await apiOpenDirectChat(uid); + setQuery(""); + setUserHits([]); + router.push(`/chat/${room.id}`); + refreshRooms(); + } catch (e) { + toast(e instanceof Error ? e.message : "无法发起私聊"); + } + }; + + const togglePin = async (room: ChatRoomView) => { + setCtxMenu(null); + if (room.is_default || room.pin_forced) { + toast("大厅已置顶且不可取消"); + return; + } + const next = !room.pinned; + try { + await apiPinChatRoom(room.id, next); + setRooms((prev) => + sortRooms( + prev.map((r) => + r.id === room.id ? { ...r, pinned: next } : r + ) + ) + ); + toast(next ? "已置顶" : "已取消置顶", "ok"); + } catch (err) { + toast(err instanceof Error ? err.message : "操作失败"); + } + }; + + const openRoomMenu = (room: ChatRoomView, x: number, y: number) => { + if (room.is_default || room.pin_forced) return; + setCtxMenu({ x, y, room }); + }; + + const clearLongPress = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const showList = !activeRoomId; + // 移动端:有会话时隐藏列表;桌面始终显示列表 + const listClass = activeRoomId + ? "hidden lg:flex" + : "flex"; + const paneClass = activeRoomId + ? "flex" + : "hidden lg:flex"; + + return ( +
+ {/* 左栏:会话列表 */} + + + {/* 右栏:会话内容 */} +
+ {children ? ( + children + ) : ( +
+ + + +

+ 选择一个会话开始聊天 +

+

+ 在左侧搜索用户发起私聊,或新建群聊。全站大厅已为你自动加入。 +

+ {showList && ( +

请从上方列表选择会话

+ )} +
+ )} +
+ + {showCreate && ( + setShowCreate(false)} + onCreated={(id) => { + setShowCreate(false); + toast("群已创建", "ok"); + router.push(`/chat/${id}`); + refreshRooms(); + }} + /> + )} + + {ctxMenu && ( + , + onClick: () => togglePin(ctxMenu.room), + }, + ] as CtxMenuItem[] + } + onClose={() => setCtxMenu(null)} + /> + )} +
+ ); +} + +function CreateGroupModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (roomId: number) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isPrivate, setIsPrivate] = useState(false); + const [memberInput, setMemberInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const submit = async () => { + const trimmed = name.trim(); + if (trimmed.length < 2 || trimmed.length > 32) { + toast("群名称需为 2-32 个字符"); + return; + } + if (description.length > 200) { + toast("群简介不能超过 200 个字符"); + return; + } + const usernames = memberInput + .split(/[\s,,]+/) + .map((s) => s.trim()) + .filter(Boolean); + setSubmitting(true); + try { + const { room } = await apiCreateChatRoom(trimmed, description.trim(), { + is_private: isPrivate, + member_usernames: isPrivate ? usernames : [], + }); + onCreated(room.id); + } catch (e) { + toast(e instanceof Error ? e.message : "建群失败"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

+ 新建群聊 +

+ +
+
+
+ + setName(e.target.value)} + maxLength={32} + placeholder="2-32 个字符" + className="w-full px-3.5 h-10 rounded-lg outline-none" + style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }} + /> +
+
+ +