From c6b709053645dc1708cd13bfcebd7ba75095d4e1 Mon Sep 17 00:00:00 2001
From: liangxu
Date: Sun, 14 Jun 2026 21:02:39 +0800
Subject: [PATCH] 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)
---
.../src/components/CoverSourceSelector.vue | 343 ++++++++++++++++++
.../src/components/GenerateTaskDrawer.vue | 263 ++++++++++----
.../src/components/PublishArticleModal.vue | 323 ++++++++++++-----
apps/admin-web/src/i18n/messages/zh-CN.ts | 9 +
packages/shared-types/src/index.ts | 8 +
.../scheduler/schedule_dispatch_worker.go | 104 +++++-
.../tenant/app/schedule_task_policy.go | 4 +
.../tenant/app/schedule_task_service.go | 125 ++++++-
.../internal/tenant/domain/schedule_task.go | 3 +
...4100000_add_schedule_random_cover.down.sql | 9 +
...614100000_add_schedule_random_cover.up.sql | 19 +
11 files changed, 1032 insertions(+), 178 deletions(-)
create mode 100644 apps/admin-web/src/components/CoverSourceSelector.vue
create mode 100644 server/migrations/20260614100000_add_schedule_random_cover.down.sql
create mode 100644 server/migrations/20260614100000_add_schedule_random_cover.up.sql
diff --git a/apps/admin-web/src/components/CoverSourceSelector.vue b/apps/admin-web/src/components/CoverSourceSelector.vue
new file mode 100644
index 0000000..4c690ed
--- /dev/null
+++ b/apps/admin-web/src/components/CoverSourceSelector.vue
@@ -0,0 +1,343 @@
+
+
+
+
+
+
+
+
+
封面模式
+
+
+
+
+
+
+
+
+
图片来源
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('coverSource.randomHint') }}
+
+
+
+
+
+
diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue
index 49e3cdd..3bbe49c 100644
--- a/apps/admin-web/src/components/GenerateTaskDrawer.vue
+++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue
@@ -13,6 +13,8 @@ import type {
KolSubscriptionPromptCard,
KolVariableDefinition,
PromptRuleSimple,
+ ScheduleCoverMode,
+ ScheduleCoverRandomScope,
ScheduleEnterpriseSiteTarget,
ScheduleKind,
ScheduleTargetType,
@@ -24,6 +26,7 @@ import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CoverPickerModal from '@/components/CoverPickerModal.vue'
+import CoverSourceSelector from '@/components/CoverSourceSelector.vue'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import KolDynamicForm from '@/components/kol/KolDynamicForm.vue'
import KolPlatformTags from '@/components/kol/KolPlatformTags.vue'
@@ -113,6 +116,9 @@ const form = reactive({
enterpriseSiteCategoryIds: {} as Record,
enterprisePublishType: 'publish' as SchedulePublishType,
coverEnabled: false,
+ coverMode: 'specific' as ScheduleCoverMode,
+ coverRandomScope: 'all' as ScheduleCoverRandomScope,
+ coverRandomFolderId: null as number | null,
coverAssetUrl: '',
coverFileName: '',
coverImageAssetId: null as number | null,
@@ -469,10 +475,14 @@ function hydrateForm(): void {
enterpriseTargets.forEach((target) => {
void loadEnterpriseCategories(target.site_id)
})
- form.coverAssetUrl = isSchedule.value ? resolveApiURL(task?.cover_asset_url) : ''
+ form.coverMode = isSchedule.value ? (task?.cover_mode ?? 'specific') : 'specific'
+ form.coverRandomScope = isSchedule.value ? (task?.cover_random_scope ?? 'all') : 'all'
+ form.coverRandomFolderId = isSchedule.value ? (task?.cover_random_folder_id ?? null) : null
+ form.coverAssetUrl =
+ isSchedule.value && form.coverMode === 'specific' ? resolveApiURL(task?.cover_asset_url) : ''
form.coverFileName = deriveCoverFileName(form.coverAssetUrl)
form.coverImageAssetId = isSchedule.value ? (task?.cover_image_asset_id ?? null) : null
- form.coverEnabled = Boolean(form.coverAssetUrl)
+ form.coverEnabled = form.coverMode === 'random' || Boolean(form.coverAssetUrl)
form.enableWebSearch = task?.enable_web_search ?? false
form.generateCount = task?.generate_count ?? 1
form.scheduleKind = task?.schedule_kind ?? 'daily'
@@ -505,7 +515,8 @@ function selectDefaultSubscriptionPrompt(): void {
function buildSchedulePayload() {
const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value
- const coverAssetUrl = coverEnabled ? form.coverAssetUrl.trim() : ''
+ const coverMode = coverEnabled ? form.coverMode : 'specific'
+ const coverAssetUrl = coverEnabled && coverMode === 'specific' ? form.coverAssetUrl.trim() : ''
const publishAccountIds =
form.autoPublish && form.publishTargetTab === 'media_accounts' ? form.publishAccountIds : []
const publishEnterpriseSiteTargets =
@@ -530,6 +541,12 @@ function buildSchedulePayload() {
publish_enterprise_site_targets: publishEnterpriseSiteTargets,
cover_asset_url: coverAssetUrl || null,
cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : null,
+ cover_mode: coverMode,
+ cover_random_scope: coverEnabled && coverMode === 'random' ? form.coverRandomScope : 'all',
+ cover_random_folder_id:
+ coverEnabled && coverMode === 'random' && form.coverRandomScope === 'folder'
+ ? form.coverRandomFolderId
+ : null,
enable_web_search: form.targetType === 'prompt_rule' ? form.enableWebSearch : false,
generate_count: form.generateCount,
}
@@ -691,6 +708,7 @@ function handleScheduleCoverPicked(payload: {
form.coverFileName = payload.fileName
form.coverImageAssetId = payload.assetId ?? null
form.coverEnabled = true
+ form.coverMode = 'specific'
}
function handleRemoveScheduleCover(): void {
@@ -699,6 +717,15 @@ function handleRemoveScheduleCover(): void {
form.coverImageAssetId = null
}
+function handleScheduleCoverModeChange(value: ScheduleCoverMode): void {
+ form.coverMode = value
+ if (value === 'random') {
+ form.coverAssetUrl = ''
+ form.coverFileName = ''
+ form.coverImageAssetId = null
+ }
+}
+
function handleScheduleCoverToggle(checked: boolean): void {
if (scheduleCoverRequired.value) {
form.coverEnabled = true
@@ -783,11 +810,21 @@ function validateForm(): boolean {
if (
form.publishTargetTab === 'media_accounts' &&
scheduleCoverRequired.value &&
+ form.coverMode === 'specific' &&
!form.coverAssetUrl.trim()
) {
message.warning(t('custom.messages.missingScheduleCover'))
return false
}
+ if (
+ effectiveScheduleCoverEnabled.value &&
+ form.coverMode === 'random' &&
+ form.coverRandomScope === 'folder' &&
+ !form.coverRandomFolderId
+ ) {
+ message.warning(t('coverSource.randomEmpty'))
+ return false
+ }
}
return true
@@ -1454,32 +1491,52 @@ function normalizeJsonRecord(value: Record): Record
-
-
+
+
+
+
![cover preview]()
+
+
+
+
+
-
-
+
+
+
@@ -2075,71 +2132,125 @@ function normalizeJsonRecord(value: Record): Record>({})
const enterprisePublishType = ref('publish')
const coverEnabled = ref(false)
+const coverMode = ref('specific')
+const coverRandomScope = ref('all')
+const coverRandomFolderId = ref(null)
const coverAssetUrl = ref('')
const coverFileName = ref('')
const coverImageAssetId = ref(null)
@@ -161,6 +168,9 @@ watch(
enterpriseCategoriesBySiteId.value = {}
enterpriseSiteCategoryIds.value = {}
enterprisePublishType.value = 'publish'
+ coverMode.value = 'specific'
+ coverRandomScope.value = 'all'
+ coverRandomFolderId.value = null
coverAssetUrl.value = ''
coverFileName.value = ''
coverImageAssetId.value = null
@@ -418,7 +428,10 @@ const publishModalVisible = computed(() => props.open && !coverPickerOpen.value)
const coverRequired = computed(() => coverUploadRequired(selectedPlatformIds.value))
const effectiveCoverEnabled = computed(() => coverRequired.value || coverEnabled.value)
const normalizedCoverValue = computed(() =>
- effectiveCoverEnabled.value ? coverAssetUrl.value.trim() : '',
+ effectiveCoverEnabled.value && coverMode.value === 'specific' ? coverAssetUrl.value.trim() : '',
+)
+const randomCoverEnabled = computed(
+ () => effectiveCoverEnabled.value && coverMode.value === 'random',
)
const publishMutation = useMutation({
mutationFn: () => runPublishFlow(),
@@ -524,7 +537,7 @@ const okDisabled = computed(() => {
) {
return true
}
- if (coverRequired.value && !normalizedCoverValue.value) {
+ if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
return true
}
if (mandatoryAdmissionBlocking.value) {
@@ -681,12 +694,12 @@ async function runPublishFlow(ackRecordId?: number) {
if (!selectedAccountIds.value.length) {
throw new Error('no_accounts_selected')
}
- if (coverRequired.value && !normalizedCoverValue.value) {
+ if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
throw new Error('cover_required_for_selected_platforms')
}
const freshDetail = await loadLatestPublishArticleDetail()
- const article = await persistCoverIfNeeded(freshDetail)
+ const article = await persistCoverIfNeeded(freshDetail, await resolvePublishCoverOverride())
const articleVersionId = article.current_version_id ?? detailQuery.data.value.current_version_id
if (!articleVersionId) {
throw new Error('invalid_article_version')
@@ -735,7 +748,7 @@ async function runEnterpriseSitePublishFlow(
}
const freshDetail = await loadLatestPublishArticleDetail()
- const article = await persistCoverIfNeeded(freshDetail)
+ const article = await persistCoverIfNeeded(freshDetail, await resolvePublishCoverOverride())
const articleVersionId = article.current_version_id ?? detailQuery.data.value.current_version_id
if (!articleVersionId) {
throw new Error('invalid_article_version')
@@ -860,6 +873,14 @@ function handlePublishError(error: unknown) {
message.warning(t('media.publish.messages.coverRequired'))
return
}
+ if (normalized === 'cover_random_folder_required') {
+ message.warning(t('coverSource.randomEmpty'))
+ return
+ }
+ if (normalized === 'cover_random_empty') {
+ message.warning('随机封面范围内暂无可用图片')
+ return
+ }
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
return
}
@@ -911,12 +932,21 @@ async function handleEnterprisePublishSuccess(
}
}
-async function persistCoverIfNeeded(detail: ArticleDetail): Promise {
- const currentUrl = resolveApiURL(detail.cover_asset_url).trim()
- const nextUrl = normalizedCoverValue.value.trim()
- const currentAssetId = detail.cover_image_asset_id ?? null
+type PublishCoverOverride = {
+ url: string
+ assetId: number | null
+}
- if (currentUrl === nextUrl && currentAssetId === coverImageAssetId.value) {
+async function persistCoverIfNeeded(
+ detail: ArticleDetail,
+ override?: PublishCoverOverride | null,
+): Promise {
+ const currentUrl = resolveApiURL(detail.cover_asset_url).trim()
+ const nextUrl = override ? override.url.trim() : normalizedCoverValue.value.trim()
+ const currentAssetId = detail.cover_image_asset_id ?? null
+ const nextAssetId = override ? override.assetId : coverImageAssetId.value
+
+ if (currentUrl === nextUrl && currentAssetId === nextAssetId) {
return detail
}
@@ -924,10 +954,47 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise {
+ if (!randomCoverEnabled.value) {
+ return null
+ }
+ if (coverRandomScope.value === 'folder' && !coverRandomFolderId.value) {
+ throw new Error('cover_random_folder_required')
+ }
+ const baseParams = {
+ folder_id: coverRandomScope.value === 'folder' ? coverRandomFolderId.value : undefined,
+ page: 1,
+ page_size: 100,
+ }
+ const firstPage = await imagesApi.list(baseParams)
+ if (!firstPage.items.length) {
+ throw new Error('cover_random_empty')
+ }
+ const pageCount = Math.max(1, Math.ceil(firstPage.total / baseParams.page_size))
+ const response =
+ pageCount > 1
+ ? await imagesApi.list({
+ ...baseParams,
+ page: Math.floor(Math.random() * pageCount) + 1,
+ })
+ : firstPage
+ if (!response.items.length) {
+ return {
+ url: firstPage.items[0].url,
+ assetId: firstPage.items[0].id,
+ }
+ }
+ const picked = response.items[Math.floor(Math.random() * response.items.length)]
+ return {
+ url: picked.url,
+ assetId: picked.id,
+ }
+}
+
function successDescription(result: CreatePublishJobResponse): string {
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
@@ -1144,7 +1211,9 @@ function cmsTypeLabel(cmsType: string): string {
}
function normalizeEnterprisePlatformId(cmsType: string): string {
- const normalized = String(cmsType ?? '').trim().toLowerCase()
+ const normalized = String(cmsType ?? '')
+ .trim()
+ .toLowerCase()
switch (normalized) {
case 'pbootcms':
return 'pbootcms'
@@ -1246,6 +1315,7 @@ function handleCoverPicked(payload: {
coverFileName.value = payload.fileName
coverImageAssetId.value = payload.assetId ?? null
coverEnabled.value = true
+ coverMode.value = 'specific'
}
function handleRemoveCover(): void {
@@ -1254,6 +1324,15 @@ function handleRemoveCover(): void {
coverImageAssetId.value = null
}
+function handleCoverModeChange(value: ScheduleCoverMode): void {
+ coverMode.value = value
+ if (value === 'random') {
+ coverAssetUrl.value = ''
+ coverFileName.value = ''
+ coverImageAssetId.value = null
+ }
+}
+
async function handleModalOk(): Promise {
if (okDisabled.value) {
return
@@ -1838,32 +1917,52 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
-
-
+
+
+
+
![cover preview]()
+
+
+
+
+
-
-
+
+
+
@@ -2758,71 +2857,125 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
}
.publish-modal__cover-body {
- display: flex;
- margin-top: 12px;
+ margin-top: 16px;
}
-.publish-modal__cover-preview-wrap {
+.publish-modal__cover-preview-card {
position: relative;
- width: 176px;
- flex: 0 0 176px;
+ width: 200px;
+ height: 112px;
+ border-radius: 10px;
+ overflow: hidden;
+ border: 1px solid #e2e8f0;
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
}
-.publish-modal__cover-preview {
+.publish-modal__cover-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.publish-modal__cover-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
- width: 100%;
- min-height: 124px;
- padding: 0;
- border: 1px dashed #d1d5db;
- border-radius: 8px;
- background: #f9fafb;
- color: #6b7280;
- font-size: 13px;
- gap: 8px;
+ gap: 10px;
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+}
+
+.publish-modal__cover-preview-card:hover .publish-modal__cover-overlay {
+ opacity: 1;
+}
+
+.publish-modal__overlay-btn {
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #0f172a;
+ background: #ffffff;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
+}
+
+.publish-modal__overlay-btn:hover {
+ background: #f1f5f9;
+ transform: scale(1.03);
+}
+
+.publish-modal__overlay-btn--danger {
+ color: #ffffff;
+ background: #ef4444;
+}
+
+.publish-modal__overlay-btn--danger:hover {
+ background: #dc2626;
+}
+
+.publish-modal__cover-upload-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 200px;
+ height: 112px;
+ padding: 12px;
+ border: 1.5px dashed #cbd5e1;
+ border-radius: 10px;
+ background: #f8fafc;
+ color: #64748b;
cursor: pointer;
- overflow: hidden;
transition: all 0.2s ease;
}
-.publish-modal__cover-preview:hover {
- border-color: #6366f1;
- color: #6366f1;
- background: #f5f3ff;
+.publish-modal__cover-upload-placeholder:hover {
+ border-color: #3b82f6;
+ color: #3b82f6;
+ background: #f0f6ff;
}
-.publish-modal__cover-preview img {
- display: block;
- width: 100%;
- height: 124px;
- object-fit: cover;
-}
-
-.publish-modal__cover-plus {
- font-size: 20px;
- line-height: 1;
-}
-
-.publish-modal__cover-remove {
- position: absolute;
- top: 8px;
- right: 8px;
- width: 28px;
- height: 28px;
- border: none;
- border-radius: 999px;
- background: rgba(17, 24, 39, 0.7);
- color: #fff;
+.publish-modal__upload-icon {
+ margin-bottom: 6px;
+ color: #94a3b8;
+ transition: color 0.2s;
display: inline-flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: background 0.2s;
}
-.publish-modal__cover-remove:hover {
- background: rgba(17, 24, 39, 0.9);
+.publish-modal__cover-upload-placeholder:hover .publish-modal__upload-icon {
+ color: #3b82f6;
+}
+
+.publish-modal__upload-text {
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.animate-fade-in {
+ animation: fadeIn 0.2s ease-out forwards;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
@media (max-width: 900px) {
diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts
index c21ab8b..f85ea08 100644
--- a/apps/admin-web/src/i18n/messages/zh-CN.ts
+++ b/apps/admin-web/src/i18n/messages/zh-CN.ts
@@ -69,6 +69,15 @@ const zhCN = {
},
},
},
+ coverSource: {
+ specific: '指定图片',
+ random: '随机图片',
+ randomAll: '全部图片',
+ randomFolder: '指定文件夹',
+ folderPlaceholder: '选择图片文件夹',
+ randomHint: '发布时会从所选范围随机抽取一张封面图。',
+ randomEmpty: '请先选择随机图片文件夹',
+ },
nav: {
workspace: '工作台',
articleCreation: '文章创作',
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index cdde71a..971c436 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -2132,6 +2132,8 @@ export type ScheduleTargetType = 'prompt_rule' | 'kol_subscription_prompt'
export type ScheduleKind = 'daily' | 'weekly'
export type ScheduleTimeMode = 'fixed' | 'random_window'
export type ScheduleDispatchStatus = 'success' | 'failed' | string
+export type ScheduleCoverMode = 'specific' | 'random'
+export type ScheduleCoverRandomScope = 'all' | 'folder'
export interface ScheduleTask {
id: number
@@ -2159,6 +2161,9 @@ export interface ScheduleTask {
auto_publish_platforms: string[]
cover_asset_url: string | null
cover_image_asset_id: number | null
+ cover_mode: ScheduleCoverMode
+ cover_random_scope: ScheduleCoverRandomScope
+ cover_random_folder_id: number | null
enable_web_search: boolean
generate_count: number
start_at: string | null
@@ -2194,6 +2199,9 @@ export interface ScheduleTaskRequest {
publish_enterprise_site_targets?: ScheduleEnterpriseSiteTarget[]
cover_asset_url?: string | null
cover_image_asset_id?: number | null
+ cover_mode?: ScheduleCoverMode
+ cover_random_scope?: ScheduleCoverRandomScope
+ cover_random_folder_id?: number | null
enable_web_search?: boolean
generate_count?: number
start_at?: string
diff --git a/server/internal/scheduler/schedule_dispatch_worker.go b/server/internal/scheduler/schedule_dispatch_worker.go
index 4d545be..3ca72c3 100644
--- a/server/internal/scheduler/schedule_dispatch_worker.go
+++ b/server/internal/scheduler/schedule_dispatch_worker.go
@@ -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
diff --git a/server/internal/tenant/app/schedule_task_policy.go b/server/internal/tenant/app/schedule_task_policy.go
index b404810..a8e3595 100644
--- a/server/internal/tenant/app/schedule_task_policy.go
+++ b/server/internal/tenant/app/schedule_task_policy.go
@@ -14,6 +14,10 @@ import (
const (
ScheduleTargetPromptRule = "prompt_rule"
ScheduleTargetKolSubscriptionPrompt = "kol_subscription_prompt"
+ ScheduleCoverModeSpecific = "specific"
+ ScheduleCoverModeRandom = "random"
+ ScheduleCoverRandomScopeAll = "all"
+ ScheduleCoverRandomScopeFolder = "folder"
scheduleKindDaily = "daily"
scheduleKindWeekly = "weekly"
diff --git a/server/internal/tenant/app/schedule_task_service.go b/server/internal/tenant/app/schedule_task_service.go
index 1e24747..20b4fb6 100644
--- a/server/internal/tenant/app/schedule_task_service.go
+++ b/server/internal/tenant/app/schedule_task_service.go
@@ -58,6 +58,9 @@ type ScheduleTaskRequest struct {
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
+ CoverMode string `json:"cover_mode"`
+ CoverRandomScope string `json:"cover_random_scope"`
+ CoverRandomFolderID *int64 `json:"cover_random_folder_id"`
EnableWebSearch *bool `json:"enable_web_search"`
GenerateCount *int `json:"generate_count"`
StartAt *string `json:"start_at"`
@@ -90,6 +93,9 @@ type ScheduleTaskResponse struct {
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
+ CoverMode string `json:"cover_mode"`
+ CoverRandomScope string `json:"cover_random_scope"`
+ CoverRandomFolderID *int64 `json:"cover_random_folder_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
@@ -212,21 +218,27 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
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,
- publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id, start_at, end_at
+ publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id,
+ cover_mode, cover_random_scope, cover_random_folder_id,
+ start_at, end_at
)
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::jsonb, $21, $22, $23::timestamptz, $24::timestamptz
+ $20::jsonb, $21, $22,
+ $23, $24, $25,
+ $26::timestamptz, $27::timestamptz
)
RETURNING id, created_at, start_at, end_at, status
`, 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.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
+ publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
+ publishConfig.CoverMode, publishConfig.CoverRandomScope, publishConfig.CoverRandomFolderID,
+ 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")
@@ -286,9 +298,11 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
RandomWindowEnd: plan.RandomWindowEnd, GenerationInput: generationInput, AutoPublish: publishConfig.AutoPublish,
PublishAccountIDs: publishConfig.AccountIDs, PublishEnterpriseSiteTargets: publishConfig.EnterpriseTargets,
AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
- CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
- GenerateCount: generateCount,
- Status: status, CreatedAt: timeStringFromDBValue(createdAt),
+ CoverImageAssetID: publishConfig.CoverImageAssetID, CoverMode: publishConfig.CoverMode,
+ CoverRandomScope: publishConfig.CoverRandomScope, CoverRandomFolderID: publishConfig.CoverRandomFolderID,
+ EnableWebSearch: enableWebSearch,
+ GenerateCount: generateCount,
+ Status: status, CreatedAt: timeStringFromDBValue(createdAt),
NextRunAt: timeStringPtr(nextRunAt),
}, nil
}
@@ -350,17 +364,19 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
workspace_id = $15, auto_publish = $16, publish_account_ids = $17::jsonb,
publish_enterprise_site_targets = $18::jsonb,
cover_asset_url = $19, cover_image_asset_id = $20,
- start_at = $21::timestamptz, end_at = $22::timestamptz, operator_id = $23,
+ cover_mode = $21, cover_random_scope = $22, cover_random_folder_id = $23,
+ start_at = $24::timestamptz, end_at = $25::timestamptz, operator_id = $26,
last_dispatch_status = NULL, last_dispatch_error = NULL,
consecutive_failures = 0,
updated_at = NOW()
- WHERE id = $24 AND tenant_id = $25 AND brand_id = $26 AND deleted_at IS NULL
+ WHERE id = $27 AND tenant_id = $28 AND brand_id = $29 AND deleted_at IS NULL
RETURNING status, start_at, end_at
`, 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.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
+ publishConfig.CoverMode, publishConfig.CoverRandomScope, publishConfig.CoverRandomFolderID,
req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
Scan(&status, &startAt, &endAt)
if err != nil {
@@ -503,7 +519,9 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
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.publish_enterprise_site_targets,
- st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
+ st.cover_asset_url, st.cover_image_asset_id,
+ st.cover_mode, st.cover_random_scope, st.cover_random_folder_id,
+ st.enable_web_search, st.generate_count, st.start_at, st.end_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,
@@ -558,7 +576,9 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
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.publish_enterprise_site_targets,
- st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
+ st.cover_asset_url, st.cover_image_asset_id,
+ st.cover_mode, st.cover_random_scope, st.cover_random_folder_id,
+ st.enable_web_search, st.generate_count, st.start_at, st.end_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,
@@ -936,7 +956,9 @@ func scanScheduleTaskResponse(scanner interface {
&item.CronExpr, &item.ScheduleKind, &scheduleDaysJSON, &item.ScheduleTimeMode,
&item.RandomWindowStart, &item.RandomWindowEnd, &generationInputJSON,
&item.AutoPublish, &publishAccountIDsJSON, &publishEnterpriseSiteTargetsJSON,
- &item.CoverAssetURL, &item.CoverImageAssetID, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
+ &item.CoverAssetURL, &item.CoverImageAssetID,
+ &item.CoverMode, &item.CoverRandomScope, &item.CoverRandomFolderID,
+ &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
&nextRunAt, &item.Status, &lastRunAt, &item.LastDispatchStatus,
&item.LastDispatchError, &item.ConsecutiveFailures, &createdAt, &updatedAt,
&item.PromptRuleName, &item.SubscriptionPromptName, &item.SubscriptionPackageName,
@@ -961,6 +983,12 @@ func scanScheduleTaskResponse(scanner interface {
if item.RandomWindowEnd == "" {
item.RandomWindowEnd = defaultRandomWindowEnd
}
+ if item.CoverMode == "" {
+ item.CoverMode = ScheduleCoverModeSpecific
+ }
+ if item.CoverRandomScope == "" {
+ item.CoverRandomScope = ScheduleCoverRandomScopeAll
+ }
item.ScheduleDays = decodeScheduleDayKeys(scheduleDaysJSON)
item.GenerationInput = decodeScheduleGenerationInput(generationInputJSON)
item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON)
@@ -1075,6 +1103,9 @@ type scheduleAutoPublishConfig struct {
EnterpriseTargetsJSON []byte
CoverAssetURL *string
CoverImageAssetID *int64
+ CoverMode string
+ CoverRandomScope string
+ CoverRandomFolderID *int64
}
func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, actor auth.Actor, req ScheduleTaskRequest) (scheduleAutoPublishConfig, error) {
@@ -1086,6 +1117,8 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
AutoPublish: autoPublish,
AccountIDs: accountIDs,
EnterpriseTargets: enterpriseTargets,
+ CoverMode: ScheduleCoverModeSpecific,
+ CoverRandomScope: ScheduleCoverRandomScopeAll,
}
if !autoPublish {
@@ -1105,6 +1138,18 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
if err := validateOptionalPositiveInt64(req.CoverImageAssetID, "cover_image_asset_id"); err != nil {
return config, err
}
+ coverMode, err := normalizeScheduleCoverMode(req.CoverMode)
+ if err != nil {
+ return config, err
+ }
+ randomScope := ScheduleCoverRandomScopeAll
+ var randomFolderID *int64
+ if coverMode == ScheduleCoverModeRandom {
+ randomScope, randomFolderID, err = s.normalizeScheduleCoverRandomSource(ctx, actor.TenantID, req.CoverRandomScope, req.CoverRandomFolderID)
+ if err != nil {
+ return config, err
+ }
+ }
coverURL := strings.TrimSpace(stringPointerValue(req.CoverAssetURL))
if len(accountIDs) > 0 {
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
@@ -1112,7 +1157,7 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
return config, err
}
config.PlatformIDs = normalizePlatformIDs(platformIDs)
- if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
+ if schedulePublishPlatformsRequireCover(platformIDs) && coverMode == ScheduleCoverModeSpecific && coverURL == "" {
return config, response.ErrBadRequest(40025, "cover_required", "已选媒体账号的平台要求封面图,请先设置封面")
}
}
@@ -1133,13 +1178,67 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
config.AccountIDsJSON = accountIDsJSON
config.EnterpriseTargetsJSON = enterpriseTargetsJSON
- if coverURL != "" {
+ config.CoverMode = coverMode
+ config.CoverRandomScope = randomScope
+ config.CoverRandomFolderID = randomFolderID
+ if coverMode == ScheduleCoverModeSpecific && coverURL != "" {
config.CoverAssetURL = &coverURL
config.CoverImageAssetID = req.CoverImageAssetID
}
return config, nil
}
+func normalizeScheduleCoverMode(value string) (string, error) {
+ switch strings.TrimSpace(value) {
+ case "", ScheduleCoverModeSpecific:
+ return ScheduleCoverModeSpecific, nil
+ case ScheduleCoverModeRandom:
+ return ScheduleCoverModeRandom, nil
+ default:
+ return "", response.ErrBadRequest(40076, "invalid_cover_mode", "封面图模式不正确")
+ }
+}
+
+func (s *ScheduleTaskService) normalizeScheduleCoverRandomSource(ctx context.Context, tenantID int64, scope string, folderID *int64) (string, *int64, error) {
+ normalizedScope := strings.TrimSpace(scope)
+ if normalizedScope == "" {
+ normalizedScope = ScheduleCoverRandomScopeAll
+ }
+ switch normalizedScope {
+ case ScheduleCoverRandomScopeAll:
+ return ScheduleCoverRandomScopeAll, nil, nil
+ case ScheduleCoverRandomScopeFolder:
+ if folderID == nil || *folderID <= 0 {
+ return "", nil, response.ErrBadRequest(40077, "cover_random_folder_required", "请选择随机封面图片文件夹")
+ }
+ if err := s.ensureImageFolderExists(ctx, tenantID, *folderID); err != nil {
+ return "", nil, err
+ }
+ return ScheduleCoverRandomScopeFolder, folderID, nil
+ default:
+ return "", nil, response.ErrBadRequest(40078, "invalid_cover_random_scope", "随机封面范围不正确")
+ }
+}
+
+func (s *ScheduleTaskService) ensureImageFolderExists(ctx context.Context, tenantID, folderID int64) error {
+ var exists bool
+ if err := s.pool.QueryRow(ctx, `
+ SELECT EXISTS (
+ SELECT 1
+ FROM image_folders
+ WHERE id = $1
+ AND tenant_id = $2
+ AND deleted_at IS NULL
+ )
+ `, folderID, tenantID).Scan(&exists); err != nil {
+ return response.ErrInternal(50010, "query_failed", "failed to validate image folder")
+ }
+ if !exists {
+ return response.ErrBadRequest(40079, "image_folder_not_found", "随机封面图片文件夹不存在")
+ }
+ return nil
+}
+
func (s *ScheduleTaskService) validateScheduleEnterpriseSiteTargets(ctx context.Context, tenantID, workspaceID int64, targets []ScheduleEnterpriseSiteTarget) error {
for _, target := range targets {
var status string
diff --git a/server/internal/tenant/domain/schedule_task.go b/server/internal/tenant/domain/schedule_task.go
index c0a5a9d..6c80a9a 100644
--- a/server/internal/tenant/domain/schedule_task.go
+++ b/server/internal/tenant/domain/schedule_task.go
@@ -23,6 +23,9 @@ type ScheduleTask struct {
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
+ CoverMode string
+ CoverRandomScope string
+ CoverRandomFolderID *int64
EnableWebSearch bool
GenerateCount int
StartAt *time.Time
diff --git a/server/migrations/20260614100000_add_schedule_random_cover.down.sql b/server/migrations/20260614100000_add_schedule_random_cover.down.sql
new file mode 100644
index 0000000..59a7ce5
--- /dev/null
+++ b/server/migrations/20260614100000_add_schedule_random_cover.down.sql
@@ -0,0 +1,9 @@
+ALTER TABLE schedule_tasks
+ DROP CONSTRAINT IF EXISTS chk_schedule_tasks_cover_random_folder_id,
+ DROP CONSTRAINT IF EXISTS chk_schedule_tasks_cover_random_scope,
+ DROP CONSTRAINT IF EXISTS chk_schedule_tasks_cover_mode;
+
+ALTER TABLE schedule_tasks
+ DROP COLUMN IF EXISTS cover_random_folder_id,
+ DROP COLUMN IF EXISTS cover_random_scope,
+ DROP COLUMN IF EXISTS cover_mode;
diff --git a/server/migrations/20260614100000_add_schedule_random_cover.up.sql b/server/migrations/20260614100000_add_schedule_random_cover.up.sql
new file mode 100644
index 0000000..4d6ae8c
--- /dev/null
+++ b/server/migrations/20260614100000_add_schedule_random_cover.up.sql
@@ -0,0 +1,19 @@
+ALTER TABLE schedule_tasks
+ ADD COLUMN cover_mode VARCHAR(20) NOT NULL DEFAULT 'specific',
+ ADD COLUMN cover_random_scope VARCHAR(20) NOT NULL DEFAULT 'all',
+ ADD COLUMN cover_random_folder_id BIGINT;
+
+ALTER TABLE schedule_tasks
+ ADD CONSTRAINT chk_schedule_tasks_cover_mode
+ CHECK (cover_mode IN ('specific', 'random'));
+
+ALTER TABLE schedule_tasks
+ ADD CONSTRAINT chk_schedule_tasks_cover_random_scope
+ CHECK (cover_random_scope IN ('all', 'folder'));
+
+ALTER TABLE schedule_tasks
+ ADD CONSTRAINT chk_schedule_tasks_cover_random_folder_id
+ CHECK (
+ cover_random_folder_id IS NULL
+ OR cover_random_folder_id > 0
+ );