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,11 +2,14 @@ package main
import ( import (
"log" "log"
"time"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config" "metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/repository"
"metazone.cc/mce/internal/router" "metazone.cc/mce/internal/router"
"metazone.cc/mce/internal/scheduler"
"metazone.cc/mce/internal/theme" "metazone.cc/mce/internal/theme"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -27,7 +30,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("连接数据库失败: %v", err) 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) log.Fatalf("数据库迁移失败: %v", err)
} }
@ -98,6 +101,9 @@ func main() {
// 路由注册 // 路由注册
router.Setup(r, db, cfg, siteSettings, redisClient) 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) log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
r.Run("0.0.0.0:" + cfg.Server.Port) r.Run("0.0.0.0:" + cfg.Server.Port)

View File

@ -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);

View File

@ -40,6 +40,7 @@ var (
// 帖子相关 // 帖子相关
ErrPostNotFound = errors.New("帖子不存在") ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑") ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过") ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")

View File

@ -42,9 +42,9 @@ type postUseCase interface {
// studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法) // studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法)
type studioUseCase interface { 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) 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 Delete(postID uint) error
SubmitForAudit(postID uint) error SubmitForAudit(postID uint) error
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)

View File

@ -30,8 +30,12 @@ func (ctrl *StudioController) Create(c *gin.Context) {
return 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 != nil {
if err == common.ErrScheduledTooSoon {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Error(c, http.StatusInternalServerError, "发布失败") common.Error(c, http.StatusInternalServerError, "发布失败")
return return
} }
@ -74,7 +78,7 @@ func (ctrl *StudioController) Update(c *gin.Context) {
return 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, "编辑失败") common.Error(c, http.StatusInternalServerError, "编辑失败")
return return
} }

View File

@ -41,6 +41,7 @@ type PostCreateRequest struct {
ReprintProhibited bool `json:"reprint_prohibited"` ReprintProhibited bool `json:"reprint_prohibited"`
CategoryID string `json:"category_id"` CategoryID string `json:"category_id"`
Tags string `json:"tags"` Tags string `json:"tags"`
ScheduledAt string `json:"scheduled_at"`
} }
// PostUpdateRequest 编辑请求 // PostUpdateRequest 编辑请求
@ -54,6 +55,7 @@ type PostUpdateRequest struct {
ReprintProhibited bool `json:"reprint_prohibited"` ReprintProhibited bool `json:"reprint_prohibited"`
CategoryID string `json:"category_id"` CategoryID string `json:"category_id"`
Tags string `json:"tags"` Tags string `json:"tags"`
ScheduledAt string `json:"scheduled_at"`
} }
// PostRejectRequest 退回请求 // PostRejectRequest 退回请求

View File

