fix tenant account unbind flow
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 2m53s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m44s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 50s
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 2m53s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m44s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 50s
This commit is contained in:
@@ -79,6 +79,12 @@ type DesktopAccountView struct {
|
||||
OldestQueuedPublishTaskAt *time.Time `json:"oldest_queued_publish_task_at"`
|
||||
}
|
||||
|
||||
type DesktopAccountDeleteResult struct {
|
||||
Account DesktopAccountView `json:"account"`
|
||||
CancelledQueuedPublishTasks int `json:"cancelled_queued_publish_task_count"`
|
||||
CompletedServerSideUnbinding bool `json:"completed_server_side_unbinding"`
|
||||
}
|
||||
|
||||
type UpsertDesktopAccountRequest struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
PlatformUID string `json:"platform_uid" binding:"required"`
|
||||
@@ -412,6 +418,46 @@ func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Ac
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) DeleteForActor(ctx context.Context, actor auth.Actor, desktopID uuid.UUID) (*DesktopAccountDeleteResult, error) {
|
||||
if actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
if s.pool == nil {
|
||||
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50086, "desktop_account_delete_begin_failed", "failed to start desktop account delete")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
accountRepo := repository.NewDesktopAccountRepository(tx)
|
||||
account, err := accountRepo.TombstoneForActor(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrNotFound(40481, "desktop_account_not_found", "desktop account not found")
|
||||
}
|
||||
return nil, response.ErrInternal(50085, "desktop_account_delete_failed", "failed to delete desktop account")
|
||||
}
|
||||
|
||||
cancelled, err := cancelQueuedPublishTasksForDesktopAccountUnbind(ctx, tx, account.TenantID, account.WorkspaceID, account.DesktopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50086, "desktop_account_delete_commit_failed", "failed to commit desktop account delete")
|
||||
}
|
||||
|
||||
view := s.buildDesktopAccountView(account, nil, nil, nil, nil)
|
||||
return &DesktopAccountDeleteResult{
|
||||
Account: view,
|
||||
CancelledQueuedPublishTasks: cancelled,
|
||||
CompletedServerSideUnbinding: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func desktopAccountHealthLatestKey(workspaceID int64, accountID uuid.UUID) string {
|
||||
return fmt.Sprintf("desktop:account-health:latest:%d:%s", workspaceID, accountID.String())
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type desktopAccountRepoSpy struct {
|
||||
markDesktopID uuid.UUID
|
||||
markWorkspaceID int64
|
||||
markUserID int64
|
||||
markDesktopID uuid.UUID
|
||||
markWorkspaceID int64
|
||||
markUserID int64
|
||||
tombstoneActorDesktopID uuid.UUID
|
||||
tombstoneActorWorkspaceID int64
|
||||
tombstoneActorUserID int64
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) ListByClient(context.Context, int64, uuid.UUID) ([]repository.DesktopAccount, error) {
|
||||
@@ -46,6 +49,24 @@ func (r *desktopAccountRepoSpy) Tombstone(context.Context, uuid.UUID, int64, uui
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) TombstoneForActor(_ context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*repository.DesktopAccount, error) {
|
||||
r.tombstoneActorDesktopID = desktopID
|
||||
r.tombstoneActorWorkspaceID = workspaceID
|
||||
r.tombstoneActorUserID = userID
|
||||
now := time.Date(2026, 6, 18, 9, 45, 0, 0, time.UTC)
|
||||
return &repository.DesktopAccount{
|
||||
DesktopID: desktopID,
|
||||
TenantID: 10,
|
||||
WorkspaceID: workspaceID,
|
||||
UserID: userID,
|
||||
Platform: "baijiahao",
|
||||
PlatformUID: "351884467",
|
||||
DisplayName: "Media Account",
|
||||
Health: "live",
|
||||
DeletedAt: &now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) MarkDeleteRequested(_ context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*repository.DesktopAccount, error) {
|
||||
r.markDesktopID = desktopID
|
||||
r.markWorkspaceID = workspaceID
|
||||
|
||||
@@ -552,6 +552,15 @@ func (s *DesktopTaskService) leaseNextQueuedPublishTask(
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.status = 'queued'
|
||||
AND dt.attempts < $4
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_accounts AS pa
|
||||
WHERE pa.desktop_id = dt.target_account_id
|
||||
AND pa.workspace_id = dt.workspace_id
|
||||
AND pa.client_id = dt.target_client_id
|
||||
AND pa.deleted_at IS NULL
|
||||
AND pa.delete_requested_at IS NULL
|
||||
)
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
@@ -607,6 +616,18 @@ func (s *DesktopTaskService) leaseQueuedDesktopTaskByID(
|
||||
AND dt.workspace_id = $2
|
||||
AND dt.status = 'queued'
|
||||
AND (dt.kind <> 'publish' OR dt.attempts < $9)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_accounts AS pa
|
||||
WHERE pa.desktop_id = dt.target_account_id
|
||||
AND pa.workspace_id = dt.workspace_id
|
||||
AND pa.client_id = dt.target_client_id
|
||||
AND pa.deleted_at IS NULL
|
||||
AND pa.delete_requested_at IS NULL
|
||||
)
|
||||
)
|
||||
AND (
|
||||
(
|
||||
dt.kind = 'monitor'
|
||||
|
||||
@@ -157,6 +157,9 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
}
|
||||
return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account")
|
||||
}
|
||||
if account.DeletedAt != nil || account.DeleteRequestedAt != nil {
|
||||
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts are being unbound or no longer exist")
|
||||
}
|
||||
accountHealth := account.Health
|
||||
if report, ok := runtimeHealth[account.DesktopID]; ok && strings.TrimSpace(report.Health) != "" {
|
||||
accountHealth = effectiveDesktopAccountRuntimeHealth(report)
|
||||
|
||||
@@ -261,6 +261,137 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelQueuedPublishTasksForDesktopAccountUnbind(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
tenantID int64,
|
||||
workspaceID int64,
|
||||
accountID uuid.UUID,
|
||||
) (int, error) {
|
||||
if tx == nil || tenantID <= 0 || workspaceID <= 0 || accountID == uuid.Nil {
|
||||
return 0, nil
|
||||
}
|
||||
errorJSON, err := marshalOptionalJSON(map[string]any{
|
||||
"code": "desktop_account_unbound",
|
||||
"message": "desktop account was unbound before publishing",
|
||||
"source": "desktop_account_unbind",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, response.ErrInternal(50120, "publish_task_cancel_payload_failed", "failed to encode publish task cancellation payload")
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT DISTINCT pr.id, pr.publish_batch_id, pr.article_id
|
||||
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
|
||||
JOIN publish_records pr
|
||||
ON pr.id::text = dt.payload->>'publish_record_id'
|
||||
AND pr.tenant_id = dt.tenant_id
|
||||
WHERE dt.tenant_id = $1
|
||||
AND dt.workspace_id = $2
|
||||
AND dt.target_account_id = $3
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.status = 'queued'
|
||||
AND pr.status IN ('queued', 'publishing', 'pending', 'running')
|
||||
`, 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")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
recordIDs := make([]int64, 0)
|
||||
batchIDs := make(map[int64]struct{})
|
||||
articleIDs := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var (
|
||||
recordID int64
|
||||
batchID int64
|
||||
articleID int64
|
||||
)
|
||||
if scanErr := rows.Scan(&recordID, &batchID, &articleID); scanErr != nil {
|
||||
return 0, response.ErrInternal(50121, "publish_task_cancel_account_unbind_failed", "failed to parse queued publish task cancellation candidates")
|
||||
}
|
||||
recordIDs = append(recordIDs, recordID)
|
||||
if batchID > 0 {
|
||||
batchIDs[batchID] = struct{}{}
|
||||
}
|
||||
if articleID > 0 {
|
||||
articleIDs[articleID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, response.ErrInternal(50121, "publish_task_cancel_account_unbind_failed", "failed to iterate queued publish task cancellation candidates")
|
||||
}
|
||||
|
||||
if len(recordIDs) > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE publish_records
|
||||
SET status = 'cancelled',
|
||||
error_message = COALESCE(NULLIF(error_message, ''), 'desktop_account_unbound'),
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $1
|
||||
AND id = ANY($2::bigint[])
|
||||
AND status IN ('queued', 'publishing', 'pending', 'running')
|
||||
`, tenantID, recordIDs); err != nil {
|
||||
return 0, response.ErrInternal(50121, "publish_task_cancel_account_unbind_failed", "failed to cancel publish records for unbound account")
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE desktop_tasks dt
|
||||
SET status = 'aborted',
|
||||
error = $4,
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
updated_at = NOW()
|
||||
FROM desktop_publish_jobs j
|
||||
WHERE dt.job_id = j.desktop_id
|
||||
AND dt.tenant_id = j.tenant_id
|
||||
AND dt.workspace_id = j.workspace_id
|
||||
AND dt.tenant_id = $1
|
||||
AND dt.workspace_id = $2
|
||||
AND dt.target_account_id = $3
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.status = 'queued'
|
||||
`, tenantID, workspaceID, accountID, errorJSON)
|
||||
if err != nil {
|
||||
return 0, response.ErrInternal(50121, "publish_task_cancel_account_unbind_failed", "failed to cancel queued publish tasks for unbound account")
|
||||
}
|
||||
|
||||
for batchID := range batchIDs {
|
||||
batchStatus, recalcErr := recalculatePublishBatchStatus(ctx, tx, batchID)
|
||||
if recalcErr != nil {
|
||||
return 0, recalcErr
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE publish_batches
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, batchStatus, batchID); err != nil {
|
||||
return 0, response.ErrInternal(50054, "publish_batch_update_failed", "failed to update publish batch")
|
||||
}
|
||||
}
|
||||
|
||||
for articleID := range articleIDs {
|
||||
articleStatus, recalcErr := recalculateArticlePublishStatus(ctx, tx, tenantID, articleID)
|
||||
if recalcErr != nil {
|
||||
return 0, recalcErr
|
||||
}
|
||||
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, tenantID); err != nil {
|
||||
return 0, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
|
||||
}
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
func syncDesktopPublishTaskState(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
@@ -514,6 +645,54 @@ func recalculatePublishBatchStatus(ctx context.Context, tx pgx.Tx, publishBatchI
|
||||
}
|
||||
}
|
||||
|
||||
func recalculateArticlePublishStatus(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (string, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT status
|
||||
FROM publish_records
|
||||
WHERE tenant_id = $1 AND article_id = $2
|
||||
`, tenantID, articleID)
|
||||
if err != nil {
|
||||
return "", response.ErrInternal(50055, "article_publish_status_query_failed", "failed to inspect article publish status")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
statuses := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var status string
|
||||
if scanErr := rows.Scan(&status); scanErr != nil {
|
||||
return "", response.ErrInternal(50055, "article_publish_status_scan_failed", "failed to parse article publish status")
|
||||
}
|
||||
statuses = append(statuses, normalizePublishStatus(status))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", response.ErrInternal(50055, "article_publish_status_scan_failed", "failed to iterate article publish status")
|
||||
}
|
||||
|
||||
if len(statuses) == 0 {
|
||||
return "unpublished", nil
|
||||
}
|
||||
|
||||
counts := map[string]int{
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"publishing": 0,
|
||||
}
|
||||
for _, status := range statuses {
|
||||
counts[normalizePublishStatus(status)]++
|
||||
}
|
||||
|
||||
switch {
|
||||
case counts["publishing"] > 0:
|
||||
return "publishing", nil
|
||||
case counts["success"] > 0 && counts["failed"] > 0:
|
||||
return "partial_success", nil
|
||||
case counts["success"] > 0:
|
||||
return "success", nil
|
||||
default:
|
||||
return "failed", nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublishArticleID(contentRef map[string]any) (int64, error) {
|
||||
if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 {
|
||||
return articleID, nil
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDesktopTaskErrorMessageMapsBaijiahaoRiskControl(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -171,3 +174,15 @@ func TestPublishBatchStatusToArticleStatusKeepsPartialSuccess(t *testing.T) {
|
||||
t.Fatalf("publishBatchStatusToArticleStatus(partial_success) = %q, want partial_success", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecalculateArticlePublishStatusDefaultsToUnpublished(t *testing.T) {
|
||||
tx := &capturePublishDedupLookupTx{rows: emptyPublishDedupRows{}}
|
||||
|
||||
got, err := recalculateArticlePublishStatus(context.Background(), tx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("recalculateArticlePublishStatus() error = %v", err)
|
||||
}
|
||||
if got != "unpublished" {
|
||||
t.Fatalf("recalculateArticlePublishStatus() = %q, want unpublished", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user