feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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 站内通知
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
74
backend/service/content_lock_test.go
Normal file
74
backend/service/content_lock_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user