feat: 实现完整的板块级RBAC内容审核系统

- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
This commit is contained in:
2026-09-15 04:47:34 +08:00
parent 6da4309453
commit a7b6421840
44 changed files with 3149 additions and 585 deletions

View File

@@ -1,10 +1,12 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -56,13 +58,20 @@ func (h *Handlers) PostDetail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.GetByID(uint(id))
claims := middleware.CurrentUser(c)
var viewerID uint
var loadActor func() *service.Actor
if claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID) // 懒加载:仅非已发布帖才查 DB
}
post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
// 填充点赞状态(仅登录用户)
if claims := middleware.CurrentUser(c); claims != nil {
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
c.JSON(http.StatusOK, gin.H{"post": post})
@@ -89,7 +98,13 @@ func (h *Handlers) CreatePost(c *gin.Context) {
if postType == "" {
postType = "normal"
}
post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType)
// 管理团队成员发帖免审直发;普通用户进入待审核队列。
// 角色变更会强制 JWT 失效(token_version 递增),claims.Role 可视为实时值
status := model.ContentStatusPending
if model.IsStaff(model.Role(claims.Role)) {
status = model.ContentStatusPublished
}
post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType, status)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -117,9 +132,10 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
post, err := h.Post.Update(uint(id), claims.ID, claims.Role, req.Title, req.Content, req.Tags)
actor := h.loadActor(claims.ID)
post, err := h.Post.Update(actor, uint(id), claims.ID, req.Title, req.Content, req.Tags)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondPostModError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
@@ -133,18 +149,16 @@ func (h *Handlers) DeletePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
if err := h.Post.Delete(uint(id), claims.ID, claims.Role); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
if err := h.Post.Delete(h.loadActor(claims.ID), uint(id), claims.ID); err != nil {
respondPostModError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// TogglePin 切换帖子置顶(仅管理员)
// TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限)
func (h *Handlers) TogglePin(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
if !h.requireAdminOrAbove(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -160,11 +174,9 @@ func (h *Handlers) TogglePin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"pinned": pinned})
}
// ToggleRecommend 切换帖子推荐(仅管理员)
// ToggleRecommend 切换帖子加精(管理员及以上,板块管理员无此权限)
func (h *Handlers) ToggleRecommend(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
if !h.requireAdminOrAbove(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -179,3 +191,25 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"recommended": recommended})
}
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false
func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool {
claims := middleware.CurrentUser(c)
if model.RoleLevel(model.Role(claims.Role)) < model.RoleLevel(model.RoleAdmin) {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
return false
}
return true
}
// respondPostModError 帖子编辑/删除业务错误 → HTTP 状态码
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):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}