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;
}
+7
View File
@@ -1262,6 +1262,9 @@ export interface PublishRecord {
id: number
publish_batch_id: number
article_id: number
brand_id: number
brand_name?: string | null
brand_deleted?: boolean
article_title?: string | null
platform_account_id?: number | null
desktop_account_id: string | null
@@ -1296,6 +1299,10 @@ export interface PublishRecordListResponse {
history_total: number
}
export interface DeletePublishRecordResponse {
deleted: boolean
}
export type EnterpriseSiteCmsType = 'pbootcms' | 'wordpress'
export type EnterpriseSiteStatus = 'draft' | 'connected' | 'error' | 'disabled'
+7
View File
@@ -123,6 +123,7 @@ func main() {
WithConfigProvider(app.ConfigStore).
WithCache(app.Cache)
monitoringRetentionWorker := internalscheduler.NewMonitoringRetentionWorker(app.MonitoringDB, app.Logger)
brandAssetCleanupWorker := internalscheduler.NewBrandAssetCleanupWorker(app.DB, app.MonitoringDB, app.Cache, app.Logger)
aiPointUsageCleanupWorker := internalscheduler.NewAIPointUsageCleanupWorker(app.DB, app.Logger)
schedulerRunRetentionWorker := internalscheduler.NewSchedulerRunRetentionWorker(app.DB, app.Logger)
@@ -181,6 +182,12 @@ func main() {
return monitoringRetentionWorker.RunOnce(runCtx, jobCtx)
},
},
{
Key: "brand_asset_cleanup",
Run: func(runCtx context.Context, jobCtx internalscheduler.JobRunContext) (map[string]any, error) {
return brandAssetCleanupWorker.RunOnce(runCtx, jobCtx)
},
},
{
Key: "ai_point_usage_cleanup",
Run: func(runCtx context.Context, jobCtx internalscheduler.JobRunContext) (map[string]any, error) {
@@ -0,0 +1,940 @@
package scheduler
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
)
const defaultBrandAssetCleanupBatch = 20
type BrandAssetCleanupWorker struct {
pool *pgxpool.Pool
monitoringPool *pgxpool.Pool
cache sharedcache.Cache
logger *zap.Logger
}
type brandCleanupCandidate struct {
TenantID int64
BrandID int64
}
type brandCleanupStats struct {
BrandCount int64
ArticleCount int64
GenerationTaskCount int64
GenerationTaskRecordCount int64
KolUsageCount int64
QuotaReservationRefundCount int64
CancelledPublishRecordCount int64
PublishBatchCount int64
DesktopPublishJobCount int64
DesktopPublishTaskCount int64
DesktopMonitorTaskCount int64
PromptRuleCount int64
PromptRuleGroupCount int64
ScheduleTaskCount int64
KeywordCount int64
QuestionCount int64
CompetitorCount int64
ImageReferenceCount int64
MediaSupplyOrderCount int64
MediaSupplyOrderItemCount int64
ComplianceReviewJobCount int64
ComplianceManualReviewCount int64
MonitoringCollectRequestCount int64
MonitoringCollectOutboxCount int64
MonitoringCollectTaskCount int64
MonitoringSnapshotCount int64
MonitoringRunCount int64
MonitoringParseResultCount int64
MonitoringCitationFactCount int64
MonitoringBrandDailyCount int64
MonitoringBrandPlatformCount int64
MonitoringArticleAliasCount int64
MonitoringMarkedArticleCount int64
MarkedDeletedBrandCount int64
CompletedBrandCount int64
}
type monitoringCleanupResult struct {
Stats brandCleanupStats
MonitorTaskIDs []int64
}
func NewBrandAssetCleanupWorker(pool, monitoringPool *pgxpool.Pool, cache sharedcache.Cache, logger *zap.Logger) *BrandAssetCleanupWorker {
return &BrandAssetCleanupWorker{
pool: pool,
monitoringPool: monitoringPool,
cache: cache,
logger: logger,
}
}
func (w *BrandAssetCleanupWorker) RunOnce(ctx context.Context, run JobRunContext) (map[string]any, error) {
stats := map[string]any{}
if w == nil || w.pool == nil {
stats["skipped"] = true
stats["reason"] = "worker_not_ready"
return stats, nil
}
startedAt := time.Now()
batchSize := AsInt(run.Config, "batch_size", defaultBrandAssetCleanupBatch)
if run.Job != nil && run.Job.BatchSize != nil {
batchSize = *run.Job.BatchSize
}
if batchSize <= 0 {
batchSize = defaultBrandAssetCleanupBatch
}
statementTimeout := AsDuration(run.Config, "statement_timeout", 5*time.Second)
dryRun := run.DryRun
stats["batch_size"] = batchSize
stats["dry_run"] = dryRun
conn, err := w.pool.Acquire(ctx)
if err != nil {
return stats, fmt.Errorf("acquire tenant connection: %w", err)
}
defer conn.Release()
if statementTimeout > 0 {
if _, err := conn.Exec(ctx, `SELECT set_config('statement_timeout', $1, false)`, fmt.Sprintf("%dms", statementTimeout.Milliseconds())); err != nil {
return stats, fmt.Errorf("set tenant statement timeout: %w", err)
}
defer conn.Exec(context.Background(), `RESET statement_timeout`)
}
candidates, err := w.claimCandidates(ctx, conn, batchSize)
if err != nil {
return stats, fmt.Errorf("claim brand cleanup candidates: %w", err)
}
total := brandCleanupStats{}
failures := int64(0)
for _, candidate := range candidates {
if dryRun {
brandStats, err := w.probeBrand(ctx, conn, candidate)
if err != nil {
failures++
w.warn("brand asset cleanup probe failed", candidate, err)
continue
}
total.add(brandStats)
continue
}
brandStats, err := w.cleanupBrand(ctx, conn, candidate)
if err != nil {
failures++
w.warn("brand asset cleanup failed", candidate, err)
continue
}
total.add(brandStats)
tenantapp.InvalidateBrandDeletionCaches(context.Background(), w.cache, candidate.TenantID, candidate.BrandID)
}
stats["candidate_count"] = len(candidates)
stats["failure_count"] = failures
total.writeTo(stats)
stats["duration_ms"] = time.Since(startedAt).Milliseconds()
return stats, nil
}
func (w *BrandAssetCleanupWorker) claimCandidates(ctx context.Context, conn *pgxpool.Conn, limit int) ([]brandCleanupCandidate, error) {
rows, err := conn.Query(ctx, `
SELECT tenant_id, id
FROM brands
WHERE deleted_at IS NOT NULL
AND status = 'deleting'
ORDER BY deleted_at ASC, id ASC
LIMIT $1
FOR UPDATE SKIP LOCKED
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]brandCleanupCandidate, 0, limit)
for rows.Next() {
var item brandCleanupCandidate
if err := rows.Scan(&item.TenantID, &item.BrandID); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (w *BrandAssetCleanupWorker) probeBrand(ctx context.Context, conn *pgxpool.Conn, c brandCleanupCandidate) (brandCleanupStats, error) {
stats := brandCleanupStats{BrandCount: 1}
if err := conn.QueryRow(ctx, `SELECT COUNT(*)::bigint FROM articles WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL`, c.TenantID, c.BrandID).Scan(&stats.ArticleCount); err != nil {
return stats, err
}
if err := conn.QueryRow(ctx, `SELECT COUNT(*)::bigint FROM brand_keywords WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL`, c.TenantID, c.BrandID).Scan(&stats.KeywordCount); err != nil {
return stats, err
}
if err := conn.QueryRow(ctx, `SELECT COUNT(*)::bigint FROM brand_questions WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL`, c.TenantID, c.BrandID).Scan(&stats.QuestionCount); err != nil {
return stats, err
}
if err := conn.QueryRow(ctx, `SELECT COUNT(*)::bigint FROM competitors WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL`, c.TenantID, c.BrandID).Scan(&stats.CompetitorCount); err != nil {
return stats, err
}
return stats, nil
}
func (w *BrandAssetCleanupWorker) cleanupBrand(ctx context.Context, conn *pgxpool.Conn, c brandCleanupCandidate) (brandCleanupStats, error) {
stats := brandCleanupStats{BrandCount: 1}
monitorTaskIDs, err := w.loadMonitoringTaskIDsForBrand(ctx, c)
if err != nil {
return stats, err
}
tx, err := conn.Begin(ctx)
if err != nil {
return stats, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
articleIDs, err := w.loadBrandArticleIDs(ctx, tx, c)
if err != nil {
return stats, err
}
articleIDArg := nullableInt64Array(articleIDs)
generationStats, err := w.cleanupGenerationAssets(ctx, tx, c, articleIDArg)
if err != nil {
return stats, err
}
stats.GenerationTaskCount += generationStats.GenerationTaskCount
stats.GenerationTaskRecordCount += generationStats.GenerationTaskRecordCount
stats.KolUsageCount += generationStats.KolUsageCount
stats.QuotaReservationRefundCount += generationStats.QuotaReservationRefundCount
count, err := execRows(ctx, tx, `
UPDATE articles
SET deleted_at = COALESCE(deleted_at, NOW()),
generate_status = CASE WHEN generate_status IN ('pending', 'generating', 'running') THEN 'failed' ELSE generate_status END,
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.ArticleCount += count
count, err = execRows(ctx, tx, `
UPDATE image_asset_references
SET updated_at = NOW()
WHERE tenant_id = $1
AND article_id = ANY($2::bigint[])
`, c.TenantID, articleIDArg)
if err != nil {
return stats, err
}
stats.ImageReferenceCount += count
if _, err := tx.Exec(ctx, `
DELETE FROM image_asset_references
WHERE tenant_id = $1
AND article_id = ANY($2::bigint[])
`, c.TenantID, articleIDArg); err != nil {
return stats, err
}
desktopStats, err := w.cancelDesktopPublishAssets(ctx, tx, c, articleIDArg)
if err != nil {
return stats, err
}
stats.DesktopPublishJobCount += desktopStats.DesktopPublishJobCount
stats.DesktopPublishTaskCount += desktopStats.DesktopPublishTaskCount
stats.CancelledPublishRecordCount += desktopStats.CancelledPublishRecordCount
stats.PublishBatchCount += desktopStats.PublishBatchCount
count, err = execRows(ctx, tx, `
UPDATE desktop_tasks
SET status = 'aborted',
error = jsonb_build_object(
'code', 'brand_deleted',
'message', 'brand deleted before monitor task completion',
'source', 'brand_asset_cleanup'
),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND kind = 'monitor'
AND status IN ('queued', 'in_progress', 'unknown')
AND monitor_task_id = ANY($2::bigint[])
`, c.TenantID, nullableInt64Array(monitorTaskIDs))
if err != nil {
return stats, err
}
stats.DesktopMonitorTaskCount += count
count, err = execRows(ctx, tx, `
UPDATE prompt_rules
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.PromptRuleCount += count
count, err = execRows(ctx, tx, `
UPDATE prompt_rule_groups
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.PromptRuleGroupCount += count
count, err = execRows(ctx, tx, `
UPDATE schedule_tasks
SET deleted_at = COALESCE(deleted_at, NOW()),
status = CASE WHEN status = 'enabled' THEN 'disabled' ELSE status END,
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.ScheduleTaskCount += count
count, err = execRows(ctx, tx, `
UPDATE brand_keywords
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.KeywordCount += count
count, err = execRows(ctx, tx, `
UPDATE brand_questions
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.QuestionCount += count
count, err = execRows(ctx, tx, `
UPDATE competitors
SET deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.CompetitorCount += count
mediaStats, err := w.cleanupMediaSupply(ctx, tx, c)
if err != nil {
return stats, err
}
stats.MediaSupplyOrderCount += mediaStats.MediaSupplyOrderCount
stats.MediaSupplyOrderItemCount += mediaStats.MediaSupplyOrderItemCount
complianceStats, err := w.cancelComplianceAssets(ctx, tx, c, articleIDArg)
if err != nil {
return stats, err
}
stats.ComplianceReviewJobCount += complianceStats.ComplianceReviewJobCount
stats.ComplianceManualReviewCount += complianceStats.ComplianceManualReviewCount
if err := tx.Commit(ctx); err != nil {
return stats, err
}
committed = true
monitoringCleanup, err := w.cleanupMonitoringBrand(ctx, c, articleIDs)
if err != nil {
return stats, err
}
stats.add(monitoringCleanup.Stats)
tag, err := conn.Exec(ctx, `
UPDATE brands
SET status = 'deleted',
updated_at = NOW()
WHERE tenant_id = $1
AND id = $2
AND deleted_at IS NOT NULL
AND status = 'deleting'
`, c.TenantID, c.BrandID)
if err != nil {
return stats, err
}
stats.CompletedBrandCount += tag.RowsAffected()
return stats, nil
}
func (w *BrandAssetCleanupWorker) loadBrandArticleIDs(ctx context.Context, tx pgx.Tx, c brandCleanupCandidate) ([]int64, error) {
rows, err := tx.Query(ctx, `
SELECT id
FROM articles
WHERE tenant_id = $1
AND brand_id = $2
ORDER BY id ASC
`, c.TenantID, c.BrandID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (w *BrandAssetCleanupWorker) loadMonitoringTaskIDsForBrand(ctx context.Context, c brandCleanupCandidate) ([]int64, error) {
if w == nil || w.monitoringPool == nil {
return nil, nil
}
conn, err := w.monitoringPool.Acquire(ctx)
if err != nil {
return nil, fmt.Errorf("acquire monitoring connection: %w", err)
}
defer conn.Release()
return w.loadMonitoringTaskIDs(ctx, conn, c)
}
func (w *BrandAssetCleanupWorker) cleanupGenerationAssets(ctx context.Context, tx pgx.Tx, c brandCleanupCandidate, articleIDs any) (brandCleanupStats, error) {
stats := brandCleanupStats{}
if err := tx.QueryRow(ctx, `
WITH affected_tasks AS (
UPDATE generation_tasks
SET status = 'failed',
error_message = COALESCE(NULLIF(error_message, ''), 'brand_deleted'),
completed_at = COALESCE(completed_at, NOW()),
lease_token = NULL,
lease_owner = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND article_id = ANY($2::bigint[])
AND status IN ('queued', 'running')
RETURNING id, quota_reservation_id
),
updated_task_records AS (
UPDATE task_records tr
SET status = 'failed',
error_message = COALESCE(NULLIF(error_message, ''), 'brand_deleted'),
updated_at = NOW()
FROM affected_tasks t
WHERE tr.tenant_id = $1
AND tr.task_type = 'article_generation'
AND tr.resource_type = 'generation_task'
AND tr.resource_id = t.id
AND tr.status IN ('pending', 'queued', 'running')
RETURNING tr.id
),
updated_kol_usage AS (
UPDATE kol_usage_logs u
SET status = 'failed',
error_message = COALESCE(NULLIF(error_message, ''), 'brand_deleted'),
finished_at = COALESCE(finished_at, NOW())
FROM affected_tasks t
WHERE u.tenant_id = $1
AND u.generation_task_id = t.id
AND u.status IN ('pending', 'running')
RETURNING u.id
),
refunded_reservations AS (
UPDATE quota_reservations qr
SET status = 'refunded',
refunded_amount = reserved_amount,
updated_at = NOW()
FROM affected_tasks t
WHERE qr.id = t.quota_reservation_id
AND qr.tenant_id = $1
AND qr.status = 'pending'
RETURNING qr.id
)
SELECT
(SELECT COUNT(*)::bigint FROM affected_tasks),
(SELECT COUNT(*)::bigint FROM updated_task_records),
(SELECT COUNT(*)::bigint FROM updated_kol_usage),
(SELECT COUNT(*)::bigint FROM refunded_reservations)
`, c.TenantID, articleIDs).Scan(
&stats.GenerationTaskCount,
&stats.GenerationTaskRecordCount,
&stats.KolUsageCount,
&stats.QuotaReservationRefundCount,
); err != nil {
return stats, err
}
return stats, nil
}
func (w *BrandAssetCleanupWorker) cancelDesktopPublishAssets(ctx context.Context, tx pgx.Tx, c brandCleanupCandidate, articleIDs any) (brandCleanupStats, error) {
stats := brandCleanupStats{}
if err := tx.QueryRow(ctx, `
WITH affected_records AS (
UPDATE publish_records pr
SET status = 'cancelled',
error_message = COALESCE(NULLIF(pr.error_message, ''), 'brand_deleted'),
updated_at = NOW()
WHERE pr.tenant_id = $1
AND pr.article_id = ANY($2::bigint[])
AND pr.deleted_at IS NULL
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
RETURNING pr.publish_batch_id
),
affected_batches AS (
SELECT DISTINCT publish_batch_id
FROM affected_records
WHERE publish_batch_id IS NOT NULL
),
updated_batches AS (
UPDATE publish_batches pb
SET status = CASE
WHEN EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
) THEN 'publishing'
WHEN EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status IN ('success', 'published', 'publish_success')
) AND EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status NOT IN ('success', 'published', 'publish_success')
) THEN 'partial_success'
WHEN EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status IN ('success', 'published', 'publish_success')
) THEN 'success'
ELSE 'failed'
END,
updated_at = NOW()
FROM affected_batches ab
WHERE pb.id = ab.publish_batch_id
AND pb.tenant_id = $1
RETURNING pb.id
)
SELECT
(SELECT COUNT(*)::bigint FROM affected_records),
(SELECT COUNT(*)::bigint FROM updated_batches)
`, c.TenantID, articleIDs).Scan(&stats.CancelledPublishRecordCount, &stats.PublishBatchCount); err != nil {
return stats, err
}
if err := tx.QueryRow(ctx, `
WITH updated_tasks AS (
UPDATE desktop_tasks dt
SET status = 'aborted',
error = jsonb_build_object(
'code', 'brand_deleted',
'message', 'brand deleted before publish completion',
'source', 'brand_asset_cleanup'
),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = NOW()
FROM desktop_publish_jobs j
WHERE dt.job_id = j.desktop_id
AND dt.tenant_id = j.tenant_id
AND dt.tenant_id = $1
AND j.article_id = ANY($2::bigint[])
AND dt.kind = 'publish'
AND dt.status IN ('queued', 'in_progress', 'unknown')
RETURNING dt.job_id
),
updated_jobs AS (
UPDATE desktop_publish_jobs j
SET status = 'cancelled',
updated_at = NOW()
WHERE j.tenant_id = $1
AND j.article_id = ANY($2::bigint[])
AND j.status IN ('queued', 'blocked_by_compliance')
RETURNING j.desktop_id
)
SELECT
(SELECT COUNT(*)::bigint FROM updated_tasks),
(SELECT COUNT(*)::bigint FROM updated_jobs)
`, c.TenantID, articleIDs).Scan(&stats.DesktopPublishTaskCount, &stats.DesktopPublishJobCount); err != nil {
return stats, err
}
return stats, nil
}
func (w *BrandAssetCleanupWorker) cleanupMediaSupply(ctx context.Context, tx pgx.Tx, c brandCleanupCandidate) (brandCleanupStats, error) {
stats := brandCleanupStats{}
if err := tx.QueryRow(ctx, `
WITH affected_orders AS (
UPDATE media_supply_orders
SET status = CASE WHEN status IN ('queued', 'submitting') THEN 'failed' ELSE status END,
error_message = CASE
WHEN status IN ('queued', 'submitting') THEN COALESCE(NULLIF(error_message, ''), 'brand_deleted')
ELSE error_message
END,
deleted_at = COALESCE(deleted_at, NOW()),
completed_at = CASE WHEN status IN ('queued', 'submitting') THEN COALESCE(completed_at, NOW()) ELSE completed_at END,
updated_at = NOW()
WHERE tenant_id = $1
AND brand_id = $2
AND deleted_at IS NULL
RETURNING id
),
affected_items AS (
UPDATE media_supply_order_items i
SET status = CASE WHEN i.status IN ('queued', 'submitting') THEN 'failed' ELSE i.status END,
updated_at = NOW()
FROM affected_orders o
WHERE i.order_id = o.id
RETURNING i.id
)
SELECT
(SELECT COUNT(*)::bigint FROM affected_orders),
(SELECT COUNT(*)::bigint FROM affected_items)
`, c.TenantID, c.BrandID).Scan(&stats.MediaSupplyOrderCount, &stats.MediaSupplyOrderItemCount); err != nil {
return stats, err
}
return stats, nil
}
func (w *BrandAssetCleanupWorker) cancelComplianceAssets(ctx context.Context, tx pgx.Tx, c brandCleanupCandidate, articleIDs any) (brandCleanupStats, error) {
stats := brandCleanupStats{}
count, err := execRows(ctx, tx, `
UPDATE compliance_review_jobs
SET status = 'failed',
last_error = COALESCE(NULLIF(last_error, ''), 'brand_deleted'),
updated_at = NOW()
WHERE tenant_id = $1
AND article_id = ANY($2::bigint[])
AND status IN ('pending', 'running')
`, c.TenantID, articleIDs)
if err != nil {
return stats, err
}
stats.ComplianceReviewJobCount += count
if err := tx.QueryRow(ctx, `
WITH affected_reviews AS (
UPDATE compliance_manual_reviews
SET status = 'cancelled',
cancelled_by = COALESCE(cancelled_by, requested_by),
cancelled_at = COALESCE(cancelled_at, NOW()),
cancel_reason = COALESCE(NULLIF(cancel_reason, ''), 'brand_deleted')
WHERE tenant_id = $1
AND article_id = ANY($2::bigint[])
AND status = 'pending'
RETURNING article_version_id
),
updated_versions AS (
UPDATE article_versions av
SET manual_review_status = 'none',
manual_review_id = NULL,
manual_review_updated_at = NOW()
FROM affected_reviews r
WHERE av.id = r.article_version_id
RETURNING av.id
)
SELECT COUNT(*)::bigint FROM affected_reviews
`, c.TenantID, articleIDs).Scan(&stats.ComplianceManualReviewCount); err != nil {
return stats, err
}
return stats, nil
}
func (w *BrandAssetCleanupWorker) cleanupMonitoringBrand(ctx context.Context, c brandCleanupCandidate, articleIDs []int64) (monitoringCleanupResult, error) {
result := monitoringCleanupResult{}
if w == nil || w.monitoringPool == nil {
return result, nil
}
conn, err := w.monitoringPool.Acquire(ctx)
if err != nil {
return result, fmt.Errorf("acquire monitoring connection: %w", err)
}
defer conn.Release()
monitorTaskIDs, err := w.loadMonitoringTaskIDs(ctx, conn, c)
if err != nil {
return result, err
}
result.MonitorTaskIDs = monitorTaskIDs
tx, err := conn.Begin(ctx)
if err != nil {
return result, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
count, err := execRows(ctx, tx, `
DELETE FROM monitoring_collect_dispatch_outbox
WHERE task_id IN (
SELECT id
FROM monitoring_collect_tasks
WHERE tenant_id = $1 AND brand_id = $2
)
OR request_id IN (
SELECT request_id
FROM monitoring_collect_requests
WHERE tenant_id = $1 AND brand_id = $2
)
`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringCollectOutboxCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_collect_requests WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringCollectRequestCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_citation_facts WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringCitationFactCount += count
count, err = execRows(ctx, tx, `DELETE FROM question_monitor_parse_results WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringParseResultCount += count
count, err = execRows(ctx, tx, `DELETE FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringRunCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_collect_tasks WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringCollectTaskCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringBrandPlatformCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_brand_daily WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringBrandDailyCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_question_config_snapshots WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringSnapshotCount += count
count, err = execRows(ctx, tx, `
DELETE FROM monitoring_article_url_aliases
WHERE tenant_id = $1
AND article_id = ANY($2::bigint[])
`, c.TenantID, nullableInt64Array(articleIDs))
if err != nil {
return result, err
}
result.Stats.MonitoringArticleAliasCount += count
count, err = execRows(ctx, tx, `DELETE FROM monitoring_user_marked_articles WHERE tenant_id = $1 AND brand_id = $2`, c.TenantID, c.BrandID)
if err != nil {
return result, err
}
result.Stats.MonitoringMarkedArticleCount += count
if err := tx.Commit(ctx); err != nil {
return result, err
}
committed = true
return result, nil
}
func (w *BrandAssetCleanupWorker) loadMonitoringTaskIDs(ctx context.Context, conn *pgxpool.Conn, c brandCleanupCandidate) ([]int64, error) {
rows, err := conn.Query(ctx, `
SELECT id
FROM monitoring_collect_tasks
WHERE tenant_id = $1
AND brand_id = $2
`, c.TenantID, c.BrandID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func execRows(ctx context.Context, tx pgx.Tx, sql string, args ...any) (int64, error) {
tag, err := tx.Exec(ctx, sql, args...)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
func nullableInt64Array(values []int64) any {
if len(values) == 0 {
return []int64{-1}
}
return values
}
func (s *brandCleanupStats) add(other brandCleanupStats) {
s.BrandCount += other.BrandCount
s.ArticleCount += other.ArticleCount
s.GenerationTaskCount += other.GenerationTaskCount
s.GenerationTaskRecordCount += other.GenerationTaskRecordCount
s.KolUsageCount += other.KolUsageCount
s.QuotaReservationRefundCount += other.QuotaReservationRefundCount
s.CancelledPublishRecordCount += other.CancelledPublishRecordCount
s.PublishBatchCount += other.PublishBatchCount
s.DesktopPublishJobCount += other.DesktopPublishJobCount
s.DesktopPublishTaskCount += other.DesktopPublishTaskCount
s.DesktopMonitorTaskCount += other.DesktopMonitorTaskCount
s.PromptRuleCount += other.PromptRuleCount
s.PromptRuleGroupCount += other.PromptRuleGroupCount
s.ScheduleTaskCount += other.ScheduleTaskCount
s.KeywordCount += other.KeywordCount
s.QuestionCount += other.QuestionCount
s.CompetitorCount += other.CompetitorCount
s.ImageReferenceCount += other.ImageReferenceCount
s.MediaSupplyOrderCount += other.MediaSupplyOrderCount
s.MediaSupplyOrderItemCount += other.MediaSupplyOrderItemCount
s.ComplianceReviewJobCount += other.ComplianceReviewJobCount
s.ComplianceManualReviewCount += other.ComplianceManualReviewCount
s.MonitoringCollectRequestCount += other.MonitoringCollectRequestCount
s.MonitoringCollectOutboxCount += other.MonitoringCollectOutboxCount
s.MonitoringCollectTaskCount += other.MonitoringCollectTaskCount
s.MonitoringSnapshotCount += other.MonitoringSnapshotCount
s.MonitoringRunCount += other.MonitoringRunCount
s.MonitoringParseResultCount += other.MonitoringParseResultCount
s.MonitoringCitationFactCount += other.MonitoringCitationFactCount
s.MonitoringBrandDailyCount += other.MonitoringBrandDailyCount
s.MonitoringBrandPlatformCount += other.MonitoringBrandPlatformCount
s.MonitoringArticleAliasCount += other.MonitoringArticleAliasCount
s.MonitoringMarkedArticleCount += other.MonitoringMarkedArticleCount
s.MarkedDeletedBrandCount += other.MarkedDeletedBrandCount
s.CompletedBrandCount += other.CompletedBrandCount
}
func (s brandCleanupStats) writeTo(out map[string]any) {
out["brand_count"] = s.BrandCount
out["article_count"] = s.ArticleCount
out["generation_task_count"] = s.GenerationTaskCount
out["generation_task_record_count"] = s.GenerationTaskRecordCount
out["kol_usage_count"] = s.KolUsageCount
out["quota_reservation_refund_count"] = s.QuotaReservationRefundCount
out["cancelled_publish_record_count"] = s.CancelledPublishRecordCount
out["publish_batch_count"] = s.PublishBatchCount
out["desktop_publish_job_count"] = s.DesktopPublishJobCount
out["desktop_publish_task_count"] = s.DesktopPublishTaskCount
out["desktop_monitor_task_count"] = s.DesktopMonitorTaskCount
out["prompt_rule_count"] = s.PromptRuleCount
out["prompt_rule_group_count"] = s.PromptRuleGroupCount
out["schedule_task_count"] = s.ScheduleTaskCount
out["keyword_count"] = s.KeywordCount
out["question_count"] = s.QuestionCount
out["competitor_count"] = s.CompetitorCount
out["image_reference_count"] = s.ImageReferenceCount
out["media_supply_order_count"] = s.MediaSupplyOrderCount
out["media_supply_order_item_count"] = s.MediaSupplyOrderItemCount
out["compliance_review_job_count"] = s.ComplianceReviewJobCount
out["compliance_manual_review_count"] = s.ComplianceManualReviewCount
out["monitoring_collect_request_count"] = s.MonitoringCollectRequestCount
out["monitoring_collect_outbox_count"] = s.MonitoringCollectOutboxCount
out["monitoring_collect_task_count"] = s.MonitoringCollectTaskCount
out["monitoring_snapshot_count"] = s.MonitoringSnapshotCount
out["monitoring_run_count"] = s.MonitoringRunCount
out["monitoring_parse_result_count"] = s.MonitoringParseResultCount
out["monitoring_citation_fact_count"] = s.MonitoringCitationFactCount
out["monitoring_brand_daily_count"] = s.MonitoringBrandDailyCount
out["monitoring_brand_platform_count"] = s.MonitoringBrandPlatformCount
out["monitoring_article_alias_count"] = s.MonitoringArticleAliasCount
out["monitoring_marked_article_count"] = s.MonitoringMarkedArticleCount
out["completed_brand_count"] = s.CompletedBrandCount
}
func (w *BrandAssetCleanupWorker) warn(message string, c brandCleanupCandidate, err error) {
if w == nil || w.logger == nil {
return
}
w.logger.Warn(message,
zap.Int64("tenant_id", c.TenantID),
zap.Int64("brand_id", c.BrandID),
zap.Error(err),
)
}
@@ -0,0 +1,43 @@
package scheduler
import (
"context"
"testing"
)
func TestBrandAssetCleanupRunOnceSkipsWhenStoreMissing(t *testing.T) {
stats, err := (*BrandAssetCleanupWorker)(nil).RunOnce(context.Background(), JobRunContext{})
if err != nil {
t.Fatalf("RunOnce returned error: %v", err)
}
if stats["skipped"] != true {
t.Fatalf("stats[skipped] = %v, want true", stats["skipped"])
}
if stats["reason"] != "worker_not_ready" {
t.Fatalf("stats[reason] = %v, want worker_not_ready", stats["reason"])
}
}
func TestBrandCleanupStatsWriteToIncludesTerminalMetrics(t *testing.T) {
stats := brandCleanupStats{
BrandCount: 1,
ArticleCount: 2,
MonitoringCollectTaskCount: 3,
MonitoringCitationFactCount: 4,
CompletedBrandCount: 1,
}
out := map[string]any{}
stats.writeTo(out)
for key, want := range map[string]any{
"brand_count": int64(1),
"article_count": int64(2),
"monitoring_collect_task_count": int64(3),
"monitoring_citation_fact_count": int64(4),
"completed_brand_count": int64(1),
} {
if got := out[key]; got != want {
t.Fatalf("out[%s] = %v, want %v", key, got, want)
}
}
}
@@ -197,7 +197,8 @@ var routeDocs = map[string]routeDoc{
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
"GET /api/tenant/publish-tasks": {"发布任务列表(后台)", "管理后台分页查询本 Workspace 下的发布队列与历史发送结果。"},
"GET /api/tenant/publish-records": {"发布记录列表(后台)", "管理后台分页查询当前品牌的发布中记录和历史发布记录。"},
"GET /api/tenant/publish-records": {"发布记录列表(后台)", "管理后台分页查询当前用户的发布中记录和历史发布记录;已删除品牌会返回删除标记。"},
"DELETE /api/tenant/publish-records/:id": {"删除发布记录(后台)", "管理后台软删除当前用户发起的发布记录,并取消关联的等待发布任务。"},
"POST /api/tenant/publish-tasks/:id/retry": {"重试发布任务(后台)", "管理后台对失败的发布任务重新创建一次发布任务。"},
"POST /api/tenant/publish-jobs": {"批量创建发布任务", "为一批文章/账号批量生成发布任务,进入派发队列。"},
@@ -327,7 +328,7 @@ var routeDocs = map[string]routeDoc{
"POST /api/tenant/brands": {"新建品牌", "创建一个新品牌。"},
"GET /api/tenant/brands/:id": {"品牌详情", "返回品牌基础信息及统计。"},
"PUT /api/tenant/brands/:id": {"更新品牌", "更新品牌名称、行业、官网等。"},
"DELETE /api/tenant/brands/:id": {"删除品牌", "删除品牌及其下属关键词、问题、竞品。"},
"DELETE /api/tenant/brands/:id": {"删除品牌", "标记品牌删除并由后台任务静默清理其文章、词库、竞品和采集数据;发布记录保留在用户发文总账。"},
"GET /api/tenant/brands/:id/keywords": {"品牌关键词列表", "返回某品牌下的关键词。"},
"POST /api/tenant/brands/:id/keywords": {"新增关键词", "为某品牌添加关键词。"},
@@ -122,6 +122,7 @@ const articlePublishStatusAggregateJoin = `
FROM publish_records pr
WHERE pr.tenant_id = a.tenant_id
AND pr.article_id = a.id
AND pr.deleted_at IS NULL
ORDER BY
COALESCE(pr.target_type, 'platform_account'),
COALESCE(pr.platform_account_id, pr.target_connection_id),
+27 -8
View File
@@ -278,19 +278,38 @@ func (s *BrandService) Delete(ctx context.Context, id int64) error {
_ = tx.Rollback(ctx)
}()
tag, err := tx.Exec(ctx, `UPDATE brands SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
tag, err := tx.Exec(ctx, `
UPDATE brands
SET status = 'deleting',
deleted_at = COALESCE(deleted_at, NOW()),
updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, id, actor.TenantID)
if err != nil {
return response.ErrInternal(50010, "delete_failed", "failed to mark brand for deletion")
}
if tag.RowsAffected() == 0 {
var alreadyDeleted bool
if lookupErr := tx.QueryRow(ctx, `
SELECT deleted_at IS NOT NULL
FROM brands
WHERE id = $1 AND tenant_id = $2
`, id, actor.TenantID).Scan(&alreadyDeleted); lookupErr != nil {
if errors.Is(lookupErr, pgx.ErrNoRows) {
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
return response.ErrInternal(50010, "delete_failed", "failed to inspect brand deletion state")
}
if !alreadyDeleted {
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
return nil
}
_, _ = tx.Exec(ctx, `UPDATE brand_keywords SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
_, _ = tx.Exec(ctx, `UPDATE brand_questions SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
_, _ = tx.Exec(ctx, `UPDATE competitors SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
if err := s.cleanupMonitoringAfterBrandDelete(ctx, actor.TenantID, id); err != nil {
return err
}
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "action": "cascade_soft_delete"})
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "status": "deleting", "cleanup": "background"})
if err := tx.Commit(ctx); err != nil {
return err
}
@@ -158,6 +158,13 @@ func invalidateBrandCaches(ctx context.Context, c sharedcache.Cache, tenantID, b
invalidateWorkspaceCaches(ctx, c, tenantID)
}
func InvalidateBrandDeletionCaches(ctx context.Context, c sharedcache.Cache, tenantID, brandID int64) {
invalidateBrandCaches(ctx, c, tenantID, brandID)
invalidateArticleCaches(ctx, c, tenantID, nil)
invalidatePromptRuleCaches(ctx, c, tenantID, brandID, nil)
invalidateScheduleTaskCaches(ctx, c, tenantID, nil)
}
func promptRuleGroupsCacheKey(tenantID, brandID int64) string {
return fmt.Sprintf("prompt_rule:groups:%d:%d", tenantID, brandID)
}
@@ -973,7 +973,10 @@ func (s *DesktopTaskService) Cancel(ctx context.Context, client *repository.Desk
if req.LeaseToken != nil && strings.TrimSpace(*req.LeaseToken) != "" {
task, err = taskRepo.CancelByLease(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(*req.LeaseToken)), errorJSON)
} else {
task, err = taskRepo.CancelByClient(ctx, desktopID, client.ID, errorJSON)
task, err = taskRepo.CancelQueuedPublishByOwner(ctx, desktopID, client.TenantID, client.WorkspaceID, client.UserID, errorJSON)
if errors.Is(err, pgx.ErrNoRows) {
task, err = taskRepo.CancelByClient(ctx, desktopID, client.ID, errorJSON)
}
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -1015,7 +1018,8 @@ func (s *DesktopTaskService) Cancel(ctx context.Context, client *repository.Desk
}
func (s *DesktopTaskService) TenantCancel(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, req CancelDesktopTaskRequest) (*DesktopTaskView, error) {
if actor.PrimaryWorkspaceID == 0 {
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
@@ -1027,7 +1031,14 @@ func (s *DesktopTaskService) TenantCancel(ctx context.Context, actor auth.Actor,
return nil, response.ErrBadRequest(40088, "invalid_desktop_task_cancel", "cancel payload must be serializable")
}
task, err := s.repo.TenantCancel(ctx, desktopID, actor.PrimaryWorkspaceID, errorJSON)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50107, "desktop_task_cancel_begin_failed", "failed to start desktop task cancel")
}
defer tx.Rollback(ctx)
taskRepo := repository.NewDesktopTaskRepository(tx)
task, err := taskRepo.TenantCancel(ctx, desktopID, actor.TenantID, workspaceID, actor.UserID, errorJSON)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40984, "desktop_task_cancel_conflict", "desktop task cannot be canceled in its current state")
@@ -1035,7 +1046,17 @@ func (s *DesktopTaskService) TenantCancel(ctx context.Context, actor auth.Actor,
return nil, response.ErrInternal(50093, "desktop_task_tenant_cancel_failed", "failed to cancel desktop task")
}
publishOutcome, err := syncDesktopPublishTaskState(ctx, tx, task)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50108, "desktop_task_cancel_commit_failed", "failed to commit desktop task cancel")
}
s.publishTaskEvent(ctx, task, "task_canceled")
s.afterRecoveredPublishOutcomes(ctx, []*desktopPublishSyncOutcome{publishOutcome})
view := buildDesktopTaskView(task)
return &view, nil
@@ -269,6 +269,7 @@ func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConne
WHERE pr.tenant_id = $1
AND pr.target_type = 'enterprise_site'
AND pr.target_connection_id IS NOT NULL
AND pr.deleted_at IS NULL
ORDER BY pr.target_connection_id, pr.created_at DESC, pr.id DESC
)
SELECT
@@ -1022,7 +1023,7 @@ func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
published_at = $5,
error_message = $6,
updated_at = NOW()
WHERE id = $7 AND tenant_id = $8
WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL
`, status, externalID, externalURL, nullableJSON(responsePayload), publishedAt, errorMessage, publishRecordID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_record_update_failed", "企业站点发布结果更新失败")
}
+153 -16
View File
@@ -4,9 +4,11 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
@@ -38,6 +40,9 @@ type PublishRecordResponse struct {
ID int64 `json:"id"`
PublishBatchID int64 `json:"publish_batch_id"`
ArticleID int64 `json:"article_id"`
BrandID int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
BrandDeleted bool `json:"brand_deleted"`
ArticleTitle *string `json:"article_title,omitempty"`
PlatformAccountID *int64 `json:"platform_account_id"`
DesktopAccountID *string `json:"desktop_account_id"`
@@ -72,6 +77,10 @@ type PublishRecordListResponse struct {
HistoryTotal int `json:"history_total"`
}
type DeletePublishRecordResponse struct {
Deleted bool `json:"deleted"`
}
func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformResponse, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order
@@ -108,9 +117,8 @@ func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformRespon
func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRecordsRequest) (*PublishRecordListResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
if actor.TenantID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
page := req.Page
@@ -125,11 +133,11 @@ func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRe
pendingStatuses := []string{"queued", "publishing"}
historyStatuses := []string{"success", "failed", "cancelled", "canceled"}
pendingItems, err := s.listPublishRecordsByStatuses(ctx, actor.TenantID, brandID, pendingStatuses, title, 0, 0, "pr.created_at ASC, pr.id ASC")
pendingItems, err := s.listPublishRecordsByStatuses(ctx, actor.TenantID, actor.UserID, pendingStatuses, title, 0, 0, "pr.created_at ASC, pr.id ASC")
if err != nil {
return nil, err
}
historyTotal, err := s.countPublishRecordsByStatuses(ctx, actor.TenantID, brandID, historyStatuses, title)
historyTotal, err := s.countPublishRecordsByStatuses(ctx, actor.TenantID, actor.UserID, historyStatuses, title)
if err != nil {
return nil, err
}
@@ -152,7 +160,7 @@ func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRe
historyOffset = 0
}
if remaining > 0 && historyOffset < historyTotal {
historyItems, listErr := s.listPublishRecordsByStatuses(ctx, actor.TenantID, brandID, historyStatuses, title, remaining, historyOffset, "pr.updated_at DESC, pr.id DESC")
historyItems, listErr := s.listPublishRecordsByStatuses(ctx, actor.TenantID, actor.UserID, historyStatuses, title, remaining, historyOffset, "pr.updated_at DESC, pr.id DESC")
if listErr != nil {
return nil, listErr
}
@@ -172,7 +180,7 @@ func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRe
func (s *MediaService) listPublishRecordsByStatuses(
ctx context.Context,
tenantID int64,
brandID int64,
userID int64,
statuses []string,
title string,
limit int,
@@ -181,6 +189,7 @@ func (s *MediaService) listPublishRecordsByStatuses(
) ([]PublishRecordResponse, error) {
query := `
SELECT pr.id, pr.publish_batch_id, pr.article_id,
a.brand_id, b.name AS brand_name, (b.id IS NULL OR b.deleted_at IS NOT NULL OR b.status IN ('deleting', 'deleted')) AS brand_deleted,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', '')) AS article_title,
pr.platform_account_id, pa.desktop_id::text, dt.desktop_id::text,
COALESCE(pr.target_type, 'platform_account'), pr.target_connection_id, pr.platform_id,
@@ -194,6 +203,7 @@ func (s *MediaService) listPublishRecordsByStatuses(
pr.external_manage_url, pr.published_at, pr.error_message, pr.created_at, pr.updated_at
FROM publish_records pr
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
LEFT JOIN brands b ON b.id = a.brand_id AND b.tenant_id = a.tenant_id
LEFT JOIN article_versions av ON av.id = a.current_version_id AND av.article_id = a.id
LEFT JOIN media_platforms mp ON mp.platform_id = pr.platform_id
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
@@ -210,15 +220,21 @@ func (s *MediaService) listPublishRecordsByStatuses(
LIMIT 1
) dt ON TRUE
WHERE pr.tenant_id = $1
AND a.brand_id = $2
AND a.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM publish_batches pb
WHERE pb.id = pr.publish_batch_id
AND pb.tenant_id = pr.tenant_id
AND pb.initiator_user_id = $2
)
AND pr.deleted_at IS NULL
AND pr.status = ANY($3)
AND (
$4 = ''
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
)
`
args := []any{tenantID, brandID, statuses, title}
args := []any{tenantID, userID, statuses, title}
if strings.TrimSpace(orderBy) != "" {
query += "\nORDER BY " + orderBy
@@ -243,6 +259,9 @@ func (s *MediaService) listPublishRecordsByStatuses(
&item.ID,
&item.PublishBatchID,
&item.ArticleID,
&item.BrandID,
&item.BrandName,
&item.BrandDeleted,
&item.ArticleTitle,
&item.PlatformAccountID,
&desktopAccountID,
@@ -281,7 +300,7 @@ func (s *MediaService) listPublishRecordsByStatuses(
func (s *MediaService) countPublishRecordsByStatuses(
ctx context.Context,
tenantID int64,
brandID int64,
userID int64,
statuses []string,
title string,
) (int, error) {
@@ -292,14 +311,20 @@ func (s *MediaService) countPublishRecordsByStatuses(
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
LEFT JOIN article_versions av ON av.id = a.current_version_id AND av.article_id = a.id
WHERE pr.tenant_id = $1
AND a.brand_id = $2
AND a.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM publish_batches pb
WHERE pb.id = pr.publish_batch_id
AND pb.tenant_id = pr.tenant_id
AND pb.initiator_user_id = $2
)
AND pr.deleted_at IS NULL
AND pr.status = ANY($3)
AND (
$4 = ''
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
)
`, tenantID, brandID, statuses, title).Scan(&count)
`, tenantID, userID, statuses, title).Scan(&count)
if err != nil {
return 0, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
}
@@ -317,7 +342,9 @@ func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID
func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, brandID, articleID int64) ([]PublishRecordResponse, error) {
rows, err := s.pool.Query(ctx, `
SELECT pr.id, pr.publish_batch_id, pr.article_id, pr.platform_account_id, pa.desktop_id::text,
SELECT pr.id, pr.publish_batch_id, pr.article_id,
a.brand_id, b.name AS brand_name, (b.id IS NULL OR b.deleted_at IS NOT NULL OR b.status IN ('deleting', 'deleted')) AS brand_deleted,
pr.platform_account_id, pa.desktop_id::text,
COALESCE(pr.target_type, 'platform_account'), pr.target_connection_id, pr.platform_id,
COALESCE(mp.name, esc.site_name, esc.name, pr.platform_id) AS platform_name,
CASE
@@ -334,7 +361,8 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
ON esc.id = pr.target_connection_id
AND esc.tenant_id = pr.tenant_id
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
LEFT JOIN brands b ON b.id = a.brand_id AND b.tenant_id = a.tenant_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL AND pr.deleted_at IS NULL
ORDER BY pr.created_at DESC, pr.id DESC
`, tenantID, articleID, brandID)
if err != nil {
@@ -350,6 +378,9 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
&item.ID,
&item.PublishBatchID,
&item.ArticleID,
&item.BrandID,
&item.BrandName,
&item.BrandDeleted,
&item.PlatformAccountID,
&desktopAccountID,
&item.TargetType,
@@ -377,6 +408,112 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
return items, nil
}
func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64) (*DeletePublishRecordResponse, error) {
if recordID <= 0 {
return nil, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number")
}
actor := auth.MustActor(ctx)
if actor.TenantID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_begin_failed", "failed to delete publish record")
}
defer tx.Rollback(ctx)
var publishBatchID int64
var articleID int64
var cancelErrorJSON []byte
cancelErrorJSON, err = marshalOptionalJSON(map[string]any{
"reason": "publish_record_deleted",
"source": "tenant_web",
})
if err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_payload_failed", "failed to delete publish record")
}
err = tx.QueryRow(ctx, `
SELECT pr.publish_batch_id, pr.article_id
FROM publish_records pr
JOIN articles a
ON a.id = pr.article_id
AND a.tenant_id = pr.tenant_id
JOIN publish_batches pb
ON pb.id = pr.publish_batch_id
AND pb.tenant_id = pr.tenant_id
WHERE pr.id = $1
AND pr.tenant_id = $2
AND pb.initiator_user_id = $3
AND pr.deleted_at IS NULL
FOR UPDATE OF pr
`, recordID, actor.TenantID, actor.UserID).Scan(&publishBatchID, &articleID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
}
return nil, response.ErrInternal(50044, "publish_record_delete_lookup_failed", "failed to delete publish record")
}
if tag, err := tx.Exec(ctx, `
UPDATE publish_records
SET deleted_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, recordID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_failed", "failed to delete publish record")
} else if tag.RowsAffected() == 0 {
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
}
if _, err := tx.Exec(ctx, `
UPDATE desktop_tasks
SET status = 'aborted',
error = $3,
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND kind = 'publish'
AND status = 'queued'
AND payload->>'publish_record_id' = $2
`, actor.TenantID, recordID, cancelErrorJSON); err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_cancel_task_failed", "failed to cancel queued publish task before deleting record")
}
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE publish_batches
SET status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
`, batchStatus, publishBatchID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50054, "publish_batch_update_failed", "failed to update publish batch")
}
articleStatus, err := recalculateArticlePublishStatus(ctx, tx, actor.TenantID, articleID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, articleStatus, articleID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_commit_failed", "failed to delete publish record")
}
return &DeletePublishRecordResponse{Deleted: true}, nil
}
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
for _, account := range accounts {
if publishPlatformRequiresCover(account.PlatformID) {
@@ -566,6 +566,7 @@ func loadExistingPublishRecordTargets(
AND pa.workspace_id = $2
AND pr.article_id = $3
AND pr.platform_account_id = ANY($4::bigint[])
AND pr.deleted_at IS NULL
AND pr.status IN ('queued', 'publishing', 'success')
AND dt.status IN ('queued', 'in_progress', 'succeeded', 'unknown')
ORDER BY pr.platform_account_id,
@@ -731,13 +732,18 @@ func (s *PublishJobService) RetryTenantDesktopTask(
if err != nil {
return nil, err
}
brandID, err := s.resolveRetryArticleBrandID(ctx, actor.TenantID, articleID)
if err != nil {
return nil, err
}
title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title")))
if title == "" {
title = "文章发布"
}
return s.Create(ctx, auth.Actor{
publishCtx := auth.WithCurrentBrandID(ctx, brandID)
return s.Create(publishCtx, auth.Actor{
TenantID: actor.TenantID,
UserID: actor.UserID,
PrimaryWorkspaceID: workspaceID,
@@ -895,7 +901,7 @@ func (s *PublishJobService) resolveRetryArticleID(ctx context.Context, tenantID
err := s.pool.QueryRow(ctx, `
SELECT article_id
FROM publish_records
WHERE id = $1 AND tenant_id = $2
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, publishRecordID, tenantID).Scan(&articleID)
if err == nil && articleID > 0 {
return articleID, nil
@@ -138,6 +138,7 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
AND pr.tenant_id = $1
AND pr.article_id = $2
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
AND pr.deleted_at IS NULL
AND j.tenant_id = $1
AND j.article_id = $2
AND j.status = 'queued'
@@ -189,23 +190,27 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
) THEN 'publishing'
WHEN EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status IN ('success', 'published', 'publish_success')
) AND EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status NOT IN ('success', 'published', 'publish_success')
) THEN 'partial_success'
WHEN EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.publish_batch_id = pb.id
AND pr.deleted_at IS NULL
AND pr.status IN ('success', 'published', 'publish_success')
) THEN 'success'
ELSE 'failed'
@@ -223,6 +228,7 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
FROM publish_records pr
WHERE pr.article_id = a.id
AND pr.tenant_id = a.tenant_id
AND pr.deleted_at IS NULL
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
) THEN 'publishing'
WHEN EXISTS (
@@ -230,12 +236,14 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
FROM publish_records pr
WHERE pr.article_id = a.id
AND pr.tenant_id = a.tenant_id
AND pr.deleted_at IS NULL
AND pr.status IN ('success', 'published', 'publish_success')
) AND EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.article_id = a.id
AND pr.tenant_id = a.tenant_id
AND pr.deleted_at IS NULL
AND pr.status NOT IN ('success', 'published', 'publish_success')
) THEN 'partial_success'
WHEN EXISTS (
@@ -243,6 +251,7 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
FROM publish_records pr
WHERE pr.article_id = a.id
AND pr.tenant_id = a.tenant_id
AND pr.deleted_at IS NULL
AND pr.status IN ('success', 'published', 'publish_success')
) THEN 'success'
ELSE 'failed'
@@ -296,6 +305,7 @@ func cancelQueuedPublishTasksForDesktopAccountUnbind(
AND dt.kind = 'publish'
AND dt.status = 'queued'
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
AND pr.deleted_at IS NULL
`, tenantID, workspaceID, accountID)
if err != nil {
return 0, response.ErrInternal(50121, "publish_task_cancel_account_unbind_failed", "failed to inspect queued publish tasks for unbound account")
@@ -335,6 +345,7 @@ func cancelQueuedPublishTasksForDesktopAccountUnbind(
WHERE tenant_id = $1
AND id = ANY($2::bigint[])
AND status IN ('queued', 'publishing', 'pending', 'running')
AND deleted_at IS NULL
`, tenantID, recordIDs); err != nil {
return 0, response.ErrInternal(50121, "publish_task_cancel_account_unbind_failed", "failed to cancel publish records for unbound account")
}
@@ -412,7 +423,7 @@ func syncDesktopPublishTaskState(
if err := tx.QueryRow(ctx, `
SELECT publish_batch_id, article_id
FROM publish_records
WHERE id = $1 AND tenant_id = $2
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, publishRecordID, task.TenantID).Scan(&publishBatchID, &articleID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40434, "publish_record_not_found", "publish record not found")
@@ -451,7 +462,7 @@ func syncDesktopPublishTaskState(
response_payload_json = $7,
error_message = $8,
updated_at = NOW()
WHERE id = $9 AND tenant_id = $10
WHERE id = $9 AND tenant_id = $10 AND deleted_at IS NULL
`, publishStatus,
externalArticleID,
externalArticleURL,
@@ -567,7 +578,9 @@ func desktopTaskToPublishStatus(taskStatus string) string {
return "failed"
case "succeeded":
return "success"
case "failed", "aborted":
case "aborted":
return "cancelled"
case "failed":
return "failed"
default:
return "failed"
@@ -602,6 +615,7 @@ func recalculatePublishBatchStatus(ctx context.Context, tx pgx.Tx, publishBatchI
SELECT status
FROM publish_records
WHERE publish_batch_id = $1
AND deleted_at IS NULL
`, publishBatchID)
if err != nil {
return "", response.ErrInternal(50054, "publish_batch_status_query_failed", "failed to inspect publish batch status")
@@ -621,7 +635,7 @@ func recalculatePublishBatchStatus(ctx context.Context, tx pgx.Tx, publishBatchI
}
if len(statuses) == 0 {
return "publishing", nil
return "failed", nil
}
counts := map[string]int{
@@ -650,6 +664,7 @@ func recalculateArticlePublishStatus(ctx context.Context, tx pgx.Tx, tenantID, a
SELECT status
FROM publish_records
WHERE tenant_id = $1 AND article_id = $2
AND deleted_at IS NULL
`, tenantID, articleID)
if err != nil {
return "", response.ErrInternal(50055, "article_publish_status_query_failed", "failed to inspect article publish status")
@@ -175,6 +175,24 @@ func TestPublishBatchStatusToArticleStatusKeepsPartialSuccess(t *testing.T) {
}
}
func TestRecalculatePublishBatchStatusDefaultsToFailed(t *testing.T) {
tx := &capturePublishDedupLookupTx{rows: emptyPublishDedupRows{}}
got, err := recalculatePublishBatchStatus(context.Background(), tx, 1)
if err != nil {
t.Fatalf("recalculatePublishBatchStatus() error = %v", err)
}
if got != "failed" {
t.Fatalf("recalculatePublishBatchStatus() = %q, want failed", got)
}
}
func TestDesktopTaskToPublishStatusMapsAbortToCancelled(t *testing.T) {
if got := desktopTaskToPublishStatus("aborted"); got != "cancelled" {
t.Fatalf("desktopTaskToPublishStatus(aborted) = %q, want cancelled", got)
}
}
func TestRecalculateArticlePublishStatusDefaultsToUnpublished(t *testing.T) {
tx := &capturePublishDedupLookupTx{rows: emptyPublishDedupRows{}}
@@ -124,7 +124,8 @@ type DesktopTaskRepository interface {
Complete(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, status string, resultJSON, errorJSON []byte) (*DesktopTask, error)
CancelByLease(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, errorJSON []byte) (*DesktopTask, error)
CancelByClient(ctx context.Context, desktopID, clientID uuid.UUID, errorJSON []byte) (*DesktopTask, error)
TenantCancel(ctx context.Context, desktopID uuid.UUID, workspaceID int64, errorJSON []byte) (*DesktopTask, error)
CancelQueuedPublishByOwner(ctx context.Context, desktopID uuid.UUID, tenantID, workspaceID, userID int64, errorJSON []byte) (*DesktopTask, error)
TenantCancel(ctx context.Context, desktopID uuid.UUID, tenantID, workspaceID, userID int64, errorJSON []byte) (*DesktopTask, error)
Reconcile(ctx context.Context, desktopID uuid.UUID, workspaceID int64, status string, resultJSON, errorJSON []byte) (*DesktopTask, error)
MarkUnknownByClient(ctx context.Context, clientID uuid.UUID, workspaceID int64, errorJSON []byte) (int64, error)
CreateAttempt(ctx context.Context, params CreateDesktopTaskAttemptParams) (*DesktopTaskAttempt, error)
@@ -263,11 +264,27 @@ func (r *desktopTaskRepository) CancelByClient(ctx context.Context, desktopID, c
return desktopTaskFromGenerated(row), nil
}
func (r *desktopTaskRepository) TenantCancel(ctx context.Context, desktopID uuid.UUID, workspaceID int64, errorJSON []byte) (*DesktopTask, error) {
func (r *desktopTaskRepository) CancelQueuedPublishByOwner(ctx context.Context, desktopID uuid.UUID, tenantID, workspaceID, userID int64, errorJSON []byte) (*DesktopTask, error) {
row, err := r.q.CancelQueuedPublishDesktopTaskByOwner(ctx, generated.CancelQueuedPublishDesktopTaskByOwnerParams{
Error: errorJSON,
DesktopID: pgUUID(desktopID),
TenantID: tenantID,
WorkspaceID: workspaceID,
UserID: userID,
})
if err != nil {
return nil, err
}
return desktopTaskFromGenerated(row), nil
}
func (r *desktopTaskRepository) TenantCancel(ctx context.Context, desktopID uuid.UUID, tenantID, workspaceID, userID int64, errorJSON []byte) (*DesktopTask, error) {
row, err := r.q.TenantCancelDesktopTask(ctx, generated.TenantCancelDesktopTaskParams{
Error: errorJSON,
DesktopID: pgUUID(desktopID),
TenantID: tenantID,
WorkspaceID: workspaceID,
UserID: userID,
})
if err != nil {
return nil, err
@@ -135,6 +135,83 @@ func (q *Queries) CancelDesktopTaskByLease(ctx context.Context, arg CancelDeskto
return i, err
}
const cancelQueuedPublishDesktopTaskByOwner = `-- name: CancelQueuedPublishDesktopTaskByOwner :one
UPDATE desktop_tasks AS t
SET status = 'aborted',
error = $1,
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
FROM desktop_publish_jobs AS j
WHERE t.desktop_id = $2
AND t.tenant_id = $3
AND t.workspace_id = $4
AND t.kind = 'publish'
AND t.status = 'queued'
AND j.desktop_id = t.job_id
AND j.tenant_id = t.tenant_id
AND j.workspace_id = t.workspace_id
AND j.created_by_user_id = $5
RETURNING t.id, t.desktop_id, t.job_id, t.tenant_id, t.workspace_id, t.target_account_id, t.target_client_id, t.platform_id, t.kind, t.payload, t.status, t.dedup_key, t.active_attempt_id, t.lease_token_hash, t.lease_expires_at, t.attempts, t.result, t.error, t.created_at, t.updated_at, t.priority, t.lane, t.lane_weight, t.source, t.scheduler_group_key, t.monitor_task_id, t.supersedes_task_id, t.control_flags, t.interrupt_generation, t.started_at, t.interrupted_at, t.interrupt_reason, t.enqueued_at, t.publish_submit_started_at
`
type CancelQueuedPublishDesktopTaskByOwnerParams struct {
Error []byte `json:"error"`
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CancelQueuedPublishDesktopTaskByOwner(ctx context.Context, arg CancelQueuedPublishDesktopTaskByOwnerParams) (DesktopTask, error) {
row := q.db.QueryRow(ctx, cancelQueuedPublishDesktopTaskByOwner,
arg.Error,
arg.DesktopID,
arg.TenantID,
arg.WorkspaceID,
arg.UserID,
)
var i DesktopTask
err := row.Scan(
&i.ID,
&i.DesktopID,
&i.JobID,
&i.TenantID,
&i.WorkspaceID,
&i.TargetAccountID,
&i.TargetClientID,
&i.PlatformID,
&i.Kind,
&i.Payload,
&i.Status,
&i.DedupKey,
&i.ActiveAttemptID,
&i.LeaseTokenHash,
&i.LeaseExpiresAt,
&i.Attempts,
&i.Result,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.Lane,
&i.LaneWeight,
&i.Source,
&i.SchedulerGroupKey,
&i.MonitorTaskID,
&i.SupersedesTaskID,
&i.ControlFlags,
&i.InterruptGeneration,
&i.StartedAt,
&i.InterruptedAt,
&i.InterruptReason,
&i.EnqueuedAt,
&i.PublishSubmitStartedAt,
)
return i, err
}
const completeDesktopTask = `-- name: CompleteDesktopTask :one
UPDATE desktop_tasks
SET status = $1,
@@ -845,19 +922,30 @@ SET status = 'aborted',
lease_expires_at = NULL,
updated_at = now()
WHERE desktop_id = $2
AND workspace_id = $3
AND tenant_id = $3
AND workspace_id = $4
AND status = 'queued'
AND EXISTS (
SELECT 1
FROM desktop_publish_jobs AS j
WHERE j.desktop_id = desktop_tasks.job_id
AND j.tenant_id = desktop_tasks.tenant_id
AND j.workspace_id = desktop_tasks.workspace_id
AND j.created_by_user_id = $5
)
RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, result, error, created_at, updated_at, priority, lane, lane_weight, source, scheduler_group_key, monitor_task_id, supersedes_task_id, control_flags, interrupt_generation, started_at, interrupted_at, interrupt_reason, enqueued_at, publish_submit_started_at
`
type TenantCancelDesktopTaskParams struct {
Error []byte `json:"error"`
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) TenantCancelDesktopTask(ctx context.Context, arg TenantCancelDesktopTaskParams) (DesktopTask, error) {
row := q.db.QueryRow(ctx, tenantCancelDesktopTask, arg.Error, arg.DesktopID, arg.WorkspaceID)
row := q.db.QueryRow(ctx, tenantCancelDesktopTask, arg.Error, arg.DesktopID, arg.TenantID, arg.WorkspaceID, arg.UserID)
var i DesktopTask
err := row.Scan(
&i.ID,
@@ -43,3 +43,24 @@ func TestReconcileDesktopTaskRetryResetsAttempts(t *testing.T) {
t.Fatalf("retry reconcile must not increment attempts; query:\n%s", query)
}
}
func TestCancelQueuedPublishDesktopTaskByOwnerIsScopedToCreator(t *testing.T) {
query := cancelQueuedPublishDesktopTaskByOwner
for _, fragment := range []string{
"FROM desktop_publish_jobs AS j",
"t.tenant_id = $3",
"t.workspace_id = $4",
"t.kind = 'publish'",
"t.status = 'queued'",
"j.desktop_id = t.job_id",
"j.created_by_user_id = $5",
} {
if !strings.Contains(query, fragment) {
t.Fatalf("owner cancel query missing %q; query:\n%s", fragment, query)
}
}
if strings.Contains(query, "target_client_id =") {
t.Fatalf("owner cancel query must not require the task target client; query:\n%s", query)
}
}
@@ -14,6 +14,7 @@ type Querier interface {
ApproveKolSubscription(ctx context.Context, arg ApproveKolSubscriptionParams) error
CancelDesktopTaskByClient(ctx context.Context, arg CancelDesktopTaskByClientParams) (DesktopTask, error)
CancelDesktopTaskByLease(ctx context.Context, arg CancelDesktopTaskByLeaseParams) (DesktopTask, error)
CancelQueuedPublishDesktopTaskByOwner(ctx context.Context, arg CancelQueuedPublishDesktopTaskByOwnerParams) (DesktopTask, error)
ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error)
CompleteDesktopTask(ctx context.Context, arg CompleteDesktopTaskParams) (DesktopTask, error)
ConfirmReservation(ctx context.Context, arg ConfirmReservationParams) error
@@ -169,6 +169,26 @@ WHERE desktop_id = sqlc.arg(desktop_id)
AND status = 'queued'
RETURNING *;
-- name: CancelQueuedPublishDesktopTaskByOwner :one
UPDATE desktop_tasks AS t
SET status = 'aborted',
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
FROM desktop_publish_jobs AS j
WHERE t.desktop_id = sqlc.arg(desktop_id)
AND t.tenant_id = sqlc.arg(tenant_id)
AND t.workspace_id = sqlc.arg(workspace_id)
AND t.kind = 'publish'
AND t.status = 'queued'
AND j.desktop_id = t.job_id
AND j.tenant_id = t.tenant_id
AND j.workspace_id = t.workspace_id
AND j.created_by_user_id = sqlc.arg(user_id)
RETURNING t.*;
-- name: TenantCancelDesktopTask :one
UPDATE desktop_tasks
SET status = 'aborted',
@@ -178,8 +198,17 @@ SET status = 'aborted',
lease_expires_at = NULL,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND tenant_id = sqlc.arg(tenant_id)
AND workspace_id = sqlc.arg(workspace_id)
AND status = 'queued'
AND EXISTS (
SELECT 1
FROM desktop_publish_jobs AS j
WHERE j.desktop_id = desktop_tasks.job_id
AND j.tenant_id = desktop_tasks.tenant_id
AND j.workspace_id = desktop_tasks.workspace_id
AND j.created_by_user_id = sqlc.arg(user_id)
)
RETURNING *;
-- name: ReconcileDesktopTask :one
@@ -80,3 +80,17 @@ func (h *MediaHandler) ListPublishRecords(c *gin.Context) {
}
response.Success(c, data)
}
func (h *MediaHandler) DeletePublishRecord(c *gin.Context) {
recordID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || recordID <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number"))
return
}
data, err := h.svc.DeletePublishRecord(c.Request.Context(), recordID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
+3 -2
View File
@@ -93,8 +93,9 @@ func RegisterRoutes(app *bootstrap.App) {
tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile)
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish)
tenantProtected.POST("/publish-tasks/:id/retry", middleware.RequireCurrentBrand(), desktopTaskHandler.TenantRetryPublish)
tenantProtected.GET("/publish-records", middleware.RequireCurrentBrand(), mediaHandler.ListAllPublishRecords)
tenantProtected.POST("/publish-tasks/:id/retry", desktopTaskHandler.TenantRetryPublish)
tenantProtected.GET("/publish-records", mediaHandler.ListAllPublishRecords)
tenantProtected.DELETE("/publish-records/:id", mediaHandler.DeletePublishRecord)
tenantProtected.POST("/publish-jobs", middleware.RequireCurrentBrand(), publishJobHandler.Create)
complianceHandler := NewComplianceHandler(app)
@@ -35,6 +35,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
{http.MethodGet, "/api/tenant/publish-tasks"},
{http.MethodPost, "/api/tenant/publish-tasks/:id/retry"},
{http.MethodGet, "/api/tenant/publish-records"},
{http.MethodDelete, "/api/tenant/publish-records/:id"},
{http.MethodPost, "/api/tenant/publish-jobs"},
{http.MethodGet, "/api/tenant/media/platforms"},
{http.MethodGet, "/api/tenant/workspace/overview"},
@@ -0,0 +1,9 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_publish_records_visible_status;
DROP INDEX CONCURRENTLY IF EXISTS idx_publish_records_article_account_active;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publish_records_article_account_active
ON publish_records (tenant_id, article_id, platform_account_id, updated_at DESC, id DESC)
WHERE status IN ('queued', 'publishing', 'success');
ALTER TABLE publish_records
DROP COLUMN IF EXISTS deleted_at;
@@ -0,0 +1,12 @@
ALTER TABLE publish_records
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publish_records_visible_status
ON publish_records (tenant_id, status, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
DROP INDEX CONCURRENTLY IF EXISTS idx_publish_records_article_account_active;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publish_records_article_account_active
ON publish_records (tenant_id, article_id, platform_account_id, updated_at DESC, id DESC)
WHERE status IN ('queued', 'publishing', 'success')
AND deleted_at IS NULL;
@@ -0,0 +1,2 @@
DELETE FROM ops.scheduler_jobs
WHERE job_key = 'brand_asset_cleanup';
@@ -0,0 +1,17 @@
INSERT INTO ops.scheduler_jobs
(job_key, display_name, category, description, enabled, schedule_type, interval_seconds, timezone, timeout_seconds, batch_size, config)
VALUES
('brand_asset_cleanup', '品牌资产后台清理', 'tenant', '删除品牌后静默清理该品牌下的文章、词库、竞品和监控采集数据;发布记录作为用户发文总账保留。', true, 'interval', 60, 'Asia/Shanghai', 120, 20,
'{"batch_size":20,"statement_timeout":"8s","dry_run":false}'::jsonb)
ON CONFLICT (job_key) DO UPDATE
SET display_name = EXCLUDED.display_name,
category = EXCLUDED.category,
description = EXCLUDED.description,
enabled = EXCLUDED.enabled,
schedule_type = EXCLUDED.schedule_type,
interval_seconds = EXCLUDED.interval_seconds,
timezone = EXCLUDED.timezone,
timeout_seconds = EXCLUDED.timeout_seconds,
batch_size = EXCLUDED.batch_size,
config = ops.scheduler_jobs.config || EXCLUDED.config,
updated_at = NOW();