7a55ee0f2e
前端: - 新增 pages/comments 独立评论页(列表 + 底部输入条 + 回复),社区卡片点评论改为 navigateTo - 输入条用普通流式布局 + 原生 adjust-position 做键盘避让,聚焦期间不 setData 碰输入框 - 评论只能配 1 张图;底部常驻「审核后公开」提示;发送按钮在途「审核中…」防连点 - 发布结果按状态区分:过审即时展示,其余提示「已提交,审核通过后展示」 - 评论页把已发布评论数回传社区列表,卡片数字实时更新 后端: - CreateComment 文本判为违规时直接拒收(ErrContentRejected),不入库 - 社区列表/用户帖子列表用「已发布评论实时条数」覆盖 comment_count, 修正异步过审/删待审/后台改状态导致的计数漂移 - IDCard 手机号返回完整号码(上一批遗留) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
12 KiB
Go
410 lines
12 KiB
Go
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
|
||
})
|
||
}
|