@ -29,6 +29,7 @@ type Post struct {
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载 ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间 PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
AllowComment bool `gorm:"default:true" json:"allow_comment"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)

View File

@ -62,27 +62,37 @@ func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
return query return query
} }
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题,过滤私密 // FindPageable 分页查询帖子列表(仅 approved + 公开 + 已到期,定时发布未到期的不可见
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery(keyword, model.PostStatusApproved). 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) return r.pageResults(query, offset, limit)
} }
// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密) // FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密和定时待发
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery(keyword, status) query := r.buildQuery(keyword, status)
return r.pageResultsAdmin(query, offset, limit) return r.pageResultsAdmin(query, offset, limit)
} }
// FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开,含作者用户名) // FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开 + 已到期,含作者用户名)
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery("", model.PostStatusApproved). query := r.buildQuery("", model.PostStatusApproved).
Where("posts.user_id = ?", userID). 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) 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=全状态) // FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态)
func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery("", status). query := r.buildQuery("", status).

View File

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

View File

@ -2,13 +2,14 @@ package service
import ( import (
"strconv" "strconv"
"time"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
) )
// Update 编辑帖子(权限在 controller 层检查) // 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) post, err := s.findPost(postID)
if err != nil { if err != nil {
return err return err
@ -31,6 +32,13 @@ func (s *PostService) Update(postID uint, title, body string, visibility, postTy
} else { } else {
post.CategoryID = nil 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 { if post.Status == model.PostStatusApproved {
@ -121,6 +129,10 @@ func (s *PostService) Approve(postID uint) error {
} }
post.Status = model.PostStatusApproved post.Status = model.PostStatusApproved
post.RejectReason = "" post.RejectReason = ""
// 审核晚于定时时间 → 即时发布
if post.ScheduledAt != nil && !post.ScheduledAt.After(time.Now()) {
post.ScheduledAt = nil
}
if err := s.repo.Update(post); err != nil { if err := s.repo.Update(post); err != nil {
return err return err
} }

View File

@ -69,7 +69,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
// Create 创建帖子 // Create 创建帖子
// body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒 // body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 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 status := model.PostStatusApproved
if s.auditEnabled() { if s.auditEnabled() {
status = model.PostStatusDraft status = model.PostStatusDraft
@ -93,6 +93,13 @@ func (s *PostService) Create(userID uint, title, body string, visibility, postTy
id := uint(cid) id := uint(cid)
post.CategoryID = &id 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 { if err := s.repo.Create(post); err != nil {
return nil, err return nil, err
} }
@ -224,3 +231,15 @@ func (s *PostService) Unpin(postID uint) error {
post.PinnedAt = nil post.PinnedAt = nil
return s.repo.Update(post) 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) RecordRead(userID, postID uint) (bool, error)
RecordGuestRead(visitorID string, postID uint) (bool, error) RecordGuestRead(visitorID string, postID uint) (bool, error)
IncrementViewsCount(postID uint) error IncrementViewsCount(postID uint) error
FindScheduledDue() ([]model.Post, error)
} }
// spaceUserStore SpaceService 所需的最小用户仓储接口 // spaceUserStore SpaceService 所需的最小用户仓储接口

View File

@ -77,6 +77,10 @@
<span class="meta-label">标签</span> <span class="meta-label">标签</span>
<input type="text" id="tagInput" placeholder="逗号分隔,最多 10 个" style="width:300px"> <input type="text" id="tagInput" placeholder="逗号分隔,最多 10 个" style="width:300px">
</div> </div>
<div class="meta-row">
<span class="meta-label">定时</span>
<input type="datetime-local" id="scheduledAt" style="width:auto" min="">
</div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">声明</span> <span class="meta-label">声明</span>
<select id="declaration" style="width:auto"> <select id="declaration" style="width:auto">

View File

@ -98,6 +98,13 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('postForm')?.addEventListener('submit', handleSubmit) document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// 定时发布时间选择器 min 设为 now+30min
const scheduledAt = document.getElementById('scheduledAt');
if (scheduledAt) {
const min = new Date(Date.now() + 30 * 60 * 1000)
scheduledAt.setAttribute('min', min.toISOString().slice(0, 16))
}
// 类型切换联动:转载→显示来源,原创→显示禁止转载 // 类型切换联动:转载→显示来源,原创→显示禁止转载
const typeRadios = document.querySelectorAll('input[name="post_type"]') const typeRadios = document.querySelectorAll('input[name="post_type"]')
const reprintSourceArea = document.getElementById('reprintSourceArea') const reprintSourceArea = document.getElementById('reprintSourceArea')
@ -192,6 +199,7 @@ async function handleSubmit(e) {
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
const categoryId = document.getElementById('categoryId')?.value || '' const categoryId = document.getElementById('categoryId')?.value || ''
const tags = document.getElementById('tagInput')?.value.trim() || '' const tags = document.getElementById('tagInput')?.value.trim() || ''
const scheduledAt = document.getElementById('scheduledAt')?.value || ''
const isEdit = !!postId const isEdit = !!postId
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts' const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
@ -211,6 +219,7 @@ async function handleSubmit(e) {
reprint_prohibited: reprintProhibited, reprint_prohibited: reprintProhibited,
category_id: categoryId, category_id: categoryId,
tags, tags,
scheduled_at: scheduledAt,
}), }),
}) })
const data = await resp.json() const data = await resp.json()