补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 06:32:06 +08:00
parent 0f83183620
commit 10185178e2
28 changed files with 1165 additions and 266 deletions

View File

@@ -194,6 +194,11 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
return nil, errors.New("账号已被禁言")
}
// 讨论锁定:管理员亦不可强评(避免结贴后仍被顶楼)
if post.CommentsLocked {
return nil, ErrPostCommentsLocked
}
// 未公开帖仅作者/管理员可评论
if post.Status != model.ContentStatusPublished && post.Status != "" {
if user.Role != model.RoleAdmin && post.UserID != in.UserID {

View File

@@ -25,6 +25,7 @@ var (
ErrPermissionDenied = errors.New("无权操作")
ErrBoardNotFound = errors.New("板块不存在")
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
ErrPostEditExpired = errors.New("已超过可编辑时限")
ErrRevisionNotFound = errors.New("历史版本不存在")
ErrInvalidSetting = errors.New("无效的设置值")

View File

@@ -130,6 +130,59 @@ func (s *MessageService) UnreadCount(userID uint) (int64, error) {
return n, err
}
// UnreadCounts 未读总数,以及私信 / 系统通知分项
func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) {
err = model.DB.Model(&model.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false).
Count(&total).Error
if err != nil {
return 0, 0, 0, err
}
err = model.DB.Model(&model.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false).
Count(&notify).Error
if err != nil {
return 0, 0, 0, err
}
dm = total - notify
if dm < 0 {
dm = 0
}
return total, dm, notify, nil
}
// ListNotifications 系统通知列表(按时间倒序,非聊天气泡)
func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]model.PrivateMessage, int64, error) {
if page < 1 {
page = 1
}
size = s.settings.NormalizePageSize(size)
db := model.DB.Model(&model.PrivateMessage{}).
Where("from_user_id = 0 AND to_user_id = ?", userID)
kind = strings.TrimSpace(kind)
if kind != "" && kind != "all" {
db = db.Where("kind = ?", kind)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.PrivateMessage
err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error
if err != nil {
return nil, 0, err
}
if list == nil {
list = []model.PrivateMessage{}
}
return list, total, nil
}
// MarkNotificationsRead 将系统通知全部标为已读
func (s *MessageService) MarkNotificationsRead(userID uint) error {
return s.MarkConversationRead(userID, 0)
}
// MessageConversation 按对方聚合的会话摘要
type MessageConversation struct {
PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知

View File

@@ -34,6 +34,7 @@ type PostListQuery struct {
Page int
Size int
Keyword string
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE
Sort string // latest | reply | hot
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool
@@ -271,6 +272,12 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
kw := "%" + q.Keyword + "%"
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
}
if tag := strings.TrimSpace(q.Tag); tag != "" {
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
escaped := escapeLikePattern(strings.ToLower(tag))
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), '', ','), ', ', ','), ' ,', ',') || ',')"
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
}
var total int64
db.Count(&total)
var posts []model.Post
@@ -310,6 +317,14 @@ func normalizePostSort(sort string) string {
}
}
// escapeLikePattern 转义 LIKE 通配符,配合 ESCAPE '\'
func escapeLikePattern(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
@@ -535,6 +550,18 @@ func (s *PostService) SetEditLocked(postID uint, locked bool) error {
return nil
}
// SetCommentsLocked 锁定/解锁讨论(禁止新评论)
func (s *PostService) SetCommentsLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("comments_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).