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
@@ -3,7 +3,6 @@ package app
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -201,8 +200,8 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
item.ExecutionTime = stringPtrFromDBValue(executionAt)
item.CreatedAt = fmt.Sprintf("%v", createdAt)
item.ExecutionTime = timeStringPtrFromDBValue(executionAt)
item.CreatedAt = timeStringFromDBValue(createdAt)
item.Articles = parseInstantTaskArticleLinks(articlesJSON)
autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, actor.TenantID, inputParamsJSON)
if err != nil {
@@ -228,14 +227,6 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
}, nil
}
func stringPtrFromDBValue(value interface{}) *string {
if value == nil {
return nil
}
text := fmt.Sprintf("%v", value)
return &text
}
func parseJSONMap(raw []byte) (map[string]interface{}, error) {
if len(raw) == 0 {
return map[string]interface{}{}, nil
@@ -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 {
@@ -0,0 +1,327 @@
package app
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/geo-platform/tenant-api/internal/shared/response"
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
)
const (
ScheduleTargetPromptRule = "prompt_rule"
ScheduleTargetKolSubscriptionPrompt = "kol_subscription_prompt"
scheduleKindDaily = "daily"
scheduleKindWeekly = "weekly"
defaultRandomWindowStart = "01:00:00"
defaultRandomWindowEnd = "05:00:00"
)
var scheduleDayOrder = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
type normalizedSchedulePlan struct {
CronExpr string
ScheduleKind string
ScheduleDays []string
ScheduleTimeMode string
RandomWindowStart string
RandomWindowEnd string
windowStart time.Duration
windowEnd time.Duration
}
func normalizeScheduleTarget(req ScheduleTaskRequest) (string, *int64, *int64, error) {
targetType := strings.TrimSpace(req.TargetType)
if targetType == "" {
switch {
case req.SubscriptionPromptID != nil && *req.SubscriptionPromptID > 0:
targetType = ScheduleTargetKolSubscriptionPrompt
default:
targetType = ScheduleTargetPromptRule
}
}
switch targetType {
case ScheduleTargetPromptRule:
if req.PromptRuleID == nil || *req.PromptRuleID <= 0 {
return "", nil, nil, response.ErrBadRequest(40001, "invalid_rule", "prompt_rule_id is required")
}
promptRuleID := *req.PromptRuleID
return targetType, &promptRuleID, nil, nil
case ScheduleTargetKolSubscriptionPrompt:
if req.SubscriptionPromptID == nil || *req.SubscriptionPromptID <= 0 {
return "", nil, nil, response.ErrBadRequest(40075, "invalid_subscription_prompt", "subscription_prompt_id is required")
}
subscriptionPromptID := *req.SubscriptionPromptID
return targetType, nil, &subscriptionPromptID, nil
default:
return "", nil, nil, response.ErrBadRequest(40076, "invalid_schedule_target", "target_type must be prompt_rule or kol_subscription_prompt")
}
}
func normalizeSchedulePlan(req ScheduleTaskRequest) (normalizedSchedulePlan, error) {
kind := strings.TrimSpace(req.ScheduleKind)
if kind == "" {
kind = scheduleKindDaily
}
if kind != scheduleKindDaily && kind != scheduleKindWeekly {
return normalizedSchedulePlan{}, response.ErrBadRequest(40077, "invalid_schedule_kind", "schedule_kind must be daily or weekly")
}
dayKeys, err := normalizeScheduleDayKeys(kind, req.ScheduleDays)
if err != nil {
return normalizedSchedulePlan{}, err
}
timeMode := strings.TrimSpace(req.ScheduleTimeMode)
if timeMode == "" {
if strings.TrimSpace(req.CronExpr) != "" {
timeMode = sharedschedule.TimeModeFixed
} else {
timeMode = sharedschedule.TimeModeRandomWindow
}
}
if timeMode != sharedschedule.TimeModeFixed && timeMode != sharedschedule.TimeModeRandomWindow {
return normalizedSchedulePlan{}, response.ErrBadRequest(40078, "invalid_schedule_time_mode", "schedule_time_mode must be fixed or random_window")
}
windowStart := strings.TrimSpace(req.RandomWindowStart)
if windowStart == "" {
windowStart = defaultRandomWindowStart
}
windowEnd := strings.TrimSpace(req.RandomWindowEnd)
if windowEnd == "" {
windowEnd = defaultRandomWindowEnd
}
windowStartDuration, err := sharedschedule.ParseClockDuration(windowStart)
if err != nil {
return normalizedSchedulePlan{}, response.ErrBadRequest(40079, "invalid_random_window_start", err.Error())
}
windowEndDuration, err := sharedschedule.ParseClockDuration(windowEnd)
if err != nil {
return normalizedSchedulePlan{}, response.ErrBadRequest(40080, "invalid_random_window_end", err.Error())
}
if windowEndDuration <= windowStartDuration {
return normalizedSchedulePlan{}, response.ErrBadRequest(40081, "invalid_random_window", "random_window_end must be after random_window_start")
}
cronExpr := strings.TrimSpace(req.CronExpr)
if timeMode == sharedschedule.TimeModeFixed {
if cronExpr == "" {
cronExpr = buildScheduleCronFromDays(dayKeys, windowStartDuration)
}
if err := validateScheduleCronExpr(cronExpr); err != nil {
return normalizedSchedulePlan{}, err
}
} else if cronExpr == "" {
cronExpr = buildScheduleCronFromDays(dayKeys, windowStartDuration)
}
return normalizedSchedulePlan{
CronExpr: cronExpr,
ScheduleKind: kind,
ScheduleDays: dayKeys,
ScheduleTimeMode: timeMode,
RandomWindowStart: sharedschedule.FormatClockDuration(windowStartDuration),
RandomWindowEnd: sharedschedule.FormatClockDuration(windowEndDuration),
windowStart: windowStartDuration,
windowEnd: windowEndDuration,
}, nil
}
func normalizeScheduleDayKeys(kind string, values []string) ([]string, error) {
if kind == scheduleKindDaily {
return append([]string(nil), scheduleDayOrder...), nil
}
if len(values) == 0 {
return nil, response.ErrBadRequest(40082, "schedule_days_required", "schedule_days is required for weekly schedule")
}
seen := make(map[string]struct{}, len(values))
for _, value := range values {
day, ok := sharedschedule.ParseDayKey(value)
if !ok {
return nil, response.ErrBadRequest(40083, "invalid_schedule_day", fmt.Sprintf("invalid schedule day: %s", value))
}
key := sharedschedule.DayKey(day)
if key == "" {
return nil, response.ErrBadRequest(40083, "invalid_schedule_day", fmt.Sprintf("invalid schedule day: %s", value))
}
seen[key] = struct{}{}
}
if len(seen) == 0 {
return nil, response.ErrBadRequest(40082, "schedule_days_required", "schedule_days is required")
}
if len(seen) > 7 {
return nil, response.ErrBadRequest(40084, "too_many_schedule_days", "schedule_days can contain at most 7 days")
}
result := make([]string, 0, len(seen))
for _, key := range scheduleDayOrder {
if _, ok := seen[key]; ok {
result = append(result, key)
}
}
return result, nil
}
func buildScheduleCronFromDays(dayKeys []string, at time.Duration) string {
hour := int(at / time.Hour)
minute := int((at - time.Duration(hour)*time.Hour) / time.Minute)
if len(dayKeys) == 0 || len(dayKeys) == 7 {
return fmt.Sprintf("%d %d * * *", minute, hour)
}
return fmt.Sprintf("%d %d * * %s", minute, hour, strings.Join(dayKeys, ","))
}
func scheduleDayKeysToWeekdays(dayKeys []string) []time.Weekday {
result := make([]time.Weekday, 0, len(dayKeys))
for _, key := range dayKeys {
day, ok := sharedschedule.ParseDayKey(key)
if ok {
result = append(result, day)
}
}
return result
}
func decodeScheduleDayKeys(raw []byte) []string {
if len(raw) == 0 {
return append([]string(nil), scheduleDayOrder...)
}
var values []string
if err := json.Unmarshal(raw, &values); err != nil {
return append([]string(nil), scheduleDayOrder...)
}
kind := scheduleKindWeekly
if len(values) == 7 {
kind = scheduleKindDaily
}
normalized, err := normalizeScheduleDayKeys(kind, values)
if err != nil || len(normalized) == 0 {
return append([]string(nil), scheduleDayOrder...)
}
return normalized
}
func DecodeScheduleDayKeys(raw []byte) []string {
return decodeScheduleDayKeys(raw)
}
func encodeScheduleDayKeys(dayKeys []string) []byte {
if len(dayKeys) == 0 {
dayKeys = scheduleDayOrder
}
raw, err := json.Marshal(dayKeys)
if err != nil {
return []byte(`["mon","tue","wed","thu","fri","sat","sun"]`)
}
return raw
}
func decodeScheduleGenerationInput(raw []byte) map[string]interface{} {
if len(raw) == 0 {
return map[string]interface{}{}
}
var value map[string]interface{}
if err := json.Unmarshal(raw, &value); err != nil || value == nil {
return map[string]interface{}{}
}
return value
}
func encodeScheduleGenerationInput(value map[string]interface{}) []byte {
if len(value) == 0 {
return []byte(`{}`)
}
raw, err := json.Marshal(value)
if err != nil {
return []byte(`{}`)
}
return raw
}
func scheduleGenerationInputCacheKey(value map[string]interface{}) string {
if len(value) == 0 {
return "{}"
}
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
ordered := make(map[string]interface{}, len(keys))
for _, key := range keys {
ordered[key] = value[key]
}
return digestCacheKey(ordered)
}
func computeScheduleNextRun(
status string,
taskID int64,
cronExpr string,
dayKeys []string,
timeMode string,
windowStart string,
windowEnd string,
startAt *time.Time,
endAt *time.Time,
now time.Time,
) (*time.Time, error) {
if status != "enabled" {
return nil, nil
}
if strings.TrimSpace(timeMode) != sharedschedule.TimeModeRandomWindow {
return sharedschedule.ComputeNextRun(cronExpr, startAt, endAt, now)
}
windowStartDuration, err := sharedschedule.ParseClockDuration(windowStart)
if err != nil {
return nil, err
}
windowEndDuration, err := sharedschedule.ParseClockDuration(windowEnd)
if err != nil {
return nil, err
}
return sharedschedule.ComputeNextRandomRun(sharedschedule.RandomPolicy{
Days: scheduleDayKeysToWeekdays(dayKeys),
TimeMode: sharedschedule.TimeModeRandomWindow,
WindowStart: windowStartDuration,
WindowEnd: windowEndDuration,
Seed: taskID,
}, startAt, endAt, now)
}
func ComputeScheduleTaskNextRun(
status string,
taskID int64,
cronExpr string,
dayKeys []string,
timeMode string,
windowStart string,
windowEnd string,
startAt *time.Time,
endAt *time.Time,
now time.Time,
) (*time.Time, error) {
return computeScheduleNextRun(status, taskID, cronExpr, dayKeys, timeMode, windowStart, windowEnd, startAt, endAt, now)
}
func normalizeKolScheduleGenerationInput(input map[string]interface{}) map[string]interface{} {
if input == nil {
return map[string]interface{}{}
}
result := clonePromptRuleExtraParams(input)
if variables, ok := result["variables"].(map[string]interface{}); ok && variables != nil {
result["variables"] = variables
} else {
result["variables"] = map[string]interface{}{}
}
return result
}
@@ -0,0 +1,62 @@
package app
import (
"testing"
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
)
func TestNormalizeSchedulePlanFixedTimeBuildsCronFromSelectedDays(t *testing.T) {
plan, err := normalizeSchedulePlan(ScheduleTaskRequest{
ScheduleKind: scheduleKindWeekly,
ScheduleDays: []string{"mon", "wed", "fri"},
ScheduleTimeMode: sharedschedule.TimeModeFixed,
CronExpr: "17 3 * * mon,wed,fri",
})
if err != nil {
t.Fatalf("normalizeSchedulePlan returned error: %v", err)
}
if plan.ScheduleKind != scheduleKindWeekly {
t.Fatalf("expected weekly schedule, got %q", plan.ScheduleKind)
}
if plan.ScheduleTimeMode != sharedschedule.TimeModeFixed {
t.Fatalf("expected fixed mode, got %q", plan.ScheduleTimeMode)
}
if plan.CronExpr != "17 3 * * mon,wed,fri" {
t.Fatalf("unexpected cron expression: %q", plan.CronExpr)
}
if plan.RandomWindowStart != defaultRandomWindowStart || plan.RandomWindowEnd != defaultRandomWindowEnd {
t.Fatalf("unexpected compatibility window: %s-%s", plan.RandomWindowStart, plan.RandomWindowEnd)
}
}
func TestNormalizeSchedulePlanRandomWindowAllowsEditableWindow(t *testing.T) {
plan, err := normalizeSchedulePlan(ScheduleTaskRequest{
ScheduleKind: scheduleKindWeekly,
ScheduleDays: []string{"mon", "wed", "fri"},
ScheduleTimeMode: sharedschedule.TimeModeRandomWindow,
RandomWindowStart: "02:00:00",
RandomWindowEnd: "04:30:00",
})
if err != nil {
t.Fatalf("normalizeSchedulePlan returned error: %v", err)
}
if plan.ScheduleKind != scheduleKindWeekly {
t.Fatalf("expected weekly schedule, got %q", plan.ScheduleKind)
}
if plan.ScheduleTimeMode != sharedschedule.TimeModeRandomWindow {
t.Fatalf("expected random window mode, got %q", plan.ScheduleTimeMode)
}
if plan.RandomWindowStart != "02:00:00" || plan.RandomWindowEnd != "04:30:00" {
t.Fatalf("unexpected random window: %s-%s", plan.RandomWindowStart, plan.RandomWindowEnd)
}
_, err = normalizeSchedulePlan(ScheduleTaskRequest{
ScheduleTimeMode: sharedschedule.TimeModeRandomWindow,
RandomWindowStart: "05:00:00",
RandomWindowEnd: "01:00:00",
})
if err == nil {
t.Fatal("expected inverted random window to be rejected")
}
}
@@ -20,6 +20,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type ScheduleTaskService struct {
@@ -39,55 +40,80 @@ func (s *ScheduleTaskService) WithCache(c sharedcache.Cache) *ScheduleTaskServic
}
type ScheduleTaskRequest struct {
PromptRuleID int64 `json:"prompt_rule_id" binding:"required"`
BrandID *int64 `json:"brand_id"`
Name string `json:"name" binding:"required"`
CronExpr string `json:"cron_expr" binding:"required"`
AutoPublish *bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch *bool `json:"enable_web_search"`
GenerateCount *int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
TargetType string `json:"target_type"`
PromptRuleID *int64 `json:"prompt_rule_id"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
BrandID *int64 `json:"brand_id"`
Name string `json:"name" binding:"required"`
CronExpr string `json:"cron_expr"`
ScheduleKind string `json:"schedule_kind"`
ScheduleDays []string `json:"schedule_days"`
ScheduleTimeMode string `json:"schedule_time_mode"`
RandomWindowStart string `json:"random_window_start"`
RandomWindowEnd string `json:"random_window_end"`
GenerationInput map[string]interface{} `json:"generation_input"`
AutoPublish *bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch *bool `json:"enable_web_search"`
GenerateCount *int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
}
type ScheduleTaskResponse struct {
ID int64 `json:"id"`
WorkspaceID *int64 `json:"workspace_id"`
PromptRuleID int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
BrandID *int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
NextRunAt *string `json:"next_run_at"`
Status string `json:"status"`
GenerationStatus *string `json:"generation_status"`
ExecutionTime *string `json:"execution_time"`
GeneratedArticles []InstantTaskArticleLink `json:"generated_articles"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
WorkspaceID *int64 `json:"workspace_id"`
TargetType string `json:"target_type"`
PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
SubscriptionPromptName *string `json:"subscription_prompt_name"`
SubscriptionPackageName *string `json:"subscription_package_name"`
SubscriptionKolName *string `json:"subscription_kol_name"`
BrandID *int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
ScheduleKind string `json:"schedule_kind"`
ScheduleDays []string `json:"schedule_days"`
ScheduleTimeMode string `json:"schedule_time_mode"`
RandomWindowStart string `json:"random_window_start"`
RandomWindowEnd string `json:"random_window_end"`
GenerationInput map[string]interface{} `json:"generation_input"`
AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
NextRunAt *string `json:"next_run_at"`
Status string `json:"status"`
LastRunAt *string `json:"last_run_at"`
LastDispatchStatus *string `json:"last_dispatch_status"`
LastDispatchError *string `json:"last_dispatch_error"`
ConsecutiveFailures int `json:"consecutive_failures"`
GenerationStatus *string `json:"generation_status"`
ExecutionTime *string `json:"execution_time"`
GeneratedArticles []InstantTaskArticleLink `json:"generated_articles"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ScheduleTaskListParams struct {
PromptRuleID *int64 `json:"prompt_rule_id"`
Status *string `json:"status"`
Keyword *string `json:"keyword"`
CreatedFrom *time.Time
CreatedTo *time.Time
Page int `json:"page"`
PageSize int `json:"page_size"`
PromptRuleID *int64 `json:"prompt_rule_id"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
TargetType *string `json:"target_type"`
Status *string `json:"status"`
Keyword *string `json:"keyword"`
CreatedFrom *time.Time
CreatedTo *time.Time
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type ScheduleTaskListResponse struct {
@@ -136,12 +162,26 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
}
req.BrandID = &brandID
if err := validateScheduleCronExpr(req.CronExpr); err != nil {
targetType, promptRuleID, subscriptionPromptID, err := normalizeScheduleTarget(req)
if err != nil {
return nil, err
}
if err := s.ensurePromptRuleExists(ctx, actor.TenantID, brandID, req.PromptRuleID); err != nil {
plan, err := normalizeSchedulePlan(req)
if err != nil {
return nil, err
}
generationInput := clonePromptRuleExtraParams(req.GenerationInput)
if targetType == ScheduleTargetPromptRule {
if err := s.ensurePromptRuleExists(ctx, actor.TenantID, brandID, *promptRuleID); err != nil {
return nil, err
}
generationInput = map[string]interface{}{}
} else {
generationInput, err = s.validateKolScheduleGenerationInput(ctx, actor.TenantID, *subscriptionPromptID, generationInput)
if err != nil {
return nil, err
}
}
generateCount, err := normalizeScheduleGenerateCount(req.GenerateCount)
if err != nil {
return nil, err
@@ -165,19 +205,31 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
var status string
err = tx.QueryRow(ctx, `
INSERT INTO schedule_tasks (
tenant_id, workspace_id, operator_id, prompt_rule_id, brand_id, name, cron_expr,
tenant_id, workspace_id, operator_id, target_type, prompt_rule_id, subscription_prompt_id,
brand_id, name, cron_expr, schedule_kind, schedule_days, schedule_time_mode,
random_window_start, random_window_end, generation_input_json,
enable_web_search, generate_count, auto_publish, publish_account_ids, cover_asset_url,
cover_image_asset_id, start_at, end_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14::timestamptz, $15::timestamptz)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11::jsonb, $12,
$13::time, $14::time, $15::jsonb,
$16, $17, $18, $19::jsonb, $20,
$21, $22::timestamptz, $23::timestamptz
)
RETURNING id, created_at, start_at, end_at, status
`, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, enableWebSearch, generateCount, publishConfig.AutoPublish, publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
`, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID, targetType, promptRuleID, subscriptionPromptID,
req.BrandID, req.Name, plan.CronExpr, plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
enableWebSearch, generateCount, publishConfig.AutoPublish, publishConfig.AccountIDsJSON,
publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
Scan(&id, &createdAt, &startAt, &endAt, &status)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task")
}
nextRunAt, err := resolveScheduleNextRunForStatus(status, req.CronExpr, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now())
nextRunAt, err := computeScheduleNextRun(status, id, plan.CronExpr, plan.ScheduleDays, plan.ScheduleTimeMode, plan.RandomWindowStart, plan.RandomWindowEnd, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now())
if err != nil {
return nil, response.ErrBadRequest(40015, "invalid_cron_expr", err.Error())
}
@@ -196,12 +248,15 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
}
afterJSON, _ := json.Marshal(map[string]interface{}{
"id": id,
"name": req.Name,
"cron_expr": req.CronExpr,
"enable_web_search": enableWebSearch,
"generate_count": generateCount,
"auto_publish": publishConfig.AutoPublish,
"id": id,
"name": req.Name,
"target_type": targetType,
"cron_expr": plan.CronExpr,
"schedule_days": plan.ScheduleDays,
"schedule_time_mode": plan.ScheduleTimeMode,
"enable_web_search": enableWebSearch,
"generate_count": generateCount,
"auto_publish": publishConfig.AutoPublish,
})
result := "success"
resourceType := "schedule_task"
@@ -221,12 +276,15 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id)
workspaceID := actor.PrimaryWorkspaceID
return &ScheduleTaskResponse{
ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
Name: req.Name, CronExpr: req.CronExpr, AutoPublish: publishConfig.AutoPublish,
ID: id, WorkspaceID: &workspaceID, TargetType: targetType, PromptRuleID: promptRuleID,
SubscriptionPromptID: subscriptionPromptID, BrandID: req.BrandID,
Name: req.Name, CronExpr: plan.CronExpr, ScheduleKind: plan.ScheduleKind, ScheduleDays: plan.ScheduleDays,
ScheduleTimeMode: plan.ScheduleTimeMode, RandomWindowStart: plan.RandomWindowStart,
RandomWindowEnd: plan.RandomWindowEnd, GenerationInput: generationInput, AutoPublish: publishConfig.AutoPublish,
PublishAccountIDs: publishConfig.AccountIDs, AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
GenerateCount: generateCount,
Status: status, CreatedAt: fmt.Sprintf("%v", createdAt),
Status: status, CreatedAt: timeStringFromDBValue(createdAt),
NextRunAt: timeStringPtr(nextRunAt),
}, nil
}
@@ -239,12 +297,26 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
}
req.BrandID = &brandID
if err := validateScheduleCronExpr(req.CronExpr); err != nil {
targetType, promptRuleID, subscriptionPromptID, err := normalizeScheduleTarget(req)
if err != nil {
return err
}
if err := s.ensurePromptRuleExists(ctx, actor.TenantID, brandID, req.PromptRuleID); err != nil {
plan, err := normalizeSchedulePlan(req)
if err != nil {
return err
}
generationInput := clonePromptRuleExtraParams(req.GenerationInput)
if targetType == ScheduleTargetPromptRule {
if err := s.ensurePromptRuleExists(ctx, actor.TenantID, brandID, *promptRuleID); err != nil {
return err
}
generationInput = map[string]interface{}{}
} else {
generationInput, err = s.validateKolScheduleGenerationInput(ctx, actor.TenantID, *subscriptionPromptID, generationInput)
if err != nil {
return err
}
}
generateCount, err := normalizeScheduleGenerateCount(req.GenerateCount)
if err != nil {
return err
@@ -265,20 +337,32 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
var startAt pgtype.Timestamptz
var endAt pgtype.Timestamptz
err = tx.QueryRow(ctx, `
UPDATE schedule_tasks SET prompt_rule_id = $1, brand_id = $2, name = $3,
cron_expr = $4, enable_web_search = $5, generate_count = $6,
workspace_id = $7, auto_publish = $8, publish_account_ids = $9::jsonb,
cover_asset_url = $10, cover_image_asset_id = $11,
start_at = $12::timestamptz, end_at = $13::timestamptz, operator_id = $14, updated_at = NOW()
WHERE id = $15 AND tenant_id = $16 AND brand_id = $17 AND deleted_at IS NULL
UPDATE schedule_tasks SET target_type = $1, prompt_rule_id = $2, subscription_prompt_id = $3,
brand_id = $4, name = $5, cron_expr = $6,
schedule_kind = $7, schedule_days = $8::jsonb, schedule_time_mode = $9,
random_window_start = $10::time, random_window_end = $11::time,
generation_input_json = $12::jsonb,
enable_web_search = $13, generate_count = $14,
workspace_id = $15, auto_publish = $16, publish_account_ids = $17::jsonb,
cover_asset_url = $18, cover_image_asset_id = $19,
start_at = $20::timestamptz, end_at = $21::timestamptz, operator_id = $22,
last_dispatch_status = NULL, last_dispatch_error = NULL,
consecutive_failures = 0,
updated_at = NOW()
WHERE id = $23 AND tenant_id = $24 AND brand_id = $25 AND deleted_at IS NULL
RETURNING status, start_at, end_at
`, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish, publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
`, targetType, promptRuleID, subscriptionPromptID, req.BrandID, req.Name, plan.CronExpr,
plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish,
publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
Scan(&status, &startAt, &endAt)
if err != nil {
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
}
nextRunAt, err := resolveScheduleNextRunForStatus(status, req.CronExpr, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now())
nextRunAt, err := computeScheduleNextRun(status, id, plan.CronExpr, plan.ScheduleDays, plan.ScheduleTimeMode, plan.RandomWindowStart, plan.RandomWindowEnd, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now())
if err != nil {
return response.ErrBadRequest(40015, "invalid_cron_expr", err.Error())
}
@@ -346,19 +430,24 @@ func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req Sc
var cronExpr string
var status string
var scheduleTimeMode string
var randomWindowStart string
var randomWindowEnd string
var scheduleDaysJSON []byte
var startAt pgtype.Timestamptz
var endAt pgtype.Timestamptz
err = tx.QueryRow(ctx, `
UPDATE schedule_tasks SET status = $1, operator_id = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND brand_id = $5 AND deleted_at IS NULL
RETURNING cron_expr, status, start_at, end_at
RETURNING cron_expr, status, schedule_days, schedule_time_mode,
random_window_start::text, random_window_end::text, start_at, end_at
`, req.Status, actor.UserID, id, actor.TenantID, brandID).
Scan(&cronExpr, &status, &startAt, &endAt)
Scan(&cronExpr, &status, &scheduleDaysJSON, &scheduleTimeMode, &randomWindowStart, &randomWindowEnd, &startAt, &endAt)
if err != nil {
return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found")
}
nextRunAt, err := resolveScheduleNextRunForStatus(status, cronExpr, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now())
nextRunAt, err := computeScheduleNextRun(status, id, cronExpr, decodeScheduleDayKeys(scheduleDaysJSON), scheduleTimeMode, randomWindowStart, randomWindowEnd, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now())
if err != nil {
return response.ErrBadRequest(40015, "invalid_cron_expr", err.Error())
}
@@ -397,19 +486,32 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
AND ($5::text IS NULL OR name ILIKE '%' || $5 || '%')
AND ($6::timestamptz IS NULL OR created_at >= $6)
AND ($7::timestamptz IS NULL OR created_at <= $7)
`, tenantID, brandID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo).Scan(&total); err != nil {
AND ($8::text IS NULL OR target_type = $8)
AND ($9::bigint IS NULL OR subscription_prompt_id = $9)
`, tenantID, brandID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.TargetType, params.SubscriptionPromptID).Scan(&total); err != nil {
return nil, response.ErrInternal(50010, "count_failed", "failed to count schedule tasks")
}
rows, err := s.pool.Query(ctx, `
SELECT st.id, st.workspace_id, st.prompt_rule_id, st.brand_id, st.name,
st.cron_expr, st.auto_publish, st.publish_account_ids, st.cover_asset_url,
SELECT st.id, st.workspace_id, st.target_type, st.prompt_rule_id,
st.subscription_prompt_id, st.brand_id, st.name,
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
st.auto_publish, st.publish_account_ids, st.cover_asset_url,
st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
st.next_run_at, st.status, st.created_at, st.updated_at,
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
pr.name AS prompt_rule_name,
sp_prompt.name AS subscription_prompt_name,
sp_pkg.name AS subscription_package_name,
sp_profile.display_name AS subscription_kol_name,
b.name AS brand_name
FROM schedule_tasks st
LEFT JOIN prompt_rules pr ON pr.id = st.prompt_rule_id
LEFT JOIN kol_subscription_prompts sp ON sp.id = st.subscription_prompt_id
LEFT JOIN kol_prompts sp_prompt ON sp_prompt.id = sp.prompt_id
LEFT JOIN kol_packages sp_pkg ON sp_pkg.id = sp.package_id
LEFT JOIN kol_profiles sp_profile ON sp_profile.id = sp_pkg.kol_profile_id
LEFT JOIN brands b ON b.id = st.brand_id
WHERE st.tenant_id = $1 AND st.brand_id = $2 AND st.deleted_at IS NULL
AND ($3::bigint IS NULL OR st.prompt_rule_id = $3)
@@ -417,9 +519,11 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
AND ($5::text IS NULL OR st.name ILIKE '%' || $5 || '%')
AND ($6::timestamptz IS NULL OR st.created_at >= $6)
AND ($7::timestamptz IS NULL OR st.created_at <= $7)
AND ($10::text IS NULL OR st.target_type = $10)
AND ($11::bigint IS NULL OR st.subscription_prompt_id = $11)
ORDER BY st.created_at DESC
LIMIT $8 OFFSET $9
`, tenantID, brandID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.PageSize, offset)
`, tenantID, brandID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.PageSize, offset, params.TargetType, params.SubscriptionPromptID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to list schedule tasks")
}
@@ -431,27 +535,38 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
if err != nil {
return nil, err
}
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
return nil, err
}
if err := s.populateScheduleLatestGeneratedArticles(ctx, tenantID, &item); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", "failed to iterate schedule tasks")
}
if err := s.populateScheduleTaskListDecorations(ctx, tenantID, items); err != nil {
return nil, err
}
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
}
func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenantID, brandID, taskID int64) (*ScheduleTaskResponse, bool, error) {
row := s.pool.QueryRow(ctx, `
SELECT st.id, st.workspace_id, st.prompt_rule_id, st.brand_id, st.name,
st.cron_expr, st.auto_publish, st.publish_account_ids, st.cover_asset_url,
SELECT st.id, st.workspace_id, st.target_type, st.prompt_rule_id,
st.subscription_prompt_id, st.brand_id, st.name,
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
st.auto_publish, st.publish_account_ids, st.cover_asset_url,
st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
st.next_run_at, st.status, st.created_at, st.updated_at,
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
pr.name AS prompt_rule_name,
sp_prompt.name AS subscription_prompt_name,
sp_pkg.name AS subscription_package_name,
sp_profile.display_name AS subscription_kol_name,
b.name AS brand_name
FROM schedule_tasks st
LEFT JOIN prompt_rules pr ON pr.id = st.prompt_rule_id
LEFT JOIN kol_subscription_prompts sp ON sp.id = st.subscription_prompt_id
LEFT JOIN kol_prompts sp_prompt ON sp_prompt.id = sp.prompt_id
LEFT JOIN kol_packages sp_pkg ON sp_pkg.id = sp.package_id
LEFT JOIN kol_profiles sp_profile ON sp_profile.id = sp_pkg.kol_profile_id
LEFT JOIN brands b ON b.id = st.brand_id
WHERE st.id = $1 AND st.tenant_id = $2 AND st.brand_id = $3 AND st.deleted_at IS NULL
`, taskID, tenantID, brandID)
@@ -471,6 +586,16 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
return &item, true, nil
}
func (s *ScheduleTaskService) populateScheduleTaskListDecorations(ctx context.Context, tenantID int64, items []ScheduleTaskResponse) error {
if len(items) == 0 {
return nil
}
if err := s.populateScheduleAutoPublishPlatformsBatch(ctx, tenantID, items); err != nil {
return err
}
return s.populateScheduleLatestGeneratedArticlesBatch(ctx, tenantID, items)
}
func (s *ScheduleTaskService) populateScheduleAutoPublishPlatforms(ctx context.Context, tenantID int64, item *ScheduleTaskResponse) error {
if item == nil || !item.AutoPublish {
return nil
@@ -483,17 +608,85 @@ func (s *ScheduleTaskService) populateScheduleAutoPublishPlatforms(ctx context.C
return nil
}
func (s *ScheduleTaskService) populateScheduleAutoPublishPlatformsBatch(ctx context.Context, tenantID int64, items []ScheduleTaskResponse) error {
accountSet := make(map[string]struct{})
for _, item := range items {
if !item.AutoPublish {
continue
}
for _, accountID := range item.PublishAccountIDs {
normalized := normalizeSchedulePublishAccountIDs([]string{accountID})
if len(normalized) == 0 {
continue
}
accountSet[normalized[0]] = struct{}{}
}
}
if len(accountSet) == 0 {
return nil
}
accountIDs := make([]string, 0, len(accountSet))
for accountID := range accountSet {
accountIDs = append(accountIDs, accountID)
}
rows, err := s.pool.Query(ctx, `
SELECT desktop_id::text, platform_id
FROM platform_accounts
WHERE tenant_id = $1
AND desktop_id::text = ANY($2::text[])
AND deleted_at IS NULL
`, tenantID, accountIDs)
if err != nil {
return response.ErrInternal(50010, "query_failed", "failed to resolve schedule auto publish platforms")
}
defer rows.Close()
platformsByAccount := make(map[string][]string, len(accountIDs))
for rows.Next() {
var accountID string
var platformID string
if err := rows.Scan(&accountID, &platformID); err != nil {
return response.ErrInternal(50010, "scan_failed", "failed to scan schedule auto publish platforms")
}
platformsByAccount[accountID] = append(platformsByAccount[accountID], platformID)
}
if err := rows.Err(); err != nil {
return response.ErrInternal(50010, "scan_failed", "failed to iterate schedule auto publish platforms")
}
for idx := range items {
if !items[idx].AutoPublish {
continue
}
platformIDs := make([]string, 0, len(items[idx].PublishAccountIDs))
for _, accountID := range items[idx].PublishAccountIDs {
normalized := normalizeSchedulePublishAccountIDs([]string{accountID})
if len(normalized) == 0 {
continue
}
platformIDs = append(platformIDs, platformsByAccount[normalized[0]]...)
}
items[idx].AutoPublishPlatforms = emptyStringSliceIfNil(normalizePlatformIDs(platformIDs))
}
return nil
}
func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticles(ctx context.Context, tenantID int64, item *ScheduleTaskResponse) error {
if item == nil || item.ID <= 0 {
return nil
}
taskType := "custom_generation"
if item.TargetType == ScheduleTargetKolSubscriptionPrompt {
taskType = kolGenerationTaskType
}
rows, err := s.pool.Query(ctx, `
WITH latest_run AS (
SELECT COALESCE(NULLIF(gt.input_params_json ->> 'scheduled_for', ''), to_char(date_trunc('second', gt.created_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) AS run_key
FROM generation_tasks gt
WHERE gt.tenant_id = $1
AND gt.task_type = 'custom_generation'
AND gt.task_type = $3
AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), '') = 'schedule'
AND NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint = $2
ORDER BY gt.created_at DESC, gt.id DESC
@@ -510,11 +703,11 @@ func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticles(ctx contex
LEFT JOIN articles a ON a.id = gt.article_id AND a.deleted_at IS NULL
LEFT JOIN article_versions av ON av.id = a.current_version_id
WHERE gt.tenant_id = $1
AND gt.task_type = 'custom_generation'
AND gt.task_type = $3
AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), '') = 'schedule'
AND NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint = $2
ORDER BY gt.created_at, gt.id
`, tenantID, item.ID)
`, tenantID, item.ID, taskType)
if err != nil {
return response.ErrInternal(50010, "query_failed", "failed to load schedule generated articles")
}
@@ -534,7 +727,7 @@ func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticles(ctx contex
return response.ErrInternal(50010, "scan_failed", err.Error())
}
if executionTime == nil {
executionTime = stringPtrFromDBValue(executionAt)
executionTime = timeStringPtrFromDBValue(executionAt)
}
statuses = append(statuses, generateStatus)
if articleID != nil && *articleID > 0 {
@@ -542,7 +735,7 @@ func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticles(ctx contex
ArticleID: *articleID,
Title: title,
GenerateStatus: generateStatus,
CreatedAt: fmt.Sprintf("%v", createdAt),
CreatedAt: timeStringFromDBValue(createdAt),
})
} else {
_ = taskID
@@ -561,6 +754,126 @@ func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticles(ctx contex
return nil
}
func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticlesBatch(ctx context.Context, tenantID int64, items []ScheduleTaskResponse) error {
if len(items) == 0 {
return nil
}
promptRuleTaskIDs := make([]int64, 0, len(items))
kolTaskIDs := make([]int64, 0, len(items))
indexByTaskID := make(map[int64]int, len(items))
for idx, item := range items {
if item.ID <= 0 {
continue
}
indexByTaskID[item.ID] = idx
if item.TargetType == ScheduleTargetKolSubscriptionPrompt {
kolTaskIDs = append(kolTaskIDs, item.ID)
} else {
promptRuleTaskIDs = append(promptRuleTaskIDs, item.ID)
}
}
if err := s.populateScheduleLatestGeneratedArticlesBatchByType(ctx, tenantID, items, indexByTaskID, promptRuleTaskIDs, "custom_generation"); err != nil {
return err
}
return s.populateScheduleLatestGeneratedArticlesBatchByType(ctx, tenantID, items, indexByTaskID, kolTaskIDs, kolGenerationTaskType)
}
func (s *ScheduleTaskService) populateScheduleLatestGeneratedArticlesBatchByType(
ctx context.Context,
tenantID int64,
items []ScheduleTaskResponse,
indexByTaskID map[int64]int,
taskIDs []int64,
taskType string,
) error {
if len(taskIDs) == 0 {
return nil
}
rows, err := s.pool.Query(ctx, `
WITH latest_run AS (
SELECT DISTINCT ON (NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint)
NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint AS schedule_task_id,
COALESCE(NULLIF(gt.input_params_json ->> 'scheduled_for', ''), to_char(date_trunc('second', gt.created_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) AS run_key
FROM generation_tasks gt
WHERE gt.tenant_id = $1
AND gt.task_type = $2
AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), '') = 'schedule'
AND NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint = ANY($3::bigint[])
ORDER BY NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint, gt.created_at DESC, gt.id DESC
)
SELECT lr.schedule_task_id,
gt.id,
COALESCE(a.id, gt.article_id) AS article_id,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'task_name', '')) AS article_title,
COALESCE(a.generate_status, gt.status) AS article_generate_status,
COALESCE(a.created_at, gt.created_at) AS article_created_at,
COALESCE(gt.started_at, gt.completed_at, gt.created_at) AS execution_time
FROM latest_run lr
JOIN generation_tasks gt
ON gt.tenant_id = $1
AND gt.task_type = $2
AND COALESCE(NULLIF(gt.input_params_json ->> 'generation_mode', ''), '') = 'schedule'
AND NULLIF(gt.input_params_json ->> 'schedule_task_id', '')::bigint = lr.schedule_task_id
AND COALESCE(NULLIF(gt.input_params_json ->> 'scheduled_for', ''), to_char(date_trunc('second', gt.created_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) = lr.run_key
LEFT JOIN articles a ON a.id = gt.article_id AND a.deleted_at IS NULL
LEFT JOIN article_versions av ON av.id = a.current_version_id
ORDER BY lr.schedule_task_id, gt.created_at, gt.id
`, tenantID, taskType, taskIDs)
if err != nil {
return response.ErrInternal(50010, "query_failed", "failed to load schedule generated articles")
}
defer rows.Close()
statusesByTask := make(map[int64][]string, len(taskIDs))
for rows.Next() {
var scheduleTaskID int64
var taskID int64
var articleID *int64
var title *string
var generateStatus string
var createdAt interface{}
var executionAt interface{}
if err := rows.Scan(&scheduleTaskID, &taskID, &articleID, &title, &generateStatus, &createdAt, &executionAt); err != nil {
return response.ErrInternal(50010, "scan_failed", err.Error())
}
idx, ok := indexByTaskID[scheduleTaskID]
if !ok {
continue
}
if items[idx].ExecutionTime == nil {
items[idx].ExecutionTime = timeStringPtrFromDBValue(executionAt)
}
statusesByTask[scheduleTaskID] = append(statusesByTask[scheduleTaskID], generateStatus)
if articleID != nil && *articleID > 0 {
items[idx].GeneratedArticles = append(items[idx].GeneratedArticles, InstantTaskArticleLink{
ArticleID: *articleID,
Title: title,
GenerateStatus: generateStatus,
CreatedAt: timeStringFromDBValue(createdAt),
})
} else {
_ = taskID
}
}
if err := rows.Err(); err != nil {
return response.ErrInternal(50010, "scan_failed", "failed to iterate schedule generated articles")
}
for scheduleTaskID, statuses := range statusesByTask {
status := aggregateGeneratedArticleStatus(statuses)
if status == "" {
continue
}
idx, ok := indexByTaskID[scheduleTaskID]
if ok {
items[idx].GenerationStatus = &status
}
}
return nil
}
func aggregateGeneratedArticleStatus(statuses []string) string {
if len(statuses) == 0 {
return ""
@@ -608,32 +921,49 @@ func scanScheduleTaskResponse(scanner interface {
var startAt interface{}
var endAt interface{}
var nextRunAt interface{}
var lastRunAt interface{}
var publishAccountIDsJSON []byte
if err := scanner.Scan(&item.ID, &item.WorkspaceID, &item.PromptRuleID, &item.BrandID, &item.Name,
&item.CronExpr, &item.AutoPublish, &publishAccountIDsJSON,
var scheduleDaysJSON []byte
var generationInputJSON []byte
if err := scanner.Scan(&item.ID, &item.WorkspaceID, &item.TargetType, &item.PromptRuleID,
&item.SubscriptionPromptID, &item.BrandID, &item.Name,
&item.CronExpr, &item.ScheduleKind, &scheduleDaysJSON, &item.ScheduleTimeMode,
&item.RandomWindowStart, &item.RandomWindowEnd, &generationInputJSON,
&item.AutoPublish, &publishAccountIDsJSON,
&item.CoverAssetURL, &item.CoverImageAssetID, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
&nextRunAt, &item.Status, &createdAt, &updatedAt,
&item.PromptRuleName, &item.BrandName); err != nil {
&nextRunAt, &item.Status, &lastRunAt, &item.LastDispatchStatus,
&item.LastDispatchError, &item.ConsecutiveFailures, &createdAt, &updatedAt,
&item.PromptRuleName, &item.SubscriptionPromptName, &item.SubscriptionPackageName,
&item.SubscriptionKolName, &item.BrandName); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ScheduleTaskResponse{}, pgx.ErrNoRows
}
return ScheduleTaskResponse{}, response.ErrInternal(50010, "scan_failed", err.Error())
}
if item.TargetType == "" {
item.TargetType = ScheduleTargetPromptRule
}
if item.ScheduleKind == "" {
item.ScheduleKind = scheduleKindDaily
}
if item.ScheduleTimeMode == "" {
item.ScheduleTimeMode = sharedschedule.TimeModeFixed
}
if item.RandomWindowStart == "" {
item.RandomWindowStart = defaultRandomWindowStart
}
if item.RandomWindowEnd == "" {
item.RandomWindowEnd = defaultRandomWindowEnd
}
item.ScheduleDays = decodeScheduleDayKeys(scheduleDaysJSON)
item.GenerationInput = decodeScheduleGenerationInput(generationInputJSON)
item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON)
item.CreatedAt = fmt.Sprintf("%v", createdAt)
item.UpdatedAt = fmt.Sprintf("%v", updatedAt)
if startAt != nil {
value := fmt.Sprintf("%v", startAt)
item.StartAt = &value
}
if endAt != nil {
value := fmt.Sprintf("%v", endAt)
item.EndAt = &value
}
if nextRunAt != nil {
value := fmt.Sprintf("%v", nextRunAt)
item.NextRunAt = &value
}
item.CreatedAt = timeStringFromDBValue(createdAt)
item.UpdatedAt = timeStringFromDBValue(updatedAt)
item.StartAt = timeStringPtrFromDBValue(startAt)
item.EndAt = timeStringPtrFromDBValue(endAt)
item.NextRunAt = timeStringPtrFromDBValue(nextRunAt)
item.LastRunAt = timeStringPtrFromDBValue(lastRunAt)
return item, nil
}
@@ -648,6 +978,80 @@ func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenant
return nil
}
func (s *ScheduleTaskService) validateKolScheduleGenerationInput(ctx context.Context, tenantID, subscriptionPromptID int64, input map[string]interface{}) (map[string]interface{}, error) {
input = normalizeKolScheduleGenerationInput(input)
row, err := repository.NewKolSubscriptionRepository(s.pool).GetSubscriptionPromptByID(ctx, tenantID, subscriptionPromptID)
if err != nil {
return input, response.ErrInternal(50109, "kol_subscription_prompt_query_failed", err.Error())
}
if row == nil {
return input, ErrKolGenerationForbidden
}
now := time.Now()
switch {
case row.Status != "active":
return input, ErrKolGenerationForbidden
case row.SubscriptionStatus != "active":
return input, ErrKolGenerationForbidden
case row.SubscriptionEndAt != nil && row.SubscriptionEndAt.Before(now):
return input, ErrKolGenerationForbidden
case row.PackageStatus != "published":
return input, ErrKolGenerationForbidden
case row.PromptStatus != "active":
return input, ErrKolGenerationForbidden
}
prompt, err := repository.NewKolPromptRepository(s.pool).GetByID(ctx, row.CreatorTenantID, row.PromptID)
if err != nil {
return input, response.ErrInternal(50110, "kol_prompt_query_failed", err.Error())
}
if prompt == nil || prompt.PromptAssetKey == nil || *prompt.PromptAssetKey == "" {
return input, response.ErrInternal(50111, "kol_prompt_content_missing", "published prompt content not found")
}
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return input, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
if err != nil {
return input, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
}
variables, _ := input["variables"].(map[string]interface{})
if variables == nil {
variables = map[string]interface{}{}
input["variables"] = variables
}
missing := make([]string, 0)
for _, variable := range schema.Variables {
_, hasValue, err := resolveKolVariableValue(variables, variable, variable.Key)
if err != nil {
return input, response.ErrBadRequest(40071, "kol_generation_variables_invalid", err.Error())
}
if variable.Required && !hasValue {
missing = append(missing, variable.Label)
}
}
if len(missing) > 0 {
return input, response.ErrBadRequest(40071, "kol_generation_variables_invalid", "missing variables: "+strings.Join(missing, ", "))
}
if extractBool(input, "enable_web_search") && !kolCardConfigAllowsWebSearch(cardConfig) {
return input, response.ErrBadRequest(40073, "kol_generation_web_search_disabled", "web search is disabled for this prompt")
}
knowledgeGroupIDs := extractKnowledgeGroupIDs(input["knowledge_group_ids"])
if len(knowledgeGroupIDs) > 0 && !kolCardConfigAllowsUserKnowledge(cardConfig) {
return input, response.ErrBadRequest(40074, "kol_generation_knowledge_disabled", "knowledge selection is disabled for this prompt")
}
if len(knowledgeGroupIDs) > 0 {
input["knowledge_group_ids"] = knowledgeGroupIDs
} else {
delete(input, "knowledge_group_ids")
}
return input, nil
}
type scheduleAutoPublishConfig struct {
AutoPublish bool
AccountIDs []string
@@ -744,13 +1148,6 @@ func schedulePublishPlatformsRequireCover(platformIDs []string) bool {
return false
}
func resolveScheduleNextRunForStatus(status, cronExpr string, startAt, endAt *time.Time, now time.Time) (*time.Time, error) {
if status != "enabled" {
return nil, nil
}
return sharedschedule.ComputeNextRun(cronExpr, startAt, endAt, now)
}
func validateScheduleCronExpr(cronExpr string) error {
if _, err := sharedschedule.Parse(cronExpr); err != nil {
return response.ErrBadRequest(40015, "invalid_cron_expr", err.Error())
@@ -827,3 +1224,21 @@ func timeStringPtr(value *time.Time) *string {
text := value.Format(time.RFC3339)
return &text
}
func timeStringFromDBValue(value interface{}) string {
if value == nil {
return ""
}
if timestamp, ok := value.(time.Time); ok {
return timestamp.Round(0).Format(time.RFC3339)
}
return fmt.Sprintf("%v", value)
}
func timeStringPtrFromDBValue(value interface{}) *string {
if value == nil {
return nil
}
text := timeStringFromDBValue(value)
return &text
}
+32 -20
View File
@@ -3,24 +3,36 @@ package domain
import "time"
type ScheduleTask struct {
ID int64
TenantID int64
WorkspaceID *int64
OperatorID *int64
PromptRuleID int64
BrandID *int64
Name string
CronExpr string
AutoPublish bool
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
EnableWebSearch bool
GenerateCount int
StartAt *time.Time
EndAt *time.Time
NextRunAt *time.Time
Status string
CreatedAt time.Time
UpdatedAt time.Time
ID int64
TenantID int64
WorkspaceID *int64
OperatorID *int64
TargetType string
PromptRuleID *int64
SubscriptionPromptID *int64
BrandID *int64
Name string
CronExpr string
ScheduleKind string
ScheduleDays []string
ScheduleTimeMode string
RandomWindowStart string
RandomWindowEnd string
GenerationInput map[string]interface{}
AutoPublish bool
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
EnableWebSearch bool
GenerateCount int
StartAt *time.Time
EndAt *time.Time
NextRunAt *time.Time
LastRunAt *time.Time
LastDispatchStatus *string
LastDispatchError *string
ConsecutiveFailures int
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -39,6 +39,14 @@ func (h *ScheduleTaskHandler) List(c *gin.Context) {
params.PromptRuleID = &rid
}
}
if v := c.Query("subscription_prompt_id"); v != "" {
if rid, err := strconv.ParseInt(v, 10, 64); err == nil {
params.SubscriptionPromptID = &rid
}
}
if v := c.Query("target_type"); v != "" {
params.TargetType = &v
}
if v := c.Query("status"); v != "" {
params.Status = &v
}