feat: 实现评论系统(后端 + 前端)
- 新增 Comment/CommentMention 模型,Post 新增 CommentsCount 冗余计数器 - 新增 CommentRepo(CRUD + @搜索 LV3+ + 回复统计 + 文章作者查询) - 新增 CommentService(发表/回复/删除/@解析/通知 NotifyComment/NotifyCommentReply) - 新增 CommentController(6 个 API 端点),commentUseCase ISP 接口 - 新增评论路由(公开读取 + 需登录写入),依赖注入,错误哨兵,数据库迁移 - 前端评论区 HTML/CSS/JS:分页加载、回复折叠展开、内联回复表单、@提及自动补全
This commit is contained in:
198
internal/service/comment_service.go
Normal file
198
internal/service/comment_service.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user