Files
mce/internal/service/comment_service.go
Victor_Jay 8bf6ac9e51 feat: @提及发送通知 + 通知点击定位高亮
- 新增 NotifyCommentMention 通知类型,归属 mention 分类 TAB
- parseMentions 中向被@用户发送通知(排除自己和重复通知)
- 消息中心 mention 通知可点击跳转至文章页面
- 通过 mention 通知进入时,来源评论置顶并持久半透明高亮
- 普通评论链接保持原有短暂高亮行为不变
2026-06-03 13:21:27 +08:00

245 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, common.ErrCommentEmpty
}
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)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, postID, userID, commenterName, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName),
&comment.PostID, &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, common.ErrCommentEmpty
}
parent, err := s.repo.FindByID(parentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrCommentNotFound
}
return nil, fmt.Errorf("查询父评论失败: %w", err)
}
comment := &model.Comment{
PostID: parent.PostID,
RootID: parent.RootID,
ParentID: &parentID,
Body: body,
UserID: userID,
ReplyToUID: parent.UserID,
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建回复失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName),
&parent.PostID, &comment.ID)
}
return comment, nil
}
// Delete 软删除评论requesterID 为请求者isAdmin 表示是否为管理员)
func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool) error {
comment, err := s.repo.FindByID(commentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrCommentNotFound
}
return fmt.Errorf("查询评论失败: %w", err)
}
if comment.IsDeleted {
return nil
}
// 权限检查:仅本人或管理员可删除
if !isAdmin && comment.UserID != requesterID {
return common.ErrPermissionDenied
}
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()
comments, total, err := s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
if err != nil {
return nil, 0, err
}
// 批量加载有效@提及映射,前端据此决定是否为链接
if len(comments) > 0 {
_ = s.repo.LoadMentionsForComments(comments)
}
return comments, total, nil
}
// 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{}
}
// 批量加载有效@提及映射
if len(list) > 0 {
_ = s.repo.LoadMentionsForComments(list)
}
return list, nil
}
// ListAllComments 后台评论列表(分页+搜索)
func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
rows, total, err := s.repo.ListAllComments(keyword, showDeleted, p.Offset(), p.PageSize)
if rows == nil {
rows = []model.AdminCommentRow{}
}
return rows, total, err
}
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗已关注优先最多5条
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
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: model.GetLevelByExp(row.Exp),
Avatar: row.Avatar,
}
}
return users, nil
}
// parseMentions 解析@用户,存储到 comment_mentions 表,并向被@用户发送通知
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool)
notified := make(map[uint]bool) // 防止重复通知同一用户
for _, m := range matches {
username := m[1]
if seen[username] {
continue
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
if len(rows) == 0 {
continue
}
mentionedUID := rows[0].UID
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: mentionedUID,
OriginalUsername: username,
})
// 向被@用户发送通知(排除自己@自己、重复通知)
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
notified[mentionedUID] = true
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
"新提及",
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
&postID, &commentID)
}
}
}