feat(schedule): support KOL subscription targets and random time windows
Frontend CI / Frontend (push) Successful in 3m37s
Backend CI / Backend (push) Failing after 6m47s

Extend schedule tasks to dispatch KOL subscription prompts in addition
to prompt rules, add daily/weekly scheduling with fixed or random time
windows, and track dispatch outcomes (last run, status, consecutive
failures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:51:45 +08:00
parent 9cc6537e68
commit c28c4f1419
20 changed files with 3034 additions and 329 deletions
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -36,6 +37,25 @@ type KolGenerationSubmitResponse struct {
UsageLogID int64 `json:"usage_log_id"`
}
type ScheduleKolGenerationInput struct {
ScheduleTaskID int64
OperatorID *int64
TenantID int64
WorkspaceID int64
BrandID *int64
SubscriptionPromptID int64
Name string
Variables map[string]any
EnableWebSearch bool
KnowledgeGroupIDs []int64
AutoPublish bool
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
GenerateCount int
ScheduledFor time.Time
}
type KolSubscriptionPromptSchemaResponse struct {
SubscriptionPromptID int64 `json:"subscription_prompt_id"`
PackageID int64 `json:"package_id"`
@@ -138,13 +158,58 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
if err != nil {
return nil, err
}
return s.enqueue(ctx, kolGenerationEnqueueInput{
Actor: actor,
BrandID: brandID,
SubscriptionPromptID: subPromptID,
Variables: req.Variables,
EnableWebSearch: req.EnableWebSearch,
KnowledgeGroupIDs: req.KnowledgeGroupIDs,
})
}
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
func (s *KolGenerationService) EnqueueScheduledGeneration(ctx context.Context, input ScheduleKolGenerationInput) (*KolGenerationSubmitResponse, error) {
if input.BrandID == nil || *input.BrandID <= 0 {
return nil, response.ErrBadRequest(40033, "brand_context_required", "current brand is required")
}
operatorID := int64(0)
if input.OperatorID != nil {
operatorID = *input.OperatorID
}
actor := auth.Actor{
TenantID: input.TenantID,
UserID: operatorID,
PrimaryWorkspaceID: input.WorkspaceID,
}
return s.enqueue(ctx, kolGenerationEnqueueInput{
Actor: actor,
BrandID: *input.BrandID,
SubscriptionPromptID: input.SubscriptionPromptID,
Variables: input.Variables,
EnableWebSearch: input.EnableWebSearch,
KnowledgeGroupIDs: input.KnowledgeGroupIDs,
Schedule: &input,
})
}
type kolGenerationEnqueueInput struct {
Actor auth.Actor
BrandID int64
SubscriptionPromptID int64
Variables map[string]any
EnableWebSearch bool
KnowledgeGroupIDs []int64
Schedule *ScheduleKolGenerationInput
}
func (s *KolGenerationService) enqueue(ctx context.Context, input kolGenerationEnqueueInput) (*KolGenerationSubmitResponse, error) {
actor := input.Actor
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, input.SubscriptionPromptID)
if err != nil {
return nil, err
}
variables := req.Variables
variables := input.Variables
if variables == nil {
variables = map[string]any{}
}
@@ -172,16 +237,16 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
return nil, response.ErrBadRequest(40071, "kol_generation_variables_invalid", err.Error())
}
renderedPromptHash := HashPrompt(renderedPrompt)
if req.EnableWebSearch && !kolCardConfigAllowsWebSearch(cardConfig) {
if input.EnableWebSearch && !kolCardConfigAllowsWebSearch(cardConfig) {
return nil, response.ErrBadRequest(40073, "kol_generation_web_search_disabled", "web search is disabled for this prompt")
}
knowledgeGroupIDs := normalizeInt64IDs(req.KnowledgeGroupIDs)
knowledgeGroupIDs := normalizeInt64IDs(input.KnowledgeGroupIDs)
if len(knowledgeGroupIDs) > 0 && !kolCardConfigAllowsUserKnowledge(cardConfig) {
return nil, response.ErrBadRequest(40074, "kol_generation_knowledge_disabled", "knowledge selection is disabled for this prompt")
}
enableWebSearch := kolCardConfigAllowsWebSearch(cardConfig) && req.EnableWebSearch
enableWebSearch := kolCardConfigAllowsWebSearch(cardConfig) && input.EnableWebSearch
promptRevisionNo := 1
if row.PublishedRevisionNo != nil && *row.PublishedRevisionNo > 0 {
promptRevisionNo = *row.PublishedRevisionNo
@@ -192,26 +257,30 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
}
inputJSON, err := json.Marshal(kolGenerationTaskInput{
SubscriptionPromptID: subPromptID,
SubscriptionID: row.SubscriptionID,
PackageID: row.PackageID,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: derefString(row.PlatformHint),
PromptRevisionNo: promptRevisionNo,
Variables: variables,
EnableWebSearch: enableWebSearch,
KnowledgeGroupIDs: knowledgeGroupIDs,
SchemaSnapshot: json.RawMessage(prompt.SchemaJSON),
CardConfigSnapshot: json.RawMessage(prompt.CardConfigJSON),
RenderedPromptHash: renderedPromptHash,
RenderedPrompt: renderedPrompt,
})
taskInput := map[string]any{
"subscription_prompt_id": input.SubscriptionPromptID,
"subscription_id": row.SubscriptionID,
"package_id": row.PackageID,
"prompt_id": row.PromptID,
"prompt_name": row.PromptName,
"platform_hint": derefString(row.PlatformHint),
"prompt_revision_no": promptRevisionNo,
"variables": variables,
"enable_web_search": enableWebSearch,
"knowledge_group_ids": knowledgeGroupIDs,
"schema_snapshot": json.RawMessage(prompt.SchemaJSON),
"card_config_snapshot": json.RawMessage(prompt.CardConfigJSON),
"rendered_prompt_hash": renderedPromptHash,
"rendered_prompt": renderedPrompt,
}
if input.Schedule != nil {
attachKolScheduleTaskInput(taskInput, *input.Schedule)
}
inputJSON, err := json.Marshal(taskInput)
if err != nil {
return nil, response.ErrInternal(50099, "kol_generation_input_encode_failed", err.Error())
}
variablesJSON, err := json.Marshal(variables)
if err != nil {
return nil, response.ErrBadRequest(40072, "kol_generation_variables_invalid", "variables must be valid json")
@@ -258,7 +327,7 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
articleID, err := articleTx.CreateArticle(ctx, repository.CreateArticleInput{
TenantID: actor.TenantID,
BrandID: brandID,
BrandID: input.BrandID,
SourceType: kolGenerationTaskType,
KolPromptID: &row.PromptID,
})
@@ -268,6 +337,22 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
if err := articleTx.UpdateArticleGenerateStatus(ctx, articleID, actor.TenantID, "generating"); err != nil {
return nil, response.ErrInternal(50112, "kol_generation_article_state_update_failed", err.Error())
}
if input.Schedule != nil {
coverAssetURL, coverImageAssetID := scheduleCoverState(input.Schedule.CoverAssetURL, input.Schedule.CoverImageAssetID)
title := strings.TrimSpace(input.Schedule.Name)
if title == "" {
title = row.PromptName
}
wizardStateJSON, _ := mergeArticleWizardState(nil, title, coverAssetURL, coverImageAssetID)
if _, err := tx.Exec(ctx, `
UPDATE articles
SET wizard_state_json = $1,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
`, wizardStateJSON, articleID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50112, "kol_generation_article_state_update_failed", err.Error())
}
}
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
return nil, response.ErrInternal(50104, "kol_generation_reservation_update_failed", err.Error())
@@ -357,6 +442,49 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
}, nil
}
func attachKolScheduleTaskInput(input map[string]any, schedule ScheduleKolGenerationInput) {
input["generation_mode"] = "schedule"
input["schedule_task_id"] = schedule.ScheduleTaskID
input["target_type"] = ScheduleTargetKolSubscriptionPrompt
input["task_name"] = strings.TrimSpace(schedule.Name)
if schedule.WorkspaceID > 0 {
input["workspace_id"] = schedule.WorkspaceID
}
if schedule.GenerateCount > 0 {
input["generate_count"] = schedule.GenerateCount
}
if schedule.BrandID != nil && *schedule.BrandID > 0 {
input["brand_id"] = *schedule.BrandID
}
if !schedule.ScheduledFor.IsZero() {
input["scheduled_for"] = schedule.ScheduledFor.UTC().Format(time.RFC3339)
}
if schedule.AutoPublish {
input["schedule_auto_publish"] = true
input["schedule_publish_account_ids"] = schedule.PublishAccountIDs
if schedule.CoverAssetURL != nil {
input["schedule_cover_asset_url"] = strings.TrimSpace(*schedule.CoverAssetURL)
}
if schedule.CoverImageAssetID != nil {
input["schedule_cover_image_asset_id"] = *schedule.CoverImageAssetID
}
}
}
func scheduleCoverState(coverURL *string, coverImageAssetID *int64) (*string, NullableInt64Input) {
var normalizedURL *string
var normalizedID NullableInt64Input
if coverURL != nil {
trimmed := strings.TrimSpace(*coverURL)
if trimmed != "" {
normalizedURL = &trimmed
normalizedID.Set = true
normalizedID.Value = coverImageAssetID
}
}
return normalizedURL, normalizedID
}
func (s *KolGenerationService) loadAuthorizedPrompt(ctx context.Context, actor auth.Actor, subPromptID int64) (*repository.KolSubscriptionPrompt, *repository.KolPrompt, error) {
row, err := s.subRepo.GetSubscriptionPromptByID(ctx, actor.TenantID, subPromptID)
if err != nil {