完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 14:30:08 +08:00
parent 1f2e4b2a33
commit c0462a3500
37 changed files with 1340 additions and 472 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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