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:
@@ -1292,6 +1292,13 @@ export const tenantAccountsApi = {
|
|||||||
list() {
|
list() {
|
||||||
return apiClient.get<DesktopAccountInfo[]>('/api/tenant/accounts')
|
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 }) {
|
requestDelete(id: string, options?: { undo?: boolean }) {
|
||||||
const query = options?.undo ? '?undo=true' : ''
|
const query = options?.undo ? '?undo=true' : ''
|
||||||
return apiClient.post<DesktopAccountInfo>(
|
return apiClient.post<DesktopAccountInfo>(
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
invalid_publish_enterprise_site_targets: '企业站点发布目标格式不正确',
|
invalid_publish_enterprise_site_targets: '企业站点发布目标格式不正确',
|
||||||
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
|
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
|
||||||
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
|
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
|
||||||
|
publish_task_cancel_account_unbind_failed: '解绑账号时取消排队发布任务失败,请稍后重试',
|
||||||
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
|
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
|
||||||
desktop_publish_duplicate: '内容已在发布队列中,未重复提交',
|
desktop_publish_duplicate: '内容已在发布队列中,未重复提交',
|
||||||
desktop_publish_job_store_unavailable: '发布队列暂时不可用,请稍后重试',
|
desktop_publish_job_store_unavailable: '发布队列暂时不可用,请稍后重试',
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ export function buildPublishAccountCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePublishState(account: DesktopAccountInfo): PublishState {
|
export function resolvePublishState(account: DesktopAccountInfo): PublishState {
|
||||||
|
if (account.delete_requested_at) {
|
||||||
|
return 'unavailable'
|
||||||
|
}
|
||||||
if (resolveAccountHealth(account) !== 'live') {
|
if (resolveAccountHealth(account) !== 'live') {
|
||||||
return 'unavailable'
|
return 'unavailable'
|
||||||
}
|
}
|
||||||
@@ -250,6 +253,9 @@ export function resolvePlatformLogoUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePublishStatusText(account: DesktopAccountInfo, publishState: PublishState): string {
|
function resolvePublishStatusText(account: DesktopAccountInfo, publishState: PublishState): string {
|
||||||
|
if (account.delete_requested_at) {
|
||||||
|
return '此运行节点绑定正在解绑中,不会再接收新的发布任务。'
|
||||||
|
}
|
||||||
if (publishState === 'immediate') {
|
if (publishState === 'immediate') {
|
||||||
return '任务消费通道在线,发布任务会立即被桌面端领取执行。'
|
return '任务消费通道在线,发布任务会立即被桌面端领取执行。'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
|
||||||
const refreshing = computed(
|
const refreshing = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -389,6 +406,9 @@ const downloadableReleasesError = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function resolvePublishState(account: DesktopAccountInfo): PublishState {
|
function resolvePublishState(account: DesktopAccountInfo): PublishState {
|
||||||
|
if (account.delete_requested_at) {
|
||||||
|
return 'unavailable'
|
||||||
|
}
|
||||||
if (resolveAccountHealth(account) !== 'live') {
|
if (resolveAccountHealth(account) !== 'live') {
|
||||||
return 'unavailable'
|
return 'unavailable'
|
||||||
}
|
}
|
||||||
@@ -399,6 +419,9 @@ function resolvePublishState(account: DesktopAccountInfo): PublishState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePublishHint(account: DesktopAccountInfo, publishState: PublishState): string {
|
function resolvePublishHint(account: DesktopAccountInfo, publishState: PublishState): string {
|
||||||
|
if (account.delete_requested_at) {
|
||||||
|
return '此运行节点绑定正在解绑中,不会再接收新的发布任务。'
|
||||||
|
}
|
||||||
if (publishState === 'immediate') {
|
if (publishState === 'immediate') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -703,10 +726,10 @@ async function handleConfirmUnbind(): Promise<void> {
|
|||||||
unbindConfirmLoading.value = true
|
unbindConfirmLoading.value = true
|
||||||
try {
|
try {
|
||||||
requestingAccountDeleteId.value = unbindAccountTarget.value.id
|
requestingAccountDeleteId.value = unbindAccountTarget.value.id
|
||||||
await requestAccountDeleteMutation.mutateAsync({ id: unbindAccountTarget.value.id })
|
await removeAccountMutation.mutateAsync(unbindAccountTarget.value.id)
|
||||||
unbindModalOpen.value = false
|
unbindModalOpen.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by requestAccountDeleteMutation onError
|
// Error is handled by removeAccountMutation onError
|
||||||
} finally {
|
} finally {
|
||||||
unbindConfirmLoading.value = false
|
unbindConfirmLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -719,7 +742,8 @@ function undoUnbindAccount(account: MediaAccountCard): void {
|
|||||||
|
|
||||||
function isAccountDeleteRequestPending(accountId: string): boolean {
|
function isAccountDeleteRequestPending(accountId: string): boolean {
|
||||||
return (
|
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>
|
</a-button>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
<a-menu>
|
<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
|
<a-menu-item
|
||||||
v-if="account.deleteRequestedAt"
|
v-if="account.deleteRequestedAt"
|
||||||
key="undo-unbind"
|
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"
|
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>
|
</svg>
|
||||||
<span>已申请解绑账号</span>
|
<span>已申请解绑,可完成服务端解绑</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="account.publishHint" class="footer-alert">
|
<div v-else-if="account.publishHint" class="footer-alert">
|
||||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
|
<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">
|
<div class="unbind-warning-icon-wrapper">
|
||||||
<DeleteOutlined class="unbind-warning-icon" />
|
<DeleteOutlined class="unbind-warning-icon" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="unbind-modal-title">申请解绑媒体账号</h3>
|
<h3 class="unbind-modal-title">解绑此运行节点上的媒体账号</h3>
|
||||||
<p class="unbind-modal-subtitle">请仔细阅读以下安全提示,解绑后可重新绑定。</p>
|
<p class="unbind-modal-subtitle">
|
||||||
|
只解除当前卡片对应的运行节点绑定,同一平台账号在其他节点上的绑定不受影响。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="unbind-account-preview" v-if="unbindAccountTarget">
|
<div class="unbind-account-preview" v-if="unbindAccountTarget">
|
||||||
@@ -1502,6 +1537,9 @@ function releaseSize(value?: number | null): string {
|
|||||||
<span class="preview-meta">
|
<span class="preview-meta">
|
||||||
{{ unbindAccountTarget.platformLabel }} · ID: {{ unbindAccountTarget.platformUid }}
|
{{ unbindAccountTarget.platformLabel }} · ID: {{ unbindAccountTarget.platformUid }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="preview-meta">
|
||||||
|
运行节点 · {{ unbindAccountTarget.clientDeviceName || '未命名客户端' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<span
|
<span
|
||||||
@@ -1520,7 +1558,7 @@ function releaseSize(value?: number | null): string {
|
|||||||
<ExclamationCircleOutlined />
|
<ExclamationCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="consequence-text">
|
<div class="consequence-text">
|
||||||
<strong>解除服务绑定</strong>:系统服务端将彻底清除此账号的授权同步及分发配置。
|
<strong>解除节点绑定</strong>:系统服务端将移除此运行节点上的账号映射,这张卡片不再接收发布任务。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="consequence-item">
|
<div class="consequence-item">
|
||||||
@@ -1528,12 +1566,12 @@ function releaseSize(value?: number | null): string {
|
|||||||
<LockOutlined />
|
<LockOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="consequence-text">
|
<div class="consequence-text">
|
||||||
<strong>清理会话缓存</strong>:
|
<strong>处理本地会话</strong>:
|
||||||
<span v-if="unbindAccountTarget.clientOnline">
|
<span v-if="unbindAccountTarget.clientOnline">
|
||||||
在线客户端将即时收到通知并清理所有登录会话 Cookie。
|
在线客户端同步后会清理当前节点的本地会话缓存。
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
客户端不在线,系统已将解绑申请加入队列。客户端上线后将自动同步清理本地会话。
|
当前运行节点离线,服务端仍会立即完成解绑;该节点下次同步时会清理本地会话缓存。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1542,7 +1580,7 @@ function releaseSize(value?: number | null): string {
|
|||||||
<CloseCircleOutlined />
|
<CloseCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="consequence-text">
|
<div class="consequence-text">
|
||||||
<strong>分发任务失效</strong>:该账号将无法接收并执行任何发布任务,再次使用必须重新扫码登录授权。
|
<strong>取消排队发布</strong>:发往此运行节点此账号的等待发布任务会被取消,其他节点上的同账号任务不受影响。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@ async function syncAccounts(source: 'startup' | 'manual' | 'timer'): Promise<voi
|
|||||||
state.lastAccountsSyncAt = Date.now()
|
state.lastAccountsSyncAt = Date.now()
|
||||||
state.lastError = null
|
state.lastError = null
|
||||||
|
|
||||||
|
await cleanupRemovedRuntimeAccounts(accounts)
|
||||||
|
|
||||||
noteSchedulerAccountsSync()
|
noteSchedulerAccountsSync()
|
||||||
setSchedulerError(null)
|
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(
|
async function consumeDeleteRequestedAccounts(
|
||||||
accounts: DesktopAccountInfo[],
|
accounts: DesktopAccountInfo[],
|
||||||
): Promise<DesktopAccountInfo[]> {
|
): Promise<DesktopAccountInfo[]> {
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ var routeDocs = map[string]routeDoc{
|
|||||||
// --- Tenant:账号 / 任务统一视图(管理员视角) ---
|
// --- Tenant:账号 / 任务统一视图(管理员视角) ---
|
||||||
"GET /api/tenant/accounts": {"租户媒体账号列表", "运营人员在管理后台查看本租户下所有媒体账号。"},
|
"GET /api/tenant/accounts": {"租户媒体账号列表", "运营人员在管理后台查看本租户下所有媒体账号。"},
|
||||||
"POST /api/tenant/accounts/:id/request-delete": {"申请删除媒体账号", "由后台发起异步删除,桌面端确认后真正解绑。"},
|
"POST /api/tenant/accounts/:id/request-delete": {"申请删除媒体账号", "由后台发起异步删除,桌面端确认后真正解绑。"},
|
||||||
|
"DELETE /api/tenant/accounts/:id": {"解绑媒体账号节点", "解除指定运行节点上的媒体账号绑定,并取消发往该节点该账号的排队发布任务。"},
|
||||||
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
|
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
|
||||||
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
|
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
|
||||||
"GET /api/tenant/publish-tasks": {"发布任务列表(后台)", "管理后台分页查询本 Workspace 下的发布队列与历史发送结果。"},
|
"GET /api/tenant/publish-tasks": {"发布任务列表(后台)", "管理后台分页查询本 Workspace 下的发布队列与历史发送结果。"},
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ type DesktopAccountView struct {
|
|||||||
OldestQueuedPublishTaskAt *time.Time `json:"oldest_queued_publish_task_at"`
|
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 {
|
type UpsertDesktopAccountRequest struct {
|
||||||
Platform string `json:"platform" binding:"required"`
|
Platform string `json:"platform" binding:"required"`
|
||||||
PlatformUID string `json:"platform_uid" 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
|
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 {
|
func desktopAccountHealthLatestKey(workspaceID int64, accountID uuid.UUID) string {
|
||||||
return fmt.Sprintf("desktop:account-health:latest:%d:%s", workspaceID, accountID.String())
|
return fmt.Sprintf("desktop:account-health:latest:%d:%s", workspaceID, accountID.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type desktopAccountRepoSpy struct {
|
type desktopAccountRepoSpy struct {
|
||||||
markDesktopID uuid.UUID
|
markDesktopID uuid.UUID
|
||||||
markWorkspaceID int64
|
markWorkspaceID int64
|
||||||
markUserID int64
|
markUserID int64
|
||||||
|
tombstoneActorDesktopID uuid.UUID
|
||||||
|
tombstoneActorWorkspaceID int64
|
||||||
|
tombstoneActorUserID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *desktopAccountRepoSpy) ListByClient(context.Context, int64, uuid.UUID) ([]repository.DesktopAccount, error) {
|
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
|
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) {
|
func (r *desktopAccountRepoSpy) MarkDeleteRequested(_ context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*repository.DesktopAccount, error) {
|
||||||
r.markDesktopID = desktopID
|
r.markDesktopID = desktopID
|
||||||
r.markWorkspaceID = workspaceID
|
r.markWorkspaceID = workspaceID
|
||||||
|
|||||||
@@ -552,6 +552,15 @@ 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 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 (
|
AND (
|
||||||
NOT EXISTS (
|
NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -607,6 +616,18 @@ func (s *DesktopTaskService) leaseQueuedDesktopTaskByID(
|
|||||||
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 < $9)
|
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 (
|
AND (
|
||||||
(
|
(
|
||||||
dt.kind = 'monitor'
|
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")
|
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
|
accountHealth := account.Health
|
||||||
if report, ok := runtimeHealth[account.DesktopID]; ok && strings.TrimSpace(report.Health) != "" {
|
if report, ok := runtimeHealth[account.DesktopID]; ok && strings.TrimSpace(report.Health) != "" {
|
||||||
accountHealth = effectiveDesktopAccountRuntimeHealth(report)
|
accountHealth = effectiveDesktopAccountRuntimeHealth(report)
|
||||||
|
|||||||
@@ -261,6 +261,137 @@ func cancelQueuedPublishTasksForUnavailableArticle(ctx context.Context, tx pgx.T
|
|||||||
return nil
|
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(
|
func syncDesktopPublishTaskState(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pgx.Tx,
|
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) {
|
func resolvePublishArticleID(contentRef map[string]any) (int64, error) {
|
||||||
if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 {
|
if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 {
|
||||||
return articleID, nil
|
return articleID, nil
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestDesktopTaskErrorMessageMapsBaijiahaoRiskControl(t *testing.T) {
|
func TestDesktopTaskErrorMessageMapsBaijiahaoRiskControl(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -171,3 +174,15 @@ func TestPublishBatchStatusToArticleStatusKeepsPartialSuccess(t *testing.T) {
|
|||||||
t.Fatalf("publishBatchStatusToArticleStatus(partial_success) = %q, want partial_success", got)
|
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)
|
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
|
||||||
Patch(ctx context.Context, params PatchDesktopAccountParams) (*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)
|
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)
|
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
|
||||||
ClearDeleteRequested(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)
|
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) {
|
func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error) {
|
||||||
row, err := r.q.MarkDesktopAccountDeleteRequested(ctx, generated.MarkDesktopAccountDeleteRequestedParams{
|
row, err := r.q.MarkDesktopAccountDeleteRequested(ctx, generated.MarkDesktopAccountDeleteRequestedParams{
|
||||||
DesktopID: pgUUID(desktopID),
|
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) {
|
func desktopAccountFromMarkDeleteRow(row generated.MarkDesktopAccountDeleteRequestedRow) (*DesktopAccount, error) {
|
||||||
return buildDesktopAccount(
|
return buildDesktopAccount(
|
||||||
row.DesktopID,
|
row.DesktopID,
|
||||||
|
|||||||
@@ -723,6 +723,90 @@ func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesk
|
|||||||
return i, err
|
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
|
const upsertDesktopAccount = `-- name: UpsertDesktopAccount :one
|
||||||
INSERT INTO platform_accounts (
|
INSERT INTO platform_accounts (
|
||||||
desktop_id,
|
desktop_id,
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ type Querier interface {
|
|||||||
SoftDeleteScheduleTask(ctx context.Context, arg SoftDeleteScheduleTaskParams) error
|
SoftDeleteScheduleTask(ctx context.Context, arg SoftDeleteScheduleTaskParams) error
|
||||||
TenantCancelDesktopTask(ctx context.Context, arg TenantCancelDesktopTaskParams) (DesktopTask, error)
|
TenantCancelDesktopTask(ctx context.Context, arg TenantCancelDesktopTaskParams) (DesktopTask, error)
|
||||||
TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error)
|
TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error)
|
||||||
|
TombstoneDesktopAccountForActor(ctx context.Context, arg TombstoneDesktopAccountForActorParams) (TombstoneDesktopAccountForActorRow, error)
|
||||||
UpdateArticleCurrentVersion(ctx context.Context, arg UpdateArticleCurrentVersionParams) error
|
UpdateArticleCurrentVersion(ctx context.Context, arg UpdateArticleCurrentVersionParams) error
|
||||||
UpdateArticleGenerateStatus(ctx context.Context, arg UpdateArticleGenerateStatusParams) error
|
UpdateArticleGenerateStatus(ctx context.Context, arg UpdateArticleGenerateStatusParams) error
|
||||||
UpdateBrand(ctx context.Context, arg UpdateBrandParams) error
|
UpdateBrand(ctx context.Context, arg UpdateBrandParams) error
|
||||||
|
|||||||
@@ -253,6 +253,36 @@ RETURNING
|
|||||||
created_at,
|
created_at,
|
||||||
updated_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
|
-- name: MarkDesktopAccountDeleteRequested :one
|
||||||
UPDATE platform_accounts
|
UPDATE platform_accounts
|
||||||
SET delete_requested_at = now(),
|
SET delete_requested_at = now(),
|
||||||
|
|||||||
@@ -139,3 +139,18 @@ func (h *DesktopAccountHandler) RequestDelete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.Success(c, data)
|
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.kind = 'publish'
|
||||||
AND dt.status = 'queued'
|
AND dt.status = 'queued'
|
||||||
AND dt.attempts < 3
|
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 (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM desktop_publish_jobs AS j
|
FROM desktop_publish_jobs AS j
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
))
|
))
|
||||||
tenantProtected.GET("/accounts", desktopAccountHandler.TenantList)
|
tenantProtected.GET("/accounts", desktopAccountHandler.TenantList)
|
||||||
tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete)
|
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/reconcile", desktopTaskHandler.Reconcile)
|
||||||
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
|
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
|
||||||
tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish)
|
tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
|
|||||||
{http.MethodPost, "/api/desktop/clients/register"},
|
{http.MethodPost, "/api/desktop/clients/register"},
|
||||||
{http.MethodGet, "/api/tenant/accounts"},
|
{http.MethodGet, "/api/tenant/accounts"},
|
||||||
{http.MethodPost, "/api/tenant/accounts/:id/request-delete"},
|
{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/reconcile"},
|
||||||
{http.MethodPost, "/api/tenant/tasks/:id/cancel"},
|
{http.MethodPost, "/api/tenant/tasks/:id/cancel"},
|
||||||
{http.MethodGet, "/api/tenant/publish-tasks"},
|
{http.MethodGet, "/api/tenant/publish-tasks"},
|
||||||
|
|||||||
Reference in New Issue
Block a user