fix publish record deletion
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 2m47s
Backend CI / Backend (push) Failing after 10m1s
Desktop Client Build / Build Desktop Client (push) Successful in 23m3s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 29s

This commit is contained in:
2026-06-26 21:04:38 +08:00
parent 6780963c28
commit 04769dec78
12 changed files with 259 additions and 53 deletions
@@ -2,6 +2,7 @@
import {
ClockCircleOutlined,
CopyOutlined,
DeleteOutlined,
DesktopOutlined,
ExportOutlined,
ReloadOutlined,
@@ -193,6 +194,7 @@ 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({
@@ -370,6 +372,19 @@ async function cancelTask(record: PublishRecordItem): Promise<void> {
}
}
async function deleteRecord(record: PublishRecordItem): Promise<void> {
deletingRecordId.value = record.id
try {
await publishRecordsApi.remove(record.recordId)
message.success('发文记录已删除')
await recordsQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
deletingRecordId.value = null
}
}
async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
if (!record.errorMessage) {
return
@@ -622,6 +637,25 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
<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>
@@ -78,6 +78,7 @@ import {
import {
cancelDesktopTask,
checkDesktopClientRelease,
deleteDesktopPublishRecord,
initTransport,
listDesktopPublishTasks,
resolveDesktopClientReleaseDownloadURL,
@@ -1246,6 +1247,9 @@ function registerBridgeHandlers(): void {
safeHandle('desktop:cancel-publish-task', async (_event, taskId: string) => {
return cancelDesktopTask(taskId, { reason: 'user_cancelled_publish' })
})
safeHandle('desktop:delete-publish-record', async (_event, recordId: number) => {
return deleteDesktopPublishRecord(recordId)
})
safeHandle('desktop:open-external-url', async (_event, url: string) => {
if (!isSafeExternalUrl(url)) {
throw new Error('unsupported_external_url')
@@ -9,6 +9,7 @@ import type {
CancelDesktopTaskRequest,
CompleteDesktopTaskRequest,
CreatePublishJobResponse,
DeletePublishRecordResponse,
DesktopAccountInfo,
DesktopArticleContent,
DesktopClientHeartbeatRequest,
@@ -678,6 +679,14 @@ export async function retryDesktopPublishTask(taskId: string): Promise<CreatePub
)
}
export async function deleteDesktopPublishRecord(
recordId: number,
): Promise<DeletePublishRecordResponse> {
return desktopDelete<DeletePublishRecordResponse>(
`/api/desktop/publish-records/${encodeURIComponent(String(recordId))}`,
)
}
export async function upsertDesktopAccount(
payload: UpsertDesktopAccountRequest,
): Promise<DesktopAccountInfo> {
@@ -226,6 +226,10 @@ const desktopBridge = {
ipcRenderer.invoke('desktop:retry-publish-task', taskId) as Promise<CreatePublishJobResponse>,
cancelPublishTask: (taskId: string) =>
ipcRenderer.invoke('desktop:cancel-publish-task', taskId) as Promise<DesktopTaskInfo>,
deletePublishRecord: (recordId: number) =>
ipcRenderer.invoke('desktop:delete-publish-record', recordId) as Promise<{
deleted: boolean
}>,
openExternalUrl: (url: string) =>
ipcRenderer.invoke('desktop:open-external-url', url) as Promise<null>,
openSettingsWindow: () => ipcRenderer.invoke('desktop:open-settings-window') as Promise<null>,
+1
View File
@@ -133,6 +133,7 @@ declare global {
): Promise<DesktopPublishTaskListResponse>
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>
cancelPublishTask(taskId: string): Promise<DesktopTaskInfo>
deletePublishRecord(recordId: number): Promise<{ deleted: boolean }>
openExternalUrl(url: string): Promise<null>
openSettingsWindow(): Promise<null>
getAppSettings(): Promise<DesktopAppSettings>
@@ -2,6 +2,7 @@
import {
ClockCircleOutlined,
CopyOutlined,
DeleteOutlined,
DesktopOutlined,
ExportOutlined,
ReloadOutlined,
@@ -36,6 +37,7 @@ const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[n
interface PublishTaskItem {
id: string
publishRecordId: number | null
title: string
articleId: number | null
platform: string
@@ -397,6 +399,7 @@ const consolePendingTaskId = ref<string | null>(null)
const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const cancelingTaskId = ref<string | null>(null)
const deletingTaskId = ref<string | null>(null)
function notifySuccess(message: string) {
notification.success({
@@ -629,6 +632,30 @@ async function cancelTask(record: PublishTaskItem) {
}
}
async function deleteRecord(record: PublishTaskItem) {
if (!record.publishRecordId) {
return
}
deletingTaskId.value = record.id
try {
await window.desktopBridge.app.deletePublishRecord(record.publishRecordId)
notifySuccess('发文记录已删除')
await refreshTasks(currentPage.value)
} catch (err) {
notifyActionError(
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '删除发文记录失败')) ??
'删除发文记录失败',
)
} finally {
deletingTaskId.value = null
}
}
function canDeleteRecord(record: PublishTaskItem): boolean {
return record.publishRecordId !== null
}
async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
@@ -741,6 +768,7 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
const articleId =
extractNumber(payload.article_id) ??
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
const publishRecordId = extractNumber(payload.publish_record_id)
const pendingTasks =
taskPage.value?.items.filter((item) =>
isPendingStatus(normalizePublishTaskStatus(item.status)),
@@ -782,6 +810,7 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
return {
id: task.id,
publishRecordId,
title: extractString(payload.title) ?? `发布任务 · ${translatePlatform(task.platform)}`,
articleId,
platform: task.platform,
@@ -1096,6 +1125,26 @@ const tableScroll = { x: 1080 } as const
<ClockCircleOutlined />
</div>
</a-tooltip>
<a-popconfirm
v-if="canDeleteRecord(record)"
title="确认删除这条发文记录?删除后后台和客户端发文管理都不再显示。"
ok-text="删除"
cancel-text="保留"
placement="topRight"
@confirm="deleteRecord(record)"
>
<a-tooltip title="删除发文记录" placement="top">
<a-button
type="text"
class="icon-action-btn icon-action-btn--danger"
:loading="deletingTaskId === record.id"
:aria-label="`删除发文记录:${record.title}`"
@click.stop
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</div>
</template>
</template>
@@ -759,6 +759,20 @@ func (s *DesktopTaskService) leaseNextQueuedPublishTask(
AND dt.kind = 'publish'
AND dt.status = 'queued'
AND dt.attempts < $4
AND (
NOT (dt.payload ? 'publish_record_id')
OR EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.tenant_id = dt.tenant_id
AND pr.id = CASE
WHEN (dt.payload->>'publish_record_id') ~ '^[0-9]+$'
THEN (dt.payload->>'publish_record_id')::bigint
ELSE NULL
END
AND pr.deleted_at IS NULL
)
)
AND EXISTS (
SELECT 1
FROM platform_accounts AS pa
@@ -866,6 +880,21 @@ func leaseQueuedDesktopTaskByIDSQL() string {
AND dt.workspace_id = $2
AND dt.status = 'queued'
AND (dt.kind <> 'publish' OR dt.attempts < $7)
AND (
dt.kind <> 'publish'
OR NOT (dt.payload ? 'publish_record_id')
OR EXISTS (
SELECT 1
FROM publish_records pr
WHERE pr.tenant_id = dt.tenant_id
AND pr.id = CASE
WHEN (dt.payload->>'publish_record_id') ~ '^[0-9]+$'
THEN (dt.payload->>'publish_record_id')::bigint
ELSE NULL
END
AND pr.deleted_at IS NULL
)
)
AND (
dt.kind <> 'publish'
OR EXISTS (
+105 -44
View File
@@ -13,6 +13,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type MediaService struct {
@@ -82,11 +83,15 @@ type DeletePublishRecordResponse struct {
Deleted bool `json:"deleted"`
}
type publishRecordDeleteOwner struct {
TenantID int64
WorkspaceID int64
UserID int64
Source string
}
const visiblePublishRecordWhereSQL = `
AND (
pr.deleted_at IS NULL
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
)
AND pr.deleted_at IS NULL
`
func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformResponse, error) {
@@ -327,10 +332,7 @@ func (s *MediaService) countPublishRecordsByStatuses(
AND pb.tenant_id = pr.tenant_id
AND pb.initiator_user_id = $2
)
AND (
pr.deleted_at IS NULL
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
)
`+visiblePublishRecordWhereSQL+`
AND pr.status = ANY($3)
AND (
$4 = ''
@@ -378,10 +380,7 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
AND pr.article_id = $2
AND a.brand_id = $3
AND a.deleted_at IS NULL
AND (
pr.deleted_at IS NULL
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
)
`+visiblePublishRecordWhereSQL+`
ORDER BY pr.created_at DESC, pr.id DESC
`, tenantID, articleID, brandID)
if err != nil {
@@ -433,7 +432,35 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
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 {
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")
}
return s.deletePublishRecordForOwner(ctx, recordID, publishRecordDeleteOwner{
TenantID: actor.TenantID,
WorkspaceID: workspaceID,
UserID: actor.UserID,
Source: "tenant_web",
})
}
func (s *MediaService) DeleteDesktopPublishRecord(ctx context.Context, client *repository.DesktopClient, recordID int64) (*DeletePublishRecordResponse, error) {
if recordID <= 0 {
return nil, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number")
}
if client == nil || client.TenantID == 0 || client.WorkspaceID == 0 || client.UserID == 0 {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
return s.deletePublishRecordForOwner(ctx, recordID, publishRecordDeleteOwner{
TenantID: client.TenantID,
WorkspaceID: client.WorkspaceID,
UserID: client.UserID,
Source: "desktop_client",
})
}
func (s *MediaService) deletePublishRecordForOwner(ctx context.Context, recordID int64, owner publishRecordDeleteOwner) (*DeletePublishRecordResponse, error) {
if owner.TenantID == 0 || owner.WorkspaceID == 0 || owner.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
@@ -445,17 +472,16 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
var publishBatchID int64
var articleID int64
var publishStatus string
cancelErrorJSONBytes, err := marshalOptionalJSON(map[string]any{
"reason": "publish_record_deleted",
"source": "tenant_web",
"source": owner.Source,
})
if err != nil {
return nil, response.ErrInternal(50044, "publish_record_delete_payload_failed", "failed to delete publish record")
}
cancelErrorJSON := string(cancelErrorJSONBytes)
err = tx.QueryRow(ctx, `
SELECT pr.publish_batch_id, pr.article_id, pr.status
SELECT pr.publish_batch_id, pr.article_id
FROM publish_records pr
JOIN articles a
ON a.id = pr.article_id
@@ -466,9 +492,44 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
WHERE pr.id = $1
AND pr.tenant_id = $2
AND pb.initiator_user_id = $3
AND (
(
COALESCE(pr.target_type, 'platform_account') = 'platform_account'
AND EXISTS (
SELECT 1
FROM platform_accounts pa
WHERE pa.id = pr.platform_account_id
AND pa.tenant_id = pr.tenant_id
AND pa.workspace_id = $4
)
)
OR (
COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
AND EXISTS (
SELECT 1
FROM enterprise_site_connections esc
WHERE esc.id = pr.target_connection_id
AND esc.tenant_id = pr.tenant_id
AND esc.workspace_id = $4
)
)
OR EXISTS (
SELECT 1
FROM desktop_tasks dt
JOIN desktop_publish_jobs j
ON j.desktop_id = dt.job_id
AND j.tenant_id = dt.tenant_id
AND j.workspace_id = dt.workspace_id
WHERE dt.tenant_id = pr.tenant_id
AND dt.workspace_id = $4
AND dt.kind = 'publish'
AND dt.payload->>'publish_record_id' = pr.id::text
AND j.created_by_user_id = $3
)
)
AND pr.deleted_at IS NULL
FOR UPDATE OF pr
`, recordID, actor.TenantID, actor.UserID).Scan(&publishBatchID, &articleID, &publishStatus)
`, recordID, owner.TenantID, owner.UserID, owner.WorkspaceID).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")
@@ -476,10 +537,6 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
return nil, response.ErrInternal(50044, "publish_record_delete_lookup_failed", "failed to delete publish record")
}
if !publishRecordMayBeDiscarded(publishStatus) {
return nil, response.ErrConflict(40996, "publish_record_history_locked", "completed publish records are kept for retry and audit")
}
if tag, err := tx.Exec(ctx, `
UPDATE publish_records
SET deleted_at = NOW(),
@@ -487,30 +544,12 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, recordID, actor.TenantID); err != nil {
`, recordID, owner.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 publishRecordMayBeDiscarded(publishStatus) {
if _, err := tx.Exec(ctx, `
UPDATE desktop_tasks
SET status = 'aborted',
error = $3::jsonb,
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::text
`, 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
@@ -519,11 +558,11 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
UPDATE publish_batches
SET status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
`, batchStatus, publishBatchID, actor.TenantID); err != nil {
`, batchStatus, publishBatchID, owner.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)
articleStatus, err := recalculateArticlePublishStatus(ctx, tx, owner.TenantID, articleID)
if err != nil {
return nil, err
}
@@ -531,16 +570,38 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
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 {
`, articleStatus, articleID, owner.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")
}
s.abortQueuedDesktopTasksForDeletedPublishRecord(ctx, owner, recordID, cancelErrorJSON)
return &DeletePublishRecordResponse{Deleted: true}, nil
}
func (s *MediaService) abortQueuedDesktopTasksForDeletedPublishRecord(ctx context.Context, owner publishRecordDeleteOwner, recordID int64, errorJSON string) {
if s == nil || s.pool == nil {
return
}
_, _ = s.pool.Exec(ctx, `
UPDATE desktop_tasks
SET status = 'aborted',
error = $3::jsonb,
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE tenant_id = $1
AND workspace_id = $4
AND kind = 'publish'
AND status = 'queued'
AND payload->>'publish_record_id' = $2::text
`, owner.TenantID, recordID, errorJSON, owner.WorkspaceID)
}
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
for _, account := range accounts {
if publishPlatformRequiresCover(account.PlatformID) {
@@ -561,7 +622,7 @@ func publishPlatformRequiresCover(platformID string) bool {
func publishRecordMayBeDiscarded(status string) bool {
switch strings.TrimSpace(status) {
case "queued", "pending":
case "queued", "pending", "publishing", "running", "failed", "publish_failed", "success", "published", "publish_success", "cancelled", "canceled":
return true
default:
return false
@@ -194,30 +194,29 @@ func TestDesktopTaskToPublishStatusMapsAbortToCancelled(t *testing.T) {
}
}
func TestPublishRecordMayBeDiscardedOnlyForQueuedRecords(t *testing.T) {
for _, status := range []string{"queued", "pending"} {
func TestPublishRecordMayBeDiscardedIncludesHistoryRecords(t *testing.T) {
for _, status := range []string{"queued", "pending", "publishing", "running", "failed", "success", "cancelled"} {
if !publishRecordMayBeDiscarded(status) {
t.Fatalf("publishRecordMayBeDiscarded(%q) = false, want true", status)
}
}
for _, status := range []string{"publishing", "running", "failed", "success", "cancelled"} {
for _, status := range []string{"", "archived"} {
if publishRecordMayBeDiscarded(status) {
t.Fatalf("publishRecordMayBeDiscarded(%q) = true, want false", status)
}
}
}
func TestVisiblePublishRecordWhereIncludesDeletedTerminalFailures(t *testing.T) {
func TestVisiblePublishRecordWhereExcludesDeletedRecords(t *testing.T) {
normalized := normalizeSQLWhitespace(visiblePublishRecordWhereSQL)
if !strings.Contains(normalized, "pr.deleted_at IS NULL") {
t.Fatalf("visible publish record filter must include active records: %s", normalized)
}
if !strings.Contains(normalized, "'failed'") || !strings.Contains(normalized, "'publish_failed'") {
t.Fatalf("visible publish record filter must keep terminal failed history: %s", normalized)
for _, status := range []string{"'queued'", "'publishing'", "'failed'", "'publish_failed'", "'success'", "'cancelled'"} {
if strings.Contains(normalized, status) {
t.Fatalf("visible publish record filter must not resurrect deleted records by status %s: %s", status, normalized)
}
if strings.Contains(normalized, "'queued'") || strings.Contains(normalized, "'publishing'") {
t.Fatalf("visible publish record filter must not resurrect deleted active records: %s", normalized)
}
}
@@ -94,3 +94,17 @@ func (h *MediaHandler) DeletePublishRecord(c *gin.Context) {
}
response.Success(c, data)
}
func (h *MediaHandler) DeleteDesktopPublishRecord(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.DeleteDesktopPublishRecord(c.Request.Context(), MustDesktopClient(c.Request.Context()), recordID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
@@ -65,6 +65,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
desktopAuth.GET("/content/assets/:token", publicAssets.ServeDesktop)
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
desktopAuth.DELETE("/publish-records/:id", mediaHandler.DeleteDesktopPublishRecord)
desktopAuth.POST("/accounts/health-reports", desktopAccountHandler.ReportHealth)
desktopAuth.POST("/accounts", desktopAccountHandler.Upsert)
desktopAuth.PATCH("/accounts/:id", desktopAccountHandler.Patch)
@@ -89,6 +89,7 @@ func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) {
{http.MethodGet, "/api/desktop/dispatch"},
{http.MethodGet, "/api/desktop/accounts"},
{http.MethodGet, "/api/desktop/publish-tasks"},
{http.MethodDelete, "/api/desktop/publish-records/:id"},
{http.MethodGet, "/api/desktop/content/assets/:token"},
{http.MethodPost, "/api/desktop/clients/offline"},
{http.MethodPost, "/api/desktop/tasks/lease"},