Version: 0.9.78.dev.260506
This commit is contained in:
284
backend/services/taskclassforum/sv/comment.go
Normal file
284
backend/services/taskclassforum/sv/comment.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/commenttree"
|
||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
)
|
||||
|
||||
// ListComments 查询评论树。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端;
|
||||
// 2. 数据库存储仍是扁平 parent_comment_id,树结构由 commenttree 包组装;
|
||||
// 3. 采用 cache-aside 缓存去个性化评论树,返回前再补当前用户的删除权限。
|
||||
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
if postID == 0 {
|
||||
return nil, forumcontracts.PageResult{}, respond.MissingParam
|
||||
}
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
sortBy = normalizeCommentSort(sortBy)
|
||||
if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil {
|
||||
return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
|
||||
if cachedItems, cachedPage, hit := s.getCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy); hit {
|
||||
return personalizeCommentNodesForActor(cachedItems, actorUserID), cachedPage, nil
|
||||
}
|
||||
|
||||
total, err := s.forumDAO.CountRootComments(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
roots, err := s.forumDAO.ListRootComments(ctx, postID, page, pageSize, sortBy)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
resultPage := pageResult(page, pageSize, total)
|
||||
if len(roots) == 0 {
|
||||
emptyItems := []forumcontracts.ForumCommentNode{}
|
||||
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, emptyItems, resultPage)
|
||||
return emptyItems, resultPage, nil
|
||||
}
|
||||
allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
sharedNodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), 0)
|
||||
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, sharedNodes, resultPage)
|
||||
return personalizeCommentNodesForActor(sharedNodes, actorUserID), resultPage, nil
|
||||
}
|
||||
|
||||
// CreateComment 创建帖子评论或多层回复。
|
||||
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.PostID == 0 || strings.TrimSpace(req.Content) == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if err := validateRuneMax(req.Content, maxCommentLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey != "" {
|
||||
existing, err := s.forumDAO.FindCommentByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return commentModelToNode(*existing, req.ActorUserID), nil
|
||||
}
|
||||
}
|
||||
|
||||
var created forummodel.ForumComment
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
if req.ParentCommentID != nil {
|
||||
parent, err := txDAO.FindCommentByID(ctx, *req.ParentCommentID)
|
||||
if err != nil {
|
||||
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||
}
|
||||
if parent.PostID != req.PostID {
|
||||
return respond.MissingParam
|
||||
}
|
||||
}
|
||||
created = forummodel.ForumComment{
|
||||
PostID: req.PostID,
|
||||
ParentCommentID: req.ParentCommentID,
|
||||
UserID: req.ActorUserID,
|
||||
Content: strings.TrimSpace(req.Content),
|
||||
Status: forummodel.ForumCommentStatusVisible,
|
||||
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||
}
|
||||
if err := txDAO.CreateComment(ctx, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
return txDAO.AddPostCounter(ctx, req.PostID, "comment_count", 1)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.bumpCommentTreeVersionBestEffort(req.PostID)
|
||||
return commentModelToNode(created, req.ActorUserID), nil
|
||||
}
|
||||
|
||||
// DeleteComment 软删除当前用户自己的评论。
|
||||
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actorUserID == 0 || commentID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
var deletedAt *string
|
||||
var changedPostID uint64
|
||||
status := forummodel.ForumCommentStatusDeleted
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
comment, err := txDAO.LockCommentByID(ctx, commentID)
|
||||
if err != nil {
|
||||
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||
}
|
||||
if comment.UserID != actorUserID {
|
||||
return respond.ErrUnauthorized
|
||||
}
|
||||
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||
deletedAt = formatTimePtr(comment.DeletedAt)
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if err := txDAO.SoftDeleteComment(ctx, commentID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil {
|
||||
return err
|
||||
}
|
||||
changedPostID = comment.PostID
|
||||
deletedAt = formatTimePtr(&now)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changedPostID != 0 {
|
||||
s.bumpCommentTreeVersionBestEffort(changedPostID)
|
||||
}
|
||||
return &forumcontracts.DeleteForumCommentResult{
|
||||
CommentID: commentID,
|
||||
Status: status,
|
||||
Content: "",
|
||||
DeletedAt: deletedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func commentModelToNode(comment forummodel.ForumComment, actorUserID uint64) *forumcontracts.ForumCommentNode {
|
||||
content := comment.Content
|
||||
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||
content = "该评论已删除"
|
||||
}
|
||||
return &forumcontracts.ForumCommentNode{
|
||||
CommentID: comment.ID,
|
||||
PostID: comment.PostID,
|
||||
ParentCommentID: comment.ParentCommentID,
|
||||
Content: content,
|
||||
Status: comment.Status,
|
||||
Author: userBrief(comment.UserID),
|
||||
CanDelete: comment.Status == forummodel.ForumCommentStatusVisible && comment.UserID == actorUserID,
|
||||
CreatedAt: formatTime(comment.CreatedAt),
|
||||
DeletedAt: formatTimePtr(comment.DeletedAt),
|
||||
Children: []forumcontracts.ForumCommentNode{},
|
||||
}
|
||||
}
|
||||
|
||||
func filterCommentsForRoots(allComments []forummodel.ForumComment, roots []forummodel.ForumComment) []forummodel.ForumComment {
|
||||
filtered := make([]forummodel.ForumComment, 0, len(allComments))
|
||||
included := make(map[uint64]struct{}, len(allComments))
|
||||
for _, root := range roots {
|
||||
filtered = append(filtered, root)
|
||||
included[root.ID] = struct{}{}
|
||||
}
|
||||
candidateSet := make(map[uint64]struct{}, len(allComments))
|
||||
for _, root := range roots {
|
||||
collectDescendantCommentIDs(root.ID, allComments, candidateSet)
|
||||
}
|
||||
for _, comment := range allComments {
|
||||
if _, ok := included[comment.ID]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := candidateSet[comment.ID]; ok {
|
||||
filtered = append(filtered, comment)
|
||||
included[comment.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumComment, result map[uint64]struct{}) {
|
||||
for _, comment := range comments {
|
||||
if comment.ParentCommentID == nil || *comment.ParentCommentID != parentID {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[comment.ID]; exists {
|
||||
continue
|
||||
}
|
||||
result[comment.ID] = struct{}{}
|
||||
collectDescendantCommentIDs(comment.ID, comments, result)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCommentSort(sortBy string) string {
|
||||
if strings.TrimSpace(sortBy) == "latest" {
|
||||
return "latest"
|
||||
}
|
||||
return "oldest"
|
||||
}
|
||||
|
||||
func (s *Service) getCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool) {
|
||||
if s == nil || s.commentTreeCache == nil {
|
||||
return nil, forumcontracts.PageResult{}, false
|
||||
}
|
||||
items, resultPage, hit, err := s.commentTreeCache.GetCommentTree(ctx, postID, page, pageSize, sortBy)
|
||||
if err != nil {
|
||||
log.Printf("评论树缓存读取失败,已降级回源 DB post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
|
||||
return nil, forumcontracts.PageResult{}, false
|
||||
}
|
||||
return items, resultPage, hit
|
||||
}
|
||||
|
||||
func (s *Service) setCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string, items []forumcontracts.ForumCommentNode, resultPage forumcontracts.PageResult) {
|
||||
if s == nil || s.commentTreeCache == nil {
|
||||
return
|
||||
}
|
||||
if err := s.commentTreeCache.SetCommentTree(ctx, postID, page, pageSize, sortBy, items, resultPage); err != nil {
|
||||
log.Printf("评论树缓存写入失败,已保持 DB 结果返回 post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) bumpCommentTreeVersionBestEffort(postID uint64) {
|
||||
if s == nil || s.commentTreeCache == nil || postID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 写库事务已经成功,缓存失效不应再反向影响评论发布/删除结果。
|
||||
// 2. 使用独立短超时 context,避免客户端取消请求后漏掉版本递增。
|
||||
// 3. 失败时只记录日志,旧缓存依靠短 TTL 自然过期作为兜底。
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := s.commentTreeCache.BumpCommentTreeVersion(cacheCtx, postID); err != nil {
|
||||
log.Printf("评论树缓存版本递增失败,等待短 TTL 自然过期 post_id=%d err=%v", postID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func personalizeCommentNodesForActor(nodes []forumcontracts.ForumCommentNode, actorUserID uint64) []forumcontracts.ForumCommentNode {
|
||||
if nodes == nil {
|
||||
return []forumcontracts.ForumCommentNode{}
|
||||
}
|
||||
result := make([]forumcontracts.ForumCommentNode, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
result = append(result, personalizeCommentNodeForActor(node, actorUserID))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func personalizeCommentNodeForActor(node forumcontracts.ForumCommentNode, actorUserID uint64) forumcontracts.ForumCommentNode {
|
||||
children := make([]forumcontracts.ForumCommentNode, 0, len(node.Children))
|
||||
for _, child := range node.Children {
|
||||
children = append(children, personalizeCommentNodeForActor(child, actorUserID))
|
||||
}
|
||||
node.Children = children
|
||||
node.CanDelete = actorUserID != 0 &&
|
||||
node.Author.UserID == actorUserID &&
|
||||
node.Status == forummodel.ForumCommentStatusVisible
|
||||
return node
|
||||
}
|
||||
Reference in New Issue
Block a user