diff --git a/backend/handler/comment.go b/backend/handler/comment.go index cf2c72a..29d7cbe 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -66,6 +66,13 @@ func (h *Handlers) CreateComment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } + if allow, err := h.Setting.AllowComments(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"}) + return + } else if !allow { + c.JSON(http.StatusForbidden, gin.H{"error": "站点已关闭评论"}) + return + } var req CreateCommentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -99,7 +106,7 @@ func (h *Handlers) CreateComment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"comment": comment}) } -// DeleteComment 删除评论 +// DeleteComment 软删评论(占位) func (h *Handlers) DeleteComment(c *gin.Context) { claims := middleware.CurrentUser(c) cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) @@ -120,3 +127,25 @@ func (h *Handlers) DeleteComment(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "已删除"}) } + +// PurgeComment 硬删评论及子树(仅版主;不占位) +func (h *Handlers) PurgeComment(c *gin.Context) { + claims := middleware.CurrentUser(c) + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) + return + } + if err := h.Comment.Purge(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil { + switch { + case errors.Is(err, service.ErrCommentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "已彻底删除"}) +} diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index 3deb4cc..673eeda 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -118,7 +119,33 @@ func (h *Handlers) AdminListComments(c *gin.Context) { } func (h *Handlers) AdminSoftDeletePost(c *gin.Context) { - h.execContentAction(c, h.Moderation.SoftDeletePost) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + var body struct { + DeleteType string `json:"delete_type"` + DeleteReason string `json:"delete_reason"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请提交删除类型与理由"}) + return + } + actor := middleware.CurrentActor(c) + authorID, err := h.Moderation.SoftDeletePost(actor, uint(id), body.DeleteType, body.DeleteReason) + if err != nil { + h.writeModerationError(c, err) + return + } + label := model.PostDeleteTypeLabel(body.DeleteType) + preview := label + if body.DeleteReason != "" { + preview = label + ":" + body.DeleteReason + } + h.Notification.Create(authorID, actor.ID, model.NotificationTypeDeleted, uint(id), 0, preview) + h.broadcastModerationChanged() + c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) } func (h *Handlers) AdminRestorePost(c *gin.Context) { @@ -168,7 +195,9 @@ func (h *Handlers) writeModerationError(c *gin.Context, err error) { default: msg := err.Error() if msg == "帖子已在回收站" || msg == "帖子未被删除" || - msg == "评论已在回收站" || msg == "评论未被删除" { + msg == "评论已在回收站" || msg == "评论未被删除" || + msg == "无效的删除类型" || msg == "请填写删除理由" || + msg == "删除理由不能超过 200 字" { c.JSON(http.StatusBadRequest, gin.H{"error": msg}) return } diff --git a/backend/handler/post.go b/backend/handler/post.go index 71c3373..0818122 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -361,7 +361,7 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) { _ = filepath.Base(path) } -// DeletePost 删除帖子 +// DeletePost 删除帖子(作者自删或 staff 软删;staff 须提交类型与理由) func (h *Handlers) DeletePost(c *gin.Context) { claims := middleware.CurrentUser(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) @@ -369,10 +369,30 @@ func (h *Handlers) DeletePost(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) return } - if err := h.Post.Delete(h.loadActor(claims.ID), uint(id), claims.ID); err != nil { + var body struct { + DeleteType string `json:"delete_type"` + DeleteReason string `json:"delete_reason"` + } + _ = c.ShouldBindJSON(&body) + + staffDeleted, authorID, err := h.Post.Delete( + h.loadActor(claims.ID), + uint(id), + claims.ID, + service.DeletePostOpts{DeleteType: body.DeleteType, DeleteReason: body.DeleteReason}, + ) + if err != nil { respondPostModError(c, err) return } + if staffDeleted { + label := model.PostDeleteTypeLabel(body.DeleteType) + preview := label + if body.DeleteReason != "" { + preview = label + ":" + body.DeleteReason + } + h.Notification.Create(authorID, claims.ID, model.NotificationTypeDeleted, uint(id), 0, preview) + } c.JSON(http.StatusOK, gin.H{"message": "已删除"}) } diff --git a/backend/model/models.go b/backend/model/models.go index 1e15712..b48e2dc 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -177,6 +177,10 @@ type Post struct { ViewCount int `gorm:"default:0" json:"view_count"` CommentCount int `gorm:"default:0" json:"comment_count"` Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库) + // 软删元数据:列表仍排除;详情 Unscoped 可读作 tombstone 页 + DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` + DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"` + DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` // 执行删除者;与 UserID 不同即为管理删除 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` @@ -185,6 +189,43 @@ type Post struct { User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } +// 帖子管理删除类型(staff 软删必填) +const ( + PostDeleteTypeSpam = "spam" + PostDeleteTypeViolation = "violation" + PostDeleteTypeOffTopic = "off_topic" + PostDeleteTypeDuplicate = "duplicate" + PostDeleteTypeOther = "other" +) + +// ValidPostDeleteType 管理删帖类型白名单 +func ValidPostDeleteType(t string) bool { + switch t { + case PostDeleteTypeSpam, PostDeleteTypeViolation, PostDeleteTypeOffTopic, + PostDeleteTypeDuplicate, PostDeleteTypeOther: + return true + } + return false +} + +// PostDeleteTypeLabel 类型中文标签(通知/专用页) +func PostDeleteTypeLabel(t string) string { + switch t { + case PostDeleteTypeSpam: + return "垃圾广告" + case PostDeleteTypeViolation: + return "违规内容" + case PostDeleteTypeOffTopic: + return "灌水跑题" + case PostDeleteTypeDuplicate: + return "重复内容" + case PostDeleteTypeOther: + return "其他" + default: + return "" + } +} + // PointLedger 积分流水(余额以 User.Points 为准,本表可审计) type PointLedger struct { ID uint `gorm:"primaryKey" json:"id"` @@ -302,6 +343,7 @@ const ( NotificationTypeRejected = "rejected" // 内容审核未通过 NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID) NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者) + NotificationTypeDeleted = "deleted" // 帖子被管理员删除 ) // Notification 站内通知 diff --git a/backend/router/router.go b/backend/router/router.go index f52305b..6776c92 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -151,6 +151,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.POST("/posts/:id/like", h.ToggleLike) api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment) api.DELETE("/posts/:id/comments/:cid", h.DeleteComment) + api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment) // 通知 api.GET("/notifications", h.Notifications) api.GET("/notifications/unread-count", h.UnreadCount) diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index e4cb1d9..b13d40e 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -303,16 +303,38 @@ func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.C return &cm, post.BoardID, nil } -// SoftDeletePost 软删帖子(未删除时) -func (s *ModerationService) SoftDeletePost(actor *Actor, id uint) error { +// SoftDeletePost 软删帖子(未删除时);须填写删除类型与理由,并通知作者 +func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) { post, err := s.loadPostUnscoped(actor, id) if err != nil { - return err + return 0, err } if post.DeletedAt.Valid { - return errors.New("帖子已在回收站") + return 0, errors.New("帖子已在回收站") } - return s.db.Delete(post).Error + deleteType = strings.TrimSpace(deleteType) + deleteReason = strings.TrimSpace(deleteReason) + if !model.ValidPostDeleteType(deleteType) { + return 0, errors.New("无效的删除类型") + } + if deleteReason == "" { + return 0, errors.New("请填写删除理由") + } + if len([]rune(deleteReason)) > 200 { + return 0, errors.New("删除理由不能超过 200 字") + } + updates := map[string]interface{}{ + "delete_type": deleteType, + "delete_reason": deleteReason, + "deleted_by": actor.ID, + } + if err := s.db.Model(post).Updates(updates).Error; err != nil { + return 0, err + } + if err := s.db.Delete(post).Error; err != nil { + return 0, err + } + return post.UserID, nil } // RestorePost 从回收站恢复帖子 diff --git a/backend/service/comment.go b/backend/service/comment.go index d6d879a..a5a15bd 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -27,6 +27,7 @@ func NewCommentService(db *gorm.DB) *CommentService { // CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略 type CommentNode struct { model.Comment + Deleted bool `json:"deleted,omitempty"` // 软删占位 Replies []CommentNode `json:"replies,omitempty"` } @@ -35,8 +36,8 @@ const maxReplyDepth = 10 // ListFloorPaged 楼层分页获取帖子评论树: // 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。 -// viewer 可为作者本人或该板可审者时并入 pending;游客仅 published。 -// 返回:树根列表、楼层数、全部评论数(含回复)。 +// 已软删评论以 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 @@ -44,18 +45,23 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A if size < 1 || size > 100 { size = 20 } - base := applyCommentListVisibility(s.db.Model(&model.Comment{}).Where("post_id = ?", postID), boardID, viewerID, actor) + // 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{}).Count(&totalComments).Error; err != nil { + // 徽章:仅未软删 + if err := base.Session(&gorm.Session{}). + Where("deleted_at IS NULL").Count(&totalComments).Error; err != nil { return nil, 0, 0, err } - // 1. 本页楼层(主评论) var roots []model.Comment if err := base.Session(&gorm.Session{}). Where("parent_id IS NULL"). @@ -70,19 +76,17 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A return []CommentNode{}, floors, totalComments, nil } - // 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询) rootIDs := make([]uint, len(roots)) for i, r := range roots { rootIDs[i] = r.ID } - replyQ := applyCommentListVisibility(s.db.Where("root_id IN (?)", rootIDs), boardID, viewerID, actor) + 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 } - // 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部 - children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序) + children := make(map[uint][]model.Comment, len(replies)) for _, r := range replies { if r.ParentID != nil { children[*r.ParentID] = append(children[*r.ParentID], r) @@ -93,24 +97,23 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A build = func(pid uint) []CommentNode { out := []CommentNode{} for _, c := range children[pid] { - out = append(out, CommentNode{Comment: c, Replies: build(c.ID)}) + out = append(out, toCommentNode(c, build(c.ID))) placed[c.ID] = true } return out } result := make([]CommentNode, len(roots)) for i := range roots { - result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)} + result[i] = toCommentNode(roots[i], build(roots[i].ID)) 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, CommentNode{Comment: r, Replies: build(r.ID)}) + result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID))) break } } @@ -118,17 +121,38 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A return result, floors, totalComments, nil } -// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审) +// toCommentNode 软删行清空正文与用户展示信息,标 deleted +func toCommentNode(c model.Comment, replies []CommentNode) CommentNode { + if !c.DeletedAt.Valid { + return CommentNode{Comment: c, Replies: replies} + } + c.Content = "" + c.User = model.User{} + return CommentNode{Comment: c, Deleted: true, Replies: replies} +} + +// 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("status = ?", model.ContentStatusPublished) + 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("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) + 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( - "status = ? OR (status = ? AND user_id = ?)", - model.ContentStatusPublished, model.ContentStatusPending, viewerID, + `(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, ) } @@ -241,14 +265,12 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) { return total, err } -// Delete 删除评论(作者本人,或对帖子所属板块有审核权的管理成员), -// 级联软删整棵子树 +// Delete 软删单条评论(作者本人或板块审核权),不级联子树;占位保留楼层。 func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error { var comment model.Comment if err := s.db.First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } - // 权限校验:作者本人或板块审核权 if comment.UserID != userID { var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { @@ -258,12 +280,23 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error { return ErrCommentForbidden } } - // 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛) + 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 +} + +// collectDescendantIDs 收集以 root 为根的子树 ID(含自身) +func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint { var all []model.Comment if comment.RootID != nil { - s.db.Where("root_id = ?", *comment.RootID).Find(&all) + s.db.Unscoped().Where("root_id = ?", *comment.RootID).Find(&all) } else { - s.db.Where("root_id = ?", comment.ID).Find(&all) + 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++ { @@ -285,27 +318,37 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error { for id := range descendants { ids = append(ids, id) } - // 只有已发布评论曾计入 comment_count,递减时排除待审/被拒评论。 - // all 含同楼层全部子评论(删除主评论时目标自身不在 all 内,需单独计入) - publishedCnt := 0 - if comment.Status == model.ContentStatusPublished { - publishedCnt++ + 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 } - for _, c := range all { - if c.ID == comment.ID { - continue - } - if descendants[c.ID] && c.Status == model.ContentStatusPublished { - publishedCnt++ - } + var post model.Post + if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { + return ErrCommentNotFound } - // 软删子树并按已发布条数递减帖子评论数 - if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil { + 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) + + if err := s.db.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil { return err } - if publishedCnt > 0 { + if livePublished > 0 { s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). - UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", publishedCnt)) + UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)) } return nil } diff --git a/backend/service/post.go b/backend/service/post.go index 75a619c..25c2e7a 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -12,8 +12,10 @@ import ( // 帖子操作错误 var ( - ErrPostNotFound = errors.New("帖子不存在") - ErrPostForbidden = errors.New("无权限操作此帖子") + ErrPostNotFound = errors.New("帖子不存在") + ErrPostForbidden = errors.New("无权限操作此帖子") + ErrPostDeleteMeta = errors.New("管理删除须填写类型与理由") + ErrPostInvalidType = errors.New("无效的删除类型") ) // PostService 帖子服务 @@ -384,6 +386,13 @@ type PostDetail struct { Poll *PollState `json:"poll,omitempty"` Bounty *BountyState `json:"bounty,omitempty"` Lottery *LotteryState `json:"lottery,omitempty"` + // 软删 tombstone:列表不可见,详情专用页可读 + Tombstone bool `json:"tombstone,omitempty"` + DeleteType string `json:"delete_type,omitempty"` + DeleteReason string `json:"delete_reason,omitempty"` + DeletedBy uint `json:"deleted_by,omitempty"` + StaffDeleted bool `json:"staff_deleted,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // CreatePostInput 发帖入参 @@ -412,11 +421,23 @@ type UpdatePostInput struct { AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表 } -// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制) +// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)。 +// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。 func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) { var post model.Post - if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil { - return nil, ErrPostNotFound + err := s.db.Preload("Board").Preload("User").First(&post, id).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPostNotFound + } + // 默认 scope 找不到:尝试软删行 → tombstone + if err := s.db.Unscoped().Preload("Board").Preload("User").First(&post, id).Error; err != nil { + return nil, ErrPostNotFound + } + if !post.DeletedAt.Valid { + return nil, ErrPostNotFound + } + return buildPostTombstone(&post), nil } if !visibleToPost(&post, viewerID, loadActor) { return nil, ErrPostNotFound @@ -448,6 +469,34 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto return detail, nil } +func buildPostTombstone(post *model.Post) *PostDetail { + var deletedAt *time.Time + if post.DeletedAt.Valid { + t := post.DeletedAt.Time + deletedAt = &t + } + staffDeleted := post.DeletedBy != 0 && post.DeletedBy != post.UserID + return &PostDetail{ + ID: post.ID, + BoardID: post.BoardID, + UserID: post.UserID, + Title: post.Title, + PostType: model.NormalizePostType(post.PostType), + Status: post.Status, + CreatedAt: post.CreatedAt, + UpdatedAt: post.UpdatedAt, + Board: post.Board, + User: post.User, + Attachments: []PostAttachmentDTO{}, + Tombstone: true, + DeleteType: post.DeleteType, + DeleteReason: post.DeleteReason, + DeletedBy: post.DeletedBy, + StaffDeleted: staffDeleted, + DeletedAt: deletedAt, + } +} + func buildPostDetail(post *model.Post) *PostDetail { return &PostDetail{ ID: post.ID, BoardID: post.BoardID, UserID: post.UserID, @@ -954,17 +1003,53 @@ func (s *PostService) checkNewUserCooldown(userID uint) error { return nil } -// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员) -func (s *PostService) Delete(actor *Actor, postID, userID uint) error { +// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值 +type DeletePostOpts struct { + DeleteType string + DeleteReason string +} + +// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)。 +// staff 删除他人帖必须提供合法类型与非空理由;作者自删不写类型/理由。 +// 返回 staffDeleted 与作者 ID,供 handler 发通知。 +func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostOpts) (staffDeleted bool, authorID uint, err error) { var post model.Post if err := s.db.First(&post, postID).Error; err != nil { - return ErrPostNotFound + return false, 0, ErrPostNotFound } - if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) { - return ErrPostForbidden + authorID = post.UserID + + deleteType := "" + deleteReason := "" + if post.UserID == userID { + staffDeleted = false + } else if actor != nil && actor.CanModerateBoard(post.BoardID) { + deleteType = strings.TrimSpace(opts.DeleteType) + deleteReason = strings.TrimSpace(opts.DeleteReason) + if !model.ValidPostDeleteType(deleteType) { + return false, authorID, ErrPostInvalidType + } + if deleteReason == "" { + return false, authorID, ErrPostDeleteMeta + } + if len([]rune(deleteReason)) > 200 { + return false, authorID, errors.New("删除理由不能超过 200 字") + } + staffDeleted = true + } else { + return false, authorID, ErrPostForbidden + } + + updates := map[string]interface{}{ + "delete_type": deleteType, + "delete_reason": deleteReason, + "deleted_by": userID, + } + if err := s.db.Model(&post).Updates(updates).Error; err != nil { + return false, authorID, err } if err := s.db.Delete(&post).Error; err != nil { - return err + return false, authorID, err } - return nil + return staffDeleted, authorID, nil } diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index a9c30a9..cbf07ae 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -34,6 +34,7 @@ import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; +import StaffDeletePostModal from "@/components/StaffDeletePostModal"; const PAGE_SIZE = 15; @@ -139,6 +140,8 @@ export default function ContentAdmin({ run: () => Promise; } | null>(null); const [confirmBusy, setConfirmBusy] = useState(false); + const [staffDeletePost, setStaffDeletePost] = useState(null); + const [staffDeleteBusy, setStaffDeleteBusy] = useState(false); const refreshCounts = useCallback(() => { apiAdminContentCounts().then(setCounts).catch(() => {}); @@ -515,18 +518,7 @@ export default function ContentAdmin({ type="button" className="btn btn-line btn-sm" disabled={busy} - onClick={() => - askConfirm( - "移入回收站", - `确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`, - () => - apiAdminSoftDeletePost(p.id).then(() => { - toast("已移入回收站", "ok"); - reload(postsPage); - }), - true - ) - } + onClick={() => setStaffDeletePost(p)} > 删除 @@ -691,6 +683,25 @@ export default function ContentAdmin({ }); }} /> + + !staffDeleteBusy && setStaffDeletePost(null)} + onConfirm={(opts) => { + if (!staffDeletePost) return; + setStaffDeleteBusy(true); + apiAdminSoftDeletePost(staffDeletePost.id, opts) + .then(() => { + toast("已移入回收站", "ok"); + setStaffDeletePost(null); + reload(postsPage); + }) + .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) + .finally(() => setStaffDeleteBusy(false)); + }} + /> ); } diff --git a/frontend/app/notifications/NotificationsClient.tsx b/frontend/app/notifications/NotificationsClient.tsx index db8a8a5..bf57417 100644 --- a/frontend/app/notifications/NotificationsClient.tsx +++ b/frontend/app/notifications/NotificationsClient.tsx @@ -76,7 +76,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti const approval = n.type === "approved" || n.type === "rejected"; const iconNode = n.type === "approved" ? - : n.type === "rejected" + : n.type === "rejected" || n.type === "deleted" ? : n.type === "like" ? @@ -87,14 +87,14 @@ export default function NotificationsClient({ initialData }: { initialData: Noti : ; const iconColor = n.type === "approved" ? "var(--ok)" - : n.type === "rejected" + : n.type === "rejected" || n.type === "deleted" ? "var(--danger)" : n.type === "like" ? "var(--clay)" : "var(--accent)"; const iconBg = n.type === "approved" ? "var(--ok-soft)" - : n.type === "rejected" + : n.type === "rejected" || n.type === "deleted" ? "var(--danger-soft)" : n.type === "like" ? "var(--clay-soft)" @@ -143,7 +143,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti ? "在消息中提到了你" : n.type === "pending_review" ? "有新内容待审核" - : "点赞了你的帖子"} + : n.type === "deleted" + ? "删除了你的帖子" + : "点赞了你的帖子"} )} diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 5f3bc0b..1d0accf 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -18,12 +18,13 @@ import { fetchComments, fetchPosts, fetchUserProfile, + postDeleteTypeLabel, type UserProfile, type PostListItem, type Board, } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; -import { getMeCached } from "@/lib/serverData"; +import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import CommentSection from "@/components/CommentSection"; import PostActions from "@/components/PostActions"; import LikeButton from "@/components/LikeButton"; @@ -50,9 +51,16 @@ export async function generateMetadata({ params }: PageProps): Promise try { // 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在" const { post } = await fetchPostDetail(id, authCookieHeader(await cookies())); + if (post.tombstone) { + return { + title: post.title || "帖子已删除", + description: "该帖子已被删除", + robots: { index: false, follow: false }, + }; + } const desc = post.content_locked ? post.access_hint || "部分内容需满足条件后可见" - : post.content.slice(0, 160); + : (post.content || "").slice(0, 160); return { title: post.title, description: desc, @@ -383,10 +391,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps notFound(); } - // 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文 - const [commentsData, me, profile, relatedData] = await Promise.all([ + // 软删专用页:列表已不可见,原链接展示删除说明 + if (post.tombstone) { + const typeLabel = postDeleteTypeLabel(post.delete_type); + return ( +
+

+ 帖子已删除 +

+

+ {post.title || "(无标题)"} +

+ {post.staff_deleted ? ( +
+

+ 此帖已被管理员删除 +

+ {typeLabel && ( +

+ 类型: + {typeLabel} +

+ )} + {post.delete_reason && ( +

+ 理由: + {post.delete_reason} +

+ )} +
+ ) : ( +

作者已删除此帖

+ )} +
+ + 返回首页 + + {post.board?.id ? ( + + 浏览板块 + + ) : null} +
+
+ ); + } + + // 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文 + const [commentsData, me, siteSettings, profile, relatedData] = await Promise.all([ fetchComments(id, commentPage, cookie), getMeCached(cookie || undefined), + getPublicSettingsCached(), fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null), fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null), ]); @@ -554,6 +615,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps pinned={post.pinned} recommended={post.recommended} status={post.status} + title={post.title} /> @@ -659,6 +721,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps page={commentsData.page} size={commentsData.size} user={me.user} + allowComments={siteSettings.allow_comments} acceptMode={acceptMode} acceptedCommentId={ post.question?.accepted_comment_id || diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 0ee5861..34ea53b 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -1,12 +1,21 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck, XCircle } from "lucide-react"; +import { + Trash2, + MessagesSquare, + MessageSquareOff, + ChevronRight, + ChevronDown, + BadgeCheck, + XCircle, +} from "lucide-react"; import { apiCreateComment, apiDeleteComment, + apiPurgeComment, apiAcceptBounty, apiAcceptQuestion, apiAdminApproveComment, @@ -16,6 +25,7 @@ import { } from "@/lib/api"; import { canModerateBoard } from "@/lib/roles"; import { toast } from "@/lib/toast"; +import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import Avatar from "./Avatar"; import Pagination from "./Pagination"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; @@ -37,6 +47,8 @@ interface CommentSectionProps { /** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */ acceptMode?: "bounty" | "question" | null; acceptedCommentId?: number; + /** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */ + allowComments?: boolean; } // 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原 @@ -59,6 +71,7 @@ export default function CommentSection({ user, acceptMode = null, acceptedCommentId = 0, + allowComments: allowCommentsProp = true, }: CommentSectionProps) { const router = useRouter(); const [content, setContent] = useState(""); @@ -72,13 +85,33 @@ export default function CommentSection({ const [approvingId, setApprovingId] = useState(null); const [rejectTargetId, setRejectTargetId] = useState(null); const [rejecting, setRejecting] = useState(false); + const [purgeTargetId, setPurgeTargetId] = useState(null); + const [purging, setPurging] = useState(false); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); + const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false); const textareaRef = useRef(null); + useEffect(() => { + setCommentsOpen(allowCommentsProp !== false); + }, [allowCommentsProp]); + + // 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝) + useEffect(() => { + return realtime.on(RT_SETTINGS_CHANGED, (data) => { + if (typeof data?.allow_comments === "boolean") { + setCommentsOpen(data.allow_comments); + if (!data.allow_comments) { + setReplyTarget(null); + setReplyContent(""); + } + } + }); + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!content.trim()) return; + if (!commentsOpen || !content.trim()) return; setSubmitting(true); try { const res = await apiCreateComment(postId, content); @@ -105,7 +138,7 @@ export default function CommentSection({ const handleSubmitReply = async (e: React.FormEvent) => { e.preventDefault(); - if (!replyTarget || !replyContent.trim()) return; + if (!commentsOpen || !replyTarget || !replyContent.trim()) return; setSubmitting(true); try { const res = await apiCreateComment(postId, replyContent, replyTarget.id); @@ -204,19 +237,47 @@ export default function CommentSection({ } }; + const handlePurgeConfirm = async () => { + if (purgeTargetId == null) return; + setPurging(true); + try { + const res = await apiPurgeComment(postId, purgeTargetId); + if (res.message) { + toast("已彻底删除", "ok"); + setPurgeTargetId(null); + router.refresh(); + } else { + toast(res.error || "彻底删除失败"); + } + } catch (e) { + toast(e instanceof Error ? e.message : "彻底删除失败"); + } finally { + setPurging(false); + } + }; + // 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能) - const canDelete = (comment: CommentNode) => - !!user && (user.id === comment.user.id || canModerateBoard(user, boardId)); + const canSoftDelete = (comment: CommentNode) => + !comment.deleted && + !!user && + (user.id === comment.user?.id || canModerateBoard(user, boardId)); + + const canPurge = (comment: CommentNode) => + !!user && canModerateBoard(user, boardId); const canApprove = (comment: CommentNode) => - !!user && comment.status === "pending" && canModerateBoard(user, boardId); + !comment.deleted && + !!user && + comment.status === "pending" && + canModerateBoard(user, boardId); const canAccept = (comment: CommentNode) => + !comment.deleted && !!acceptMode && !!user && comment.status === "published" && - comment.user.id !== authorId; + comment.user?.id !== authorId; const openReply = (c: CommentNode) => { setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username }); @@ -235,8 +296,8 @@ export default function CommentSection({ const totalPages = Math.max(1, Math.ceil(total / size)); - // 内联回复表单(出现在目标评论正下方) - const inlineReplyForm = replyTarget && ( + // 内联回复表单(出现在目标评论正下方;全站关评时不渲染) + const inlineReplyForm = commentsOpen && replyTarget && (
回复 @{replyTarget.nickname} @@ -285,7 +346,104 @@ export default function CommentSection({ const replyCount = countReplies(node); const isFloor = depth === 0; const isExpanded = isFloor ? expandedFloors.has(c.id) : true; - const parentAuthor = parent?.user.nickname || parent?.user.username; + const parentAuthor = parent?.user?.nickname || parent?.user?.username; + + // 软删占位:保留楼层/回复位,不展示正文;子回复仍可展开 + if (c.deleted) { + const tombstoneBody = ( +
+ 此评论已删除 +
+ ); + const purgeBtn = canPurge(c) && ( + + ); + + if (!isFloor) { + return ( +
+
+ +
+ {tombstoneBody} +
+ {new Date(c.created_at).toLocaleString("zh-CN")} + {purgeBtn} +
+
+
+ {node.replies && node.replies.length > 0 && ( +
+ {node.replies.map((r) => renderComment(r, depth + 1, undefined, node))} +
+ )} +
+ ); + } + + return ( +
+
+ +
+
+ + 已删除 + + {floor && ( + + #{floor} + + )} +
+ {tombstoneBody} +
+ {new Date(c.created_at).toLocaleString("zh-CN")} + {purgeBtn} +
+ {node.replies && node.replies.length > 0 && ( +
+ + {isExpanded && ( +
+ {node.replies.map((r) => renderComment(r, 1, undefined, node))} +
+ )} +
+ )} +
+
+
+ ); + } if (!isFloor) { // 子回复:紧凑行式布局(抖音风格) @@ -323,7 +481,7 @@ export default function CommentSection({
{new Date(c.created_at).toLocaleString("zh-CN")} - {user && ( + {commentsOpen && user && (
- {canDelete(c) && ( + {canSoftDelete(c) && ( )} + {canPurge(c) && ( + + )}
{replyTarget?.id === c.id && inlineReplyForm}
@@ -440,7 +608,7 @@ export default function CommentSection({
{new Date(c.created_at).toLocaleString("zh-CN")} - {user && ( + {commentsOpen && user && (
- {canDelete(c) && ( + {canSoftDelete(c) && ( )} + {canPurge(c) && ( + + )}
{replyTarget?.id === c.id && inlineReplyForm} @@ -555,7 +733,34 @@ export default function CommentSection({ 共 {totalPages} 页楼层 - {user ? ( + {!commentsOpen ? ( +
+ + + +

+ 评论功能暂时关闭 +

+

+ 站点当前暂停发表新评论与回复,历史评论仍可浏览。 +

+
+ ) : user ? (