feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Frontend CI / Frontend (push) Successful in 4m17s
Backend CI / Backend (push) Failing after 6m42s

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:
2026-06-09 19:44:24 +08:00
parent 41f5623791
commit c7bad83496
30 changed files with 2946 additions and 696 deletions
+1
View File
@@ -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>
+26 -2
View File
@@ -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.',
+23 -2
View File
@@ -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 内容',
+28
View File
@@ -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
+9 -1
View File
@@ -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
+12
View File
@@ -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,
+8 -1
View File
@@ -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');