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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user