feat: 管理端板块管理与旧版数据导入,补充部署运营文档

- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
2026-09-24 03:37:44 +08:00
parent c1f6a6636b
commit 3e55b5d230
53 changed files with 5078 additions and 409 deletions

View File

@@ -1,6 +1,8 @@
package service
import (
"errors"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
@@ -85,6 +87,304 @@ func (s *BoardService) Get(id uint) (*model.Board, error) {
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)