diff --git a/cmd/server/main.go b/cmd/server/main.go index 03bda18..a03207a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,11 +2,14 @@ package main import ( "log" + "time" "metazone.cc/mce/internal/common" "metazone.cc/mce/internal/config" "metazone.cc/mce/internal/model" + "metazone.cc/mce/internal/repository" "metazone.cc/mce/internal/router" + "metazone.cc/mce/internal/scheduler" "metazone.cc/mce/internal/theme" "github.com/gin-gonic/gin" @@ -27,7 +30,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}, &model.Announcement{}, &model.Category{}, &model.Tag{}, &model.PostTag{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } @@ -98,6 +101,9 @@ func main() { // 路由注册 router.Setup(r, db, cfg, siteSettings, redisClient) + // 定时发布调度器 + scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute) + // 启动 log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) r.Run("0.0.0.0:" + cfg.Server.Port) diff --git a/docs/migrations/005_scheduled_posts.sql b/docs/migrations/005_scheduled_posts.sql new file mode 100644 index 0000000..0de8027 --- /dev/null +++ b/docs/migrations/005_scheduled_posts.sql @@ -0,0 +1,3 @@ +-- 定时发布 +ALTER TABLE posts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMP; +CREATE INDEX IF NOT EXISTS idx_posts_scheduled_at ON posts(scheduled_at); diff --git a/internal/common/errors.go b/internal/common/errors.go index e771558..8ec9c53 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -40,6 +40,7 @@ var ( // 帖子相关 ErrPostNotFound = errors.New("帖子不存在") ErrPostCannotEdit = errors.New("当前状态不允许编辑") + ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟") ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") ErrPostCannotApprove = errors.New("仅待审核帖子可通过") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index e4f57cf..d1c7c91 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -42,9 +42,9 @@ type postUseCase interface { // studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法) type studioUseCase interface { - Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error) + Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) (*model.Post, error) GetByID(id uint) (*model.Post, error) - Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error + Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) error Delete(postID uint) error SubmitForAudit(postID uint) error ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) diff --git a/internal/controller/studio_post_api_controller.go b/internal/controller/studio_post_api_controller.go index 257648a..89904f5 100644 --- a/internal/controller/studio_post_api_controller.go +++ b/internal/controller/studio_post_api_controller.go @@ -30,8 +30,12 @@ func (ctrl *StudioController) Create(c *gin.Context) { return } - post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID) + post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt) if err != nil { + if err == common.ErrScheduledTooSoon { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } common.Error(c, http.StatusInternalServerError, "发布失败") return } @@ -74,7 +78,7 @@ func (ctrl *StudioController) Update(c *gin.Context) { return } - if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID); err != nil { + if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt); err != nil { common.Error(c, http.StatusInternalServerError, "编辑失败") return } diff --git a/internal/model/dto.go b/internal/model/dto.go index 28172f2..8f629af 100644 --- a/internal/model/dto.go +++ b/internal/model/dto.go @@ -41,6 +41,7 @@ type PostCreateRequest struct { ReprintProhibited bool `json:"reprint_prohibited"` CategoryID string `json:"category_id"` Tags string `json:"tags"` + ScheduledAt string `json:"scheduled_at"` } // PostUpdateRequest 编辑请求 @@ -54,6 +55,7 @@ type PostUpdateRequest struct { ReprintProhibited bool `json:"reprint_prohibited"` CategoryID string `json:"category_id"` Tags string `json:"tags"` + ScheduledAt string `json:"scheduled_at"` } // PostRejectRequest 退回请求 diff --git a/internal/model/post.go b/internal/model/post.go index 1e13de4..1c4a7d7 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -26,9 +26,10 @@ type Post struct { PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源 Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明 - ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载 - PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global - PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间 + ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载 + PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global + PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间 + ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间 AllowComment bool `gorm:"default:true" json:"allow_comment"` CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index e9c170a..d1f90a9 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -62,27 +62,37 @@ func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB { return query } -// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题,过滤私密) +// FindPageable 分页查询帖子列表(仅 approved + 公开 + 已到期,定时发布未到期的不可见) func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery(keyword, model.PostStatusApproved). - Where("posts.visibility = ?", model.VisibilityPublic) + Where("posts.visibility = ?", model.VisibilityPublic). + Where("posts.scheduled_at IS NULL OR posts.scheduled_at <= NOW()") return r.pageResults(query, offset, limit) } -// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密) +// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密和定时待发) func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery(keyword, status) return r.pageResultsAdmin(query, offset, limit) } -// FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开,含作者用户名) +// FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开 + 已到期,含作者用户名) func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery("", model.PostStatusApproved). Where("posts.user_id = ?", userID). - Where("posts.visibility = ?", model.VisibilityPublic) + Where("posts.visibility = ?", model.VisibilityPublic). + Where("posts.scheduled_at IS NULL OR posts.scheduled_at <= NOW()") return r.pageResults(query, offset, limit) } +// FindScheduledDue 查询到期未发布的定时文章 +func (r *PostRepo) FindScheduledDue() ([]model.Post, error) { + var posts []model.Post + err := r.db.Where("status = ? AND scheduled_at IS NOT NULL AND scheduled_at <= NOW()", model.PostStatusApproved). + Find(&posts).Error + return posts, err +} + // FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态) func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery("", status). diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..4af6577 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,45 @@ +package scheduler + +import ( + "log" + "time" + + "metazone.cc/mce/internal/model" +) + +// PostStore Scheduler 所需的最小帖子仓储接口(ISP) +type PostStore interface { + FindScheduledDue() ([]model.Post, error) + Update(post *model.Post) error +} + +// StartPublishScheduler 启动定时发布调度器(goroutine,每分钟扫描到期文章) +func StartPublishScheduler(repo PostStore, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + log.Printf("[Scheduler] 定时发布调度器已启动,间隔=%v", interval) + for range ticker.C { + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[Scheduler] panic recovered: %v", r) + } + }() + posts, err := repo.FindScheduledDue() + if err != nil { + log.Printf("[Scheduler] 扫描失败: %v", err) + return + } + for _, p := range posts { + p.ScheduledAt = nil + if err := repo.Update(&p); err != nil { + log.Printf("[Scheduler] 发布 postID=%d 失败: %v", p.ID, err) + continue + } + log.Printf("[Scheduler] 定时发布 postID=%d title=%q", p.ID, p.Title) + } + }() + } + }() +} diff --git a/internal/service/post_audit_service.go b/internal/service/post_audit_service.go index 4ca9b8f..278bf51 100644 --- a/internal/service/post_audit_service.go +++ b/internal/service/post_audit_service.go @@ -2,13 +2,14 @@ package service import ( "strconv" + "time" "metazone.cc/mce/internal/common" "metazone.cc/mce/internal/model" ) // Update 编辑帖子(权限在 controller 层检查) -func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error { +func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) error { post, err := s.findPost(postID) if err != nil { return err @@ -31,6 +32,13 @@ func (s *PostService) Update(postID uint, title, body string, visibility, postTy } else { post.CategoryID = nil } + // 定时发布 + if t, err := parseScheduledAt(scheduledAt); err == nil { + if time.Until(t) < 30*time.Minute { + return common.ErrScheduledTooSoon + } + post.ScheduledAt = &t + } // 已通过帖子:编辑内容保存为待审修订,不影响当前展示 if post.Status == model.PostStatusApproved { @@ -121,6 +129,10 @@ func (s *PostService) Approve(postID uint) error { } post.Status = model.PostStatusApproved post.RejectReason = "" + // 审核晚于定时时间 → 即时发布 + if post.ScheduledAt != nil && !post.ScheduledAt.After(time.Now()) { + post.ScheduledAt = nil + } if err := s.repo.Update(post); err != nil { return err } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 24af317..a653681 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -69,7 +69,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) { // Create 创建帖子 // body 为 Vditor 输出的 Markdown,后端直接存储不做 HTML 消毒 // MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML -func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error) { +func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) (*model.Post, error) { status := model.PostStatusApproved if s.auditEnabled() { status = model.PostStatusDraft @@ -93,6 +93,13 @@ func (s *PostService) Create(userID uint, title, body string, visibility, postTy id := uint(cid) post.CategoryID = &id } + // 定时发布 + if t, err := parseScheduledAt(scheduledAt); err == nil { + if time.Until(t) < 30*time.Minute { + return nil, common.ErrScheduledTooSoon + } + post.ScheduledAt = &t + } if err := s.repo.Create(post); err != nil { return nil, err } @@ -224,3 +231,15 @@ func (s *PostService) Unpin(postID uint) error { post.PinnedAt = nil return s.repo.Update(post) } + +// parseScheduledAt 解析 RFC3339 时间,非法格式返回零值+error +func parseScheduledAt(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, err + } + return t, nil +} diff --git a/internal/service/repository.go b/internal/service/repository.go index 9f72d16..03f1fca 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -45,6 +45,7 @@ type postStore interface { RecordRead(userID, postID uint) (bool, error) RecordGuestRead(visitorID string, postID uint) (bool, error) IncrementViewsCount(postID uint) error + FindScheduledDue() ([]model.Post, error) } // spaceUserStore SpaceService 所需的最小用户仓储接口 diff --git a/templates/MetaLab-2026/html/studio/write.html b/templates/MetaLab-2026/html/studio/write.html index 96f6d38..c190d5d 100644 --- a/templates/MetaLab-2026/html/studio/write.html +++ b/templates/MetaLab-2026/html/studio/write.html @@ -77,6 +77,10 @@ +