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 PROBE_TICK_MS = 10_000
|
||||||
const STALE_AFTER_MS = 45 * 60_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_MIN_MS = 30 * 60_000
|
||||||
const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS
|
const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS
|
||||||
const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const
|
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 {
|
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 {
|
function nextRegularProbeAt(now = Date.now()): number {
|
||||||
@@ -311,7 +313,7 @@ function applyExpiredResult(
|
|||||||
record.probeState = 'idle'
|
record.probeState = 'idle'
|
||||||
record.authReason = result.reason ?? 'login_redirect'
|
record.authReason = result.reason ?? 'login_redirect'
|
||||||
record.lastProbeAt = now
|
record.lastProbeAt = now
|
||||||
record.nextProbeAt = nextRegularProbeAt(now)
|
record.nextProbeAt = null
|
||||||
record.lastAuthFailureAt = now
|
record.lastAuthFailureAt = now
|
||||||
record.suspectedExpiredAt = null
|
record.suspectedExpiredAt = null
|
||||||
record.confirmFailureCount = 0
|
record.confirmFailureCount = 0
|
||||||
@@ -795,58 +797,6 @@ export function markTrackedAccountMissingPartition(
|
|||||||
return snapshot
|
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 {
|
export function getAccountHealthSnapshot(accountId: string): AccountHealthSnapshot | null {
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
const record = records.get(accountId)
|
const record = records.get(accountId)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
markTrackedAccountMissingPartition,
|
markTrackedAccountMissingPartition,
|
||||||
markTrackedAccountBound,
|
markTrackedAccountBound,
|
||||||
probeTrackedAccount,
|
probeTrackedAccount,
|
||||||
reconcileTrackedAccountRemoteState,
|
|
||||||
reportAccountFailure,
|
reportAccountFailure,
|
||||||
syncTrackedAccounts,
|
syncTrackedAccounts,
|
||||||
} from './account-health'
|
} from './account-health'
|
||||||
@@ -326,21 +325,67 @@ function isAccountOwnedByCurrentClient(account: DesktopAccountInfo): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasLocalAccountAuthorization(account: DesktopAccountInfo): boolean {
|
function hasLocalAccountAuthorization(account: DesktopAccountInfo): boolean {
|
||||||
return isAccountOwnedByCurrentClient(account)
|
return isAccountOwnedByCurrentClient(account) && hasLocalAccountSession(account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountRequiresLocalAuthorization(account: DesktopAccountInfo): boolean {
|
function accountRequiresLocalAuthorization(account: DesktopAccountInfo): boolean {
|
||||||
return Boolean(account.client_id)
|
return Boolean(account.client_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountRequiresStrictProbe(account: DesktopAccountInfo): boolean {
|
|
||||||
return ['bilibili', 'juejin', 'smzdm'].includes(account.platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldReportAccountPresence(account: DesktopAccountInfo): boolean {
|
function shouldReportAccountPresence(account: DesktopAccountInfo): boolean {
|
||||||
return hasLocalAccountSession(account.id) && isAccountOwnedByCurrentClient(account)
|
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 {
|
function runtimePlatformLabel(platform: string): string {
|
||||||
return getAIPlatformCatalogItem(platform)?.label ?? platform
|
return getAIPlatformCatalogItem(platform)?.label ?? platform
|
||||||
}
|
}
|
||||||
@@ -988,17 +1033,7 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
|||||||
...account,
|
...account,
|
||||||
platform_uid: normalizedPlatformUid,
|
platform_uid: normalizedPlatformUid,
|
||||||
})
|
})
|
||||||
if (hasLocalAccountAuthorization(account) && !accountRequiresStrictProbe(account)) {
|
if (accountRequiresLocalAuthorization(account) && !hasLocalAccountAuthorization(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)) {
|
|
||||||
markTrackedAccountMissingPartition(accountIdentity)
|
markTrackedAccountMissingPartition(accountIdentity)
|
||||||
}
|
}
|
||||||
const projected = getProjectedAccountHealth({
|
const projected = getProjectedAccountHealth({
|
||||||
@@ -1045,24 +1080,27 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
|||||||
})
|
})
|
||||||
const duplicateAccounts: DesktopAccountInfo[] = []
|
const duplicateAccounts: DesktopAccountInfo[] = []
|
||||||
const dedupedAccounts: DesktopAccountInfo[] = []
|
const dedupedAccounts: DesktopAccountInfo[] = []
|
||||||
const seenAccountKeys = new Set<string>()
|
const seenAccountIndexes = new Map<string, number>()
|
||||||
|
|
||||||
for (const account of syncedAccounts) {
|
for (const account of syncedAccounts) {
|
||||||
if (!account) {
|
if (!account) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const identityKey = buildAccountIdentityKey({
|
const identityKey = buildRuntimeAccountDedupeKey(account)
|
||||||
platform: account.platform,
|
const existingIndex = seenAccountIndexes.get(identityKey)
|
||||||
platformUid: account.platform_uid,
|
if (existingIndex !== undefined) {
|
||||||
displayName: account.display_name,
|
const existingAccount = dedupedAccounts[existingIndex]
|
||||||
})
|
if (shouldPreferRuntimeAccount(account, existingAccount)) {
|
||||||
if (seenAccountKeys.has(identityKey)) {
|
duplicateAccounts.push(existingAccount)
|
||||||
duplicateAccounts.push(account)
|
dedupedAccounts[existingIndex] = account
|
||||||
|
} else {
|
||||||
|
duplicateAccounts.push(account)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
seenAccountKeys.add(identityKey)
|
seenAccountIndexes.set(identityKey, dedupedAccounts.length)
|
||||||
dedupedAccounts.push(account)
|
dedupedAccounts.push(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,6 +1135,8 @@ async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]):
|
|||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
try {
|
try {
|
||||||
await deleteDesktopAccount(account.id, account.sync_version)
|
await deleteDesktopAccount(account.id, account.sync_version)
|
||||||
|
await clearSessionHandle(account.id)
|
||||||
|
forgetTrackedAccountHealth(account.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[desktop-runtime] duplicate desktop account cleanup failed', {
|
console.warn('[desktop-runtime] duplicate desktop account cleanup failed', {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
@@ -1331,9 +1371,14 @@ export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
|||||||
const existingIndex = state.accounts.findIndex(
|
const existingIndex = state.accounts.findIndex(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.id === normalizedAccount.id ||
|
item.id === normalizedAccount.id ||
|
||||||
|
(isAIPlatformId(normalizedAccount.platform) && item.platform === normalizedAccount.platform) ||
|
||||||
(item.platform === normalizedAccount.platform &&
|
(item.platform === normalizedAccount.platform &&
|
||||||
sameAccountPlatformUid(item.platform_uid, normalizedAccount.platform_uid)),
|
sameAccountPlatformUid(item.platform_uid, normalizedAccount.platform_uid)),
|
||||||
)
|
)
|
||||||
|
const replacedAccount =
|
||||||
|
existingIndex >= 0 && state.accounts[existingIndex].id !== normalizedAccount.id
|
||||||
|
? state.accounts[existingIndex]
|
||||||
|
: null
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
state.accounts = state.accounts.map((item, index) =>
|
state.accounts = state.accounts.map((item, index) =>
|
||||||
@@ -1348,6 +1393,19 @@ export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
|||||||
displayName: normalizedAccount.display_name,
|
displayName: normalizedAccount.display_name,
|
||||||
avatarUrl: normalizedAccount.avatar_url,
|
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()
|
state.lastAccountsSyncAt = Date.now()
|
||||||
syncTrackedAccounts(
|
syncTrackedAccounts(
|
||||||
state.accounts
|
state.accounts
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ function isLocallyAuthorized(account: AccountRow | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overview = computed(() => ({
|
const overview = computed(() => ({
|
||||||
configuredPlatforms: desktopMonitoringMediaCatalog.length,
|
|
||||||
authorized: platformCards.value.filter((platform) => isLocallyAuthorized(platform.account))
|
authorized: platformCards.value.filter((platform) => isLocallyAuthorized(platform.account))
|
||||||
.length,
|
.length,
|
||||||
|
pending: platformCards.value.filter(
|
||||||
|
(platform) => platform.account && !isLocallyAuthorized(platform.account),
|
||||||
|
).length,
|
||||||
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
|
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -259,12 +261,12 @@ async function unbindPlatform(account: AccountRow) {
|
|||||||
|
|
||||||
<div class="stats-strip">
|
<div class="stats-strip">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">支持平台</div>
|
<div class="stat-label">已授权</div>
|
||||||
<div class="stat-value">{{ overview.configuredPlatforms }}</div>
|
<div class="stat-value">{{ overview.authorized }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">授权正常</div>
|
<div class="stat-label">待处理</div>
|
||||||
<div class="stat-value">{{ overview.authorized }}</div>
|
<div class="stat-value">{{ overview.pending }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">在线客户端</div>
|
<div class="stat-label">在线客户端</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user