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
@@ -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)
}
if strings.Contains(normalized, "'queued'") || strings.Contains(normalized, "'publishing'") {
t.Fatalf("visible publish record filter must not resurrect deleted active records: %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)
}
}
}
@@ -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"},