2026-03-09 21:28:58 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"carrot_bbs/internal/cache"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"carrot_bbs/internal/model"
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
"carrot_bbs/internal/pkg/cursor"
|
2026-03-20 12:23:28 +08:00
|
|
|
|
"carrot_bbs/internal/pkg/hook"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"carrot_bbs/internal/repository"
|
2026-03-17 00:47:17 +08:00
|
|
|
|
|
|
|
|
|
|
"go.uber.org/zap"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type CommentService struct {
|
2026-03-26 18:14:16 +08:00
|
|
|
|
commentRepo repository.CommentRepository
|
|
|
|
|
|
postRepo repository.PostRepository
|
2026-03-09 21:28:58 +08:00
|
|
|
|
systemMessageService SystemMessageService
|
2026-03-10 12:58:23 +08:00
|
|
|
|
cache cache.Cache
|
2026-03-09 21:28:58 +08:00
|
|
|
|
postAIService *PostAIService
|
2026-03-15 02:25:10 +08:00
|
|
|
|
logService *LogService
|
2026-03-20 12:23:28 +08:00
|
|
|
|
hookManager *hook.Manager
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 07:03:21 +08:00
|
|
|
|
func NewCommentService(commentRepo repository.CommentRepository, postRepo repository.PostRepository, systemMessageService SystemMessageService, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager, logService *LogService) *CommentService {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return &CommentService{
|
|
|
|
|
|
commentRepo: commentRepo,
|
|
|
|
|
|
postRepo: postRepo,
|
|
|
|
|
|
systemMessageService: systemMessageService,
|
2026-03-13 09:38:18 +08:00
|
|
|
|
cache: cacheBackend,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
postAIService: postAIService,
|
2026-03-28 07:03:21 +08:00
|
|
|
|
logService: logService,
|
2026-03-20 12:23:28 +08:00
|
|
|
|
hookManager: hookManager,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create 创建评论
|
|
|
|
|
|
func (s *CommentService) Create(ctx context.Context, postID, userID, content string, parentID *string, images string, imageURLs []string) (*model.Comment, error) {
|
|
|
|
|
|
if s.postAIService != nil {
|
|
|
|
|
|
// 采用异步审核,前端先立即返回
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取帖子信息用于发送通知
|
|
|
|
|
|
post, err := s.postRepo.GetByID(postID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comment := &model.Comment{
|
|
|
|
|
|
PostID: postID,
|
|
|
|
|
|
UserID: userID,
|
|
|
|
|
|
Content: content,
|
|
|
|
|
|
ParentID: parentID,
|
|
|
|
|
|
Images: images,
|
|
|
|
|
|
Status: model.CommentStatusPending,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有父评论,设置根评论ID
|
|
|
|
|
|
var parentUserID string
|
|
|
|
|
|
if parentID != nil {
|
|
|
|
|
|
parent, err := s.commentRepo.GetByID(*parentID)
|
|
|
|
|
|
if err == nil && parent != nil {
|
|
|
|
|
|
if parent.RootID != nil {
|
|
|
|
|
|
comment.RootID = parent.RootID
|
|
|
|
|
|
} else {
|
|
|
|
|
|
comment.RootID = parentID
|
|
|
|
|
|
}
|
|
|
|
|
|
parentUserID = parent.UserID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = s.commentRepo.Create(comment)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// 记录操作日志
|
|
|
|
|
|
if s.logService != nil {
|
|
|
|
|
|
s.logService.OperationLog.RecordOperation(&model.OperationLog{
|
|
|
|
|
|
UserID: userID,
|
|
|
|
|
|
Operation: string(model.OpCommentCreate),
|
|
|
|
|
|
Module: "comment",
|
|
|
|
|
|
TargetType: "comment",
|
|
|
|
|
|
TargetID: comment.ID,
|
|
|
|
|
|
Status: "success",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 重新查询以获取关联的 User
|
|
|
|
|
|
comment, err = s.commentRepo.GetByID(comment.ID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
go s.reviewCommentAsync(comment.ID, userID, postID, content, imageURLs, parentID, parentUserID, post.UserID)
|
|
|
|
|
|
|
|
|
|
|
|
return comment, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *CommentService) reviewCommentAsync(
|
|
|
|
|
|
commentID, userID, postID, content string,
|
|
|
|
|
|
imageURLs []string,
|
|
|
|
|
|
parentID *string,
|
|
|
|
|
|
parentUserID string,
|
|
|
|
|
|
postOwnerID string,
|
|
|
|
|
|
) {
|
2026-03-20 12:23:28 +08:00
|
|
|
|
if s.hookManager == nil {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil {
|
2026-03-20 12:23:28 +08:00
|
|
|
|
zap.L().Warn("Failed to publish comment without hook manager",
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
if err := s.applyCommentPublishedStats(commentID); err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to apply published stats for comment",
|
|
|
|
|
|
zap.String("commentID", commentID),
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
s.invalidatePostCaches(postID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 12:23:28 +08:00
|
|
|
|
var authorID uint
|
|
|
|
|
|
fmt.Sscanf(userID, "%d", &authorID)
|
|
|
|
|
|
|
|
|
|
|
|
result := &hook.ModerationResult{}
|
|
|
|
|
|
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
|
|
|
|
|
CommentID: commentID,
|
|
|
|
|
|
PostID: postID,
|
|
|
|
|
|
Content: content,
|
|
|
|
|
|
Images: imageURLs,
|
|
|
|
|
|
AuthorID: authorID,
|
|
|
|
|
|
ParentID: parentID,
|
|
|
|
|
|
}, map[string]interface{}{
|
|
|
|
|
|
"result": result,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
s.hookManager.Trigger(context.Background(), hook.HookCommentModerated, authorID, &hook.CommentModeratedHookData{
|
|
|
|
|
|
CommentID: commentID,
|
|
|
|
|
|
PostID: postID,
|
|
|
|
|
|
AuthorID: authorID,
|
|
|
|
|
|
Approved: result.Approved,
|
|
|
|
|
|
RejectReason: result.RejectReason,
|
|
|
|
|
|
ReviewedBy: result.ReviewedBy,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if !result.Approved {
|
|
|
|
|
|
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
|
|
|
|
|
zap.L().Warn("Failed to delete rejected comment",
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.String("commentID", commentID),
|
2026-03-20 12:23:28 +08:00
|
|
|
|
zap.Error(delErr),
|
2026-03-17 00:47:17 +08:00
|
|
|
|
)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
2026-03-20 12:23:28 +08:00
|
|
|
|
s.notifyCommentModerationRejected(userID, result.RejectReason)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to publish comment",
|
|
|
|
|
|
zap.String("commentID", commentID),
|
|
|
|
|
|
zap.Error(updateErr),
|
|
|
|
|
|
)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to apply published stats for comment",
|
|
|
|
|
|
zap.String("commentID", commentID),
|
|
|
|
|
|
zap.Error(statsErr),
|
|
|
|
|
|
)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
s.invalidatePostCaches(postID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
func (s *CommentService) applyCommentPublishedStats(commentID string) error {
|
|
|
|
|
|
comment, err := s.commentRepo.GetByID(commentID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.commentRepo.ApplyPublishedStats(comment)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *CommentService) invalidatePostCaches(postID string) {
|
|
|
|
|
|
cache.InvalidatePostDetail(s.cache, postID)
|
|
|
|
|
|
cache.InvalidatePostList(s.cache)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (s *CommentService) afterCommentPublished(userID, postID, commentID string, parentID *string, parentUserID, postOwnerID string) {
|
|
|
|
|
|
// 发送系统消息通知
|
|
|
|
|
|
if s.systemMessageService != nil {
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
if parentID != nil && parentUserID != "" {
|
|
|
|
|
|
// 回复评论,通知被回复的人
|
|
|
|
|
|
if parentUserID != userID {
|
|
|
|
|
|
notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID)
|
|
|
|
|
|
if notifyErr != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Error("Error sending reply notification",
|
|
|
|
|
|
zap.Error(notifyErr),
|
|
|
|
|
|
)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 评论帖子,通知帖子作者
|
|
|
|
|
|
if postOwnerID != userID {
|
|
|
|
|
|
notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID)
|
|
|
|
|
|
if notifyErr != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Error("Error sending comment notification",
|
|
|
|
|
|
zap.Error(notifyErr),
|
|
|
|
|
|
)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *CommentService) notifyCommentModerationRejected(userID, reason string) {
|
|
|
|
|
|
if s.systemMessageService == nil || strings.TrimSpace(userID) == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
content := "您发布的评论未通过AI审核,请修改后重试。"
|
|
|
|
|
|
if strings.TrimSpace(reason) != "" {
|
|
|
|
|
|
content = fmt.Sprintf("您发布的评论未通过AI审核,原因:%s。请修改后重试。", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
if err := s.systemMessageService.SendSystemAnnouncement(
|
|
|
|
|
|
context.Background(),
|
|
|
|
|
|
[]string{userID},
|
|
|
|
|
|
"评论审核未通过",
|
|
|
|
|
|
content,
|
|
|
|
|
|
); err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to send comment moderation reject notification",
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetByID 根据ID获取评论
|
|
|
|
|
|
func (s *CommentService) GetByID(ctx context.Context, id string) (*model.Comment, error) {
|
|
|
|
|
|
return s.commentRepo.GetByID(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetByPostID 获取帖子评论
|
|
|
|
|
|
func (s *CommentService) GetByPostID(ctx context.Context, postID string, page, pageSize int) ([]*model.Comment, int64, error) {
|
|
|
|
|
|
// 使用带回复的查询,默认加载前3条回复
|
|
|
|
|
|
return s.commentRepo.GetByPostIDWithReplies(postID, page, pageSize, 3)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetRepliesByRootID 根据根评论ID分页获取回复
|
|
|
|
|
|
func (s *CommentService) GetRepliesByRootID(ctx context.Context, rootID string, page, pageSize int) ([]*model.Comment, int64, error) {
|
|
|
|
|
|
return s.commentRepo.GetRepliesByRootID(rootID, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetReplies 获取回复
|
|
|
|
|
|
func (s *CommentService) GetReplies(ctx context.Context, parentID string) ([]*model.Comment, error) {
|
|
|
|
|
|
return s.commentRepo.GetReplies(parentID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update 更新评论
|
|
|
|
|
|
func (s *CommentService) Update(ctx context.Context, comment *model.Comment) error {
|
|
|
|
|
|
return s.commentRepo.Update(comment)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete 删除评论
|
|
|
|
|
|
func (s *CommentService) Delete(ctx context.Context, id string) error {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
comment, err := s.commentRepo.GetByID(id)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.commentRepo.Delete(id); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
s.invalidatePostCaches(comment.PostID)
|
|
|
|
|
|
return nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Like 点赞评论
|
|
|
|
|
|
func (s *CommentService) Like(ctx context.Context, commentID, userID string) error {
|
|
|
|
|
|
// 获取评论信息用于发送通知
|
|
|
|
|
|
comment, err := s.commentRepo.GetByID(commentID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = s.commentRepo.Like(commentID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送评论/回复点赞通知(只有不是给自己点赞时才发送)
|
|
|
|
|
|
if s.systemMessageService != nil && comment.UserID != userID {
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
var notifyErr error
|
|
|
|
|
|
if comment.ParentID != nil {
|
|
|
|
|
|
notifyErr = s.systemMessageService.SendLikeReplyNotification(
|
|
|
|
|
|
context.Background(),
|
|
|
|
|
|
comment.UserID,
|
|
|
|
|
|
userID,
|
|
|
|
|
|
comment.PostID,
|
|
|
|
|
|
commentID,
|
|
|
|
|
|
comment.Content,
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
notifyErr = s.systemMessageService.SendLikeCommentNotification(
|
|
|
|
|
|
context.Background(),
|
|
|
|
|
|
comment.UserID,
|
|
|
|
|
|
userID,
|
|
|
|
|
|
comment.PostID,
|
|
|
|
|
|
commentID,
|
|
|
|
|
|
comment.Content,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if notifyErr != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Error("Error sending like notification",
|
|
|
|
|
|
zap.Error(notifyErr),
|
|
|
|
|
|
)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Unlike 取消点赞评论
|
|
|
|
|
|
func (s *CommentService) Unlike(ctx context.Context, commentID, userID string) error {
|
|
|
|
|
|
return s.commentRepo.Unlike(commentID, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsLiked 检查是否已点赞
|
|
|
|
|
|
func (s *CommentService) IsLiked(ctx context.Context, commentID, userID string) bool {
|
|
|
|
|
|
return s.commentRepo.IsLiked(commentID, userID)
|
|
|
|
|
|
}
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
|
|
|
|
|
|
// GetCommentsByCursor 游标分页获取帖子评论
|
|
|
|
|
|
func (s *CommentService) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
|
|
|
|
|
return s.commentRepo.GetCommentsByCursor(ctx, postID, req)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetRepliesByCursor 游标分页获取根评论的回复
|
|
|
|
|
|
func (s *CommentService) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
|
|
|
|
|
return s.commentRepo.GetRepliesByCursor(ctx, rootID, req)
|
|
|
|
|
|
}
|