feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Add WordPress as an enterprise-site CMS type alongside pbootcms, and let schedule tasks auto-publish generated articles to enterprise sites. - WordPress connection/publisher service and tests; register wordpress media platform and relax cms_type CHECK constraint - New publish_enterprise_site_targets JSONB column on schedule_tasks with array type constraint; thread targets through scheduler dispatch, prompt/KOL generation, and worker - Admin UI: enterprise-site targets in generate/schedule/publish flows, KOL package form, MediaView, and i18n strings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import ImageUploadProgressOverlay from '@/components/ImageUploadProgressOverlay.
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
colorBgLayout: '#f0f2f5',
|
||||
zIndexPopupBase: 3000,
|
||||
},
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -251,6 +251,13 @@ function enterprisePlatformFallback(platformId: string) {
|
||||
accent: '#0f766e',
|
||||
}
|
||||
}
|
||||
if (platformId === 'wordpress') {
|
||||
return {
|
||||
name: 'WordPress',
|
||||
shortName: 'WP',
|
||||
accent: '#21759b',
|
||||
}
|
||||
}
|
||||
return getPublishPlatformMeta(platformId)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,12 +4,16 @@ import {
|
||||
MinusCircleFilled,
|
||||
PlusOutlined,
|
||||
ScheduleOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type {
|
||||
EnterpriseSiteCategory,
|
||||
EnterpriseSiteConnection,
|
||||
JsonValue,
|
||||
KolSubscriptionPromptCard,
|
||||
KolVariableDefinition,
|
||||
PromptRuleSimple,
|
||||
ScheduleEnterpriseSiteTarget,
|
||||
ScheduleKind,
|
||||
ScheduleTargetType,
|
||||
ScheduleTask,
|
||||
@@ -25,6 +29,7 @@ import KolDynamicForm from '@/components/kol/KolDynamicForm.vue'
|
||||
import KolPlatformTags from '@/components/kol/KolPlatformTags.vue'
|
||||
import PromptRuleModal from '@/components/PromptRuleModal.vue'
|
||||
import {
|
||||
enterpriseSitesApi,
|
||||
generateApi,
|
||||
imagesApi,
|
||||
kolGenerateApi,
|
||||
@@ -67,6 +72,21 @@ const companyStore = useCompanyStore()
|
||||
|
||||
const promptDrawerOpen = ref(false)
|
||||
const coverPickerOpen = ref(false)
|
||||
type SchedulePublishTargetTab = 'media_accounts' | 'enterprise_sites'
|
||||
type SchedulePublishType = 'publish' | 'draft'
|
||||
|
||||
interface ScheduleEnterpriseSiteCard {
|
||||
id: number
|
||||
name: string
|
||||
siteUrl: string
|
||||
cmsType: string
|
||||
status: string
|
||||
statusText: string
|
||||
selectable: boolean
|
||||
disabledReason: string
|
||||
categoryCount: number
|
||||
defaultCategoryId: string
|
||||
}
|
||||
|
||||
const scheduleDayOptions = [
|
||||
{ labelKey: 'custom.schedule.weekdays.mon', value: 'mon' },
|
||||
@@ -87,7 +107,11 @@ const form = reactive({
|
||||
kolEnableWebSearch: false,
|
||||
kolKnowledgeGroupIds: [] as number[],
|
||||
autoPublish: false,
|
||||
publishTargetTab: 'media_accounts' as SchedulePublishTargetTab,
|
||||
publishAccountIds: [] as string[],
|
||||
publishEnterpriseSiteIds: [] as number[],
|
||||
enterpriseSiteCategoryIds: {} as Record<number, string>,
|
||||
enterprisePublishType: 'publish' as SchedulePublishType,
|
||||
coverEnabled: false,
|
||||
coverAssetUrl: '',
|
||||
coverFileName: '',
|
||||
@@ -143,6 +167,12 @@ const accountsQuery = useQuery({
|
||||
queryFn: () => tenantAccountsApi.list(),
|
||||
})
|
||||
|
||||
const enterpriseSitesQuery = useQuery({
|
||||
queryKey: ['tenant', 'enterprise-sites', 'schedule-task-drawer'],
|
||||
enabled: schedulePublishQueryEnabled,
|
||||
queryFn: () => enterpriseSitesApi.list(),
|
||||
})
|
||||
|
||||
const promptOptions = computed(() =>
|
||||
(rulesQuery.data.value ?? []).map((rule: PromptRuleSimple) => ({
|
||||
label: rule.name,
|
||||
@@ -179,8 +209,37 @@ const schedulePublishAccounts = computed<PublishAccountCard[]>(() =>
|
||||
const schedulePublishLoading = computed(
|
||||
() => platformsQuery.isPending.value || accountsQuery.isPending.value,
|
||||
)
|
||||
const scheduleEnterpriseSitesLoading = computed(() => enterpriseSitesQuery.isPending.value)
|
||||
|
||||
const scheduleEnterpriseSites = computed(() => enterpriseSitesQuery.data.value ?? [])
|
||||
|
||||
const scheduleEnterpriseSiteCards = computed<ScheduleEnterpriseSiteCard[]>(() =>
|
||||
scheduleEnterpriseSites.value.map((site) => {
|
||||
const siteName =
|
||||
site.name?.trim() || site.site_name?.trim() || site.site_url?.trim() || '企业站点'
|
||||
const status = enterpriseSiteConnectionStatus(site)
|
||||
const disabledReason = resolveScheduleEnterpriseSiteDisabledReason(site, status)
|
||||
return {
|
||||
id: site.id,
|
||||
name: siteName,
|
||||
siteUrl: site.site_url,
|
||||
cmsType: cmsTypeLabel(site.cms_type),
|
||||
status,
|
||||
statusText: enterpriseSiteStatusLabel(status),
|
||||
selectable: !disabledReason,
|
||||
disabledReason,
|
||||
categoryCount: site.category_count,
|
||||
defaultCategoryId: site.default_scode?.trim() || '',
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const enterpriseCategoriesBySiteId = ref<Record<number, EnterpriseSiteCategory[]>>({})
|
||||
|
||||
const selectedSchedulePublishPlatformIds = computed(() => {
|
||||
if (!form.autoPublish || form.publishTargetTab !== 'media_accounts') {
|
||||
return []
|
||||
}
|
||||
const selected = new Set(form.publishAccountIds)
|
||||
return Array.from(
|
||||
new Set(
|
||||
@@ -241,6 +300,23 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
scheduleEnterpriseSiteCards,
|
||||
(cards) => {
|
||||
if (enterpriseSitesQuery.isPending.value || enterpriseSitesQuery.isFetching.value) {
|
||||
return
|
||||
}
|
||||
if (cards.length === 0) {
|
||||
return
|
||||
}
|
||||
const selectableIds = new Set(cards.filter((card) => card.selectable).map((card) => card.id))
|
||||
form.publishEnterpriseSiteIds = form.publishEnterpriseSiteIds.filter((id) =>
|
||||
selectableIds.has(id),
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(currentBrandId, () => {
|
||||
form.promptRuleId = undefined
|
||||
})
|
||||
@@ -331,6 +407,16 @@ const updateScheduleMutation = useMutation({
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
})
|
||||
|
||||
const syncEnterpriseCategoriesMutation = useMutation({
|
||||
mutationFn: (siteId: number) => enterpriseSitesApi.syncCategories(siteId),
|
||||
onSuccess: (categories, siteId) => {
|
||||
setEnterpriseCategories(siteId, categories)
|
||||
void queryClient.invalidateQueries({ queryKey: ['tenant', 'enterprise-sites'] })
|
||||
message.success(t('custom.messages.enterpriseCategoriesSynced'))
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
})
|
||||
|
||||
const instantGenerateMutation = useMutation({
|
||||
mutationFn: () => generateApi.fromRule(buildInstantPayload()),
|
||||
onSuccess: async () => {
|
||||
@@ -362,7 +448,27 @@ function hydrateForm(): void {
|
||||
form.kolEnableWebSearch = Boolean(task?.generation_input?.enable_web_search)
|
||||
form.kolKnowledgeGroupIds = normalizeNumberList(task?.generation_input?.knowledge_group_ids)
|
||||
form.autoPublish = isSchedule.value ? Boolean(task?.auto_publish) : false
|
||||
form.publishTargetTab =
|
||||
task?.publish_enterprise_site_targets?.length && !task?.publish_account_ids?.length
|
||||
? 'enterprise_sites'
|
||||
: 'media_accounts'
|
||||
form.publishAccountIds = isSchedule.value ? [...(task?.publish_account_ids ?? [])] : []
|
||||
const enterpriseTargets = isSchedule.value ? (task?.publish_enterprise_site_targets ?? []) : []
|
||||
form.publishEnterpriseSiteIds = enterpriseTargets.map((target) => target.site_id)
|
||||
form.enterpriseSiteCategoryIds = enterpriseTargets.reduce<Record<number, string>>(
|
||||
(acc, target) => {
|
||||
acc[target.site_id] = target.category_id
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
form.enterprisePublishType =
|
||||
enterpriseTargets.find((target) => target.publish_type === 'draft')?.publish_type === 'draft'
|
||||
? 'draft'
|
||||
: 'publish'
|
||||
enterpriseTargets.forEach((target) => {
|
||||
void loadEnterpriseCategories(target.site_id)
|
||||
})
|
||||
form.coverAssetUrl = isSchedule.value ? resolveApiURL(task?.cover_asset_url) : ''
|
||||
form.coverFileName = deriveCoverFileName(form.coverAssetUrl)
|
||||
form.coverImageAssetId = isSchedule.value ? (task?.cover_image_asset_id ?? null) : null
|
||||
@@ -400,6 +506,12 @@ function selectDefaultSubscriptionPrompt(): void {
|
||||
function buildSchedulePayload() {
|
||||
const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value
|
||||
const coverAssetUrl = coverEnabled ? form.coverAssetUrl.trim() : ''
|
||||
const publishAccountIds =
|
||||
form.autoPublish && form.publishTargetTab === 'media_accounts' ? form.publishAccountIds : []
|
||||
const publishEnterpriseSiteTargets =
|
||||
form.autoPublish && form.publishTargetTab === 'enterprise_sites'
|
||||
? buildScheduleEnterpriseSiteTargets()
|
||||
: []
|
||||
const basePayload = {
|
||||
name: form.name.trim(),
|
||||
target_type: form.targetType,
|
||||
@@ -414,7 +526,8 @@ function buildSchedulePayload() {
|
||||
random_window_start: '01:00:00',
|
||||
random_window_end: '05:00:00',
|
||||
auto_publish: form.autoPublish,
|
||||
publish_account_ids: form.autoPublish ? form.publishAccountIds : [],
|
||||
publish_account_ids: publishAccountIds,
|
||||
publish_enterprise_site_targets: publishEnterpriseSiteTargets,
|
||||
cover_asset_url: coverAssetUrl || null,
|
||||
cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : null,
|
||||
enable_web_search: form.targetType === 'prompt_rule' ? form.enableWebSearch : false,
|
||||
@@ -476,6 +589,88 @@ function isSchedulePublishAccountSelected(accountId: string): boolean {
|
||||
return form.publishAccountIds.includes(accountId)
|
||||
}
|
||||
|
||||
function buildScheduleEnterpriseSiteTargets(): ScheduleEnterpriseSiteTarget[] {
|
||||
return form.publishEnterpriseSiteIds
|
||||
.map((siteId) => ({
|
||||
site_id: siteId,
|
||||
category_id: form.enterpriseSiteCategoryIds[siteId]?.trim() ?? '',
|
||||
publish_type: form.enterprisePublishType,
|
||||
}))
|
||||
.filter((target) => target.site_id > 0 && target.category_id)
|
||||
}
|
||||
|
||||
function toggleScheduleEnterpriseSite(site: ScheduleEnterpriseSiteCard): void {
|
||||
if (!site.selectable) {
|
||||
return
|
||||
}
|
||||
if (form.publishEnterpriseSiteIds.includes(site.id)) {
|
||||
form.publishEnterpriseSiteIds = form.publishEnterpriseSiteIds.filter((id) => id !== site.id)
|
||||
return
|
||||
}
|
||||
form.publishEnterpriseSiteIds = [...form.publishEnterpriseSiteIds, site.id]
|
||||
if (!form.enterpriseSiteCategoryIds[site.id]) {
|
||||
form.enterpriseSiteCategoryIds = {
|
||||
...form.enterpriseSiteCategoryIds,
|
||||
[site.id]: site.defaultCategoryId,
|
||||
}
|
||||
}
|
||||
void loadEnterpriseCategories(site.id)
|
||||
}
|
||||
|
||||
function isScheduleEnterpriseSiteSelected(siteId: number): boolean {
|
||||
return form.publishEnterpriseSiteIds.includes(siteId)
|
||||
}
|
||||
|
||||
function enterpriseCategoryOptions(siteId: number) {
|
||||
return (enterpriseCategoriesBySiteId.value[siteId] ?? []).map((category) => ({
|
||||
label: enterpriseCategoryLabel(category),
|
||||
value: category.remote_id,
|
||||
}))
|
||||
}
|
||||
|
||||
function enterpriseCategoryLabel(category: EnterpriseSiteCategory): string {
|
||||
const slug = category.slug?.trim()
|
||||
return slug ? `${category.name} / ${slug}` : category.name
|
||||
}
|
||||
|
||||
function setEnterpriseCategories(siteId: number, categories: EnterpriseSiteCategory[]): void {
|
||||
enterpriseCategoriesBySiteId.value = {
|
||||
...enterpriseCategoriesBySiteId.value,
|
||||
[siteId]: categories,
|
||||
}
|
||||
const current = form.enterpriseSiteCategoryIds[siteId]?.trim()
|
||||
const currentExists = categories.some((category) => category.remote_id === current)
|
||||
if ((!current || !currentExists) && categories.length > 0) {
|
||||
form.enterpriseSiteCategoryIds = {
|
||||
...form.enterpriseSiteCategoryIds,
|
||||
[siteId]: categories[0].remote_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnterpriseCategories(siteId: number): Promise<void> {
|
||||
if (enterpriseCategoriesBySiteId.value[siteId]) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const categories = await enterpriseSitesApi.categories(siteId)
|
||||
setEnterpriseCategories(siteId, categories)
|
||||
} catch {
|
||||
enterpriseCategoriesBySiteId.value = {
|
||||
...enterpriseCategoriesBySiteId.value,
|
||||
[siteId]: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncEnterpriseCategories(siteId: number): void {
|
||||
syncEnterpriseCategoriesMutation.mutate(siteId)
|
||||
}
|
||||
|
||||
function enterpriseSelectPopupContainer(triggerNode?: HTMLElement | null): HTMLElement {
|
||||
return (triggerNode?.closest('.ant-drawer-content') as HTMLElement | null) ?? document.body
|
||||
}
|
||||
|
||||
async function uploadScheduleCover(
|
||||
file: File,
|
||||
): Promise<{ url: string; fileName: string; assetId?: number | null }> {
|
||||
@@ -568,11 +763,28 @@ function validateForm(): boolean {
|
||||
}
|
||||
|
||||
if (isSchedule.value && form.autoPublish) {
|
||||
if (form.publishAccountIds.length === 0) {
|
||||
message.warning(t('custom.messages.missingPublishAccounts'))
|
||||
if (form.publishTargetTab === 'media_accounts' && form.publishAccountIds.length === 0) {
|
||||
message.warning(t('custom.messages.missingPublishTargets'))
|
||||
return false
|
||||
}
|
||||
if (scheduleCoverRequired.value && !form.coverAssetUrl.trim()) {
|
||||
if (form.publishTargetTab === 'enterprise_sites') {
|
||||
if (form.publishEnterpriseSiteIds.length === 0) {
|
||||
message.warning(t('custom.messages.missingPublishTargets'))
|
||||
return false
|
||||
}
|
||||
const missingCategorySite = form.publishEnterpriseSiteIds.find(
|
||||
(siteId) => !form.enterpriseSiteCategoryIds[siteId]?.trim(),
|
||||
)
|
||||
if (missingCategorySite) {
|
||||
message.warning(t('custom.messages.missingEnterpriseSiteCategory'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (
|
||||
form.publishTargetTab === 'media_accounts' &&
|
||||
scheduleCoverRequired.value &&
|
||||
!form.coverAssetUrl.trim()
|
||||
) {
|
||||
message.warning(t('custom.messages.missingScheduleCover'))
|
||||
return false
|
||||
}
|
||||
@@ -637,6 +849,47 @@ function normalizeScheduleDays(days?: string[] | null): string[] {
|
||||
.filter((day) => allowed.has(day) && input.includes(day))
|
||||
}
|
||||
|
||||
function cmsTypeLabel(cmsType: string): string {
|
||||
switch (String(cmsType ?? '').trim()) {
|
||||
case 'pbootcms':
|
||||
return 'PBootCMS'
|
||||
case 'wordpress':
|
||||
return 'WordPress'
|
||||
default:
|
||||
return cmsType || 'CMS'
|
||||
}
|
||||
}
|
||||
|
||||
function enterpriseSiteConnectionStatus(site: EnterpriseSiteConnection): string {
|
||||
return String(site.status ?? '').trim()
|
||||
}
|
||||
|
||||
function enterpriseSiteStatusLabel(status: string): string {
|
||||
switch (String(status ?? '').trim()) {
|
||||
case 'connected':
|
||||
return t('custom.task.enterpriseSiteConnected')
|
||||
case 'error':
|
||||
return t('custom.task.enterpriseSiteError')
|
||||
case 'disabled':
|
||||
return t('custom.task.enterpriseSiteDisabled')
|
||||
default:
|
||||
return t('custom.task.enterpriseSiteDraft')
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScheduleEnterpriseSiteDisabledReason(
|
||||
site: EnterpriseSiteConnection,
|
||||
status: string,
|
||||
): string {
|
||||
if (status === 'disabled') {
|
||||
return t('custom.task.enterpriseSiteDisabledHint')
|
||||
}
|
||||
if (status === 'error') {
|
||||
return site.last_error?.trim() || t('custom.task.enterpriseSiteErrorHint')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function fieldKey(key?: string | null, id?: string): string {
|
||||
return key?.trim() || id || ''
|
||||
}
|
||||
@@ -754,7 +1007,7 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
||||
:title="drawerTitle"
|
||||
placement="right"
|
||||
width="1120"
|
||||
:mask-closable="false"
|
||||
:mask-closable="true"
|
||||
class="generate-task-drawer"
|
||||
@close="emit('update:open', false)"
|
||||
>
|
||||
@@ -1001,18 +1254,29 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
||||
<template v-if="form.autoPublish">
|
||||
<div class="generate-task-drawer__field">
|
||||
<label class="generate-task-drawer__label generate-task-drawer__label--required">
|
||||
{{ t('custom.task.publishAccounts') }}:
|
||||
{{ t('custom.task.publishTargets') }}:
|
||||
</label>
|
||||
<p class="generate-task-drawer__hint">{{ t('custom.task.publishAccountsHint') }}</p>
|
||||
<p class="generate-task-drawer__hint">{{ t('custom.task.publishTargetsHint') }}</p>
|
||||
|
||||
<a-segmented
|
||||
v-model:value="form.publishTargetTab"
|
||||
:options="[
|
||||
{ label: t('custom.task.publishTargetMediaAccounts'), value: 'media_accounts' },
|
||||
{ label: t('custom.task.publishTargetEnterpriseSites'), value: 'enterprise_sites' },
|
||||
]"
|
||||
class="generate-task-drawer__target-tabs"
|
||||
/>
|
||||
|
||||
<a-skeleton
|
||||
v-if="schedulePublishLoading"
|
||||
v-if="form.publishTargetTab === 'media_accounts' && schedulePublishLoading"
|
||||
active
|
||||
:title="false"
|
||||
:paragraph="{ rows: 4 }"
|
||||
/>
|
||||
<div
|
||||
v-else-if="schedulePublishAccounts.length"
|
||||
v-else-if="
|
||||
form.publishTargetTab === 'media_accounts' && schedulePublishAccounts.length
|
||||
"
|
||||
class="generate-task-drawer__account-grid"
|
||||
>
|
||||
<button
|
||||
@@ -1057,10 +1321,114 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
||||
</a-tooltip>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="generate-task-drawer__empty">
|
||||
<div
|
||||
v-else-if="form.publishTargetTab === 'media_accounts'"
|
||||
class="generate-task-drawer__empty"
|
||||
>
|
||||
<strong>{{ t('custom.task.publishAccountEmptyTitle') }}</strong>
|
||||
<p>{{ t('custom.task.publishAccountEmptyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<a-skeleton
|
||||
v-if="form.publishTargetTab === 'enterprise_sites' && scheduleEnterpriseSitesLoading"
|
||||
active
|
||||
:title="false"
|
||||
:paragraph="{ rows: 4 }"
|
||||
/>
|
||||
<template v-else-if="form.publishTargetTab === 'enterprise_sites'">
|
||||
<div class="generate-task-drawer__enterprise-toolbar">
|
||||
<span>{{ t('custom.task.enterprisePublishType') }}</span>
|
||||
<a-segmented
|
||||
v-model:value="form.enterprisePublishType"
|
||||
:options="[
|
||||
{ label: t('custom.task.enterprisePublishNow'), value: 'publish' },
|
||||
{ label: t('custom.task.enterprisePublishDraft'), value: 'draft' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="scheduleEnterpriseSiteCards.length"
|
||||
class="generate-task-drawer__account-grid"
|
||||
>
|
||||
<article
|
||||
v-for="site in scheduleEnterpriseSiteCards"
|
||||
:key="site.id"
|
||||
class="generate-task-drawer__site-card"
|
||||
:class="{
|
||||
'generate-task-drawer__site-card--active': isScheduleEnterpriseSiteSelected(
|
||||
site.id,
|
||||
),
|
||||
'generate-task-drawer__site-card--disabled': !site.selectable,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="generate-task-drawer__site-main"
|
||||
:aria-disabled="!site.selectable"
|
||||
@click="toggleScheduleEnterpriseSite(site)"
|
||||
>
|
||||
<span class="generate-task-drawer__account-check">
|
||||
<span v-if="isScheduleEnterpriseSiteSelected(site.id)"></span>
|
||||
</span>
|
||||
<span class="generate-task-drawer__site-avatar">
|
||||
{{ site.name.slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span class="generate-task-drawer__account-copy">
|
||||
<strong>{{ site.name }}</strong>
|
||||
<small>{{ site.cmsType }} · {{ site.siteUrl }}</small>
|
||||
</span>
|
||||
<a-tooltip :title="site.disabledReason || site.statusText" placement="top">
|
||||
<span
|
||||
class="generate-task-drawer__account-status"
|
||||
:class="
|
||||
site.status === 'connected'
|
||||
? 'generate-task-drawer__account-status--immediate'
|
||||
: 'generate-task-drawer__account-status--unavailable'
|
||||
"
|
||||
>
|
||||
{{ site.statusText }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isScheduleEnterpriseSiteSelected(site.id)"
|
||||
class="generate-task-drawer__site-config"
|
||||
@click.stop
|
||||
>
|
||||
<a-select
|
||||
v-model:value="form.enterpriseSiteCategoryIds[site.id]"
|
||||
:options="enterpriseCategoryOptions(site.id)"
|
||||
:loading="syncEnterpriseCategoriesMutation.isPending.value"
|
||||
:placeholder="t('custom.task.enterpriseCategoryPlaceholder')"
|
||||
:get-popup-container="enterpriseSelectPopupContainer"
|
||||
:dropdown-match-select-width="true"
|
||||
popup-class-name="generate-task-drawer__enterprise-select-dropdown"
|
||||
class="generate-task-drawer__site-category-select"
|
||||
@focus="loadEnterpriseCategories(site.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<a-button
|
||||
class="generate-task-drawer__site-sync"
|
||||
:loading="syncEnterpriseCategoriesMutation.isPending.value"
|
||||
@click="syncEnterpriseCategories(site.id)"
|
||||
>
|
||||
<template #icon><SyncOutlined /></template>
|
||||
{{ t('custom.task.enterpriseSyncCategories') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<p v-if="site.disabledReason" class="generate-task-drawer__site-warning">
|
||||
{{ site.disabledReason }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="generate-task-drawer__empty">
|
||||
<strong>{{ t('custom.task.enterpriseSiteEmptyTitle') }}</strong>
|
||||
<p>{{ t('custom.task.enterpriseSiteEmptyHint') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="generate-task-drawer__field">
|
||||
@@ -1419,10 +1787,14 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
||||
|
||||
.generate-task-drawer__account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.generate-task-drawer__target-tabs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.generate-task-drawer__account {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 36px minmax(0, 1fr);
|
||||
@@ -1473,6 +1845,11 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
||||
background: #355dff;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card--active .generate-task-drawer__account-check {
|
||||
border-color: #355dff;
|
||||
background: #355dff;
|
||||
}
|
||||
|
||||
.generate-task-drawer__account-check span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -1553,6 +1930,117 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.generate-task-drawer__enterprise-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.generate-task-drawer__enterprise-toolbar span {
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card {
|
||||
border: 1px solid #e1e8f2;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card:hover:not(.generate-task-drawer__site-card--disabled) {
|
||||
border-color: #b7c7ec;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card--active {
|
||||
border-color: #355dff;
|
||||
background: #f4f7ff;
|
||||
box-shadow: 0 4px 12px rgba(53, 93, 255, 0.08);
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card--disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-main {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 36px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card--disabled .generate-task-drawer__site-main {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #e4e9f2;
|
||||
border-radius: 8px;
|
||||
background: #f2f4f7;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-config {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0 14px 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
z-index: 2;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-card--active .generate-task-drawer__site-config {
|
||||
background: #ffffff;
|
||||
border-color: #bfdbfe;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-category-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-sync {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.generate-task-drawer__site-warning {
|
||||
margin: -4px 14px 14px;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.generate-task-drawer__cover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -54,6 +54,7 @@ interface EnterpriseSiteCard {
|
||||
id: number
|
||||
name: string
|
||||
siteUrl: string
|
||||
cmsTypeId: string
|
||||
cmsType: string
|
||||
status: string
|
||||
statusText: string
|
||||
@@ -265,6 +266,9 @@ const accountCards = computed<PublishAccountCard[]>(() =>
|
||||
const enterpriseSites = computed(() => enterpriseSitesQuery.data.value ?? [])
|
||||
const enterpriseSiteCards = computed<EnterpriseSiteCard[]>(() =>
|
||||
enterpriseSites.value.map((site) => {
|
||||
const siteName =
|
||||
site.name?.trim() || site.site_name?.trim() || site.site_url?.trim() || '企业站点'
|
||||
const cmsType = String(site.cms_type ?? '').trim()
|
||||
const alreadyPublished = successfulEnterpriseSiteIds.value.has(site.id)
|
||||
const activeStatus = activeEnterpriseRecordStatusBySiteId.value.get(site.id)
|
||||
const connectionStatus = enterpriseSiteConnectionStatus(site)
|
||||
@@ -277,9 +281,10 @@ const enterpriseSiteCards = computed<EnterpriseSiteCard[]>(() =>
|
||||
)
|
||||
return {
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
name: siteName,
|
||||
siteUrl: site.site_url,
|
||||
cmsType: cmsTypeLabel(site.cms_type),
|
||||
cmsTypeId: cmsType,
|
||||
cmsType: cmsTypeLabel(cmsType),
|
||||
status: connectionStatus,
|
||||
statusText: alreadyPublished
|
||||
? '已发布'
|
||||
@@ -364,14 +369,18 @@ const selectedEnterpriseSiteCards = computed(() => {
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => detailQuery.data.value?.title || t('article.untitled'))
|
||||
const enterpriseTargetPlatformId = computed(() =>
|
||||
selectedEnterpriseSiteCards.value.length > 0 ? 'pbootcms' : '',
|
||||
const enterpriseTargetPlatformIds = computed(() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
selectedEnterpriseSiteCards.value
|
||||
.map((site) => normalizeEnterprisePlatformId(site.cmsTypeId))
|
||||
.filter((platformId): platformId is string => Boolean(platformId)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const selectedPlatformIds = computed(() =>
|
||||
activeTargetTab.value === 'enterprise_sites'
|
||||
? enterpriseTargetPlatformId.value
|
||||
? [enterpriseTargetPlatformId.value]
|
||||
: []
|
||||
? enterpriseTargetPlatformIds.value
|
||||
: Array.from(new Set(selectedCards.value.map((card) => card.platformId))),
|
||||
)
|
||||
watch([selectedPlatformIds, activeTargetTab], () => {
|
||||
@@ -441,8 +450,8 @@ const publishAdmissionPassed = computed(
|
||||
() => complianceDisplayDecision(publishAdmissionResult.value) === 'pass',
|
||||
)
|
||||
|
||||
function enterpriseSelectPopupContainer(triggerNode: HTMLElement): HTMLElement {
|
||||
return (triggerNode.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
|
||||
function enterpriseSelectPopupContainer(triggerNode?: HTMLElement | null): HTMLElement {
|
||||
return (triggerNode?.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
|
||||
}
|
||||
const mandatoryAdmissionBlocking = computed(
|
||||
() => publishComplianceMandatory.value && !publishAdmissionPassed.value,
|
||||
@@ -878,7 +887,7 @@ async function handleEnterprisePublishSuccess(
|
||||
message: '企业站发布失败',
|
||||
description:
|
||||
formatStoredErrorMessage(failed[0]?.error_message) ||
|
||||
'请检查插件、栏目和 PBootCMS API 配置。',
|
||||
'请检查站点凭证、栏目和 CMS 接口配置。',
|
||||
placement: 'topRight',
|
||||
duration: 6,
|
||||
})
|
||||
@@ -887,7 +896,7 @@ async function handleEnterprisePublishSuccess(
|
||||
message: enterprisePublishType.value === 'draft' ? '企业站草稿已保存' : '企业站发布成功',
|
||||
description:
|
||||
succeeded.length === 1
|
||||
? succeeded[0]?.external_article_url || 'PBootCMS 已返回成功。'
|
||||
? succeeded[0]?.external_article_url || 'CMS 已返回成功。'
|
||||
: `共 ${succeeded.length} 个企业站点已完成。`,
|
||||
placement: 'topRight',
|
||||
duration: 5,
|
||||
@@ -1129,15 +1138,23 @@ function cmsTypeLabel(cmsType: string): string {
|
||||
return 'PBootCMS'
|
||||
case 'wordpress':
|
||||
return 'WordPress'
|
||||
case 'dedecms':
|
||||
return 'DedeCMS'
|
||||
case 'phpcms':
|
||||
return 'PHPCMS'
|
||||
default:
|
||||
return cmsType || 'CMS'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnterprisePlatformId(cmsType: string): string {
|
||||
const normalized = String(cmsType ?? '').trim().toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'pbootcms':
|
||||
return 'pbootcms'
|
||||
case 'wordpress':
|
||||
return 'wordpress'
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
function enterpriseSiteStatusLabel(status: string): string {
|
||||
switch (String(status ?? '').trim()) {
|
||||
case 'connected':
|
||||
@@ -1167,6 +1184,8 @@ function enterpriseSiteStatusColor(status: string): string {
|
||||
function enterpriseCategoryOptions(siteId: number) {
|
||||
return (enterpriseCategoriesBySiteId.value[siteId] ?? []).map((category) => ({
|
||||
label: enterpriseCategoryLabel(category),
|
||||
name: category.name,
|
||||
slug: category.slug?.trim() ?? '',
|
||||
value: category.remote_id,
|
||||
}))
|
||||
}
|
||||
@@ -1182,7 +1201,8 @@ function setEnterpriseCategories(siteId: number, categories: EnterpriseSiteCateg
|
||||
[siteId]: categories,
|
||||
}
|
||||
const current = enterpriseSiteCategoryIds.value[siteId]?.trim()
|
||||
if (!current && categories.length > 0) {
|
||||
const currentExists = categories.some((category) => category.remote_id === current)
|
||||
if ((!current || !currentExists) && categories.length > 0) {
|
||||
enterpriseSiteCategoryIds.value = {
|
||||
...enterpriseSiteCategoryIds.value,
|
||||
[siteId]: categories[0].remote_id,
|
||||
@@ -1618,7 +1638,10 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="enterpriseSiteCards.length" class="publish-modal__list">
|
||||
<div
|
||||
v-if="enterpriseSiteCards.length"
|
||||
class="publish-modal__list publish-modal__list--enterprise"
|
||||
>
|
||||
<article
|
||||
v-for="site in enterpriseSiteCards"
|
||||
:key="site.id"
|
||||
@@ -1758,12 +1781,22 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
:options="enterpriseCategoryOptions(site.id)"
|
||||
:loading="syncEnterpriseCategoriesMutation.isPending.value"
|
||||
:get-popup-container="enterpriseSelectPopupContainer"
|
||||
:dropdown-match-select-width="true"
|
||||
:dropdown-style="{ maxWidth: 'calc(100vw - 96px)' }"
|
||||
popup-class-name="publish-modal__enterprise-select-dropdown"
|
||||
class="publish-modal__enterprise-select"
|
||||
placeholder="选择栏目"
|
||||
@focus="loadEnterpriseCategories(site.id)"
|
||||
@click.stop
|
||||
/>
|
||||
>
|
||||
<template #option="item">
|
||||
<a-tooltip :title="item.label" placement="left">
|
||||
<div class="publish-modal__select-option-text">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-select>
|
||||
<a-button
|
||||
class="publish-modal__sync-btn"
|
||||
:loading="syncEnterpriseCategoriesMutation.isPending.value"
|
||||
@@ -1781,10 +1814,7 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<a-empty
|
||||
v-else
|
||||
description="当前还没有企业自建站点,请先到媒体管理添加 PBootCMS 站点。"
|
||||
/>
|
||||
<a-empty v-else description="当前还没有企业自建站点,请先到媒体管理添加站点连接。" />
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
@@ -2157,6 +2187,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.publish-modal__list--enterprise {
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
align-items: start;
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for list */
|
||||
.publish-modal__list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -2213,6 +2249,7 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: visible;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise:hover:not(.publish-modal__card--disabled):not(
|
||||
@@ -2283,17 +2320,19 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
|
||||
.publish-modal__config-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-modal__enterprise-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-modal__enterprise-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-modal__sync-btn {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2314,6 +2353,24 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
z-index: 3200;
|
||||
}
|
||||
|
||||
:global(.publish-modal__enterprise-select-dropdown .ant-select-item-option) {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
:global(.publish-modal__enterprise-select-dropdown .ant-select-item-option-content) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-modal__select-option-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-modal__site-warning {
|
||||
margin: -4px 16px 14px;
|
||||
color: #b45309;
|
||||
@@ -2454,6 +2511,37 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
border-top-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise .publish-modal__card-identity {
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise .publish-modal__identity-copy {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise .publish-modal__identity-copy strong {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise .publish-modal__identity-copy span {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise .publish-modal__platform-line {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-modal__card--enterprise .publish-modal__platform-text {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.publish-modal__check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -353,6 +353,19 @@ function dispatchStatusMeta(status?: string | null): { label: string; color: str
|
||||
}
|
||||
return { label: t('custom.schedule.notDispatched'), color: 'default' }
|
||||
}
|
||||
|
||||
function schedulePublishTargetsText(record: ScheduleTask): string {
|
||||
const parts: string[] = []
|
||||
const platformText = formatPublishPlatformList(record.auto_publish_platforms)
|
||||
if (platformText !== '--') {
|
||||
parts.push(platformText)
|
||||
}
|
||||
const enterpriseSiteCount = record.publish_enterprise_site_targets?.length ?? 0
|
||||
if (enterpriseSiteCount > 0) {
|
||||
parts.push(t('custom.schedule.enterpriseSiteTargetCount', { count: enterpriseSiteCount }))
|
||||
}
|
||||
return parts.length ? parts.join('、') : '--'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -490,7 +503,7 @@ function dispatchStatusMeta(status?: string | null): { label: string; color: str
|
||||
<a-tag :color="record.auto_publish ? 'blue' : 'default'">
|
||||
{{
|
||||
record.auto_publish
|
||||
? formatPublishPlatformList(record.auto_publish_platforms)
|
||||
? schedulePublishTargetsText(record)
|
||||
: t('custom.schedule.manualPublish')
|
||||
}}
|
||||
</a-tag>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, PictureOutlined } from '@ant-design/icons-vue'
|
||||
import { CloseOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons-vue'
|
||||
import type { CreateKolPackageRequest } from '@geo/shared-types'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
@@ -52,6 +52,13 @@ watch(
|
||||
|
||||
const coverPreviewUrl = computed(() => resolveApiURL(formState.cover_url))
|
||||
const coverFileName = computed(() => deriveCoverFileName(formState.cover_url))
|
||||
const tagOptions = computed(() =>
|
||||
Array.from(new Set(formState.tags.map((tag) => tag.trim()).filter(Boolean))).map((tag) => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
title: null,
|
||||
})),
|
||||
)
|
||||
|
||||
function handleOk() {
|
||||
if (!formState.name.trim()) {
|
||||
@@ -70,6 +77,21 @@ function handleRemoveCover() {
|
||||
formState.cover_url = ''
|
||||
}
|
||||
|
||||
function getModalPopupContainer(triggerNode: HTMLElement): HTMLElement {
|
||||
return (triggerNode.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
|
||||
}
|
||||
|
||||
function preventTagMouseDown(event: MouseEvent): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function handleTagClose(onClose: (event?: MouseEvent) => void, event: MouseEvent): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose(event)
|
||||
}
|
||||
|
||||
function handleCoverConfirmed(payload: { url: string }) {
|
||||
formState.cover_url = resolveApiURL(payload.url)
|
||||
}
|
||||
@@ -111,8 +133,29 @@ async function uploadCover(file: File) {
|
||||
v-model:value="formState.tags"
|
||||
mode="tags"
|
||||
style="width: 100%"
|
||||
:options="tagOptions"
|
||||
:placeholder="t('kol.manage.packageForm.tagsPlaceholder')"
|
||||
/>
|
||||
:get-popup-container="getModalPopupContainer"
|
||||
popup-class-name="kol-package-tags-dropdown"
|
||||
>
|
||||
<template #tagRender="{ label, closable, onClose }">
|
||||
<span class="package-tag" @mousedown="preventTagMouseDown">
|
||||
<span class="package-tag__label">{{ label }}</span>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="package-tag__remove"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="handleTagClose(onClose, $event)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ label }">
|
||||
<span class="package-tag-option">{{ label }}</span>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('kol.manage.packageForm.priceNote')">
|
||||
@@ -240,4 +283,53 @@ async function uploadCover(file: File) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.package-tag {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-end: 4px;
|
||||
padding: 1px 4px 1px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
color: #1f2937;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.package-tag__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-tag__remove {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #667085;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.package-tag__remove:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
:global(.kol-package-tags-dropdown .ant-select-item-option) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:global(.kol-package-tags-dropdown .ant-select-item-option-content),
|
||||
.package-tag-option {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1624,13 +1624,33 @@ const enUS = {
|
||||
'No platform data is available yet. Please verify the Media Management setup first.',
|
||||
publishSection: 'Publish Settings',
|
||||
autoPublish: 'Auto publish',
|
||||
autoPublishHint: 'When enabled, scheduled articles enter the publish queue after generation.',
|
||||
autoPublishHint:
|
||||
'When enabled, scheduled articles publish to selected media accounts or enterprise sites.',
|
||||
publishTargets: 'Publish targets',
|
||||
publishTargetsHint:
|
||||
'Choose desktop media accounts or enterprise sites. At least one target is required.',
|
||||
publishTargetMediaAccounts: 'Media accounts',
|
||||
publishTargetEnterpriseSites: 'Enterprise sites',
|
||||
publishAccounts: 'Media accounts',
|
||||
publishAccountsHint:
|
||||
'Choose specific accounts. Offline clients queue first and continue after reconnecting.',
|
||||
publishAccountEmptyTitle: 'No media accounts',
|
||||
publishAccountEmptyHint:
|
||||
'Bind media accounts in the desktop client before configuring auto publish.',
|
||||
enterprisePublishType: 'Publish type',
|
||||
enterprisePublishNow: 'Publish now',
|
||||
enterprisePublishDraft: 'Save draft',
|
||||
enterpriseCategoryPlaceholder: 'Choose category',
|
||||
enterpriseSyncCategories: 'Sync categories',
|
||||
enterpriseSiteEmptyTitle: 'No enterprise sites',
|
||||
enterpriseSiteEmptyHint:
|
||||
'Add a site connection in Media Management and sync categories first.',
|
||||
enterpriseSiteConnected: 'Connected',
|
||||
enterpriseSiteError: 'Connection error',
|
||||
enterpriseSiteDisabled: 'Disabled',
|
||||
enterpriseSiteDraft: 'Pending check',
|
||||
enterpriseSiteDisabledHint: 'This enterprise site is disabled.',
|
||||
enterpriseSiteErrorHint: 'This enterprise site has a connection error. Test it first.',
|
||||
cover: 'Cover image',
|
||||
coverHint: 'Keep the cover clear, complete, and visually polished.',
|
||||
scheduleCoverHint:
|
||||
@@ -1683,7 +1703,8 @@ const enUS = {
|
||||
'New tasks default to a random time between 01:00 and 05:00 to smooth overnight load. You can edit it before saving.',
|
||||
executionTimeDefault: 'Default staggered time',
|
||||
to: 'to',
|
||||
autoPublishPlatforms: 'Auto publish platforms',
|
||||
autoPublishPlatforms: 'Auto publish targets',
|
||||
enterpriseSiteTargetCount: '{count} enterprise sites',
|
||||
autoPublish: 'Auto publish',
|
||||
manualPublish: 'Manual publish',
|
||||
nextRun: 'Next run',
|
||||
@@ -1775,6 +1796,9 @@ const enUS = {
|
||||
invalidRandomWindow: 'The schedule time configuration is invalid.',
|
||||
missingScheduleDays: 'Select at least one run day.',
|
||||
missingPublishAccounts: 'Choose at least one media account when auto publish is enabled.',
|
||||
missingPublishTargets: 'Choose at least one publish target when auto publish is enabled.',
|
||||
missingEnterpriseSiteCategory: 'Choose an enterprise site category.',
|
||||
enterpriseCategoriesSynced: 'Enterprise site categories synced.',
|
||||
missingScheduleCover: 'A selected media account platform requires a cover image.',
|
||||
missingPromptName: 'Enter a prompt name first.',
|
||||
missingPromptContent: 'Enter prompt content first.',
|
||||
|
||||
@@ -1550,11 +1550,28 @@ const zhCN = {
|
||||
platformEmptyHint: '当前还没有可用的平台数据,请先检查媒体管理配置。',
|
||||
publishSection: '发布设置',
|
||||
autoPublish: '是否自动发布',
|
||||
autoPublishHint: '开启后,定时生成完成的文章会使用指定媒体账号自动进入发布队列。',
|
||||
autoPublishHint: '开启后,定时生成完成的文章会自动发布到选中的媒体账号或企业自建站点。',
|
||||
publishTargets: '发布目标',
|
||||
publishTargetsHint: '可选择桌面媒体账号,也可选择企业自建站点;至少选择一个目标。',
|
||||
publishTargetMediaAccounts: '媒体账号',
|
||||
publishTargetEnterpriseSites: '企业自建站点',
|
||||
publishAccounts: '媒体账号',
|
||||
publishAccountsHint: '请选择具体账号。客户端离线时会先排队,上线后继续发布。',
|
||||
publishAccountEmptyTitle: '暂无可选媒体账号',
|
||||
publishAccountEmptyHint: '请先在桌面端绑定媒体账号,再回到这里配置自动发布。',
|
||||
enterprisePublishType: '发布方式',
|
||||
enterprisePublishNow: '直接发布',
|
||||
enterprisePublishDraft: '保存草稿',
|
||||
enterpriseCategoryPlaceholder: '选择栏目',
|
||||
enterpriseSyncCategories: '同步栏目',
|
||||
enterpriseSiteEmptyTitle: '暂无企业自建站点',
|
||||
enterpriseSiteEmptyHint: '请先到媒体管理添加站点连接,并同步栏目。',
|
||||
enterpriseSiteConnected: '已连通',
|
||||
enterpriseSiteError: '连接异常',
|
||||
enterpriseSiteDisabled: '已禁用',
|
||||
enterpriseSiteDraft: '待校验',
|
||||
enterpriseSiteDisabledHint: '该企业站点已禁用。',
|
||||
enterpriseSiteErrorHint: '该企业站点连接异常,请先测试连通性。',
|
||||
cover: '封面图',
|
||||
coverHint: '请保证封面清晰、美观和完整',
|
||||
scheduleCoverHint: '默认不设置封面。需要时可单独指定,生成完成后会写入文章并用于发布。',
|
||||
@@ -1604,7 +1621,8 @@ const zhCN = {
|
||||
'新建任务会默认随机落在 01:00-05:00,避免凌晨任务集中;你也可以手动修改为固定执行时间。',
|
||||
executionTimeDefault: '默认错峰生成',
|
||||
to: '至',
|
||||
autoPublishPlatforms: '自动发布平台',
|
||||
autoPublishPlatforms: '自动发布目标',
|
||||
enterpriseSiteTargetCount: '企业自建站点 {count} 个',
|
||||
autoPublish: '自动发布',
|
||||
manualPublish: '手动发布',
|
||||
nextRun: '下次执行',
|
||||
@@ -1695,6 +1713,9 @@ const zhCN = {
|
||||
invalidRandomWindow: '执行时间配置不正确',
|
||||
missingScheduleDays: '请至少选择一个执行日',
|
||||
missingPublishAccounts: '开启自动发布后,请至少选择一个媒体账号',
|
||||
missingPublishTargets: '开启自动发布后,请至少选择一个发布目标',
|
||||
missingEnterpriseSiteCategory: '请选择企业自建站点栏目',
|
||||
enterpriseCategoriesSynced: '企业站点栏目已同步',
|
||||
missingScheduleCover: '已选媒体账号的平台要求封面图,请先设置封面',
|
||||
missingPromptName: '请先填写 Prompt 名称',
|
||||
missingPromptContent: '请先填写 Prompt 内容',
|
||||
|
||||
@@ -136,6 +136,8 @@ const errorMessageMap: Record<string, string> = {
|
||||
brand_not_found: '品牌不存在或已删除',
|
||||
keyword_not_found: '关键词不存在或已删除',
|
||||
publish_cover_required: '当前选择的平台要求上传封面图,请先上传封面图',
|
||||
publish_targets_required: '开启自动发布后,请至少选择一个发布目标',
|
||||
invalid_publish_enterprise_site_targets: '企业站点发布目标格式不正确',
|
||||
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
|
||||
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
|
||||
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
|
||||
@@ -264,6 +266,7 @@ const LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN =
|
||||
/^enterprise site category sync failed: /i
|
||||
const LEGACY_PBOOTCMS_HTTP_PATTERN = /^pbootcms\s+\w+\s+returned http \d+$/i
|
||||
const LEGACY_PBOOTCMS_CODE_PATTERN = /^pbootcms\s+\w+\s+failed with code \d+$/i
|
||||
const LEGACY_WORDPRESS_HTTP_PATTERN = /^wordpress\s+.*\s+returned http \d+$/i
|
||||
const LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN = /^failed to .*enterprise site/i
|
||||
const LEGACY_ENTERPRISE_SITE_EXISTS_PATTERN =
|
||||
/^(enterprise site connection already exists|another enterprise site already uses this domain)$/i
|
||||
@@ -321,6 +324,31 @@ function translateRawErrorMessage(raw: string): string | null {
|
||||
if (lower.startsWith('pbootcms ') && lower.includes(' endpoint is unavailable')) {
|
||||
return 'CMS 接口不可用,请确认接口安装路径'
|
||||
}
|
||||
if (lower.startsWith('request wordpress ')) {
|
||||
return 'WordPress 请求失败,请检查站点地址和网络连通性'
|
||||
}
|
||||
if (lower.startsWith('parse wordpress response')) {
|
||||
return 'WordPress 响应解析失败,请确认 REST API 返回 JSON 格式'
|
||||
}
|
||||
if (
|
||||
lower.includes('rest_not_logged_in') ||
|
||||
lower.includes('incorrect_password') ||
|
||||
lower.includes('invalid_username')
|
||||
) {
|
||||
return 'WordPress 认证失败,请使用 WordPress 用户名和 Application Password'
|
||||
}
|
||||
if (lower.startsWith('invalid wordpress site url')) {
|
||||
return '站点域名格式不正确'
|
||||
}
|
||||
if (lower.includes('invalid wordpress category id')) {
|
||||
return 'WordPress 栏目 ID 不正确,请先同步栏目后再发布'
|
||||
}
|
||||
if (LEGACY_WORDPRESS_HTTP_PATTERN.test(normalized)) {
|
||||
return 'WordPress REST API 请求失败,请检查站点权限和接口状态'
|
||||
}
|
||||
if (lower.includes('unsupported cms type')) {
|
||||
return '当前版本暂不支持该 CMS 类型'
|
||||
}
|
||||
if (LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN.test(normalized)) {
|
||||
return '企业站点操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1296,7 +1296,7 @@ export interface PublishRecordListResponse {
|
||||
history_total: number
|
||||
}
|
||||
|
||||
export type EnterpriseSiteCmsType = 'pbootcms' | 'wordpress' | 'dedecms' | 'phpcms'
|
||||
export type EnterpriseSiteCmsType = 'pbootcms' | 'wordpress'
|
||||
export type EnterpriseSiteStatus = 'draft' | 'connected' | 'error' | 'disabled'
|
||||
|
||||
export interface EnterpriseSiteLatestPublishRecord {
|
||||
@@ -1399,6 +1399,12 @@ export interface EnterpriseSitePublishResponse {
|
||||
published_at?: string | null
|
||||
}
|
||||
|
||||
export interface ScheduleEnterpriseSiteTarget {
|
||||
site_id: number
|
||||
category_id: string
|
||||
publish_type?: 'publish' | 'draft' | string
|
||||
}
|
||||
|
||||
export interface PublisherLocalPlatformState {
|
||||
platform_id: string
|
||||
connected: boolean
|
||||
@@ -2149,6 +2155,7 @@ export interface ScheduleTask {
|
||||
generation_input: Record<string, JsonValue>
|
||||
auto_publish: boolean
|
||||
publish_account_ids: string[]
|
||||
publish_enterprise_site_targets: ScheduleEnterpriseSiteTarget[]
|
||||
auto_publish_platforms: string[]
|
||||
cover_asset_url: string | null
|
||||
cover_image_asset_id: number | null
|
||||
@@ -2184,6 +2191,7 @@ export interface ScheduleTaskRequest {
|
||||
generation_input?: Record<string, JsonValue>
|
||||
auto_publish?: boolean
|
||||
publish_account_ids?: string[]
|
||||
publish_enterprise_site_targets?: ScheduleEnterpriseSiteTarget[]
|
||||
cover_asset_url?: string | null
|
||||
cover_image_asset_id?: number | null
|
||||
enable_web_search?: boolean
|
||||
|
||||
@@ -64,6 +64,18 @@ func main() {
|
||||
generationCfg,
|
||||
app.Config().LLM.MaxOutputTokens,
|
||||
).WithCache(app.Cache).WithLogger(app.Logger).WithConfigProvider(generationProvider)
|
||||
publishJobSvc := tenantapp.NewPublishJobServiceWithConfig(
|
||||
app.DB,
|
||||
app.RabbitMQ,
|
||||
app.Redis,
|
||||
app.Logger,
|
||||
app.ConfigStore,
|
||||
).WithCache(app.Cache)
|
||||
enterpriseSiteSvc := tenantapp.NewEnterpriseSiteService(app.DB, app.ConfigStore).
|
||||
WithCache(app.Cache).
|
||||
WithObjectStorage(app.ObjectStorage)
|
||||
promptRuleSvc.WithPublishJobService(publishJobSvc)
|
||||
promptRuleSvc.WithEnterpriseSiteService(enterpriseSiteSvc)
|
||||
kolGenerationSvc := tenantapp.NewKolGenerationService(
|
||||
app.DB,
|
||||
app.KolSubscriptions,
|
||||
|
||||
@@ -74,7 +74,11 @@ func main() {
|
||||
app.Logger,
|
||||
app.ConfigStore,
|
||||
).WithCache(app.Cache)
|
||||
enterpriseSiteSvc := tenantapp.NewEnterpriseSiteService(app.DB, app.ConfigStore).
|
||||
WithCache(app.Cache).
|
||||
WithObjectStorage(app.ObjectStorage)
|
||||
promptRuleSvc.WithPublishJobService(publishJobSvc)
|
||||
promptRuleSvc.WithEnterpriseSiteService(enterpriseSiteSvc)
|
||||
|
||||
imitationSvc := tenantapp.NewArticleImitationService(
|
||||
app.DB,
|
||||
@@ -93,7 +97,10 @@ func main() {
|
||||
app.Logger,
|
||||
generationCfg,
|
||||
app.Config().LLM.MaxOutputTokens,
|
||||
).WithCache(app.Cache).WithConfigProvider(app.ConfigStore)
|
||||
).WithCache(app.Cache).
|
||||
WithPublishJobService(publishJobSvc).
|
||||
WithEnterpriseSiteService(enterpriseSiteSvc).
|
||||
WithConfigProvider(app.ConfigStore)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -40,30 +40,31 @@ type ScheduleDispatchWorker struct {
|
||||
}
|
||||
|
||||
type dueScheduleTask struct {
|
||||
ID int64
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
OperatorID *int64
|
||||
TargetType string
|
||||
PromptRuleID *int64
|
||||
SubscriptionPromptID *int64
|
||||
BrandID *int64
|
||||
Name string
|
||||
CronExpr string
|
||||
ScheduleDays []string
|
||||
ScheduleTimeMode string
|
||||
RandomWindowStart string
|
||||
RandomWindowEnd string
|
||||
GenerationInput map[string]interface{}
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
EnableWebSearch bool
|
||||
GenerateCount int
|
||||
StartAt *time.Time
|
||||
EndAt *time.Time
|
||||
ScheduledFor time.Time
|
||||
ID int64
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
OperatorID *int64
|
||||
TargetType string
|
||||
PromptRuleID *int64
|
||||
SubscriptionPromptID *int64
|
||||
BrandID *int64
|
||||
Name string
|
||||
CronExpr string
|
||||
ScheduleDays []string
|
||||
ScheduleTimeMode string
|
||||
RandomWindowStart string
|
||||
RandomWindowEnd string
|
||||
GenerationInput map[string]interface{}
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
PublishEnterpriseSiteTargets []tenantapp.ScheduleEnterpriseSiteTarget
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
EnableWebSearch bool
|
||||
GenerateCount int
|
||||
StartAt *time.Time
|
||||
EndAt *time.Time
|
||||
ScheduledFor time.Time
|
||||
}
|
||||
|
||||
type scheduleTaskCacheUpdate struct {
|
||||
@@ -376,7 +377,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
SELECT id, tenant_id, workspace_id, operator_id, target_type, prompt_rule_id,
|
||||
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, cover_asset_url, cover_image_asset_id,
|
||||
auto_publish, publish_account_ids, publish_enterprise_site_targets, 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
|
||||
@@ -400,17 +401,18 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
}, 0, runtimeCfg.BatchSize)
|
||||
for rows.Next() {
|
||||
var (
|
||||
task dueScheduleTask
|
||||
workspaceID pgtype.Int8
|
||||
operatorID pgtype.Int8
|
||||
promptRuleID pgtype.Int8
|
||||
subscriptionPromptID pgtype.Int8
|
||||
generationInputJSON []byte
|
||||
scheduleDaysJSON []byte
|
||||
publishAccountIDsJSON []byte
|
||||
startAt pgtype.Timestamptz
|
||||
endAt pgtype.Timestamptz
|
||||
nextRunAt pgtype.Timestamptz
|
||||
task dueScheduleTask
|
||||
workspaceID pgtype.Int8
|
||||
operatorID pgtype.Int8
|
||||
promptRuleID pgtype.Int8
|
||||
subscriptionPromptID pgtype.Int8
|
||||
generationInputJSON []byte
|
||||
scheduleDaysJSON []byte
|
||||
publishAccountIDsJSON []byte
|
||||
publishEnterpriseSiteTargetsJSON []byte
|
||||
startAt pgtype.Timestamptz
|
||||
endAt pgtype.Timestamptz
|
||||
nextRunAt pgtype.Timestamptz
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&task.ID,
|
||||
@@ -430,6 +432,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
&generationInputJSON,
|
||||
&task.AutoPublish,
|
||||
&publishAccountIDsJSON,
|
||||
&publishEnterpriseSiteTargetsJSON,
|
||||
&task.CoverAssetURL,
|
||||
&task.CoverImageAssetID,
|
||||
&task.EnableWebSearch,
|
||||
@@ -456,6 +459,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
||||
task.TargetType = tenantapp.ScheduleTargetPromptRule
|
||||
}
|
||||
task.PublishAccountIDs = decodeDueSchedulePublishAccountIDs(publishAccountIDsJSON)
|
||||
task.PublishEnterpriseSiteTargets = tenantapp.DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
|
||||
if task.GenerateCount <= 0 {
|
||||
task.GenerateCount = 1
|
||||
}
|
||||
@@ -589,20 +593,21 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
||||
return nil, errors.New("schedule prompt_rule target missing prompt_rule_id")
|
||||
}
|
||||
return w.promptRuleService.EnqueueScheduledGeneration(ctx, tenantapp.SchedulePromptRuleGenerationInput{
|
||||
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,
|
||||
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,
|
||||
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
||||
CoverAssetURL: task.CoverAssetURL,
|
||||
CoverImageAssetID: task.CoverImageAssetID,
|
||||
EnableWebSearch: task.EnableWebSearch,
|
||||
GenerateCount: generateCount,
|
||||
ScheduledFor: task.ScheduledFor,
|
||||
})
|
||||
case tenantapp.ScheduleTargetKolSubscriptionPrompt:
|
||||
if w.kolService == nil {
|
||||
@@ -613,22 +618,23 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
||||
}
|
||||
variables, _ := task.GenerationInput["variables"].(map[string]interface{})
|
||||
kolResp, err := w.kolService.EnqueueScheduledGeneration(ctx, tenantapp.ScheduleKolGenerationInput{
|
||||
ScheduleTaskID: task.ID,
|
||||
OperatorID: task.OperatorID,
|
||||
TenantID: task.TenantID,
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
BrandID: task.BrandID,
|
||||
SubscriptionPromptID: *task.SubscriptionPromptID,
|
||||
Name: task.Name,
|
||||
Variables: variables,
|
||||
EnableWebSearch: scheduleGenerationInputBool(task.GenerationInput, "enable_web_search", task.EnableWebSearch),
|
||||
KnowledgeGroupIDs: scheduleGenerationInputInt64s(task.GenerationInput, "knowledge_group_ids"),
|
||||
AutoPublish: task.AutoPublish,
|
||||
PublishAccountIDs: task.PublishAccountIDs,
|
||||
CoverAssetURL: task.CoverAssetURL,
|
||||
CoverImageAssetID: task.CoverImageAssetID,
|
||||
GenerateCount: generateCount,
|
||||
ScheduledFor: task.ScheduledFor,
|
||||
ScheduleTaskID: task.ID,
|
||||
OperatorID: task.OperatorID,
|
||||
TenantID: task.TenantID,
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
BrandID: task.BrandID,
|
||||
SubscriptionPromptID: *task.SubscriptionPromptID,
|
||||
Name: task.Name,
|
||||
Variables: variables,
|
||||
EnableWebSearch: scheduleGenerationInputBool(task.GenerationInput, "enable_web_search", task.EnableWebSearch),
|
||||
KnowledgeGroupIDs: scheduleGenerationInputInt64s(task.GenerationInput, "knowledge_group_ids"),
|
||||
AutoPublish: task.AutoPublish,
|
||||
PublishAccountIDs: task.PublishAccountIDs,
|
||||
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
||||
CoverAssetURL: task.CoverAssetURL,
|
||||
CoverImageAssetID: task.CoverImageAssetID,
|
||||
GenerateCount: generateCount,
|
||||
ScheduledFor: task.ScheduledFor,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -66,10 +66,34 @@ type pbootCMSRequestError struct {
|
||||
routeUnavailable bool
|
||||
}
|
||||
|
||||
type unsupportedCMSPublisher struct {
|
||||
cmsType string
|
||||
}
|
||||
|
||||
func (err *pbootCMSRequestError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
func (p unsupportedCMSPublisher) Ping(context.Context, enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
|
||||
return nil, nil, p.err()
|
||||
}
|
||||
|
||||
func (p unsupportedCMSPublisher) Categories(context.Context, enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
|
||||
return nil, nil, p.err()
|
||||
}
|
||||
|
||||
func (p unsupportedCMSPublisher) Publish(context.Context, enterpriseSiteCredential, cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
|
||||
return nil, nil, p.err()
|
||||
}
|
||||
|
||||
func (p unsupportedCMSPublisher) err() error {
|
||||
cmsType := strings.TrimSpace(p.cmsType)
|
||||
if cmsType == "" {
|
||||
cmsType = "CMS"
|
||||
}
|
||||
return fmt.Errorf("unsupported cms type: %s", cmsType)
|
||||
}
|
||||
|
||||
func newPBootCMSPublisher(client *http.Client) *pbootCMSPublisher {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -45,11 +48,9 @@ type EnterpriseSiteService struct {
|
||||
|
||||
func NewEnterpriseSiteService(pool *pgxpool.Pool, cfg config.Provider) *EnterpriseSiteService {
|
||||
return &EnterpriseSiteService{
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 25 * time.Second,
|
||||
},
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
httpClient: newEnterpriseSiteHTTPClient(cfg),
|
||||
compliance: tenantcompliance.NewService(pool, cfg, nil),
|
||||
}
|
||||
}
|
||||
@@ -344,6 +345,9 @@ func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterprise
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateEnterpriseSiteCMSURL(normalized.SiteURL, normalized.CMSType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, err := encryptEnterpriseSiteCredential(normalized.Secret, s.credentialKeyMaterial())
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
|
||||
@@ -465,6 +469,9 @@ func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req Update
|
||||
if !validEnterpriseSiteStatus(next.Status) {
|
||||
next.Status = "draft"
|
||||
}
|
||||
if err := validateEnterpriseSiteCMSURL(next.SiteURL, next.CMSType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(next.SiteURL, current.SiteURL) {
|
||||
if err := s.ensureEnterpriseSiteURLAvailable(ctx, actor.TenantID, workspaceID, id, next.SiteURL, next.CMSType); err != nil {
|
||||
@@ -1361,8 +1368,10 @@ func (s *EnterpriseSiteService) publisherFor(cmsType string) cmsPublisher {
|
||||
switch strings.TrimSpace(cmsType) {
|
||||
case pbootCMSPlatformID:
|
||||
return newPBootCMSPublisher(s.httpClient)
|
||||
case wordpressPlatformID:
|
||||
return newWordPressPublisher(s.httpClient)
|
||||
default:
|
||||
return newPBootCMSPublisher(s.httpClient)
|
||||
return unsupportedCMSPublisher{cmsType: cmsType}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,7 +1523,7 @@ func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConn
|
||||
return normalizedEnterpriseSiteConnectionRequest{}, err
|
||||
}
|
||||
cmsType := normalizeEnterpriseCMSType(req.CMSType)
|
||||
if cmsType != pbootCMSPlatformID {
|
||||
if !validEnterpriseCMSType(cmsType) {
|
||||
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "当前版本暂不支持该 CMS 类型")
|
||||
}
|
||||
appid := strings.TrimSpace(req.AppID)
|
||||
@@ -1562,6 +1571,13 @@ func normalizeEnterpriseSiteURL(value string) (string, error) {
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名必须使用 http 或 https")
|
||||
}
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
if host == "" {
|
||||
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
|
||||
}
|
||||
if isEnterpriseSiteBlockedHost(host) {
|
||||
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名不可使用内网或系统保留地址")
|
||||
}
|
||||
parsed.Scheme = scheme
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
@@ -1569,10 +1585,130 @@ func normalizeEnterpriseSiteURL(value string) (string, error) {
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func validateEnterpriseSiteCMSURL(siteURL, cmsType string) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(siteURL))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cmsType), wordpressPlatformID) &&
|
||||
strings.EqualFold(parsed.Scheme, "http") &&
|
||||
!isEnterpriseSiteLoopbackHost(parsed.Hostname()) {
|
||||
return response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEnterpriseSiteBlockedHost(host string) bool {
|
||||
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
|
||||
if host == "" {
|
||||
return true
|
||||
}
|
||||
if isEnterpriseSiteLoopbackHost(host) || host == "localhost" {
|
||||
return false
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return isEnterpriseSiteBlockedAddr(addr)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEnterpriseSiteLoopbackHost(host string) bool {
|
||||
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
return err == nil && addr.IsLoopback()
|
||||
}
|
||||
|
||||
func isEnterpriseSiteBlockedAddr(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
if addr.Is4In6() {
|
||||
addr = addr.Unmap()
|
||||
}
|
||||
if addr.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
if addr.IsPrivate() ||
|
||||
addr.IsLinkLocalUnicast() ||
|
||||
addr.IsLinkLocalMulticast() ||
|
||||
addr.IsMulticast() ||
|
||||
addr.IsUnspecified() ||
|
||||
addr.IsInterfaceLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
return enterpriseSiteBlockedAddrPrefix(addr)
|
||||
}
|
||||
|
||||
func enterpriseSiteBlockedAddrPrefix(addr netip.Addr) bool {
|
||||
blockedPrefixes := []netip.Prefix{
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("::/128"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
for _, prefix := range blockedPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newEnterpriseSiteHTTPClient(config.Provider) *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
host = address
|
||||
}
|
||||
if isEnterpriseSiteBlockedHost(host) {
|
||||
return nil, fmt.Errorf("enterprise site target host is blocked")
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteAddr := conn.RemoteAddr()
|
||||
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
|
||||
if addr, ok := netip.AddrFromSlice(tcpAddr.IP); ok && isEnterpriseSiteBlockedAddr(addr) {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("enterprise site target address is blocked")
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: 25 * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEnterpriseCMSType(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func validEnterpriseCMSType(cmsType string) bool {
|
||||
switch strings.TrimSpace(cmsType) {
|
||||
case pbootCMSPlatformID, wordpressPlatformID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOptionalEnterpriseCodePtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
@@ -2486,6 +2622,27 @@ func sanitizeEnterpriseSiteError(err error) string {
|
||||
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " endpoint is unavailable") {
|
||||
return "CMS 接口不可用,请确认接口安装路径"
|
||||
}
|
||||
if strings.HasPrefix(lower, "request wordpress ") {
|
||||
return "WordPress 请求失败,请检查站点地址和网络连通性"
|
||||
}
|
||||
if strings.HasPrefix(lower, "parse wordpress response") {
|
||||
return "WordPress 响应解析失败,请确认 REST API 返回 JSON 格式"
|
||||
}
|
||||
if strings.Contains(lower, "rest_not_logged_in") || strings.Contains(lower, "incorrect_password") || strings.Contains(lower, "invalid_username") {
|
||||
return "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
|
||||
}
|
||||
if strings.HasPrefix(lower, "invalid wordpress site url") {
|
||||
return "站点域名格式不正确"
|
||||
}
|
||||
if strings.Contains(lower, "invalid wordpress category id") {
|
||||
return "WordPress 栏目 ID 不正确,请先同步栏目后再发布"
|
||||
}
|
||||
if strings.HasPrefix(lower, "wordpress ") && strings.Contains(lower, " returned http ") {
|
||||
return "WordPress REST API 请求失败,请检查站点权限和接口状态"
|
||||
}
|
||||
if strings.Contains(lower, "unsupported cms type") {
|
||||
return "当前版本暂不支持该 CMS 类型"
|
||||
}
|
||||
if len([]rune(message)) > 300 {
|
||||
runes := []rune(message)
|
||||
return string(runes[:300])
|
||||
|
||||
@@ -67,6 +67,30 @@ func TestEnterpriseSiteUpdateRealDatabaseRepro(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnterpriseSiteCMSURLRequiresHTTPSForRemoteWordPress(t *testing.T) {
|
||||
if err := validateEnterpriseSiteCMSURL("http://example.com", wordpressPlatformID); err == nil {
|
||||
t.Fatalf("remote wordpress http url should be rejected")
|
||||
}
|
||||
if err := validateEnterpriseSiteCMSURL("http://127.0.0.1:6060", wordpressPlatformID); err != nil {
|
||||
t.Fatalf("local wordpress http url should be allowed for development: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEnterpriseSiteURLRejectsReservedTargets(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"http://169.254.169.254",
|
||||
"http://10.0.0.8",
|
||||
"http://[fe80::1]",
|
||||
} {
|
||||
if _, err := normalizeEnterpriseSiteURL(raw); err == nil {
|
||||
t.Fatalf("reserved site url %q should be rejected", raw)
|
||||
}
|
||||
}
|
||||
if got, err := normalizeEnterpriseSiteURL("http://127.0.0.1:6060"); err != nil || got != "http://127.0.0.1:6060" {
|
||||
t.Fatalf("local loopback url = %q, %v; want allowed", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
|
||||
got := enterpriseSiteDescription(
|
||||
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
|
||||
@@ -452,6 +476,18 @@ func TestEnterpriseSiteNormalizeLegacyPublishErrorStateKeepsPingError(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnterpriseSiteErrorTranslatesWordPressAuthFailure(t *testing.T) {
|
||||
got := sanitizeEnterpriseSiteError(&wordpressRequestError{
|
||||
message: "wordpress wp/v2/users/me returned http 401: rest_not_logged_in: 您目前没有登录。",
|
||||
status: 401,
|
||||
code: "rest_not_logged_in",
|
||||
})
|
||||
want := "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
|
||||
if got != want {
|
||||
t.Fatalf("sanitize error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type enterpriseSiteImageObjectStorage struct {
|
||||
content []byte
|
||||
gotKey string
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const wordpressPlatformID = "wordpress"
|
||||
|
||||
type wordpressPublisher struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type wordpressAPIIndex struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Home string `json:"home"`
|
||||
Namespaces []string `json:"namespaces"`
|
||||
Headers map[string]string `json:"-"`
|
||||
}
|
||||
|
||||
type wordpressCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Parent int64 `json:"parent"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type wordpressPost struct {
|
||||
ID int64 `json:"id"`
|
||||
Link string `json:"link"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type wordpressErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type wordpressRequestError struct {
|
||||
message string
|
||||
status int
|
||||
code string
|
||||
}
|
||||
|
||||
func (err *wordpressRequestError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
func newWordPressPublisher(client *http.Client) *wordpressPublisher {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &wordpressPublisher{client: client}
|
||||
}
|
||||
|
||||
func (p *wordpressPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
|
||||
var payload map[string]any
|
||||
if err := p.request(ctx, http.MethodGet, credential, "", nil, nil, &payload); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var me map[string]any
|
||||
if err := p.request(ctx, http.MethodGet, credential, "wp/v2/users/me", nil, nil, &me); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
capability := &EnterpriseSiteCapability{
|
||||
CMSType: wordpressPlatformID,
|
||||
SiteURL: strings.TrimSpace(firstStringFromMap(payload, "home", "url")),
|
||||
SiteName: stringPointerFromMap(payload, "name"),
|
||||
APIAuth: true,
|
||||
}
|
||||
if capability.SiteURL == "" {
|
||||
capability.SiteURL = credential.SiteURL
|
||||
}
|
||||
safeRaw := map[string]any{
|
||||
"cms_type": wordpressPlatformID,
|
||||
"site_url": capability.SiteURL,
|
||||
"site_name": enterpriseSiteStringPointerValue(capability.SiteName),
|
||||
"api_auth": true,
|
||||
"authenticated": true,
|
||||
}
|
||||
if id := firstStringFromMap(me, "id", "slug", "name"); id != "" {
|
||||
safeRaw["authenticated_user"] = id
|
||||
}
|
||||
return capability, safeRaw, nil
|
||||
}
|
||||
|
||||
func (p *wordpressPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
|
||||
query := url.Values{}
|
||||
query.Set("per_page", "100")
|
||||
query.Set("hide_empty", "false")
|
||||
|
||||
items := make([]EnterpriseSiteCategoryItem, 0)
|
||||
rawItems := make([]map[string]any, 0)
|
||||
for page := 1; page <= 50; page++ {
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
var categories []wordpressCategory
|
||||
resp, err := p.requestWithResponse(ctx, http.MethodGet, credential, "wp/v2/categories", query, nil, &categories)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, category := range categories {
|
||||
if category.ID <= 0 || strings.TrimSpace(category.Name) == "" {
|
||||
continue
|
||||
}
|
||||
raw := map[string]any{
|
||||
"id": category.ID,
|
||||
"count": category.Count,
|
||||
"name": category.Name,
|
||||
"slug": category.Slug,
|
||||
"parent": category.Parent,
|
||||
"link": category.Link,
|
||||
}
|
||||
parentRemoteID := wordpressParentCategoryID(category.Parent)
|
||||
slug := normalizeStringPointer(&category.Slug)
|
||||
items = append(items, EnterpriseSiteCategoryItem{
|
||||
RemoteID: strconv.FormatInt(category.ID, 10),
|
||||
ParentRemoteID: parentRemoteID,
|
||||
Name: strings.TrimSpace(category.Name),
|
||||
Slug: slug,
|
||||
Raw: raw,
|
||||
})
|
||||
rawItems = append(rawItems, raw)
|
||||
}
|
||||
totalPages := wordpressHeaderInt(resp.Header, "X-WP-TotalPages")
|
||||
if totalPages <= 0 || page >= totalPages {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items, rawItems, nil
|
||||
}
|
||||
|
||||
func (p *wordpressPublisher) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
|
||||
categoryID := strings.TrimSpace(req.CategoryID)
|
||||
if categoryID == "" {
|
||||
categoryID = strings.TrimSpace(credential.DefaultScode)
|
||||
}
|
||||
categoryNumber, err := strconv.ParseInt(categoryID, 10, 64)
|
||||
if err != nil || categoryNumber <= 0 {
|
||||
return nil, nil, fmt.Errorf("invalid wordpress category id")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"title": strings.TrimSpace(req.Title),
|
||||
"content": strings.TrimSpace(req.ContentHTML),
|
||||
"status": wordpressStatusFromPublishType(req.PublishType),
|
||||
"categories": []int64{categoryNumber},
|
||||
}
|
||||
if description := strings.TrimSpace(req.Description); description != "" {
|
||||
body["excerpt"] = description
|
||||
}
|
||||
|
||||
var payload wordpressPost
|
||||
if _, err := p.requestWithResponse(ctx, http.MethodPost, credential, "wp/v2/posts", nil, body, &payload); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
raw := map[string]any{
|
||||
"id": payload.ID,
|
||||
"link": payload.Link,
|
||||
"status": payload.Status,
|
||||
}
|
||||
result := &cmsPublishResult{
|
||||
RemoteID: strconv.FormatInt(payload.ID, 10),
|
||||
URL: strings.TrimSpace(payload.Link),
|
||||
Status: strings.TrimSpace(payload.Status),
|
||||
}
|
||||
if result.Status == "" {
|
||||
result.Status = "published"
|
||||
}
|
||||
return result, raw, nil
|
||||
}
|
||||
|
||||
func (p *wordpressPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, path string, query url.Values, body any, out any) error {
|
||||
_, err := p.requestWithResponse(ctx, method, credential, path, query, body, out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *wordpressPublisher) requestWithResponse(ctx context.Context, method string, credential enterpriseSiteCredential, path string, query url.Values, body any, out any) (*http.Response, error) {
|
||||
endpoint, err := wordpressEndpoint(credential.SiteURL, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.EqualFold(endpoint.Scheme, "http") && !isEnterpriseSiteLoopbackHost(endpoint.Hostname()) {
|
||||
return nil, response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS")
|
||||
}
|
||||
if query != nil {
|
||||
requestQuery := endpoint.Query()
|
||||
for key, values := range query {
|
||||
requestQuery.Del(key)
|
||||
for _, value := range values {
|
||||
requestQuery.Add(key, value)
|
||||
}
|
||||
}
|
||||
endpoint.RawQuery = requestQuery.Encode()
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, marshalErr := json.Marshal(body)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("marshal wordpress request: %w", marshalErr)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build wordpress request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "GeoRankly-WordPress/1.0")
|
||||
httpReq.SetBasicAuth(strings.TrimSpace(credential.AppID), strings.TrimSpace(credential.Secret))
|
||||
if body != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request wordpress %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("read wordpress response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp, wordpressHTTPError(path, resp.StatusCode, raw)
|
||||
}
|
||||
if out == nil || len(raw) == 0 || string(raw) == "null" {
|
||||
return resp, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return resp, fmt.Errorf("parse wordpress response: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func wordpressEndpoint(siteURL string, path string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(siteURL))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid wordpress site url")
|
||||
}
|
||||
basePath := strings.TrimRight(parsed.Path, "/")
|
||||
cleanPath := strings.Trim(path, "/")
|
||||
restRoute := "/"
|
||||
if cleanPath != "" {
|
||||
restRoute += cleanPath
|
||||
}
|
||||
parsed.Path = basePath + "/index.php"
|
||||
query := url.Values{}
|
||||
query.Set("rest_route", restRoute)
|
||||
parsed.RawQuery = query.Encode()
|
||||
parsed.Fragment = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func wordpressHTTPError(path string, status int, raw []byte) error {
|
||||
var payload wordpressErrorResponse
|
||||
if err := json.Unmarshal(raw, &payload); err == nil {
|
||||
code := strings.TrimSpace(payload.Code)
|
||||
message := strings.TrimSpace(payload.Message)
|
||||
if code != "" || message != "" {
|
||||
detail := message
|
||||
if code != "" && message != "" {
|
||||
detail = code + ": " + message
|
||||
} else if code != "" {
|
||||
detail = code
|
||||
}
|
||||
return &wordpressRequestError{
|
||||
message: fmt.Sprintf("wordpress %s returned http %d: %s", path, status, detail),
|
||||
status: status,
|
||||
code: code,
|
||||
}
|
||||
}
|
||||
}
|
||||
return &wordpressRequestError{
|
||||
message: fmt.Sprintf("wordpress %s returned http %d", path, status),
|
||||
status: status,
|
||||
}
|
||||
}
|
||||
|
||||
func (err *wordpressRequestError) Status() int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
return err.status
|
||||
}
|
||||
|
||||
func (err *wordpressRequestError) Code() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.code
|
||||
}
|
||||
|
||||
func wordpressHeaderInt(header http.Header, key string) int {
|
||||
value := strings.TrimSpace(header.Get(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
number, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
func wordpressParentCategoryID(parent int64) *string {
|
||||
if parent <= 0 {
|
||||
return nil
|
||||
}
|
||||
value := strconv.FormatInt(parent, 10)
|
||||
return &value
|
||||
}
|
||||
|
||||
func wordpressStatusFromPublishType(publishType string) string {
|
||||
if strings.TrimSpace(publishType) == "draft" {
|
||||
return "draft"
|
||||
}
|
||||
return "publish"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWordPressPublisherCategoriesUsesRESTAPIAndBasicAuth(t *testing.T) {
|
||||
const username = "editor@example.com"
|
||||
const applicationPassword = "abcd efgh ijkl mnop"
|
||||
var gotAuth string
|
||||
var gotPath string
|
||||
var gotQuery string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-WP-TotalPages", "1")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id": 12, "name": "案例", "slug": "case", "parent": 0, "count": 3, "link": "https://example.test/category/case/"}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
publisher := newWordPressPublisher(server.Client())
|
||||
items, raw, err := publisher.Categories(context.Background(), enterpriseSiteCredential{
|
||||
SiteURL: server.URL,
|
||||
AppID: username,
|
||||
Secret: applicationPassword,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sync wordpress categories: %v", err)
|
||||
}
|
||||
|
||||
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+applicationPassword))
|
||||
if gotAuth != wantAuth {
|
||||
t.Fatalf("authorization = %q, want %q", gotAuth, wantAuth)
|
||||
}
|
||||
if gotPath != "/index.php" {
|
||||
t.Fatalf("path = %q, want wordpress categories endpoint", gotPath)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "rest_route=%2Fwp%2Fv2%2Fcategories") ||
|
||||
!strings.Contains(gotQuery, "per_page=100") ||
|
||||
!strings.Contains(gotQuery, "hide_empty=false") {
|
||||
t.Fatalf("query = %q, want wordpress rest_route, per_page and hide_empty", gotQuery)
|
||||
}
|
||||
if len(items) != 1 || items[0].RemoteID != "12" || items[0].Name != "案例" {
|
||||
t.Fatalf("items = %#v, want parsed wordpress category", items)
|
||||
}
|
||||
if len(raw) != 1 || raw[0]["slug"] != "case" {
|
||||
t.Fatalf("raw = %#v, want preserved category payload", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressPublisherPingReturnsSafeRawSummary(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.RawQuery, "rest_route=%2Fwp%2Fv2%2Fusers%2Fme"):
|
||||
_, _ = w.Write([]byte(`{"id": 7, "name": "admin", "email": "admin@example.test", "capabilities": {"administrator": true}}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"name": "Example", "home": "https://example.test", "routes": {"/wp/v2/users/me": {"sensitive": true}}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
publisher := newWordPressPublisher(server.Client())
|
||||
capability, raw, err := publisher.Ping(context.Background(), enterpriseSiteCredential{
|
||||
SiteURL: server.URL,
|
||||
AppID: "editor",
|
||||
Secret: "application-password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ping wordpress: %v", err)
|
||||
}
|
||||
if capability.SiteName == nil || *capability.SiteName != "Example" || !capability.APIAuth {
|
||||
t.Fatalf("capability = %#v, want sanitized wordpress capability", capability)
|
||||
}
|
||||
if raw["api_auth"] != true || raw["authenticated"] != true {
|
||||
t.Fatalf("raw = %#v, want safe auth summary", raw)
|
||||
}
|
||||
if _, ok := raw["routes"]; ok {
|
||||
t.Fatalf("raw leaked wordpress index routes: %#v", raw)
|
||||
}
|
||||
if user, ok := raw["authenticated_user"].(map[string]any); ok || user != nil {
|
||||
t.Fatalf("raw leaked authenticated user body: %#v", raw)
|
||||
}
|
||||
if strings.Contains(fmt.Sprintf("%v", raw), "admin@example.test") {
|
||||
t.Fatalf("raw leaked authenticated user email: %#v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressPublisherPublishCreatesPost(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotQuery string
|
||||
var gotPayload map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.RawQuery
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if _, _, ok := r.BasicAuth(); !ok {
|
||||
t.Fatalf("missing basic auth")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id": 88, "link": "https://example.test/post/88/", "status": "publish"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
publisher := newWordPressPublisher(server.Client())
|
||||
result, raw, err := publisher.Publish(context.Background(), enterpriseSiteCredential{
|
||||
SiteURL: server.URL,
|
||||
AppID: "editor",
|
||||
Secret: "application-password",
|
||||
}, cmsPublishRequest{
|
||||
Title: "测试标题",
|
||||
ContentHTML: "<p>正文</p>",
|
||||
CategoryID: "12",
|
||||
Description: "摘要",
|
||||
PublishType: "draft",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish wordpress post: %v", err)
|
||||
}
|
||||
|
||||
if gotPath != "/index.php" {
|
||||
t.Fatalf("path = %q, want wordpress posts endpoint", gotPath)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "rest_route=%2Fwp%2Fv2%2Fposts") {
|
||||
t.Fatalf("query = %q, want wordpress posts rest_route", gotQuery)
|
||||
}
|
||||
if gotPayload == nil {
|
||||
t.Fatalf("missing request payload")
|
||||
}
|
||||
if gotPayload["title"] != "测试标题" || gotPayload["content"] != "<p>正文</p>" || gotPayload["status"] != "draft" {
|
||||
t.Fatalf("payload = %#v, want wordpress post body", gotPayload)
|
||||
}
|
||||
categories, ok := gotPayload["categories"].([]any)
|
||||
if !ok || len(categories) != 1 {
|
||||
t.Fatalf("categories = %#v, want [12]", gotPayload["categories"])
|
||||
}
|
||||
categoryID, ok := categories[0].(float64)
|
||||
if !ok || categoryID != 12 {
|
||||
t.Fatalf("category id = %#v, want 12", categories[0])
|
||||
}
|
||||
if result.RemoteID != "88" || result.URL != "https://example.test/post/88/" {
|
||||
t.Fatalf("result = %#v, want wordpress post result", result)
|
||||
}
|
||||
if raw["id"] != int64(88) {
|
||||
t.Fatalf("raw = %#v, want saved wordpress response", raw)
|
||||
}
|
||||
}
|
||||
@@ -39,22 +39,23 @@ type KolGenerationSubmitResponse struct {
|
||||
}
|
||||
|
||||
type ScheduleKolGenerationInput struct {
|
||||
ScheduleTaskID int64
|
||||
OperatorID *int64
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
BrandID *int64
|
||||
SubscriptionPromptID int64
|
||||
Name string
|
||||
Variables map[string]any
|
||||
EnableWebSearch bool
|
||||
KnowledgeGroupIDs []int64
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
GenerateCount int
|
||||
ScheduledFor time.Time
|
||||
ScheduleTaskID int64
|
||||
OperatorID *int64
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
BrandID *int64
|
||||
SubscriptionPromptID int64
|
||||
Name string
|
||||
Variables map[string]any
|
||||
EnableWebSearch bool
|
||||
KnowledgeGroupIDs []int64
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
GenerateCount int
|
||||
ScheduledFor time.Time
|
||||
}
|
||||
|
||||
type KolSubscriptionPromptSchemaResponse struct {
|
||||
@@ -465,6 +466,7 @@ func attachKolScheduleTaskInput(input map[string]any, schedule ScheduleKolGenera
|
||||
if schedule.AutoPublish {
|
||||
input["schedule_auto_publish"] = true
|
||||
input["schedule_publish_account_ids"] = schedule.PublishAccountIDs
|
||||
input["schedule_publish_enterprise_site_targets"] = schedule.PublishEnterpriseSiteTargets
|
||||
if schedule.CoverAssetURL != nil {
|
||||
input["schedule_cover_asset_url"] = strings.TrimSpace(*schedule.CoverAssetURL)
|
||||
}
|
||||
|
||||
@@ -25,16 +25,17 @@ import (
|
||||
)
|
||||
|
||||
type PromptRuleGenerationService struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
rabbitMQ *rabbitmq.Client
|
||||
knowledge *KnowledgeService
|
||||
streams *stream.GenerationHub
|
||||
cfg generationRuntimeConfig
|
||||
configProvider runtimeConfigProvider
|
||||
cache sharedcache.Cache
|
||||
publishJobs *PublishJobService
|
||||
logger *zap.Logger
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
rabbitMQ *rabbitmq.Client
|
||||
knowledge *KnowledgeService
|
||||
streams *stream.GenerationHub
|
||||
cfg generationRuntimeConfig
|
||||
configProvider runtimeConfigProvider
|
||||
cache sharedcache.Cache
|
||||
publishJobs *PublishJobService
|
||||
enterpriseSites *EnterpriseSiteService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type promptRuleGenerationJob struct {
|
||||
@@ -72,20 +73,21 @@ type GenerateFromRuleItem struct {
|
||||
}
|
||||
|
||||
type SchedulePromptRuleGenerationInput struct {
|
||||
ScheduleTaskID int64
|
||||
OperatorID *int64
|
||||
TenantID int64
|
||||
PromptRuleID int64
|
||||
BrandID *int64
|
||||
Name string
|
||||
WorkspaceID int64
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
EnableWebSearch bool
|
||||
GenerateCount int
|
||||
ScheduledFor time.Time
|
||||
ScheduleTaskID int64
|
||||
OperatorID *int64
|
||||
TenantID int64
|
||||
PromptRuleID int64
|
||||
BrandID *int64
|
||||
Name string
|
||||
WorkspaceID int64
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
EnableWebSearch bool
|
||||
GenerateCount int
|
||||
ScheduledFor time.Time
|
||||
}
|
||||
|
||||
type promptRuleGenerationRecord struct {
|
||||
@@ -143,6 +145,11 @@ func (s *PromptRuleGenerationService) WithPublishJobService(publishJobs *Publish
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) WithEnterpriseSiteService(enterpriseSites *EnterpriseSiteService) *PromptRuleGenerationService {
|
||||
s.enterpriseSites = enterpriseSites
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) WithLogger(logger *zap.Logger) *PromptRuleGenerationService {
|
||||
s.logger = logger
|
||||
return s
|
||||
@@ -232,6 +239,7 @@ func (s *PromptRuleGenerationService) EnqueueScheduledGeneration(ctx context.Con
|
||||
if input.AutoPublish {
|
||||
extra["schedule_auto_publish"] = true
|
||||
extra["schedule_publish_account_ids"] = input.PublishAccountIDs
|
||||
extra["schedule_publish_enterprise_site_targets"] = input.PublishEnterpriseSiteTargets
|
||||
if input.CoverAssetURL != nil {
|
||||
extra["schedule_cover_asset_url"] = strings.TrimSpace(*input.CoverAssetURL)
|
||||
}
|
||||
@@ -323,6 +331,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
|
||||
if extractBool(extra, "schedule_auto_publish") {
|
||||
inputParams["schedule_auto_publish"] = true
|
||||
inputParams["schedule_publish_account_ids"] = extractStringList(extra["schedule_publish_account_ids"], 64)
|
||||
inputParams["schedule_publish_enterprise_site_targets"] = extractScheduleEnterpriseSiteTargets(extra["schedule_publish_enterprise_site_targets"], scheduleEnterpriseSiteTargetLimit)
|
||||
if coverURL := strings.TrimSpace(extractString(extra, "schedule_cover_asset_url")); coverURL != "" {
|
||||
inputParams["schedule_cover_asset_url"] = coverURL
|
||||
}
|
||||
@@ -635,69 +644,26 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
||||
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
||||
}
|
||||
|
||||
s.enqueueScheduleAutoPublish(cleanupCtx, job, title)
|
||||
s.enqueueScheduleAutoPublish(job, title)
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) enqueueScheduleAutoPublish(ctx context.Context, job promptRuleGenerationJob, title string) {
|
||||
if s == nil || s.publishJobs == nil || !extractBool(job.InputParams, "schedule_auto_publish") {
|
||||
func (s *PromptRuleGenerationService) enqueueScheduleAutoPublish(job promptRuleGenerationJob, title string) {
|
||||
if s == nil || !extractBool(job.InputParams, "schedule_auto_publish") {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := int64(extractInt(job.InputParams, "workspace_id"))
|
||||
accountIDs := extractStringList(job.InputParams["schedule_publish_account_ids"], 64)
|
||||
coverURL := strings.TrimSpace(extractString(job.InputParams, "schedule_cover_asset_url"))
|
||||
if workspaceID <= 0 || job.OperatorID <= 0 || len(accountIDs) == 0 {
|
||||
s.logAutoPublishWarn("schedule auto publish skipped because configuration is incomplete", nil,
|
||||
zap.Int64("generation_task_id", job.TaskID),
|
||||
zap.Int64("article_id", job.ArticleID),
|
||||
zap.Int64("workspace_id", workspaceID),
|
||||
zap.Int("account_count", len(accountIDs)),
|
||||
zap.Bool("has_cover", coverURL != ""),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
accounts := make([]CreatePublishJobAccountRequest, 0, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, CreatePublishJobAccountRequest{AccountID: accountID})
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
publishTitle := strings.TrimSpace(title)
|
||||
if publishTitle == "" {
|
||||
publishTitle = "定时生成文章"
|
||||
}
|
||||
|
||||
brandID := int64(extractInt(job.InputParams, "brand_id"))
|
||||
publishCtx := ctx
|
||||
if brandID > 0 {
|
||||
publishCtx = auth.WithCurrentBrandID(ctx, brandID)
|
||||
}
|
||||
|
||||
if _, err := s.publishJobs.Create(publishCtx, auth.Actor{
|
||||
TenantID: job.TenantID,
|
||||
UserID: job.OperatorID,
|
||||
PrimaryWorkspaceID: workspaceID,
|
||||
}, CreatePublishJobRequest{
|
||||
Title: publishTitle,
|
||||
ContentRef: map[string]any{
|
||||
"article_id": job.ArticleID,
|
||||
},
|
||||
Accounts: accounts,
|
||||
}); err != nil {
|
||||
s.logAutoPublishWarn("schedule auto publish enqueue failed", err,
|
||||
zap.Int64("generation_task_id", job.TaskID),
|
||||
zap.Int64("article_id", job.ArticleID),
|
||||
zap.Int64("workspace_id", workspaceID),
|
||||
zap.Int("account_count", len(accounts)),
|
||||
)
|
||||
}
|
||||
StartScheduleAutoPublish(ScheduleAutoPublishInput{
|
||||
TenantID: job.TenantID,
|
||||
WorkspaceID: int64(extractInt(job.InputParams, "workspace_id")),
|
||||
OperatorID: job.OperatorID,
|
||||
BrandID: int64(extractInt(job.InputParams, "brand_id")),
|
||||
ArticleID: job.ArticleID,
|
||||
GenerationTaskID: job.TaskID,
|
||||
Title: title,
|
||||
InputParams: job.InputParams,
|
||||
PublishJobs: s.publishJobs,
|
||||
EnterpriseSites: s.enterpriseSites,
|
||||
Logger: s.logger,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) logAutoPublishWarn(message string, err error, fields ...zap.Field) {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
scheduleEnterpriseSiteTargetLimit = 64
|
||||
scheduleAutoPublishTimeout = 15 * time.Minute
|
||||
scheduleEnterpriseSiteTargetTimeout = 45 * time.Second
|
||||
scheduleEnterpriseSitePublishParallel = 4
|
||||
)
|
||||
|
||||
var runScheduleAutoPublishBackground = func(fn func()) {
|
||||
go fn()
|
||||
}
|
||||
|
||||
type ScheduleEnterpriseSiteTarget struct {
|
||||
SiteID int64 `json:"site_id"`
|
||||
CategoryID string `json:"category_id"`
|
||||
PublishType string `json:"publish_type,omitempty"`
|
||||
}
|
||||
|
||||
type ScheduleAutoPublishInput struct {
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
OperatorID int64
|
||||
BrandID int64
|
||||
ArticleID int64
|
||||
GenerationTaskID int64
|
||||
Title string
|
||||
InputParams map[string]interface{}
|
||||
PublishJobs *PublishJobService
|
||||
EnterpriseSites *EnterpriseSiteService
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
func StartScheduleAutoPublish(input ScheduleAutoPublishInput) {
|
||||
if !extractBool(input.InputParams, "schedule_auto_publish") {
|
||||
return
|
||||
}
|
||||
runScheduleAutoPublishBackground(func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule auto publish panic recovered", nil,
|
||||
zap.Any("panic", recovered),
|
||||
zap.ByteString("stack", debug.Stack()),
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scheduleAutoPublishTimeout)
|
||||
defer cancel()
|
||||
RunScheduleAutoPublish(ctx, input)
|
||||
})
|
||||
}
|
||||
|
||||
func RunScheduleAutoPublish(ctx context.Context, input ScheduleAutoPublishInput) {
|
||||
if !extractBool(input.InputParams, "schedule_auto_publish") {
|
||||
return
|
||||
}
|
||||
|
||||
accountIDs := normalizeSchedulePublishAccountIDs(extractStringList(input.InputParams["schedule_publish_account_ids"], 64))
|
||||
enterpriseTargets := extractScheduleEnterpriseSiteTargets(input.InputParams["schedule_publish_enterprise_site_targets"], scheduleEnterpriseSiteTargetLimit)
|
||||
if len(accountIDs) == 0 && len(enterpriseTargets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if input.WorkspaceID <= 0 || input.OperatorID <= 0 || input.ArticleID <= 0 {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule auto publish skipped because configuration is incomplete", nil,
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int64("workspace_id", input.WorkspaceID),
|
||||
zap.Int64("operator_id", input.OperatorID),
|
||||
zap.Int("account_count", len(accountIDs)),
|
||||
zap.Int("enterprise_site_count", len(enterpriseTargets)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
brandID := input.BrandID
|
||||
if brandID <= 0 {
|
||||
brandID = int64(extractInt(input.InputParams, "brand_id"))
|
||||
}
|
||||
if brandID <= 0 {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule auto publish skipped because brand context is missing", nil,
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
publishTitle := strings.TrimSpace(input.Title)
|
||||
if publishTitle == "" {
|
||||
publishTitle = strings.TrimSpace(extractString(input.InputParams, "task_name"))
|
||||
}
|
||||
if publishTitle == "" {
|
||||
publishTitle = "定时生成文章"
|
||||
}
|
||||
|
||||
publishCtx := auth.WithCurrentBrandID(ctx, brandID)
|
||||
publishCtx = auth.WithCurrentWorkspaceID(publishCtx, input.WorkspaceID)
|
||||
actor := auth.Actor{
|
||||
TenantID: input.TenantID,
|
||||
UserID: input.OperatorID,
|
||||
PrimaryWorkspaceID: input.WorkspaceID,
|
||||
}
|
||||
publishCtx = auth.WithActor(publishCtx, actor)
|
||||
|
||||
if len(accountIDs) > 0 {
|
||||
runScheduleMediaAccountAutoPublish(publishCtx, input, actor, accountIDs, publishTitle)
|
||||
}
|
||||
if len(enterpriseTargets) > 0 {
|
||||
runScheduleEnterpriseSiteAutoPublish(publishCtx, input, enterpriseTargets)
|
||||
}
|
||||
}
|
||||
|
||||
func runScheduleMediaAccountAutoPublish(ctx context.Context, input ScheduleAutoPublishInput, actor auth.Actor, accountIDs []string, title string) {
|
||||
if input.PublishJobs == nil {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule media auto publish skipped because publish job service is unavailable", nil,
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int("account_count", len(accountIDs)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
accounts := make([]CreatePublishJobAccountRequest, 0, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, CreatePublishJobAccountRequest{AccountID: accountID})
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := input.PublishJobs.Create(ctx, actor, CreatePublishJobRequest{
|
||||
Title: title,
|
||||
ContentRef: map[string]any{
|
||||
"article_id": input.ArticleID,
|
||||
},
|
||||
Accounts: accounts,
|
||||
}); err != nil {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule media auto publish enqueue failed", err,
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int64("workspace_id", input.WorkspaceID),
|
||||
zap.Int("account_count", len(accounts)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func runScheduleEnterpriseSiteAutoPublish(ctx context.Context, input ScheduleAutoPublishInput, targets []ScheduleEnterpriseSiteTarget) {
|
||||
if input.EnterpriseSites == nil {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish skipped because enterprise site service is unavailable", nil,
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int("enterprise_site_count", len(targets)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
parallelism := scheduleEnterpriseSitePublishParallel
|
||||
if parallelism <= 0 {
|
||||
parallelism = 1
|
||||
}
|
||||
if parallelism > len(targets) {
|
||||
parallelism = len(targets)
|
||||
}
|
||||
sem := make(chan struct{}, parallelism)
|
||||
var wg sync.WaitGroup
|
||||
for _, target := range targets {
|
||||
if target.SiteID <= 0 || strings.TrimSpace(target.CategoryID) == "" {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish stopped because context is done", ctx.Err(),
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int64("workspace_id", input.WorkspaceID),
|
||||
)
|
||||
wg.Wait()
|
||||
return
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(target ScheduleEnterpriseSiteTarget) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish panic recovered", nil,
|
||||
zap.Any("panic", recovered),
|
||||
zap.ByteString("stack", debug.Stack()),
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int64("workspace_id", input.WorkspaceID),
|
||||
zap.Int64("enterprise_site_id", target.SiteID),
|
||||
)
|
||||
}
|
||||
}()
|
||||
runScheduleEnterpriseSiteTargetAutoPublish(ctx, input, target)
|
||||
}(target)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func runScheduleEnterpriseSiteTargetAutoPublish(ctx context.Context, input ScheduleAutoPublishInput, target ScheduleEnterpriseSiteTarget) {
|
||||
targetCtx, cancel := context.WithTimeout(ctx, scheduleEnterpriseSiteTargetTimeout)
|
||||
defer cancel()
|
||||
if _, err := input.EnterpriseSites.PublishArticle(targetCtx, target.SiteID, PublishEnterpriseSiteArticleRequest{
|
||||
ArticleID: input.ArticleID,
|
||||
CategoryID: target.CategoryID,
|
||||
PublishType: target.PublishType,
|
||||
}); err != nil {
|
||||
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish failed", err,
|
||||
zap.Int64("generation_task_id", input.GenerationTaskID),
|
||||
zap.Int64("article_id", input.ArticleID),
|
||||
zap.Int64("workspace_id", input.WorkspaceID),
|
||||
zap.Int64("enterprise_site_id", target.SiteID),
|
||||
zap.String("category_id", target.CategoryID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func logScheduleAutoPublishWarn(logger *zap.Logger, message string, err error, fields ...zap.Field) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
logger.Warn(message, fields...)
|
||||
}
|
||||
|
||||
func NormalizeScheduleEnterpriseSiteTargets(values []ScheduleEnterpriseSiteTarget) []ScheduleEnterpriseSiteTarget {
|
||||
if len(values) == 0 {
|
||||
return []ScheduleEnterpriseSiteTarget{}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
normalized := make([]ScheduleEnterpriseSiteTarget, 0, len(values))
|
||||
for _, value := range values {
|
||||
siteID := value.SiteID
|
||||
categoryID := strings.TrimSpace(value.CategoryID)
|
||||
if siteID <= 0 || categoryID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[siteID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[siteID] = struct{}{}
|
||||
normalized = append(normalized, ScheduleEnterpriseSiteTarget{
|
||||
SiteID: siteID,
|
||||
CategoryID: categoryID,
|
||||
PublishType: normalizeEnterprisePublishType(value.PublishType),
|
||||
})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func DecodeScheduleEnterpriseSiteTargets(raw []byte) []ScheduleEnterpriseSiteTarget {
|
||||
if len(raw) == 0 {
|
||||
return []ScheduleEnterpriseSiteTarget{}
|
||||
}
|
||||
var values []ScheduleEnterpriseSiteTarget
|
||||
if err := json.Unmarshal(raw, &values); err == nil {
|
||||
return NormalizeScheduleEnterpriseSiteTargets(values)
|
||||
}
|
||||
var loose []map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &loose); err != nil {
|
||||
return []ScheduleEnterpriseSiteTarget{}
|
||||
}
|
||||
return normalizeScheduleEnterpriseSiteTargetMaps(loose, scheduleEnterpriseSiteTargetLimit)
|
||||
}
|
||||
|
||||
func extractScheduleEnterpriseSiteTargets(value interface{}, limit int) []ScheduleEnterpriseSiteTarget {
|
||||
switch typed := value.(type) {
|
||||
case []ScheduleEnterpriseSiteTarget:
|
||||
return limitScheduleEnterpriseSiteTargets(NormalizeScheduleEnterpriseSiteTargets(typed), limit)
|
||||
case []map[string]interface{}:
|
||||
return normalizeScheduleEnterpriseSiteTargetMaps(typed, limit)
|
||||
case []interface{}:
|
||||
items := make([]map[string]interface{}, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch target := item.(type) {
|
||||
case map[string]interface{}:
|
||||
items = append(items, target)
|
||||
case ScheduleEnterpriseSiteTarget:
|
||||
items = append(items, map[string]interface{}{
|
||||
"site_id": target.SiteID,
|
||||
"category_id": target.CategoryID,
|
||||
"publish_type": target.PublishType,
|
||||
})
|
||||
}
|
||||
if limit > 0 && len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return normalizeScheduleEnterpriseSiteTargetMaps(items, limit)
|
||||
default:
|
||||
return []ScheduleEnterpriseSiteTarget{}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeScheduleEnterpriseSiteTargetMaps(values []map[string]interface{}, limit int) []ScheduleEnterpriseSiteTarget {
|
||||
targets := make([]ScheduleEnterpriseSiteTarget, 0, len(values))
|
||||
for _, value := range values {
|
||||
siteID := extractInt64FromTargetMap(value, "site_id", "connection_id", "id")
|
||||
categoryID := strings.TrimSpace(extractStringFromTargetMap(value, "category_id", "remote_id", "scode"))
|
||||
publishType := strings.TrimSpace(extractStringFromTargetMap(value, "publish_type", "status"))
|
||||
targets = append(targets, ScheduleEnterpriseSiteTarget{
|
||||
SiteID: siteID,
|
||||
CategoryID: categoryID,
|
||||
PublishType: publishType,
|
||||
})
|
||||
if limit > 0 && len(targets) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return limitScheduleEnterpriseSiteTargets(NormalizeScheduleEnterpriseSiteTargets(targets), limit)
|
||||
}
|
||||
|
||||
func limitScheduleEnterpriseSiteTargets(values []ScheduleEnterpriseSiteTarget, limit int) []ScheduleEnterpriseSiteTarget {
|
||||
if limit > 0 && len(values) > limit {
|
||||
return values[:limit]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func extractInt64FromTargetMap(payload map[string]interface{}, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
raw, ok := payload[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case int32:
|
||||
return int64(typed)
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
value, _ := typed.Int64()
|
||||
return value
|
||||
case string:
|
||||
value, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return value
|
||||
default:
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
|
||||
value, _ := strconv.ParseInt(text, 10, 64)
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractStringFromTargetMap(payload map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
raw, ok := payload[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case string:
|
||||
return typed
|
||||
default:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func encodeScheduleEnterpriseSiteTargets(targets []ScheduleEnterpriseSiteTarget) ([]byte, error) {
|
||||
targets = NormalizeScheduleEnterpriseSiteTargets(targets)
|
||||
raw, err := json.Marshal(targets)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40029, "invalid_publish_enterprise_site_targets", "企业站点发布目标格式不正确")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartScheduleAutoPublishReturnsAfterScheduling(t *testing.T) {
|
||||
previousRunner := runScheduleAutoPublishBackground
|
||||
defer func() {
|
||||
runScheduleAutoPublishBackground = previousRunner
|
||||
}()
|
||||
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
runScheduleAutoPublishBackground = func(fn func()) {
|
||||
go func() {
|
||||
close(started)
|
||||
<-release
|
||||
fn()
|
||||
close(done)
|
||||
}()
|
||||
}
|
||||
|
||||
StartScheduleAutoPublish(ScheduleAutoPublishInput{
|
||||
InputParams: map[string]interface{}{
|
||||
"schedule_auto_publish": true,
|
||||
},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("background runner was not scheduled")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("auto publish should not block caller until background work completes")
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("background auto publish did not finish")
|
||||
}
|
||||
}
|
||||
@@ -41,68 +41,70 @@ func (s *ScheduleTaskService) WithCache(c sharedcache.Cache) *ScheduleTaskServic
|
||||
}
|
||||
|
||||
type ScheduleTaskRequest struct {
|
||||
TargetType string `json:"target_type"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
ScheduleKind string `json:"schedule_kind"`
|
||||
ScheduleDays []string `json:"schedule_days"`
|
||||
ScheduleTimeMode string `json:"schedule_time_mode"`
|
||||
RandomWindowStart string `json:"random_window_start"`
|
||||
RandomWindowEnd string `json:"random_window_end"`
|
||||
GenerationInput map[string]interface{} `json:"generation_input"`
|
||||
AutoPublish *bool `json:"auto_publish"`
|
||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
EnableWebSearch *bool `json:"enable_web_search"`
|
||||
GenerateCount *int `json:"generate_count"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
TargetType string `json:"target_type"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
ScheduleKind string `json:"schedule_kind"`
|
||||
ScheduleDays []string `json:"schedule_days"`
|
||||
ScheduleTimeMode string `json:"schedule_time_mode"`
|
||||
RandomWindowStart string `json:"random_window_start"`
|
||||
RandomWindowEnd string `json:"random_window_end"`
|
||||
GenerationInput map[string]interface{} `json:"generation_input"`
|
||||
AutoPublish *bool `json:"auto_publish"`
|
||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
EnableWebSearch *bool `json:"enable_web_search"`
|
||||
GenerateCount *int `json:"generate_count"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
}
|
||||
|
||||
type ScheduleTaskResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkspaceID *int64 `json:"workspace_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
|
||||
SubscriptionPromptName *string `json:"subscription_prompt_name"`
|
||||
SubscriptionPackageName *string `json:"subscription_package_name"`
|
||||
SubscriptionKolName *string `json:"subscription_kol_name"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
BrandName *string `json:"brand_name"`
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
ScheduleKind string `json:"schedule_kind"`
|
||||
ScheduleDays []string `json:"schedule_days"`
|
||||
ScheduleTimeMode string `json:"schedule_time_mode"`
|
||||
RandomWindowStart string `json:"random_window_start"`
|
||||
RandomWindowEnd string `json:"random_window_end"`
|
||||
GenerationInput map[string]interface{} `json:"generation_input"`
|
||||
AutoPublish bool `json:"auto_publish"`
|
||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
EnableWebSearch bool `json:"enable_web_search"`
|
||||
GenerateCount int `json:"generate_count"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
NextRunAt *string `json:"next_run_at"`
|
||||
Status string `json:"status"`
|
||||
LastRunAt *string `json:"last_run_at"`
|
||||
LastDispatchStatus *string `json:"last_dispatch_status"`
|
||||
LastDispatchError *string `json:"last_dispatch_error"`
|
||||
ConsecutiveFailures int `json:"consecutive_failures"`
|
||||
GenerationStatus *string `json:"generation_status"`
|
||||
ExecutionTime *string `json:"execution_time"`
|
||||
GeneratedArticles []InstantTaskArticleLink `json:"generated_articles"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
WorkspaceID *int64 `json:"workspace_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
|
||||
SubscriptionPromptName *string `json:"subscription_prompt_name"`
|
||||
SubscriptionPackageName *string `json:"subscription_package_name"`
|
||||
SubscriptionKolName *string `json:"subscription_kol_name"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
BrandName *string `json:"brand_name"`
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
ScheduleKind string `json:"schedule_kind"`
|
||||
ScheduleDays []string `json:"schedule_days"`
|
||||
ScheduleTimeMode string `json:"schedule_time_mode"`
|
||||
RandomWindowStart string `json:"random_window_start"`
|
||||
RandomWindowEnd string `json:"random_window_end"`
|
||||
GenerationInput map[string]interface{} `json:"generation_input"`
|
||||
AutoPublish bool `json:"auto_publish"`
|
||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
EnableWebSearch bool `json:"enable_web_search"`
|
||||
GenerateCount int `json:"generate_count"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
NextRunAt *string `json:"next_run_at"`
|
||||
Status string `json:"status"`
|
||||
LastRunAt *string `json:"last_run_at"`
|
||||
LastDispatchStatus *string `json:"last_dispatch_status"`
|
||||
LastDispatchError *string `json:"last_dispatch_error"`
|
||||
ConsecutiveFailures int `json:"consecutive_failures"`
|
||||
GenerationStatus *string `json:"generation_status"`
|
||||
ExecutionTime *string `json:"execution_time"`
|
||||
GeneratedArticles []InstantTaskArticleLink `json:"generated_articles"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ScheduleTaskListParams struct {
|
||||
@@ -209,22 +211,22 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
|
||||
tenant_id, workspace_id, operator_id, target_type, prompt_rule_id, subscription_prompt_id,
|
||||
brand_id, name, cron_expr, schedule_kind, schedule_days, schedule_time_mode,
|
||||
random_window_start, random_window_end, generation_input_json,
|
||||
enable_web_search, generate_count, auto_publish, publish_account_ids, cover_asset_url,
|
||||
cover_image_asset_id, start_at, end_at
|
||||
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
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11::jsonb, $12,
|
||||
$13::time, $14::time, $15::jsonb,
|
||||
$16, $17, $18, $19::jsonb, $20,
|
||||
$21, $22::timestamptz, $23::timestamptz
|
||||
$16, $17, $18, $19::jsonb,
|
||||
$20::jsonb, $21, $22, $23::timestamptz, $24::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.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
|
||||
publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
|
||||
Scan(&id, &createdAt, &startAt, &endAt, &status)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task")
|
||||
@@ -282,7 +284,8 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
|
||||
Name: req.Name, CronExpr: plan.CronExpr, ScheduleKind: plan.ScheduleKind, ScheduleDays: plan.ScheduleDays,
|
||||
ScheduleTimeMode: plan.ScheduleTimeMode, RandomWindowStart: plan.RandomWindowStart,
|
||||
RandomWindowEnd: plan.RandomWindowEnd, GenerationInput: generationInput, AutoPublish: publishConfig.AutoPublish,
|
||||
PublishAccountIDs: publishConfig.AccountIDs, AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
|
||||
PublishAccountIDs: publishConfig.AccountIDs, PublishEnterpriseSiteTargets: publishConfig.EnterpriseTargets,
|
||||
AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
|
||||
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
|
||||
GenerateCount: generateCount,
|
||||
Status: status, CreatedAt: timeStringFromDBValue(createdAt),
|
||||
@@ -345,18 +348,19 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
|
||||
generation_input_json = $12::jsonb,
|
||||
enable_web_search = $13, generate_count = $14,
|
||||
workspace_id = $15, auto_publish = $16, publish_account_ids = $17::jsonb,
|
||||
cover_asset_url = $18, cover_image_asset_id = $19,
|
||||
start_at = $20::timestamptz, end_at = $21::timestamptz, operator_id = $22,
|
||||
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,
|
||||
last_dispatch_status = NULL, last_dispatch_error = NULL,
|
||||
consecutive_failures = 0,
|
||||
updated_at = NOW()
|
||||
WHERE id = $23 AND tenant_id = $24 AND brand_id = $25 AND deleted_at IS NULL
|
||||
WHERE id = $24 AND tenant_id = $25 AND brand_id = $26 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.CoverAssetURL, publishConfig.CoverImageAssetID,
|
||||
publishConfig.AccountIDsJSON, publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
|
||||
req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
|
||||
Scan(&status, &startAt, &endAt)
|
||||
if err != nil {
|
||||
@@ -498,8 +502,8 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
|
||||
st.subscription_prompt_id, st.brand_id, st.name,
|
||||
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
|
||||
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
|
||||
st.auto_publish, st.publish_account_ids, st.cover_asset_url,
|
||||
st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
|
||||
st.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.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,
|
||||
@@ -553,8 +557,8 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
|
||||
st.subscription_prompt_id, st.brand_id, st.name,
|
||||
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
|
||||
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
|
||||
st.auto_publish, st.publish_account_ids, st.cover_asset_url,
|
||||
st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
|
||||
st.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.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,
|
||||
@@ -924,13 +928,14 @@ func scanScheduleTaskResponse(scanner interface {
|
||||
var nextRunAt interface{}
|
||||
var lastRunAt interface{}
|
||||
var publishAccountIDsJSON []byte
|
||||
var publishEnterpriseSiteTargetsJSON []byte
|
||||
var scheduleDaysJSON []byte
|
||||
var generationInputJSON []byte
|
||||
if err := scanner.Scan(&item.ID, &item.WorkspaceID, &item.TargetType, &item.PromptRuleID,
|
||||
&item.SubscriptionPromptID, &item.BrandID, &item.Name,
|
||||
&item.CronExpr, &item.ScheduleKind, &scheduleDaysJSON, &item.ScheduleTimeMode,
|
||||
&item.RandomWindowStart, &item.RandomWindowEnd, &generationInputJSON,
|
||||
&item.AutoPublish, &publishAccountIDsJSON,
|
||||
&item.AutoPublish, &publishAccountIDsJSON, &publishEnterpriseSiteTargetsJSON,
|
||||
&item.CoverAssetURL, &item.CoverImageAssetID, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
|
||||
&nextRunAt, &item.Status, &lastRunAt, &item.LastDispatchStatus,
|
||||
&item.LastDispatchError, &item.ConsecutiveFailures, &createdAt, &updatedAt,
|
||||
@@ -959,6 +964,7 @@ func scanScheduleTaskResponse(scanner interface {
|
||||
item.ScheduleDays = decodeScheduleDayKeys(scheduleDaysJSON)
|
||||
item.GenerationInput = decodeScheduleGenerationInput(generationInputJSON)
|
||||
item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON)
|
||||
item.PublishEnterpriseSiteTargets = DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
|
||||
item.CreatedAt = timeStringFromDBValue(createdAt)
|
||||
item.UpdatedAt = timeStringFromDBValue(updatedAt)
|
||||
item.StartAt = timeStringPtrFromDBValue(startAt)
|
||||
@@ -1061,54 +1067,72 @@ func (s *ScheduleTaskService) validateKolScheduleGenerationInput(ctx context.Con
|
||||
}
|
||||
|
||||
type scheduleAutoPublishConfig struct {
|
||||
AutoPublish bool
|
||||
AccountIDs []string
|
||||
PlatformIDs []string
|
||||
AccountIDsJSON []byte
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
AutoPublish bool
|
||||
AccountIDs []string
|
||||
EnterpriseTargets []ScheduleEnterpriseSiteTarget
|
||||
PlatformIDs []string
|
||||
AccountIDsJSON []byte
|
||||
EnterpriseTargetsJSON []byte
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, actor auth.Actor, req ScheduleTaskRequest) (scheduleAutoPublishConfig, error) {
|
||||
autoPublish := boolValue(req.AutoPublish)
|
||||
accountIDs := normalizeSchedulePublishAccountIDs(req.PublishAccountIDs)
|
||||
enterpriseTargets := NormalizeScheduleEnterpriseSiteTargets(req.PublishEnterpriseSiteTargets)
|
||||
|
||||
config := scheduleAutoPublishConfig{
|
||||
AutoPublish: autoPublish,
|
||||
AccountIDs: accountIDs,
|
||||
AutoPublish: autoPublish,
|
||||
AccountIDs: accountIDs,
|
||||
EnterpriseTargets: enterpriseTargets,
|
||||
}
|
||||
|
||||
if !autoPublish {
|
||||
config.AccountIDs = []string{}
|
||||
config.EnterpriseTargets = []ScheduleEnterpriseSiteTarget{}
|
||||
config.AccountIDsJSON = []byte("[]")
|
||||
config.EnterpriseTargetsJSON = []byte("[]")
|
||||
return config, nil
|
||||
}
|
||||
|
||||
if actor.PrimaryWorkspaceID <= 0 {
|
||||
return config, response.ErrBadRequest(40023, "workspace_required", "workspace context is required for automatic publishing")
|
||||
return config, response.ErrBadRequest(40023, "workspace_required", "自动发布需要工作区上下文")
|
||||
}
|
||||
if len(accountIDs) == 0 {
|
||||
return config, response.ErrBadRequest(40024, "publish_accounts_required", "publish_account_ids is required when auto_publish is enabled")
|
||||
if len(accountIDs) == 0 && len(enterpriseTargets) == 0 {
|
||||
return config, response.ErrBadRequest(40024, "publish_targets_required", "开启自动发布后,请至少选择一个发布目标")
|
||||
}
|
||||
if err := validateOptionalPositiveInt64(req.CoverImageAssetID, "cover_image_asset_id"); err != nil {
|
||||
return config, err
|
||||
}
|
||||
coverURL := strings.TrimSpace(stringPointerValue(req.CoverAssetURL))
|
||||
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
|
||||
if err != nil {
|
||||
return config, err
|
||||
if len(accountIDs) > 0 {
|
||||
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.PlatformIDs = normalizePlatformIDs(platformIDs)
|
||||
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
|
||||
return config, response.ErrBadRequest(40025, "cover_required", "已选媒体账号的平台要求封面图,请先设置封面")
|
||||
}
|
||||
}
|
||||
config.PlatformIDs = normalizePlatformIDs(platformIDs)
|
||||
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
|
||||
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
|
||||
if len(enterpriseTargets) > 0 {
|
||||
if err := s.validateScheduleEnterpriseSiteTargets(ctx, actor.TenantID, actor.PrimaryWorkspaceID, enterpriseTargets); err != nil {
|
||||
return config, err
|
||||
}
|
||||
}
|
||||
|
||||
accountIDsJSON, err := json.Marshal(accountIDs)
|
||||
if err != nil {
|
||||
return config, response.ErrBadRequest(40026, "invalid_publish_account_ids", "publish_account_ids must be serializable")
|
||||
return config, response.ErrBadRequest(40026, "invalid_publish_account_ids", "媒体账号发布目标格式不正确")
|
||||
}
|
||||
enterpriseTargetsJSON, err := encodeScheduleEnterpriseSiteTargets(enterpriseTargets)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
config.AccountIDsJSON = accountIDsJSON
|
||||
config.EnterpriseTargetsJSON = enterpriseTargetsJSON
|
||||
if coverURL != "" {
|
||||
config.CoverAssetURL = &coverURL
|
||||
config.CoverImageAssetID = req.CoverImageAssetID
|
||||
@@ -1116,6 +1140,48 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) validateScheduleEnterpriseSiteTargets(ctx context.Context, tenantID, workspaceID int64, targets []ScheduleEnterpriseSiteTarget) error {
|
||||
for _, target := range targets {
|
||||
var status string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT status
|
||||
FROM enterprise_site_connections
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
AND workspace_id = $3
|
||||
AND deleted_at IS NULL
|
||||
`, target.SiteID, tenantID, workspaceID).Scan(&status); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return response.ErrBadRequest(40050, "invalid_publish_enterprise_site_targets", "企业站点连接不存在或已删除")
|
||||
}
|
||||
return response.ErrInternal(50231, "enterprise_site_lookup_failed", "企业站点连接读取失败")
|
||||
}
|
||||
if status == "disabled" {
|
||||
return response.ErrConflict(40942, "enterprise_site_disabled", "企业站点已禁用")
|
||||
}
|
||||
if strings.TrimSpace(target.CategoryID) == "" {
|
||||
return response.ErrBadRequest(40047, "enterprise_site_category_required", "请选择企业站点栏目")
|
||||
}
|
||||
var categoryExists bool
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM enterprise_site_categories
|
||||
WHERE tenant_id = $1
|
||||
AND workspace_id = $2
|
||||
AND connection_id = $3
|
||||
AND remote_id = $4
|
||||
)
|
||||
`, tenantID, workspaceID, target.SiteID, target.CategoryID).Scan(&categoryExists); err != nil {
|
||||
return response.ErrInternal(50230, "enterprise_site_category_lookup_failed", "企业站点栏目校验失败")
|
||||
}
|
||||
if !categoryExists {
|
||||
return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "企业站点栏目不存在,请先同步栏目")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScheduleTaskService) loadPublishAccountPlatformIDs(ctx context.Context, tenantID, workspaceID int64, accountIDs []string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT platform_id
|
||||
|
||||
@@ -66,7 +66,14 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
||||
a.GenerationStreams,
|
||||
a.Config().Generation,
|
||||
a.Config().LLM.MaxOutputTokens,
|
||||
).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore),
|
||||
).WithCache(a.Cache).
|
||||
WithLogger(a.Logger).
|
||||
WithConfigProvider(a.ConfigStore).
|
||||
WithEnterpriseSiteService(
|
||||
app.NewEnterpriseSiteService(a.DB, a.ConfigStore).
|
||||
WithCache(a.Cache).
|
||||
WithObjectStorage(a.ObjectStorage),
|
||||
),
|
||||
imitationSvc: app.NewArticleImitationService(
|
||||
a.DB,
|
||||
a.LLM,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,14 +24,16 @@ import (
|
||||
const kolGenerationTaskType = "kol"
|
||||
|
||||
type KolGenerationWorker struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
knowledge *tenantapp.KnowledgeService
|
||||
logger *zap.Logger
|
||||
cfg config.GenerationConfig
|
||||
llmCfg config.LLMConfig
|
||||
provider config.Provider
|
||||
cache sharedcache.Cache
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
knowledge *tenantapp.KnowledgeService
|
||||
logger *zap.Logger
|
||||
cfg config.GenerationConfig
|
||||
llmCfg config.LLMConfig
|
||||
provider config.Provider
|
||||
cache sharedcache.Cache
|
||||
publishJobs *tenantapp.PublishJobService
|
||||
enterpriseSites *tenantapp.EnterpriseSiteService
|
||||
}
|
||||
|
||||
func NewKolGenerationWorker(
|
||||
@@ -56,6 +59,16 @@ func (w *KolGenerationWorker) WithCache(c sharedcache.Cache) *KolGenerationWorke
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *KolGenerationWorker) WithPublishJobService(svc *tenantapp.PublishJobService) *KolGenerationWorker {
|
||||
w.publishJobs = svc
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *KolGenerationWorker) WithEnterpriseSiteService(svc *tenantapp.EnterpriseSiteService) *KolGenerationWorker {
|
||||
w.enterpriseSites = svc
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *KolGenerationWorker) WithConfigProvider(provider config.Provider) *KolGenerationWorker {
|
||||
w.provider = provider
|
||||
return w
|
||||
@@ -225,6 +238,19 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
tenantapp.InvalidateArticleCaches(cleanupCtx, w.cache, task.TenantID, &task.ArticleID)
|
||||
tenantapp.StartScheduleAutoPublish(tenantapp.ScheduleAutoPublishInput{
|
||||
TenantID: task.TenantID,
|
||||
WorkspaceID: int64(kolIntInput(task.InputParams, "workspace_id")),
|
||||
OperatorID: task.OperatorID,
|
||||
BrandID: int64(kolIntInput(task.InputParams, "brand_id")),
|
||||
ArticleID: task.ArticleID,
|
||||
GenerationTaskID: task.ID,
|
||||
Title: title,
|
||||
InputParams: task.InputParams,
|
||||
PublishJobs: w.publishJobs,
|
||||
EnterpriseSites: w.enterpriseSites,
|
||||
Logger: w.logger,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *KolGenerationWorker) HandleTaskFailure(ctx context.Context, task tenantapp.ClaimedGenerationTask, stage string, taskErr error) {
|
||||
@@ -283,6 +309,31 @@ func kolBoolInput(input map[string]interface{}, key string) bool {
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
func kolIntInput(input map[string]interface{}, key string) int {
|
||||
if input == nil {
|
||||
return 0
|
||||
}
|
||||
value, ok := input[key]
|
||||
if !ok || value == nil {
|
||||
return 0
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int32:
|
||||
return int(typed)
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case string:
|
||||
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func kolKnowledgeGroupIDs(value interface{}) []int64 {
|
||||
switch typed := value.(type) {
|
||||
case []int64:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE enterprise_site_connections
|
||||
DROP CONSTRAINT IF EXISTS ck_enterprise_site_connections_cms_type;
|
||||
|
||||
ALTER TABLE enterprise_site_connections
|
||||
ADD CONSTRAINT ck_enterprise_site_connections_cms_type
|
||||
CHECK (cms_type IN ('pbootcms', 'wordpress', 'dedecms', 'phpcms'));
|
||||
|
||||
DELETE FROM media_platforms
|
||||
WHERE platform_id = 'wordpress';
|
||||
@@ -0,0 +1,18 @@
|
||||
INSERT INTO media_platforms (platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order)
|
||||
VALUES ('wordpress', 'WordPress', 'enterprise', 'WP', '#21759b', NULL, NULL, 'active', 901)
|
||||
ON CONFLICT (platform_id)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
category = EXCLUDED.category,
|
||||
short_name = EXCLUDED.short_name,
|
||||
accent_color = EXCLUDED.accent_color,
|
||||
status = EXCLUDED.status,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW();
|
||||
|
||||
ALTER TABLE enterprise_site_connections
|
||||
DROP CONSTRAINT IF EXISTS ck_enterprise_site_connections_cms_type;
|
||||
|
||||
ALTER TABLE enterprise_site_connections
|
||||
ADD CONSTRAINT ck_enterprise_site_connections_cms_type
|
||||
CHECK (cms_type IN ('pbootcms', 'wordpress'));
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE schedule_tasks
|
||||
DROP CONSTRAINT IF EXISTS chk_schedule_tasks_publish_enterprise_site_targets_json;
|
||||
|
||||
ALTER TABLE schedule_tasks
|
||||
DROP COLUMN IF EXISTS publish_enterprise_site_targets;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE schedule_tasks
|
||||
ADD COLUMN publish_enterprise_site_targets JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
ALTER TABLE schedule_tasks
|
||||
ADD CONSTRAINT chk_schedule_tasks_publish_enterprise_site_targets_json
|
||||
CHECK (jsonb_typeof(publish_enterprise_site_targets) = 'array');
|
||||
Reference in New Issue
Block a user