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 ? (
<>
-
- 删除类型 -
-- 选「其他」时需补充理由;其余类型直接按标签展示给读者。 -
- > - ) : null} - - {reasonRequired ? ( +- {isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"} -
+普通读者看不到这条理由。
> - ) : null} + )} {err ? (
diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx
index 2bc301a..46fbfd8 100644
--- a/frontend/components/Header.tsx
+++ b/frontend/components/Header.tsx
@@ -21,7 +21,7 @@ import {
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
-import { isStaff } from "@/lib/roles";
+import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
@@ -396,7 +396,7 @@ export default function Header({
)}
{user &&
+ 消息管理 +
+ +账号状态 diff --git a/frontend/components/StaffDeleteFields.tsx b/frontend/components/StaffDeleteFields.tsx new file mode 100644 index 0000000..2cb3a7d --- /dev/null +++ b/frontend/components/StaffDeleteFields.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { POST_DELETE_TYPES } from "@/lib/api"; + +/** 管理软删共用:类型单选 + 仅「其他」时的补充理由 */ +export default function StaffDeleteFields({ + deleteType, + onTypeChange, + reason, + onReasonChange, + reasonId, + reasonPlaceholder, + typeHint, + reasonHint, +}: { + deleteType: string; + onTypeChange: (value: string) => void; + reason: string; + onReasonChange: (value: string) => void; + reasonId: string; + reasonPlaceholder: string; + typeHint: string; + reasonHint: string; +}) { + const reasonRequired = deleteType === "other"; + + return ( + <> +
+ 删除类型 +
+{typeHint}
+ {reasonRequired ? ( + <> + +