diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 12c33de..8bdfcf2 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "fmt" "net/http" "strconv" @@ -83,7 +84,7 @@ func (h *Handlers) PostComments(c *gin.Context) { // CreateCommentRequest 评论请求 type CreateCommentRequest struct { - Content string `json:"content" binding:"required,min=1"` + Content string `json:"content" binding:"required,min=1,max=5000"` ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论) } @@ -109,11 +110,24 @@ func (h *Handlers) CreateComment(c *gin.Context) { } // staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知) status := h.resolvePublishStatus(claims.ID, claims.Role) - comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status) + comment, parent, necroPoints, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status, model.Role(claims.Role)) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + switch { + case errors.Is(err, service.ErrPostReplyLocked): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrInsufficientPoints): + // 旧帖回复扣分余额不足(文案含具体规则) + c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } return } + // 旧帖回复扣分:系统提醒本人(ActorID=0,不经过自我通知过滤) + if necroPoints > 0 { + h.Notification.CreateSystem(claims.ID, model.NotificationTypeNecroReply, uint(id), comment.ID, + fmt.Sprintf("您回复了长期没有新回复的旧帖,已扣除 %d 积分", necroPoints)) + } if comment.Status == model.ContentStatusPublished { if parent == nil { // 主评论:通知帖子作者(排除自己评论自己的帖子) @@ -146,7 +160,7 @@ func (h *Handlers) UpdateComment(c *gin.Context) { return } var body struct { - Content string `json:"content"` + Content string `json:"content" binding:"omitempty,min=1,max=5000"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -157,7 +171,9 @@ func (h *Handlers) UpdateComment(c *gin.Context) { switch { case errors.Is(err, service.ErrCommentNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, service.ErrCommentForbidden): + case errors.Is(err, service.ErrCommentForbidden), + errors.Is(err, service.ErrCommentPostLocked), + errors.Is(err, service.ErrCommentEditLocked): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrCommentDeleted), errors.Is(err, service.ErrCommentNotChanged): @@ -256,3 +272,39 @@ func (h *Handlers) PurgeComment(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "已彻底删除"}) } + +// CommentEditHistory 评论编辑历史(登录即可见;分页) +func (h *Handlers) CommentEditHistory(c *gin.Context) { + claims := middleware.CurrentUser(c) + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil || cid == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + items, total, err := h.Comment.ListEditHistory(h.loadActor(claims.ID), uint(cid), page, size) + if 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.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"}) + } + return + } + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "size": size, + }) +} diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index 4fb8dc7..e32aa05 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -193,41 +193,6 @@ func (h *Handlers) AdminPurgeComment(c *gin.Context) { h.execContentAction(c, h.Moderation.PurgeComment) } -// AdminCommentHistory 评论编辑历史(板域 scope;分页) -func (h *Handlers) AdminCommentHistory(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) - items, total, err := h.Comment.ListEditHistory(middleware.CurrentActor(c), uint(id), page, size) - if 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.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"}) - } - return - } - if page < 1 { - page = 1 - } - if size < 1 || size > 50 { - size = 10 - } - c.JSON(http.StatusOK, gin.H{ - "items": items, - "total": total, - "page": page, - "size": size, - }) -} - func (h *Handlers) execContentAction(c *gin.Context, fn func(*service.Actor, uint) error) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { diff --git a/backend/handler/post.go b/backend/handler/post.go index 05c71b0..1e8bdf1 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -89,6 +89,7 @@ func (h *Handlers) PostDetail(c *gin.Context) { if claims != nil { post.Liked = h.Like.HasLiked(post.ID, claims.ID) } + h.attachNecroReplyHint(claims, post) c.JSON(http.StatusOK, gin.H{"post": post}) } @@ -96,7 +97,7 @@ func (h *Handlers) PostDetail(c *gin.Context) { type CreatePostRequest struct { BoardID uint `json:"board_id" binding:"required"` Title string `json:"title" binding:"required,min=1,max=256"` - Content string `json:"content" binding:"required,min=1"` + Content string `json:"content" binding:"required,min=1,max=20000"` Tags string `json:"tags"` PostType string `json:"post_type"` TypeMeta string `json:"type_meta"` @@ -138,7 +139,7 @@ func (h *Handlers) CreatePost(c *gin.Context) { // UpdatePostRequest 更新帖子请求 type UpdatePostRequest struct { Title string `json:"title" binding:"omitempty,min=1,max=256"` - Content string `json:"content" binding:"omitempty,min=1"` + Content string `json:"content" binding:"omitempty,min=1,max=20000"` Tags string `json:"tags"` TypeMeta *string `json:"type_meta"` AttachmentIDs *[]uint `json:"attachment_ids"` @@ -172,6 +173,42 @@ func (h *Handlers) UpdatePost(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"post": post}) } +// PostEditHistory 帖子编辑历史(登录即可见;分页) +func (h *Handlers) PostEditHistory(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + items, total, err := h.Post.ListEditHistory(h.loadActor(claims.ID), uint(id), page, size) + if err != nil { + switch { + case errors.Is(err, service.ErrPostNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrPostForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"}) + } + return + } + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "size": size, + }) +} + // UnlockPostContent 积分解锁正文 func (h *Handlers) UnlockPostContent(c *gin.Context) { claims := middleware.CurrentUser(c) @@ -239,6 +276,7 @@ func (h *Handlers) UnlockPostPassword(c *gin.Context) { if claims != nil { post.Liked = h.Like.HasLiked(post.ID, claims.ID) } + h.attachNecroReplyHint(claims, post) c.JSON(http.StatusOK, gin.H{"post": post}) } @@ -539,6 +577,52 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"recommended": rec}) } +// TogglePostLock 切换帖子手动锁定(管理员及以上;锁定后普通用户不可编辑/回复,staff 豁免) +func (h *Handlers) TogglePostLock(c *gin.Context) { + if !h.requireAdminOrAbove(c) { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) + return + } + locked, err := h.Post.ToggleLock(uint(id)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"locked": locked}) +} + +// attachNecroReplyHint 旧帖回复确认提示:仅当 viewer 已登录、非 staff、规则开启、 +// 帖子判定为旧帖、且其评论将直发(待审不扣分不提醒,不弹窗)时返回 +func (h *Handlers) attachNecroReplyHint(claims *service.UserClaims, detail *service.PostDetail) { + if claims == nil || detail == nil || detail.Tombstone || detail.NecroReply != nil { + return + } + if model.IsStaff(model.Role(claims.Role)) { + return + } + afterHours, err1 := h.Setting.NecroReplyAfterHours() + penalty, err2 := h.Setting.NecroReplyPenalty() + if err1 != nil || err2 != nil || afterHours <= 0 || penalty <= 0 { + return + } + // 旧帖基准:最后回复时间;无回复(null)回落发帖时间 + baseline := detail.CreatedAt + if detail.LastReplyAt != nil { + baseline = *detail.LastReplyAt + } + if !service.IsNecroReply(baseline, afterHours, time.Now()) { + return + } + if h.resolvePublishStatus(claims.ID, claims.Role) != model.ContentStatusPublished { + return + } + detail.NecroReply = &service.NecroReplyHint{AfterHours: afterHours, Penalty: penalty} +} + // requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准 func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool { claims := middleware.CurrentUser(c) @@ -556,7 +640,9 @@ func respondPostModError(c *gin.Context, err error) { case errors.Is(err, service.ErrPostNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrPostForbidden), - errors.Is(err, service.ErrAuthorProtected): + errors.Is(err, service.ErrAuthorProtected), + errors.Is(err, service.ErrPostLocked), + errors.Is(err, service.ErrPostEditLocked): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrPollOptionsLocked), errors.Is(err, service.ErrLotteryPrizesLocked): diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 50fbc80..fb80d78 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -68,6 +68,11 @@ type updateSettingsRequest struct { PointsReplyDailyCap *int `json:"points_reply_daily_cap"` PointsRecommendReward *int `json:"points_recommend_reward"` + PostEditLockHours *int `json:"post_edit_lock_hours"` + CommentEditLockHours *int `json:"comment_edit_lock_hours"` + NecroReplyAfterHours *int `json:"necro_reply_after_hours"` + NecroReplyPenalty *int `json:"necro_reply_penalty"` + Levels *[]model.LevelDef `json:"levels"` LevelsFx *bool `json:"levels_fx"` } @@ -120,6 +125,11 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "points_reply_daily_cap": saved.PointsReplyDailyCap, "points_recommend_reward": saved.PointsRecommendReward, + "post_edit_lock_hours": saved.PostEditLockHours, + "comment_edit_lock_hours": saved.CommentEditLockHours, + "necro_reply_after_hours": saved.NecroReplyAfterHours, + "necro_reply_penalty": saved.NecroReplyPenalty, + "levels": saved.Levels, "levels_fx": saved.LevelsFx, } @@ -142,7 +152,9 @@ func (req *updateSettingsRequest) hasAny() bool { req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil || req.PointsPostReward != nil || req.PointsPostDailyCap != nil || req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil || - req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil + req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil || + req.PostEditLockHours != nil || req.CommentEditLockHours != nil || + req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil } // AdminGetSettings 超管读取站点设置(与公开 payload 字段一致) @@ -247,6 +259,29 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + lockRules := []struct { + req *int + set func(int) error + hint string + }{ + {req.PostEditLockHours, h.Setting.SetPostEditLockHours, "帖子可编辑时长须为 0–8760 小时"}, + {req.CommentEditLockHours, h.Setting.SetCommentEditLockHours, "评论可编辑时长须为 0–8760 小时"}, + {req.NecroReplyAfterHours, h.Setting.SetNecroReplyAfterHours, "旧帖回复阈值须为 0–8760 小时"}, + {req.NecroReplyPenalty, h.Setting.SetNecroReplyPenalty, "旧帖回复扣分须为 0–100"}, + } + for _, r := range lockRules { + if r.req == nil { + continue + } + if err := r.set(*r.req); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": r.hint}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } pointsRules := []struct { req *int set func(int) error diff --git a/backend/model/db.go b/backend/model/db.go index 8fb4a3f..b3bf353 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -57,7 +57,7 @@ func InitDB(dsn string) error { if err := db.AutoMigrate( &TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, - &User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, + &User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, diff --git a/backend/model/models.go b/backend/model/models.go index 5c27f03..2db9dde 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -220,6 +220,7 @@ type Post struct { TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) Pinned int `gorm:"default:0" json:"pinned"` Recommended bool `gorm:"default:false;index" json:"recommended"` + Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) Status string `gorm:"size:16;default:published;index" json:"status"` LikeCount int `gorm:"default:0" json:"like_count"` ViewCount int `gorm:"default:0" json:"view_count"` @@ -327,6 +328,7 @@ const ( PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成 PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次) PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额) + PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭) ) // PostPollVote 投票记录(多选时同一用户多行) @@ -412,6 +414,16 @@ type CommentEditHistory struct { CreatedAt time.Time `gorm:"index" json:"created_at"` } +// PostEditHistory 帖子修订快照(每次编辑前的旧标题/旧正文;登录用户可见) +type PostEditHistory struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"index;not null" json:"post_id"` + EditorID uint `gorm:"index;not null" json:"editor_id"` + OldTitle string `gorm:"size:256;not null;default:''" json:"old_title"` + OldContent string `gorm:"type:text;not null" json:"old_content"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + // Like 点赞记录(联合唯一索引防止重复点赞) type Like struct { ID uint `gorm:"primaryKey" json:"id"` @@ -440,6 +452,7 @@ const ( NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者) NotificationTypeBadge = "badge" // 获得管理员颁发的徽章 NotificationTypeDeleted = "deleted" // 帖子被管理员删除 + NotificationTypeNecroReply = "necro_reply" // 回复旧帖提醒(系统,ActorID=0) ) // Notification 站内通知 diff --git a/backend/router/router.go b/backend/router/router.go index 31a16a2..2d83b02 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -204,6 +204,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.PUT("/profile", h.UpdateProfile) api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost) api.PUT("/posts/:id", h.UpdatePost) + api.GET("/posts/:id/history", h.PostEditHistory) + // 评论编辑历史(登录即可见;顶层路由:管理后台无 post_id 上下文) + api.GET("/comments/:cid/history", h.CommentEditHistory) api.POST("/posts/:id/unlock", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.UnlockPostContent) api.POST("/posts/:id/poll/vote", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.VotePoll) api.POST("/posts/:id/poll/close", h.ClosePoll) @@ -215,6 +218,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.DELETE("/posts/:id", h.DeletePost) api.PUT("/posts/:id/pin", h.TogglePin) api.PUT("/posts/:id/recommend", h.ToggleRecommend) + api.PUT("/posts/:id/lock", h.TogglePostLock) api.POST("/posts/:id/like", h.ToggleLike) api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment) api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment) @@ -293,7 +297,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.DELETE("/content/comments/:id", h.AdminSoftDeleteComment) staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment) staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment) - staffAPI.GET("/content/comments/:id/history", h.AdminCommentHistory) // 积分与类型帖概览(管理员及以上) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats) diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index a05d147..4ba50b1 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -81,6 +81,7 @@ type AdminContentComment struct { BoardID uint `json:"board_id"` Content string `json:"content"` Status string `json:"status"` + Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口 Deleted bool `json:"deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -223,13 +224,14 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri Content string Status string CreatedAt time.Time + UpdatedAt time.Time DeletedAt gorm.DeletedAt BoardID uint PostTitle string UserID uint } var rows []row - if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id"). + if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.updated_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id"). Order("comments.id DESC"). Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize). Scan(&rows).Error; err != nil { @@ -276,6 +278,7 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri BoardID: r.BoardID, Content: r.Content, Status: r.Status, + Edited: r.UpdatedAt.Sub(r.CreatedAt) > time.Minute, CreatedAt: r.CreatedAt, Board: boards[r.BoardID], User: users[r.UserID], @@ -354,7 +357,8 @@ func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, de "delete_reason": deleteReason, "deleted_by": actor.ID, } - if err := s.db.Model(post).Updates(updates).Error; err != nil { + // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导) + if err := s.db.Model(post).UpdateColumns(updates).Error; err != nil { return 0, err } if err := s.db.Delete(post).Error; err != nil { @@ -375,7 +379,8 @@ func (s *ModerationService) RestorePost(actor *Actor, id uint) error { if !post.DeletedAt.Valid { return errors.New("帖子未被删除") } - return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error + // UpdateColumn:恢复非内容编辑,不刷新 updated_at + return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).UpdateColumn("deleted_at", nil).Error } // PurgePost 彻底删除已软删帖子及其评论(仅站长) @@ -391,6 +396,9 @@ func (s *ModerationService) PurgePost(actor *Actor, id uint) error { return ErrContentNotDeleted } return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.PostEditHistory{}).Error; err != nil { + return err + } if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil { return err } @@ -430,7 +438,8 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType, "delete_reason": deleteReason, "deleted_by": actor.ID, } - if err := s.db.Model(cm).Updates(updates).Error; err != nil { + // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定) + if err := s.db.Model(cm).UpdateColumns(updates).Error; err != nil { return err } if err := s.db.Delete(cm).Error; err != nil { @@ -461,7 +470,8 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error { "delete_reason": "", "deleted_by": 0, } - if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(updates).Error; err != nil { + // UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定) + if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).UpdateColumns(updates).Error; err != nil { return err } if cm.Status == model.ContentStatusPublished { diff --git a/backend/service/announcement.go b/backend/service/announcement.go index b9987e6..9693b0d 100644 --- a/backend/service/announcement.go +++ b/backend/service/announcement.go @@ -3,6 +3,7 @@ package service import ( "errors" "strings" + "unicode/utf8" "github.com/freefire/jiang13-bbs/markdown" "github.com/freefire/jiang13-bbs/model" @@ -49,6 +50,9 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error { if in.Content == "" { return errors.New("内容不能为空") } + if utf8.RuneCountInString(in.Content) > 20000 { + return errors.New("内容不能超过 20000 字") + } if err := markdown.ValidateHideContent(in.Content); err != nil { return err } diff --git a/backend/service/comment.go b/backend/service/comment.go index d47769c..2ca4b4b 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "strings" "time" @@ -18,8 +19,29 @@ var ( ErrCommentNotDeleted = errors.New("评论未被删除") ErrCommentNotChanged = errors.New("评论内容未变更") ErrCommentDeleted = errors.New("已删除的评论不可编辑") + ErrPostReplyLocked = errors.New("帖子已被锁定,无法回复") + ErrCommentPostLocked = errors.New("帖子已被锁定,评论无法编辑") + ErrCommentEditLocked = errors.New("评论已超过可编辑时限,无法编辑") ) +// commentEditLockHours 评论可编辑时长(setting 未注入或读取出错时=0 关闭) +func commentEditLockHours(setting *SettingService) int { + if setting == nil { + return 0 + } + hours, err := setting.CommentEditLockHours() + if err != nil { + return 0 + } + return hours +} + +// IsNecroReply 旧帖判定:lastReplyAt(最后回复时间,无回复回落发帖时间)距 now 超过 afterHours。 +// staff 豁免、规则开关与回落基准由调用方负责。 +func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool { + return afterHours > 0 && now.Sub(lastReplyAt) > time.Duration(afterHours)*time.Hour +} + // CommentService 评论服务 type CommentService struct { db *gorm.DB @@ -303,17 +325,18 @@ func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Acto // 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) { +// actorRole 用于锁帖与旧帖扣分规则的 staff 豁免判断。 +// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)、旧帖扣分值(0=未触发) +func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string, actorRole model.Role) (*model.Comment, *model.Comment, int, error) { if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil { - return nil, nil, err + return nil, nil, 0, err } content = strings.TrimSpace(content) if content == "" { - return nil, nil, errors.New("评论内容不能为空") + return nil, nil, 0, errors.New("评论内容不能为空") } if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil { - return nil, nil, err + return nil, nil, 0, err } if status != model.ContentStatusPending && status != model.ContentStatusPublished { status = model.ContentStatusPending @@ -322,10 +345,14 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u // 检查帖子存在且已发布(待审/被拒帖子不接受评论) var post model.Post if err := s.db.First(&post, postID).Error; err != nil { - return nil, nil, errors.New("帖子不存在") + return nil, nil, 0, errors.New("帖子不存在") } if post.Status != model.ContentStatusPublished { - return nil, nil, errors.New("帖子不存在") + return nil, nil, 0, errors.New("帖子不存在") + } + // 管理员手动锁帖:普通用户禁止回复,staff 豁免 + if post.Locked && !model.IsStaff(actorRole) { + return nil, nil, 0, ErrPostReplyLocked } comment := &model.Comment{ @@ -342,10 +369,10 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u 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("回复的评论不存在") + return nil, nil, 0, errors.New("回复的评论不存在") } if parent.Depth >= maxReplyDepth { - return nil, nil, errors.New("回复层级过深") + return nil, nil, 0, errors.New("回复层级过深") } rootID := parent.ID if parent.RootID != nil { @@ -356,26 +383,57 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u comment.Depth = parent.Depth + 1 } - if err := s.db.Create(comment).Error; err != nil { - return nil, nil, err + // 旧帖回复扣分(staff 豁免):仅已发布评论生效,待审不发不提醒。 + // 旧帖判定:最后一条已发布评论时间距现在超过阈值;无回复(MAX 为 NULL)回落发帖时间。 + necroAfterHours, necroPoints := 0, 0 + if comment.Status == model.ContentStatusPublished && s.setting != nil && !model.IsStaff(actorRole) { + afterHours, afterErr := s.setting.NecroReplyAfterHours() + penalty, penaltyErr := s.setting.NecroReplyPenalty() + var lastAt *time.Time + if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, postID).Scan(&lastAt).Error; err != nil || lastAt == nil { + lastAt = &post.CreatedAt + } + if afterErr == nil && penaltyErr == nil && + IsNecroReply(*lastAt, afterHours, time.Now()) { + necroAfterHours, necroPoints = afterHours, penalty + } } - // 仅已发布评论立即计入评论数;待审评论通过审核时才 +1 - if comment.Status == model.ContentStatusPublished { - s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) + + // 原子事务:评论落库 + 计数 + 旧帖扣分 + 回复奖励(扣分余额不足整体回滚,禁止回复) + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(comment).Error; err != nil { + return err + } + if comment.Status != model.ContentStatusPublished { + return nil + } + // 仅已发布评论立即计入评论数;待审评论通过审核时才 +1 + if err := tx.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil { + return err + } + if necroPoints > 0 { + if _, err := DebitTx(tx, userID, necroPoints, model.PointReasonNecroReply, "post", post.ID, + fmt.Sprintf("回复超过 %d 小时无新回复的旧帖", necroAfterHours)); err != nil { + return err + } + } // 回复奖励(可配置;待审不发且过审不补发,防拒审刷分) if s.setting != nil { if rules, err := s.setting.PointsRules(); err == nil { - s.db.Transaction(func(tx *gorm.DB) error { - RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap, - model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title) - return nil - }) + RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap, + model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title) } } + return nil + }); err != nil { + if necroPoints > 0 && errors.Is(err, ErrInsufficientPoints) { + return nil, nil, 0, fmt.Errorf("%w:该帖已超过 %d 小时没有新回复,回复需扣除 %d 积分", ErrInsufficientPoints, necroAfterHours, necroPoints) + } + return nil, nil, 0, err } // 预加载用户 s.db.Preload("User").Preload("User.Badges.Badge").First(comment, comment.ID) - return comment, parent, nil + return comment, parent, necroPoints, nil } // UserCommentItem 用户评论列表项(含帖子标题便于跳转) @@ -484,7 +542,8 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts Delet "delete_reason": deleteReason, "deleted_by": userID, } - if err := s.db.Model(&comment).Updates(updates).Error; err != nil { + // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定) + if err := s.db.Model(&comment).UpdateColumns(updates).Error; err != nil { return err } if err := s.db.Delete(&comment).Error; err != nil { @@ -537,7 +596,8 @@ func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error { "delete_reason": "", "deleted_by": 0, } - if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil { + // UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定) + if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumns(updates).Error; err != nil { return err } if comment.Status == model.ContentStatusPublished { @@ -637,14 +697,23 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st if comment.DeletedAt.Valid { return nil, ErrCommentDeleted } + var post model.Post + if err := s.db.Select("id", "board_id", "created_at", "locked").First(&post, comment.PostID).Error; err != nil { + return nil, ErrCommentNotFound + } 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 - } + // 版主编辑他人评论不受锁帖/时限约束(本就为 staff) if actor == nil || !actor.CanModerateBoard(post.BoardID) { return nil, ErrCommentForbidden } + } else if !model.IsStaff(actor.Role) { + // 作者本人编辑:锁帖禁止、超过可编辑时限禁止,staff 豁免 + if post.Locked { + return nil, ErrCommentPostLocked + } + if editLocked(comment.CreatedAt, false, commentEditLockHours(s.setting), time.Now()) { + return nil, ErrCommentEditLocked + } } if comment.Content == content { return nil, ErrCommentNotChanged @@ -672,7 +741,7 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st return &node, nil } -// CommentEditHistoryItem 管理可见的评论修订记录 +// CommentEditHistoryItem 登录可见的评论修订记录 type CommentEditHistoryItem struct { ID uint `json:"id"` Editor CommentActorBrief `json:"editor"` @@ -680,7 +749,7 @@ type CommentEditHistoryItem struct { CreatedAt time.Time `json:"created_at"` } -// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页) +// ListEditHistory 评论编辑历史(登录即可见;含软删评论;按创建时间倒序分页) func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) { if page < 1 { page = 1 @@ -689,14 +758,10 @@ func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, siz size = 10 } var comment model.Comment - if err := s.db.Unscoped().Select("id", "post_id").First(&comment, commentID).Error; err != nil { + if err := s.db.Unscoped().Select("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) { + if actor == nil { return nil, 0, ErrCommentForbidden } diff --git a/backend/service/content_lock_test.go b/backend/service/content_lock_test.go new file mode 100644 index 0000000..9c5a7c2 --- /dev/null +++ b/backend/service/content_lock_test.go @@ -0,0 +1,74 @@ +package service + +import ( + "testing" + "time" + + "github.com/freefire/jiang13-bbs/model" +) + +// TestEditLocked 内容自动编辑锁定规则(帖子与评论共用): +// staff 豁免、hours<=0 关闭、以创建时间为基准计时 +func TestEditLocked(t *testing.T) { + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.Local) + createdAt := now.Add(-48 * time.Hour) // 两天前创建 + + cases := []struct { + name string + createdAt time.Time + isStaff bool + hours int + now time.Time + want bool + }{ + {"未启用", createdAt, false, 0, now, false}, + {"负值视为关闭", createdAt, false, -1, now, false}, + {"超过时限锁定", createdAt, false, 24, now, true}, + {"恰好到期未锁(严格大于)", createdAt, false, 48, now, false}, + {"未到期不锁", createdAt, false, 72, now, false}, + {"staff 超时也豁免", createdAt, true, 1, now, false}, + {"staff 管理员角色豁免", createdAt, model.IsStaff(model.RoleAdmin), 1, now, false}, + {"普通用户非豁免", createdAt, model.IsStaff(model.RoleUser), 1, now, true}, + {"板块管理员豁免", createdAt, model.IsStaff(model.RoleBoardAdmin), 1, now, false}, + {"刚创建且时限极短不锁", now, false, 1, now, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := editLocked(c.createdAt, c.isStaff, c.hours, c.now); got != c.want { + t.Fatalf("editLocked() = %v, want %v", got, c.want) + } + }) + } +} + +// TestIsNecroReply 旧帖判定:最后回复时间(无回复回落发帖时间)距 now 超过阈值。 +// staff 豁免、规则开关、回落基准由调用方负责,不在本 helper 内。 +func TestIsNecroReply(t *testing.T) { + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.Local) + lastReplyAt := now.Add(-48 * time.Hour) // 最后回复在两天前 + + cases := []struct { + name string + lastReplyAt time.Time + afterHours int + now time.Time + want bool + }{ + {"阈值关闭", lastReplyAt, 0, now, false}, + {"阈值负值视为关闭", lastReplyAt, -1, now, false}, + {"超过阈值触发", lastReplyAt, 24, now, true}, + {"恰好到期不触发(严格大于)", lastReplyAt, 48, now, false}, + {"未到期不触发", lastReplyAt, 72, now, false}, + {"无回复回落发帖时间同理(基准即发帖时间)", now.Add(-72 * time.Hour), 48, now, true}, + {"刚回复不久不触发", now.Add(-time.Hour), 2, now, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IsNecroReply(c.lastReplyAt, c.afterHours, c.now); got != c.want { + t.Fatalf("IsNecroReply() = %v, want %v", got, c.want) + } + }) + } +} diff --git a/backend/service/moderation.go b/backend/service/moderation.go index 75368cb..468c6a2 100644 --- a/backend/service/moderation.go +++ b/backend/service/moderation.go @@ -180,7 +180,8 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok { updates["type_meta"] = raw } - if err := tx.Model(&post).Updates(updates).Error; err != nil { + // UpdateColumns:审核非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导) + if err := tx.Model(&post).UpdateColumns(updates).Error; err != nil { return err } boardID = post.BoardID @@ -204,7 +205,8 @@ func (s *ModerationService) RejectPost(actor *Actor, id uint) error { if post.Status != model.ContentStatusPending { return ErrNotPending } - if err := tx.Model(&post).Update("status", model.ContentStatusRejected).Error; err != nil { + // UpdateColumn:审核非内容编辑,不刷新 updated_at + if err := tx.Model(&post).UpdateColumn("status", model.ContentStatusRejected).Error; err != nil { return err } s.notif.Create(post.UserID, actor.ID, model.NotificationTypeRejected, post.ID, 0, @@ -230,7 +232,8 @@ func (s *ModerationService) ApproveComment(actor *Actor, id uint) (postID, board if cm.Status != model.ContentStatusPending { return ErrNotPending } - if err := tx.Model(&cm).Update("status", model.ContentStatusPublished).Error; err != nil { + // UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定) + if err := tx.Model(&cm).UpdateColumn("status", model.ContentStatusPublished).Error; err != nil { return err } if err := tx.Model(&model.Post{}).Where("id = ?", cm.PostID). @@ -273,7 +276,8 @@ func (s *ModerationService) RejectComment(actor *Actor, id uint) error { if cm.Status != model.ContentStatusPending { return ErrNotPending } - if err := tx.Model(&cm).Update("status", model.ContentStatusRejected).Error; err != nil { + // UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定) + if err := tx.Model(&cm).UpdateColumn("status", model.ContentStatusRejected).Error; err != nil { return err } s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeRejected, cm.PostID, cm.ID, diff --git a/backend/service/notification.go b/backend/service/notification.go index 8660c60..5c1dbdf 100644 --- a/backend/service/notification.go +++ b/backend/service/notification.go @@ -100,6 +100,31 @@ func (s *NotificationService) Create(userID, actorID uint, notifType string, pos } } +// CreateSystem 系统通知(ActorID=0,提醒对象即本人,绕过自我通知检查;失败静默) +func (s *NotificationService) CreateSystem(userID uint, notifType string, postID, commentID uint, content string) { + if userID == 0 { + return + } + if len(content) > 200 { + content = content[:200] + } + n := &model.Notification{ + UserID: userID, + ActorID: 0, + Type: notifType, + PostID: postID, + CommentID: commentID, + Content: content, + IsRead: false, + } + if err := s.db.Create(n).Error; err != nil { + return + } + if s.OnNotifyNew != nil { + s.OnNotifyNew(userID) + } +} + // CreateMention 创建群聊 @ 提醒(PostID=0,用 RoomID/MessageID 关联)。 // 返回创建出的通知 ID(0 表示未创建),供 handler 实时推送。 func (s *NotificationService) CreateMention(userID, actorID, roomID, messageID uint, content string) uint { diff --git a/backend/service/post.go b/backend/service/post.go index 55e8f9b..a786b48 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -17,8 +17,27 @@ var ( ErrPostForbidden = errors.New("无权限操作此帖子") ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由") ErrPostInvalidType = errors.New("无效的删除类型") + ErrPostLocked = errors.New("帖子已被锁定,无法编辑") + ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑") ) +// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用 +func editLocked(createdAt time.Time, isStaff bool, hours int, now time.Time) bool { + return !isStaff && hours > 0 && now.Sub(createdAt) > time.Duration(hours)*time.Hour +} + +// postEditLockHours 帖子可编辑时长(setting 未注入或读取出错时=0 关闭) +func postEditLockHours(setting *SettingService) int { + if setting == nil { + return 0 + } + hours, err := setting.PostEditLockHours() + if err != nil { + return 0 + } + return hours +} + // PostService 帖子服务 type PostService struct { db *gorm.DB @@ -323,13 +342,30 @@ func (s *PostService) TogglePin(id uint) (int, error) { } else { newPinned = 1 } - result := s.db.Model(&post).Update("pinned", newPinned) + // UpdateColumn:置顶非内容编辑,不刷新 updated_at + result := s.db.Model(&post).UpdateColumn("pinned", newPinned) if result.Error != nil { return 0, result.Error } return newPinned, nil } +// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。 +// 锁定后普通用户不可编辑帖子、不可回复;staff 豁免。 +func (s *PostService) ToggleLock(id uint) (bool, error) { + var post model.Post + if err := s.db.First(&post, id).Error; err != nil { + return false, err + } + newVal := !post.Locked + // UpdateColumn:锁定非内容编辑,不刷新 updated_at + result := s.db.Model(&post).UpdateColumn("locked", newVal) + if result.Error != nil { + return false, result.Error + } + return newVal, nil +} + // ToggleRecommend 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发) func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) { var post model.Post @@ -337,7 +373,8 @@ func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) { return false, err } newVal := !post.Recommended - result := s.db.Model(&post).Update("recommended", newVal) + // UpdateColumn:加精非内容编辑,不刷新 updated_at + result := s.db.Model(&post).UpdateColumn("recommended", newVal) if result.Error != nil { return false, result.Error } @@ -414,17 +451,23 @@ type PostDetail struct { TypeStatus string `json:"type_status,omitempty"` Pinned int `json:"pinned"` Recommended bool `json:"recommended"` + Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) Status string `json:"status"` LikeCount int `json:"like_count"` ViewCount int `json:"view_count"` CommentCount int `json:"comment_count"` Liked bool `json:"liked"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Board model.Board `json:"board"` - User model.User `json:"user"` - ContentLocked bool `json:"content_locked"` - AccessHint string `json:"access_hint,omitempty"` + Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示) + // 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) + LastReplyAt *time.Time `json:"last_reply_at,omitempty"` + // 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框 + NecroReply *NecroReplyHint `json:"necro_reply,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Board model.Board `json:"board"` + User model.User `json:"user"` + ContentLocked bool `json:"content_locked"` + AccessHint string `json:"access_hint,omitempty"` Attachments []PostAttachmentDTO `json:"attachments"` Question *QuestionState `json:"question,omitempty"` Poll *PollState `json:"poll,omitempty"` @@ -439,6 +482,12 @@ type PostDetail struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// NecroReplyHint 旧帖回复确认提示(详情接口按查看者实时计算;命中才返回) +type NecroReplyHint struct { + AfterHours int `json:"after_hours"` + Penalty int `json:"penalty"` +} + // CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生) type CreatePostInput struct { UserID uint @@ -498,6 +547,12 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto post.ViewCount++ detail := buildPostDetail(&post) + detail.Edited = s.hasEditHistory(post.ID) + // 最后一条已发布评论时间(旧帖判定基准;无回复为 nil,调用方回落 created_at) + var lastReplyAt *time.Time + if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, post.ID).Scan(&lastReplyAt).Error; err == nil { + detail.LastReplyAt = lastReplyAt + } sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked) detail.Content = sanitized detail.ContentLocked = fullyLocked @@ -545,7 +600,7 @@ func buildPostDetail(post *model.Post) *PostDetail { ContentAccess: model.NormalizeContentAccess(post.ContentAccess), AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta, TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta), - Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status, + Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status, LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount, Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt, Board: post.Board, User: post.User, @@ -919,6 +974,15 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) { return nil, ErrPostForbidden } + editorIsStaff := model.IsStaff(actor.Role) + // 手动锁定:仅管理团队可编辑 + if post.Locked && !editorIsStaff { + return nil, ErrPostLocked + } + // 自动编辑锁定:非管理团队的作者超时不可编辑;管理成员(版主代编等)豁免 + if post.UserID == userID && editLocked(post.CreatedAt, editorIsStaff, postEditLockHours(s.setting), time.Now()) { + return nil, ErrPostEditLocked + } updates := map[string]interface{}{} if in.Title != "" { @@ -1042,6 +1106,26 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp } err := s.db.Transaction(func(tx *gorm.DB) error { + // 仅标题/正文实际变化时留修订快照(tags/type_meta/附件变更不留痕) + newTitle, _ := updates["title"].(string) + if newTitle == "" { + newTitle = post.Title + } + newContent, _ := updates["content"].(string) + if newContent == "" { + newContent = post.Content + } + if newTitle != post.Title || newContent != post.Content { + hist := model.PostEditHistory{ + PostID: post.ID, + EditorID: userID, // 执行编辑者(可能是版主代编) + OldTitle: post.Title, + OldContent: post.Content, + } + if err := tx.Create(&hist).Error; err != nil { + return err + } + } if err := tx.Model(&post).Updates(updates).Error; err != nil { return err } @@ -1085,6 +1169,87 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp return detail, nil } +// PostEditHistoryItem 登录可见的帖子修订记录 +type PostEditHistoryItem struct { + ID uint `json:"id"` + Editor CommentActorBrief `json:"editor"` + OldTitle string `json:"old_title"` + OldContent string `json:"old_content"` + CreatedAt time.Time `json:"created_at"` +} + +// hasEditHistory 帖子是否存在修订快照;updated_at 会被置顶/计数等写操作推动,不可作为编辑依据 +func (s *PostService) hasEditHistory(postID uint) bool { + var n int64 + s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID).Limit(1).Count(&n) + return n > 0 +} + +// ListEditHistory 帖子编辑历史(登录即可见;含软删帖子;按创建时间倒序分页) +func (s *PostService) ListEditHistory(actor *Actor, postID uint, page, size int) ([]PostEditHistoryItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + var post model.Post + if err := s.db.Unscoped().Select("id").First(&post, postID).Error; err != nil { + return nil, 0, ErrPostNotFound + } + if actor == nil { + return nil, 0, ErrPostForbidden + } + + q := s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []model.PostEditHistory + if err := s.db.Where("post_id = ?", postID). + 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([]PostEditHistoryItem, 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, PostEditHistoryItem{ + ID: r.ID, + Editor: ed, + OldTitle: r.OldTitle, + OldContent: r.OldContent, + CreatedAt: r.CreatedAt, + }) + } + return items, total, nil +} + // EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量) func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error { var post model.Post @@ -1184,7 +1349,8 @@ func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostO "delete_reason": deleteReason, "deleted_by": userID, } - if err := s.db.Model(&post).Updates(updates).Error; err != nil { + // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导) + if err := s.db.Model(&post).UpdateColumns(updates).Error; err != nil { return false, authorID, err } if err := s.db.Delete(&post).Error; err != nil { diff --git a/backend/service/setting.go b/backend/service/setting.go index 6bc2f88..0289a2e 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -59,6 +59,12 @@ const ( // SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90 SettingKeyAnalyticsRetentionDays = "analytics_retention_days" + // 内容锁定与旧帖回复(全部缺行=0 关闭) + SettingKeyPostEditLockHours = "post_edit_lock_hours" // 帖子可编辑时长;0=不锁定 + SettingKeyCommentEditLockHours = "comment_edit_lock_hours" // 评论可编辑时长;0=不锁定 + SettingKeyNecroReplyAfterHours = "necro_reply_after_hours" // 超过该时长回复视为旧帖回复;0=关闭 + SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分 + // 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值) SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5 SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7 @@ -100,6 +106,9 @@ const ( MinAnalyticsRetentionDays = 7 MaxAnalyticsRetentionDays = 365 + // MaxContentLockHours 内容锁定/旧帖阈值小时数上限(一年) + MaxContentLockHours = 8760 + DefaultPointsCheckinBase = 5 DefaultPointsStreakEveryDays = 7 MaxPointsRule = 100 // 各积分规则值/上限的统一上界 @@ -150,6 +159,12 @@ type PublicSiteSettings struct { AttachmentMaxMB int `json:"attachment_max_mb"` AttachmentMaxCount int `json:"attachment_max_count"` ImageMaxMB int `json:"image_max_mb"` + + // 内容锁定与旧帖回复(0=关闭;公开保持规则透明) + PostEditLockHours int `json:"post_edit_lock_hours"` + CommentEditLockHours int `json:"comment_edit_lock_hours"` + NecroReplyAfterHours int `json:"necro_reply_after_hours"` + NecroReplyPenalty int `json:"necro_reply_penalty"` BgSiteURL string `json:"bg_site_url"` BgSiteMode string `json:"bg_site_mode"` BgAdminURL string `json:"bg_admin_url"` @@ -438,6 +453,27 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { } out.ImageMaxMB = imgMB + postLock, err := s.PostEditLockHours() + if err != nil { + return out, err + } + out.PostEditLockHours = postLock + commentLock, err := s.CommentEditLockHours() + if err != nil { + return out, err + } + out.CommentEditLockHours = commentLock + necroAfter, err := s.NecroReplyAfterHours() + if err != nil { + return out, err + } + out.NecroReplyAfterHours = necroAfter + necroPenalty, err := s.NecroReplyPenalty() + if err != nil { + return out, err + } + out.NecroReplyPenalty = necroPenalty + siteURL, err := s.BgSiteURL() if err != nil { return out, err @@ -941,6 +977,91 @@ func (s *SettingService) ImageMaxBytes() (int64, error) { return int64(mb) << 20, nil } +// getLockHours 读取小时数配置(缺行/非法=0 关闭,超上限钳制) +func (s *SettingService) getLockHours(key string) (int, error) { + v, found, err := s.getValue(key) + if err != nil { + return 0, err + } + if !found { + return 0, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < 0 { + return 0, nil + } + if n > MaxContentLockHours { + return MaxContentLockHours, nil + } + return n, nil +} + +func (s *SettingService) setLockHours(key string, hours int) error { + if hours < 0 || hours > MaxContentLockHours { + return ErrInvalidSiteSetting + } + if hours == 0 { + return s.deleteKey(key) + } + return s.putValue(key, strconv.Itoa(hours)) +} + +// PostEditLockHours 帖子可编辑时长(小时)。缺行=0(不锁定)。 +func (s *SettingService) PostEditLockHours() (int, error) { + return s.getLockHours(SettingKeyPostEditLockHours) +} + +func (s *SettingService) SetPostEditLockHours(hours int) error { + return s.setLockHours(SettingKeyPostEditLockHours, hours) +} + +// CommentEditLockHours 评论可编辑时长(小时)。缺行=0(不锁定)。 +func (s *SettingService) CommentEditLockHours() (int, error) { + return s.getLockHours(SettingKeyCommentEditLockHours) +} + +func (s *SettingService) SetCommentEditLockHours(hours int) error { + return s.setLockHours(SettingKeyCommentEditLockHours, hours) +} + +// NecroReplyAfterHours 旧帖回复阈值(小时)。缺行=0(关闭)。 +func (s *SettingService) NecroReplyAfterHours() (int, error) { + return s.getLockHours(SettingKeyNecroReplyAfterHours) +} + +func (s *SettingService) SetNecroReplyAfterHours(hours int) error { + return s.setLockHours(SettingKeyNecroReplyAfterHours, hours) +} + +// NecroReplyPenalty 旧帖回复扣分。缺行=0(仅提醒不扣分)。 +func (s *SettingService) NecroReplyPenalty() (int, error) { + v, found, err := s.getValue(SettingKeyNecroReplyPenalty) + if err != nil { + return 0, err + } + if !found { + return 0, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < 0 { + return 0, nil + } + if n > MaxPointsRule { + return MaxPointsRule, nil + } + return n, nil +} + +func (s *SettingService) SetNecroReplyPenalty(n int) error { + if n < 0 || n > MaxPointsRule { + return ErrInvalidSiteSetting + } + if n == 0 { + return s.deleteKey(SettingKeyNecroReplyPenalty) + } + return s.putValue(SettingKeyNecroReplyPenalty, strconv.Itoa(n)) +} + // AnalyticsEnabled 访问统计采集开关。缺行视为开启。 func (s *SettingService) AnalyticsEnabled() (bool, error) { v, found, err := s.getValue(SettingKeyAnalyticsEnabled) diff --git a/backend/service/site_page.go b/backend/service/site_page.go index acf6362..6131830 100644 --- a/backend/service/site_page.go +++ b/backend/service/site_page.go @@ -82,6 +82,9 @@ func (in *SitePageInput) normalize(existing *model.SitePage) error { if in.Content == "" { return errors.New("内容不能为空") } + if utf8.RuneCountInString(in.Content) > 20000 { + return errors.New("内容不能超过 20000 字") + } if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil { return err } diff --git a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx index 62cbffa..02fd8c6 100644 --- a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx +++ b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx @@ -21,6 +21,9 @@ import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColor type PublishKey = "published" | "draft"; type StatusFilter = "all" | "published" | "draft"; +/** 公告正文字符上限(与后端 service/announcement.go 校验一致) */ +const CONTENT_MAX = 20000; + type FormState = { title: string; content: string; @@ -328,8 +331,9 @@ export default function AnnouncementComposeClient({ value={form.content} onChange={(content) => { setErrors((prev) => ({ ...prev, content: undefined })); - patchForm({ content }); + patchForm({ content: content.slice(0, CONTENT_MAX) }); }} + maxChars={CONTENT_MAX} allowHide allowReplyHide={false} placeholder="请输入公告正文,支持 Markdown…" diff --git a/frontend/app/admin/badges/BadgesClient.tsx b/frontend/app/admin/badges/BadgesClient.tsx index 8ee27e9..172395b 100644 --- a/frontend/app/admin/badges/BadgesClient.tsx +++ b/frontend/app/admin/badges/BadgesClient.tsx @@ -200,7 +200,7 @@ export default function BadgesClient() { 管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
-