feat: 评论支持回复(两级 + @某人)
不做无限层级——手机屏幕撑不住层层缩进,读到第四层就没法看了。
微信、小红书、B站清一色是两级:一级评论 + 其下的回复列表,回复里
用「回复 @某人」表达指向谁。
数据结构:comments 加 parent_id + reply_to_name。回复的回复会被
压平到同一条一级评论下(parent_id 取爷爷的),同时把被回复人的名字
记进 reply_to_name —— 这样既保住两级,又不丢「在跟谁说话」的信息。
几个容易做错的地方:
- 一次查完这一页所有一级评论的回复,不是每条一次查询(N+1)
- 一级评论默认只带 3 条回复,其余点「展开全部 N 条」再拉,避免热门
评论一次返回几百条
- 删一级评论要连它下面的回复一起删,否则回复变成挂在空处的孤儿;
帖子的 comment_count 也要按实际删除条数减,不是减 1
- total 统计含回复,和帖子上显示的数字对得上
- replies 为空时返回 [],不是 null
实测四条评论(含一条回复的回复):
▸ 甲:一级评论 (2 条回复)
└ 乙:多久了?
└ 甲 回复 乙:三天了
▸ 乙:另一条一级评论 (0 条回复)
删掉第一条一级评论后 total 4→1,comment_count 同步。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -193,23 +193,67 @@ func (s *Service) postLikeCount(postID string) (int, error) {
|
||||
|
||||
// ListComments 评论分页
|
||||
func (s *Service) ListComments(userID, postID string, offset, limit int) ([]model.Comment, int64, error) {
|
||||
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
||||
base := func() *gorm.DB {
|
||||
return s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
||||
}
|
||||
// total 是这条帖子的评论总数(含回复),和帖子上显示的数字对得上
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
if err := base().Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var comments []model.Comment
|
||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
|
||||
// 只分页一级评论,回复跟着它爹一起返回
|
||||
var roots []model.Comment
|
||||
if err := base().Where("parent_id = ? OR parent_id IS NULL", "").
|
||||
Order("id desc").Offset(offset).Limit(limit).Find(&roots).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range comments {
|
||||
comments[i].IsSelf = comments[i].UserID == userID
|
||||
if len(roots) == 0 {
|
||||
return []model.Comment{}, total, nil
|
||||
}
|
||||
return comments, total, nil
|
||||
|
||||
ids := make([]string, 0, len(roots))
|
||||
for i := range roots {
|
||||
roots[i].IsSelf = roots[i].UserID == userID
|
||||
ids = append(ids, roots[i].ID)
|
||||
}
|
||||
// 一次查完这一页所有一级评论的回复,不要每条一次查询
|
||||
var replies []model.Comment
|
||||
s.db.Where("parent_id IN ? AND status = ?", ids, "published").Order("id asc").Find(&replies)
|
||||
byParent := map[string][]model.Comment{}
|
||||
for i := range replies {
|
||||
replies[i].IsSelf = replies[i].UserID == userID
|
||||
byParent[replies[i].ParentID] = append(byParent[replies[i].ParentID], replies[i])
|
||||
}
|
||||
for i := range roots {
|
||||
list := byParent[roots[i].ID]
|
||||
if list == nil {
|
||||
list = []model.Comment{} // 空切片而不是 nil,否则 JSON 出来是 null
|
||||
}
|
||||
roots[i].ReplyN = len(list)
|
||||
// 默认只带前 3 条,其余等前端点「展开」再要,避免热门评论一次拉几百条
|
||||
if len(list) > 3 {
|
||||
list = list[:3]
|
||||
}
|
||||
roots[i].Replies = list
|
||||
}
|
||||
return roots, total, nil
|
||||
}
|
||||
|
||||
// ListReplies 展开某条一级评论下的全部回复
|
||||
func (s *Service) ListReplies(userID, commentID string) ([]model.Comment, error) {
|
||||
var list []model.Comment
|
||||
if err := s.db.Where("parent_id = ? AND status = ?", commentID, "published").
|
||||
Order("id asc").Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range list {
|
||||
list[i].IsSelf = list[i].UserID == userID
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// CreateComment 评论
|
||||
func (s *Service) CreateComment(userID, postID string, content string, images []string) (*model.Comment, error) {
|
||||
func (s *Service) CreateComment(userID, postID string, content string, images []string, parentID string) (*model.Comment, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -218,7 +262,23 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
|
||||
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
|
||||
name = user.Nickname
|
||||
}
|
||||
comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"}
|
||||
// 回复的回复仍然挂到同一条一级评论下,保持两级不塌成深树
|
||||
replyTo := ""
|
||||
if parentID != "" {
|
||||
var parent model.Comment
|
||||
if err := s.db.Where("id = ? AND post_id = ?", parentID, postID).First(&parent).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if parent.ParentID != "" {
|
||||
replyTo = parent.AuthorName
|
||||
parentID = parent.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
comment := model.Comment{
|
||||
PostID: postID, UserID: userID, AuthorName: name, Content: content,
|
||||
Status: "published", ParentID: parentID, ReplyToName: replyTo,
|
||||
}
|
||||
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
|
||||
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
|
||||
comment.Images = b
|
||||
@@ -265,7 +325,18 @@ func (s *Service) DeleteOwnComment(userID, commentID string) error {
|
||||
Update("status", "deleted").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Post{}).Where("id = ? AND comment_count > 0", c.PostID).
|
||||
UpdateColumn("comment_count", gorm.Expr("comment_count - 1")).Error
|
||||
// 删一级评论要连它下面的回复一起删,否则回复会变成挂在空处的孤儿
|
||||
n := int64(1)
|
||||
if c.ParentID == "" {
|
||||
res := tx.Model(&model.Comment{}).
|
||||
Where("parent_id = ? AND status = ?", commentID, "published").
|
||||
Update("status", "deleted")
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
n += res.RowsAffected
|
||||
}
|
||||
return tx.Model(&model.Post{}).Where("id = ? AND comment_count >= ?", c.PostID, n).
|
||||
UpdateColumn("comment_count", gorm.Expr("comment_count - ?", n)).Error
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user