feat: 帖子分级置顶(板块/全站)与板块管理员置顶权限、消息管理权限调整

This commit is contained in:
2026-10-15 23:35:30 +08:00
parent e7b0686947
commit 47f87a011c
40 changed files with 822 additions and 207 deletions

View File

@@ -18,7 +18,7 @@ const (
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
PermSettings = "settings" // 站点外观设置(超管/站长)
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
PermMessages = "messages" // 后台消息管理(站长固有;超管及其他角色需站点消息 flag;群管另有业务层放行)
PermBoards = "boards" // 板块管理(仅站长)
)
@@ -105,7 +105,7 @@ func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages
for _, p := range ParsePermOverrides(permOverridesRaw) {
set[p] = true
}
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
if role == model.RoleOwner || canManageMessages {
set[PermMessages] = true
}
out := make([]string, 0, len(set))
@@ -123,7 +123,7 @@ type Actor struct {
Username string
Role model.Role
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
CanManageMessages bool // 站点级消息管理 flag(仅站长可授,超管亦需此授予);站长固有
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
}
@@ -151,15 +151,22 @@ func (a *Actor) HasPerm(p string) bool {
return false
}
// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages
// 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.Role == model.RoleOwner || a.CanManageMessages
}
// CanOverseeChat 全站消息监管(可见全部群含私聊、撤回任意消息、旁路订阅任意房间):
// 站长固有;超管需先被授予站点消息管理
func (a *Actor) CanOverseeChat() bool {
if a == nil {
return false
}
return a.CanManageMessages
return a.Role == model.RoleOwner ||
(a.Role == model.RoleSuperAdmin && a.CanManageMessages)
}
// CanPurgeContent 彻底删除帖/评:仅站长

View File

@@ -13,6 +13,7 @@ import (
var (
ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
ErrContentNotFound = errors.New("内容不存在")
ErrPinOrderForbidden = errors.New("无权配置该层级置顶顺序")
)
func (s *ModerationService) authorRole(userID uint) (model.Role, error) {
@@ -511,3 +512,105 @@ func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
return tx.Unscoped().Delete(&model.Comment{}, id).Error
})
}
// ===== 置顶顺序配置(后台可拖拽排序,同层级 pin_order 越小越靠前) =====
// PinnedPostItem 置顶帖排序列表项
type PinnedPostItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Pinned int `json:"pinned"`
PinOrder int `json:"pin_order"`
BoardID uint `json:"board_id"`
BoardName string `json:"board_name"`
CreatedAt time.Time `json:"created_at"`
}
// assertPinScopePermission 校验 actor 对指定置顶层级/板块的排序权限:
// 全局置顶仅管理员及以上;板块置顶允许管理员或管辖该板块的板管(board_id 必填)。
func (s *ModerationService) assertPinScopePermission(actor *Actor, level int, boardID uint) error {
if actor == nil {
return ErrPinOrderForbidden
}
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
return nil
}
if level == model.PinGlobal {
return ErrPinOrderForbidden
}
if level == model.PinBoard {
if boardID == 0 {
return ErrPinOrderForbidden // 板管必须指定具体板块
}
if actor.CanModerateBoard(boardID) {
return nil
}
}
return ErrPinOrderForbidden
}
// ListPinnedPosts 列出指定层级的置顶帖(当前顺序:pin_order 升序,未配置按发帖时间倒序兜底)。
// level=2 全站置顶;level=1 需带 board_id(管理员可省=全部板块的板块置顶)。
func (s *ModerationService) ListPinnedPosts(actor *Actor, level int, boardID uint) ([]PinnedPostItem, error) {
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
return nil, err
}
q := s.db.Model(&model.Post{}).
Where("pinned = ? AND deleted_at IS NULL AND status = ?", level, model.ContentStatusPublished)
if boardID > 0 {
q = q.Where("board_id = ?", boardID)
}
var posts []model.Post
if err := q.Order("(pin_order = 0), pin_order ASC, created_at DESC").
Preload("Board").Find(&posts).Error; err != nil {
return nil, err
}
items := make([]PinnedPostItem, 0, len(posts))
for _, p := range posts {
items = append(items, PinnedPostItem{
ID: p.ID,
Title: p.Title,
Pinned: p.Pinned,
PinOrder: p.PinOrder,
BoardID: p.BoardID,
BoardName: p.Board.Name,
CreatedAt: p.CreatedAt,
})
}
return items, nil
}
// SavePinOrder 按传入 ID 顺序重写 pin_order(从 1 递增)。
// 安全约束:ids 必须全部属于该层级且在权限范围内,否则整体拒绝,避免半截写入。
func (s *ModerationService) SavePinOrder(actor *Actor, level int, boardID uint, ids []uint) error {
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
return err
}
if len(ids) == 0 {
return nil
}
if len(ids) > 200 {
return errors.New("单次最多排序 200 条")
}
var count int64
q := s.db.Model(&model.Post{}).
Where("id IN ? AND pinned = ? AND deleted_at IS NULL", ids, level)
if boardID > 0 {
q = q.Where("board_id = ?", boardID)
}
if err := q.Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return ErrPinOrderForbidden // 含越权/已取消置顶/已删除的 ID
}
return s.db.Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.Post{}).Where("id = ?", id).
UpdateColumn("pin_order", i+1).Error; err != nil {
return err
}
}
return nil
})
}

View File

@@ -470,7 +470,7 @@ func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perm
return s.getItem(s.db, targetID)
}
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
// SetCanManageMessages 站长授予/撤销站点消息管理权限(超管亦需授予;不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner {
return nil, ErrOwnerOnly
@@ -486,10 +486,6 @@ func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint,
if u.Role == model.RoleOwner {
return ErrProtectedOwner
}
// 超管固有全站监管,flag 无意义;强制保持 false 以免误导
if u.Role == model.RoleSuperAdmin {
enabled = false
}
if u.CanManageMessages == enabled {
return nil
}

