feat: 定时发布系统 — Phase 5 完成

- Post ScheduledAt 字段
- Scheduler goroutine 每分钟扫描到期文章
- Create/Update 校验最短 30min 定时
- Approve 审核晚于定时则即时发布
- public 列表+空间页过滤未到期文章
- 写文章页 datetime-local 选择器(min=now+30min)
- AutoMigrate 追加新表
This commit is contained in:
2026-06-22 00:39:04 +08:00
parent 0b8f6f890d
commit e9b7401dc9
14 changed files with 132 additions and 15 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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 所需的最小用户仓储接口