package service import ( "encoding/json" "gorm.io/datatypes" "gorm.io/gorm" "github.com/sundynix/pets-be/internal/model" ) // attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 Images(URL 数组)。 // 机器人帖 ImageFileIDs 为空,保留其 Images(emoji)不变。 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) } } } // attachCommentCounts 用「已发布评论」的实时条数覆盖 comment_count。 // 维护型计数器在这些路径上会漂移:图片评论异步过审后没补计数、删除待审评论时误减、 // 后台审核改状态没同步。直接按实际已发布条数算,永远和评论弹层「共 X 条」一致。 func (s *Service) attachCommentCounts(posts []model.Post) { if len(posts) == 0 { return } ids := make([]string, len(posts)) for i := range posts { ids[i] = posts[i].ID } type row struct { PostID string N int } var rows []row s.db.Model(&model.Comment{}). Select("post_id, count(*) as n"). Where("post_id IN ? AND status = ?", ids, model.PostPublished). Group("post_id").Scan(&rows) cnt := make(map[string]int, len(rows)) for _, r := range rows { cnt[r.PostID] = r.N } for i := range posts { posts[i].CommentCount = cnt[posts[i].ID] } } // 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.attachCommentCounts(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) { // 取作者信息:统一用发布者昵称(去掉了发布身份选择),openid 给内容安全接口用 var user model.User s.db.Select("id, open_id, nickname").First(&user, userID) authorName := user.Nickname if authorName == "" { authorName = "宠友" } authorEmoji := "🐾" imgURLs := s.resolveImageURLs(in.ImageFileIDs) // 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调 status, _ := s.moderateInitial(in.Content, user.OpenID, 3, len(imgURLs) > 0) 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: status, } if err := s.db.Create(&p).Error; err != nil { return nil, err } // 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回 if status == model.PostPending && len(imgURLs) > 0 { s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3) } one := []model.Post{p} s.attachPostImages(one) return &one[0], nil } // resolveImageURLs 把 file id 数组(JSON)解析成可访问 URL 列表 func (s *Service) resolveImageURLs(fileIDs datatypes.JSON) []string { if len(fileIDs) == 0 { return nil } var ids []string if json.Unmarshal(fileIDs, &ids) != nil || len(ids) == 0 { return nil } m := s.fileURLs(ids) urls := make([]string, 0, len(ids)) for _, id := range ids { if u := m[id]; u != "" { urls = append(urls, u) } } return urls } // 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 } } // 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0) // 文本被判违规:直接拒绝、不入库,让用户知道发不出去 if status == model.PostRejected { return nil, ErrContentRejected } comment := model.Comment{ PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: status, 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 } // 待审核/打回的评论先不计入楼主的评论数,过审后再补 if status == model.PostPublished { return tx.Model(&model.Post{}).Where("id = ?", postID). UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error } return nil }); err != nil { return nil, err } // 文本已过、有图 → 异步查图 if status == model.PostPending && len(images) > 0 { s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2) } 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 }) }