refactor(comment): unify status transitions with atomic count maintenance
All checks were successful
Build Backend / build (push) Successful in 3m31s
Build Backend / build-docker (push) Successful in 1m25s

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
This commit is contained in:
lafay
2026-06-21 17:17:38 +08:00
parent 322aa9eebb
commit 28cfcbf9a8
7 changed files with 285 additions and 139 deletions

View File

@@ -1,6 +1,8 @@
package repository
import (
"errors"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"context"
@@ -13,9 +15,8 @@ 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) error
UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) (*model.Comment, error)
Delete(id string) error
ApplyPublishedStats(comment *model.Comment) 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)
@@ -57,31 +58,72 @@ func (r *commentRepository) GetByID(id string) (*model.Comment, error) {
return &comment, nil
}
// Update 更新评论
// 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.Save(comment).Error
}
// UpdateModerationStatus 更新评论审核状态
func (r *commentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) error {
updates := map[string]any{
"status": status,
"reviewed_at": gorm.Expr("CURRENT_TIMESTAMP"),
"reviewed_by": reviewedBy,
"reject_reason": rejectReason,
"updated_at": gorm.Expr("updated_at"),
}
return r.db.Model(&model.Comment{}).
Where("id = ?", commentID).
UpdateColumns(updates).Error
Where("id = ?", comment.ID).
Select("content", "segments", "images", "updated_at").
Updates(comment).Error
}
// Delete 删除评论(软删除,同时清理关联数据)
// 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 {
// 先查询评论获取post_id和parent_id
var comment model.Comment
if err := tx.First(&comment, "id = ?", id).Error; err != nil {
// 统一走状态转换层published → deleted 会自动 -1 计数;非 published 删除则不调整
if _, err := transitionStatus(tx, id, model.CommentStatusDeleted); err != nil {
if errors.Is(err, errInvalidStatusTransition) {
// 已删除的评论重复删除:视为幂等成功
return nil
}
return err
}
@@ -95,54 +137,6 @@ func (r *commentRepository) Delete(id string) error {
return err
}
// 仅已发布评论才参与统计,避免 pending/rejected 影响计数
if comment.Status == model.CommentStatusPublished {
// 减少帖子的评论数并同步热度分
// 评论数属于统计字段不应影响帖子内容更新时间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
})
}
// 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
})
}
@@ -413,24 +407,26 @@ func (r *commentRepository) GetAdminCommentByID(id string) (*model.Comment, erro
return &comment, nil
}
// BatchDelete 批量删除评论(使用单次事务批量删除,避免 N+1 问题)
// 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
}
// 先查询所有评论获取 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 {
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
@@ -440,24 +436,6 @@ func (r *commentRepository) BatchDelete(ids []string) ([]string, error) {
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
})