diff --git a/apps/admin-web/src/views/MediaView.vue b/apps/admin-web/src/views/MediaView.vue index d49d3d7..b178fc9 100644 --- a/apps/admin-web/src/views/MediaView.vue +++ b/apps/admin-web/src/views/MediaView.vue @@ -11,6 +11,7 @@ import { ExclamationCircleOutlined, GlobalOutlined, LinkOutlined, + LockOutlined, MoreOutlined, PlusOutlined, ReloadOutlined, @@ -107,6 +108,19 @@ const activeMediaScope = ref('ugc') const searchQuery = ref('') const selectedHealth = ref('all') const downloadingReleaseKey = ref(null) +const requestingAccountDeleteId = ref(null) +const unbindModalOpen = ref(false) +const unbindAccountTarget = ref(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 editingEnterpriseSiteId = ref(null) const deletingEnterpriseSiteId = ref(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 refreshing = computed( () => @@ -555,7 +582,7 @@ function handleEnterpriseSiteCMSTypeChange(value: CmsType): void { } 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 { @@ -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 { + 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 { try { const parsed = new URL(site.site_url) @@ -939,7 +999,10 @@ function releaseSize(value?: number | null): string {

暂无企业自建站点

-

对接您的 CMS 站点(如 PBootCMS、WordPress 等)后,系统将能够通过 API 自动将文章推送到您的自建网站,实现多渠道一键发布与同步。

+

+ 对接您的 CMS 站点(如 PBootCMS、WordPress 等)后,系统将能够通过 API + 自动将文章推送到您的自建网站,实现多渠道一键发布与同步。 +

- +
- +

{{ site.name }}

@@ -975,7 +1038,7 @@ function releaseSize(value?: number | null): string { {{ cmsTypeLabel(site.cms_type as CmsType) }}
- + {{ site.site_url }}
- +
{{ enterpriseSiteCredentialLabel(site) }} @@ -1039,7 +1102,7 @@ function releaseSize(value?: number | null): string { 同步栏目 - + @@ -1290,6 +1353,38 @@ function releaseSize(value?: number | null): string {

+ + + + + + +
@@ -1365,6 +1460,120 @@ function releaseSize(value?: number | null): string { + + +
+
+ +
+

申请解绑媒体账号

+

请仔细阅读以下安全提示,解绑后可重新绑定。

+
+ + + +
+
+
+ +
+
+ 解除服务绑定:系统服务端将彻底清除此账号的授权同步及分发配置。 +
+
+
+
+ +
+
+ 清理会话缓存: + + 在线客户端将即时收到通知并清理所有登录会话 Cookie。 + + + 客户端不在线,系统已将解绑申请加入队列。客户端上线后将自动同步清理本地会话。 + +
+
+
+
+ +
+
+ 分发任务失效:该账号将无法接收并执行任何发布任务,再次使用必须重新扫码登录授权。 +
+
+
+ +
+
+ 请输入账号名称 {{ unbindAccountTarget.displayName }} 或输入 解绑 进行确认: +
+ +
+ + +
+ diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 7a8ae26..d9c37a1 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -150,6 +150,7 @@ type MonitorExecutionPhase = const heartbeatIntervalMs = 15_000 const pullIntervalMs = 60_000 const publishPullIntervalMs = 30_000 +const accountSyncIntervalMs = 30_000 const pumpWatchdogIntervalMs = 30_000 const leaseExtendIntervalMs = 60_000 const defaultPublishTaskTimeoutMs = 5 * 60_000 @@ -258,11 +259,13 @@ interface RuntimeState { publishFallbackBackoffUntil: number heartbeatTimer: ReturnType | null pullTimer: ReturnType | null + accountSyncTimer: ReturnType | null pumpWatchdogTimer: ReturnType | null accountHealthReportTimer: ReturnType | null accountHealthReportQueue: Map accountHealthReportBadAccounts: Set accountRemoteHealth: Map + accountSyncRunning: boolean dispatchWsClient: DispatchWsClient | null dispatchWsConnected: boolean lastHeartbeatAt: number @@ -289,11 +292,13 @@ const state: RuntimeState = { publishFallbackBackoffUntil: 0, heartbeatTimer: null, pullTimer: null, + accountSyncTimer: null, pumpWatchdogTimer: null, accountHealthReportTimer: null, accountHealthReportQueue: new Map(), accountHealthReportBadAccounts: new Set(), accountRemoteHealth: new Map(), + accountSyncRunning: false, dispatchWsClient: null, dispatchWsConnected: false, lastHeartbeatAt: 0, @@ -712,6 +717,7 @@ function startRuntime(): void { connectDispatchWs() scheduleHeartbeatLoop() schedulePullLoop() + scheduleAccountSyncLoop() schedulePumpWatchdogLoop() void sendHeartbeat('startup') @@ -753,6 +759,10 @@ function stopRuntime(): void { clearInterval(state.pullTimer) state.pullTimer = null } + if (state.accountSyncTimer) { + clearInterval(state.accountSyncTimer) + state.accountSyncTimer = null + } if (state.pumpWatchdogTimer) { clearInterval(state.pumpWatchdogTimer) state.pumpWatchdogTimer = null @@ -824,6 +834,16 @@ function schedulePullLoop(): void { }, publishPullIntervalMs) } +function scheduleAccountSyncLoop(): void { + if (state.accountSyncTimer) { + clearInterval(state.accountSyncTimer) + } + + state.accountSyncTimer = setInterval(() => { + void syncAccounts('timer') + }, accountSyncIntervalMs) +} + function schedulePumpWatchdogLoop(): void { if (state.pumpWatchdogTimer) { clearInterval(state.pumpWatchdogTimer) @@ -1180,13 +1200,17 @@ async function sendHeartbeat(source: 'startup' | 'timer'): Promise { } } -async function syncAccounts(source: 'startup' | 'manual'): Promise { +async function syncAccounts(source: 'startup' | 'manual' | 'timer'): Promise { if (!canRunLive(state.session)) { return } + if (state.accountSyncRunning) { + return + } + state.accountSyncRunning = true try { - const accounts = await listDesktopAccounts() + const accounts = await consumeDeleteRequestedAccounts(await listDesktopAccounts()) state.lastAccountsSyncAt = Date.now() state.lastError = null @@ -1298,9 +1322,47 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise { setSchedulerError(message) recordActivity('warn', '账号同步失败', message) + } finally { + state.accountSyncRunning = false } } +async function consumeDeleteRequestedAccounts( + accounts: DesktopAccountInfo[], +): Promise { + 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 { for (const account of accounts) { try { diff --git a/server/internal/tenant/app/desktop_account_service.go b/server/internal/tenant/app/desktop_account_service.go index c15a90d..b915531 100644 --- a/server/internal/tenant/app/desktop_account_service.go +++ b/server/internal/tenant/app/desktop_account_service.go @@ -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) { - if actor.PrimaryWorkspaceID == 0 { + if actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 { 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 { - account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID) + account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID) } 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 errors.Is(err, pgx.ErrNoRows) { diff --git a/server/internal/tenant/app/desktop_account_service_test.go b/server/internal/tenant/app/desktop_account_service_test.go index d51955f..63d47c2 100644 --- a/server/internal/tenant/app/desktop_account_service_test.go +++ b/server/internal/tenant/app/desktop_account_service_test.go @@ -1,15 +1,73 @@ package app import ( + "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/geo-platform/tenant-api/internal/shared/auth" "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) { 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) } +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) { now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC) lastSeen := now.Add(-desktopClientPresenceTTL / 2) diff --git a/server/internal/tenant/repository/desktop_account_repo.go b/server/internal/tenant/repository/desktop_account_repo.go index 983f9a5..db2568d 100644 --- a/server/internal/tenant/repository/desktop_account_repo.go +++ b/server/internal/tenant/repository/desktop_account_repo.go @@ -68,8 +68,8 @@ 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) - MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) - ClearDeleteRequested(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, userID int64) (*DesktopAccount, error) } type desktopAccountRepository struct { @@ -222,10 +222,11 @@ func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid 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{ DesktopID: pgUUID(desktopID), WorkspaceID: workspaceID, + UserID: userID, }) if err != nil { return nil, err @@ -233,10 +234,11 @@ func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desk 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{ DesktopID: pgUUID(desktopID), WorkspaceID: workspaceID, + UserID: userID, }) if err != nil { return nil, err diff --git a/server/internal/tenant/repository/generated/desktop_account.sql.go b/server/internal/tenant/repository/generated/desktop_account.sql.go index 36cd46f..4c9ec7c 100644 --- a/server/internal/tenant/repository/generated/desktop_account.sql.go +++ b/server/internal/tenant/repository/generated/desktop_account.sql.go @@ -18,6 +18,7 @@ SET delete_requested_at = NULL, updated_at = now() WHERE desktop_id = $1 AND workspace_id = $2 + AND user_id = $3 AND deleted_at IS NULL RETURNING desktop_id, @@ -43,6 +44,7 @@ RETURNING type ClearDesktopAccountDeleteRequestedParams struct { DesktopID pgtype.UUID `json:"desktop_id"` WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` } type ClearDesktopAccountDeleteRequestedRow struct { @@ -67,7 +69,7 @@ type ClearDesktopAccountDeleteRequestedRow struct { } 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 err := row.Scan( &i.DesktopID, @@ -449,6 +451,7 @@ SET delete_requested_at = now(), updated_at = now() WHERE desktop_id = $1 AND workspace_id = $2 + AND user_id = $3 AND deleted_at IS NULL RETURNING desktop_id, @@ -474,6 +477,7 @@ RETURNING type MarkDesktopAccountDeleteRequestedParams struct { DesktopID pgtype.UUID `json:"desktop_id"` WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` } type MarkDesktopAccountDeleteRequestedRow struct { @@ -498,7 +502,7 @@ type MarkDesktopAccountDeleteRequestedRow struct { } 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 err := row.Scan( &i.DesktopID, diff --git a/server/internal/tenant/repository/queries/desktop_account.sql b/server/internal/tenant/repository/queries/desktop_account.sql index e7cafa9..0986a67 100644 --- a/server/internal/tenant/repository/queries/desktop_account.sql +++ b/server/internal/tenant/repository/queries/desktop_account.sql @@ -260,6 +260,7 @@ SET delete_requested_at = now(), 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, @@ -288,6 +289,7 @@ SET delete_requested_at = NULL, 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,