Files
jiang13-bbs/backend/service/comment.go

710 lines
22 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 (
"errors"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 评论操作错误
var (
ErrCommentNotFound = errors.New("评论不存在")
ErrCommentForbidden = errors.New("无权限删除此评论")
ErrCommentDeleteMeta = errors.New("请填写删除理由")
ErrCommentInvalidType = errors.New("无效的删除类型")
ErrCommentNotDeleted = errors.New("评论未被删除")
ErrCommentNotChanged = errors.New("评论内容未变更")
ErrCommentDeleted = errors.New("已删除的评论不可编辑")
)
// CommentService 评论服务
type CommentService struct {
db *gorm.DB
}
func NewCommentService(db *gorm.DB) *CommentService {
return &CommentService{db: db}
}
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
type CommentNode struct {
model.Comment
Deleted bool `json:"deleted,omitempty"` // 软删占位
StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
Edited bool `json:"edited,omitempty"` // 相对创建已编辑
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
Replies []CommentNode `json:"replies,omitempty"`
// 覆盖嵌入的 User:指针 + omitempty,管理删对公众可整段省略(避免空对象仍进 JSON/DOM)
User *model.User `json:"user,omitempty"`
}
// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等)
type CommentActorBrief struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
const maxReplyDepth = 10
// ListFloorPaged 楼层分页获取帖子评论树:
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
// 已软删评论以 tombstone 占位(计入楼层);已硬删不存在。
// totalComments 仅计未删条数(头部徽章);floors 含 tombstone 主评。
func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *Actor, page, size int) ([]CommentNode, int64, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
// Unscoped:把软删行一并纳入,供占位展示
base := applyCommentListVisibility(
s.db.Unscoped().Model(&model.Comment{}).Where("post_id = ?", postID),
boardID, viewerID, actor,
)
var floors, totalComments int64
if err := base.Session(&gorm.Session{}).
Where("parent_id IS NULL").Count(&floors).Error; err != nil {
return nil, 0, 0, err
}
// 徽章:仅未软删
if err := base.Session(&gorm.Session{}).
Where("deleted_at IS NULL").Count(&totalComments).Error; err != nil {
return nil, 0, 0, err
}
var roots []model.Comment
if err := base.Session(&gorm.Session{}).
Where("parent_id IS NULL").
Order("created_at ASC").
Preload("User").
Offset((page - 1) * size).
Limit(size).
Find(&roots).Error; err != nil {
return nil, 0, 0, err
}
if len(roots) == 0 {
return []CommentNode{}, floors, totalComments, nil
}
rootIDs := make([]uint, len(roots))
for i, r := range roots {
rootIDs[i] = r.ID
}
replyQ := applyCommentListVisibility(s.db.Unscoped().Where("root_id IN (?)", rootIDs), boardID, viewerID, actor)
var replies []model.Comment
if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil {
return nil, 0, 0, err
}
children := make(map[uint][]model.Comment, len(replies))
for _, r := range replies {
if r.ParentID != nil {
children[*r.ParentID] = append(children[*r.ParentID], r)
}
}
allRows := make([]model.Comment, 0, len(roots)+len(replies))
allRows = append(allRows, roots...)
allRows = append(allRows, replies...)
deleters := s.loadCommentActorsByIDs(collectStaffDeleterIDs(allRows))
placed := make(map[uint]bool, len(roots)+len(replies))
revealAudit := actor != nil && actor.CanModerateBoard(boardID)
var build func(pid uint) []CommentNode
build = func(pid uint) []CommentNode {
out := []CommentNode{}
for _, c := range children[pid] {
out = append(out, toCommentNode(c, build(c.ID), revealAudit, viewerID, deleters))
placed[c.ID] = true
}
return out
}
result := make([]CommentNode, len(roots))
for i := range roots {
result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, viewerID, deleters)
placed[roots[i].ID] = true
}
for _, r := range replies {
if placed[r.ID] || r.RootID == nil {
continue
}
for i := range result {
if result[i].ID == *r.RootID {
result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, viewerID, deleters))
break
}
}
}
return result, floors, totalComments, nil
}
func collectStaffDeleterIDs(rows []model.Comment) []uint {
seen := map[uint]bool{}
var ids []uint
for _, c := range rows {
if !c.DeletedAt.Valid || c.DeletedBy == 0 || c.DeletedBy == c.UserID {
continue
}
if !seen[c.DeletedBy] {
seen[c.DeletedBy] = true
ids = append(ids, c.DeletedBy)
}
}
return ids
}
func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActorBrief {
out := map[uint]CommentActorBrief{}
if len(ids) == 0 {
return out
}
var users []model.User
if err := s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
return out
}
for _, u := range users {
out[u.ID] = CommentActorBrief{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
}
}
return out
}
// commentAuthorPtr 有预加载作者时返回拷贝,供 JSON omitempty 整段省略。
func commentAuthorPtr(c model.Comment) *model.User {
if c.User.ID == 0 {
return nil
}
u := c.User
return &u
}
// toCommentNode 软删占位:公众清空正文;版主与评论者本人保留原文。
// 自删理由仅版主可见。管理删:公众与其他登录用户不返回评论者身份。
func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, viewerID uint, deleters map[uint]CommentActorBrief) CommentNode {
edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute
if !c.DeletedAt.Valid {
return CommentNode{Comment: c, Edited: edited, Replies: replies, User: commentAuthorPtr(c)}
}
staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID
isCommenter := viewerID != 0 && viewerID == c.UserID
if !revealAudit && !isCommenter {
c.Content = ""
}
if !staff {
c.DeleteType = ""
if !revealAudit {
c.DeleteReason = ""
}
}
var deleter *CommentActorBrief
if staff {
if u, ok := deleters[c.DeletedBy]; ok {
cp := u
deleter = &cp
}
}
// 版主审计、评论者本人仍可见身份;其余观众剥离
if staff && !revealAudit && !isCommenter {
c.UserID = 0
c.User = model.User{}
}
return CommentNode{
Comment: c,
Deleted: true,
StaffDeleted: staff,
Edited: edited,
DeletedByUser: deleter,
Replies: replies,
User: commentAuthorPtr(c),
}
}
// FloorNumber 本帖主评楼层号(含软删占位,按 created_at/id 升序);子评取其所属主楼。无则 0。
func FloorNumber(db *gorm.DB, postID, commentID uint) int {
if db == nil || postID == 0 || commentID == 0 {
return 0
}
var c model.Comment
if err := db.Unscoped().Select("id", "post_id", "parent_id", "root_id", "created_at").
Where("id = ? AND post_id = ?", commentID, postID).First(&c).Error; err != nil {
return 0
}
floorID := c.ID
floorCreated := c.CreatedAt
if c.ParentID != nil {
rootID := c.ID
if c.RootID != nil {
rootID = *c.RootID
}
var root model.Comment
if err := db.Unscoped().Select("id", "created_at").
Where("id = ? AND post_id = ? AND parent_id IS NULL", rootID, postID).First(&root).Error; err != nil {
return 0
}
floorID = root.ID
floorCreated = root.CreatedAt
}
var ahead int64
db.Unscoped().Model(&model.Comment{}).
Where("post_id = ? AND parent_id IS NULL AND (created_at < ? OR (created_at = ? AND id < ?))",
postID, floorCreated, floorCreated, floorID).
Count(&ahead)
return int(ahead) + 1
}
// (s *CommentService) FloorNumber 见包级 FloorNumber
func (s *CommentService) FloorNumber(postID, commentID uint) int {
return FloorNumber(s.db, postID, commentID)
}
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
pub := model.ContentStatusPublished
pend := model.ContentStatusPending
if viewerID == 0 || actor == nil {
return db.Where(
"(deleted_at IS NULL AND status = ?) OR (deleted_at IS NOT NULL AND status = ?)",
pub, pub,
)
}
if actor.CanModerateBoard(boardID) {
return db.Where(
"(deleted_at IS NULL AND status IN ?) OR (deleted_at IS NOT NULL AND status IN ?)",
[]string{pub, pend}, []string{pub, pend},
)
}
return db.Where(
`(deleted_at IS NULL AND (status = ? OR (status = ? AND user_id = ?)))
OR (deleted_at IS NOT NULL AND (status = ? OR (status = ? AND user_id = ?)))`,
pub, pend, viewerID,
pub, pend, viewerID,
)
}
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。
// status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending;
// pending 评论不计入 comment_count,审核通过时才 +1。
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, nil, errors.New("评论内容不能为空")
}
if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil {
return nil, nil, err
}
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
status = model.ContentStatusPending
}
// 检查帖子存在且已发布(待审/被拒帖子不接受评论)
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, nil, errors.New("帖子不存在")
}
if post.Status != model.ContentStatusPublished {
return nil, nil, errors.New("帖子不存在")
}
comment := &model.Comment{
PostID: postID,
UserID: userID,
Content: content,
Status: status,
}
// 子回复:校验父评论(同帖、已发布),继承楼层根与层级
var parent *model.Comment
if parentID != nil {
parent = &model.Comment{}
if err := s.db.
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
First(parent).Error; err != nil {
return nil, nil, errors.New("回复的评论不存在")
}
if parent.Depth >= maxReplyDepth {
return nil, nil, errors.New("回复层级过深")
}
rootID := parent.ID
if parent.RootID != nil {
rootID = *parent.RootID
}
comment.ParentID = parentID
comment.RootID = &rootID
comment.Depth = parent.Depth + 1
}
if err := s.db.Create(comment).Error; err != nil {
return nil, nil, err
}
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
if comment.Status == model.ContentStatusPublished {
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
// 预加载用户
s.db.Preload("User").First(comment, comment.ID)
return comment, parent, nil
}
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
type UserCommentItem struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// ListByUser 获取指定用户发表的评论(含所属帖子标题)
func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Table("comments").
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.user_id = ? AND comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []UserCommentItem
offset := (page - 1) * size
if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil {
return nil, 0, err
}
if items == nil {
items = []UserCommentItem{}
}
return items, total, nil
}
// CountByUser 统计用户评论数(仅统计未删除帖子上的评论,与列表保持一致)
func (s *CommentService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Table("comments").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.user_id = ? AND comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
Count(&total).Error
return total, err
}
// DeleteCommentOpts 软删选项:自删须填理由;staff 须选类型,「其他」才须填理由
type DeleteCommentOpts struct {
DeleteType string
DeleteReason string
}
// Delete 软删单条评论(评论者本人或板块审核权),不级联子树;占位保留楼层。
func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts DeleteCommentOpts) error {
var comment model.Comment
if err := s.db.First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
deleteType := ""
deleteReason := strings.TrimSpace(opts.DeleteReason)
if comment.UserID == userID {
if deleteReason == "" {
return ErrCommentDeleteMeta
}
if len([]rune(deleteReason)) > 200 {
return errors.New("删除理由不能超过 200 字")
}
} else {
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return ErrCommentForbidden
}
deleteType = strings.TrimSpace(opts.DeleteType)
if !model.ValidPostDeleteType(deleteType) {
return ErrCommentInvalidType
}
if deleteType == model.PostDeleteTypeOther {
if deleteReason == "" {
return ErrCommentDeleteMeta
}
if len([]rune(deleteReason)) > 200 {
return errors.New("删除理由不能超过 200 字")
}
} else {
// 非「其他」仅展示类型标签,不落库自由文本
deleteReason = ""
}
}
updates := map[string]interface{}{
"delete_type": deleteType,
"delete_reason": deleteReason,
"deleted_by": userID,
}
if err := s.db.Model(&comment).Updates(updates).Error; err != nil {
return err
}
if err := s.db.Delete(&comment).Error; err != nil {
return err
}
if comment.Status == model.ContentStatusPublished {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
}
return nil
}
// Restore 恢复软删评论:作者可恢复自己的自删;版主可恢复本板任意软删(含管理删除)。
func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
if !comment.DeletedAt.Valid {
return ErrCommentNotDeleted
}
staffDeleted := comment.DeletedBy != 0 && comment.DeletedBy != comment.UserID
allowed := false
if comment.UserID == userID && !staffDeleted {
allowed = true
} else {
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return ErrCommentNotFound
}
if actor != nil && actor.CanModerateBoard(post.BoardID) {
allowed = true
}
}
if !allowed {
return ErrCommentForbidden
}
updates := map[string]interface{}{
"deleted_at": nil,
"delete_type": "",
"delete_reason": "",
"deleted_by": 0,
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil {
return err
}
if comment.Status == model.ContentStatusPublished {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
return nil
}
// collectDescendantIDs 收集以 root 为根的子树 ID(含自身)
func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
var all []model.Comment
if comment.RootID != nil {
s.db.Unscoped().Where("root_id = ?", *comment.RootID).Find(&all)
} else {
s.db.Unscoped().Where("root_id = ? OR id = ?", comment.ID, comment.ID).Find(&all)
}
descendants := map[uint]bool{comment.ID: true}
for i := 0; i <= maxReplyDepth; i++ {
added := false
for _, c := range all {
if c.ParentID == nil || descendants[c.ID] {
continue
}
if descendants[*c.ParentID] {
descendants[c.ID] = true
added = true
}
}
if !added {
break
}
}
ids := make([]uint, 0, len(descendants))
for id := range descendants {
ids = append(ids, id)
}
return ids
}
// Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。
func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return ErrCommentForbidden
}
_ = userID
ids := s.collectDescendantIDs(comment)
// 仅对「尚未软删」的已发布评论减计数(软删时已减过)
var livePublished int64
s.db.Model(&model.Comment{}).
Where("id IN ? AND status = ? AND deleted_at IS NULL", ids, model.ContentStatusPublished).
Count(&livePublished)
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentEditHistory{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil {
return err
}
if livePublished > 0 {
return tx.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)).Error
}
return nil
})
}
// Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。
func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("评论内容不能为空")
}
if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil {
return nil, err
}
var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return nil, ErrCommentNotFound
}
if comment.DeletedAt.Valid {
return nil, ErrCommentDeleted
}
if comment.UserID != userID {
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return nil, ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return nil, ErrCommentForbidden
}
}
if comment.Content == content {
return nil, ErrCommentNotChanged
}
old := comment.Content
if err := s.db.Transaction(func(tx *gorm.DB) error {
hist := model.CommentEditHistory{
CommentID: comment.ID,
EditorID: userID,
OldContent: old,
}
if err := tx.Create(&hist).Error; err != nil {
return err
}
return tx.Model(&comment).Update("content", content).Error
}); err != nil {
return nil, err
}
if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil {
return nil, err
}
node := toCommentNode(comment, nil, false, userID, nil)
return &node, nil
}
// CommentEditHistoryItem 管理可见的评论修订记录
type CommentEditHistoryItem struct {
ID uint `json:"id"`
Editor CommentActorBrief `json:"editor"`
OldContent string `json:"old_content"`
CreatedAt time.Time `json:"created_at"`
}
// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页)
func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 10
}
var comment model.Comment
if err := s.db.Unscoped().Select("id", "post_id").First(&comment, commentID).Error; err != nil {
return nil, 0, ErrCommentNotFound
}
var post model.Post
if err := s.db.Unscoped().Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return nil, 0, ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return nil, 0, ErrCommentForbidden
}
q := s.db.Model(&model.CommentEditHistory{}).Where("comment_id = ?", commentID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.CommentEditHistory
if err := s.db.Where("comment_id = ?", commentID).
Order("created_at DESC").
Offset((page - 1) * size).Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
editorIDs := make([]uint, 0, len(rows))
seen := map[uint]struct{}{}
for _, r := range rows {
if _, ok := seen[r.EditorID]; ok {
continue
}
seen[r.EditorID] = struct{}{}
editorIDs = append(editorIDs, r.EditorID)
}
editors := map[uint]CommentActorBrief{}
if len(editorIDs) > 0 {
var users []model.User
s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", editorIDs).Find(&users)
for _, u := range users {
editors[u.ID] = CommentActorBrief{
ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar,
}
}
}
items := make([]CommentEditHistoryItem, 0, len(rows))
for _, r := range rows {
ed := editors[r.EditorID]
if ed.ID == 0 {
ed = CommentActorBrief{ID: r.EditorID, Username: "已注销", Nickname: "已注销"}
}
items = append(items, CommentEditHistoryItem{
ID: r.ID,
Editor: ed,
OldContent: r.OldContent,
CreatedAt: r.CreatedAt,
})
}
return items, total, nil
}