add timed

This commit is contained in:
2025-09-02 16:37:28 +08:00
parent 2f3817c8f0
commit c7c4ab7a19
28 changed files with 478 additions and 2373 deletions

View File

@@ -268,8 +268,7 @@ func (r *GormArticleRepository) ListArticles(ctx context.Context, query *repoQue
var articles []entities.Article
var total int64
dbQuery := r.db.WithContext(ctx).Model(&entities.Article{}).
Select("id, title, summary, cover_image, category_id, status, is_featured, published_at, created_at, updated_at, scheduled_at")
dbQuery := r.db.WithContext(ctx).Model(&entities.Article{})
// 用户端不显示归档文章
dbQuery = dbQuery.Where("status != ?", entities.ArticleStatusArchived)
@@ -357,8 +356,7 @@ func (r *GormArticleRepository) ListArticlesForAdmin(ctx context.Context, query
var articles []entities.Article
var total int64
dbQuery := r.db.WithContext(ctx).Model(&entities.Article{}).
Select("id, title, summary, cover_image, category_id, status, is_featured, published_at, view_count, created_at, updated_at, scheduled_at")
dbQuery := r.db.WithContext(ctx).Model(&entities.Article{})
// 应用筛选条件
if query.Status != "" {

View File

@@ -142,8 +142,8 @@ func (r *GormTagRepository) AddTagToArticle(ctx context.Context, articleID strin
// 创建关联
err = r.db.WithContext(ctx).Exec(`
INSERT INTO article_tag_relations (id, article_id, tag_id, created_at)
VALUES (UUID(), ?, ?, NOW())
INSERT INTO article_tag_relations (article_id, tag_id)
VALUES (?, ?)
`, articleID, tagID).Error
if err != nil {

View File

@@ -53,19 +53,19 @@ func (h *ArticleHandler) CreateArticle(c *gin.Context) {
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
// 验证用户是否已登录
if _, exists := c.Get("user_id"); !exists {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
if err := h.appService.CreateArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("创建文章失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Created(c, nil, "文章创建成功")
}
@@ -83,19 +83,19 @@ func (h *ArticleHandler) CreateArticle(c *gin.Context) {
// @Router /api/v1/articles/{id} [get]
func (h *ArticleHandler) GetArticleByID(c *gin.Context) {
var query appQueries.GetArticleQuery
query.ID = c.Param("id")
if query.ID == "" {
h.responseBuilder.BadRequest(c, "文章ID不能为空")
// 绑定URI参数文章ID
if err := h.validator.ValidateParam(c, &query); err != nil {
return
}
response, err := h.appService.GetArticleByID(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取文章详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "文章不存在")
return
}
h.responseBuilder.Success(c, response, "获取文章详情成功")
}
@@ -124,7 +124,7 @@ func (h *ArticleHandler) ListArticles(c *gin.Context) {
if err := h.validator.ValidateQuery(c, &query); err != nil {
return
}
// 设置默认值
if query.Page <= 0 {
query.Page = 1
@@ -135,14 +135,14 @@ func (h *ArticleHandler) ListArticles(c *gin.Context) {
if query.PageSize > 100 {
query.PageSize = 100
}
response, err := h.appService.ListArticles(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取文章列表失败", zap.Error(err))
h.responseBuilder.InternalError(c, "获取文章列表失败")
return
}
h.responseBuilder.Success(c, response, "获取文章列表成功")
}
@@ -173,7 +173,7 @@ func (h *ArticleHandler) ListArticlesForAdmin(c *gin.Context) {
if err := h.validator.ValidateQuery(c, &query); err != nil {
return
}
// 设置默认值
if query.Page <= 0 {
query.Page = 1
@@ -184,19 +184,17 @@ func (h *ArticleHandler) ListArticlesForAdmin(c *gin.Context) {
if query.PageSize > 100 {
query.PageSize = 100
}
response, err := h.appService.ListArticlesForAdmin(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取文章列表失败", zap.Error(err))
h.responseBuilder.InternalError(c, "获取文章列表失败")
return
}
h.responseBuilder.Success(c, response, "获取文章列表成功")
}
// UpdateArticle 更新文章
// @Summary 更新文章
// @Description 更新文章信息
@@ -214,21 +212,23 @@ func (h *ArticleHandler) ListArticlesForAdmin(c *gin.Context) {
// @Router /api/v1/admin/articles/{id} [put]
func (h *ArticleHandler) UpdateArticle(c *gin.Context) {
var cmd commands.UpdateArticleCommand
cmd.ID = c.Param("id")
if cmd.ID == "" {
h.responseBuilder.BadRequest(c, "文章ID不能为空")
// 先绑定URI参数文章ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体文章信息
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.UpdateArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("更新文章失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "文章更新成功")
}
@@ -251,13 +251,13 @@ func (h *ArticleHandler) DeleteArticle(c *gin.Context) {
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.DeleteArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("删除文章失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "文章删除成功")
}
@@ -280,19 +280,19 @@ func (h *ArticleHandler) PublishArticle(c *gin.Context) {
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.PublishArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("发布文章失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "文章发布成功")
}
// SchedulePublishArticle 定时发布文章
// @Summary 定时发布文章
// @Description 设置文章的定时发布时间
// @Description 设置文章的定时发布时间支持格式YYYY-MM-DD HH:mm:ss
// @Tags 文章管理-管理端
// @Accept json
// @Produce json
@@ -307,22 +307,57 @@ func (h *ArticleHandler) PublishArticle(c *gin.Context) {
// @Router /api/v1/admin/articles/{id}/schedule-publish [post]
func (h *ArticleHandler) SchedulePublishArticle(c *gin.Context) {
var cmd commands.SchedulePublishCommand
// 先绑定URI参数文章ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体定时发布时间
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.SchedulePublishArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("设置定时发布失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "定时发布设置成功")
}
// CancelSchedulePublishArticle 取消定时发布文章
// @Summary 取消定时发布文章
// @Description 取消文章的定时发布设置
// @Tags 文章管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "文章ID"
// @Success 200 {object} map[string]interface{} "取消定时发布成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "文章不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/articles/{id}/cancel-schedule [post]
func (h *ArticleHandler) CancelSchedulePublishArticle(c *gin.Context) {
var cmd commands.CancelScheduleCommand
// 绑定URI参数文章ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.CancelSchedulePublishArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("取消定时发布失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "取消定时发布成功")
}
// ArchiveArticle 归档文章
// @Summary 归档文章
// @Description 将已发布文章归档
@@ -342,13 +377,13 @@ func (h *ArticleHandler) ArchiveArticle(c *gin.Context) {
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.ArchiveArticle(c.Request.Context(), &cmd); err != nil {
h.logger.Error("归档文章失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "文章归档成功")
}
@@ -369,19 +404,23 @@ func (h *ArticleHandler) ArchiveArticle(c *gin.Context) {
// @Router /api/v1/admin/articles/{id}/featured [put]
func (h *ArticleHandler) SetFeatured(c *gin.Context) {
var cmd commands.SetFeaturedCommand
// 先绑定URI参数文章ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体推荐状态
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.SetFeatured(c.Request.Context(), &cmd); err != nil {
h.logger.Error("设置推荐状态失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "设置推荐状态成功")
}
@@ -403,11 +442,10 @@ func (h *ArticleHandler) GetArticleStats(c *gin.Context) {
h.responseBuilder.InternalError(c, "获取文章统计失败")
return
}
h.responseBuilder.Success(c, response, "获取统计成功")
}
// ==================== 分类相关方法 ====================
// ListCategories 获取分类列表
@@ -426,7 +464,7 @@ func (h *ArticleHandler) ListCategories(c *gin.Context) {
h.responseBuilder.InternalError(c, "获取分类列表失败")
return
}
h.responseBuilder.Success(c, response, "获取分类列表成功")
}
@@ -444,19 +482,19 @@ func (h *ArticleHandler) ListCategories(c *gin.Context) {
// @Router /api/v1/article-categories/{id} [get]
func (h *ArticleHandler) GetCategoryByID(c *gin.Context) {
var query appQueries.GetCategoryQuery
query.ID = c.Param("id")
if query.ID == "" {
h.responseBuilder.BadRequest(c, "分类ID不能为空")
// 绑定URI参数分类ID
if err := h.validator.ValidateParam(c, &query); err != nil {
return
}
response, err := h.appService.GetCategoryByID(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取分类详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "分类不存在")
return
}
h.responseBuilder.Success(c, response, "获取分类详情成功")
}
@@ -478,13 +516,13 @@ func (h *ArticleHandler) CreateCategory(c *gin.Context) {
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.CreateCategory(c.Request.Context(), &cmd); err != nil {
h.logger.Error("创建分类失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Created(c, nil, "分类创建成功")
}
@@ -505,21 +543,23 @@ func (h *ArticleHandler) CreateCategory(c *gin.Context) {
// @Router /api/v1/admin/article-categories/{id} [put]
func (h *ArticleHandler) UpdateCategory(c *gin.Context) {
var cmd commands.UpdateCategoryCommand
cmd.ID = c.Param("id")
if cmd.ID == "" {
h.responseBuilder.BadRequest(c, "分类ID不能为空")
// 先绑定URI参数分类ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体分类信息
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.UpdateCategory(c.Request.Context(), &cmd); err != nil {
h.logger.Error("更新分类失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "分类更新成功")
}
@@ -542,13 +582,13 @@ func (h *ArticleHandler) DeleteCategory(c *gin.Context) {
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.DeleteCategory(c.Request.Context(), &cmd); err != nil {
h.logger.Error("删除分类失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "分类删除成功")
}
@@ -570,7 +610,7 @@ func (h *ArticleHandler) ListTags(c *gin.Context) {
h.responseBuilder.InternalError(c, "获取标签列表失败")
return
}
h.responseBuilder.Success(c, response, "获取标签列表成功")
}
@@ -588,19 +628,19 @@ func (h *ArticleHandler) ListTags(c *gin.Context) {
// @Router /api/v1/article-tags/{id} [get]
func (h *ArticleHandler) GetTagByID(c *gin.Context) {
var query appQueries.GetTagQuery
query.ID = c.Param("id")
if query.ID == "" {
h.responseBuilder.BadRequest(c, "标签ID不能为空")
// 绑定URI参数标签ID
if err := h.validator.ValidateParam(c, &query); err != nil {
return
}
response, err := h.appService.GetTagByID(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取标签详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "标签不存在")
return
}
h.responseBuilder.Success(c, response, "获取标签详情成功")
}
@@ -622,13 +662,13 @@ func (h *ArticleHandler) CreateTag(c *gin.Context) {
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.CreateTag(c.Request.Context(), &cmd); err != nil {
h.logger.Error("创建标签失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Created(c, nil, "标签创建成功")
}
@@ -649,21 +689,23 @@ func (h *ArticleHandler) CreateTag(c *gin.Context) {
// @Router /api/v1/admin/article-tags/{id} [put]
func (h *ArticleHandler) UpdateTag(c *gin.Context) {
var cmd commands.UpdateTagCommand
cmd.ID = c.Param("id")
if cmd.ID == "" {
h.responseBuilder.BadRequest(c, "标签ID不能为空")
// 先绑定URI参数标签ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体标签信息
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.UpdateTag(c.Request.Context(), &cmd); err != nil {
h.logger.Error("更新标签失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "标签更新成功")
}
@@ -686,12 +728,12 @@ func (h *ArticleHandler) DeleteTag(c *gin.Context) {
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.DeleteTag(c.Request.Context(), &cmd); err != nil {
h.logger.Error("删除标签失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "标签删除成功")
}

View File

@@ -48,7 +48,7 @@ func (r *ApiRoutes) Register(router *sharedhttp.GinRouter) {
// 加密接口(用于前端调试)
apiGroup.POST("/encrypt", r.authMiddleware.Handle(), r.apiHandler.EncryptParams)
// 解密接口(用于前端调试)
apiGroup.POST("/decrypt", r.authMiddleware.Handle(), r.apiHandler.DecryptParams)

View File

@@ -79,6 +79,7 @@ func (r *ArticleRoutes) Register(router *sharedhttp.GinRouter) {
// 文章状态管理
adminArticleGroup.POST("/:id/publish", r.handler.PublishArticle) // 发布文章
adminArticleGroup.POST("/:id/schedule-publish", r.handler.SchedulePublishArticle) // 定时发布文章
adminArticleGroup.POST("/:id/cancel-schedule", r.handler.CancelSchedulePublishArticle) // 取消定时发布
adminArticleGroup.POST("/:id/archive", r.handler.ArchiveArticle) // 归档文章
adminArticleGroup.PUT("/:id/featured", r.handler.SetFeatured) // 设置推荐状态
}

View File

@@ -31,7 +31,7 @@ func (c *AsynqClient) Close() error {
}
// ScheduleArticlePublish 调度文章定时发布任务
func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID string, publishTime time.Time) error {
func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID string, publishTime time.Time) (string, error) {
payload := map[string]interface{}{
"article_id": articleID,
}
@@ -39,7 +39,7 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri
payloadBytes, err := json.Marshal(payload)
if err != nil {
c.logger.Error("序列化任务载荷失败", zap.Error(err))
return fmt.Errorf("创建任务失败: %w", err)
return "", fmt.Errorf("创建任务失败: %w", err)
}
task := asynq.NewTask(TaskTypeArticlePublish, payloadBytes)
@@ -47,7 +47,7 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri
// 计算延迟时间
delay := publishTime.Sub(time.Now())
if delay <= 0 {
return fmt.Errorf("定时发布时间不能早于当前时间")
return "", fmt.Errorf("定时发布时间不能早于当前时间")
}
// 设置任务选项
@@ -63,7 +63,7 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri
zap.String("article_id", articleID),
zap.Time("publish_time", publishTime),
zap.Error(err))
return fmt.Errorf("调度任务失败: %w", err)
return "", fmt.Errorf("调度任务失败: %w", err)
}
c.logger.Info("定时发布任务调度成功",
@@ -71,5 +71,44 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri
zap.Time("publish_time", publishTime),
zap.String("task_id", info.ID))
return info.ID, nil
}
// CancelScheduledTask 取消已调度的任务
func (c *AsynqClient) CancelScheduledTask(ctx context.Context, taskID string) error {
// 注意Asynq不直接支持取消已调度的任务
// 这里我们记录日志,实际取消需要在数据库中标记
c.logger.Info("请求取消定时任务",
zap.String("task_id", taskID))
// 在实际应用中,你可能需要:
// 1. 在数据库中标记任务为已取消
// 2. 在任务执行时检查取消状态
// 3. 或者使用Redis的TTL机制
return nil
}
// RescheduleArticlePublish 重新调度文章定时发布任务
func (c *AsynqClient) RescheduleArticlePublish(ctx context.Context, articleID string, oldTaskID string, newPublishTime time.Time) (string, error) {
// 1. 取消旧任务(标记为已取消)
if err := c.CancelScheduledTask(ctx, oldTaskID); err != nil {
c.logger.Warn("取消旧任务失败",
zap.String("old_task_id", oldTaskID),
zap.Error(err))
}
// 2. 创建新任务
newTaskID, err := c.ScheduleArticlePublish(ctx, articleID, newPublishTime)
if err != nil {
return "", fmt.Errorf("重新调度任务失败: %w", err)
}
c.logger.Info("重新调度定时发布任务成功",
zap.String("article_id", articleID),
zap.String("old_task_id", oldTaskID),
zap.String("new_task_id", newTaskID),
zap.Time("new_publish_time", newPublishTime))
return newTaskID, nil
}