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

This commit is contained in:
2026-06-19 09:29:04 +08:00
parent 0cddb47055
commit 97ae601cfd
20 changed files with 561 additions and 15 deletions
@@ -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)
}
}