package repository import ( "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/cursor" "context" "gorm.io/gorm" ) // CommentRepository 评论仓储 type CommentRepository struct { db *gorm.DB } // NewCommentRepository 创建评论仓储 func NewCommentRepository(db *gorm.DB) *CommentRepository { return &CommentRepository{db: db} } // Create 创建评论 func (r *CommentRepository) Create(comment *model.Comment) error { return r.db.Create(comment).Error } // GetByID 根据ID获取评论 func (r *CommentRepository) GetByID(id string) (*model.Comment, error) { var comment model.Comment err := r.db.Preload("User").First(&comment, "id = ?", id).Error if err != nil { return nil, err } return &comment, nil } // Update 更新评论 func (r *CommentRepository) Update(comment *model.Comment) error { return r.db.Save(comment).Error } // UpdateModerationStatus 更新评论审核状态 func (r *CommentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus) error { // 审核状态更新属于管理操作,不应影响评论内容更新时间(updated_at) return r.db.Model(&model.Comment{}). Where("id = ?", commentID). UpdateColumn("status", status).Error } // Delete 删除评论(软删除,同时清理关联数据) func (r *CommentRepository) Delete(id string) error { return r.db.Transaction(func(tx *gorm.DB) error { // 先查询评论获取post_id和parent_id var comment model.Comment if err := tx.First(&comment, "id = ?", id).Error; err != nil { return err } // 删除评论点赞记录 if err := tx.Where("comment_id = ?", id).Delete(&model.CommentLike{}).Error; err != nil { return err } // 删除评论(软删除) if err := tx.Delete(&model.Comment{}, "id = ?", id).Error; err != nil { return err } // 仅已发布评论才参与统计,避免 pending/rejected 影响计数 if comment.Status == model.CommentStatusPublished { // 减少帖子的评论数并同步热度分 // 评论数属于统计字段,不应影响帖子内容更新时间(updated_at) if err := tx.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumns(map[string]interface{}{ "comments_count": gorm.Expr("comments_count - 1"), "updated_at": gorm.Expr("updated_at"), }).Error; err != nil { return err } // 如果是回复,减少父评论的回复数 if comment.ParentID != nil && *comment.ParentID != "" { if err := tx.Model(&model.Comment{}).Where("id = ?", *comment.ParentID). UpdateColumn("replies_count", gorm.Expr("replies_count - 1")).Error; err != nil { return err } } } return nil }) } // ApplyPublishedStats 在评论审核通过后更新帖子评论数/回复数 func (r *CommentRepository) ApplyPublishedStats(comment *model.Comment) error { if comment == nil { return nil } return r.db.Transaction(func(tx *gorm.DB) error { // 增加帖子的评论数并同步热度分 // 评论数属于统计字段,不应影响帖子内容更新时间(updated_at) if err := tx.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumns(map[string]any{ "comments_count": gorm.Expr("comments_count + 1"), "updated_at": gorm.Expr("updated_at"), }).Error; err != nil { return err } // 如果是回复,增加父评论的回复数 if comment.ParentID != nil && *comment.ParentID != "" { if err := tx.Model(&model.Comment{}).Where("id = ?", *comment.ParentID). UpdateColumn("replies_count", gorm.Expr("replies_count + 1")).Error; err != nil { return err } } return nil }) } // GetByPostID 获取帖子评论 func (r *CommentRepository) GetByPostID(postID string, page, pageSize int) ([]*model.Comment, int64, error) { var comments []*model.Comment var total int64 r.db.Model(&model.Comment{}).Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished).Count(&total) offset := (page - 1) * pageSize err := r.db.Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished). Preload("User"). Offset(offset).Limit(pageSize). Order("created_at ASC"). Find(&comments).Error return comments, total, err } // GetByPostIDWithReplies 获取帖子评论(包含回复,扁平化结构) // 所有层级的回复都扁平化展示在顶级评论的 replies 中 func (r *CommentRepository) GetByPostIDWithReplies(postID string, page, pageSize, replyLimit int) ([]*model.Comment, int64, error) { var comments []*model.Comment var total int64 r.db.Model(&model.Comment{}).Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished).Count(&total) offset := (page - 1) * pageSize err := r.db.Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished). Preload("User"). Offset(offset).Limit(pageSize). Order("created_at ASC"). Find(&comments).Error if err != nil { return nil, 0, err } if len(comments) == 0 { return comments, total, nil } rootIDs := make([]string, 0, len(comments)) commentsByID := make(map[string]*model.Comment, len(comments)) for _, comment := range comments { rootIDs = append(rootIDs, comment.ID) commentsByID[comment.ID] = comment } // 批量加载所有回复,内存中按 root_id 分组并裁剪每个根评论的返回条数 var allReplies []*model.Comment if err := r.db.Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished). Preload("User"). Order("created_at ASC"). Find(&allReplies).Error; err != nil { return nil, 0, err } repliesByRoot := make(map[string][]*model.Comment, len(rootIDs)) for _, reply := range allReplies { if reply.RootID == nil { continue } rootID := *reply.RootID if replyLimit <= 0 || len(repliesByRoot[rootID]) < replyLimit { repliesByRoot[rootID] = append(repliesByRoot[rootID], reply) } } type replyCountRow struct { RootID string Total int64 } var replyCountRows []replyCountRow if err := r.db.Model(&model.Comment{}). Select("root_id, COUNT(*) AS total"). Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished). Group("root_id"). Scan(&replyCountRows).Error; err != nil { return nil, 0, err } replyCountMap := make(map[string]int64, len(replyCountRows)) for _, row := range replyCountRows { replyCountMap[row.RootID] = row.Total } for _, rootID := range rootIDs { comment := commentsByID[rootID] comment.Replies = repliesByRoot[rootID] comment.RepliesCount = int(replyCountMap[rootID]) } return comments, total, nil } // loadFlatReplies 加载评论的所有回复(扁平化,所有层级都在同一层) func (r *CommentRepository) loadFlatReplies(rootComment *model.Comment, limit int) { var allReplies []*model.Comment // 查询所有以该评论为根评论的回复(不包括顶级评论本身) r.db.Where("root_id = ? AND status = ?", rootComment.ID, model.CommentStatusPublished). Preload("User"). Order("created_at ASC"). Limit(limit). Find(&allReplies) rootComment.Replies = allReplies } // GetRepliesByRootID 根据根评论ID分页获取回复(扁平化) func (r *CommentRepository) GetRepliesByRootID(rootID string, page, pageSize int) ([]*model.Comment, int64, error) { var replies []*model.Comment var total int64 // 统计总数 r.db.Model(&model.Comment{}).Where("root_id = ? AND status = ?", rootID, model.CommentStatusPublished).Count(&total) // 分页查询 offset := (page - 1) * pageSize err := r.db.Where("root_id = ? AND status = ?", rootID, model.CommentStatusPublished). Preload("User"). Order("created_at ASC"). Offset(offset). Limit(pageSize). Find(&replies).Error return replies, total, err } // GetReplies 获取回复 func (r *CommentRepository) GetReplies(parentID string) ([]*model.Comment, error) { var comments []*model.Comment err := r.db.Where("parent_id = ? AND status = ?", parentID, model.CommentStatusPublished). Preload("User"). Order("created_at ASC"). Find(&comments).Error return comments, err } // Like 点赞评论(使用事务保证数据一致性) func (r *CommentRepository) Like(commentID, userID string) error { return r.db.Transaction(func(tx *gorm.DB) error { // 检查是否已经点赞 var existing model.CommentLike err := tx.Where("comment_id = ? AND user_id = ?", commentID, userID).First(&existing).Error if err == nil { // 已经点赞 return nil } if err != gorm.ErrRecordNotFound { return err } // 创建点赞记录 like := &model.CommentLike{ CommentID: commentID, UserID: userID, } if err := tx.Create(like).Error; err != nil { return err } // 增加评论点赞数 return tx.Model(&model.Comment{}).Where("id = ?", commentID). UpdateColumn("likes_count", gorm.Expr("likes_count + 1")).Error }) } // Unlike 取消点赞评论(使用事务保证数据一致性) func (r *CommentRepository) Unlike(commentID, userID string) error { return r.db.Transaction(func(tx *gorm.DB) error { result := tx.Where("comment_id = ? AND user_id = ?", commentID, userID).Delete(&model.CommentLike{}) if result.Error != nil { return result.Error } if result.RowsAffected > 0 { // 减少评论点赞数 return tx.Model(&model.Comment{}).Where("id = ?", commentID). UpdateColumn("likes_count", gorm.Expr("likes_count - 1")).Error } return nil }) } // IsLiked 检查是否已点赞 func (r *CommentRepository) IsLiked(commentID, userID string) bool { var count int64 r.db.Model(&model.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count) return count > 0 } // IsLikedBatch 批量检查是否点赞(解决 N+1 问题) // 返回 map[commentID]bool func (r *CommentRepository) IsLikedBatch(commentIDs []string, userID string) map[string]bool { result := make(map[string]bool) if len(commentIDs) == 0 || userID == "" { return result } // 初始化所有 commentID 为 false for _, commentID := range commentIDs { result[commentID] = false } // 一次性查询所有点赞记录 var likedCommentIDs []string r.db.Model(&model.CommentLike{}). Where("comment_id IN ? AND user_id = ?", commentIDs, userID). Pluck("comment_id", &likedCommentIDs) // 更新已点赞的评论 for _, commentID := range likedCommentIDs { result[commentID] = true } return result } // AdminCommentListFilter 管理端评论列表筛选条件 type AdminCommentListFilter struct { Keyword string PostID string AuthorID string Status string StartDate string EndDate string Page int PageSize int } // GetAdminCommentList 获取管理端评论列表 func (r *CommentRepository) GetAdminCommentList(filter AdminCommentListFilter) ([]*model.Comment, int64, error) { var comments []*model.Comment var total int64 query := r.db.Model(&model.Comment{}).Preload("User") // 应用筛选条件 if filter.Keyword != "" { query = query.Where("content LIKE ?", "%"+filter.Keyword+"%") } if filter.PostID != "" { query = query.Where("post_id = ?", filter.PostID) } if filter.AuthorID != "" { query = query.Where("user_id = ?", filter.AuthorID) } if filter.Status != "" { query = query.Where("status = ?", filter.Status) } if filter.StartDate != "" { query = query.Where("created_at >= ?", filter.StartDate) } if filter.EndDate != "" { query = query.Where("created_at <= ?", filter.EndDate+" 23:59:59") } // 统计总数 if err := query.Count(&total).Error; err != nil { return nil, 0, err } // 分页查询 offset := (filter.Page - 1) * filter.PageSize if err := query.Offset(offset).Limit(filter.PageSize).Order("created_at DESC").Find(&comments).Error; err != nil { return nil, 0, err } return comments, total, nil } // GetAdminCommentByID 获取管理端评论详情(包含关联信息) func (r *CommentRepository) GetAdminCommentByID(id string) (*model.Comment, error) { var comment model.Comment err := r.db.Preload("User").First(&comment, "id = ?", id).Error if err != nil { return nil, err } return &comment, nil } // BatchDelete 批量删除评论(使用单次事务批量删除,避免 N+1 问题) func (r *CommentRepository) BatchDelete(ids []string) ([]string, error) { if len(ids) == 0 { return nil, nil } // 先查询所有评论获取 post_id 和状态信息 type commentInfo struct { ID string PostID string Status model.CommentStatus } var comments []commentInfo if err := r.db.Model(&model.Comment{}).Where("id IN ?", ids).Scan(&comments).Error; err != nil { return ids, err } err := r.db.Transaction(func(tx *gorm.DB) error { // 批量删除评论点赞记录 if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentLike{}).Error; err != nil { return err } // 批量删除评论(软删除) if err := tx.Where("id IN ?", ids).Delete(&model.Comment{}).Error; err != nil { return err } // 更新帖子的评论数(仅统计已发布评论) postCountMap := make(map[string]int) for _, c := range comments { if c.Status == model.CommentStatusPublished { postCountMap[c.PostID]++ } } for postID, count := range postCountMap { if err := tx.Model(&model.Post{}).Where("id = ?", postID). UpdateColumns(map[string]any{ "comments_count": gorm.Expr("comments_count - ?", count), "updated_at": gorm.Expr("updated_at"), }).Error; err != nil { return err } } return nil }) if err != nil { return ids, err } return nil, nil } // GetCommentsByPostIDForAdmin 管理端获取帖子的评论列表(包含所有状态) func (r *CommentRepository) GetCommentsByPostIDForAdmin(postID string, page, pageSize int) ([]*model.Comment, int64, error) { var comments []*model.Comment var total int64 r.db.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&total) offset := (page - 1) * pageSize err := r.db.Where("post_id = ?", postID). Preload("User"). Offset(offset).Limit(pageSize). Order("created_at DESC"). Find(&comments).Error return comments, total, err } // ========== Cursor Pagination Methods ========== // GetCommentsByCursor 游标分页获取帖子评论(包含回复) // 排序方式:created_at DESC func (r *CommentRepository) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) { // 构建基础查询 - 只查询顶级评论 query := r.db.WithContext(ctx).Model(&model.Comment{}). Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished) // 使用游标构建器 builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc). WithCursor(req.Cursor, req.Direction). WithPageSize(req.PageSize) if builder.Error() != nil { // 无效游标,返回空列表 return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil } // 执行查询 var comments []*model.Comment query = builder.Build().Preload("User") if err := query.Find(&comments).Error; err != nil { return nil, err } // 构建响应 pageSize := builder.GetPageSize() hasMore := cursor.HasMore(len(comments), pageSize) if hasMore { comments = comments[:pageSize] } // 加载每个评论的回复(默认加载前3条) if len(comments) > 0 { r.loadRepliesForComments(comments, 3) } // 生成游标 var nextCursor, prevCursor string if len(comments) > 0 { // 下一页游标 if hasMore { lastComment := comments[len(comments)-1] nextCursor = cursor.NewCursor( cursor.FormatTime(lastComment.CreatedAt), lastComment.ID, cursor.SortByCreatedAtDesc, ).Encode() } // 上一页游标 firstComment := comments[0] prevCursor = cursor.NewCursor( cursor.FormatTime(firstComment.CreatedAt), firstComment.ID, cursor.SortByCreatedAtDesc, ).Encode() } return cursor.NewCursorPageResult(comments, nextCursor, prevCursor, hasMore), nil } // loadRepliesForComments 为评论列表加载回复 func (r *CommentRepository) loadRepliesForComments(comments []*model.Comment, replyLimit int) { if len(comments) == 0 { return } rootIDs := make([]string, 0, len(comments)) commentsByID := make(map[string]*model.Comment, len(comments)) for _, comment := range comments { rootIDs = append(rootIDs, comment.ID) commentsByID[comment.ID] = comment } // 批量加载所有回复 var allReplies []*model.Comment if err := r.db.Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished). Preload("User"). Order("created_at ASC"). Find(&allReplies).Error; err != nil { return } // 按 root_id 分组并裁剪 repliesByRoot := make(map[string][]*model.Comment, len(rootIDs)) for _, reply := range allReplies { if reply.RootID == nil { continue } rootID := *reply.RootID if replyLimit <= 0 || len(repliesByRoot[rootID]) < replyLimit { repliesByRoot[rootID] = append(repliesByRoot[rootID], reply) } } // 查询每个根评论的回复总数 type replyCountRow struct { RootID string Total int64 } var replyCountRows []replyCountRow if err := r.db.Model(&model.Comment{}). Select("root_id, COUNT(*) AS total"). Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished). Group("root_id"). Scan(&replyCountRows).Error; err != nil { return } replyCountMap := make(map[string]int64, len(replyCountRows)) for _, row := range replyCountRows { replyCountMap[row.RootID] = row.Total } // 设置回复和回复数 for _, rootID := range rootIDs { comment := commentsByID[rootID] comment.Replies = repliesByRoot[rootID] comment.RepliesCount = int(replyCountMap[rootID]) } } // GetRepliesByCursor 游标分页获取根评论的回复 // 排序方式:created_at ASC(回复按时间正序) func (r *CommentRepository) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) { // 构建基础查询 query := r.db.WithContext(ctx).Model(&model.Comment{}). Where("root_id = ? AND status = ?", rootID, model.CommentStatusPublished) // 使用游标构建器(回复按时间升序) builder := cursor.NewBuilder(query, cursor.SortByCreatedAtAsc). WithCursor(req.Cursor, req.Direction). WithPageSize(req.PageSize) if builder.Error() != nil { // 无效游标,返回空列表 return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil } // 执行查询 var replies []*model.Comment query = builder.Build().Preload("User") if err := query.Find(&replies).Error; err != nil { return nil, err } // 构建响应 pageSize := builder.GetPageSize() hasMore := cursor.HasMore(len(replies), pageSize) if hasMore { replies = replies[:pageSize] } // 生成游标 var nextCursor, prevCursor string if len(replies) > 0 { // 下一页游标 if hasMore { lastReply := replies[len(replies)-1] nextCursor = cursor.NewCursor( cursor.FormatTime(lastReply.CreatedAt), lastReply.ID, cursor.SortByCreatedAtAsc, ).Encode() } // 上一页游标 firstReply := replies[0] prevCursor = cursor.NewCursor( cursor.FormatTime(firstReply.CreatedAt), firstReply.ID, cursor.SortByCreatedAtAsc, ).Encode() } return cursor.NewCursorPageResult(replies, nextCursor, prevCursor, hasMore), nil }