- 新增站长/超级管理员/管理员/板块管理员四级角色体系 - 实现实时权限快照加载与细粒度权限校验 - 新增内容审核队列与前后端页面 - 重构用户权限管理与站点管理逻辑 - 新增评论/帖子审核状态与通知推送 - 优化前端权限控制与角色徽章展示 - 修正数据库迁移与默认值问题
418 lines
12 KiB
Go
418 lines
12 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 帖子操作错误
|
||
var (
|
||
ErrPostNotFound = errors.New("帖子不存在")
|
||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||
)
|
||
|
||
// PostService 帖子服务
|
||
type PostService struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewPostService(db *gorm.DB) *PostService {
|
||
return &PostService{db: db}
|
||
}
|
||
|
||
// PostListQuery 帖子列表查询参数
|
||
type PostListQuery struct {
|
||
BoardID uint
|
||
Page int
|
||
Size int
|
||
Sort string // latest | hot
|
||
Keyword string // 搜索关键词
|
||
Recommended bool // 仅精华帖
|
||
}
|
||
|
||
// toPostListItems 将 Post 模型批量转为不含正文的列表项
|
||
func toPostListItems(posts []model.Post) []PostListItem {
|
||
items := make([]PostListItem, 0, len(posts))
|
||
for _, p := range posts {
|
||
items = append(items, PostListItem{
|
||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||
Board: p.Board, User: p.User,
|
||
})
|
||
}
|
||
return items
|
||
}
|
||
|
||
// LastReplyUser 最后回复人(仅列表展示所需字段)
|
||
type LastReplyUser struct {
|
||
ID uint `json:"id"`
|
||
Username string `json:"username"`
|
||
Nickname string `json:"nickname"`
|
||
Avatar string `json:"avatar"`
|
||
}
|
||
|
||
// LastReplyInfo 帖子最后一条已发布评论的摘要
|
||
type LastReplyInfo struct {
|
||
User LastReplyUser `json:"user"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// PostListItem 帖子列表项(不含正文)
|
||
type PostListItem struct {
|
||
ID uint `json:"id"`
|
||
BoardID uint `json:"board_id"`
|
||
UserID uint `json:"user_id"`
|
||
Title string `json:"title"`
|
||
Tags string `json:"tags"`
|
||
PostType string `json:"post_type"`
|
||
Pinned int `json:"pinned"`
|
||
Recommended bool `json:"recommended"`
|
||
LikeCount int `json:"like_count"`
|
||
ViewCount int `json:"view_count"`
|
||
CommentCount int `json:"comment_count"`
|
||
Liked bool `json:"liked"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
|
||
Board model.Board `json:"board"`
|
||
User model.User `json:"user"`
|
||
}
|
||
|
||
// fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间),
|
||
// 单条 SQL 取每帖最新评论,再批量取用户,避免 N+1
|
||
func (s *PostService) fillLastReply(items []PostListItem) {
|
||
if len(items) == 0 {
|
||
return
|
||
}
|
||
postIDs := make([]uint, 0, len(items))
|
||
for _, it := range items {
|
||
postIDs = append(postIDs, it.ID)
|
||
}
|
||
|
||
type latestRow struct {
|
||
PostID uint
|
||
UserID uint
|
||
CreatedAt time.Time
|
||
}
|
||
var rows []latestRow
|
||
if err := s.db.Raw(`
|
||
SELECT DISTINCT ON (post_id) post_id, user_id, created_at
|
||
FROM comments
|
||
WHERE post_id IN ? AND status = 'published' AND deleted_at IS NULL
|
||
ORDER BY post_id, created_at DESC`, postIDs).Scan(&rows).Error; err != nil || len(rows) == 0 {
|
||
return
|
||
}
|
||
|
||
userIDs := make([]uint, 0, len(rows))
|
||
seen := map[uint]bool{}
|
||
for _, r := range rows {
|
||
if !seen[r.UserID] {
|
||
seen[r.UserID] = true
|
||
userIDs = append(userIDs, r.UserID)
|
||
}
|
||
}
|
||
var users []model.User
|
||
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", userIDs).Find(&users).Error; err != nil {
|
||
return
|
||
}
|
||
userMap := make(map[uint]model.User, len(users))
|
||
for _, u := range users {
|
||
userMap[u.ID] = u
|
||
}
|
||
|
||
replyMap := make(map[uint]latestRow, len(rows))
|
||
for _, r := range rows {
|
||
replyMap[r.PostID] = r
|
||
}
|
||
for i := range items {
|
||
r, ok := replyMap[items[i].ID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
u, ok := userMap[r.UserID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
items[i].LastReply = &LastReplyInfo{
|
||
User: LastReplyUser{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar},
|
||
CreatedAt: r.CreatedAt,
|
||
}
|
||
}
|
||
}
|
||
|
||
// List 获取帖子列表
|
||
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||
if q.Page < 1 {
|
||
q.Page = 1
|
||
}
|
||
if q.Size < 1 || q.Size > 50 {
|
||
q.Size = 20
|
||
}
|
||
|
||
query := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
|
||
if q.BoardID > 0 {
|
||
query = query.Where("board_id = ?", q.BoardID)
|
||
}
|
||
if q.Keyword != "" {
|
||
kw := "%" + q.Keyword + "%"
|
||
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
|
||
}
|
||
if q.Recommended {
|
||
query = query.Where("recommended = ?", true)
|
||
}
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
var posts []model.Post
|
||
offset := (q.Page - 1) * q.Size
|
||
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
|
||
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
|
||
order := `pinned DESC, 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 == "hot" {
|
||
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
||
}
|
||
// new:纯按发帖时间(新帖子)
|
||
if q.Sort == "new" {
|
||
order = "pinned DESC, created_at DESC"
|
||
}
|
||
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
items := toPostListItems(posts)
|
||
s.fillLastReply(items)
|
||
return items, total, nil
|
||
}
|
||
|
||
// ListByUser 获取指定用户发布的帖子(公开已发布)
|
||
func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, int64, error) {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 || size > 50 {
|
||
size = 20
|
||
}
|
||
query := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished)
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
var posts []model.Post
|
||
offset := (page - 1) * size
|
||
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items := toPostListItems(posts)
|
||
s.fillLastReply(items)
|
||
return items, total, nil
|
||
}
|
||
|
||
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
||
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
|
||
var post model.Post
|
||
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return post.UserID, nil
|
||
}
|
||
|
||
// CountByUser 统计用户发帖数
|
||
func (s *PostService) CountByUser(userID uint) (int64, error) {
|
||
var total int64
|
||
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
|
||
return total, err
|
||
}
|
||
|
||
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
|
||
func (s *PostService) TogglePin(id uint) (int, error) {
|
||
var post model.Post
|
||
if err := s.db.First(&post, id).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
var newPinned int
|
||
if post.Pinned > 0 {
|
||
newPinned = 0
|
||
} else {
|
||
newPinned = 1
|
||
}
|
||
result := s.db.Model(&post).Update("pinned", newPinned)
|
||
if result.Error != nil {
|
||
return 0, result.Error
|
||
}
|
||
return newPinned, nil
|
||
}
|
||
|
||
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
|
||
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
||
var post model.Post
|
||
if err := s.db.First(&post, id).Error; err != nil {
|
||
return false, err
|
||
}
|
||
newVal := !post.Recommended
|
||
result := s.db.Model(&post).Update("recommended", newVal)
|
||
if result.Error != nil {
|
||
return false, result.Error
|
||
}
|
||
return newVal, nil
|
||
}
|
||
|
||
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
|
||
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
|
||
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
|
||
if post.Status == model.ContentStatusPublished {
|
||
return true
|
||
}
|
||
if viewerID > 0 && post.UserID == viewerID {
|
||
return true
|
||
}
|
||
if loadActor == nil {
|
||
return false
|
||
}
|
||
return loadActor().CanModerateBoard(post.BoardID)
|
||
}
|
||
|
||
// GetByIDForViewer 获取帖子详情(带可见性校验);通过校验才计入浏览量。
|
||
// viewerID 为当前登录用户(未登录传 0),loadActor 可传 nil
|
||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*model.Post, error) {
|
||
var post model.Post
|
||
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
if !visibleToPost(&post, viewerID, loadActor) {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
// 增加浏览量(待审/被拒内容不计)
|
||
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||
return &post, nil
|
||
}
|
||
|
||
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
|
||
func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error {
|
||
var post model.Post
|
||
if err := s.db.Select("id", "user_id", "board_id", "status").First(&post, postID).Error; err != nil {
|
||
return ErrPostNotFound
|
||
}
|
||
if !visibleToPost(&post, viewerID, loadActor) {
|
||
return ErrPostNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Create 创建帖子。status 由 handler 按角色计算:
|
||
// 管理团队成员直发 published,普通用户进入 pending 等待审核
|
||
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType, status string) (*model.Post, error) {
|
||
title = strings.TrimSpace(title)
|
||
content = strings.TrimSpace(content)
|
||
if title == "" {
|
||
return nil, errors.New("标题不能为空")
|
||
}
|
||
if content == "" {
|
||
return nil, errors.New("内容不能为空")
|
||
}
|
||
if boardID == 0 {
|
||
return nil, errors.New("请选择板块")
|
||
}
|
||
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
|
||
status = model.ContentStatusPending
|
||
}
|
||
|
||
// 新用户 24h 冷静期校验
|
||
if err := s.checkNewUserCooldown(userID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
post := &model.Post{
|
||
BoardID: boardID,
|
||
UserID: userID,
|
||
Title: title,
|
||
Content: content,
|
||
Tags: tags,
|
||
PostType: postType,
|
||
Status: status,
|
||
}
|
||
if err := s.db.Create(post).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
// 预加载关联
|
||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||
return post, nil
|
||
}
|
||
|
||
// checkNewUserCooldown 新用户发帖 24h 冷静期
|
||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||
var user model.User
|
||
if err := s.db.First(&user, userID).Error; err != nil {
|
||
return err
|
||
}
|
||
// 注册不足 24 小时的新用户不能发帖
|
||
if time.Since(user.CreatedAt) < 24*time.Hour {
|
||
return errors.New("新用户注册 24 小时后才能发帖")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||
func (s *PostService) Update(actor *Actor, postID, userID uint, title, content, tags string) (*model.Post, error) {
|
||
var post model.Post
|
||
if err := s.db.First(&post, postID).Error; err != nil {
|
||
return nil, ErrPostNotFound
|
||
}
|
||
// 权限校验:作者本人或板块审核权
|
||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||
return nil, ErrPostForbidden
|
||
}
|
||
|
||
updates := map[string]interface{}{}
|
||
if title != "" {
|
||
t := strings.TrimSpace(title)
|
||
if t == "" {
|
||
return nil, errors.New("标题不能为空")
|
||
}
|
||
updates["title"] = t
|
||
}
|
||
if content != "" {
|
||
c := strings.TrimSpace(content)
|
||
if c == "" {
|
||
return nil, errors.New("内容不能为空")
|
||
}
|
||
updates["content"] = c
|
||
}
|
||
updates["tags"] = tags
|
||
|
||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||
return &post, nil
|
||
}
|
||
|
||
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
|
||
func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
|
||
var post model.Post
|
||
if err := s.db.First(&post, postID).Error; err != nil {
|
||
return ErrPostNotFound
|
||
}
|
||
// 权限校验:作者本人或板块审核权
|
||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||
return ErrPostForbidden
|
||
}
|
||
// 软删除(gorm DeletedAt)
|
||
if err := s.db.Delete(&post).Error; err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|