package service import ( "errors" "strings" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // BoardService 板块服务 type BoardService struct { db *gorm.DB } func NewBoardService(db *gorm.DB) *BoardService { return &BoardService{db: db} } // BoardWithCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平) type BoardWithCount struct { model.Board PostCount int64 `json:"post_count"` } // BoardModerator 板块管理员公开资料 type BoardModerator struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` } // BoardSidebarStats 板级统计(不含在线) type BoardSidebarStats struct { PostCount int64 `json:"post_count"` TodayPosts int64 `json:"today_posts"` CommentCount int64 `json:"comment_count"` TodayComments int64 `json:"today_comments"` } // BoardSidebarData 首页选中板块后的右栏聚合 type BoardSidebarData struct { Board BoardWithCount `json:"board"` Moderators []BoardModerator `json:"moderators"` Stats BoardSidebarStats `json:"stats"` Hot []PostListItem `json:"hot"` ActiveUsers []ActiveUser `json:"active_users"` Checkin *CheckinStatus `json:"checkin,omitempty"` } // List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用) func (s *BoardService) List() ([]BoardWithCount, error) { var boards []model.Board if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil { return nil, err } type countRow struct { BoardID uint Cnt int64 } var counts []countRow if err := s.db.Model(&model.Post{}). Select("board_id, COUNT(*) AS cnt"). Where("status = ?", model.ContentStatusPublished). Group("board_id").Scan(&counts).Error; err != nil { return nil, err } countMap := make(map[uint]int64, len(counts)) for _, c := range counts { countMap[c.BoardID] = c.Cnt } items := make([]BoardWithCount, 0, len(boards)) for _, b := range boards { items = append(items, BoardWithCount{Board: b, PostCount: countMap[b.ID]}) } return items, nil } // Get 根据 ID 获取板块 func (s *BoardService) Get(id uint) (*model.Board, error) { var board model.Board if err := s.db.First(&board, id).Error; err != nil { return nil, err } return &board, nil } // ===== 板块管理(仅站长;handler 通过 PermBoards 兜底) ===== // BoardInput 板块新建/更新入参;service 层做归一与长度约束 type BoardInput struct { Name string `json:"name"` Description string `json:"description"` Icon string `json:"icon"` ColorIndex *int `json:"color_index"` SortOrder *int `json:"sort_order"` PostPolicy string `json:"post_policy"` Visible *bool `json:"visible"` } // 板块错误(handler 映射为 4xx) var ( ErrBoardNameRequired = errors.New("板块名称不能为空") ErrBoardInUse = errors.New("该板块下仍有帖子,无法删除") ) const ( boardNameMaxRunes = 64 boardDescMaxRunes = 512 boardIconMaxRunes = 64 ) // normalizeBoardInput 归一化并校验入参;ColorIndex/SortOrder/Visible 为 nil 时保持原值 func normalizeBoardInput(in *BoardInput) error { in.Name = strings.TrimSpace(in.Name) in.Description = strings.TrimSpace(in.Description) in.Icon = strings.TrimSpace(in.Icon) in.PostPolicy = model.NormalizeBoardPostPolicy(in.PostPolicy) if in.Name == "" { return ErrBoardNameRequired } if len([]rune(in.Name)) > boardNameMaxRunes { in.Name = string([]rune(in.Name)[:boardNameMaxRunes]) } if len([]rune(in.Description)) > boardDescMaxRunes { in.Description = string([]rune(in.Description)[:boardDescMaxRunes]) } if len([]rune(in.Icon)) > boardIconMaxRunes { in.Icon = string([]rune(in.Icon)[:boardIconMaxRunes]) } return nil } // applyInput 把入参写入 board(用于 Create 与 Update;nil 字段保留旧值) func applyInput(b *model.Board, in *BoardInput) { b.Name = in.Name b.Description = in.Description b.Icon = in.Icon b.PostPolicy = in.PostPolicy if in.ColorIndex != nil { b.ColorIndex = *in.ColorIndex } if in.SortOrder != nil { b.SortOrder = *in.SortOrder } if in.Visible != nil { b.Visible = *in.Visible } } // Create 创建板块 func (s *BoardService) Create(in BoardInput) (*model.Board, error) { if err := normalizeBoardInput(&in); err != nil { return nil, err } b := model.Board{ Name: in.Name, Description: in.Description, Icon: in.Icon, ColorIndex: -1, SortOrder: 0, PostPolicy: in.PostPolicy, Visible: true, } applyInput(&b, &in) if err := s.db.Create(&b).Error; err != nil { return nil, err } return &b, nil } // Update 更新板块;不存在返回 gorm.ErrRecordNotFound func (s *BoardService) Update(id uint, in BoardInput) (*model.Board, error) { if err := normalizeBoardInput(&in); err != nil { return nil, err } var b model.Board if err := s.db.First(&b, id).Error; err != nil { return nil, err } applyInput(&b, &in) if err := s.db.Save(&b).Error; err != nil { return nil, err } return &b, nil } // Delete 软删板块;若仍有未删除帖子则拒绝,避免内容悬空 func (s *BoardService) Delete(id uint) error { var cnt int64 if err := s.db.Model(&model.Post{}). Where("board_id = ? AND deleted_at IS NULL", id). Count(&cnt).Error; err != nil { return err } if cnt > 0 { return ErrBoardInUse } return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("board_id = ?", id).Delete(&model.UserBoard{}).Error; err != nil { return err } return tx.Delete(&model.Board{}, id).Error }) } // Reorder 批量设置 sort_order;ids 顺序即展示顺序 func (s *BoardService) Reorder(ids []uint) error { if len(ids) == 0 { return nil } return s.db.Transaction(func(tx *gorm.DB) error { for i, id := range ids { if err := tx.Model(&model.Board{}).Where("id = ?", id). Update("sort_order", i).Error; err != nil { return err } } return nil }) } // BoardModeratorMember 板块版主列表项(id/username/nickname/avatar/role 透出) type BoardModeratorMember struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` Email string `json:"email"` Banned bool `json:"banned"` } // ListModerators 列出该板块的版主(role=board_admin 且授权本板) func (s *BoardService) ListModerators(boardID uint) ([]BoardModeratorMember, error) { var mods []model.User if err := s.db.Table("users"). Select("users.id, users.username, users.nickname, users.avatar, users.email, users.banned"). Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID). Where("users.role = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin). Order("users.id ASC"). Find(&mods).Error; err != nil { return nil, err } out := make([]BoardModeratorMember, 0, len(mods)) for _, u := range mods { out = append(out, BoardModeratorMember{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, Email: u.Email, Banned: u.Banned, }) } return out, nil } // AddModerator 将用户设为该板块版主: // - 目标用户必须存在且非站长;站长身份不可被授予 // - 若其当前非 board_admin,则升级为 board_admin 并授权本板(强制下线) // - 若已是 board_admin,仅追加本板授权(不动角色与登录态) // 操作者权限由 handler 通过 PermBoards 兜底(仅站长可调用) func (s *BoardService) AddModerator(boardID, userID uint) error { var board model.Board if err := s.db.First(&board, boardID).Error; err != nil { return err } return s.db.Transaction(func(tx *gorm.DB) error { var u model.User if err := tx.First(&u, userID).Error; err != nil { return err } if u.Role == model.RoleOwner { return ErrProtectedOwner } // 已授权则幂等返回 var cnt int64 if err := tx.Model(&model.UserBoard{}). Where("user_id = ? AND board_id = ?", userID, boardID).Count(&cnt).Error; err != nil { return err } if cnt > 0 { return nil } // 角色升级:非 board_admin 升为 board_admin 并强制下线 roleChanged := u.Role != model.RoleBoardAdmin if roleChanged { if err := tx.Model(&u).Update("role", model.RoleBoardAdmin).Error; err != nil { return err } if err := invalidateUserSessions(tx, userID); err != nil { return err } } return tx.Create(&model.UserBoard{UserID: userID, BoardID: boardID}).Error }) } // RemoveModerator 取消该用户在本板的版主授权;若无其他板块授权则降级为普通用户 func (s *BoardService) RemoveModerator(boardID, userID uint) error { return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("user_id = ? AND board_id = ?", userID, boardID). Delete(&model.UserBoard{}).Error; err != nil { return err } var rest int64 if err := tx.Model(&model.UserBoard{}). Where("user_id = ?", userID).Count(&rest).Error; err != nil { return err } if rest == 0 { var u model.User if err := tx.Select("id, role").First(&u, userID).Error; err != nil { return err } if u.Role == model.RoleBoardAdmin { if err := tx.Model(&u).Update("role", model.RoleUser).Error; err != nil { return err } if err := invalidateUserSessions(tx, userID); err != nil { return err } } } return nil }) } // viewerPostInfo 发帖/可见性判定所需的访问者信息 type viewerPostInfo struct { Role model.Role BoardIDs []uint } // loadViewerPostInfo 读取用户角色与其被授权的板块(用于发帖策略与可见性判定) func (s *BoardService) loadViewerPostInfo(userID uint) (*viewerPostInfo, error) { var u model.User if err := s.db.Select("id, role").First(&u, userID).Error; err != nil { return nil, err } info := &viewerPostInfo{Role: u.Role, BoardIDs: []uint{}} if u.Role == model.RoleBoardAdmin { if err := s.db.Model(&model.UserBoard{}). Where("user_id = ?", userID).Pluck("board_id", &info.BoardIDs).Error; err != nil { return nil, err } } return info, nil } // CanPostToBoard 判定 userID 是否可在 board 发帖。 // policy=staff 时:仅 admin 及以上,或管辖该板的 board_admin 允许;否则拒绝。 func (s *BoardService) CanPostToBoard(userID uint, board *model.Board) (bool, error) { if board == nil || board.PostPolicy != model.BoardPostPolicyStaff { return true, nil } info, err := s.loadViewerPostInfo(userID) if err != nil { return false, err } if model.RoleLevel(info.Role) >= model.RoleLevel(model.RoleAdmin) { return true, nil } if info.Role == model.RoleBoardAdmin { for _, id := range info.BoardIDs { if id == board.ID { return true, nil } } } return false, nil } // CanViewBoardEntry 判定访问者是否可见该板块入口(列表/导航)。 // visible=false 的板块仅 staff 可见;普通用户不可见。 func (s *BoardService) CanViewBoardEntry(userID uint, board *model.Board) (bool, error) { if board == nil || board.Visible { return true, nil } if userID == 0 { return false, nil } info, err := s.loadViewerPostInfo(userID) if err != nil { return false, err } return model.IsStaff(info.Role), nil } // Sidebar 首页板块右栏:管理员 + 概况统计 + 本板热门/活跃 func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) { board, err := s.Get(boardID) if err != nil { return nil, err // 含 gorm.ErrRecordNotFound } data := &BoardSidebarData{ Board: BoardWithCount{Board: *board}, Moderators: []BoardModerator{}, Hot: []PostListItem{}, ActiveUsers: []ActiveUser{}, } now := time.Now() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) weekStart := startOfWeek(now) // 帖子总数 / 今日新帖 if err := s.db.Model(&model.Post{}). Where("board_id = ? AND status = ?", boardID, model.ContentStatusPublished). Count(&data.Stats.PostCount).Error; err != nil { return nil, err } data.Board.PostCount = data.Stats.PostCount if err := s.db.Model(&model.Post{}). Where("board_id = ? AND status = ? AND created_at >= ?", boardID, model.ContentStatusPublished, dayStart). Count(&data.Stats.TodayPosts).Error; err != nil { return nil, err } // 评论总数 / 今日评论(经 posts.board_id) if err := s.db.Model(&model.Comment{}). Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL"). Where("posts.board_id = ? AND posts.status = ? AND comments.status = ?", boardID, model.ContentStatusPublished, model.ContentStatusPublished). Count(&data.Stats.CommentCount).Error; err != nil { return nil, err } if err := s.db.Model(&model.Comment{}). Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL"). Where("posts.board_id = ? AND posts.status = ? AND comments.status = ? AND comments.created_at >= ?", boardID, model.ContentStatusPublished, model.ContentStatusPublished, dayStart). Count(&data.Stats.TodayComments).Error; err != nil { return nil, err } // 板块管理员:仅 board_admin 且授权本板、未封禁 var mods []model.User if err := s.db.Table("users"). Select("users.id, users.username, users.nickname, users.avatar"). Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID). Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false). Order("users.id ASC"). Find(&mods).Error; err != nil { return nil, err } for _, u := range mods { data.Moderators = append(data.Moderators, BoardModerator{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, }) } // 本板热门 var hotPosts []model.Post if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished). Order("(like_count + comment_count * 2) DESC, created_at DESC"). Limit(5).Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&hotPosts).Error; err != nil { return nil, err } data.Hot = toPostListItems(hotPosts) // 本板本周活跃 type activityRow struct { UserID uint PostCount int64 CommentCount int64 } var rows []activityRow activitySQL := ` SELECT a.user_id AS user_id, SUM(a.is_post) AS post_count, SUM(1 - a.is_post) AS comment_count FROM ( SELECT user_id, 1 AS is_post FROM posts WHERE status = ? AND board_id = ? AND created_at >= ? AND deleted_at IS NULL UNION ALL SELECT c.user_id, 0 AS is_post FROM comments c JOIN posts p ON p.id = c.post_id AND p.deleted_at IS NULL WHERE c.status = ? AND c.created_at >= ? AND c.deleted_at IS NULL AND p.board_id = ? AND p.status = ? ) AS a JOIN users AS u ON u.id = a.user_id WHERE u.banned = ? AND u.deleted_at IS NULL GROUP BY a.user_id ORDER BY COUNT(*) DESC, a.user_id ASC LIMIT 5` if err := s.db.Raw(activitySQL, model.ContentStatusPublished, boardID, weekStart, model.ContentStatusPublished, weekStart, boardID, model.ContentStatusPublished, false).Scan(&rows).Error; err != nil { return nil, err } if len(rows) > 0 { ids := make([]uint, 0, len(rows)) for _, r := range rows { ids = append(ids, r.UserID) } var users []model.User if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil { return nil, err } userMap := make(map[uint]model.User, len(users)) for _, u := range users { userMap[u.ID] = u } for _, r := range rows { u, ok := userMap[r.UserID] if !ok { continue } data.ActiveUsers = append(data.ActiveUsers, ActiveUser{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, PostCount: r.PostCount, CommentCount: r.CommentCount, }) } } return data, nil }