Files
jiang13-bbs/backend/service/board.go
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

525 lines
16 KiB
Go
Raw 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"`
}
// 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
}