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 | new Keyword string // 搜索关键词 Recommended bool // 仅精华帖 ViewerID uint // 当前查看者(0=游客) Actor *Actor // 查看者权限快照;nil 仅 published } // 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, Status: p.Status, 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"` Status string `json:"status"` 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, } } } // applyListVisibility 公开流可见性:published,或 pending 且(作者本人 / 该板可审者) func applyListVisibility(db *gorm.DB, viewerID uint, actor *Actor) *gorm.DB { if viewerID == 0 || actor == nil { return db.Where("status = ?", model.ContentStatusPublished) } if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) { return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) } if actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 { return db.Where( "status = ? OR (status = ? AND (user_id = ? OR board_id IN ?))", model.ContentStatusPublished, model.ContentStatusPending, viewerID, actor.BoardIDs, ) } return db.Where( "status = ? OR (status = ? AND user_id = ?)", model.ContentStatusPublished, model.ContentStatusPending, viewerID, ) } // 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 := applyListVisibility(s.db.Model(&model.Post{}), q.ViewerID, q.Actor) 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 获取指定用户发布的帖子。 // viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。 func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, 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 = ?", userID) if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) { query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) } else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 { query = query.Where( "status = ? OR (status = ? AND board_id IN ?)", model.ContentStatusPublished, model.ContentStatusPending, actor.BoardIDs, ) } else { query = query.Where("status = ?", 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 } // GetBoardID 获取帖子所属板块 ID(不增加浏览量) func (s *PostService) GetBoardID(postID uint) (uint, error) { var post model.Post if err := s.db.Select("board_id").First(&post, postID).Error; err != nil { return 0, err } return post.BoardID, 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 }