diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md index 8897b36..839596f 100644 --- a/.cursor/project_memory.md +++ b/.cursor/project_memory.md @@ -150,14 +150,16 @@ data volume ──► Go 的 dataDir(与本地 backend/data 语义一致 ## 四级 RBAC + 内容审核(2026-09) -- 角色:`owner`(全站唯一,迁移把 id=1 testuser 升站长,唯一硬保护:不可改角色/封禁) > `super_admin`(全站后台,仅 owner 可授予) > `admin`(公告+全站帖/评论审核+置顶加精) > `board_admin`(仅 user_boards 授权板块帖/评论审核) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上 -- Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids);RequirePerm(PermAnnouncements/PermUsers/PermSettings) 挂在 staffAPI 子组;板块隔离在 service 层按 Actor.BoardIDs +- 角色:`owner`(全站唯一,不可改角色/封禁;独占彻底删除) > `super_admin`(全站后台+群聊全站监管,仅 owner 可授予) > `admin`(公告+全站帖/评论审核软删+置顶加精) > `board_admin`(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上 +- Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids+can_manage_messages);RequirePerm(announcements/users/settings/messages);板块隔离按 BoardIDs;板管仪表盘 analytics 按授权板过滤 +- 消息:站长可授 `can_manage_messages`(全站群聊后台,无私聊);站长可在群内任命 `ChatRoleAdmin`;站长/超管 `CanOverseeChat` 含私聊;禁言 API 已接 +- 彻底删除帖/评:仅 owner;其余管理员仅软删+恢复 - 发帖/评论:staff 直发 published,普通用户进 pending;pending 不计 comment_count(ApproveComment 时 +1,删除只减 published);公开列表仅 published;作者/staff 可经详情页看 pending/rejected(GetByIDForViewer + EnsurePostVisible) -- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400 +- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400、ErrAuthorProtected/ErrPurgeOwnerOnly→403 - 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列 - 用户安全设置「最近登录设备」:GET /api/me/login-devices(RequireAuth,仅本人仍有效的 refresh 会话,按归一化 IP+UA 指纹去重);DELETE /api/me/login-devices/:id 剔除其它设备(吊销该指纹下除当前会话外的 refresh,JWT `fid` 使 access 立即失效,需重新输入密码)。展示设备/浏览器与原始 IP(不使用 ip2region);本机标 current=当前在线,其余有效会话标在线。SSR 转发 refresh cookie + 浏览器 UA/IP,直出 `/u/:id?tab=security` - 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400 -- 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(待审角标 WS `moderation:changed` + 回前台 HTTP 校准,无定时轮询);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...] +- 前端:lib/roles.ts(canModerateAuthor/canPurgeContent/canAccessAdminMessages 等);RoleBadge;Forbidden.tsx;用户管理 /admin/users - 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组 --- diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go index bd3c0d8..4a1fad4 100644 --- a/backend/handler/admin_user.go +++ b/backend/handler/admin_user.go @@ -73,6 +73,27 @@ func (h *Handlers) AdminUpdateUserRole(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"user": user}) } +// AdminSetUserMessages 站长授予/撤销站点消息管理 +func (h *Handlers) AdminSetUserMessages(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + var body struct { + CanManageMessages bool `json:"can_manage_messages"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + user, err := h.AdminUser.SetCanManageMessages(middleware.CurrentActor(c), id, body.CanManageMessages) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": user}) +} + // AdminSetUserBan 封禁 / 解封用户 func (h *Handlers) AdminSetUserBan(c *gin.Context) { id, ok := parseAdminUserID(c) diff --git a/backend/handler/auth.go b/backend/handler/auth.go index d8ee56a..c2b1bfd 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -301,14 +301,15 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H { boardIDs = []uint{} } return gin.H{ - "id": user.ID, - "username": user.Username, - "nickname": user.Nickname, - "avatar": user.Avatar, - "signature": user.Signature, - "email": user.Email, - "role": user.Role, - "board_ids": boardIDs, + "id": user.ID, + "username": user.Username, + "nickname": user.Nickname, + "avatar": user.Avatar, + "signature": user.Signature, + "email": user.Email, + "role": user.Role, + "board_ids": boardIDs, + "can_manage_messages": user.CanManageMessages, } } @@ -346,8 +347,15 @@ func (h *Handlers) Me(c *gin.Context) { if user.Role == model.RoleBoardAdmin { boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID) } + body := meUserBody(user, boardIDs) + // 群管理员数量:非站长/超管用于后台消息入口判定 + if h.Chat != nil && user.Role != model.RoleOwner && user.Role != model.RoleSuperAdmin { + if n, err := h.Chat.CountAdminRooms(user.ID); err == nil { + body["chat_admin_room_count"] = n + } + } c.JSON(http.StatusOK, gin.H{ - "user": meUserBody(user, boardIDs), + "user": body, "unread_count": unread, "chat_unread_count": chatUnread, }) diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 3264362..3db53d2 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -21,9 +21,12 @@ func chatErrToStatus(err error) int { 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): + errors.Is(err, service.ErrChatHallPinFixed), errors.Is(err, service.ErrChatSiteOwnerOnly), + errors.Is(err, service.ErrChatMuteDenied), errors.Is(err, service.ErrChatCannotMuteOwner), + errors.Is(err, service.ErrChatAdminAccess): return http.StatusForbidden - case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone): + case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone), + errors.Is(err, service.ErrChatInvalidRole): return http.StatusBadRequest default: return http.StatusBadRequest @@ -307,7 +310,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"}) return } - if err := h.Chat.Kick(claims.ID, roomID, uint(targetID)); err != nil { + if err := h.Chat.Kick(claims.ID, roomID, uint(targetID), h.chatOversee(claims.ID)); err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } @@ -319,6 +322,63 @@ func (h *Handlers) KickChatMember(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// SetChatMemberMute 禁言/解禁成员 +func (h *Handlers) SetChatMemberMute(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 + } + var body struct { + Muted bool `json:"muted"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil { + c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted}) +} + +// SetChatMemberRole 站长任命/撤销群管理员 +func (h *Handlers) SetChatMemberRole(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 + } + var body struct { + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + actor := h.loadActor(claims.ID) + role := model.RoleUser + if actor != nil { + role = actor.Role + } + if err := h.Chat.SetMemberRole(role, roomID, uint(targetID), body.Role); err != nil { + c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "role": body.Role}) +} + // ListChatMessages 历史消息 func (h *Handlers) ListChatMessages(c *gin.Context) { claims := middleware.CurrentUser(c) @@ -474,11 +534,21 @@ func (h *Handlers) ChatUnreadSummary(c *gin.Context) { // AdminChatMessages 管理端消息监管队列;room_type=group|direct func (h *Handlers) AdminChatMessages(c *gin.Context) { + claims := middleware.CurrentUser(c) + actor := h.loadActor(claims.ID) + if actor == nil || !h.Chat.CanAccessAdminMessages(actor) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"}) + return + } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) q := c.Query("q") roomType := c.Query("room_type") - msgs, total, err := h.Chat.AdminListMessages(page, q, roomType) + msgs, total, err := h.Chat.AdminListMessages(actor, page, q, roomType) if err != nil { + if errors.Is(err, service.ErrChatAdminAccess) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } if err.Error() == "房间类型无效" { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -486,12 +556,24 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"}) return } - c.JSON(http.StatusOK, gin.H{"messages": msgs, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{ + "messages": msgs, + "total": total, + "page": page, + "can_oversee": service.CanOverseeChat(actor.Role), + "can_recall": true, + "site_messages": actor.HasSiteMessagePerm(), + }) } -// AdminRecallChatMessage 管理端撤回群聊消息(staff;监管者可撤任意) +// AdminRecallChatMessage 管理端撤回群聊消息 func (h *Handlers) AdminRecallChatMessage(c *gin.Context) { claims := middleware.CurrentUser(c) + actor := h.loadActor(claims.ID) + if actor == nil || !h.Chat.CanAccessAdminMessages(actor) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"}) + return + } roomID, err := strconv.ParseUint(c.Param("roomId"), 10, 64) if err != nil || roomID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "房间 ID 无效"}) @@ -502,8 +584,7 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"}) return } - oversee := h.chatOversee(claims.ID) - // 非监管 staff:仅允许撤回自己发送的(与前台一致) + oversee := service.CanOverseeChat(actor.Role) msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) diff --git a/backend/handler/comment.go b/backend/handler/comment.go index ba2fdbc..5a06dbf 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -176,7 +176,8 @@ func (h *Handlers) DeleteComment(c *gin.Context) { switch { case errors.Is(err, service.ErrCommentNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, service.ErrCommentForbidden): + case errors.Is(err, service.ErrCommentForbidden), + errors.Is(err, service.ErrAuthorProtected): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrCommentDeleteMeta), errors.Is(err, service.ErrCommentInvalidType): @@ -206,7 +207,8 @@ func (h *Handlers) RestoreComment(c *gin.Context) { switch { case errors.Is(err, service.ErrCommentNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, service.ErrCommentForbidden): + case errors.Is(err, service.ErrCommentForbidden), + errors.Is(err, service.ErrAuthorProtected): c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"}) case errors.Is(err, service.ErrCommentNotDeleted): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -218,7 +220,7 @@ func (h *Handlers) RestoreComment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "已恢复"}) } -// PurgeComment 硬删评论及子树(仅版主;不占位) +// PurgeComment 硬删评论及子树(仅站长;不占位) func (h *Handlers) PurgeComment(c *gin.Context) { claims := middleware.CurrentUser(c) cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) @@ -230,7 +232,8 @@ func (h *Handlers) PurgeComment(c *gin.Context) { switch { case errors.Is(err, service.ErrCommentNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, service.ErrCommentForbidden): + case errors.Is(err, service.ErrCommentForbidden), + errors.Is(err, service.ErrPurgeOwnerOnly): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index e018a7b..4fb8dc7 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -247,7 +247,9 @@ func (h *Handlers) writeModerationError(c *gin.Context, err error) { switch { case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, service.ErrContentNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "内容不存在"}) - case errors.Is(err, service.ErrModerationForbidden): + case errors.Is(err, service.ErrModerationForbidden), + errors.Is(err, service.ErrAuthorProtected), + errors.Is(err, service.ErrPurgeOwnerOnly): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrNotPending), errors.Is(err, service.ErrContentNotDeleted): diff --git a/backend/handler/post.go b/backend/handler/post.go index 4d052fa..c01cc86 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -536,7 +536,8 @@ func respondPostModError(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrPostNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, service.ErrPostForbidden): + case errors.Is(err, service.ErrPostForbidden), + errors.Is(err, service.ErrAuthorProtected): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrPollOptionsLocked), errors.Is(err, service.ErrLotteryPrizesLocked): diff --git a/backend/handler/telemetry.go b/backend/handler/telemetry.go index 83e807d..992d923 100644 --- a/backend/handler/telemetry.go +++ b/backend/handler/telemetry.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -47,7 +48,13 @@ func (h *Handlers) TelemetryPageView(c *gin.Context) { // GET /api/admin/analytics/overview?range=7d|30d func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) { rangeQ := c.DefaultQuery("range", "7d") - data, err := h.Analytics.Overview(rangeQ) + actor := middleware.CurrentActor(c) + scoped := actor != nil && actor.Role == model.RoleBoardAdmin + var boardIDs []uint + if scoped { + boardIDs = actor.BoardIDs + } + data, err := h.Analytics.Overview(rangeQ, boardIDs, scoped) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"}) return diff --git a/backend/model/models.go b/backend/model/models.go index d51ff4a..c79f01b 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -112,21 +112,22 @@ func NormalizeContentAccess(a string) string { // User 用户表 type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"uniqueIndex;size:128;not null" json:"username"` - Email string `gorm:"index;size:128;default:''" json:"-"` - Password string `gorm:"size:128;not null" json:"-"` - Nickname string `gorm:"size:64" json:"nickname"` - Avatar string `gorm:"size:512" json:"avatar"` - Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名 - Role Role `gorm:"size:16;default:user" json:"role"` - Banned bool `gorm:"default:false" json:"banned"` - Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 - TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效 - LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;size:128;not null" json:"username"` + Email string `gorm:"index;size:128;default:''" json:"-"` + Password string `gorm:"size:128;not null" json:"-"` + Nickname string `gorm:"size:64" json:"nickname"` + Avatar string `gorm:"size:512" json:"avatar"` + Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名 + Role Role `gorm:"size:16;default:user" json:"role"` + Banned bool `gorm:"default:false" json:"banned"` + CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管) + Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 + TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效 + LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测) @@ -483,9 +484,19 @@ type LoginLog struct { // 群聊成员角色 const ( ChatRoleOwner = "owner" // 群主(建群者,每群唯一):改名/解散/踢人/禁言 + ChatRoleAdmin = "admin" // 群管理员(仅站长可任命):本群撤回他人消息/踢人/禁言 ChatRoleMember = "member" // 普通成员 ) +// ValidChatMemberRole 群内角色白名单(任命接口仅允许 admin/member) +func ValidChatMemberRole(r string) bool { + switch r { + case ChatRoleOwner, ChatRoleAdmin, ChatRoleMember: + return true + } + return false +} + // ChatRoom 会话房间:群聊或一对一私聊(RoomType);IsDefault 为全站大厅 type ChatRoom struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/backend/router/router.go b/backend/router/router.go index cbfdc60..438aaff 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -208,6 +208,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { chatAPI.GET("/rooms/:id/members", h.ListChatMembers) chatAPI.POST("/rooms/:id/members", h.InviteChatMembers) chatAPI.DELETE("/rooms/:id/members/:uid", h.KickChatMember) + chatAPI.PUT("/rooms/:id/members/:uid/mute", h.SetChatMemberMute) + chatAPI.PUT("/rooms/:id/members/:uid/role", h.SetChatMemberRole) chatAPI.GET("/rooms/:id/messages", h.ListChatMessages) chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage) chatAPI.DELETE("/rooms/:id/messages/:mid", h.RecallChatMessage) @@ -244,12 +246,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 积分与类型帖概览(管理员及以上) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats) - // 站点分析概览(任意 staff) + // 站点分析概览(任意 staff;板管按授权板过滤) staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview) - // 群聊监管队列(任意 staff;撤回权限仍按 oversee 规则) - staffAPI.GET("/chat/messages", h.AdminChatMessages) - staffAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage) + // 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问 + // (见下方 msgAPI) // 站点公告文章管理(管理员及以上) announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) @@ -275,6 +276,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers)) usersAPI.GET("/users", h.AdminListUsers) usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole) + usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages) usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan) usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs) // 站长内容档案(handler 内再校验 owner) @@ -284,6 +286,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages) } + // 消息管理后台:登录即可(handler 内校验站点消息权或群管理员) + msgAPI := r.Group("/api/admin", authMW.RequireAuth(), middleware.CSRFMiddleware()) + { + msgAPI.GET("/chat/messages", h.AdminChatMessages) + msgAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage) + } + r.NoRoute(func(c *gin.Context) { c.JSON(404, gin.H{"error": "not found"}) }) diff --git a/backend/service/actor.go b/backend/service/actor.go index 4df237b..ecd6ff6 100644 --- a/backend/service/actor.go +++ b/backend/service/actor.go @@ -13,15 +13,17 @@ const ( PermAnnouncements = "announcements" // 公告管理(管理员及以上) PermSettings = "settings" // 站点外观设置(超管/站长) PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限) + PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行) ) // Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取, // 不依赖 JWT 内的 role claim,角色/授权变更立即生效) type Actor struct { - ID uint - Username string - Role model.Role - BoardIDs []uint // 板块管理员被授权的板块;其他角色为空 + ID uint + Username string + Role model.Role + BoardIDs []uint // 板块管理员被授权的板块;其他角色为空 + CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段 } // IsStaff 是否管理团队成员 @@ -41,10 +43,28 @@ func (a *Actor) HasPerm(p string) bool { return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin) case PermModeration: return a.IsStaff() + case PermMessages: + return a.HasSiteMessagePerm() } return false } +// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages +func (a *Actor) HasSiteMessagePerm() bool { + if a == nil { + return false + } + if a.Role == model.RoleOwner || a.Role == model.RoleSuperAdmin { + return true + } + return a.CanManageMessages +} + +// CanPurgeContent 彻底删除帖/评:仅站长 +func (a *Actor) CanPurgeContent() bool { + return a != nil && a.Role == model.RoleOwner +} + // CanModerateBoard 是否可管理指定板块的内容: // 管理员及以上不限板块;板块管理员仅限被授权板块 func (a *Actor) CanModerateBoard(boardID uint) bool { @@ -65,6 +85,17 @@ func (a *Actor) CanModerateBoard(boardID uint) bool { return false } +// CanModerateAuthor 板管不可软删/恢复管理员及以上作者的内容;其他角色不受此限 +func (a *Actor) CanModerateAuthor(authorRole model.Role) bool { + if a == nil { + return false + } + if a.Role == model.RoleBoardAdmin { + return model.RoleLevel(authorRole) < model.RoleLevel(model.RoleAdmin) + } + return true +} + // CanAssignRole 操作者能否把他人设置为目标角色(站长不可被授予,仅站长可授予超管) func (a *Actor) CanAssignRole(target model.Role) bool { if a == nil { @@ -82,10 +113,13 @@ func (a *Actor) CanAssignRole(target model.Role) bool { // LoadActor 读取用户实时角色与板块授权 func (s *AuthService) LoadActor(id uint) (*Actor, error) { var u model.User - if err := s.db.Select("id", "username", "role").First(&u, id).Error; err != nil { + if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil { return nil, err } - actor := &Actor{ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{}} + actor := &Actor{ + ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{}, + CanManageMessages: u.CanManageMessages, + } if u.Role == model.RoleBoardAdmin { var ids []uint if err := s.db.Model(&model.UserBoard{}). @@ -99,6 +133,15 @@ func (s *AuthService) LoadActor(id uint) (*Actor, error) { return actor, nil } +// GetUserRole 读取用户角色(内容作者保护用);用户不存在返回 user +func (s *AuthService) GetUserRole(id uint) (model.Role, error) { + var u model.User + if err := s.db.Select("role").First(&u, id).Error; err != nil { + return model.RoleUser, err + } + return u.Role, nil +} + // GetUserBoardIDs 读取用户被授权的板块 ID(供 /me 等场景) func (s *AuthService) GetUserBoardIDs(id uint) ([]uint, error) { var ids []uint diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index fb83a74..a05d147 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -15,6 +15,25 @@ var ( ErrContentNotFound = errors.New("内容不存在") ) +func (s *ModerationService) authorRole(userID uint) (model.Role, error) { + var u model.User + if err := s.db.Select("role").First(&u, userID).Error; err != nil { + return model.RoleUser, err + } + return u.Role, nil +} + +func (s *ModerationService) assertCanModerateAuthor(actor *Actor, authorID uint) error { + role, err := s.authorRole(authorID) + if err != nil { + return err + } + if !actor.CanModerateAuthor(role) { + return ErrAuthorProtected + } + return nil +} + const adminContentPageSize = 15 // AdminContentStatus 列表筛选:live=未删除全部状态;其余为具体状态或已删除 @@ -303,12 +322,15 @@ func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.C return &cm, post.BoardID, nil } -// SoftDeletePost 软删帖子(未删除时);须填写删除类型与理由,并通知作者 +// SoftDeletePost 软删帖子(未删除时);须选类型,「其他」才须填理由,并通知作者 func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) { post, err := s.loadPostUnscoped(actor, id) if err != nil { return 0, err } + if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil { + return 0, err + } if post.DeletedAt.Valid { return 0, errors.New("帖子已在回收站") } @@ -317,11 +339,15 @@ func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, de if !model.ValidPostDeleteType(deleteType) { return 0, errors.New("无效的删除类型") } - if deleteReason == "" { - return 0, errors.New("请填写删除理由") - } - if len([]rune(deleteReason)) > 200 { - return 0, errors.New("删除理由不能超过 200 字") + if deleteType == model.PostDeleteTypeOther { + if deleteReason == "" { + return 0, errors.New("请填写删除理由") + } + if len([]rune(deleteReason)) > 200 { + return 0, errors.New("删除理由不能超过 200 字") + } + } else { + deleteReason = "" } updates := map[string]interface{}{ "delete_type": deleteType, @@ -343,14 +369,20 @@ func (s *ModerationService) RestorePost(actor *Actor, id uint) error { if err != nil { return err } + if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil { + return err + } if !post.DeletedAt.Valid { return errors.New("帖子未被删除") } return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error } -// PurgePost 彻底删除已软删帖子及其评论 +// PurgePost 彻底删除已软删帖子及其评论(仅站长) func (s *ModerationService) PurgePost(actor *Actor, id uint) error { + if !actor.CanPurgeContent() { + return ErrPurgeOwnerOnly + } post, err := s.loadPostUnscoped(actor, id) if err != nil { return err @@ -372,6 +404,9 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType, if err != nil { return err } + if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil { + return err + } if cm.DeletedAt.Valid { return errors.New("评论已在回收站") } @@ -414,6 +449,9 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error { if err != nil { return err } + if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil { + return err + } if !cm.DeletedAt.Valid { return errors.New("评论未被删除") } @@ -433,8 +471,11 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error { return nil } -// PurgeComment 彻底删除已软删评论 +// PurgeComment 彻底删除已软删评论(仅站长) func (s *ModerationService) PurgeComment(actor *Actor, id uint) error { + if !actor.CanPurgeContent() { + return ErrPurgeOwnerOnly + } cm, _, err := s.loadCommentUnscoped(actor, id) if err != nil { return err diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index aa0526c..f89cb9a 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -44,23 +44,24 @@ func NewAdminUserService(db *gorm.DB) *AdminUserService { // AdminUserItem 后台用户列表项:email / last_seen / 登录 IP 在 User 模型上 json:"-", // 仅管理员接口通过此 DTO 显式带出 type AdminUserItem struct { - ID uint `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Email string `json:"email"` - Avatar string `json:"avatar"` - Signature string `json:"signature"` - Role string `json:"role"` - BoardIDs []uint `json:"board_ids"` - Banned bool `json:"banned"` - PostCount int64 `json:"post_count"` - CommentCount int64 `json:"comment_count"` - Points int `json:"points"` - CreatedAt time.Time `json:"created_at"` - LastSeenAt *time.Time `json:"last_seen_at"` - Online bool `json:"online"` - LastLoginIP string `json:"last_login_ip"` - LastLoginAt *time.Time `json:"last_login_at"` + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Signature string `json:"signature"` + Role string `json:"role"` + BoardIDs []uint `json:"board_ids"` + Banned bool `json:"banned"` + CanManageMessages bool `json:"can_manage_messages"` + PostCount int64 `json:"post_count"` + CommentCount int64 `json:"comment_count"` + Points int `json:"points"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt *time.Time `json:"last_seen_at"` + Online bool `json:"online"` + LastLoginIP string `json:"last_login_ip"` + LastLoginAt *time.Time `json:"last_login_at"` } // AdminUserSummary 列表顶部汇总 @@ -222,18 +223,19 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem { for _, u := range users { ids = append(ids, u.ID) item := AdminUserItem{ - ID: u.ID, - Username: u.Username, - Nickname: u.Nickname, - Email: u.Email, - Avatar: u.Avatar, - Signature: u.Signature, - Role: string(u.Role), - BoardIDs: []uint{}, - Banned: u.Banned, - Points: u.Points, - CreatedAt: u.CreatedAt, - LastSeenAt: u.LastSeenAt, + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Email: u.Email, + Avatar: u.Avatar, + Signature: u.Signature, + Role: string(u.Role), + BoardIDs: []uint{}, + Banned: u.Banned, + CanManageMessages: u.CanManageMessages, + Points: u.Points, + CreatedAt: u.CreatedAt, + LastSeenAt: u.LastSeenAt, } if u.LastSeenAt != nil && now.Sub(*u.LastSeenAt) <= onlineThreshold { item.Online = true @@ -407,6 +409,38 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R return s.getItem(s.db, targetID) } +// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己) +func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) { + if operator == nil || operator.Role != model.RoleOwner { + return nil, ErrOwnerOnly + } + if operator.ID == targetID { + return nil, ErrAdminSelfAction + } + err := s.db.Transaction(func(tx *gorm.DB) error { + var u model.User + if err := tx.First(&u, targetID).Error; err != nil { + return err + } + if u.Role == model.RoleOwner { + return ErrProtectedOwner + } + // 超管固有全站监管,flag 无意义;强制保持 false 以免误导 + if u.Role == model.RoleSuperAdmin { + enabled = false + } + if u.CanManageMessages == enabled { + return nil + } + // GORM bool 零值省略:强制 Select 写入 + return tx.Model(&u).Select("can_manage_messages").Update("can_manage_messages", enabled).Error + }) + if err != nil { + return nil, err + } + return s.getItem(s.db, targetID) +} + // SetBanned 封禁/解封用户。站长账号不可封禁;封禁时同事务使登录态立即失效 func (s *AdminUserService) SetBanned(operator *Actor, targetID uint, banned bool) (*AdminUserItem, error) { if operator == nil { diff --git a/backend/service/analytics.go b/backend/service/analytics.go index 4fad808..94379aa 100644 --- a/backend/service/analytics.go +++ b/backend/service/analytics.go @@ -81,8 +81,9 @@ func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount { return out } -// Overview 返回近 N 日访问与内容趋势 -func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) { +// Overview 返回近 N 日访问与内容趋势。 +// scoped=true 时按 boardIDs 过滤内容类指标,并隐藏全站 PV/UV/注册。 +func (s *AnalyticsService) Overview(rangeQ string, boardIDs []uint, scoped bool) (*AnalyticsOverview, error) { days := parseRangeDays(rangeQ) now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) @@ -97,22 +98,38 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) { TopViewed: make([]TopViewPost, 0), } - var stats []model.SiteDailyStats - if err := s.db.Where("date >= ? AND date <= ?", start, end). - Order("date ASC").Find(&stats).Error; err != nil { - return nil, err + if scoped && len(boardIDs) == 0 { + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)}) + } + out.PostsSeries = fillDaySeries(start, end, nil) + out.CommentsSeries = fillDaySeries(start, end, nil) + out.RegistersSeries = fillDaySeries(start, end, nil) + return out, nil } - visitMap := map[string]model.SiteDailyStats{} - for _, st := range stats { - visitMap[dateKey(st.Date)] = st - } - for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { - k := dateKey(d) - st := visitMap[k] - out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV}) - if k == dateKey(today) { - out.TodayPV = st.PV - out.TodayUV = st.UV + + if !scoped { + var stats []model.SiteDailyStats + if err := s.db.Where("date >= ? AND date <= ?", start, end). + Order("date ASC").Find(&stats).Error; err != nil { + return nil, err + } + visitMap := map[string]model.SiteDailyStats{} + for _, st := range stats { + visitMap[dateKey(st.Date)] = st + } + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + k := dateKey(d) + st := visitMap[k] + out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV}) + if k == dateKey(today) { + out.TodayPV = st.PV + out.TodayUV = st.UV + } + } + } else { + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)}) } } @@ -132,38 +149,56 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) { return m, nil } + boardFilter := "" + var postArgs []interface{} + postArgs = append(postArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1)) + if scoped { + boardFilter = " AND board_id IN ?" + postArgs = append(postArgs, boardIDs) + } postMap, err := scanDayCounts(` SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt FROM posts - WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ? + WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+boardFilter+` GROUP BY day ORDER BY day`, - model.ContentStatusPublished, start, end.AddDate(0, 0, 1)) + postArgs...) if err != nil { return nil, err } out.PostsSeries = fillDaySeries(start, end, postMap) + var commentArgs []interface{} + commentArgs = append(commentArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1)) + commentBoard := "" + if scoped { + commentBoard = " AND post_id IN (SELECT id FROM posts WHERE board_id IN ?)" + commentArgs = append(commentArgs, boardIDs) + } commentMap, err := scanDayCounts(` SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt FROM comments - WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ? + WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+commentBoard+` GROUP BY day ORDER BY day`, - model.ContentStatusPublished, start, end.AddDate(0, 0, 1)) + commentArgs...) if err != nil { return nil, err } out.CommentsSeries = fillDaySeries(start, end, commentMap) - regMap, err := scanDayCounts(` - SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt - FROM users - WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ? - GROUP BY day ORDER BY day`, - start, end.AddDate(0, 0, 1)) - if err != nil { - return nil, err + if !scoped { + regMap, err := scanDayCounts(` + SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt + FROM users + WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ? + GROUP BY day ORDER BY day`, + start, end.AddDate(0, 0, 1)) + if err != nil { + return nil, err + } + out.RegistersSeries = fillDaySeries(start, end, regMap) + } else { + out.RegistersSeries = fillDaySeries(start, end, nil) } - out.RegistersSeries = fillDaySeries(start, end, regMap) type boardRow struct { ID uint @@ -171,13 +206,18 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) { PostCount int64 } var boards []boardRow - if err := s.db.Raw(` + boardSQL := ` SELECT b.id, b.name, count(p.id) AS post_count FROM boards b LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ? - WHERE b.deleted_at IS NULL - GROUP BY b.id, b.name - ORDER BY post_count DESC`, model.ContentStatusPublished).Scan(&boards).Error; err != nil { + WHERE b.deleted_at IS NULL` + boardArgs := []interface{}{model.ContentStatusPublished} + if scoped { + boardSQL += ` AND b.id IN ?` + boardArgs = append(boardArgs, boardIDs) + } + boardSQL += ` GROUP BY b.id, b.name ORDER BY post_count DESC` + if err := s.db.Raw(boardSQL, boardArgs...).Scan(&boards).Error; err != nil { return nil, err } for _, b := range boards { @@ -192,11 +232,14 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) { PostType string Cnt int64 } - var types []typeRow - if err := s.db.Model(&model.Post{}). + typeQ := s.db.Model(&model.Post{}). Select("post_type, count(*) as cnt"). - Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished). - Group("post_type").Scan(&types).Error; err != nil { + Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished) + if scoped { + typeQ = typeQ.Where("board_id IN ?", boardIDs) + } + var types []typeRow + if err := typeQ.Group("post_type").Scan(&types).Error; err != nil { return nil, err } for _, t := range types { @@ -208,10 +251,13 @@ func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) { }) } + topQ := s.db.Select("id, title, view_count, board_id"). + Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished) + if scoped { + topQ = topQ.Where("board_id IN ?", boardIDs) + } var tops []model.Post - if err := s.db.Select("id, title, view_count, board_id"). - Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished). - Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil { + if err := topQ.Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil { return nil, err } for _, p := range tops { diff --git a/backend/service/chat.go b/backend/service/chat.go index b493445..d5961eb 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -35,9 +35,14 @@ var ( ErrChatDMDissolve = errors.New("私聊会话不可解散") ErrChatUserGone = errors.New("用户不存在或已封禁") ErrChatHallPinFixed = errors.New("大厅已置顶且不可取消") + ErrChatSiteOwnerOnly = errors.New("仅站长可任命群管理员") + ErrChatInvalidRole = errors.New("无效的群成员角色") + ErrChatMuteDenied = errors.New("无权禁言该成员") + ErrChatCannotMuteOwner = errors.New("不能禁言群主或站长") + ErrChatAdminAccess = errors.New("无权管理消息") ) -// CanOverseeChat 站长/超管可旁路查看全部群并撤回任意消息 +// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息 func CanOverseeChat(role model.Role) bool { return role == model.RoleOwner || role == model.RoleSuperAdmin } @@ -530,7 +535,7 @@ func (s *ChatService) Join(userID, roomID uint) (member *model.ChatRoomMember, j return &m, true, nil } -// InviteMembers 群主(或监管)拉人入群;返回实际新加入的用户 ID +// InviteMembers 群主/群管(或监管)拉人入群;返回实际新加入的用户 ID func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) { room, err := s.getRoom(roomID) if err != nil { @@ -544,7 +549,7 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove if err != nil { return nil, err } - if op.Role != model.ChatRoleOwner { + if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin { return nil, ErrChatOwnerOnly } } @@ -702,7 +707,7 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch } var list []model.ChatRoomMember err := s.db.Where("room_id = ?", roomID). - Order("CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at ASC"). + Order("CASE WHEN role = 'owner' THEN 0 WHEN role = 'admin' THEN 1 ELSE 2 END, created_at ASC"). Preload("User"). Find(&list).Error if err != nil { @@ -711,18 +716,11 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch return list, nil } -// Kick 群主踢人(不能踢群主) -func (s *ChatService) Kick(operatorID, roomID, targetID uint) error { +// Kick 群主/群管/全站监管踢人(不能踢群主;群管不能踢其他群管) +func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) 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 @@ -730,9 +728,158 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint) error { if target.Role == model.ChatRoleOwner { return ErrChatOwnerOnly } + if !oversee { + op, err := s.membership(s.db, roomID, operatorID) + if err != nil { + return err + } + if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin { + return ErrChatOwnerOnly + } + if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner { + return ErrChatOwnerOnly + } + } return s.removeMember(roomID, targetID) } +// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号 +func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error { + room, err := s.getRoom(roomID) + if err != nil { + return err + } + if room.RoomType == model.ChatRoomTypeDirect { + return ErrChatOwnerOnly + } + target, err := s.membership(s.db, roomID, targetID) + if err != nil { + return err + } + if target.Role == model.ChatRoleOwner { + return ErrChatCannotMuteOwner + } + var targetUser model.User + if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil { + return ErrChatUserGone + } + if targetUser.Role == model.RoleOwner { + return ErrChatCannotMuteOwner + } + if !oversee { + op, err := s.membership(s.db, roomID, operatorID) + if err != nil { + return err + } + if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin { + return ErrChatMuteDenied + } + if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner { + return ErrChatMuteDenied + } + } + return s.db.Model(&model.ChatRoomMember{}). + Where("room_id = ? AND user_id = ?", roomID, targetID). + Select("muted").Update("muted", muted).Error +} + +// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主 +func (s *ChatService) SetMemberRole(operatorSiteRole model.Role, roomID, targetID uint, role string) error { + if operatorSiteRole != model.RoleOwner { + return ErrChatSiteOwnerOnly + } + if role != model.ChatRoleAdmin && role != model.ChatRoleMember { + return ErrChatInvalidRole + } + room, err := s.getRoom(roomID) + if err != nil { + return err + } + if room.RoomType != model.ChatRoomTypeGroup { + return ErrChatOwnerOnly + } + target, err := s.membership(s.db, roomID, targetID) + if err != nil { + return err + } + if target.Role == model.ChatRoleOwner || room.OwnerID == targetID { + return ErrChatOwnerOnly + } + return s.db.Model(&model.ChatRoomMember{}). + Where("room_id = ? AND user_id = ?", roomID, targetID). + Update("role", role).Error +} + +// CountAdminRooms 用户作为群管理员的群数量 +func (s *ChatService) CountAdminRooms(userID uint) (int64, error) { + var n int64 + err := s.db.Model(&model.ChatRoomMember{}). + Joins("JOIN chat_rooms ON chat_rooms.id = chat_room_members.room_id"). + Where("chat_room_members.user_id = ? AND chat_room_members.role = ? AND chat_rooms.deleted_at IS NULL AND chat_rooms.room_type = ?", + userID, model.ChatRoleAdmin, model.ChatRoomTypeGroup). + Count(&n).Error + return n, err +} + +// ListAdminRoomIDs 用户作为群管理员的群 ID 列表 +func (s *ChatService) ListAdminRoomIDs(userID uint) ([]uint, error) { + var ids []uint + err := s.db.Model(&model.ChatRoomMember{}). + Joins("JOIN chat_rooms ON chat_rooms.id = chat_room_members.room_id"). + Where("chat_room_members.user_id = ? AND chat_room_members.role = ? AND chat_rooms.deleted_at IS NULL AND chat_rooms.room_type = ?", + userID, model.ChatRoleAdmin, model.ChatRoomTypeGroup). + Pluck("chat_room_members.room_id", &ids).Error + return ids, err +} + +// CanAccessAdminMessages 可否进入后台消息管理 +func (s *ChatService) CanAccessAdminMessages(actor *Actor) bool { + if actor == nil { + return false + } + if actor.HasSiteMessagePerm() { + return true + } + n, err := s.CountAdminRooms(actor.ID) + return err == nil && n > 0 +} + +// isChatRoomModerator 群主或群管理员 +func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool { + m, err := s.membership(s.db, roomID, userID) + if err != nil { + return false + } + return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin +} + +// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee) +func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool { + var u model.User + if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil { + return false + } + return u.CanManageMessages +} + +// canModerateRoomMessages 可撤回他人消息 / 看已撤回正文:全站监管、站点消息管(仅群)、群主/群管 +func (s *ChatService) canModerateRoomMessages(userID, roomID uint, oversee bool) bool { + if oversee { + return true + } + if s.isChatRoomModerator(userID, roomID) { + return true + } + if !s.hasSiteGroupMessagePerm(userID) { + return false + } + room, err := s.getRoom(roomID) + if err != nil { + return false + } + return room.RoomType == model.ChatRoomTypeGroup +} + // SendMessage 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。 func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) { content = strings.TrimSpace(content) @@ -907,8 +1054,9 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over 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 { + // 已撤回正文:监管者 / 站点消息管(群)/ 群主群管保留 + seeRecalled := oversee || s.canModerateRoomMessages(userID, roomID, false) + if !seeRecalled { for i := range msgs { if msgs[i].RecalledAt != nil { msgs[i].Content = "" @@ -918,7 +1066,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over return msgs, hasMore, nil } -// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计。 +// RecallMessage 撤回消息:本人;或群主/群管/站点消息管(群)/全站监管可撤他人。 // newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。 func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) { if _, err := s.getRoom(roomID); err != nil { @@ -934,19 +1082,16 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee if msg.RecalledAt != nil { return &msg, false, nil // 幂等 } - if !oversee { - m, err := s.membership(s.db, roomID, operatorID) - if err != nil { - return nil, false, err - } - // 群主/成员仅可撤回自己的消息;监管者可撤任意 - if msg.SenderID != operatorID { - _ = m // 成员校验已通过 - return nil, false, ErrChatRecallDenied + if msg.SenderID == operatorID { + if !oversee { + if _, err := s.membership(s.db, roomID, operatorID); err != nil { + return nil, false, err + } } + } else if !s.canModerateRoomMessages(operatorID, roomID, oversee) { + return nil, false, ErrChatRecallDenied } now := time.Now() - // 只打撤回标记,保留正文供站长审计;前台 ListMessages 对非监管者置空 content if err := s.db.Model(&msg).Updates(map[string]interface{}{ "recalled_at": now, "recalled_by": operatorID, @@ -982,20 +1127,44 @@ type AdminChatMessageItem struct { CreatedAt time.Time `json:"created_at"` } -// AdminListMessages 按房间类型分页:group=大厅+群聊,direct=私聊;支持正文/房间/参与者搜索 -func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) { +// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。 +// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。 +func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) { + if actor == nil || !s.CanAccessAdminMessages(actor) { + return nil, 0, ErrChatAdminAccess + } if page < 1 { page = 1 } + fullOversee := CanOverseeChat(actor.Role) + siteGroup := actor.HasSiteMessagePerm() && !fullOversee if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect { return nil, 0, errors.New("房间类型无效") } + // 非全站监管不可看私聊 + if roomType == model.ChatRoomTypeDirect && !fullOversee { + return []AdminChatMessageItem{}, 0, nil + } const pageSize = 15 q := s.db.Model(&model.ChatMessage{}). Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id"). Joins("LEFT JOIN users ON users.id = chat_messages.sender_id"). Where("chat_rooms.deleted_at IS NULL AND chat_messages.deleted_at IS NULL"). Where("chat_rooms.room_type = ?", roomType) + + if !fullOversee && !siteGroup { + ids, err := s.ListAdminRoomIDs(actor.ID) + if err != nil { + return nil, 0, err + } + if len(ids) == 0 { + return []AdminChatMessageItem{}, 0, nil + } + q = q.Where("chat_messages.room_id IN ?", ids) + } else if siteGroup { + q = q.Where("chat_rooms.room_type = ?", model.ChatRoomTypeGroup) + } + if kw := strings.TrimSpace(keyword); kw != "" { like := "%" + kw + "%" q = q.Where( @@ -1037,7 +1206,6 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A roomMap[r.ID] = r } } - // 私聊:预加载双方,供前端叠放头像与「甲 · 乙」会话条 participantsByRoom := map[uint][]AdminChatParticipant{} if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 { var members []model.ChatRoomMember @@ -1073,9 +1241,14 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A name = "私聊" } } + content := m.Content + // 后台列表:仅全站监管可见已撤回正文;群管/消息管理员在前台会话内可看 + if !fullOversee && m.RecalledAt != nil { + content = "" + } item := AdminChatMessageItem{ ID: m.ID, RoomID: m.RoomID, RoomName: name, RoomType: r.RoomType, IsDefault: r.IsDefault, - SenderID: m.SenderID, Content: m.Content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt, + SenderID: m.SenderID, Content: content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt, Participants: parts, } if m.Sender.ID > 0 { diff --git a/backend/service/comment.go b/backend/service/comment.go index 2933b19..01619fa 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -435,6 +435,13 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts Delet if actor == nil || !actor.CanModerateBoard(post.BoardID) { return ErrCommentForbidden } + var author model.User + if err := s.db.Select("role").First(&author, comment.UserID).Error; err != nil { + return ErrCommentNotFound + } + if !actor.CanModerateAuthor(author.Role) { + return ErrAuthorProtected + } deleteType = strings.TrimSpace(opts.DeleteType) if !model.ValidPostDeleteType(deleteType) { return ErrCommentInvalidType @@ -490,6 +497,13 @@ func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error { return ErrCommentNotFound } if actor != nil && actor.CanModerateBoard(post.BoardID) { + var author model.User + if err := s.db.Select("role").First(&author, comment.UserID).Error; err != nil { + return ErrCommentNotFound + } + if !actor.CanModerateAuthor(author.Role) { + return ErrAuthorProtected + } allowed = true } } @@ -546,6 +560,9 @@ func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint { // Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。 func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error { + if actor == nil || !actor.CanPurgeContent() { + return ErrPurgeOwnerOnly + } var comment model.Comment if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { return ErrCommentNotFound @@ -554,7 +571,7 @@ func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error { if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return ErrCommentNotFound } - if actor == nil || !actor.CanModerateBoard(post.BoardID) { + if !actor.CanModerateBoard(post.BoardID) { return ErrCommentForbidden } _ = userID diff --git a/backend/service/moderation.go b/backend/service/moderation.go index 8101ecd..75368cb 100644 --- a/backend/service/moderation.go +++ b/backend/service/moderation.go @@ -12,6 +12,8 @@ import ( var ( ErrModerationForbidden = errors.New("无权审核该板块的内容") ErrNotPending = errors.New("该内容不在待审核状态") + ErrAuthorProtected = errors.New("无权处置该作者的内容") + ErrPurgeOwnerOnly = errors.New("仅站长可彻底删除") ) // ModerationService 帖子/评论审核队列 diff --git a/backend/service/post.go b/backend/service/post.go index 89d0240..5cb813e 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -15,7 +15,7 @@ import ( var ( ErrPostNotFound = errors.New("帖子不存在") ErrPostForbidden = errors.New("无权限操作此帖子") - ErrPostDeleteMeta = errors.New("管理删除须填写类型与理由") + ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由") ErrPostInvalidType = errors.New("无效的删除类型") ) @@ -1083,16 +1083,27 @@ func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostO if post.UserID == userID { staffDeleted = false } else if actor != nil && actor.CanModerateBoard(post.BoardID) { + var author model.User + if err := s.db.Select("role").First(&author, post.UserID).Error; err != nil { + return false, authorID, ErrPostNotFound + } + if !actor.CanModerateAuthor(author.Role) { + return false, authorID, ErrAuthorProtected + } deleteType = strings.TrimSpace(opts.DeleteType) deleteReason = strings.TrimSpace(opts.DeleteReason) if !model.ValidPostDeleteType(deleteType) { return false, authorID, ErrPostInvalidType } - if deleteReason == "" { - return false, authorID, ErrPostDeleteMeta - } - if len([]rune(deleteReason)) > 200 { - return false, authorID, errors.New("删除理由不能超过 200 字") + if deleteType == model.PostDeleteTypeOther { + if deleteReason == "" { + return false, authorID, ErrPostDeleteMeta + } + if len([]rune(deleteReason)) > 200 { + return false, authorID, errors.New("删除理由不能超过 200 字") + } + } else { + deleteReason = "" } staffDeleted = true } else { diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index 3b72d4a..471eece 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -41,6 +41,7 @@ import { type AdminContentStatus, type PublicSettings, } from "@/lib/api"; +import { canModerateAuthor } from "@/lib/roles"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; @@ -290,6 +291,8 @@ export default function ContentAdmin({ initialCounts, initError, canConfigureSettings, + canPurge, + viewerRole, initialSettings, }: { initialKind: ContentKind; @@ -301,6 +304,8 @@ export default function ContentAdmin({ initialCounts: AdminContentCounts; initError: string; canConfigureSettings: boolean; + canPurge: boolean; + viewerRole: string; initialSettings: PublicSettings; }) { // 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见 @@ -663,46 +668,50 @@ export default function ContentAdmin({ ) : null} {p.deleted ? ( <> - - ) : ( - - ) - } - onClick={() => - runAction( - key, - () => apiAdminRestorePost(p.id), - "帖子已恢复", - () => reloadPosts() - ) - } - /> - } - onClick={() => - askConfirm( - "彻底删除帖子", - `「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`, - () => - apiAdminPurgePost(p.id).then(() => { - toast("帖子已彻底删除", "ok"); - reloadPosts(); - }), - true - ) - } - /> + {canModerateAuthor({ role: viewerRole }, p.user?.role) ? ( + + ) : ( + + ) + } + onClick={() => + runAction( + key, + () => apiAdminRestorePost(p.id), + "帖子已恢复", + () => reloadPosts() + ) + } + /> + ) : null} + {canPurge ? ( + } + onClick={() => + askConfirm( + "彻底删除帖子", + `「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`, + () => + apiAdminPurgePost(p.id).then(() => { + toast("帖子已彻底删除", "ok"); + reloadPosts(); + }), + true + ) + } + /> + ) : null} - ) : ( + ) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? ( } onClick={() => setStaffDeletePost(p)} /> - )} + ) : null} @@ -803,46 +812,50 @@ export default function ContentAdmin({ /> {c.deleted ? ( <> - - ) : ( - - ) - } - onClick={() => - runAction( - key, - () => apiAdminRestoreComment(c.id), - "评论已恢复", - () => reloadComments() - ) - } - /> - } - onClick={() => - askConfirm( - "彻底删除评论", - "该评论将永久删除且不可恢复。", - () => - apiAdminPurgeComment(c.id).then(() => { - toast("评论已彻底删除", "ok"); - reloadComments(); - }), - true - ) - } - /> + {canModerateAuthor({ role: viewerRole }, c.user?.role) ? ( + + ) : ( + + ) + } + onClick={() => + runAction( + key, + () => apiAdminRestoreComment(c.id), + "评论已恢复", + () => reloadComments() + ) + } + /> + ) : null} + {canPurge ? ( + } + onClick={() => + askConfirm( + "彻底删除评论", + "该评论将永久删除且不可恢复。", + () => + apiAdminPurgeComment(c.id).then(() => { + toast("评论已彻底删除", "ok"); + reloadComments(); + }), + true + ) + } + /> + ) : null} - ) : ( + ) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? ( } onClick={() => setStaffDeleteComment(c)} /> - )} + ) : null} diff --git a/frontend/app/admin/content/page.tsx b/frontend/app/admin/content/page.tsx index d16a92f..4b3d7b2 100644 --- a/frontend/app/admin/content/page.tsx +++ b/frontend/app/admin/content/page.tsx @@ -10,7 +10,7 @@ import { type AdminContentStatus, } from "@/lib/api"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; -import { canModerateAny, isSuperOrOwner } from "@/lib/roles"; +import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles"; import Forbidden from "../Forbidden"; import ContentAdmin, { type ContentKind } from "./ContentAdmin"; @@ -93,6 +93,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp initialCounts={counts} initError={initError} canConfigureSettings={isSuperOrOwner(me.user.role)} + canPurge={isOwner(me.user.role)} + viewerRole={me.user.role} initialSettings={settings} /> ); diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index 3bda987..4427599 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -4,7 +4,7 @@ import { cookies } from "next/headers"; import { ShieldAlert } from "lucide-react"; import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; -import { isStaff } from "@/lib/roles"; +import { canAccessAdminMessages, isStaff } from "@/lib/roles"; import AdminShell from "@/components/admin/AdminShell"; // 整个 /admin 树不进入索引(子页面无需重复声明) @@ -12,8 +12,8 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出, -// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。 +// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。 +// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。 export default async function AdminLayout({ children }: { children: React.ReactNode }) { const cookieStore = await cookies(); const cookie = authCookieHeader(cookieStore); @@ -21,7 +21,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN const settings = await getPublicSettingsCached(); const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light"; - if (!me.user || !isStaff(me.user.role)) { + const allowed = + !!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user)); + + if (!allowed || !me.user) { return (
("group"); const group = useMessageColumn("group"); const direct = useMessageColumn("direct"); @@ -485,37 +488,43 @@ export default function MessagesAdmin() { -
- - -
+ {canOversee ? ( +
+ + +
+ ) : null} -
+
{groupCol} - {directCol} + {canOversee ? directCol : null}
; + if (!me.user || !canAccessAdminMessages(me.user)) { + return ; } - return ; + return ; } diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 722e168..f316a31 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -21,7 +21,7 @@ import { } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import { getMeCached } from "@/lib/serverData"; -import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles"; +import { canModerateAny, isSuperOrOwner, isAdminOrAbove, ROLES } from "@/lib/roles"; import { AdminPageHeader, AdminStatCard } from "@/components/admin"; import DashboardClient from "./DashboardClient"; @@ -115,9 +115,12 @@ export default async function AdminDashboardPage() { const canMod = canModerateAny(me.user); const canUsers = isSuperOrOwner(me.user?.role); const canEconomy = isAdminOrAbove(me.user?.role); + const boardScoped = me.user?.role === ROLES.BOARD_ADMIN; const [overview, pendingCounts, analytics] = await Promise.all([ - fetchOverview(cookie).catch(() => null), + boardScoped + ? Promise.resolve(null) + : fetchOverview(cookie).catch(() => null), canMod ? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 })) : Promise.resolve(null), @@ -133,7 +136,15 @@ export default async function AdminDashboardPage() { }).format(now); const stats = overview?.stats; - const newUsers = overview?.new_users ?? []; + const newUsers = boardScoped ? [] : overview?.new_users ?? []; + + // 板管:今日内容用授权板 analytics 序列今日点;隐藏全站注册/在线卡的误导数字 + const todayPosts = boardScoped + ? analytics.posts_series[analytics.posts_series.length - 1]?.count ?? 0 + : stats?.today_posts ?? 0; + const todayComments = boardScoped + ? analytics.comments_series[analytics.comments_series.length - 1]?.count ?? 0 + : stats?.today_comments ?? 0; return (
@@ -155,30 +166,34 @@ export default async function AdminDashboardPage() { } /> } color="var(--ok)" soft="var(--ok-soft)" /> } color="var(--gold)" soft="var(--gold-soft)" /> } color="var(--clay)" soft="var(--clay-soft)" diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index 5921df1..3c38a5e 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -27,6 +27,8 @@ import { apiDissolveChatRoom, apiUpdateChatRoom, apiKickChatMember, + apiMuteChatMember, + apiSetChatMemberRole, apiInviteChatMembers, apiRecallChatMessage, type ChatMessage, @@ -44,7 +46,7 @@ import type { RtChatMembershipData, RtChatRecalledData, } from "@/lib/realtime"; -import { canOverseeChat } from "@/lib/roles"; +import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles"; import { toast } from "@/lib/toast"; import { scrollBehavior } from "@/lib/motion"; import Avatar from "@/components/Avatar"; @@ -171,7 +173,7 @@ interface Props { roomDescription: string; ownerId: number; memberCount: number; - myRole: "" | "owner" | "member" | "overseer"; + myRole: "" | "owner" | "admin" | "member" | "overseer"; joined: boolean; isPrivate: boolean; isDefault: boolean; @@ -179,6 +181,8 @@ interface Props { peer: { id: number; username: string; nickname: string; avatar: string } | null; meId: number; meRole: string; + /** 站点消息管理 flag */ + canManageMessages?: boolean; meName: string; meAvatar: string; initialMessages: ChatMessage[]; @@ -315,6 +319,7 @@ export default function ChatRoomClient({ peer, meId, meRole, + canManageMessages = false, initialMessages, initialHasMore, embedded = false, @@ -353,11 +358,16 @@ export default function ChatRoomClient({ const msgRefs = useRef>(new Map()); const isOwner = myRole === "owner"; + const isRoomAdmin = myRole === "admin"; const isDirect = roomType === "direct"; - const isRealMember = myRole === "owner" || myRole === "member"; + const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member"; const canView = joined; const oversee = canOverseeChat(meRole); - const canManageInvite = !isDirect && (isOwner || oversee); + const siteOwner = isSiteOwner(meRole); + const canModerateChat = + oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect); + const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee); + const canKickMembers = isOwner || isRoomAdmin || oversee; const rememberReEdit = useCallback((id: number, text: string) => { const t = text.trim(); @@ -472,7 +482,7 @@ export default function ChatRoomClient({ return { ...m, // 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示 - content: oversee ? m.content : "", + content: canModerateChat ? m.content : "", recalled_at: data.recalled_at || new Date().toISOString(), recalled_by: data.recalled_by, sender_id: data.sender_id || m.sender_id, @@ -490,7 +500,7 @@ export default function ChatRoomClient({ ); }); return off; - }, [roomId, oversee, meId, rememberReEdit]); + }, [roomId, canModerateChat, meId, rememberReEdit]); useEffect(() => { const off = realtime.on( @@ -820,6 +830,7 @@ export default function ChatRoomClient({ }; const handleKick = (uid: number, nickname: string) => { + if (!canKickMembers) return; setConfirm({ title: "移出成员", message: `确定将 ${nickname} 移出该群?`, @@ -834,9 +845,49 @@ export default function ChatRoomClient({ }); }; + const handleMute = (uid: number, nickname: string, muted: boolean) => { + if (!canKickMembers) return; + setConfirm({ + title: muted ? "禁言成员" : "解除禁言", + message: muted + ? `确定禁言 ${nickname}?禁言后无法在本群发言。` + : `确定解除 ${nickname} 的禁言?`, + confirmLabel: muted ? "禁言" : "解禁", + danger: muted, + action: async () => { + await apiMuteChatMember(roomId, uid, muted); + setMembers((prev) => + prev.map((m) => (m.user_id === uid ? { ...m, muted } : m)) + ); + toast(muted ? "已禁言" : "已解禁", "ok"); + }, + }); + }; + + const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => { + if (!siteOwner) return; + setConfirm({ + title: makeAdmin ? "设为群管理员" : "取消群管理员", + message: makeAdmin + ? `确定将 ${nickname} 设为本群管理员?其可撤回消息、踢人与禁言。` + : `确定取消 ${nickname} 的群管理员身份?`, + confirmLabel: makeAdmin ? "任命" : "撤销", + danger: !makeAdmin, + action: async () => { + await apiSetChatMemberRole(roomId, uid, makeAdmin ? "admin" : "member"); + setMembers((prev) => + prev.map((m) => + m.user_id === uid ? { ...m, role: makeAdmin ? "admin" : "member" } : m + ) + ); + toast(makeAdmin ? "已设为群管理员" : "已取消群管理员", "ok"); + }, + }); + }; + const canRecallMsg = (m: ChatMessage) => { if (m.recalled_at) return false; - if (oversee) return true; + if (canModerateChat) return true; return m.sender_id === meId && isRealMember; }; @@ -858,7 +909,7 @@ export default function ChatRoomClient({ m.id === mid ? { ...m, - content: oversee ? body || m.content : "", + content: canModerateChat ? body || m.content : "", recalled_at: message.recalled_at || new Date().toISOString(), recalled_by: message.recalled_by, recaller: message.recaller || m.recaller, @@ -1119,7 +1170,7 @@ export default function ChatRoomClient({ const reEditText = mine ? takeReEditDraft(m.id) : null; // 普通成员:居中系统提示(本人可重新编辑);不展示原文 - if (recalled && !(oversee && m.content)) { + if (recalled && !(canModerateChat && m.content)) { return (
)} {rightPanel === "invite" && ( @@ -1566,9 +1621,13 @@ export default function ChatRoomClient({ @@ -1634,16 +1693,24 @@ function JoinButton({ function MembersPanel({ members, meId, - isOwner, + canKick, + isChatOwner, + siteOwner, onRefresh, onKick, + onMute, + onSetAdmin, showTitle = false, }: { members: ChatRoomMember[]; meId: number; - isOwner: boolean; + canKick: boolean; + isChatOwner: boolean; + siteOwner: boolean; onRefresh: () => void; onKick: (uid: number, nickname: string) => void; + onMute: (uid: number, nickname: string, muted: boolean) => void; + onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void; showTitle?: boolean; }) { return ( @@ -1675,38 +1742,74 @@ function MembersPanel({
)}
    - {members.map((m) => ( -
  • - - { + const name = m.user.nickname || m.user.username; + const isTargetOwner = m.role === "owner"; + const isTargetAdmin = m.role === "admin"; + const canActOn = + canKick && + m.user_id !== meId && + !isTargetOwner && + (isChatOwner || !isTargetAdmin); + return ( +
  • - {m.user.nickname} - - {m.role === "owner" ? ( - - ) : null} - {isOwner && m.user_id !== meId && ( - - )} -
  • - ))} + {name} + {m.muted ? ( + + · 禁言 + + ) : null} + + {isTargetOwner ? ( + + ) : isTargetAdmin ? ( + + 管 + + ) : null} + {siteOwner && !isTargetOwner && m.user_id !== meId && ( + + )} + {canActOn && ( + <> + + + + )} + + ); + })}
); diff --git a/frontend/app/chat/[id]/page.tsx b/frontend/app/chat/[id]/page.tsx index 05e0b09..45904da 100644 --- a/frontend/app/chat/[id]/page.tsx +++ b/frontend/app/chat/[id]/page.tsx @@ -88,6 +88,7 @@ export default async function ChatRoomPage({ params }: PageProps) { peer={room.peer || null} meId={me.user.id} meRole={me.user.role} + canManageMessages={!!me.user.can_manage_messages} meName={me.user.nickname || me.user.username} meAvatar={me.user.avatar || ""} initialMessages={initialMessages.messages} diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 799307e..935d8ac 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -684,6 +684,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps !comment.deleted && !!user && - (user.id === comment.user?.id || canModerateBoard(user, boardId)); + (user.id === comment.user?.id || + canStaffSoftDelete(user, boardId, comment.user?.role)); const canEdit = (comment: CommentNode) => canSoftDelete(comment); const canViewHistory = !!user && canModerateBoard(user, boardId); const canPurge = (comment: CommentNode) => - !!user && canModerateBoard(user, boardId); + !!user && canPurgeContent(user.role) && canModerateBoard(user, boardId); - /** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */ + /** 作者可恢复自删;版主可恢复本板任意软删(含管理删除),板管受作者角色保护 */ const canRestore = (comment: CommentNode) => { if (!comment.deleted || !user) return false; - if (canModerateBoard(user, boardId)) return true; + if ( + canModerateBoard(user, boardId) && + canStaffSoftDelete(user, boardId, comment.user?.role) + ) { + return true; + } return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted; }; diff --git a/frontend/components/DeleteCommentModal.tsx b/frontend/components/DeleteCommentModal.tsx index 5828255..b24931c 100644 --- a/frontend/components/DeleteCommentModal.tsx +++ b/frontend/components/DeleteCommentModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import Modal from "@/components/Modal"; -import { POST_DELETE_TYPES } from "@/lib/api"; +import StaffDeleteFields from "@/components/StaffDeleteFields"; export type DeleteCommentPayload = { delete_type: string; @@ -12,7 +12,7 @@ export type DeleteCommentPayload = { /** * 评论软删弹窗: * - 评论者自删:必填理由(仅管理可见) - * - 管理删:必选类型;仅「其他」须填理由 + * - 管理删:与删帖共用类型表单;仅「其他」须填理由 */ export default function DeleteCommentModal({ open, @@ -89,55 +89,31 @@ export default function DeleteCommentModal({ } > {isStaff ? ( - <> -

- 删除类型 -

-
- {POST_DELETE_TYPES.map((t) => { - const active = deleteType === t.value; - return ( - - ); - })} -
-

- 选「其他」时需补充理由;其余类型直接按标签展示给读者。 -

- - ) : null} - - {reasonRequired ? ( + { + setDeleteType(value); + setErr(""); + if (value !== "other") setReason(""); + }} + reason={reason} + onReasonChange={(value) => { + setReason(value); + setErr(""); + }} + reasonId="cmt-delete-reason" + reasonPlaceholder="向读者说明删除原因(将展示在占位中)" + typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。" + reasonHint="将公开显示在评论占位旁。" + /> + ) : ( <>