Files
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

530 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
Badges []model.UserBadge `json:"badges,omitempty"` // 管理员颁发的徽章(Preload("Badges.Badge") 填充)
}
// 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 且授权本板、未封禁(等级由 AfterFind 算,徽章随 Preload 带)
var mods []model.User
if err := s.db.Model(&model.User{}).
Select("users.id, users.username, users.nickname, users.avatar, users.total_points").
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").
Preload("Badges.Badge").
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,
Level: u.Level,
Badges: u.Badges,
})
}
// 本板热门
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
}