feat: 实现评论系统(后端 + 前端)

- 新增 Comment/CommentMention 模型,Post 新增 CommentsCount 冗余计数器
- 新增 CommentRepo(CRUD + @搜索 LV3+ + 回复统计 + 文章作者查询)
- 新增 CommentService(发表/回复/删除/@解析/通知 NotifyComment/NotifyCommentReply)
- 新增 CommentController(6 个 API 端点),commentUseCase ISP 接口
- 新增评论路由(公开读取 + 需登录写入),依赖注入,错误哨兵,数据库迁移
- 前端评论区 HTML/CSS/JS:分页加载、回复折叠展开、内联回复表单、@提及自动补全
This commit is contained in:
2026-06-01 13:24:16 +08:00
parent b44d7ca5c3
commit b7acc91f8c
15 changed files with 1416 additions and 7 deletions

View File

@ -0,0 +1,198 @@
package service
import (
"errors"
"fmt"
"regexp"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
var mentionPattern = regexp.MustCompile(`@(\S{1,16})`)
// CommentService 评论业务逻辑
type CommentService struct {
repo commentStore
notifier commentNotifier
}
// NewCommentService 构造函数
func NewCommentService(repo commentStore, notifier commentNotifier) *CommentService {
return &CommentService{repo: repo, notifier: notifier}
}
// CreateRoot 创建顶级评论
func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*model.Comment, error) {
if strings.TrimSpace(body) == "" {
return nil, errors.New("评论内容不能为空")
}
postAuthorID, err := s.repo.FindPostAuthorID(postID)
if err != nil {
return nil, fmt.Errorf("查询文章失败: %w", err)
}
comment := &model.Comment{
PostID: postID,
RootID: 0,
Body: body,
UserID: userID,
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建评论失败: %w", err)
}
// RootID 指向自身UPDATE 而非 INSERT
comment.RootID = comment.ID
if err := s.repo.UpdateRootID(comment.ID, comment.ID); err != nil {
return nil, fmt.Errorf("更新根评论ID失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(postID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 通知文章作者(评论者 ≠ 作者)
if s.notifier != nil && userID != postAuthorID {
commenterUID, _ := s.repo.GetUserUIDByID(userID)
s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterUID),
&comment.ID)
}
return comment, nil
}
// CreateReply 创建回复
func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*model.Comment, error) {
if strings.TrimSpace(body) == "" {
return nil, errors.New("回复内容不能为空")
}
parent, err := s.repo.FindByID(parentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("父评论不存在")
}
return nil, fmt.Errorf("查询父评论失败: %w", err)
}
comment := &model.Comment{
PostID: parent.PostID,
RootID: parent.RootID,
ParentID: &parentID,
Body: body,
UserID: userID,
}
// 获取被回复者UID
if replyToUID, err := s.repo.GetUserUIDByID(parent.UserID); err == nil {
comment.ReplyToUID = replyToUID
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建回复失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 通知被回复者(不自己回复自己)
if s.notifier != nil && parent.UserID != userID {
commenterUID, _ := s.repo.GetUserUIDByID(userID)
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterUID),
&comment.ID)
}
return comment, nil
}
// Delete 软删除评论
func (s *CommentService) Delete(commentID uint) error {
comment, err := s.repo.FindByID(commentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("评论不存在")
}
return fmt.Errorf("查询评论失败: %w", err)
}
if comment.IsDeleted {
return nil
}
if err := s.repo.SoftDelete(commentID); err != nil {
return fmt.Errorf("删除评论失败: %w", err)
}
_ = s.repo.DecrCommentsCount(comment.PostID)
return nil
}
// ListRootComments 分页获取文章顶级评论
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
}
// ListReplies 获取某条顶级评论的全部回复
func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
list, err := s.repo.FindReplies(rootID)
if err != nil {
return nil, fmt.Errorf("查询回复失败: %w", err)
}
if list == nil {
list = []model.Comment{}
}
return list, nil
}
// SearchUsers 搜索LV3+用户(用于@艾特)
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err)
}
users := make([]model.UserSearchResult, len(rows))
for i, row := range rows {
users[i] = model.UserSearchResult{
UID: row.UID,
Username: row.Username,
Level: 0,
}
}
return users, nil
}
// parseMentions 解析@用户并存储到 comment_mentions 表
func (s *CommentService) parseMentions(commentID uint, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool)
for _, m := range matches {
username := m[1]
if seen[username] {
continue
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1)
if len(rows) > 0 {
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: rows[0].UID,
})
}
}
}

View File

@ -115,3 +115,28 @@ type energyStore = EnergyStore
type energyNotifier interface {
Create(userID uint, notifyType, title, content string, relatedID *uint) error
}
// commentStore CommentService 所需的最小仓储接口ISP
type commentStore interface {
Create(comment *model.Comment) error
UpdateRootID(id, rootID uint) error
FindByID(id uint) (*model.Comment, error)
FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error)
FindReplies(rootID uint) ([]model.Comment, error)
SoftDelete(id uint) error
IncrCommentsCount(postID uint) error
DecrCommentsCount(postID uint) error
CreateMention(mention *model.CommentMention) error
FindPostIDByComment(commentID uint) (uint, error)
FindPostAuthorID(postID uint) (uint, error)
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
UID string
Username string
}, error)
GetUserUIDByID(userID uint) (string, error)
}
// commentNotifier CommentService 所需的通知接口ISP
type commentNotifier interface {
Create(userID uint, notifyType, title, content string, relatedID *uint) error
}