Files
backend/internal/repository/comment_status.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

156 lines
5.5 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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// clauseLockUpdate 行级锁子句,供 transitionStatus 加锁读取使用。
// 集中定义便于在评论仓储内复用。
var clauseLockUpdate = clause.Locking{Strength: "UPDATE"}
// 评论状态机与计数维护的统一入口。
//
// 设计目标:
// 把"评论状态变更"与"聚合计数维护 (posts.comments_count / comments.replies_count)"
// 强绑定到同一个事务内,作为唯一的状态变更通道。任何外部路径
// (自动审核、管理端审核、批量审核、举报自动隐藏、删除等)都必须经由
// UpdateModerationStatus / Delete / BatchDelete 走到这里,从而杜绝
// "调用方忘记维护计数"导致的计数与显示不一致问题如显示2条但计数为1
//
// 防御性:
// 1. SELECT ... FOR UPDATE 加锁读旧状态,避免并发审核造成计数错乱;
// 2. 非法状态转换(如 deleted → published直接拒绝从源头防止脏数据再次污染计数
// 3. 计数递减使用 CASE WHEN 防止下溢为负,即使历史脏数据导致 count 与实际不一致,
// 也不会产生负值计数;
// 4. 计数规则集中表达:统计字段只反映 published 评论,状态进出 published 时分别 +1 / -1。
// errInvalidStatusTransition 非法状态转换
var errInvalidStatusTransition = errors.New("invalid comment status transition")
// isCountedStatus 判断该状态的评论是否参与聚合计数
func isCountedStatus(s model.CommentStatus) bool {
return s == model.CommentStatusPublished
}
// statusDelta 返回从 oldStatus 迁移到 newStatus 时,聚合计数应当的增量:
// +1非计数状态 → 计数状态(审核通过 / 恢复发布)
// -1计数状态 → 非计数状态(已发布评论被驳回/隐藏/删除)
// 0其余转换如 pending → rejected均不参与计数
func statusDelta(oldStatus, newStatus model.CommentStatus) int {
oldCounted := isCountedStatus(oldStatus)
newCounted := isCountedStatus(newStatus)
switch {
case !oldCounted && newCounted:
return 1
case oldCounted && !newCounted:
return -1
default:
return 0
}
}
// canTransition 校验状态转换是否合法。
// deleted 是终态:已软删除的评论不应再被审核(恢复发布等),
// 否则会引入"幽灵计数"——评论已被关联数据清理,却仍试图调整计数。
func canTransition(oldStatus, newStatus model.CommentStatus) error {
if oldStatus == newStatus {
return nil
}
if oldStatus == model.CommentStatusDeleted {
return errInvalidStatusTransition
}
return nil
}
// applyStatusDelta 在事务内根据 delta 原子调整帖子评论数与父评论回复数。
// 使用 CASE WHEN 保证计数永不下溢为负(防御历史脏数据)。
func applyStatusDelta(tx *gorm.DB, comment *model.Comment, delta int) error {
if delta == 0 {
return nil
}
// 帖子评论数delta>0 直接相加delta<0 用 CASE WHEN 防止下溢
var postCountExpr any
if delta > 0 {
postCountExpr = gorm.Expr("comments_count + ?", delta)
} else {
postCountExpr = gorm.Expr(
"CASE WHEN comments_count >= ? THEN comments_count + ? ELSE 0 END",
-delta, delta,
)
}
// 统计字段变更不应影响帖子内容更新时间updated_at
if err := tx.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumns(map[string]any{
"comments_count": postCountExpr,
"updated_at": gorm.Expr("updated_at"),
}).Error; err != nil {
return err
}
// 若为回复,同步调整父评论回复数(同样防下溢)
if comment.ParentID != nil && *comment.ParentID != "" {
var replyCountExpr any
if delta > 0 {
replyCountExpr = gorm.Expr("replies_count + ?", delta)
} else {
replyCountExpr = gorm.Expr(
"CASE WHEN replies_count >= ? THEN replies_count + ? ELSE 0 END",
-delta, delta,
)
}
if err := tx.Model(&model.Comment{}).Where("id = ?", *comment.ParentID).
UpdateColumn("replies_count", replyCountExpr).Error; err != nil {
return err
}
}
return nil
}
// transitionStatus 在指定事务内将评论状态由当前值迁移到 newStatus
// 并原子维护聚合计数。这是评论状态变更的唯一计数维护通道。
//
// 调用方需在外层开启事务Delete / BatchDelete 等),并传入该事务的 tx。
// 仅做状态迁移与计数维护,不负责清理点赞、软删除标记等副作用——
// 那些由调用方在各自事务内完成。
//
// 返回迁移发生前的旧评论快照(含旧状态),供调用方决策后续清理逻辑。
func transitionStatus(tx *gorm.DB, commentID string, newStatus model.CommentStatus) (*model.Comment, error) {
// 加锁读取当前状态,避免并发审核造成计数错乱
var comment model.Comment
if err := tx.Clauses(clauseLockUpdate).First(&comment, "id = ?", commentID).Error; err != nil {
return nil, err
}
oldStatus := comment.Status
if err := canTransition(oldStatus, newStatus); err != nil {
return &comment, err
}
if oldStatus == newStatus {
// 状态未变化:不写库、不调整计数
return &comment, nil
}
updates := map[string]any{
"status": newStatus,
"updated_at": gorm.Expr("updated_at"), // 状态变更不应刷新内容更新时间
}
if err := tx.Model(&model.Comment{}).
Where("id = ?", commentID).
UpdateColumns(updates).Error; err != nil {
return nil, err
}
if err := applyStatusDelta(tx, &comment, statusDelta(oldStatus, newStatus)); err != nil {
return nil, err
}
comment.Status = newStatus
return &comment, nil
}