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
156 lines
5.5 KiB
Go
156 lines
5.5 KiB
Go
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
|
||
}
|