- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
525 lines
16 KiB
Go
525 lines
16 KiB
Go
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").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
|
||
}
|