feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -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),

View File

@@ -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,
})
}

459
backend/handler/chat.go Normal file
View File

@@ -0,0 +1,459 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// chatErrToStatus 群聊业务错误 → HTTP 状态
func chatErrToStatus(err error) int {
switch {
case errors.Is(err, service.ErrChatNotFound), errors.Is(err, service.ErrChatMsgNotFound):
return http.StatusNotFound
case errors.Is(err, service.ErrChatNotMember), errors.Is(err, service.ErrChatMuted),
errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite),
errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave),
errors.Is(err, service.ErrChatDefaultDissolve), errors.Is(err, service.ErrChatDMDissolve),
errors.Is(err, service.ErrChatHallPinFixed):
return http.StatusForbidden
case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone):
return http.StatusBadRequest
default:
return http.StatusBadRequest
}
}
func chatRoomID(c *gin.Context) (uint, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "群 ID 无效"})
return 0, false
}
return uint(id), true
}
func (h *Handlers) chatOversee(userID uint) bool {
actor, err := h.Auth.LoadActor(userID)
if err != nil || actor == nil {
return false
}
return service.CanOverseeChat(actor.Role)
}
// CreateChatRoom 建群(可公开/私密,私密可带初始成员)
func (h *Handlers) CreateChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"is_private"`
MemberIDs []uint `json:"member_ids"`
MemberUsernames []string `json:"member_usernames"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
memberIDs := append([]uint{}, req.MemberIDs...)
for _, name := range req.MemberUsernames {
if uid := h.Auth.GetUserIDByUsername(name); uid > 0 && uid != claims.ID {
memberIDs = append(memberIDs, uid)
}
}
room, err := h.Chat.CreateRoom(claims.ID, req.Name, req.Description, req.IsPrivate, memberIDs)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
// 通知被拉入的初始成员
seen := map[uint]bool{}
for _, uid := range memberIDs {
if uid == 0 || uid == claims.ID || seen[uid] {
continue
}
seen[uid] = true
h.Hub.BroadcastUser(uid, realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": room.ID, "status": "invited"},
})
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// ListChatRooms 会话列表(默认 IM 侧栏仅已加入;scope=all 可浏览公开群)
func (h *Handlers) ListChatRooms(c *gin.Context) {
claims := middleware.CurrentUser(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
joinedOnly := c.DefaultQuery("scope", "joined") != "all"
oversee := h.chatOversee(claims.ID)
rooms, total, err := h.Chat.ListRooms(claims.ID, oversee, joinedOnly, c.Query("q"), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取会话列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"rooms": rooms, "total": total, "page": page, "size": size})
}
// GetChatRoom 群详情(公开非成员可预览;私密非成员 404)
func (h *Handlers) GetChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
oversee := h.chatOversee(claims.ID)
room, err := h.Chat.RoomDetail(claims.ID, roomID, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// UpdateChatRoom 群主改群名/简介
func (h *Handlers) UpdateChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
room, err := h.Chat.UpdateRoom(claims.ID, roomID, req.Name, req.Description)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// PinChatRoom 当前用户置顶/取消置顶会话
func (h *Handlers) PinChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
Pinned bool `json:"pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
room, err := h.Chat.SetRoomPinned(claims.ID, roomID, req.Pinned)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// DissolveChatRoom 群主/监管解散群
func (h *Handlers) DissolveChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
oversee := h.chatOversee(claims.ID)
members, err := h.Chat.ListMembers(claims.ID, roomID, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.Chat.Dissolve(claims.ID, roomID, oversee); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
roomName := realtime.RoomChat(roomID)
for _, m := range members {
h.Hub.RemoveUserRoom(m.UserID, roomName)
h.Hub.BroadcastUser(m.UserID, realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": roomID, "status": "dissolved"},
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// JoinChatRoom 加入公开群
func (h *Handlers) JoinChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
_, _, err := h.Chat.Join(claims.ID, roomID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// LeaveChatRoom 退出群
func (h *Handlers) LeaveChatRoom(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
if err := h.Chat.Leave(claims.ID, roomID); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.RemoveUserRoom(claims.ID, realtime.RoomChat(roomID))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// InviteChatMembers 群主/监管邀请入群
func (h *Handlers) InviteChatMembers(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
UserIDs []uint `json:"user_ids"`
Usernames []string `json:"usernames"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"})
return
}
ids := append([]uint{}, req.UserIDs...)
for _, name := range req.Usernames {
if uid := h.Auth.GetUserIDByUsername(name); uid > 0 {
ids = append(ids, uid)
}
}
if len(ids) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"})
return
}
oversee := h.chatOversee(claims.ID)
added, err := h.Chat.InviteMembers(claims.ID, roomID, ids, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
for _, uid := range added {
h.Hub.BroadcastUser(uid, realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": roomID, "status": "invited"},
})
}
c.JSON(http.StatusOK, gin.H{"added": added})
}
// ListChatMembers 成员列表
func (h *Handlers) ListChatMembers(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
oversee := h.chatOversee(claims.ID)
list, err := h.Chat.ListMembers(claims.ID, roomID, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"members": list})
}
// KickChatMember 群主踢人
func (h *Handlers) KickChatMember(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"})
return
}
if err := h.Chat.Kick(claims.ID, roomID, uint(targetID)); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.RemoveUserRoom(uint(targetID), realtime.RoomChat(roomID))
h.Hub.BroadcastUser(uint(targetID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{"room_id": roomID, "status": "kicked"},
})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ListChatMessages 历史消息
func (h *Handlers) ListChatMessages(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
beforeID, _ := strconv.ParseUint(c.Query("before_id"), 10, 64)
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
oversee := h.chatOversee(claims.ID)
msgs, hasMore, err := h.Chat.ListMessages(claims.ID, roomID, uint(beforeID), size, oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"messages": msgs, "has_more": hasMore})
}
// SendChatMessage 发消息
func (h *Handlers) SendChatMessage(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
Content string `json:"content"`
ReplyToID uint `json:"reply_to_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
msg, _, err := h.Chat.SendMessage(claims.ID, roomID, req.Content, req.ReplyToID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: msg,
})
// 向未在房内订阅的成员推未读增量(Header / 会话列表实时角标)
if memberIDs, mErr := h.Chat.ListMemberIDs(roomID); mErr == nil {
preview := req.Content
runes := []rune(preview)
if len(runes) > 80 {
preview = string(runes[:80])
}
for _, uid := range memberIDs {
if uid == claims.ID {
continue
}
h.Hub.BroadcastUser(uid, realtime.Envelope{
Type: realtime.EventChatUnread,
Data: gin.H{
"room_id": roomID,
"message_id": msg.ID,
"sender_id": claims.ID,
"preview": preview,
"delta": 1,
},
})
}
}
// @ 通知的 WS 红点由 Notification.CreateMention → OnNotifyNew 统一推送
c.JSON(http.StatusOK, gin.H{"message": msg})
}
// RecallChatMessage 撤回消息
func (h *Handlers) RecallChatMessage(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
mid, err := strconv.ParseUint(c.Param("mid"), 10, 64)
if err != nil || mid == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
return
}
oversee := h.chatOversee(claims.ID)
msg, err := h.Chat.RecallMessage(claims.ID, roomID, uint(mid), oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatRecalled,
Data: gin.H{
"id": msg.ID,
"room_id": roomID,
"sender_id": msg.SenderID,
"recalled_by": msg.RecalledBy,
"recalled_at": msg.RecalledAt,
},
})
c.JSON(http.StatusOK, gin.H{"message": msg})
}
// MarkChatRead 推进已读水位
func (h *Handlers) MarkChatRead(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
if !ok {
return
}
var req struct {
MessageID uint `json:"message_id"`
}
_ = c.ShouldBindJSON(&req)
if err := h.Chat.MarkRead(claims.ID, roomID, req.MessageID); err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// OpenDirectChat 获取或创建与指定用户的私聊
func (h *Handlers) OpenDirectChat(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req struct {
UserID uint `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定用户"})
return
}
room, err := h.Chat.GetOrCreateDM(claims.ID, req.UserID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"room": room})
}
// SearchChatUsers 消息页搜索用户以发起私聊
func (h *Handlers) SearchChatUsers(c *gin.Context) {
claims := middleware.CurrentUser(c)
users, err := h.Chat.SearchUsersForChat(claims.ID, c.Query("q"), 10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
// ChatUnreadSummary 未读汇总
func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
claims := middleware.CurrentUser(c)
total, rooms, err := h.Chat.UnreadSummary(claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取未读失败"})
return
}
c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms})
}

View File

@@ -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})
}

View File

@@ -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,
})
}

View File

@@ -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": "操作成功"})
}

View File

@@ -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})
}

126
backend/handler/realtime.go Normal file
View File

@@ -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
}

View File

@@ -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})
}

View File

@@ -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]
}