View File

@@ -42,11 +42,6 @@ var (
ErrChatAdminAccess = errors.New("无权管理消息")
)
// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息
func CanOverseeChat(role model.Role) bool {
return role == model.RoleOwner || role == model.RoleSuperAdmin
}
const (
chatNameMinRune = 2
chatNameMaxRune = 32
@@ -929,7 +924,7 @@ func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool {
return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin
}
// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee)
// hasSiteGroupMessagePerm 站点消息管理 flag(不含站长,超管经 oversee 已放行;调用方通常已判 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 {
@@ -1219,7 +1214,7 @@ type AdminChatMessageItem struct {
}
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
// 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
@@ -1227,7 +1222,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp
if page < 1 {
page = 1
}
fullOversee := CanOverseeChat(actor.Role)
fullOversee := actor.CanOverseeChat()
siteGroup := actor.HasSiteMessagePerm() && !fullOversee
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
return nil, 0, errors.New("房间类型无效")

View File

@@ -226,6 +226,14 @@ type legacyPostRow struct {
func (legacyPostRow) TableName() string { return "posts" }
// legacyPinLevel 旧库 pinned(0/1,非零=全站置顶)映射到新置顶分级:板块置顶为本站新能力,旧数据无此语义
func legacyPinLevel(v int) int {
if v > 0 {
return model.PinGlobal
}
return model.PinNone
}
type legacyCommentRow struct {
ID uint `gorm:"primaryKey"`
PostID uint `gorm:"column:post_id"`
@@ -757,7 +765,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
Content: content,
Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned,
Pinned: legacyPinLevel(p.Pinned),
Recommended: p.Featured,
Status: model.ContentStatusPublished,
LikeCount: p.LikeCount,

View File

@@ -368,7 +368,7 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
return e
}
origin, originErr := url.Parse(o.cfg.SiteURL)
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) {
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && o.cfg.IsSecureOrigin()) {
return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件")
}
}
@@ -430,8 +430,8 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
}
if c.Backend == "s3" {
u, e := url.Parse(c.Endpoint)
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) {
return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)")
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && o.cfg.IsSecureOrigin()) {
return bad("endpoint", "须为 HTTPS origin(HTTP origin 部署可放宽)")
}
if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
return bad("bucket", "桶名称无效")

View File

@@ -19,6 +19,8 @@ var (
ErrPostInvalidType = errors.New("无效的删除类型")
ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
ErrPinGlobalOnly = errors.New("全站置顶仅管理员可操作")
ErrPinInvalidLevel = errors.New("无效的置顶级别")
)
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
@@ -247,18 +249,25 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
var posts []model.Post
offset := (q.Page - 1) * q.Size
// 置顶排序前缀按范围区分:板块视图里全站置顶(2)在板块置顶(1)之上;
// 全站信息流只让全站置顶(2)浮顶,板块置顶(1)不影响首页与跨板列表。
// 同层级置顶帖之间按后台配置的 pin_order 升序(pin_order=0 视为未配置,排在已配置的后面,再按活跃序兜底)。
pinFirst := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC"
if q.BoardID > 0 {
pinFirst = "pinned DESC, (pin_order = 0), pin_order ASC"
}
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
order := `pinned DESC, GREATEST(created_at, COALESCE((
order := pinFirst + `, GREATEST(created_at, COALESCE((
SELECT MAX(c.created_at) FROM comments c
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
), created_at)) DESC`
if q.Sort == "recommended" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
order = pinFirst + ", (like_count + comment_count * 2) DESC, created_at DESC"
}
// new:纯按发帖时间(新帖子)
if q.Sort == "new" {
order = "pinned DESC, created_at DESC"
order = pinFirst + ", created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
@@ -311,7 +320,7 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size
}
var posts []model.Post
offset := (page - 1) * size
order := "pinned DESC, created_at DESC"
order := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC, created_at DESC"
if sort == "hot" {
order = "like_count + comment_count DESC, view_count DESC, created_at DESC"
}
@@ -349,24 +358,36 @@ func (s *PostService) CountByUser(userID uint) (int64, error) {
return total, err
}
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) TogglePin(id uint) (int, error) {
// TogglePin 设置置顶级别:level 0=取消置顶,1=板块置顶,2=全站置顶。
// 板块管理员只能操作自己授权板块且只能设置/取消板块置顶;管理员可设置任意级别。
func (s *PostService) TogglePin(id uint, actor *Actor, level int) (int, error) {
if level < model.PinNone || level > model.PinGlobal {
return 0, ErrPinInvalidLevel
}
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
if err := s.db.Select("id", "board_id", "pinned").First(&post, id).Error; err != nil {
return 0, err
}
var newPinned int
if post.Pinned > 0 {
newPinned = 0
} else {
newPinned = 1
isGlobalAdmin := actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)
isBoardModerator := actor != nil && actor.CanModerateBoard(post.BoardID)
// 权限校验
if level == model.PinGlobal && !isGlobalAdmin {
return 0, ErrPinGlobalOnly
}
if level > model.PinNone && !isGlobalAdmin && !isBoardModerator {
return 0, ErrPostForbidden
}
// 板块管理员不能取消全局置顶(只能取消自己设置的板块置顶)
if !isGlobalAdmin && level == model.PinNone && post.Pinned == model.PinGlobal {
return 0, ErrPinGlobalOnly
}
// UpdateColumn:置顶非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
if err := s.db.Model(&post).UpdateColumn("pinned", level).Error; err != nil {
return 0, err
}
return newPinned, nil
return level, nil
}
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。