refactor(desktop): tighten account dedup, probe scheduling, and session cleanup

- Dedup AI platform accounts by platform ID rather than identity key, preferring higher health rank then newer verified_at
- Clean up session data and health records when duplicate or replaced accounts are removed
- Remove reconcileTrackedAccountRemoteState — no longer optimistically trust remote health state
- Spread initial probes over a random window (5s–3min) to avoid thundering-herd on startup
- Stop scheduling a next probe after an account is marked expired
- Update AI platforms stats strip to show authorized/pending instead of configured/authorized

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Xu Liang
2026-05-06 15:38:53 +08:00
parent ccffe87e28
commit a99d7a4971
3 changed files with 95 additions and 85 deletions
+4 -54
View File
@@ -19,6 +19,8 @@ import { getPersistedPartition, getSessionHandle } from './session-registry'
const PROBE_TICK_MS = 10_000
const STALE_AFTER_MS = 45 * 60_000
const INITIAL_PROBE_MIN_MS = 5_000
const INITIAL_PROBE_MAX_MS = 3 * 60_000
const REGULAR_PROBE_MIN_MS = 30 * 60_000
const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS
const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const
@@ -103,7 +105,7 @@ function resolveKnownPartition(accountId: string): string | null {
}
function nextInitialProbeAt(now = Date.now()): number {
return now
return now + randomInt(INITIAL_PROBE_MIN_MS, INITIAL_PROBE_MAX_MS)
}
function nextRegularProbeAt(now = Date.now()): number {
@@ -311,7 +313,7 @@ function applyExpiredResult(
record.probeState = 'idle'
record.authReason = result.reason ?? 'login_redirect'
record.lastProbeAt = now
record.nextProbeAt = nextRegularProbeAt(now)
record.nextProbeAt = null
record.lastAuthFailureAt = now
record.suspectedExpiredAt = null
record.confirmFailureCount = 0
@@ -795,58 +797,6 @@ export function markTrackedAccountMissingPartition(
return snapshot
}
export function reconcileTrackedAccountRemoteState(
account: PublishAccountIdentity,
input: {
health?: DesktopAccountInfo['health'] | null
verifiedAt?: string | null
profile?: AccountHealthProfile | null
},
): AccountHealthSnapshot | null {
if (input.health !== 'live') {
return getAccountHealthSnapshot(account.id)
}
const remoteVerifiedAt = parseVerifiedAt(input.verifiedAt)
if (!remoteVerifiedAt) {
return getAccountHealthSnapshot(account.id)
}
const record = ensureRecord(account)
const newestLocalStateAt = Math.max(
record.lastVerifiedAt ?? 0,
record.lastAuthFailureAt ?? 0,
record.lastProbeAt ?? 0,
)
if (remoteVerifiedAt <= newestLocalStateAt) {
return toPublicSnapshot(record)
}
const previousSignature = snapshotSignature(record)
record.authRevision += 1
record.platform = account.platform
record.authState = 'active'
record.probeState = 'idle'
record.authReason = 'bind_success'
record.lastVerifiedAt = remoteVerifiedAt
record.lastProbeAt = remoteVerifiedAt
record.nextProbeAt = nextRegularProbeAt()
record.lastAuthFailureAt = null
record.profile = input.profile ?? record.profile
record.sessionFingerprint = input.profile?.subjectUid ?? record.sessionFingerprint
record.suspectedExpiredAt = null
record.confirmFailureCount = 0
const snapshot = commitRecord(record, previousSignature)
const queuedJob = probeJobs.get(account.id)
if (queuedJob?.status === 'queued') {
removeQueuedProbeJob(account.id)
queuedJob.resolve(snapshot)
}
return snapshot
}
export function getAccountHealthSnapshot(accountId: string): AccountHealthSnapshot | null {
ensureInitialized()
const record = records.get(accountId)
@@ -32,7 +32,6 @@ import {
markTrackedAccountMissingPartition,
markTrackedAccountBound,
probeTrackedAccount,
reconcileTrackedAccountRemoteState,
reportAccountFailure,
syncTrackedAccounts,
} from './account-health'
@@ -326,21 +325,67 @@ function isAccountOwnedByCurrentClient(account: DesktopAccountInfo): boolean {
}
function hasLocalAccountAuthorization(account: DesktopAccountInfo): boolean {
return isAccountOwnedByCurrentClient(account)
return isAccountOwnedByCurrentClient(account) && hasLocalAccountSession(account.id)
}
function accountRequiresLocalAuthorization(account: DesktopAccountInfo): boolean {
return Boolean(account.client_id)
}
function accountRequiresStrictProbe(account: DesktopAccountInfo): boolean {
return ['bilibili', 'juejin', 'smzdm'].includes(account.platform)
}
function shouldReportAccountPresence(account: DesktopAccountInfo): boolean {
return hasLocalAccountSession(account.id) && isAccountOwnedByCurrentClient(account)
}
function buildRuntimeAccountDedupeKey(account: DesktopAccountInfo): string {
if (isAIPlatformId(account.platform)) {
return `ai-platform:${account.platform}`
}
return buildAccountIdentityKey({
platform: account.platform,
platformUid: account.platform_uid,
displayName: account.display_name,
})
}
function accountHealthRank(health: DesktopAccountInfo['health']): number {
switch (health) {
case 'live':
return 4
case 'risk':
return 3
case 'captcha':
return 2
case 'expired':
return 1
default:
return 0
}
}
function accountTimestamp(value: string | null | undefined): number {
return parseTimestamp(value) ?? 0
}
function shouldPreferRuntimeAccount(
candidate: DesktopAccountInfo,
current: DesktopAccountInfo,
): boolean {
const candidateHealthRank = accountHealthRank(candidate.health)
const currentHealthRank = accountHealthRank(current.health)
if (candidateHealthRank !== currentHealthRank) {
return candidateHealthRank > currentHealthRank
}
const candidateVerifiedAt = accountTimestamp(candidate.verified_at)
const currentVerifiedAt = accountTimestamp(current.verified_at)
if (candidateVerifiedAt !== currentVerifiedAt) {
return candidateVerifiedAt > currentVerifiedAt
}
return candidate.sync_version > current.sync_version
}
function runtimePlatformLabel(platform: string): string {
return getAIPlatformCatalogItem(platform)?.label ?? platform
}
@@ -988,17 +1033,7 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
...account,
platform_uid: normalizedPlatformUid,
})
if (hasLocalAccountAuthorization(account) && !accountRequiresStrictProbe(account)) {
reconcileTrackedAccountRemoteState(accountIdentity, {
health: account.health,
verifiedAt: account.verified_at,
profile: {
subjectUid: normalizedPlatformUid,
displayName: account.display_name,
avatarUrl: account.avatar_url,
},
})
} else if (accountRequiresLocalAuthorization(account)) {
if (accountRequiresLocalAuthorization(account) && !hasLocalAccountAuthorization(account)) {
markTrackedAccountMissingPartition(accountIdentity)
}
const projected = getProjectedAccountHealth({
@@ -1045,24 +1080,27 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
})
const duplicateAccounts: DesktopAccountInfo[] = []
const dedupedAccounts: DesktopAccountInfo[] = []
const seenAccountKeys = new Set<string>()
const seenAccountIndexes = new Map<string, number>()
for (const account of syncedAccounts) {
if (!account) {
continue
}
const identityKey = buildAccountIdentityKey({
platform: account.platform,
platformUid: account.platform_uid,
displayName: account.display_name,
})
if (seenAccountKeys.has(identityKey)) {
duplicateAccounts.push(account)
const identityKey = buildRuntimeAccountDedupeKey(account)
const existingIndex = seenAccountIndexes.get(identityKey)
if (existingIndex !== undefined) {
const existingAccount = dedupedAccounts[existingIndex]
if (shouldPreferRuntimeAccount(account, existingAccount)) {
duplicateAccounts.push(existingAccount)
dedupedAccounts[existingIndex] = account
} else {
duplicateAccounts.push(account)
}
continue
}
seenAccountKeys.add(identityKey)
seenAccountIndexes.set(identityKey, dedupedAccounts.length)
dedupedAccounts.push(account)
}
@@ -1097,6 +1135,8 @@ async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]):
for (const account of accounts) {
try {
await deleteDesktopAccount(account.id, account.sync_version)
await clearSessionHandle(account.id)
forgetTrackedAccountHealth(account.id)
} catch (error) {
console.warn('[desktop-runtime] duplicate desktop account cleanup failed', {
accountId: account.id,
@@ -1331,9 +1371,14 @@ export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
const existingIndex = state.accounts.findIndex(
(item) =>
item.id === normalizedAccount.id ||
(isAIPlatformId(normalizedAccount.platform) && item.platform === normalizedAccount.platform) ||
(item.platform === normalizedAccount.platform &&
sameAccountPlatformUid(item.platform_uid, normalizedAccount.platform_uid)),
)
const replacedAccount =
existingIndex >= 0 && state.accounts[existingIndex].id !== normalizedAccount.id
? state.accounts[existingIndex]
: null
if (existingIndex >= 0) {
state.accounts = state.accounts.map((item, index) =>
@@ -1348,6 +1393,19 @@ export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
displayName: normalizedAccount.display_name,
avatarUrl: normalizedAccount.avatar_url,
})
if (replacedAccount) {
state.accountProfiles.delete(replacedAccount.id)
forgetTrackedAccountHealth(replacedAccount.id)
void clearSessionHandle(replacedAccount.id)
void deleteDesktopAccount(replacedAccount.id, replacedAccount.sync_version).catch((error) => {
console.warn('[desktop-runtime] replaced desktop account cleanup failed', {
accountId: replacedAccount.id,
platform: replacedAccount.platform,
platformUid: replacedAccount.platform_uid,
message: errorMessage(error),
})
})
}
state.lastAccountsSyncAt = Date.now()
syncTrackedAccounts(
state.accounts
@@ -45,9 +45,11 @@ function isLocallyAuthorized(account: AccountRow | null): boolean {
}
const overview = computed(() => ({
configuredPlatforms: desktopMonitoringMediaCatalog.length,
authorized: platformCards.value.filter((platform) => isLocallyAuthorized(platform.account))
.length,
pending: platformCards.value.filter(
(platform) => platform.account && !isLocallyAuthorized(platform.account),
).length,
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
}))
@@ -259,12 +261,12 @@ async function unbindPlatform(account: AccountRow) {
<div class="stats-strip">
<div class="stat-card">
<div class="stat-label">支持平台</div>
<div class="stat-value">{{ overview.configuredPlatforms }}</div>
<div class="stat-label">已授权</div>
<div class="stat-value">{{ overview.authorized }}</div>
</div>
<div class="stat-card">
<div class="stat-label">授权正常</div>
<div class="stat-value">{{ overview.authorized }}</div>
<div class="stat-label">待处理</div>
<div class="stat-value">{{ overview.pending }}</div>
</div>
<div class="stat-card">
<div class="stat-label">在线客户端</div>