Files
sundynix-pets/pets-be/internal/service/community.go
T
Blizzard 9fc61e878e 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>
2026-07-29 16:33:50 +08:00

343 lines
9.9 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 service
import (
"encoding/json"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 ImagesURL 数组)。
// 机器人帖 ImageFileIDs 为空,保留其 Imagesemoji)不变。
func (s *Service) attachPostImages(posts []model.Post) {
perPost := make([][]string, len(posts))
all := []string{}
for i := range posts {
if len(posts[i].ImageFileIDs) == 0 {
continue
}
var ids []string
if json.Unmarshal(posts[i].ImageFileIDs, &ids) == nil && len(ids) > 0 {
perPost[i] = ids
all = append(all, ids...)
}
}
if len(all) == 0 {
return
}
urlMap := s.fileURLs(all)
for i := range posts {
if len(perPost[i]) == 0 {
continue
}
urls := make([]string, 0, len(perPost[i]))
for _, id := range perPost[i] {
if u := urlMap[id]; u != "" {
urls = append(urls, u)
}
}
if b, err := json.Marshal(urls); err == nil {
posts[i].Images = datatypes.JSON(b)
}
}
}
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
func tabTag(tab string) string {
switch tab {
case "新手求助":
return "求助"
case "晒宠":
return "晒宠"
case "经验":
return "经验"
default: // 推荐 / 关注
return ""
}
}
// ListPosts 帖子分页列表。userID 用于「关注」tab 过滤和标记关注状态(可为空)。
func (s *Service) ListPosts(userID, tab string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
if tag := tabTag(tab); tag != "" {
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
}
if tab == "关注" {
ids := s.followeeIDs(userID)
if len(ids) == 0 {
// 一个都没关注:返回空列表,而不是退化成「推荐」
return []model.Post{}, 0, nil
}
q = q.Where("user_id IN ?", ids)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
return nil, 0, err
}
s.attachPostImages(posts)
s.markFollowed(userID, posts)
return posts, total, nil
}
// GetPost 帖子详情
func (s *Service) GetPost(postID string) (*model.Post, error) {
var p model.Post
if err := s.db.First(&p, postID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// PostInput 发帖入参
type PostInput struct {
PetID *string
Identity string
Content string
Tags datatypes.JSON
Images datatypes.JSON
ImageFileIDs datatypes.JSON
}
// CreatePost 发帖
func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
authorName := "匿名宠友"
authorEmoji := "🐾"
switch in.Identity {
case "official":
authorName = "肉垫计划官方"
case "petName":
if in.PetID != nil {
var pet model.Pet
if err := s.db.First(&pet, *in.PetID).Error; err == nil {
authorName = pet.Name + "的铲屎官"
authorEmoji = pet.Emoji
}
}
}
p := model.Post{
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
ImageFileIDs: in.ImageFileIDs, Status: model.PostPublished,
}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// LikePost 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID string) (int, error) {
if _, err := s.GetPost(postID); err != nil {
return 0, err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
like := model.PostLike{PostID: postID, UserID: userID}
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).FirstOrCreate(&like)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 1 { // 新建才计数
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("like_count", gorm.Expr("like_count + 1")).Error
}
return nil
})
if err != nil {
return 0, err
}
return s.postLikeCount(postID)
}
// UnlikePost 取消点赞
func (s *Service) UnlikePost(userID, postID string) (int, error) {
if _, err := s.GetPost(postID); err != nil {
return 0, err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.PostLike{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 1 {
return tx.Model(&model.Post{}).Where("id = ? AND like_count > 0", postID).
UpdateColumn("like_count", gorm.Expr("like_count - 1")).Error
}
return nil
})
if err != nil {
return 0, err
}
return s.postLikeCount(postID)
}
func (s *Service) postLikeCount(postID string) (int, error) {
var p model.Post
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
return 0, err
}
return p.LikeCount, nil
}
// ListComments 评论分页
func (s *Service) ListComments(userID, postID string, offset, limit int) ([]model.Comment, int64, error) {
base := func() *gorm.DB {
return s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
}
// total 是这条帖子的评论总数(含回复),和帖子上显示的数字对得上
var total int64
if err := base().Count(&total).Error; err != nil {
return nil, 0, err
}
// 只分页一级评论,回复跟着它爹一起返回
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
}
if len(roots) == 0 {
return []model.Comment{}, 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, parentID string) (*model.Comment, error) {
if _, err := s.GetPost(postID); err != nil {
return nil, err
}
var user model.User
name := "宠友"
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
name = user.Nickname
}
// 回复的回复仍然挂到同一条一级评论下,保持两级不塌成深树
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
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&comment).Error; err != nil {
return err
}
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
}); err != nil {
return nil, err
}
return &comment, nil
}
// DeleteOwnPost 用户删除自己的帖子(软删除,保留数据可追溯)
func (s *Service) DeleteOwnPost(userID, postID string) error {
var p model.Post
if err := s.db.Where("id = ?", postID).First(&p).Error; err != nil {
return ErrNotFound
}
if p.UserID != userID {
return ErrForbidden
}
return s.db.Model(&model.Post{}).Where("id = ?", postID).
Update("status", model.PostDeleted).Error
}
// DeleteOwnComment 用户删除自己的评论,并同步帖子评论数
func (s *Service) DeleteOwnComment(userID, commentID string) error {
var c model.Comment
if err := s.db.Where("id = ?", commentID).First(&c).Error; err != nil {
return ErrNotFound
}
if c.UserID != userID {
return ErrForbidden
}
if c.Status == "deleted" {
return nil // 幂等
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Comment{}).Where("id = ?", commentID).
Update("status", "deleted").Error; err != nil {
return err
}
// 删一级评论要连它下面的回复一起删,否则回复会变成挂在空处的孤儿
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
})
}