Files
sundynix-pets/pets-be/internal/service/community.go
T
Blizzard 2e3345cdfc feat: 记录删除与筛选、用户侧内容删除、AI 聊天历史
修复雪花 ID 精度丢失(真 bug)
- bottom-sheet 的 postId 声明为 Number,而 ID 是 18 位雪花字符串,
  超出 JS 安全整数范围:339346584095428608 会被转成 ...600,
  评论弹层实际查的是不存在的帖子。改为 String

记录
- 后端 DELETE /records/:id 早已就绪但前端从未调用,记错了删不掉。
  时间轴支持长按删除
- 时间轴加类型筛选(体重/便便/饮食/异常/用药/疫苗/消费/照片),
  后端 ?type= 本就支持

用户侧内容删除(UGC 合规:用户应能删除自己发布的内容)
- 新增 DELETE /posts/:id 与 DELETE /comments/:id,仅限本人,
  越权返回 40300;帖子软删除,评论删除同步递减 comment_count
- 评论列表返回 is_self;社区长按自己的帖子、长按自己的评论可删

AI 聊天历史
- ai_messages 一直在写但没有查询接口,每次打开弹层都是空白。
  新增 GET /api/ai/messages,弹层打开时接在开场白后加载

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 17:42:14 +08:00

268 lines
7.4 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) {
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
var total int64
if err := q.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 {
return nil, 0, err
}
for i := range comments {
comments[i].IsSelf = comments[i].UserID == userID
}
return comments, total, nil
}
// CreateComment 评论
func (s *Service) CreateComment(userID, postID string, content 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
}
comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"}
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
}
return tx.Model(&model.Post{}).Where("id = ? AND comment_count > 0", c.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count - 1")).Error
})
}