Files
sundynix-pets/pets-be/internal/service/community.go
T
Blizzard 7a55ee0f2e feat(community): 评论改为独立二级页面 + 评论内容安全审核 + 评论数按实发实时纠正
前端:
- 新增 pages/comments 独立评论页(列表 + 底部输入条 + 回复),社区卡片点评论改为 navigateTo
- 输入条用普通流式布局 + 原生 adjust-position 做键盘避让,聚焦期间不 setData 碰输入框
- 评论只能配 1 张图;底部常驻「审核后公开」提示;发送按钮在途「审核中…」防连点
- 发布结果按状态区分:过审即时展示,其余提示「已提交,审核通过后展示」
- 评论页把已发布评论数回传社区列表,卡片数字实时更新

后端:
- CreateComment 文本判为违规时直接拒收(ErrContentRejected),不入库
- 社区列表/用户帖子列表用「已发布评论实时条数」覆盖 comment_count,
  修正异步过审/删待审/后台改状态导致的计数漂移
- IDCard 手机号返回完整号码(上一批遗留)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:04:21 +08:00

410 lines
12 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)
}
}
}
// 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
})
}