feat: add brand asset cleanup worker and related functionality
Frontend CI / Frontend (push) Successful in 3m23s
Backend CI / Backend (push) Failing after 6m46s

- Implemented a new BrandAssetCleanupWorker to handle the cleanup of brand-related assets after a brand is deleted.
- Added SQL queries for cleaning up articles, keywords, questions, competitors, and monitoring data associated with a brand.
- Introduced a new API endpoint to delete publish records.
- Updated the router to include the new delete publish record endpoint.
- Added tests for the BrandAssetCleanupWorker to ensure proper functionality.
- Created migration scripts to support soft deletion of publish records and to add the brand asset cleanup scheduler job.
This commit is contained in:
2026-06-19 11:45:13 +08:00
parent 97ae601cfd
commit 6e0519a232
29 changed files with 1621 additions and 54 deletions
@@ -592,13 +592,13 @@ function handlePromptSaved(ruleId: number): void {
}
function toggleSchedulePublishAccount(account: PublishAccountCard): void {
if (!account.selectable) {
return
}
if (form.publishAccountIds.includes(account.id)) {
form.publishAccountIds = form.publishAccountIds.filter((id) => id !== account.id)
return
}
if (!account.selectable) {
return
}
form.publishAccountIds = [...form.publishAccountIds, account.id]
}
@@ -606,6 +606,10 @@ function isSchedulePublishAccountSelected(accountId: string): boolean {
return form.publishAccountIds.includes(accountId)
}
function canToggleSchedulePublishAccount(account: PublishAccountCard): boolean {
return account.selectable || isSchedulePublishAccountSelected(account.id)
}
function buildScheduleEnterpriseSiteTargets(): ScheduleEnterpriseSiteTarget[] {
return form.publishEnterpriseSiteIds
.map((siteId) => ({
@@ -1325,8 +1329,10 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
'generate-task-drawer__account--active': isSchedulePublishAccountSelected(
account.id,
),
'generate-task-drawer__account--disabled': !account.selectable,
'generate-task-drawer__account--disabled':
!canToggleSchedulePublishAccount(account),
}"
:aria-disabled="!canToggleSchedulePublishAccount(account)"
@click="toggleSchedulePublishAccount(account)"
>
<span class="generate-task-drawer__account-check">
+12 -2
View File
@@ -35,6 +35,7 @@ import type {
CreatePublishBatchResponse,
CreatePublishJobRequest,
CreatePublishJobResponse,
DeletePublishRecordResponse,
DesktopAccountInfo,
DesktopClientDownloadableRelease,
DesktopClientDownloadableReleaseListResponse,
@@ -351,8 +352,6 @@ const currentBrandScopedPathPatterns = [
/^\/api\/tenant\/schedules(?:\/|$)/,
/^\/api\/tenant\/instant-tasks(?:\/|$)/,
/^\/api\/tenant\/publish-jobs(?:\/|$)/,
/^\/api\/tenant\/publish-records(?:\/|$)/,
/^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/,
/^\/api\/tenant\/enterprise-sites\/[^/]+\/publish(?:\/|$)/,
/^\/api\/tenant\/media-supply\/orders(?:\/|$)/,
/^\/api\/tenant\/prompt-rules(?:\/|$)/,
@@ -1396,6 +1395,12 @@ export const publishTasksApi = {
params,
})
},
cancel(taskId: string) {
return apiClient.post<unknown, { reason: string }>(
`/api/tenant/tasks/${encodeURIComponent(taskId)}/cancel`,
{ reason: 'tenant_cancelled_publish' },
)
},
retry(taskId: string) {
return apiClient.post<CreatePublishJobResponse>(
`/api/tenant/publish-tasks/${encodeURIComponent(taskId)}/retry`,
@@ -1409,6 +1414,11 @@ export const publishRecordsApi = {
params,
})
},
remove(recordId: number) {
return apiClient.remove<DeletePublishRecordResponse>(
`/api/tenant/publish-records/${encodeURIComponent(String(recordId))}`,
)
},
}
export const enterpriseSitesApi = {
@@ -2,11 +2,13 @@
import {
ClockCircleOutlined,
CopyOutlined,
DeleteOutlined,
DesktopOutlined,
ExportOutlined,
ReloadOutlined,
RetweetOutlined,
SearchOutlined,
StopOutlined,
} from '@ant-design/icons-vue'
import type { PublishRecord, PublishRecordListResponse } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
@@ -21,7 +23,6 @@ import { formatDateTime } from '@/lib/display'
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
import { resolvePlatformLogoUrl } from '@/lib/publish-account-cards'
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 52
@@ -35,6 +36,9 @@ interface PublishRecordItem {
recordId: number
title: string
articleId: number
brandId: number
brandName: string
brandDeleted: boolean
targetType: string
platform: string
platformName: string
@@ -184,22 +188,21 @@ function buildDesktopWorkbenchUrl(record: {
}
const { t } = useI18n()
const companyStore = useCompanyStore()
const page = ref(1)
const searchTitle = ref('')
const appliedTitle = ref('')
const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const cancelingTaskId = ref<string | null>(null)
const deletingRecordId = ref<string | null>(null)
const manualRefreshing = ref(false)
const recordsQuery = useQuery({
queryKey: computed(() => [
'tenant',
'publish-records',
companyStore.currentBrandId,
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () =>
publishRecordsApi.list({
page: page.value,
@@ -246,6 +249,9 @@ const publishRecords = computed<PublishRecordItem[]>(() =>
recordId: record.id,
title: extractString(record.article_title) ?? `文章 #${record.article_id}`,
articleId: record.article_id,
brandId: record.brand_id,
brandName: extractString(record.brand_name) ?? `公司 #${record.brand_id}`,
brandDeleted: Boolean(record.brand_deleted),
targetType: record.target_type ?? 'platform_account',
platform: platformId,
platformName: isEnterpriseSite ? '企业自建站点' : record.platform_name || platformMeta.name,
@@ -312,7 +318,11 @@ function rowClassName(record: PublishRecordItem): string {
}
function canRetryTask(record: PublishRecordItem): boolean {
return Boolean(record.desktopTaskId && record.status === 'failed')
return Boolean(record.desktopTaskId && record.status === 'failed' && !record.brandDeleted)
}
function canCancelTask(record: PublishRecordItem): boolean {
return Boolean(record.desktopTaskId && record.status === 'queued')
}
async function retryTask(record: PublishRecordItem): Promise<void> {
@@ -346,6 +356,38 @@ async function retryTask(record: PublishRecordItem): Promise<void> {
}
}
async function cancelTask(record: PublishRecordItem): Promise<void> {
if (!record.desktopTaskId) {
return
}
cancelingTaskId.value = record.id
try {
await publishTasksApi.cancel(record.desktopTaskId)
message.success('已取消等待发布任务')
await recordsQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
cancelingTaskId.value = null
}
}
async function deleteRecord(record: PublishRecordItem): Promise<void> {
deletingRecordId.value = record.id
try {
await publishRecordsApi.remove(record.recordId)
message.success('发布记录已删除')
if (publishRecords.value.length === 1 && page.value > 1) {
page.value -= 1
}
await recordsQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
deletingRecordId.value = null
}
}
async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
if (!record.errorMessage) {
return
@@ -443,6 +485,12 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
<template v-if="record.articleId">文章 #{{ record.articleId }}</template>
<template v-else>未关联文章</template>
</span>
<span class="publish-brand-line">
<span class="publish-brand-name">{{ record.brandName }}</span>
<a-tag v-if="record.brandDeleted" color="default" class="publish-brand-tag">
公司已删除
</a-tag>
</span>
</div>
</template>
@@ -567,11 +615,50 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
<template #icon><RetweetOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isPendingStatus(record.status)" title="发布进行中">
<a-popconfirm
v-if="canCancelTask(record)"
title="确认取消这个等待发布任务?"
ok-text="取消发布"
cancel-text="保留"
placement="topRight"
@confirm="cancelTask(record)"
>
<a-tooltip title="取消发布任务" placement="top">
<a-button
type="text"
class="publish-action-btn publish-action-btn--danger"
:loading="cancelingTaskId === record.id"
:aria-label="`取消发布任务:${record.title}`"
@click.stop
>
<template #icon><StopOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
<a-tooltip v-else-if="isPendingStatus(record.status)" title="发布进行中">
<span class="publish-action-placeholder">
<ClockCircleOutlined />
</span>
</a-tooltip>
<a-popconfirm
title="确认删除这条发布记录?"
ok-text="删除"
cancel-text="取消"
placement="topRight"
@confirm="deleteRecord(record)"
>
<a-tooltip title="删除发布记录" placement="top">
<a-button
type="text"
class="publish-action-btn publish-action-btn--danger"
:loading="deletingRecordId === record.id"
:aria-label="`删除发布记录:${record.title}`"
@click.stop
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</div>
</template>
</template>
@@ -746,6 +833,30 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
font-weight: 500;
}
.publish-brand-line {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 6px;
margin-top: 3px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.publish-brand-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.publish-brand-tag {
flex: 0 0 auto;
margin-inline-end: 0;
font-size: 11px;
}
.publish-account-cell {
display: flex;
min-width: 0;
@@ -843,6 +954,11 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
color: #1677ff;
}
.publish-action-btn--danger:hover:not(:disabled) {
background: #fff1f0;
color: #cf1322;
}
.publish-action-tooltip-wrap {
display: inline-flex;
}