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 }