package repository import ( "errors" "with_you/internal/model" "with_you/internal/pkg/cursor" "context" "gorm.io/gorm" ) // CommentRepository 评论仓储接口 type CommentRepository interface { Create(comment *model.Comment) error GetByID(id string) (*model.Comment, error) Update(comment *model.Comment) error UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) (*model.Comment, error) Delete(id string) error GetByPostID(postID string, page, pageSize int) ([]*model.Comment, int64, error) GetByPostIDWithReplies(postID string, page, pageSize, replyLimit int) ([]*model.Comment, int64, error) GetRepliesByRootID(rootID string, page, pageSize int) ([]*model.Comment, int64, error) GetReplies(parentID string) ([]*model.Comment, error) Like(commentID, userID string) error Unlike(commentID, userID string) error IsLiked(commentID, userID string) bool IsLikedBatch(commentIDs []string, userID string) map[string]bool GetAdminCommentList(filter AdminCommentListFilter) ([]*model.Comment, int64, error) GetAdminCommentByID(id string) (*model.Comment, error) BatchDelete(ids []string) ([]string, error) GetCommentsByPostIDForAdmin(postID string, page, pageSize int) ([]*model.Comment, int64, error) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) } // 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 更新评论(仅内容字段,如 content / segments / images)。 // // 防御性:评论的 status 字段是计数维护的核心,必须经由 UpdateModerationStatus / // Delete 等状态转换通道变更,否则会破坏 comments_count / replies_count 一致性。 // 因此本方法显式不更新 status / reviewed_* 等审核字段——即便上层传入的 // comment 结构体 status 为零值或脏值,也不会污染状态机。 func (r *commentRepository) Update(comment *model.Comment) error { return r.db.Model(&model.Comment{}). Where("id = ?", comment.ID). Select("content", "segments", "images", "updated_at"). Updates(comment).Error } // UpdateModerationStatus 更新评论审核状态,并在同一事务内原子地维护 // 帖子评论数 / 父评论回复数等统计字段。 // // 计数维护统一收敛到 transitionStatus(评论状态变更的唯一通道), // 因此任何调用方(自动审核、管理端单条 / 批量审核、举报自动隐藏等) // 都能正确维护计数,避免"评论显示 N 条但计数为 N-1"。 // 同时内置防御:非法状态转换(如 deleted → published)会被拒绝, // 计数递减使用 CASE WHEN 保证永不为负。 // // 返回更新后的评论(含 User 关联),便于调用方做缓存失效、通知等后续处理。 func (r *commentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) (*model.Comment, error) { var updated model.Comment err := r.db.Transaction(func(tx *gorm.DB) error { prev, err := transitionStatus(tx, commentID, status) if err != nil { return err } // 状态未变化或仅元数据需补充(reviewed_at / reviewed_by / reject_reason) updates := map[string]any{ "reviewed_at": gorm.Expr("CURRENT_TIMESTAMP"), "reviewed_by": reviewedBy, "reject_reason": rejectReason, "updated_at": gorm.Expr("updated_at"), } if prev.Status != status { updates["status"] = status } if err := tx.Model(&model.Comment{}). Where("id = ?", commentID). UpdateColumns(updates).Error; err != nil { return err } // 重新加载(含 User 关联)供调用方使用 return tx.Preload("User").First(&updated, "id = ?", commentID).Error }) if err != nil { return nil, err } return &updated, nil } // Delete 删除评论(软删除,同时清理关联数据)。 // 删除 = 状态迁移到 deleted,复用 transitionStatus 统一维护计数, // 保证 Delete 与审核路径的计数行为一致、对称。 func (r *commentRepository) Delete(id string) error { return r.db.Transaction(func(tx *gorm.DB) error { // 统一走状态转换层:published → deleted 会自动 -1 计数;非 published 删除则不调整 if _, err := transitionStatus(tx, id, model.CommentStatusDeleted); err != nil { if errors.Is(err, errInvalidStatusTransition) { // 已删除的评论重复删除:视为幂等成功 return 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 } 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 } // 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 批量删除评论。 // 逐条复用 transitionStatus 完成 published → deleted 的状态迁移, // 统一维护 comments_count 与父评论 replies_count(修复此前批量删除漏减 replies_count 的问题)。 // 返回失败 ID 列表(当前实现下事务整体回滚,故失败时返回全部 ids)。 func (r *commentRepository) BatchDelete(ids []string) ([]string, error) { if len(ids) == 0 { return nil, nil } err := r.db.Transaction(func(tx *gorm.DB) error { for _, id := range ids { // 统一走状态转换层:幂等处理已删除评论,已发布评论自动 -1 计数 + 父回复数 if _, terr := transitionStatus(tx, id, model.CommentStatusDeleted); terr != nil { if errors.Is(terr, errInvalidStatusTransition) { continue // 已删除:幂等跳过 } return terr } } // 批量删除评论点赞记录 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 } 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 ASC(最早发布的为一楼) 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.SortByCreatedAtAsc). WithCursor(req.Cursor, req.Direction). WithPageSize(req.PageSize) if builder.Error() != nil { // 无效游标,返回空列表 return cursor.NewCursorPageResult([]*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.SortByCreatedAtAsc, ).Encode() } // 上一页游标 firstComment := comments[0] prevCursor = cursor.NewCursor( cursor.FormatTime(firstComment.CreatedAt), firstComment.ID, cursor.SortByCreatedAtAsc, ).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{}, "", "", 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 }