feat(schedule-tasks): support auto-publish via media accounts

Replace the legacy target_platform string on schedule tasks with a
workspace-aware auto-publish payload (auto_publish + publish_account_ids
JSONB + cover_asset_url + cover_image_asset_id) and migrate the schema,
sqlc queries, generated models, domain struct, ScheduleTaskService DTOs,
and dispatch worker to round-trip the new fields. PromptRuleGeneration
gains a WithPublishJobService hook so executeGeneration can enqueue an
auto-publish job once the article is ready, and worker-generate wires
the publish-job service in. On the admin-web side, extract
PublishArticleModal's account-card builders into a shared
publish-account-cards module, rebuild GenerateTaskDrawer's schedule
mode around account selection plus a CoverPickerModal, and surface the
new "auto publish" column on ScheduleTaskTab. Shared types and i18n
strings cover the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 01:32:19 +08:00
parent c9267d2fae
commit 6e6e19cccb
22 changed files with 1380 additions and 603 deletions
@@ -2,6 +2,7 @@ package scheduler
import (
"context"
"encoding/json"
"sync"
"time"
@@ -37,19 +38,23 @@ type ScheduleDispatchWorker struct {
}
type dueScheduleTask struct {
ID int64
TenantID int64
OperatorID *int64
PromptRuleID int64
BrandID *int64
Name string
CronExpr string
TargetPlatform *string
EnableWebSearch bool
GenerateCount int
StartAt *time.Time
EndAt *time.Time
ScheduledFor time.Time
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
ScheduledFor time.Time
}
type scheduleTaskCacheUpdate struct {
@@ -250,7 +255,8 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT id, tenant_id, operator_id, prompt_rule_id, brand_id, name, cron_expr, target_platform,
SELECT id, tenant_id, workspace_id, operator_id, prompt_rule_id, brand_id, name, cron_expr,
auto_publish, publish_account_ids, cover_asset_url, cover_image_asset_id,
enable_web_search, generate_count, start_at, end_at, next_run_at
FROM schedule_tasks
WHERE deleted_at IS NULL
@@ -273,21 +279,27 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
}, 0, w.batchSize)
for rows.Next() {
var (
task dueScheduleTask
operatorID pgtype.Int8
startAt pgtype.Timestamptz
endAt pgtype.Timestamptz
nextRunAt pgtype.Timestamptz
task dueScheduleTask
workspaceID pgtype.Int8
operatorID pgtype.Int8
publishAccountIDsJSON []byte
startAt pgtype.Timestamptz
endAt pgtype.Timestamptz
nextRunAt pgtype.Timestamptz
)
if err := rows.Scan(
&task.ID,
&task.TenantID,
&workspaceID,
&operatorID,
&task.PromptRuleID,
&task.BrandID,
&task.Name,
&task.CronExpr,
&task.TargetPlatform,
&task.AutoPublish,
&publishAccountIDsJSON,
&task.CoverAssetURL,
&task.CoverImageAssetID,
&task.EnableWebSearch,
&task.GenerateCount,
&startAt,
@@ -300,7 +312,11 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
task.StartAt = timePtrFromTimestamp(startAt)
task.EndAt = timePtrFromTimestamp(endAt)
if workspaceID.Valid {
task.WorkspaceID = workspaceID.Int64
}
task.OperatorID = int64PtrFromInt8(operatorID)
task.PublishAccountIDs = decodeDueSchedulePublishAccountIDs(publishAccountIDsJSON)
if task.GenerateCount <= 0 {
task.GenerateCount = 1
}
@@ -372,16 +388,20 @@ func (w *ScheduleDispatchWorker) dispatch(parent context.Context, task dueSchedu
for idx := 0; idx < generateCount; idx++ {
ctx, cancel := context.WithTimeout(parent, w.timeout)
resp, err := w.promptRuleService.EnqueueScheduledGeneration(ctx, tenantapp.SchedulePromptRuleGenerationInput{
ScheduleTaskID: task.ID,
OperatorID: task.OperatorID,
TenantID: task.TenantID,
PromptRuleID: task.PromptRuleID,
BrandID: task.BrandID,
Name: task.Name,
TargetPlatform: task.TargetPlatform,
EnableWebSearch: task.EnableWebSearch,
GenerateCount: generateCount,
ScheduledFor: task.ScheduledFor,
ScheduleTaskID: task.ID,
OperatorID: task.OperatorID,
TenantID: task.TenantID,
PromptRuleID: task.PromptRuleID,
BrandID: task.BrandID,
Name: task.Name,
WorkspaceID: task.WorkspaceID,
AutoPublish: task.AutoPublish,
PublishAccountIDs: task.PublishAccountIDs,
CoverAssetURL: task.CoverAssetURL,
CoverImageAssetID: task.CoverImageAssetID,
EnableWebSearch: task.EnableWebSearch,
GenerateCount: generateCount,
ScheduledFor: task.ScheduledFor,
})
cancel()
if err != nil {
@@ -522,3 +542,26 @@ func int64PtrFromInt8(value pgtype.Int8) *int64 {
result := value.Int64
return &result
}
func decodeDueSchedulePublishAccountIDs(raw []byte) []string {
if len(raw) == 0 {
return []string{}
}
var values []string
if err := json.Unmarshal(raw, &values); err != nil {
return []string{}
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}