升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,10 @@ var (
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrPermissionDenied = errors.New("无权操作")
|
||||
ErrBoardNotFound = errors.New("板块不存在")
|
||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
||||
ErrInvalidSetting = errors.New("无效的设置值")
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
|
||||
|
||||
182
service/post.go
182
service/post.go
@@ -3,17 +3,19 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PostService struct {
|
||||
filter *SensitiveFilter
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewPostService(filter *SensitiveFilter) *PostService {
|
||||
return &PostService{filter: filter}
|
||||
func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *PostService {
|
||||
return &PostService{filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
type PostListQuery struct {
|
||||
@@ -21,12 +23,14 @@ type PostListQuery struct {
|
||||
Page int
|
||||
Size int
|
||||
Keyword string
|
||||
Sort string // latest | reply | hot
|
||||
}
|
||||
|
||||
// PostListItem 帖子列表项(含评论数等扩展字段)
|
||||
type PostListItem struct {
|
||||
model.Post
|
||||
CommentCount int `json:"comment_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
@@ -42,11 +46,13 @@ func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error)
|
||||
ids[i] = p.ID
|
||||
}
|
||||
countMap := s.commentCountMap(ids)
|
||||
replyMap := s.lastReplyMap(ids)
|
||||
items := make([]PostListItem, len(posts))
|
||||
for i, p := range posts {
|
||||
items[i] = PostListItem{
|
||||
Post: p,
|
||||
CommentCount: countMap[p.ID],
|
||||
LastReplyAt: replyMap[p.ID],
|
||||
}
|
||||
}
|
||||
return items, total, nil
|
||||
@@ -67,6 +73,44 @@ func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *PostService) lastReplyMap(postIDs []uint) map[uint]*time.Time {
|
||||
type row struct {
|
||||
PostID uint
|
||||
LastReply string
|
||||
}
|
||||
var rows []row
|
||||
model.DB.Model(&model.Comment{}).
|
||||
Select("post_id, MAX(created_at) as last_reply").
|
||||
Where("post_id IN ?", postIDs).
|
||||
Group("post_id").
|
||||
Scan(&rows)
|
||||
m := make(map[uint]*time.Time, len(rows))
|
||||
for _, r := range rows {
|
||||
if t, ok := parseSQLiteTime(r.LastReply); ok {
|
||||
m[r.PostID] = &t
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseSQLiteTime 解析 SQLite 聚合查询返回的时间字符串
|
||||
func parseSQLiteTime(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, layout := range []string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
@@ -113,10 +157,37 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
var posts []model.Post
|
||||
err := db.Order("pinned desc, id desc").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&posts).Error
|
||||
db = db.Order("pinned desc")
|
||||
switch normalizePostSort(q.Sort) {
|
||||
case "reply":
|
||||
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底
|
||||
db = db.Order(`(
|
||||
SELECT COUNT(*) FROM comments
|
||||
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
|
||||
) > 0 DESC`)
|
||||
db = db.Order(`(
|
||||
SELECT MAX(created_at) FROM comments
|
||||
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
|
||||
) DESC`)
|
||||
db = db.Order("posts.created_at DESC")
|
||||
case "hot":
|
||||
db = db.Order("like_count desc, view_count desc")
|
||||
default:
|
||||
db = db.Order("id desc")
|
||||
}
|
||||
err := db.Order("id desc").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&posts).Error
|
||||
return posts, total, err
|
||||
}
|
||||
|
||||
func normalizePostSort(sort string) string {
|
||||
switch sort {
|
||||
case "reply", "hot":
|
||||
return sort
|
||||
default:
|
||||
return "latest"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
var post model.Post
|
||||
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
|
||||
@@ -152,12 +223,107 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
||||
if !isAdmin && post.UserID != userID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if err := s.checkEditable(&post, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
title = s.filter.Filter(strings.TrimSpace(title))
|
||||
content = s.filter.Filter(content)
|
||||
tags = s.filter.Filter(strings.TrimSpace(tags))
|
||||
return model.DB.Model(&post).Updates(map[string]interface{}{
|
||||
"title": title, "content": content, "tags": tags,
|
||||
}).Error
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
rev := model.PostRevision{
|
||||
PostID: postID, EditorID: userID,
|
||||
Title: post.Title, Content: post.Content, Tags: post.Tags,
|
||||
}
|
||||
if err := tx.Create(&rev).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&post).Updates(map[string]interface{}{
|
||||
"title": title, "content": content, "tags": tags,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// CanEdit 判断当前用户是否可编辑帖子
|
||||
func (s *PostService) CanEdit(post *model.Post, isAdmin bool) bool {
|
||||
return s.checkEditable(post, isAdmin) == nil
|
||||
}
|
||||
|
||||
// EditBlockReason 返回不可编辑的原因(可编辑时返回空字符串)
|
||||
func (s *PostService) EditBlockReason(post *model.Post, isAdmin bool) string {
|
||||
if err := s.checkEditable(post, isAdmin); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *PostService) checkEditable(post *model.Post, isAdmin bool) error {
|
||||
if isAdmin {
|
||||
return nil
|
||||
}
|
||||
if post.EditLocked {
|
||||
return ErrPostEditLocked
|
||||
}
|
||||
window := s.settings.PostEditWindowHours()
|
||||
if window > 0 && time.Since(post.CreatedAt) > time.Duration(window)*time.Hour {
|
||||
return ErrPostEditExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanUserEdit 判断指定用户是否可编辑帖子
|
||||
func (s *PostService) CanUserEdit(post *model.Post, userID uint, isAdmin bool) bool {
|
||||
if userID == 0 {
|
||||
return false
|
||||
}
|
||||
if !isAdmin && post.UserID != userID {
|
||||
return false
|
||||
}
|
||||
return s.CanEdit(post, isAdmin)
|
||||
}
|
||||
|
||||
// UserEditBlockReason 返回用户不可编辑的原因
|
||||
func (s *PostService) UserEditBlockReason(post *model.Post, userID uint, isAdmin bool) string {
|
||||
if userID == 0 {
|
||||
return "请先登录"
|
||||
}
|
||||
if !isAdmin && post.UserID != userID {
|
||||
return ErrPermissionDenied.Error()
|
||||
}
|
||||
return s.EditBlockReason(post, isAdmin)
|
||||
}
|
||||
|
||||
func (s *PostService) SetEditLocked(postID uint, locked bool) error {
|
||||
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("edit_locked", locked)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
|
||||
var revs []model.PostRevision
|
||||
err := model.DB.Preload("Editor").Where("post_id = ?", postID).
|
||||
Order("id desc").Find(&revs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revs == nil {
|
||||
revs = []model.PostRevision{}
|
||||
}
|
||||
return revs, nil
|
||||
}
|
||||
|
||||
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) {
|
||||
var rev model.PostRevision
|
||||
err := model.DB.Preload("Editor").
|
||||
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
|
||||
if err != nil {
|
||||
return nil, ErrRevisionNotFound
|
||||
}
|
||||
return &rev, nil
|
||||
}
|
||||
|
||||
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
|
||||
|
||||
70
service/settings.go
Normal file
70
service/settings.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingPostEditWindowHours = "post_edit_window_hours"
|
||||
defaultEditWindowHours = 24
|
||||
)
|
||||
|
||||
// ForumSettingsService 论坛全局设置
|
||||
type ForumSettingsService struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewForumSettingsService() *ForumSettingsService {
|
||||
s := &ForumSettingsService{}
|
||||
s.ensureDefaults()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) ensureDefaults() {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", SettingPostEditWindowHours).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{
|
||||
Key: SettingPostEditWindowHours,
|
||||
Value: strconv.Itoa(defaultEditWindowHours),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PostEditWindowHours 返回用户可编辑帖子的时限(小时),0 表示不限
|
||||
func (s *ForumSettingsService) PostEditWindowHours() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var setting model.ForumSetting
|
||||
if err := model.DB.First(&setting, "`key` = ?", SettingPostEditWindowHours).Error; err != nil {
|
||||
return defaultEditWindowHours
|
||||
}
|
||||
h, err := strconv.Atoi(setting.Value)
|
||||
if err != nil || h < 0 {
|
||||
return defaultEditWindowHours
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// SetPostEditWindowHours 设置编辑时限(小时),0 表示不限
|
||||
func (s *ForumSettingsService) SetPostEditWindowHours(hours int) error {
|
||||
if hours < 0 {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return model.DB.Save(&model.ForumSetting{
|
||||
Key: SettingPostEditWindowHours,
|
||||
Value: strconv.Itoa(hours),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ForumSettings 返回所有可配置的论坛设置
|
||||
func (s *ForumSettingsService) ForumSettings() map[string]int {
|
||||
return map[string]int{
|
||||
"post_edit_window_hours": s.PostEditWindowHours(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user