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 @@ + + + + + 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
-
- + + +
@@ -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) {

-
- + + +
@@ -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 + );