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
+7
View File
@@ -1292,6 +1292,13 @@ export const tenantAccountsApi = {
list() {
return apiClient.get<DesktopAccountInfo[]>('/api/tenant/accounts')
},
remove(id: string) {
return apiClient.remove<{
account: DesktopAccountInfo
cancelled_queued_publish_task_count: number
completed_server_side_unbinding: boolean
}>(`/api/tenant/accounts/${encodeURIComponent(id)}`)
},
requestDelete(id: string, options?: { undo?: boolean }) {
const query = options?.undo ? '?undo=true' : ''
return apiClient.post<DesktopAccountInfo>(
+1
View File
@@ -140,6 +140,7 @@ const errorMessageMap: Record<string, string> = {
invalid_publish_enterprise_site_targets: '企业站点发布目标格式不正确',
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
publish_task_cancel_account_unbind_failed: '解绑账号时取消排队发布任务失败,请稍后重试',
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
desktop_publish_duplicate: '内容已在发布队列中,未重复提交',
desktop_publish_job_store_unavailable: '发布队列暂时不可用,请稍后重试',
@@ -130,6 +130,9 @@ export function buildPublishAccountCard(
}
export function resolvePublishState(account: DesktopAccountInfo): PublishState {
if (account.delete_requested_at) {
return 'unavailable'
}
if (resolveAccountHealth(account) !== 'live') {
return 'unavailable'
}
@@ -250,6 +253,9 @@ export function resolvePlatformLogoUrl(
}
function resolvePublishStatusText(account: DesktopAccountInfo, publishState: PublishState): string {
if (account.delete_requested_at) {
return '此运行节点绑定正在解绑中,不会再接收新的发布任务。'
}
if (publishState === 'immediate') {
return '任务消费通道在线,发布任务会立即被桌面端领取执行。'
}
+49 -11
View File
@@ -239,6 +239,23 @@ const requestAccountDeleteMutation = useMutation({
},
})
const removeAccountMutation = useMutation({
mutationFn: (id: string) => tenantAccountsApi.remove(id),
onSuccess: async (result) => {
const cancelledCount = result.cancelled_queued_publish_task_count ?? 0
message.success(
cancelledCount > 0
? `已完成解绑,并取消 ${cancelledCount} 个排队发布任务`
: '已完成解绑',
)
await accountsQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
onSettled: () => {
requestingAccountDeleteId.value = null
},
})
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
const refreshing = computed(
() =>
@@ -389,6 +406,9 @@ const downloadableReleasesError = computed(() => {
})
function resolvePublishState(account: DesktopAccountInfo): PublishState {
if (account.delete_requested_at) {
return 'unavailable'
}
if (resolveAccountHealth(account) !== 'live') {
return 'unavailable'
}
@@ -399,6 +419,9 @@ function resolvePublishState(account: DesktopAccountInfo): PublishState {
}
function resolvePublishHint(account: DesktopAccountInfo, publishState: PublishState): string {
if (account.delete_requested_at) {
return '此运行节点绑定正在解绑中,不会再接收新的发布任务。'
}
if (publishState === 'immediate') {
return ''
}
@@ -703,10 +726,10 @@ async function handleConfirmUnbind(): Promise<void> {
unbindConfirmLoading.value = true
try {
requestingAccountDeleteId.value = unbindAccountTarget.value.id
await requestAccountDeleteMutation.mutateAsync({ id: unbindAccountTarget.value.id })
await removeAccountMutation.mutateAsync(unbindAccountTarget.value.id)
unbindModalOpen.value = false
} catch (err) {
// Error is handled by requestAccountDeleteMutation onError
// Error is handled by removeAccountMutation onError
} finally {
unbindConfirmLoading.value = false
}
@@ -719,7 +742,8 @@ function undoUnbindAccount(account: MediaAccountCard): void {
function isAccountDeleteRequestPending(accountId: string): boolean {
return (
requestAccountDeleteMutation.isPending.value && requestingAccountDeleteId.value === accountId
(requestAccountDeleteMutation.isPending.value || removeAccountMutation.isPending.value) &&
requestingAccountDeleteId.value === accountId
)
}
@@ -1365,6 +1389,15 @@ function releaseSize(value?: number | null): string {
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
v-if="account.deleteRequestedAt"
key="complete-unbind"
danger
@click="requestUnbindAccount(account)"
>
<DeleteOutlined />
<span>完成解绑</span>
</a-menu-item>
<a-menu-item
v-if="account.deleteRequestedAt"
key="undo-unbind"
@@ -1443,7 +1476,7 @@ function releaseSize(value?: number | null): string {
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
/>
</svg>
<span>已申请解绑账号</span>
<span>已申请解绑可完成服务端解绑</span>
</div>
<div v-else-if="account.publishHint" class="footer-alert">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
@@ -1473,8 +1506,10 @@ function releaseSize(value?: number | null): string {
<div class="unbind-warning-icon-wrapper">
<DeleteOutlined class="unbind-warning-icon" />
</div>
<h3 class="unbind-modal-title">申请解绑媒体账号</h3>
<p class="unbind-modal-subtitle">请仔细阅读以下安全提示解绑后可重新绑定</p>
<h3 class="unbind-modal-title">解绑此运行节点上的媒体账号</h3>
<p class="unbind-modal-subtitle">
只解除当前卡片对应的运行节点绑定同一平台账号在其他节点上的绑定不受影响
</p>
</div>
<div class="unbind-account-preview" v-if="unbindAccountTarget">
@@ -1502,6 +1537,9 @@ function releaseSize(value?: number | null): string {
<span class="preview-meta">
{{ unbindAccountTarget.platformLabel }} · ID: {{ unbindAccountTarget.platformUid }}
</span>
<span class="preview-meta">
运行节点 · {{ unbindAccountTarget.clientDeviceName || '未命名客户端' }}
</span>
</div>
<div class="preview-status">
<span
@@ -1520,7 +1558,7 @@ function releaseSize(value?: number | null): string {
<ExclamationCircleOutlined />
</div>
<div class="consequence-text">
<strong>解除服务绑定</strong>系统服务端将彻底清除此账号的授权同步及分发配置
<strong>解除节点绑定</strong>系统服务端将移除此运行节点上的账号映射这张卡片不再接收发布任务
</div>
</div>
<div class="consequence-item">
@@ -1528,12 +1566,12 @@ function releaseSize(value?: number | null): string {
<LockOutlined />
</div>
<div class="consequence-text">
<strong>清理会话缓存</strong>
<strong>处理本地会话</strong>
<span v-if="unbindAccountTarget.clientOnline">
在线客户端将即时收到通知并清理所有登录会话 Cookie
在线客户端同步后会清理当前节点的本地会话缓存
</span>
<span v-else>
客户端不在线系统已将解绑申请加入队列客户端上线后将自动同步清理本地会话
当前运行节点离线服务端仍会立即完成解绑该节点下次同步时会清理本地会话缓存
</span>
</div>
</div>
@@ -1542,7 +1580,7 @@ function releaseSize(value?: number | null): string {
<CloseCircleOutlined />
</div>
<div class="consequence-text">
<strong>分发任务失效</strong>该账号将无法接收并执行任何发布任务再次使用必须重新扫码登录授权
<strong>取消排队发布</strong>发往此运行节点此账号的等待发布任务会被取消其他节点上的同账号任务不受影响
</div>
</div>
</div>
@@ -1213,6 +1213,8 @@ async function syncAccounts(source: 'startup' | 'manual' | 'timer'): Promise<voi
state.lastAccountsSyncAt = Date.now()
state.lastError = null
await cleanupRemovedRuntimeAccounts(accounts)
noteSchedulerAccountsSync()
setSchedulerError(null)
@@ -1326,6 +1328,35 @@ async function syncAccounts(source: 'startup' | 'manual' | 'timer'): Promise<voi
}
}
async function cleanupRemovedRuntimeAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
const nextAccountIds = new Set(accounts.map((account) => account.id))
const removedAccounts = state.accounts.filter((account) => !nextAccountIds.has(account.id))
if (removedAccounts.length === 0) {
return
}
for (const account of removedAccounts) {
try {
await clearSessionHandle(account.id)
forgetTrackedAccountHealth(account.id)
state.accountProfiles.delete(account.id)
recordActivity(
'info',
'账号已完成解绑',
`${account.display_name} 已从服务端解除绑定,本机本地会话缓存已清理。`,
)
} catch (error) {
console.warn('[desktop-runtime] removed desktop account cleanup failed', {
accountId: account.id,
platform: account.platform,
platformUid: account.platform_uid,
message: errorMessage(error),
})
recordActivity('warn', '账号本地清理失败', `${account.display_name}${errorMessage(error)}`)
}
}
}
async function consumeDeleteRequestedAccounts(
accounts: DesktopAccountInfo[],
): Promise<DesktopAccountInfo[]> {
@@ -193,6 +193,7 @@ var routeDocs = map[string]routeDoc{
// --- Tenant:账号 / 任务统一视图(管理员视角) ---
"GET /api/tenant/accounts": {"租户媒体账号列表", "运营人员在管理后台查看本租户下所有媒体账号。"},
"POST /api/tenant/accounts/:id/request-delete": {"申请删除媒体账号", "由后台发起异步删除,桌面端确认后真正解绑。"},
"DELETE /api/tenant/accounts/:id": {"解绑媒体账号节点", "解除指定运行节点上的媒体账号绑定,并取消发往该节点该账号的排队发布任务。"},
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
"GET /api/tenant/publish-tasks": {"发布任务列表(后台)", "管理后台分页查询本 Workspace 下的发布队列与历史发送结果。"},
@@ -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)
}
}
@@ -68,6 +68,7 @@ type DesktopAccountRepository interface {
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error)
Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error)
TombstoneForActor(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
}
@@ -222,6 +223,18 @@ func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid
return desktopAccountFromTombstoneRow(row)
}
func (r *desktopAccountRepository) TombstoneForActor(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error) {
row, err := r.q.TombstoneDesktopAccountForActor(ctx, generated.TombstoneDesktopAccountForActorParams{
DesktopID: pgUUID(desktopID),
WorkspaceID: workspaceID,
UserID: userID,
})
if err != nil {
return nil, err
}
return desktopAccountFromTombstoneForActorRow(row)
}
func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error) {
row, err := r.q.MarkDesktopAccountDeleteRequested(ctx, generated.MarkDesktopAccountDeleteRequestedParams{
DesktopID: pgUUID(desktopID),
@@ -407,6 +420,29 @@ func desktopAccountFromTombstoneRow(row generated.TombstoneDesktopAccountRow) (*
)
}
func desktopAccountFromTombstoneForActorRow(row generated.TombstoneDesktopAccountForActorRow) (*DesktopAccount, error) {
return buildDesktopAccount(
row.DesktopID,
row.TenantID,
row.WorkspaceID,
row.UserID,
row.ClientID,
row.PlatformID,
row.PlatformUid,
row.AccountFingerprint,
row.DisplayName,
row.AvatarUrl,
row.Health,
row.VerifiedAt,
row.Tags,
row.SyncVersion,
row.DeletedAt,
row.DeleteRequestedAt,
row.CreatedAt,
row.UpdatedAt,
)
}
func desktopAccountFromMarkDeleteRow(row generated.MarkDesktopAccountDeleteRequestedRow) (*DesktopAccount, error) {
return buildDesktopAccount(
row.DesktopID,
@@ -723,6 +723,90 @@ func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesk
return i, err
}
const tombstoneDesktopAccountForActor = `-- name: TombstoneDesktopAccountForActor :one
UPDATE platform_accounts
SET deleted_at = now(),
delete_requested_at = COALESCE(delete_requested_at, now()),
sync_version = sync_version + 1,
updated_at = now()
WHERE desktop_id = $1
AND workspace_id = $2
AND user_id = $3
AND deleted_at IS NULL
RETURNING
desktop_id,
tenant_id,
workspace_id,
user_id,
client_id,
platform_id,
platform_uid,
account_fingerprint,
display_name,
avatar_url,
health,
verified_at,
tags,
sync_version,
deleted_at,
delete_requested_at,
created_at,
updated_at
`
type TombstoneDesktopAccountForActorParams struct {
DesktopID pgtype.UUID `json:"desktop_id"`
WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
}
type TombstoneDesktopAccountForActorRow struct {
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
ClientID pgtype.UUID `json:"client_id"`
PlatformID string `json:"platform_id"`
PlatformUid string `json:"platform_uid"`
AccountFingerprint pgtype.Text `json:"account_fingerprint"`
DisplayName string `json:"display_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Health string `json:"health"`
VerifiedAt pgtype.Timestamptz `json:"verified_at"`
Tags []byte `json:"tags"`
SyncVersion int64 `json:"sync_version"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) TombstoneDesktopAccountForActor(ctx context.Context, arg TombstoneDesktopAccountForActorParams) (TombstoneDesktopAccountForActorRow, error) {
row := q.db.QueryRow(ctx, tombstoneDesktopAccountForActor, arg.DesktopID, arg.WorkspaceID, arg.UserID)
var i TombstoneDesktopAccountForActorRow
err := row.Scan(
&i.DesktopID,
&i.TenantID,
&i.WorkspaceID,
&i.UserID,
&i.ClientID,
&i.PlatformID,
&i.PlatformUid,
&i.AccountFingerprint,
&i.DisplayName,
&i.AvatarUrl,
&i.Health,
&i.VerifiedAt,
&i.Tags,
&i.SyncVersion,
&i.DeletedAt,
&i.DeleteRequestedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertDesktopAccount = `-- name: UpsertDesktopAccount :one
INSERT INTO platform_accounts (
desktop_id,
@@ -182,6 +182,7 @@ type Querier interface {
SoftDeleteScheduleTask(ctx context.Context, arg SoftDeleteScheduleTaskParams) error
TenantCancelDesktopTask(ctx context.Context, arg TenantCancelDesktopTaskParams) (DesktopTask, error)
TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error)
TombstoneDesktopAccountForActor(ctx context.Context, arg TombstoneDesktopAccountForActorParams) (TombstoneDesktopAccountForActorRow, error)
UpdateArticleCurrentVersion(ctx context.Context, arg UpdateArticleCurrentVersionParams) error
UpdateArticleGenerateStatus(ctx context.Context, arg UpdateArticleGenerateStatusParams) error
UpdateBrand(ctx context.Context, arg UpdateBrandParams) error
@@ -253,6 +253,36 @@ RETURNING
created_at,
updated_at;
-- name: TombstoneDesktopAccountForActor :one
UPDATE platform_accounts
SET deleted_at = now(),
delete_requested_at = COALESCE(delete_requested_at, now()),
sync_version = sync_version + 1,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id)
AND user_id = sqlc.arg(user_id)
AND deleted_at IS NULL
RETURNING
desktop_id,
tenant_id,
workspace_id,
user_id,
client_id,
platform_id,
platform_uid,
account_fingerprint,
display_name,
avatar_url,
health,
verified_at,
tags,
sync_version,
deleted_at,
delete_requested_at,
created_at,
updated_at;
-- name: MarkDesktopAccountDeleteRequested :one
UPDATE platform_accounts
SET delete_requested_at = now(),
@@ -139,3 +139,18 @@ func (h *DesktopAccountHandler) RequestDelete(c *gin.Context) {
}
response.Success(c, data)
}
func (h *DesktopAccountHandler) TenantDelete(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid"))
return
}
data, err := h.svc.DeleteForActor(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
@@ -193,6 +193,15 @@ func (h *DesktopDispatchHandler) replayQueuedPublishTasks(ctx context.Context, c
AND dt.kind = 'publish'
AND dt.status = 'queued'
AND dt.attempts < 3
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
FROM desktop_publish_jobs AS j
@@ -89,6 +89,7 @@ func RegisterRoutes(app *bootstrap.App) {
))
tenantProtected.GET("/accounts", desktopAccountHandler.TenantList)
tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete)
tenantProtected.DELETE("/accounts/:id", desktopAccountHandler.TenantDelete)
tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile)
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish)
@@ -29,6 +29,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
{http.MethodPost, "/api/desktop/clients/register"},
{http.MethodGet, "/api/tenant/accounts"},
{http.MethodPost, "/api/tenant/accounts/:id/request-delete"},
{http.MethodDelete, "/api/tenant/accounts/:id"},
{http.MethodPost, "/api/tenant/tasks/:id/reconcile"},
{http.MethodPost, "/api/tenant/tasks/:id/cancel"},
{http.MethodGet, "/api/tenant/publish-tasks"},