feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
@@ -14,6 +14,7 @@ const (
|
||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||
PermBoards = "boards" // 板块管理(仅站长)
|
||||
)
|
||||
|
||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||
@@ -37,6 +38,8 @@ func (a *Actor) HasPerm(p string) bool {
|
||||
return false
|
||||
}
|
||||
switch p {
|
||||
case PermBoards:
|
||||
return a.Role == model.RoleOwner
|
||||
case PermUsers, PermSettings:
|
||||
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
|
||||
case PermAnnouncements:
|
||||
|
||||
@@ -576,7 +576,7 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
||||
|
||||
// UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)
|
||||
// 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。
|
||||
// - nickname 非空且不超过 64 字符
|
||||
// - nickname 非空、不超过 64 字符,且全站唯一(忽略大小写,排除自身;注册时昵称=用户名,用户名本身唯一)
|
||||
// - email 可为空;非空时需符合邮箱格式且不与他人重复
|
||||
// - signature 不超过 255 字符
|
||||
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
|
||||
@@ -590,6 +590,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
|
||||
if len(nickname) > 64 {
|
||||
return nil, errors.New("昵称不能超过 64 个字符")
|
||||
}
|
||||
// 昵称全站唯一(排除自身):与用户名同级,保证 @提及 昵称→用户名 映射无歧义
|
||||
var nickCount int64
|
||||
s.db.Model(&model.User{}).
|
||||
Where("LOWER(nickname) = LOWER(?) AND id <> ?", nickname, userID).
|
||||
Count(&nickCount)
|
||||
if nickCount > 0 {
|
||||
return nil, errors.New("该昵称已被使用")
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
if email != "" {
|
||||
if !strings.Contains(email, "@") || len(email) > 128 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -587,7 +587,16 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove
|
||||
func (s *ChatService) EnsureDefaultMembership(userID uint) error {
|
||||
var room model.ChatRoom
|
||||
if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil {
|
||||
return nil // 尚无大厅则跳过
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 空库首次部署时启动播种因无用户跳过,这里就地补建大厅(自愈,不依赖重启)
|
||||
if err := model.EnsureDefaultChatRoom(s.db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil {
|
||||
return nil // 仍无大厅(尚无任何用户)则跳过
|
||||
}
|
||||
}
|
||||
if _, err := s.membership(s.db, room.ID, userID); err == nil {
|
||||
return nil
|
||||
|
||||
738
backend/service/legacyimport.go
Normal file
738
backend/service/legacyimport.go
Normal file
@@ -0,0 +1,738 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包,
|
||||
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
|
||||
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
|
||||
// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。
|
||||
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
|
||||
|
||||
const (
|
||||
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
|
||||
LegacyDBMaxBytes = 64 << 20
|
||||
// LegacyZipMaxBytes 头像压缩包上传上限
|
||||
LegacyZipMaxBytes = 128 << 20
|
||||
// legacyAvatarFileMax 单个头像解压上限
|
||||
legacyAvatarFileMax = 8 << 20
|
||||
// legacySignatureMaxRunes 新站 Signature 列上限
|
||||
legacySignatureMaxRunes = 255
|
||||
// legacyEmailMaxRunes 新站 Email 列上限
|
||||
legacyEmailMaxRunes = 128
|
||||
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
|
||||
legacyAvatarPrefix = "/uploads/avatars/"
|
||||
// legacyDetailLimit 报告明细条数上限,防止超长响应
|
||||
legacyDetailLimit = 50
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
|
||||
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
|
||||
)
|
||||
|
||||
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
|
||||
func LegacyImportSource(source string) bool {
|
||||
return source == "jiang13"
|
||||
}
|
||||
|
||||
// LegacyImportOptions 导入选项
|
||||
type LegacyImportOptions struct {
|
||||
WithContent bool // 同时导入板块 / 帖子 / 评论
|
||||
DryRun bool // 预检:只统计不写库
|
||||
}
|
||||
|
||||
// LegacyImportReport 导入结果摘要(JSON 返回给前端)
|
||||
type LegacyImportReport struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Users LegacyUserReport `json:"users"`
|
||||
Boards *LegacyBoardReport `json:"boards,omitempty"`
|
||||
Posts *LegacyPostReport `json:"posts,omitempty"`
|
||||
Comments *LegacyCommentReport `json:"comments,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyUserReport 用户导入明细
|
||||
type LegacyUserReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
AvatarWritten int `json:"avatar_written"`
|
||||
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
|
||||
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyBoardReport 板块导入明细
|
||||
type LegacyBoardReport struct {
|
||||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||||
Reused int `json:"reused"` // 复用同名已有板块
|
||||
Names []string `json:"names,omitempty"` // 新建板块名
|
||||
}
|
||||
|
||||
// LegacyPostReport 帖子导入明细
|
||||
type LegacyPostReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"` // 已导入过(去重记录)
|
||||
PollsSkipped int `json:"polls_skipped"`
|
||||
PollTitles []string `json:"poll_titles,omitempty"`
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyCommentReport 评论导入明细
|
||||
type LegacyCommentReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"`
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// ===== 旧库行结构(只映射所需列) =====
|
||||
|
||||
type legacyUserRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
Signature string `gorm:"column:signature"`
|
||||
Avatar string `gorm:"column:avatar"`
|
||||
Role string `gorm:"column:role"`
|
||||
Banned bool `gorm:"column:banned"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤
|
||||
}
|
||||
|
||||
func (legacyUserRow) TableName() string { return "users" }
|
||||
|
||||
type legacyBoardRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description string `gorm:"column:description"`
|
||||
Icon string `gorm:"column:icon"`
|
||||
ColorIndex int `gorm:"column:color_index"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (legacyBoardRow) TableName() string { return "boards" }
|
||||
|
||||
type legacyPostRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
BoardID uint `gorm:"column:board_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Title string `gorm:"column:title"`
|
||||
Content string `gorm:"column:content"`
|
||||
Tags string `gorm:"column:tags"`
|
||||
Pinned int `gorm:"column:pinned"`
|
||||
Featured bool `gorm:"column:featured"`
|
||||
Status string `gorm:"column:status"`
|
||||
PostType string `gorm:"column:post_type"`
|
||||
LikeCount int `gorm:"column:like_count"`
|
||||
ViewCount int `gorm:"column:view_count"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (legacyPostRow) TableName() string { return "posts" }
|
||||
|
||||
type legacyCommentRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
PostID uint `gorm:"column:post_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
ReplyTo *uint `gorm:"column:reply_to"`
|
||||
Content string `gorm:"column:content"`
|
||||
Status string `gorm:"column:status"`
|
||||
LikeCount int `gorm:"column:like_count"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (legacyCommentRow) TableName() string { return "comments" }
|
||||
|
||||
// LegacyImportService 管理后台「数据导入」
|
||||
type LegacyImportService struct {
|
||||
db *gorm.DB
|
||||
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars
|
||||
md *htmltomarkdown.Converter
|
||||
}
|
||||
|
||||
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
|
||||
return &LegacyImportService{
|
||||
db: db,
|
||||
uploadsDir: uploadsDir,
|
||||
// 空域名:相对链接保持原样;CommonMark 风格输出
|
||||
md: htmltomarkdown.NewConverter("", true, nil),
|
||||
}
|
||||
}
|
||||
|
||||
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。
|
||||
// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。
|
||||
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
|
||||
if err := validateSQLiteMagic(dbPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err)
|
||||
}
|
||||
|
||||
rep := &LegacyImportReport{DryRun: opts.DryRun}
|
||||
rep.Users = s.importUsers(oldDB, zipPath, opts)
|
||||
if !opts.WithContent {
|
||||
return rep, nil
|
||||
}
|
||||
boardMap, boardRep := s.importBoards(oldDB, opts)
|
||||
rep.Boards = boardRep
|
||||
rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep)
|
||||
rep.Comments = s.importComments(oldDB, operatorID, opts, rep)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ===== 用户 =====
|
||||
|
||||
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport {
|
||||
var rows []legacyUserRow
|
||||
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}}
|
||||
}
|
||||
|
||||
// 头像包索引:文件名 → 条目。
|
||||
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
|
||||
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
|
||||
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
|
||||
avatars := map[string]*zip.File{}
|
||||
if zipPath != "" {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}}
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(f.Name, "\\", "/")
|
||||
parts := strings.Split(name, "/")
|
||||
var key string
|
||||
switch {
|
||||
case len(parts) == 1:
|
||||
key = parts[0]
|
||||
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
|
||||
key = parts[1]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if key == "" || key == "." || key == ".." {
|
||||
continue
|
||||
}
|
||||
if _, exists := avatars[key]; !exists {
|
||||
avatars[key] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rep := LegacyUserReport{Total: len(rows)}
|
||||
avatarDir := filepath.Join(s.uploadsDir, "avatars")
|
||||
for _, u := range rows {
|
||||
username := strings.TrimSpace(u.Username)
|
||||
if username == "" || u.Password == "" {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID))
|
||||
continue
|
||||
}
|
||||
// 用户名列是硬唯一索引,软删行仍占用 → Unscoped 查重,存在即跳过(幂等)
|
||||
var n int64
|
||||
if err := s.db.Unscoped().Model(&model.User{}).Where("username = ?", username).Count(&n).Error; err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (查重失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
rep.Skipped++
|
||||
rep.Conflicts = append(rep.Conflicts, username)
|
||||
continue
|
||||
}
|
||||
|
||||
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun)
|
||||
if err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
if wrote {
|
||||
rep.AvatarWritten++
|
||||
}
|
||||
if u.Avatar != "" && avatarURL == "" {
|
||||
rep.AvatarMissing = append(rep.AvatarMissing, fmt.Sprintf("%s: %s", username, u.Avatar))
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
rep.Imported++
|
||||
continue
|
||||
}
|
||||
user := model.User{
|
||||
Username: username,
|
||||
Email: truncateRunesN(u.Email, legacyEmailMaxRunes),
|
||||
Password: u.Password, // bcrypt 哈希原样复制
|
||||
Nickname: s.uniqueNickname(u.Nickname, username, u.ID),
|
||||
Signature: truncateRunesN(u.Signature, legacySignatureMaxRunes),
|
||||
Avatar: avatarURL,
|
||||
Role: model.RoleUser, // 导入者即新站站长,旧角色一律降为普通用户
|
||||
Banned: u.Banned,
|
||||
CreatedAt: u.CreatedAt, // 保留原注册时间
|
||||
}
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
rep.Imported++
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// uniqueNickname 旧昵称与本站已有昵称(忽略大小写;软删不占用,与唯一索引语义一致)冲突时降级:
|
||||
// 旧昵称 → 用户名 → 用户名-旧ID,保证满足 LOWER(nickname) 唯一索引且不为空。
|
||||
func (s *LegacyImportService) uniqueNickname(nickname, username string, oldID uint) string {
|
||||
candidates := []string{strings.TrimSpace(nickname), username, fmt.Sprintf("%s-%d", username, oldID)}
|
||||
last := candidates[len(candidates)-1]
|
||||
for _, cand := range candidates {
|
||||
var n int64
|
||||
if err := s.db.Model(&model.User{}).Where("LOWER(nickname) = LOWER(?)", cand).Count(&n).Error; err != nil || n == 0 {
|
||||
return cand // 查重失败按可用处理,Create 时仍有唯一索引兜底
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// resolveAvatar 从头像包解出旧头像文件到新站 avatars 目录(文件名原样保留,URL 即旧值)。
|
||||
// 返回新 Avatar 字段值(空串=无法迁移)与是否实际写入了文件。dryRun 时只判断不解压。
|
||||
func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*zip.File, avatarDir string, dryRun bool) (string, bool, error) {
|
||||
if oldAvatar == "" || len(oldAvatar) <= len(legacyAvatarPrefix) {
|
||||
return "", false, nil
|
||||
}
|
||||
name := oldAvatar[len(legacyAvatarPrefix):]
|
||||
if name != path.Base(name) || strings.ContainsRune(name, '\\') {
|
||||
return "", false, nil // 不符合旧站 URL 约定(如外部链接),无法迁移
|
||||
}
|
||||
f, ok := pack[name]
|
||||
if !ok {
|
||||
return "", false, nil // 包内缺失
|
||||
}
|
||||
dst := filepath.Join(avatarDir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
|
||||
}
|
||||
if dryRun {
|
||||
return legacyAvatarPrefix + name, true, nil
|
||||
}
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer src.Close()
|
||||
// 先写临时文件再 rename,避免中断留下半截文件
|
||||
out, err := os.CreateTemp(avatarDir, ".legacy-*")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
tmpName := out.Name()
|
||||
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
|
||||
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
|
||||
if closeErr := out.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if written > legacyAvatarFileMax {
|
||||
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20)
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
// Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return legacyAvatarPrefix + name, false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return legacyAvatarPrefix + name, true, nil
|
||||
}
|
||||
|
||||
// ===== 板块 =====
|
||||
|
||||
// importBoards 按旧板块名自动建新板块(同名忽略大小写复用),返回 旧板块ID → 新板块ID 映射。
|
||||
func (s *LegacyImportService) importBoards(oldDB *gorm.DB, opts LegacyImportOptions) (map[uint]uint, *LegacyBoardReport) {
|
||||
rep := &LegacyBoardReport{}
|
||||
m := map[uint]uint{}
|
||||
var rows []legacyBoardRow
|
||||
if err := oldDB.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return m, rep // 板块读取失败:帖子逐条按 board_id 缺失进 failed
|
||||
}
|
||||
for _, b := range rows {
|
||||
name := strings.TrimSpace(b.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var existing model.Board
|
||||
if err := s.db.Where("LOWER(name) = LOWER(?)", name).First(&existing).Error; err == nil {
|
||||
m[b.ID] = existing.ID
|
||||
rep.Reused++
|
||||
continue
|
||||
}
|
||||
rep.Created++
|
||||
rep.Names = append(rep.Names, name)
|
||||
if opts.DryRun {
|
||||
// 预检不写库;占位映射(0 不会用于真实写入)让帖子预检仍按「将创建」计数
|
||||
m[b.ID] = 0
|
||||
continue
|
||||
}
|
||||
board := model.Board{
|
||||
Name: name,
|
||||
Description: b.Description,
|
||||
Icon: b.Icon,
|
||||
ColorIndex: b.ColorIndex,
|
||||
SortOrder: b.SortOrder,
|
||||
}
|
||||
if err := s.db.Create(&board).Error; err != nil {
|
||||
continue // 创建失败:对应帖子进 failed
|
||||
}
|
||||
s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "board", SourceID: strconv.FormatUint(uint64(b.ID), 10), NewID: board.ID})
|
||||
m[b.ID] = board.ID
|
||||
}
|
||||
return m, rep
|
||||
}
|
||||
|
||||
// ===== 帖子 / 评论 =====
|
||||
|
||||
// mapLegacyUsers 旧用户ID → 本站用户ID。先按用户名映射(含软删本站用户),
|
||||
// 找不到回退到 operatorID(站长,由调用方处理并记录明细)。
|
||||
func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
|
||||
type oldRef struct {
|
||||
ID uint
|
||||
Username string
|
||||
}
|
||||
var olds []oldRef
|
||||
// 含软删旧用户:其内容仍需归属。
|
||||
// oldRef 是包内匿名结构体,必须显式指定表名,否则 GORM 会推断成 old_refs
|
||||
if err := oldDB.Unscoped().Table("users").Select("id", "username").Find(&olds).Error; err != nil {
|
||||
return map[uint]uint{}
|
||||
}
|
||||
var news []model.User
|
||||
// Unscoped:软删本站账号也参与映射(内容归属不变)
|
||||
if err := s.db.Unscoped().Select("id", "username").Find(&news).Error; err != nil {
|
||||
return map[uint]uint{}
|
||||
}
|
||||
byName := make(map[string]uint, len(news))
|
||||
for _, u := range news {
|
||||
byName[strings.ToLower(u.Username)] = u.ID
|
||||
}
|
||||
m := make(map[uint]uint, len(olds))
|
||||
for _, o := range olds {
|
||||
name := strings.ToLower(strings.TrimSpace(o.Username))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if id, ok := byName[name]; ok {
|
||||
m[o.ID] = id
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
|
||||
out := &LegacyPostReport{}
|
||||
if boardMap == nil || len(boardMap) == 0 {
|
||||
out.Failed = append(out.Failed, "无可用板块映射,帖子未导入")
|
||||
return out
|
||||
}
|
||||
var rows []legacyPostRow
|
||||
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
out.Failed = append(out.Failed, "读取 posts 表失败: "+err.Error())
|
||||
return out
|
||||
}
|
||||
out.Total = len(rows)
|
||||
|
||||
userMap := s.mapLegacyUsers(oldDB)
|
||||
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
|
||||
convFailed := 0
|
||||
|
||||
for _, p := range rows {
|
||||
title := strings.TrimSpace(p.Title)
|
||||
if title == "" {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
|
||||
continue
|
||||
}
|
||||
if p.PostType == "poll" {
|
||||
out.PollsSkipped++
|
||||
out.PollTitles = append(out.PollTitles, title)
|
||||
continue
|
||||
}
|
||||
if p.Status != "published" {
|
||||
out.Skipped++
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (旧状态 %s,跳过)", title, p.Status))
|
||||
continue
|
||||
}
|
||||
boardID, ok := boardMap[p.BoardID]
|
||||
if !ok {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (旧板块 #%d 无法映射)", title, p.BoardID))
|
||||
continue
|
||||
}
|
||||
// 幂等:去重记录已存在即跳过
|
||||
if !opts.DryRun {
|
||||
var n int64
|
||||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(p.ID), 10)).Count(&n)
|
||||
if n > 0 {
|
||||
out.Skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 作者映射,缺失归站长
|
||||
authorID := operatorID
|
||||
if id, ok := userMap[p.UserID]; ok {
|
||||
authorID = id
|
||||
} else if !missingAuthors[p.UserID] {
|
||||
missingAuthors[p.UserID] = true
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID))
|
||||
}
|
||||
|
||||
// HTML → Markdown;失败保留原文(正文不丢)
|
||||
content := p.Content
|
||||
if strings.Contains(strings.ToLower(content), "<") {
|
||||
if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" {
|
||||
content = md
|
||||
} else {
|
||||
convFailed++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
out.Imported++
|
||||
continue
|
||||
}
|
||||
post := model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: authorID,
|
||||
Title: truncateRunesN(title, 256),
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
ViewCount: p.ViewCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if err := s.db.Create(&post).Error; err != nil {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))
|
||||
continue
|
||||
}
|
||||
s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "post", SourceID: strconv.FormatUint(uint64(p.ID), 10), NewID: post.ID})
|
||||
out.Imported++
|
||||
}
|
||||
if convFailed > 0 {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed))
|
||||
}
|
||||
out.OwnerFallback = capList(out.OwnerFallback)
|
||||
out.Failed = capList(out.Failed)
|
||||
out.PollTitles = capList(out.PollTitles)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
|
||||
out := &LegacyCommentReport{}
|
||||
var rows []legacyCommentRow
|
||||
if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
out.Failed = append(out.Failed, "读取 comments 表失败: "+err.Error())
|
||||
return out
|
||||
}
|
||||
out.Total = len(rows)
|
||||
|
||||
var posts []legacyPostRow
|
||||
if err := oldDB.Unscoped().Select("id", "title").Find(&posts).Error; err != nil {
|
||||
posts = nil
|
||||
}
|
||||
postTitles := map[uint]string{}
|
||||
for _, p := range posts {
|
||||
postTitles[p.ID] = p.Title
|
||||
}
|
||||
|
||||
userMap := s.mapLegacyUsers(oldDB)
|
||||
missingAuthors := map[uint]bool{}
|
||||
convFailed := 0
|
||||
|
||||
for _, cm := range rows {
|
||||
if cm.Status != "published" {
|
||||
out.Skipped++
|
||||
out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status))
|
||||
continue
|
||||
}
|
||||
// 幂等:去重记录已存在即跳过
|
||||
if !opts.DryRun {
|
||||
var n int64
|
||||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "comment", strconv.FormatUint(uint64(cm.ID), 10)).Count(&n)
|
||||
if n > 0 {
|
||||
out.Skipped++
|
||||
continue
|
||||
}
|
||||
// 所属帖子必须已导入(否则评论无落点)
|
||||
var pn int64
|
||||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).Count(&pn)
|
||||
if pn == 0 {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (所属帖子未导入)", postTitles[cm.PostID], cm.ID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
authorID := operatorID
|
||||
if id, ok := userMap[cm.UserID]; ok {
|
||||
authorID = id
|
||||
} else if !missingAuthors[cm.UserID] {
|
||||
missingAuthors[cm.UserID] = true
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID))
|
||||
}
|
||||
|
||||
// HTML → Markdown;失败保留原文(内容不丢)
|
||||
content := cm.Content
|
||||
if strings.Contains(strings.ToLower(content), "<") {
|
||||
if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" {
|
||||
content = md
|
||||
} else {
|
||||
convFailed++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
out.Imported++
|
||||
continue
|
||||
}
|
||||
if err := s.importCommentOne(cm, authorID, content); err != nil {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (写入失败: %v)", postTitles[cm.PostID], cm.ID, err))
|
||||
continue
|
||||
}
|
||||
out.Imported++
|
||||
}
|
||||
if convFailed > 0 {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed))
|
||||
}
|
||||
out.SkippedDetail = capList(out.SkippedDetail)
|
||||
out.OwnerFallback = capList(out.OwnerFallback)
|
||||
out.Failed = capList(out.Failed)
|
||||
return out
|
||||
}
|
||||
|
||||
// importCommentOne 写入单条评论并处理回复链挂接与去重记录。
|
||||
// 源 ID 顺序保证父评论先于子评论处理;reply_to 指向的父评论通过去重记录取回新 ID。
|
||||
func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uint, content string) error {
|
||||
// 源帖子 → 新帖子 ID
|
||||
var postRec model.ImportRecord
|
||||
if err := s.db.Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).First(&postRec).Error; err != nil {
|
||||
return errors.New("找不到源帖子记录")
|
||||
}
|
||||
|
||||
comment := model.Comment{
|
||||
PostID: postRec.NewID,
|
||||
UserID: authorID,
|
||||
Content: content,
|
||||
Status: model.ContentStatusPublished,
|
||||
CreatedAt: cm.CreatedAt,
|
||||
UpdatedAt: cm.UpdatedAt,
|
||||
}
|
||||
|
||||
// 回复挂接:reply_to 指向同帖已有评论;找不到父评论(被删/跨帖)→ 落为主评论
|
||||
if cm.ReplyTo != nil && *cm.ReplyTo > 0 {
|
||||
var parent model.Comment
|
||||
err := s.db.Where("comments.post_id = ?", postRec.NewID).
|
||||
Joins("JOIN import_records ir ON ir.kind = 'comment' AND ir.new_id = comments.id").
|
||||
Where("ir.source = ? AND ir.source_id = ?", "jiang13", strconv.FormatUint(uint64(*cm.ReplyTo), 10)).
|
||||
First(&parent).Error
|
||||
if err == nil {
|
||||
comment.ParentID = &parent.ID
|
||||
comment.Depth = parent.Depth + 1
|
||||
if parent.RootID != nil {
|
||||
root := *parent.RootID
|
||||
comment.RootID = &root
|
||||
} else {
|
||||
comment.RootID = &parent.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 评论 / 计数 / 去重记录同一事务,避免重试导致重复评论
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 帖子评论计数 +1(UpdateColumn 不触碰 updated_at)
|
||||
if err := tx.Model(&model.Post{}).Where("id = ?", postRec.NewID).
|
||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.ImportRecord{
|
||||
Source: "jiang13", Kind: "comment",
|
||||
SourceID: strconv.FormatUint(uint64(cm.ID), 10), NewID: comment.ID,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 工具 =====
|
||||
|
||||
// capList 明细条数截断,超出部分以「…等 N 条」收尾
|
||||
func capList(list []string) []string {
|
||||
if len(list) <= legacyDetailLimit {
|
||||
return list
|
||||
}
|
||||
out := append([]string{}, list[:legacyDetailLimit]...)
|
||||
out = append(out, fmt.Sprintf("…等共 %d 条", len(list)))
|
||||
return out
|
||||
}
|
||||
|
||||
// validateSQLiteMagic 校验文件以 SQLite 3 魔数开头,避免把任意文件喂给解析器
|
||||
func validateSQLiteMagic(p string) error {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
magic := make([]byte, 16)
|
||||
if _, err := io.ReadFull(f, magic); err != nil {
|
||||
return ErrLegacyInvalidSQLite
|
||||
}
|
||||
if string(magic) != "SQLite format 3\x00" {
|
||||
return ErrLegacyInvalidSQLite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateRunesN 按 rune 数截断字符串到 max
|
||||
func truncateRunesN(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
@@ -577,3 +577,16 @@ func randomID() string {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randomCode 生成 6 位数字邮箱验证码,供用户阅读输入。
|
||||
func randomCode() string {
|
||||
const n = 6
|
||||
b := make([]byte, n)
|
||||
if _, e := io.ReadFull(rand.Reader, b); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = '0' + b[i]%10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
@@ -125,6 +126,13 @@ func MatchFilter(c FilterConfig, scope, input string) FilterResult {
|
||||
return result
|
||||
}
|
||||
func (o *Operations) Filter(scope, input string, actor uint) error {
|
||||
if actor > 0 {
|
||||
var role model.Role
|
||||
if err := o.db.Model(&model.User{}).Where("id = ?", actor).
|
||||
Select("role").Scan(&role).Error; err == nil && model.IsStaff(role) {
|
||||
return nil // 管理角色不受内容过滤限制
|
||||
}
|
||||
}
|
||||
v, _, e := o.read(o.db, "filter")
|
||||
if e != nil {
|
||||
return errors.New("内容过滤暂不可用,请稍后重试")
|
||||
|
||||
@@ -392,7 +392,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
code := randomID()
|
||||
code := randomCode()
|
||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil {
|
||||
@@ -402,17 +402,60 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
})
|
||||
return 0, e
|
||||
}
|
||||
const (
|
||||
codeVerifyMaxAttempts = 5
|
||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||
)
|
||||
|
||||
// CodeThrottleError 表示验证码校验失败次数超限,携带需等待的秒数。
|
||||
type CodeThrottleError struct{ Wait int }
|
||||
|
||||
func (e *CodeThrottleError) Error() string {
|
||||
return fmt.Sprintf("验证码错误次数过多,请 %d 秒后重试", e.Wait)
|
||||
}
|
||||
|
||||
func (o *Operations) ConsumeCode(email, purpose, code string) error {
|
||||
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true)
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
failKey := "verifyfail:" + purpose + ":" + email
|
||||
|
||||
// 失败次数限流:窗口内错误次数超限则拒绝,防止 6 位数字码被暴力枚举。
|
||||
if wait, e := o.verifyFailWait(failKey); e != nil {
|
||||
return e
|
||||
} else if wait > 0 {
|
||||
return &CodeThrottleError{Wait: wait}
|
||||
}
|
||||
|
||||
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(email+":"+purpose+":"+code), time.Now()).Update("used", true)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
if _, e := o.Quota(failKey, codeVerifyMaxAttempts, codeVerifyWindow); e != nil {
|
||||
return e
|
||||
}
|
||||
return fmt.Errorf("验证码无效或已过期")
|
||||
}
|
||||
// 校验成功,清除失败计数。
|
||||
o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey(failKey))
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFailWait 读取当前失败计数,若已达上限返回需等待的秒数,不修改计数。
|
||||
func (o *Operations) verifyFailWait(key string) (int, error) {
|
||||
var c model.ActionCounter
|
||||
e := o.db.First(&c, "key = ?", counterKey(key)).Error
|
||||
if e != nil {
|
||||
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, e
|
||||
}
|
||||
if time.Now().Before(c.ExpiresAt) && c.Count >= codeVerifyMaxAttempts {
|
||||
return int(time.Until(c.ExpiresAt).Seconds()) + 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Probe merged drafts so partial API updates cannot bypass activation validation.
|
||||
func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error {
|
||||
v, _, _, e := o.draft(o.db, name, raw, clear)
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
type PostService struct {
|
||||
db *gorm.DB
|
||||
setting *SettingService
|
||||
board *BoardService
|
||||
devMode bool
|
||||
}
|
||||
|
||||
@@ -36,6 +37,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService {
|
||||
return s
|
||||
}
|
||||
|
||||
// WithBoard 注入板块服务(发帖策略校验用);可链式调用
|
||||
func (s *PostService) WithBoard(board *BoardService) *PostService {
|
||||
s.board = board
|
||||
return s
|
||||
}
|
||||
|
||||
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
|
||||
func (s *PostService) WithDevMode(dev bool) *PostService {
|
||||
s.devMode = dev
|
||||
@@ -744,35 +751,37 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
typeMeta, _ = encodeMeta(qm)
|
||||
}
|
||||
|
||||
// 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限)
|
||||
// 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限);管理角色豁免防刷
|
||||
var bountyPts int
|
||||
if postType == model.PostTypeBounty {
|
||||
bm, _ := parseBountyMeta(typeMeta)
|
||||
bountyPts = bm.Points
|
||||
var bountyPosts []model.Post
|
||||
s.db.Select("type_meta").
|
||||
Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty).
|
||||
Find(&bountyPosts)
|
||||
open := 0
|
||||
for _, bp := range bountyPosts {
|
||||
m, err := parseBountyMeta(bp.TypeMeta)
|
||||
if err != nil {
|
||||
continue
|
||||
if !s.isStaffUser(in.UserID) {
|
||||
var bountyPosts []model.Post
|
||||
s.db.Select("type_meta").
|
||||
Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty).
|
||||
Find(&bountyPosts)
|
||||
open := 0
|
||||
for _, bp := range bountyPosts {
|
||||
m, err := parseBountyMeta(bp.TypeMeta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
|
||||
open++
|
||||
}
|
||||
}
|
||||
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
|
||||
open++
|
||||
if open >= 3 {
|
||||
return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回")
|
||||
}
|
||||
var todayN int64
|
||||
s.db.Model(&model.Post{}).
|
||||
Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL",
|
||||
in.UserID, model.PostTypeBounty).
|
||||
Count(&todayN)
|
||||
if todayN >= 5 {
|
||||
return nil, errors.New("今日悬赏发帖已达上限(5)")
|
||||
}
|
||||
}
|
||||
if open >= 3 {
|
||||
return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回")
|
||||
}
|
||||
var todayN int64
|
||||
s.db.Model(&model.Post{}).
|
||||
Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL",
|
||||
in.UserID, model.PostTypeBounty).
|
||||
Count(&todayN)
|
||||
if todayN >= 5 {
|
||||
return nil, errors.New("今日悬赏发帖已达上限(5)")
|
||||
}
|
||||
bm.Escrowed = true
|
||||
bm.Refunded = false
|
||||
@@ -1051,7 +1060,7 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭)
|
||||
// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭);管理角色豁免
|
||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
hours := DefaultCooldownHours
|
||||
if s.setting != nil {
|
||||
@@ -1066,12 +1075,25 @@ func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if model.IsStaff(user.Role) { // 管理角色不受发帖冷静期限制
|
||||
return nil
|
||||
}
|
||||
if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour {
|
||||
return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isStaffUser 作者是否管理角色(悬赏防刷等限制豁免用)
|
||||
func (s *PostService) isStaffUser(userID uint) bool {
|
||||
var role model.Role
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).
|
||||
Select("role").Scan(&role).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return model.IsStaff(role)
|
||||
}
|
||||
|
||||
// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值
|
||||
type DeletePostOpts struct {
|
||||
DeleteType string
|
||||
|
||||
@@ -828,7 +828,11 @@ func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error)
|
||||
Group("c.user_id")
|
||||
if hours > 0 {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
q = q.Where("u.created_at <= ?", cutoff)
|
||||
// 管理角色不受新号冷静期限制
|
||||
q = q.Where("u.created_at <= ? OR u.role IN ?", cutoff, []string{
|
||||
string(model.RoleBoardAdmin), string(model.RoleAdmin),
|
||||
string(model.RoleSuperAdmin), string(model.RoleOwner),
|
||||
})
|
||||
}
|
||||
var ids []uint
|
||||
if err := q.Pluck("c.user_id", &ids).Error; err != nil {
|
||||
|
||||
@@ -17,6 +17,8 @@ const (
|
||||
SettingKeyAccent = "theme_accent"
|
||||
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
|
||||
SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
|
||||
// SettingKeyPublishWithoutReview 全站免审:所有人发帖/评论直接公开,无需过审记录;缺行视为关闭(默认关)
|
||||
SettingKeyPublishWithoutReview = "publish_without_review"
|
||||
SettingKeySiteName = "site_name"
|
||||
SettingKeySiteDescription = "site_description"
|
||||
SettingKeyAllowRegister = "allow_register"
|
||||
@@ -99,6 +101,7 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
type PublicSiteSettings struct {
|
||||
Accent string `json:"accent"`
|
||||
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
|
||||
PublishWithoutReview bool `json:"publish_without_review"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
@@ -210,6 +213,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
SiteName: DefaultSiteName,
|
||||
SiteDescription: DefaultSiteDescription,
|
||||
TrustReviewedPublish: true,
|
||||
PublishWithoutReview: false,
|
||||
AllowRegister: true,
|
||||
AllowComments: true,
|
||||
CommentsRequireLogin: false,
|
||||
@@ -247,6 +251,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
}
|
||||
out.TrustReviewedPublish = trust
|
||||
|
||||
withoutReview, err := s.PublishWithoutReview()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.PublishWithoutReview = withoutReview
|
||||
|
||||
name, found, err := s.getValue(SettingKeySiteName)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -429,6 +439,20 @@ func (s *SettingService) SetTrustReviewedPublish(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on)
|
||||
}
|
||||
|
||||
// PublishWithoutReview 全站免审:所有人发布内容直接公开(优先于过审免审)。缺行视为关闭。
|
||||
func (s *SettingService) PublishWithoutReview() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyPublishWithoutReview)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parseBoolDefaultFalse(v, found), nil
|
||||
}
|
||||
|
||||
// SetPublishWithoutReview 持久化全站免审开关;关闭时删键保持「缺行=默认关」。
|
||||
func (s *SettingService) SetPublishWithoutReview(on bool) error {
|
||||
return s.setBoolDefaultFalse(SettingKeyPublishWithoutReview, on)
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSiteName(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == DefaultSiteName {
|
||||
|
||||
146
backend/service/staff_exempt_test.go
Normal file
146
backend/service/staff_exempt_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// staffTestDB 独立 schema 隔离的测试库(同 testOps 模式;未配置 DSN 则跳过)
|
||||
func staffTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database")
|
||||
}
|
||||
u, e := url.Parse(dsn)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
schema := "staff_exempt_" + fmt.Sprint(time.Now().UnixNano())
|
||||
if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sql, _ := scoped.DB()
|
||||
_ = sql.Close()
|
||||
db.Exec("DROP SCHEMA " + schema + " CASCADE")
|
||||
sql, _ = db.DB()
|
||||
_ = sql.Close()
|
||||
})
|
||||
if e = scoped.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.SiteSetting{},
|
||||
&model.PostLotteryEntry{}, &model.ModuleConfig{}, &model.SettingsAudit{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
|
||||
func mustUser(t *testing.T, db *gorm.DB, username string, role model.Role, createdAt time.Time) model.User {
|
||||
t.Helper()
|
||||
u := model.User{Username: username, Password: "x", Role: role, CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestCheckNewUserCooldownStaffExempt(t *testing.T) {
|
||||
db := staffTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
plain := mustUser(t, db, "plain", model.RoleUser, now)
|
||||
owner := mustUser(t, db, "owner", model.RoleOwner, now)
|
||||
badmin := mustUser(t, db, "badmin", model.RoleBoardAdmin, now)
|
||||
|
||||
ps := NewPostService(db).WithSetting(NewSettingService(db))
|
||||
if err := ps.checkNewUserCooldown(plain.ID); err == nil {
|
||||
t.Fatal("new plain user should be blocked by cooldown")
|
||||
}
|
||||
for _, u := range []model.User{owner, badmin} {
|
||||
if err := ps.checkNewUserCooldown(u.ID); err != nil {
|
||||
t.Fatalf("staff role %s should bypass cooldown: %v", u.Role, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterStaffExempt(t *testing.T) {
|
||||
db := staffTestDB(t)
|
||||
b, _ := json.Marshal(FilterConfig{Enabled: true, Rules: []FilterRule{{
|
||||
ID: "r1", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true,
|
||||
}}})
|
||||
if err := db.Create(&model.ModuleConfig{Name: "filter", Version: 1, Data: string(b)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := mustUser(t, db, "plain", model.RoleUser, time.Now().UTC())
|
||||
owner := mustUser(t, db, "owner", model.RoleOwner, time.Now().UTC())
|
||||
|
||||
o := NewOperations(db, &config.Config{DevMode: true})
|
||||
if err := o.Filter("body", "a bad word", plain.ID); err == nil {
|
||||
t.Fatal("plain user should be blocked by filter")
|
||||
}
|
||||
if err := o.Filter("body", "a bad word", owner.ID); err != nil {
|
||||
t.Fatalf("owner should bypass filter: %v", err)
|
||||
}
|
||||
if err := o.Filter("body", "a bad word", 0); err == nil {
|
||||
t.Fatal("anonymous should be blocked by filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLotteryEligibleStaffBypassesCooldown(t *testing.T) {
|
||||
db := staffTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
author := mustUser(t, db, "author", model.RoleUser, now)
|
||||
plain := mustUser(t, db, "plain", model.RoleUser, now)
|
||||
owner := mustUser(t, db, "owner", model.RoleOwner, now)
|
||||
|
||||
board := model.Board{Name: "test"}
|
||||
if err := db.Create(&board).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := model.Post{BoardID: board.ID, UserID: author.ID, Title: "lottery", Content: "draw",
|
||||
PostType: model.PostTypeLottery, Status: model.ContentStatusPublished}
|
||||
if err := db.Create(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, uid := range []uint{plain.ID, owner.ID} {
|
||||
c := model.Comment{PostID: post.ID, UserID: uid, Content: "join", Status: model.ContentStatusPublished}
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
ps := NewPostService(db).WithSetting(NewSettingService(db))
|
||||
ids, err := ps.lotteryEligibleIDs(post.ID, author.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[uint]bool{}
|
||||
for _, id := range ids {
|
||||
got[id] = true
|
||||
}
|
||||
if got[plain.ID] {
|
||||
t.Fatal("new plain user should be excluded from lottery pool")
|
||||
}
|
||||
if !got[owner.ID] {
|
||||
t.Fatal("staff new account should stay in lottery pool")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user