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
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:
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
@@ -193,6 +194,7 @@ const appliedTitle = ref('')
|
|||||||
const copiedErrorTaskId = ref<string | null>(null)
|
const copiedErrorTaskId = ref<string | null>(null)
|
||||||
const retryingTaskId = ref<string | null>(null)
|
const retryingTaskId = ref<string | null>(null)
|
||||||
const cancelingTaskId = ref<string | null>(null)
|
const cancelingTaskId = ref<string | null>(null)
|
||||||
|
const deletingRecordId = ref<string | null>(null)
|
||||||
const manualRefreshing = ref(false)
|
const manualRefreshing = ref(false)
|
||||||
|
|
||||||
const recordsQuery = useQuery({
|
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> {
|
async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
|
||||||
if (!record.errorMessage) {
|
if (!record.errorMessage) {
|
||||||
return
|
return
|
||||||
@@ -622,6 +637,25 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
|
|||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
cancelDesktopTask,
|
cancelDesktopTask,
|
||||||
checkDesktopClientRelease,
|
checkDesktopClientRelease,
|
||||||
|
deleteDesktopPublishRecord,
|
||||||
initTransport,
|
initTransport,
|
||||||
listDesktopPublishTasks,
|
listDesktopPublishTasks,
|
||||||
resolveDesktopClientReleaseDownloadURL,
|
resolveDesktopClientReleaseDownloadURL,
|
||||||
@@ -1246,6 +1247,9 @@ function registerBridgeHandlers(): void {
|
|||||||
safeHandle('desktop:cancel-publish-task', async (_event, taskId: string) => {
|
safeHandle('desktop:cancel-publish-task', async (_event, taskId: string) => {
|
||||||
return cancelDesktopTask(taskId, { reason: 'user_cancelled_publish' })
|
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) => {
|
safeHandle('desktop:open-external-url', async (_event, url: string) => {
|
||||||
if (!isSafeExternalUrl(url)) {
|
if (!isSafeExternalUrl(url)) {
|
||||||
throw new Error('unsupported_external_url')
|
throw new Error('unsupported_external_url')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CancelDesktopTaskRequest,
|
CancelDesktopTaskRequest,
|
||||||
CompleteDesktopTaskRequest,
|
CompleteDesktopTaskRequest,
|
||||||
CreatePublishJobResponse,
|
CreatePublishJobResponse,
|
||||||
|
DeletePublishRecordResponse,
|
||||||
DesktopAccountInfo,
|
DesktopAccountInfo,
|
||||||
DesktopArticleContent,
|
DesktopArticleContent,
|
||||||
DesktopClientHeartbeatRequest,
|
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(
|
export async function upsertDesktopAccount(
|
||||||
payload: UpsertDesktopAccountRequest,
|
payload: UpsertDesktopAccountRequest,
|
||||||
): Promise<DesktopAccountInfo> {
|
): Promise<DesktopAccountInfo> {
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ const desktopBridge = {
|
|||||||
ipcRenderer.invoke('desktop:retry-publish-task', taskId) as Promise<CreatePublishJobResponse>,
|
ipcRenderer.invoke('desktop:retry-publish-task', taskId) as Promise<CreatePublishJobResponse>,
|
||||||
cancelPublishTask: (taskId: string) =>
|
cancelPublishTask: (taskId: string) =>
|
||||||
ipcRenderer.invoke('desktop:cancel-publish-task', taskId) as Promise<DesktopTaskInfo>,
|
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) =>
|
openExternalUrl: (url: string) =>
|
||||||
ipcRenderer.invoke('desktop:open-external-url', url) as Promise<null>,
|
ipcRenderer.invoke('desktop:open-external-url', url) as Promise<null>,
|
||||||
openSettingsWindow: () => ipcRenderer.invoke('desktop:open-settings-window') as Promise<null>,
|
openSettingsWindow: () => ipcRenderer.invoke('desktop:open-settings-window') as Promise<null>,
|
||||||
|
|||||||
+1
@@ -133,6 +133,7 @@ declare global {
|
|||||||
): Promise<DesktopPublishTaskListResponse>
|
): Promise<DesktopPublishTaskListResponse>
|
||||||
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>
|
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>
|
||||||
cancelPublishTask(taskId: string): Promise<DesktopTaskInfo>
|
cancelPublishTask(taskId: string): Promise<DesktopTaskInfo>
|
||||||
|
deletePublishRecord(recordId: number): Promise<{ deleted: boolean }>
|
||||||
openExternalUrl(url: string): Promise<null>
|
openExternalUrl(url: string): Promise<null>
|
||||||
openSettingsWindow(): Promise<null>
|
openSettingsWindow(): Promise<null>
|
||||||
getAppSettings(): Promise<DesktopAppSettings>
|
getAppSettings(): Promise<DesktopAppSettings>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
@@ -36,6 +37,7 @@ const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[n
|
|||||||
|
|
||||||
interface PublishTaskItem {
|
interface PublishTaskItem {
|
||||||
id: string
|
id: string
|
||||||
|
publishRecordId: number | null
|
||||||
title: string
|
title: string
|
||||||
articleId: number | null
|
articleId: number | null
|
||||||
platform: string
|
platform: string
|
||||||
@@ -397,6 +399,7 @@ const consolePendingTaskId = ref<string | null>(null)
|
|||||||
const copiedErrorTaskId = ref<string | null>(null)
|
const copiedErrorTaskId = ref<string | null>(null)
|
||||||
const retryingTaskId = ref<string | null>(null)
|
const retryingTaskId = ref<string | null>(null)
|
||||||
const cancelingTaskId = ref<string | null>(null)
|
const cancelingTaskId = ref<string | null>(null)
|
||||||
|
const deletingTaskId = ref<string | null>(null)
|
||||||
|
|
||||||
function notifySuccess(message: string) {
|
function notifySuccess(message: string) {
|
||||||
notification.success({
|
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> {
|
async function copyTextToClipboard(text: string): Promise<void> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -741,6 +768,7 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
|
|||||||
const articleId =
|
const articleId =
|
||||||
extractNumber(payload.article_id) ??
|
extractNumber(payload.article_id) ??
|
||||||
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
|
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
|
||||||
|
const publishRecordId = extractNumber(payload.publish_record_id)
|
||||||
const pendingTasks =
|
const pendingTasks =
|
||||||
taskPage.value?.items.filter((item) =>
|
taskPage.value?.items.filter((item) =>
|
||||||
isPendingStatus(normalizePublishTaskStatus(item.status)),
|
isPendingStatus(normalizePublishTaskStatus(item.status)),
|
||||||
@@ -782,6 +810,7 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
publishRecordId,
|
||||||
title: extractString(payload.title) ?? `发布任务 · ${translatePlatform(task.platform)}`,
|
title: extractString(payload.title) ?? `发布任务 · ${translatePlatform(task.platform)}`,
|
||||||
articleId,
|
articleId,
|
||||||
platform: task.platform,
|
platform: task.platform,
|
||||||
@@ -1096,6 +1125,26 @@ const tableScroll = { x: 1080 } as const
|
|||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
</a-tooltip>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -759,6 +759,20 @@ func (s *DesktopTaskService) leaseNextQueuedPublishTask(
|
|||||||
AND dt.kind = 'publish'
|
AND dt.kind = 'publish'
|
||||||
AND dt.status = 'queued'
|
AND dt.status = 'queued'
|
||||||
AND dt.attempts < $4
|
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 (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM platform_accounts AS pa
|
FROM platform_accounts AS pa
|
||||||
@@ -866,6 +880,21 @@ func leaseQueuedDesktopTaskByIDSQL() string {
|
|||||||
AND dt.workspace_id = $2
|
AND dt.workspace_id = $2
|
||||||
AND dt.status = 'queued'
|
AND dt.status = 'queued'
|
||||||
AND (dt.kind <> 'publish' OR dt.attempts < $7)
|
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 (
|
AND (
|
||||||
dt.kind <> 'publish'
|
dt.kind <> 'publish'
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"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/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaService struct {
|
type MediaService struct {
|
||||||
@@ -82,11 +83,15 @@ type DeletePublishRecordResponse struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type publishRecordDeleteOwner struct {
|
||||||
|
TenantID int64
|
||||||
|
WorkspaceID int64
|
||||||
|
UserID int64
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
const visiblePublishRecordWhereSQL = `
|
const visiblePublishRecordWhereSQL = `
|
||||||
AND (
|
AND pr.deleted_at IS NULL
|
||||||
pr.deleted_at IS NULL
|
|
||||||
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
|
|
||||||
)
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformResponse, error) {
|
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.tenant_id = pr.tenant_id
|
||||||
AND pb.initiator_user_id = $2
|
AND pb.initiator_user_id = $2
|
||||||
)
|
)
|
||||||
AND (
|
`+visiblePublishRecordWhereSQL+`
|
||||||
pr.deleted_at IS NULL
|
|
||||||
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
|
|
||||||
)
|
|
||||||
AND pr.status = ANY($3)
|
AND pr.status = ANY($3)
|
||||||
AND (
|
AND (
|
||||||
$4 = ''
|
$4 = ''
|
||||||
@@ -378,10 +380,7 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
|
|||||||
AND pr.article_id = $2
|
AND pr.article_id = $2
|
||||||
AND a.brand_id = $3
|
AND a.brand_id = $3
|
||||||
AND a.deleted_at IS NULL
|
AND a.deleted_at IS NULL
|
||||||
AND (
|
`+visiblePublishRecordWhereSQL+`
|
||||||
pr.deleted_at IS NULL
|
|
||||||
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
|
|
||||||
)
|
|
||||||
ORDER BY pr.created_at DESC, pr.id DESC
|
ORDER BY pr.created_at DESC, pr.id DESC
|
||||||
`, tenantID, articleID, brandID)
|
`, tenantID, articleID, brandID)
|
||||||
if err != nil {
|
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")
|
return nil, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number")
|
||||||
}
|
}
|
||||||
actor := auth.MustActor(ctx)
|
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")
|
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 publishBatchID int64
|
||||||
var articleID int64
|
var articleID int64
|
||||||
var publishStatus string
|
|
||||||
cancelErrorJSONBytes, err := marshalOptionalJSON(map[string]any{
|
cancelErrorJSONBytes, err := marshalOptionalJSON(map[string]any{
|
||||||
"reason": "publish_record_deleted",
|
"reason": "publish_record_deleted",
|
||||||
"source": "tenant_web",
|
"source": owner.Source,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50044, "publish_record_delete_payload_failed", "failed to delete publish record")
|
return nil, response.ErrInternal(50044, "publish_record_delete_payload_failed", "failed to delete publish record")
|
||||||
}
|
}
|
||||||
cancelErrorJSON := string(cancelErrorJSONBytes)
|
cancelErrorJSON := string(cancelErrorJSONBytes)
|
||||||
err = tx.QueryRow(ctx, `
|
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
|
FROM publish_records pr
|
||||||
JOIN articles a
|
JOIN articles a
|
||||||
ON a.id = pr.article_id
|
ON a.id = pr.article_id
|
||||||
@@ -466,9 +492,44 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
|||||||
WHERE pr.id = $1
|
WHERE pr.id = $1
|
||||||
AND pr.tenant_id = $2
|
AND pr.tenant_id = $2
|
||||||
AND pb.initiator_user_id = $3
|
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
|
AND pr.deleted_at IS NULL
|
||||||
FOR UPDATE OF pr
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
|
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")
|
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, `
|
if tag, err := tx.Exec(ctx, `
|
||||||
UPDATE publish_records
|
UPDATE publish_records
|
||||||
SET deleted_at = NOW(),
|
SET deleted_at = NOW(),
|
||||||
@@ -487,30 +544,12 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND tenant_id = $2
|
AND tenant_id = $2
|
||||||
AND deleted_at IS NULL
|
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")
|
return nil, response.ErrInternal(50044, "publish_record_delete_failed", "failed to delete publish record")
|
||||||
} else if tag.RowsAffected() == 0 {
|
} else if tag.RowsAffected() == 0 {
|
||||||
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
|
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)
|
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -519,11 +558,11 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
|||||||
UPDATE publish_batches
|
UPDATE publish_batches
|
||||||
SET status = $1, updated_at = NOW()
|
SET status = $1, updated_at = NOW()
|
||||||
WHERE id = $2 AND tenant_id = $3
|
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")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -531,16 +570,38 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
|||||||
UPDATE articles
|
UPDATE articles
|
||||||
SET publish_status = $1, updated_at = NOW()
|
SET publish_status = $1, updated_at = NOW()
|
||||||
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
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")
|
return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, response.ErrInternal(50044, "publish_record_delete_commit_failed", "failed to delete publish record")
|
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
|
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 {
|
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
if publishPlatformRequiresCover(account.PlatformID) {
|
if publishPlatformRequiresCover(account.PlatformID) {
|
||||||
@@ -561,7 +622,7 @@ func publishPlatformRequiresCover(platformID string) bool {
|
|||||||
|
|
||||||
func publishRecordMayBeDiscarded(status string) bool {
|
func publishRecordMayBeDiscarded(status string) bool {
|
||||||
switch strings.TrimSpace(status) {
|
switch strings.TrimSpace(status) {
|
||||||
case "queued", "pending":
|
case "queued", "pending", "publishing", "running", "failed", "publish_failed", "success", "published", "publish_success", "cancelled", "canceled":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -194,30 +194,29 @@ func TestDesktopTaskToPublishStatusMapsAbortToCancelled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublishRecordMayBeDiscardedOnlyForQueuedRecords(t *testing.T) {
|
func TestPublishRecordMayBeDiscardedIncludesHistoryRecords(t *testing.T) {
|
||||||
for _, status := range []string{"queued", "pending"} {
|
for _, status := range []string{"queued", "pending", "publishing", "running", "failed", "success", "cancelled"} {
|
||||||
if !publishRecordMayBeDiscarded(status) {
|
if !publishRecordMayBeDiscarded(status) {
|
||||||
t.Fatalf("publishRecordMayBeDiscarded(%q) = false, want true", 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) {
|
if publishRecordMayBeDiscarded(status) {
|
||||||
t.Fatalf("publishRecordMayBeDiscarded(%q) = true, want false", status)
|
t.Fatalf("publishRecordMayBeDiscarded(%q) = true, want false", status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVisiblePublishRecordWhereIncludesDeletedTerminalFailures(t *testing.T) {
|
func TestVisiblePublishRecordWhereExcludesDeletedRecords(t *testing.T) {
|
||||||
normalized := normalizeSQLWhitespace(visiblePublishRecordWhereSQL)
|
normalized := normalizeSQLWhitespace(visiblePublishRecordWhereSQL)
|
||||||
if !strings.Contains(normalized, "pr.deleted_at IS NULL") {
|
if !strings.Contains(normalized, "pr.deleted_at IS NULL") {
|
||||||
t.Fatalf("visible publish record filter must include active records: %s", normalized)
|
t.Fatalf("visible publish record filter must include active records: %s", normalized)
|
||||||
}
|
}
|
||||||
if !strings.Contains(normalized, "'failed'") || !strings.Contains(normalized, "'publish_failed'") {
|
for _, status := range []string{"'queued'", "'publishing'", "'failed'", "'publish_failed'", "'success'", "'cancelled'"} {
|
||||||
t.Fatalf("visible publish record filter must keep terminal failed history: %s", normalized)
|
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)
|
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/articles/:id", desktopContentHandler.Article)
|
||||||
desktopAuth.GET("/content/assets/:token", publicAssets.ServeDesktop)
|
desktopAuth.GET("/content/assets/:token", publicAssets.ServeDesktop)
|
||||||
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
|
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
|
||||||
|
desktopAuth.DELETE("/publish-records/:id", mediaHandler.DeleteDesktopPublishRecord)
|
||||||
desktopAuth.POST("/accounts/health-reports", desktopAccountHandler.ReportHealth)
|
desktopAuth.POST("/accounts/health-reports", desktopAccountHandler.ReportHealth)
|
||||||
desktopAuth.POST("/accounts", desktopAccountHandler.Upsert)
|
desktopAuth.POST("/accounts", desktopAccountHandler.Upsert)
|
||||||
desktopAuth.PATCH("/accounts/:id", desktopAccountHandler.Patch)
|
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/dispatch"},
|
||||||
{http.MethodGet, "/api/desktop/accounts"},
|
{http.MethodGet, "/api/desktop/accounts"},
|
||||||
{http.MethodGet, "/api/desktop/publish-tasks"},
|
{http.MethodGet, "/api/desktop/publish-tasks"},
|
||||||
|
{http.MethodDelete, "/api/desktop/publish-records/:id"},
|
||||||
{http.MethodGet, "/api/desktop/content/assets/:token"},
|
{http.MethodGet, "/api/desktop/content/assets/:token"},
|
||||||
{http.MethodPost, "/api/desktop/clients/offline"},
|
{http.MethodPost, "/api/desktop/clients/offline"},
|
||||||
{http.MethodPost, "/api/desktop/tasks/lease"},
|
{http.MethodPost, "/api/desktop/tasks/lease"},
|
||||||
|
|||||||
Reference in New Issue
Block a user