710 lines
22 KiB
Go
710 lines
22 KiB
Go
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
|
||
}
|