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
@@ -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"},