Files
backend/internal/repository/comment_repo.go
lafay 28cfcbf9a8
All checks were successful
Build Backend / build (push) Successful in 3m31s
Build Backend / build-docker (push) Successful in 1m25s
refactor(comment): unify status transitions with atomic count maintenance
Consolidate comment status changes through transitionStatus() to ensure
posts.comments_count and comments.replies_count are atomically maintained
within the same transaction, preventing inconsistencies between displayed
and stored counts.

- Remove separate ApplyPublishedStats() calls, now handled in UpdateModerationStatus
- Add cache invalidation for admin moderation operations
- Update report auto-hide to use unified status transition
2026-06-21 17:17:38 +08:00

642 lines
20 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 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
}