feat(schedule): support random cover source for scheduled and manual publish
Add a cover mode to schedule tasks so auto-publish can pick a random cover image instead of a fixed one, scoped to all images or a specific folder. - migration: add cover_mode / cover_random_scope / cover_random_folder_id columns and check constraints on schedule_tasks - backend: validate cover mode/scope/folder on create & update; the dispatch worker resolves a random active image (signed asset URL) at enqueue time - frontend: new CoverSourceSelector component wired into GenerateTaskDrawer and PublishArticleModal, plus coverSource i18n strings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
||||
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
|
||||
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
)
|
||||
@@ -60,6 +61,9 @@ type dueScheduleTask struct {
|
||||
PublishEnterpriseSiteTargets []tenantapp.ScheduleEnterpriseSiteTarget
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
CoverMode string
|
||||
CoverRandomScope string
|
||||
CoverRandomFolderID *int64
|
||||
EnableWebSearch bool
|
||||
GenerateCount int
|
||||
StartAt *time.Time
|
||||
@@ -378,6 +382,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
subscription_prompt_id, brand_id, name, cron_expr, schedule_days, schedule_time_mode,
|
||||
random_window_start::text, random_window_end::text, generation_input_json,
|
||||
auto_publish, publish_account_ids, publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id,
|
||||
cover_mode, cover_random_scope, cover_random_folder_id,
|
||||
enable_web_search, generate_count, start_at, end_at, next_run_at
|
||||
FROM schedule_tasks
|
||||
WHERE deleted_at IS NULL
|
||||
@@ -410,6 +415,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
scheduleDaysJSON []byte
|
||||
publishAccountIDsJSON []byte
|
||||
publishEnterpriseSiteTargetsJSON []byte
|
||||
coverRandomFolderID pgtype.Int8
|
||||
startAt pgtype.Timestamptz
|
||||
endAt pgtype.Timestamptz
|
||||
nextRunAt pgtype.Timestamptz
|
||||
@@ -435,6 +441,9 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
&publishEnterpriseSiteTargetsJSON,
|
||||
&task.CoverAssetURL,
|
||||
&task.CoverImageAssetID,
|
||||
&task.CoverMode,
|
||||
&task.CoverRandomScope,
|
||||
&coverRandomFolderID,
|
||||
&task.EnableWebSearch,
|
||||
&task.GenerateCount,
|
||||
&startAt,
|
||||
@@ -460,6 +469,13 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
}
|
||||
task.PublishAccountIDs = decodeDueSchedulePublishAccountIDs(publishAccountIDsJSON)
|
||||
task.PublishEnterpriseSiteTargets = tenantapp.DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
|
||||
task.CoverRandomFolderID = int64PtrFromInt8(coverRandomFolderID)
|
||||
if task.CoverMode == "" {
|
||||
task.CoverMode = tenantapp.ScheduleCoverModeSpecific
|
||||
}
|
||||
if task.CoverRandomScope == "" {
|
||||
task.CoverRandomScope = tenantapp.ScheduleCoverRandomScopeAll
|
||||
}
|
||||
if task.GenerateCount <= 0 {
|
||||
task.GenerateCount = 1
|
||||
}
|
||||
@@ -587,6 +603,10 @@ func (w *ScheduleDispatchWorker) dispatch(parent context.Context, task dueSchedu
|
||||
}
|
||||
|
||||
func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task dueScheduleTask, generateCount int) (*tenantapp.GenerateFromRuleResponse, error) {
|
||||
coverAssetURL, coverImageAssetID, err := w.resolveDueScheduleCover(ctx, task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch task.TargetType {
|
||||
case "", tenantapp.ScheduleTargetPromptRule:
|
||||
if task.PromptRuleID == nil || *task.PromptRuleID <= 0 {
|
||||
@@ -603,8 +623,8 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
||||
AutoPublish: task.AutoPublish,
|
||||
PublishAccountIDs: task.PublishAccountIDs,
|
||||
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
||||
CoverAssetURL: task.CoverAssetURL,
|
||||
CoverImageAssetID: task.CoverImageAssetID,
|
||||
CoverAssetURL: coverAssetURL,
|
||||
CoverImageAssetID: coverImageAssetID,
|
||||
EnableWebSearch: task.EnableWebSearch,
|
||||
GenerateCount: generateCount,
|
||||
ScheduledFor: task.ScheduledFor,
|
||||
@@ -631,8 +651,8 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
||||
AutoPublish: task.AutoPublish,
|
||||
PublishAccountIDs: task.PublishAccountIDs,
|
||||
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
||||
CoverAssetURL: task.CoverAssetURL,
|
||||
CoverImageAssetID: task.CoverImageAssetID,
|
||||
CoverAssetURL: coverAssetURL,
|
||||
CoverImageAssetID: coverImageAssetID,
|
||||
GenerateCount: generateCount,
|
||||
ScheduledFor: task.ScheduledFor,
|
||||
})
|
||||
@@ -648,6 +668,82 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ScheduleDispatchWorker) resolveDueScheduleCover(ctx context.Context, task dueScheduleTask) (*string, *int64, error) {
|
||||
if !task.AutoPublish {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if task.CoverMode != tenantapp.ScheduleCoverModeRandom {
|
||||
return task.CoverAssetURL, task.CoverImageAssetID, nil
|
||||
}
|
||||
image, err := w.pickRandomScheduleCoverImage(ctx, task)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
coverURL := buildScheduleCoverAssetURL(image.ObjectKey, w.assetTokenSecret())
|
||||
return &coverURL, &image.ID, nil
|
||||
}
|
||||
|
||||
type scheduleCoverImage struct {
|
||||
ID int64
|
||||
ObjectKey string
|
||||
}
|
||||
|
||||
func (w *ScheduleDispatchWorker) pickRandomScheduleCoverImage(ctx context.Context, task dueScheduleTask) (scheduleCoverImage, error) {
|
||||
if w == nil || w.pool == nil {
|
||||
return scheduleCoverImage{}, errors.New("schedule random cover lookup unavailable")
|
||||
}
|
||||
|
||||
var row pgxRow
|
||||
if task.CoverRandomScope == tenantapp.ScheduleCoverRandomScopeFolder {
|
||||
if task.CoverRandomFolderID == nil || *task.CoverRandomFolderID <= 0 {
|
||||
return scheduleCoverImage{}, errors.New("schedule random cover folder missing")
|
||||
}
|
||||
row = w.pool.QueryRow(ctx, `
|
||||
SELECT id, object_key
|
||||
FROM image_assets
|
||||
WHERE tenant_id = $1
|
||||
AND folder_id = $2
|
||||
AND status = 'active'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY random()
|
||||
LIMIT 1
|
||||
`, task.TenantID, *task.CoverRandomFolderID)
|
||||
} else {
|
||||
row = w.pool.QueryRow(ctx, `
|
||||
SELECT id, object_key
|
||||
FROM image_assets
|
||||
WHERE tenant_id = $1
|
||||
AND status = 'active'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY random()
|
||||
LIMIT 1
|
||||
`, task.TenantID)
|
||||
}
|
||||
|
||||
var image scheduleCoverImage
|
||||
if err := row.Scan(&image.ID, &image.ObjectKey); err != nil {
|
||||
return scheduleCoverImage{}, errors.New("schedule random cover image not found")
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
type pgxRow interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (w *ScheduleDispatchWorker) assetTokenSecret() string {
|
||||
if w != nil && w.configProvider != nil {
|
||||
if cfg := w.configProvider.Current(); cfg != nil {
|
||||
return strings.TrimSpace(cfg.JWT.Secret)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildScheduleCoverAssetURL(objectKey string, secret string) string {
|
||||
return "/api/public/assets/" + publicasset.SignObjectKey(objectKey, secret)
|
||||
}
|
||||
|
||||
func (w *ScheduleDispatchWorker) recordDispatchResult(ctx context.Context, task dueScheduleTask, generateCount int, failures []error) {
|
||||
if w == nil || w.pool == nil {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user