feat: support media account unbind requests

This commit is contained in:
2026-06-18 23:49:01 +08:00
parent fd37263116
commit 889a575188
7 changed files with 708 additions and 27 deletions
+549 -16
View File
@@ -11,6 +11,7 @@ import {
ExclamationCircleOutlined, ExclamationCircleOutlined,
GlobalOutlined, GlobalOutlined,
LinkOutlined, LinkOutlined,
LockOutlined,
MoreOutlined, MoreOutlined,
PlusOutlined, PlusOutlined,
ReloadOutlined, ReloadOutlined,
@@ -107,6 +108,19 @@ const activeMediaScope = ref<MediaScope>('ugc')
const searchQuery = ref('') const searchQuery = ref('')
const selectedHealth = ref<AccountHealthFilter>('all') const selectedHealth = ref<AccountHealthFilter>('all')
const downloadingReleaseKey = ref<string | null>(null) const downloadingReleaseKey = ref<string | null>(null)
const requestingAccountDeleteId = ref<string | null>(null)
const unbindModalOpen = ref(false)
const unbindAccountTarget = ref<MediaAccountCard | null>(null)
const unbindVerifyInput = ref('')
const unbindConfirmLoading = ref(false)
const isUnbindVerified = computed(() => {
if (!unbindAccountTarget.value) return false
const targetName = unbindAccountTarget.value.displayName.trim()
const inputVal = unbindVerifyInput.value.trim()
return inputVal === targetName || inputVal === '解绑'
})
const enterpriseSiteModalOpen = ref(false) const enterpriseSiteModalOpen = ref(false)
const editingEnterpriseSiteId = ref<number | null>(null) const editingEnterpriseSiteId = ref<number | null>(null)
const deletingEnterpriseSiteId = ref<number | null>(null) const deletingEnterpriseSiteId = ref<number | null>(null)
@@ -212,6 +226,19 @@ const deleteEnterpriseSiteMutation = useMutation({
}, },
}) })
const requestAccountDeleteMutation = useMutation({
mutationFn: (payload: { id: string; undo?: boolean }) =>
tenantAccountsApi.requestDelete(payload.id, { undo: payload.undo }),
onSuccess: async (_account, payload) => {
message.success(payload.undo ? '已撤销解绑申请' : '已申请解绑,客户端会自动清理本地授权')
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(
() => () =>
@@ -555,7 +582,7 @@ function handleEnterpriseSiteCMSTypeChange(value: CmsType): void {
} }
function getEnterpriseSiteSelectPopupContainer(triggerNode: HTMLElement): HTMLElement { function getEnterpriseSiteSelectPopupContainer(triggerNode: HTMLElement): HTMLElement {
return triggerNode.closest('.ant-modal-content') as HTMLElement || document.body return (triggerNode.closest('.ant-modal-content') as HTMLElement) || document.body
} }
function normalizeSiteUrl(value: string): string { function normalizeSiteUrl(value: string): string {
@@ -663,6 +690,39 @@ function deleteEnterpriseSite(site: EnterpriseSiteConnection): void {
}) })
} }
function requestUnbindAccount(account: MediaAccountCard): void {
unbindAccountTarget.value = account
unbindVerifyInput.value = ''
unbindConfirmLoading.value = false
unbindModalOpen.value = true
}
async function handleConfirmUnbind(): Promise<void> {
if (!isUnbindVerified.value || !unbindAccountTarget.value) return
unbindConfirmLoading.value = true
try {
requestingAccountDeleteId.value = unbindAccountTarget.value.id
await requestAccountDeleteMutation.mutateAsync({ id: unbindAccountTarget.value.id })
unbindModalOpen.value = false
} catch (err) {
// Error is handled by requestAccountDeleteMutation onError
} finally {
unbindConfirmLoading.value = false
}
}
function undoUnbindAccount(account: MediaAccountCard): void {
requestingAccountDeleteId.value = account.id
requestAccountDeleteMutation.mutate({ id: account.id, undo: true })
}
function isAccountDeleteRequestPending(accountId: string): boolean {
return (
requestAccountDeleteMutation.isPending.value && requestingAccountDeleteId.value === accountId
)
}
function enterpriseSiteFaviconURL(site: EnterpriseSiteConnection): string { function enterpriseSiteFaviconURL(site: EnterpriseSiteConnection): string {
try { try {
const parsed = new URL(site.site_url) const parsed = new URL(site.site_url)
@@ -939,7 +999,10 @@ function releaseSize(value?: number | null): string {
<PlusOutlined class="empty-plus" /> <PlusOutlined class="empty-plus" />
</div> </div>
<h3>暂无企业自建站点</h3> <h3>暂无企业自建站点</h3>
<p>对接您的 CMS 站点 PBootCMSWordPress 系统将能够通过 API 自动将文章推送到您的自建网站实现多渠道一键发布与同步</p> <p>
对接您的 CMS 站点 PBootCMSWordPress 系统将能够通过 API
自动将文章推送到您的自建网站实现多渠道一键发布与同步
</p>
<a-button <a-button
type="primary" type="primary"
size="large" size="large"
@@ -955,7 +1018,7 @@ function releaseSize(value?: number | null): string {
<article v-for="site in enterpriseSites" :key="site.id" class="site-connection-card"> <article v-for="site in enterpriseSites" :key="site.id" class="site-connection-card">
<!-- Top accent based on status --> <!-- Top accent based on status -->
<div class="site-card-accent" :class="`accent-${site.status || 'pending'}`"></div> <div class="site-card-accent" :class="`accent-${site.status || 'pending'}`"></div>
<div class="site-connection-card__header"> <div class="site-connection-card__header">
<span class="site-connection-card__icon"> <span class="site-connection-card__icon">
<img <img
@@ -967,7 +1030,7 @@ function releaseSize(value?: number | null): string {
/> />
<GlobalOutlined v-else /> <GlobalOutlined v-else />
</span> </span>
<div class="site-connection-card__header-main"> <div class="site-connection-card__header-main">
<div class="site-name-row"> <div class="site-name-row">
<h4>{{ site.name }}</h4> <h4>{{ site.name }}</h4>
@@ -975,7 +1038,7 @@ function releaseSize(value?: number | null): string {
{{ cmsTypeLabel(site.cms_type as CmsType) }} {{ cmsTypeLabel(site.cms_type as CmsType) }}
</span> </span>
</div> </div>
<span <span
class="site-badge site-badge--status" class="site-badge site-badge--status"
:class="`site-badge--status-${site.status || 'pending'}`" :class="`site-badge--status-${site.status || 'pending'}`"
@@ -993,7 +1056,7 @@ function releaseSize(value?: number | null): string {
<span>{{ site.site_url }}</span> <span>{{ site.site_url }}</span>
</a> </a>
</div> </div>
<div class="site-meta-grid"> <div class="site-meta-grid">
<div class="meta-cell"> <div class="meta-cell">
<span class="meta-label">{{ enterpriseSiteCredentialLabel(site) }}</span> <span class="meta-label">{{ enterpriseSiteCredentialLabel(site) }}</span>
@@ -1039,7 +1102,7 @@ function releaseSize(value?: number | null): string {
<template #icon><SyncOutlined /></template> <template #icon><SyncOutlined /></template>
同步栏目 同步栏目
</a-button> </a-button>
<a-dropdown :trigger="['click']" placement="bottomRight"> <a-dropdown :trigger="['click']" placement="bottomRight">
<a-button size="middle" class="action-more-btn" type="text" @click.stop> <a-button size="middle" class="action-more-btn" type="text" @click.stop>
<template #icon><MoreOutlined /></template> <template #icon><MoreOutlined /></template>
@@ -1290,6 +1353,38 @@ function releaseSize(value?: number | null): string {
</p> </p>
</div> </div>
</div> </div>
<a-dropdown :trigger="['click']" placement="bottomRight" overlayClassName="premium-unbind-dropdown">
<a-button
type="text"
class="media-card__more action-more-btn"
:loading="isAccountDeleteRequestPending(account.id)"
@click.stop
>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
v-if="account.deleteRequestedAt"
key="undo-unbind"
@click="undoUnbindAccount(account)"
>
<SyncOutlined />
<span>撤销解绑申请</span>
</a-menu-item>
<a-menu-item
v-else
key="unbind"
danger
@click="requestUnbindAccount(account)"
>
<DeleteOutlined />
<span>解绑账号</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div> </div>
<div class="media-card__tags"> <div class="media-card__tags">
@@ -1365,6 +1460,120 @@ function releaseSize(value?: number | null): string {
</section> </section>
</template> </template>
<!-- Custom Account Unbind Modal -->
<a-modal
v-model:open="unbindModalOpen"
:destroy-on-close="true"
:footer="null"
:width="480"
class="premium-unbind-modal"
:closable="true"
>
<div class="unbind-modal-header">
<div class="unbind-warning-icon-wrapper">
<DeleteOutlined class="unbind-warning-icon" />
</div>
<h3 class="unbind-modal-title">申请解绑媒体账号</h3>
<p class="unbind-modal-subtitle">请仔细阅读以下安全提示解绑后可重新绑定</p>
</div>
<div class="unbind-account-preview" v-if="unbindAccountTarget">
<div class="preview-avatar-wrapper">
<img
v-if="unbindAccountTarget.avatarUrl"
:src="unbindAccountTarget.avatarUrl"
class="preview-avatar"
/>
<span
v-else
class="preview-avatar-placeholder"
:style="{ background: unbindAccountTarget.platformAccent }"
>
{{ accountInitial(unbindAccountTarget) }}
</span>
<img
v-if="unbindAccountTarget.platformLogoUrl"
:src="unbindAccountTarget.platformLogoUrl"
class="preview-platform-icon"
/>
</div>
<div class="preview-info">
<span class="preview-name">{{ unbindAccountTarget.displayName }}</span>
<span class="preview-meta">
{{ unbindAccountTarget.platformLabel }} · ID: {{ unbindAccountTarget.platformUid }}
</span>
</div>
<div class="preview-status">
<span
class="badge-status"
:class="`badge-status--${unbindAccountTarget.clientOnline ? 'success' : 'warning'}`"
>
<span class="indicator-dot"></span>
{{ unbindAccountTarget.clientOnline ? '在线' : '离线' }}
</span>
</div>
</div>
<div class="unbind-consequences" v-if="unbindAccountTarget">
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<ExclamationCircleOutlined />
</div>
<div class="consequence-text">
<strong>解除服务绑定</strong>系统服务端将彻底清除此账号的授权同步及分发配置
</div>
</div>
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<LockOutlined />
</div>
<div class="consequence-text">
<strong>清理会话缓存</strong>
<span v-if="unbindAccountTarget.clientOnline">
在线客户端将即时收到通知并清理所有登录会话 Cookie
</span>
<span v-else>
客户端不在线系统已将解绑申请加入队列客户端上线后将自动同步清理本地会话
</span>
</div>
</div>
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<CloseCircleOutlined />
</div>
<div class="consequence-text">
<strong>分发任务失效</strong>该账号将无法接收并执行任何发布任务再次使用必须重新扫码登录授权
</div>
</div>
</div>
<div class="unbind-verification" v-if="unbindAccountTarget">
<div class="verification-label">
请输入账号名称 <span class="highlight-name">{{ unbindAccountTarget.displayName }}</span> 或输入 <span class="highlight-keyword">解绑</span> 进行确认
</div>
<a-input
v-model:value="unbindVerifyInput"
placeholder="请输入名称或“解绑”"
class="unbind-input"
@pressEnter="handleConfirmUnbind"
/>
</div>
<div class="unbind-modal-footer">
<a-button @click="unbindModalOpen = false" class="btn-cancel">取消</a-button>
<a-button
type="primary"
danger
:disabled="!isUnbindVerified"
:loading="unbindConfirmLoading"
@click="handleConfirmUnbind"
class="btn-confirm"
>
确认解绑
</a-button>
</div>
</a-modal>
<a-modal <a-modal
v-model:open="enterpriseSiteModalOpen" v-model:open="enterpriseSiteModalOpen"
:title="enterpriseSiteModalTitle" :title="enterpriseSiteModalTitle"
@@ -2203,12 +2412,16 @@ function releaseSize(value?: number | null): string {
padding: 24px; padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -1px rgba(0, 0, 0, 0.01); box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px -1px rgba(0, 0, 0, 0.01);
} }
.site-connection-card:hover { .site-connection-card:hover {
border-color: #cbd5e1; border-color: #cbd5e1;
box-shadow: 0 20px 25px -5px rgba(15, 23, 42, 0.08), 0 10px 10px -5px rgba(15, 23, 42, 0.04); box-shadow:
0 20px 25px -5px rgba(15, 23, 42, 0.08),
0 10px 10px -5px rgba(15, 23, 42, 0.04);
transform: translateY(-4px); transform: translateY(-4px);
} }
@@ -2795,6 +3008,8 @@ function releaseSize(value?: number | null): string {
.media-card__head { .media-card__head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 12px;
} }
.media-card__identity { .media-card__identity {
@@ -2802,7 +3017,24 @@ function releaseSize(value?: number | null): string {
align-items: center; align-items: center;
gap: 14px; gap: 14px;
min-width: 0; min-width: 0;
width: 100%; flex: 1;
}
.media-card__more.ant-btn-text {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 8px;
color: #94a3b8;
background: transparent;
border: 1px solid transparent;
}
.media-card__more.ant-btn-text:hover,
.media-card__more.ant-btn-text:focus {
color: #334155;
background: #f8fafc;
border-color: #e2e8f0;
} }
/* Circular Avatar glow container */ /* Circular Avatar glow container */
@@ -3236,32 +3468,32 @@ function releaseSize(value?: number | null): string {
.site-connection-card { .site-connection-card {
padding: 16px; padding: 16px;
} }
.site-meta-grid { .site-meta-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
} }
.meta-cell.full-width { .meta-cell.full-width {
grid-column: span 1; grid-column: span 1;
} }
.site-connection-card__footer-actions { .site-connection-card__footer-actions {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 12px; gap: 12px;
} }
.btn-action-ping.ant-btn { .btn-action-ping.ant-btn {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
.footer-actions-right { .footer-actions-right {
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
} }
.btn-action-sync.ant-btn { .btn-action-sync.ant-btn {
flex: 1; flex: 1;
justify-content: center; justify-content: center;
@@ -3304,4 +3536,305 @@ function releaseSize(value?: number | null): string {
:deep(.ant-dropdown-menu-item-danger:hover .anticon) { :deep(.ant-dropdown-menu-item-danger:hover .anticon) {
color: #ffffff; color: #ffffff;
} }
/* Custom Account Unbind Modal Styles */
.premium-unbind-modal :deep(.ant-modal-content) {
border-radius: 16px;
padding: 24px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.premium-unbind-modal :deep(.ant-modal-header) {
margin-bottom: 20px;
}
.unbind-modal-header {
text-align: center;
margin-bottom: 24px;
}
.unbind-warning-icon-wrapper {
width: 56px;
height: 56px;
background-color: #fef2f2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
border: 1px solid #fee2e2;
animation: pulse-warning 2s infinite ease-in-out;
}
@keyframes pulse-warning {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.15);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
.unbind-warning-icon {
font-size: 24px;
color: #ef4444;
}
.unbind-modal-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
margin: 0 0 8px;
}
.unbind-modal-subtitle {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* Account Preview inside Modal */
.unbind-account-preview {
display: flex;
align-items: center;
gap: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 20px;
}
.preview-avatar-wrapper {
position: relative;
width: 40px;
height: 40px;
flex-shrink: 0;
}
.preview-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1px solid #cbd5e1;
}
.preview-avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 14px;
}
.preview-platform-icon {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
padding: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.preview-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.preview-name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-status {
flex-shrink: 0;
}
/* Consequences List */
.unbind-consequences {
display: flex;
flex-direction: column;
gap: 12px;
background-color: #fffaf0;
border: 1px dashed #feebc8;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
}
.consequence-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.consequence-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 2px;
color: #dd6b20;
}
.consequence-icon-wrapper .anticon {
font-size: 14px;
}
.consequence-text {
font-size: 13px;
color: #7b341e;
line-height: 1.5;
}
.consequence-text strong {
color: #dd6b20;
}
/* Verification Input */
.unbind-verification {
margin-bottom: 24px;
}
.verification-label {
font-size: 13px;
color: #475569;
margin-bottom: 8px;
line-height: 1.5;
}
.highlight-name {
font-family: monospace;
font-weight: 700;
color: #ef4444;
background-color: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #fee2e2;
margin: 0 2px;
}
.highlight-keyword {
font-weight: 700;
color: #3b82f6;
background-color: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #dbeafe;
margin: 0 2px;
}
.unbind-input.ant-input {
height: 40px;
border-radius: 8px;
border-color: #cbd5e1;
font-size: 14px;
transition: all 0.2s ease;
}
.unbind-input.ant-input:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
/* Footer Actions */
.unbind-modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.unbind-modal-footer .btn-cancel {
height: 40px;
border-radius: 8px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
color: #475569;
border-color: #cbd5e1;
}
.unbind-modal-footer .btn-cancel:hover {
color: #1e293b;
border-color: #94a3b8;
background: #f8fafc;
}
.unbind-modal-footer .btn-confirm {
height: 40px;
border-radius: 8px;
padding: 0 20px;
font-size: 14px;
font-weight: 600;
}
/* Premium Dropdown styles */
:global(.premium-unbind-dropdown .ant-dropdown-menu) {
border-radius: 12px !important;
padding: 6px !important;
border: 1px solid #f1f5f9 !important;
box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.08), 0 8px 16px -6px rgba(15, 23, 42, 0.04) !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item) {
border-radius: 8px !important;
padding: 8px 16px !important;
font-size: 13.5px !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover) {
background-color: #eff6ff !important;
color: #2563eb !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover .anticon) {
color: #2563eb !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger) {
color: #ef4444 !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger .anticon) {
color: #ef4444 !important;
font-size: 15px !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger:hover) {
background-color: #fef2f2 !important;
color: #dc2626 !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger:hover .anticon) {
color: #dc2626 !important;
}
</style> </style>
@@ -150,6 +150,7 @@ type MonitorExecutionPhase =
const heartbeatIntervalMs = 15_000 const heartbeatIntervalMs = 15_000
const pullIntervalMs = 60_000 const pullIntervalMs = 60_000
const publishPullIntervalMs = 30_000 const publishPullIntervalMs = 30_000
const accountSyncIntervalMs = 30_000
const pumpWatchdogIntervalMs = 30_000 const pumpWatchdogIntervalMs = 30_000
const leaseExtendIntervalMs = 60_000 const leaseExtendIntervalMs = 60_000
const defaultPublishTaskTimeoutMs = 5 * 60_000 const defaultPublishTaskTimeoutMs = 5 * 60_000
@@ -258,11 +259,13 @@ interface RuntimeState {
publishFallbackBackoffUntil: number publishFallbackBackoffUntil: number
heartbeatTimer: ReturnType<typeof setInterval> | null heartbeatTimer: ReturnType<typeof setInterval> | null
pullTimer: ReturnType<typeof setInterval> | null pullTimer: ReturnType<typeof setInterval> | null
accountSyncTimer: ReturnType<typeof setInterval> | null
pumpWatchdogTimer: ReturnType<typeof setInterval> | null pumpWatchdogTimer: ReturnType<typeof setInterval> | null
accountHealthReportTimer: ReturnType<typeof setTimeout> | null accountHealthReportTimer: ReturnType<typeof setTimeout> | null
accountHealthReportQueue: Map<string, DesktopAccountHealthReport> accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
accountHealthReportBadAccounts: Set<string> accountHealthReportBadAccounts: Set<string>
accountRemoteHealth: Map<string, DesktopAccountInfo['health']> accountRemoteHealth: Map<string, DesktopAccountInfo['health']>
accountSyncRunning: boolean
dispatchWsClient: DispatchWsClient | null dispatchWsClient: DispatchWsClient | null
dispatchWsConnected: boolean dispatchWsConnected: boolean
lastHeartbeatAt: number lastHeartbeatAt: number
@@ -289,11 +292,13 @@ const state: RuntimeState = {
publishFallbackBackoffUntil: 0, publishFallbackBackoffUntil: 0,
heartbeatTimer: null, heartbeatTimer: null,
pullTimer: null, pullTimer: null,
accountSyncTimer: null,
pumpWatchdogTimer: null, pumpWatchdogTimer: null,
accountHealthReportTimer: null, accountHealthReportTimer: null,
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(), accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
accountHealthReportBadAccounts: new Set<string>(), accountHealthReportBadAccounts: new Set<string>(),
accountRemoteHealth: new Map<string, DesktopAccountInfo['health']>(), accountRemoteHealth: new Map<string, DesktopAccountInfo['health']>(),
accountSyncRunning: false,
dispatchWsClient: null, dispatchWsClient: null,
dispatchWsConnected: false, dispatchWsConnected: false,
lastHeartbeatAt: 0, lastHeartbeatAt: 0,
@@ -712,6 +717,7 @@ function startRuntime(): void {
connectDispatchWs() connectDispatchWs()
scheduleHeartbeatLoop() scheduleHeartbeatLoop()
schedulePullLoop() schedulePullLoop()
scheduleAccountSyncLoop()
schedulePumpWatchdogLoop() schedulePumpWatchdogLoop()
void sendHeartbeat('startup') void sendHeartbeat('startup')
@@ -753,6 +759,10 @@ function stopRuntime(): void {
clearInterval(state.pullTimer) clearInterval(state.pullTimer)
state.pullTimer = null state.pullTimer = null
} }
if (state.accountSyncTimer) {
clearInterval(state.accountSyncTimer)
state.accountSyncTimer = null
}
if (state.pumpWatchdogTimer) { if (state.pumpWatchdogTimer) {
clearInterval(state.pumpWatchdogTimer) clearInterval(state.pumpWatchdogTimer)
state.pumpWatchdogTimer = null state.pumpWatchdogTimer = null
@@ -824,6 +834,16 @@ function schedulePullLoop(): void {
}, publishPullIntervalMs) }, publishPullIntervalMs)
} }
function scheduleAccountSyncLoop(): void {
if (state.accountSyncTimer) {
clearInterval(state.accountSyncTimer)
}
state.accountSyncTimer = setInterval(() => {
void syncAccounts('timer')
}, accountSyncIntervalMs)
}
function schedulePumpWatchdogLoop(): void { function schedulePumpWatchdogLoop(): void {
if (state.pumpWatchdogTimer) { if (state.pumpWatchdogTimer) {
clearInterval(state.pumpWatchdogTimer) clearInterval(state.pumpWatchdogTimer)
@@ -1180,13 +1200,17 @@ async function sendHeartbeat(source: 'startup' | 'timer'): Promise<void> {
} }
} }
async function syncAccounts(source: 'startup' | 'manual'): Promise<void> { async function syncAccounts(source: 'startup' | 'manual' | 'timer'): Promise<void> {
if (!canRunLive(state.session)) { if (!canRunLive(state.session)) {
return return
} }
if (state.accountSyncRunning) {
return
}
state.accountSyncRunning = true
try { try {
const accounts = await listDesktopAccounts() const accounts = await consumeDeleteRequestedAccounts(await listDesktopAccounts())
state.lastAccountsSyncAt = Date.now() state.lastAccountsSyncAt = Date.now()
state.lastError = null state.lastError = null
@@ -1298,9 +1322,47 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
setSchedulerError(message) setSchedulerError(message)
recordActivity('warn', '账号同步失败', message) recordActivity('warn', '账号同步失败', message)
} finally {
state.accountSyncRunning = false
} }
} }
async function consumeDeleteRequestedAccounts(
accounts: DesktopAccountInfo[],
): Promise<DesktopAccountInfo[]> {
const remainingAccounts: DesktopAccountInfo[] = []
for (const account of accounts) {
if (!account.delete_requested_at) {
remainingAccounts.push(account)
continue
}
try {
await deleteDesktopAccount(account.id, account.sync_version)
await clearSessionHandle(account.id)
forgetTrackedAccountHealth(account.id)
state.accountProfiles.delete(account.id)
recordActivity(
'info',
'账号已自动解绑',
`${account.display_name} 已按服务端解绑申请移除,并清理了当前机器上的本地会话缓存。`,
)
} catch (error) {
remainingAccounts.push(account)
console.warn('[desktop-runtime] requested desktop account cleanup failed', {
accountId: account.id,
platform: account.platform,
platformUid: account.platform_uid,
message: errorMessage(error),
})
recordActivity('warn', '账号自动解绑失败', `${account.display_name}${errorMessage(error)}`)
}
}
return remainingAccounts
}
async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> { async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
for (const account of accounts) { for (const account of accounts) {
try { try {
@@ -387,7 +387,7 @@ func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repositor
} }
func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, undo bool) (*DesktopAccountView, error) { func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, undo bool) (*DesktopAccountView, error) {
if actor.PrimaryWorkspaceID == 0 { if actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
} }
@@ -397,9 +397,9 @@ func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Ac
) )
if undo { if undo {
account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID) account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID)
} else { } else {
account, err = s.repo.MarkDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID) account, err = s.repo.MarkDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID)
} }
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -1,15 +1,73 @@
package app package app
import ( import (
"context"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/tenant/repository" "github.com/geo-platform/tenant-api/internal/tenant/repository"
) )
type desktopAccountRepoSpy struct {
markDesktopID uuid.UUID
markWorkspaceID int64
markUserID int64
}
func (r *desktopAccountRepoSpy) ListByClient(context.Context, int64, uuid.UUID) ([]repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) ListByUser(context.Context, int64, int64) ([]repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) GetByDesktopID(context.Context, uuid.UUID, int64) (*repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) GetByIdentity(context.Context, int64, uuid.UUID, string, string) (*repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) Upsert(context.Context, repository.UpsertDesktopAccountParams) (*repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) Patch(context.Context, repository.PatchDesktopAccountParams) (*repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) Tombstone(context.Context, uuid.UUID, int64, uuid.UUID, int64) (*repository.DesktopAccount, error) {
return nil, nil
}
func (r *desktopAccountRepoSpy) MarkDeleteRequested(_ context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*repository.DesktopAccount, error) {
r.markDesktopID = desktopID
r.markWorkspaceID = workspaceID
r.markUserID = userID
now := time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)
return &repository.DesktopAccount{
DesktopID: desktopID,
TenantID: 10,
WorkspaceID: workspaceID,
UserID: userID,
Platform: "toutiaohao",
PlatformUID: "122707592",
DisplayName: "Media Account",
Health: "live",
DeleteRequestedAt: &now,
}, nil
}
func (r *desktopAccountRepoSpy) ClearDeleteRequested(context.Context, uuid.UUID, int64, int64) (*repository.DesktopAccount, error) {
return nil, nil
}
func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) { func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC) now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
@@ -21,6 +79,26 @@ func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
assert.True(t, online) assert.True(t, online)
} }
func TestRequestDeleteScopesToActorUser(t *testing.T) {
repo := &desktopAccountRepoSpy{}
service := &DesktopAccountService{repo: repo}
accountID := uuid.New()
view, err := service.RequestDelete(
context.Background(),
auth.Actor{UserID: 42, TenantID: 10, PrimaryWorkspaceID: 7},
accountID,
false,
)
assert.NoError(t, err)
assert.Equal(t, accountID, repo.markDesktopID)
assert.Equal(t, int64(7), repo.markWorkspaceID)
assert.Equal(t, int64(42), repo.markUserID)
assert.NotNil(t, view)
assert.NotNil(t, view.DeleteRequestedAt)
}
func TestResolveDesktopClientOnline_PresenceMissWithKnownPresenceIsOffline(t *testing.T) { func TestResolveDesktopClientOnline_PresenceMissWithKnownPresenceIsOffline(t *testing.T) {
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC) now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
lastSeen := now.Add(-desktopClientPresenceTTL / 2) lastSeen := now.Add(-desktopClientPresenceTTL / 2)
@@ -68,8 +68,8 @@ 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)
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
} }
type desktopAccountRepository struct { type desktopAccountRepository struct {
@@ -222,10 +222,11 @@ func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid
return desktopAccountFromTombstoneRow(row) return desktopAccountFromTombstoneRow(row)
} }
func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID 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),
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
UserID: userID,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -233,10 +234,11 @@ func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desk
return desktopAccountFromMarkDeleteRow(row) return desktopAccountFromMarkDeleteRow(row)
} }
func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) { func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error) {
row, err := r.q.ClearDesktopAccountDeleteRequested(ctx, generated.ClearDesktopAccountDeleteRequestedParams{ row, err := r.q.ClearDesktopAccountDeleteRequested(ctx, generated.ClearDesktopAccountDeleteRequestedParams{
DesktopID: pgUUID(desktopID), DesktopID: pgUUID(desktopID),
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
UserID: userID,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -18,6 +18,7 @@ SET delete_requested_at = NULL,
updated_at = now() updated_at = now()
WHERE desktop_id = $1 WHERE desktop_id = $1
AND workspace_id = $2 AND workspace_id = $2
AND user_id = $3
AND deleted_at IS NULL AND deleted_at IS NULL
RETURNING RETURNING
desktop_id, desktop_id,
@@ -43,6 +44,7 @@ RETURNING
type ClearDesktopAccountDeleteRequestedParams struct { type ClearDesktopAccountDeleteRequestedParams struct {
DesktopID pgtype.UUID `json:"desktop_id"` DesktopID pgtype.UUID `json:"desktop_id"`
WorkspaceID int64 `json:"workspace_id"` WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
} }
type ClearDesktopAccountDeleteRequestedRow struct { type ClearDesktopAccountDeleteRequestedRow struct {
@@ -67,7 +69,7 @@ type ClearDesktopAccountDeleteRequestedRow struct {
} }
func (q *Queries) ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error) { func (q *Queries) ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error) {
row := q.db.QueryRow(ctx, clearDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID) row := q.db.QueryRow(ctx, clearDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID, arg.UserID)
var i ClearDesktopAccountDeleteRequestedRow var i ClearDesktopAccountDeleteRequestedRow
err := row.Scan( err := row.Scan(
&i.DesktopID, &i.DesktopID,
@@ -449,6 +451,7 @@ SET delete_requested_at = now(),
updated_at = now() updated_at = now()
WHERE desktop_id = $1 WHERE desktop_id = $1
AND workspace_id = $2 AND workspace_id = $2
AND user_id = $3
AND deleted_at IS NULL AND deleted_at IS NULL
RETURNING RETURNING
desktop_id, desktop_id,
@@ -474,6 +477,7 @@ RETURNING
type MarkDesktopAccountDeleteRequestedParams struct { type MarkDesktopAccountDeleteRequestedParams struct {
DesktopID pgtype.UUID `json:"desktop_id"` DesktopID pgtype.UUID `json:"desktop_id"`
WorkspaceID int64 `json:"workspace_id"` WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
} }
type MarkDesktopAccountDeleteRequestedRow struct { type MarkDesktopAccountDeleteRequestedRow struct {
@@ -498,7 +502,7 @@ type MarkDesktopAccountDeleteRequestedRow struct {
} }
func (q *Queries) MarkDesktopAccountDeleteRequested(ctx context.Context, arg MarkDesktopAccountDeleteRequestedParams) (MarkDesktopAccountDeleteRequestedRow, error) { func (q *Queries) MarkDesktopAccountDeleteRequested(ctx context.Context, arg MarkDesktopAccountDeleteRequestedParams) (MarkDesktopAccountDeleteRequestedRow, error) {
row := q.db.QueryRow(ctx, markDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID) row := q.db.QueryRow(ctx, markDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID, arg.UserID)
var i MarkDesktopAccountDeleteRequestedRow var i MarkDesktopAccountDeleteRequestedRow
err := row.Scan( err := row.Scan(
&i.DesktopID, &i.DesktopID,
@@ -260,6 +260,7 @@ SET delete_requested_at = now(),
updated_at = now() updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id) WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id) AND workspace_id = sqlc.arg(workspace_id)
AND user_id = sqlc.arg(user_id)
AND deleted_at IS NULL AND deleted_at IS NULL
RETURNING RETURNING
desktop_id, desktop_id,
@@ -288,6 +289,7 @@ SET delete_requested_at = NULL,
updated_at = now() updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id) WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id) AND workspace_id = sqlc.arg(workspace_id)
AND user_id = sqlc.arg(user_id)
AND deleted_at IS NULL AND deleted_at IS NULL
RETURNING RETURNING
desktop_id, desktop_id,