Files
geo/apps/desktop-client/src/main/account-health.ts
T
root 5f6e9f11da
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
feat(desktop): add client release updater
2026-05-25 19:23:49 +08:00

1019 lines
28 KiB
TypeScript

import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { DesktopAccountInfo } from '@geo/shared-types'
import { app } from 'electron/main'
import type { PublishAccountIdentity } from './account-binder'
import type {
AccountAuthReason,
AccountAuthState,
AccountHealthProfile,
AccountHealthSnapshot,
AccountProbeState,
AuthProbeResult,
} from './auth-types'
import {
getPlatformAdapter,
type PlatformAdapter,
type PlatformFailureInput,
} from './platform-auth-adapters'
import { emitRuntimeInvalidated } from './runtime-events'
import { getPersistedPartition, getSessionHandle } from './session-registry'
const PROBE_TICK_MS = 1_000
const STALE_AFTER_MS = 45 * 60_000
const INITIAL_PROBE_MIN_MS = 1_000
const INITIAL_PROBE_MAX_MS = 10_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
const NETWORK_RETRY_MS = 2 * 60_000
const PREFLIGHT_MAX_AGE_MS = 15 * 60_000
const MAX_CONCURRENT_ACCOUNT_PROBES = 2
const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const
const CHALLENGE_RECHECK_ATTEMPTS = 1
type ProbeTrigger = 'scheduled' | 'manual' | 'preflight' | 'confirm'
interface PersistedAccountHealthRecord {
accountId: string
platform: string
lastVerifiedAt: number | null
lastAuthFailureAt: number | null
profile: AccountHealthProfile | null
sessionFingerprint: string | null
}
interface AccountHealthRecord {
accountId: string
platform: string
authRevision: number
authState: AccountAuthState
probeState: AccountProbeState
authReason: AccountAuthReason
lastVerifiedAt: number | null
lastProbeAt: number | null
nextProbeAt: number | null
lastAuthFailureAt: number | null
profile: AccountHealthProfile | null
sessionFingerprint: string | null
suspectedExpiredAt: number | null
confirmFailureCount: number
}
interface AccountProbeJob {
account: PublishAccountIdentity
trigger: ProbeTrigger
allowSilentRefresh: boolean
force: boolean
status: 'queued' | 'running'
attempt: number
maxAttempts: number
resolve: (snapshot: AccountHealthSnapshot) => void
reject: (error: unknown) => void
promise: Promise<AccountHealthSnapshot>
}
const trackedAccounts = new Map<string, PublishAccountIdentity>()
const records = new Map<string, AccountHealthRecord>()
const accountLocks = new Map<string, Promise<void>>()
const probeJobs = new Map<string, AccountProbeJob>()
const probeQueue: string[] = []
let initialized = false
let probeTimer: ReturnType<typeof setInterval> | null = null
let activeProbeCount = 0
function randomInt(min: number, max: number): number {
if (max <= min) {
return min
}
return min + Math.floor(Math.random() * (max - min))
}
function persistedHealthPath(): string {
return join(app.getPath('userData'), 'desktop-account-health.json')
}
function resolvePartition(accountId: string): string {
return (
getSessionHandle(accountId)?.partition ??
getPersistedPartition(accountId) ??
`persist:acc-${accountId}`
)
}
function resolveKnownPartition(accountId: string): string | null {
return getSessionHandle(accountId)?.partition ?? getPersistedPartition(accountId) ?? null
}
function nextInitialProbeAt(now = Date.now()): number {
return now + randomInt(INITIAL_PROBE_MIN_MS, INITIAL_PROBE_MAX_MS)
}
function nextRegularProbeAt(now = Date.now()): number {
return now + randomInt(REGULAR_PROBE_MIN_MS, REGULAR_PROBE_MAX_MS)
}
function normalizePersisted(record: PersistedAccountHealthRecord): AccountHealthRecord {
return {
accountId: record.accountId,
platform: record.platform,
authRevision: 0,
authState: record.lastVerifiedAt ? 'active' : 'unknown',
probeState: 'idle',
authReason: record.lastVerifiedAt ? 'probe_success' : null,
lastVerifiedAt: record.lastVerifiedAt ?? null,
lastProbeAt: null,
nextProbeAt: nextInitialProbeAt(),
lastAuthFailureAt: record.lastAuthFailureAt ?? null,
profile: record.profile ?? null,
sessionFingerprint: record.sessionFingerprint ?? null,
suspectedExpiredAt: null,
confirmFailureCount: 0,
}
}
function loadPersistedRecords(): void {
try {
const raw = JSON.parse(
readFileSync(persistedHealthPath(), 'utf8'),
) as PersistedAccountHealthRecord[]
for (const item of raw) {
if (!item?.accountId || !item?.platform) {
continue
}
records.set(item.accountId, normalizePersisted(item))
}
} catch {
records.clear()
}
}
function persistRecords(): void {
const payload: PersistedAccountHealthRecord[] = [...records.values()].map((record) => ({
accountId: record.accountId,
platform: record.platform,
lastVerifiedAt: record.lastVerifiedAt,
lastAuthFailureAt: record.lastAuthFailureAt,
profile: record.profile,
sessionFingerprint: record.sessionFingerprint,
}))
const target = persistedHealthPath()
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, JSON.stringify(payload, null, 2), 'utf8')
}
function ensureInitialized(): void {
if (initialized) {
return
}
initialized = true
loadPersistedRecords()
}
function ensureProbeTimer(): void {
ensureInitialized()
if (probeTimer) {
return
}
probeTimer = setInterval(() => {
void runDueProbes()
}, PROBE_TICK_MS)
}
function coerceDerivedAuthState(record: AccountHealthRecord): AccountAuthState {
return record.authState
}
function toPublicSnapshot(record: AccountHealthRecord): AccountHealthSnapshot {
return {
accountId: record.accountId,
platform: record.platform,
partition: resolvePartition(record.accountId),
authState: coerceDerivedAuthState(record),
probeState: record.probeState,
authReason: record.authReason,
lastVerifiedAt: record.lastVerifiedAt,
lastProbeAt: record.lastProbeAt,
nextProbeAt: record.nextProbeAt,
lastAuthFailureAt: record.lastAuthFailureAt,
profile: record.profile,
sessionFingerprint: record.sessionFingerprint,
}
}
function snapshotSignature(record: AccountHealthRecord): string {
return JSON.stringify(toPublicSnapshot(record))
}
function commitRecord(
record: AccountHealthRecord,
previousSignature: string | null,
): AccountHealthSnapshot {
records.set(record.accountId, record)
persistRecords()
const nextSignature = snapshotSignature(record)
if (previousSignature !== nextSignature) {
emitRuntimeInvalidated('account-health', { accountId: record.accountId })
}
return toPublicSnapshot(record)
}
function ensureRecord(account: PublishAccountIdentity): AccountHealthRecord {
ensureInitialized()
const existing = records.get(account.id)
if (existing) {
existing.platform = account.platform
return existing
}
const created: AccountHealthRecord = {
accountId: account.id,
platform: account.platform,
authRevision: 0,
authState: 'unknown',
probeState: 'idle',
authReason: null,
lastVerifiedAt: null,
lastProbeAt: null,
nextProbeAt: nextInitialProbeAt(),
lastAuthFailureAt: null,
profile: null,
sessionFingerprint: null,
suspectedExpiredAt: null,
confirmFailureCount: 0,
}
records.set(account.id, created)
persistRecords()
return created
}
async function withAccountLock<T>(accountId: string, work: () => Promise<T>): Promise<T> {
const previous = accountLocks.get(accountId) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
accountLocks.set(accountId, current)
await previous.catch(() => undefined)
try {
return await work()
} finally {
release()
if (accountLocks.get(accountId) === current) {
accountLocks.delete(accountId)
}
}
}
function confirmBackoffMs(failureCount: number): number {
const index = Math.min(Math.max(failureCount - 1, 0), CONFIRM_BACKOFF_STEPS_MS.length - 1)
return (
CONFIRM_BACKOFF_STEPS_MS[index] ?? CONFIRM_BACKOFF_STEPS_MS[CONFIRM_BACKOFF_STEPS_MS.length - 1]
)
}
function applyActiveResult(
record: AccountHealthRecord,
result: AuthProbeResult,
now: number,
): AccountHealthSnapshot {
const previousSignature = snapshotSignature(record)
record.authState = 'active'
record.probeState = 'idle'
record.authReason = result.reason ?? 'probe_success'
record.lastVerifiedAt = now
record.lastProbeAt = now
record.nextProbeAt = nextRegularProbeAt(now)
record.lastAuthFailureAt = null
record.profile = result.profile ?? record.profile
record.sessionFingerprint = result.sessionFingerprint ?? record.sessionFingerprint
record.suspectedExpiredAt = null
record.confirmFailureCount = 0
return commitRecord(record, previousSignature)
}
function applyExpiredResult(
record: AccountHealthRecord,
result: AuthProbeResult,
now: number,
): AccountHealthSnapshot {
const previousSignature = snapshotSignature(record)
record.authState = 'expired'
record.probeState = 'idle'
record.authReason = result.reason ?? 'login_redirect'
record.lastProbeAt = now
record.nextProbeAt = null
record.lastAuthFailureAt = now
record.suspectedExpiredAt = null
record.confirmFailureCount = 0
return commitRecord(record, previousSignature)
}
function applyChallengeResult(
record: AccountHealthRecord,
result: AuthProbeResult,
now: number,
): AccountHealthSnapshot {
const previousSignature = snapshotSignature(record)
record.authState = 'challenge_required'
record.probeState = 'idle'
record.authReason = result.reason ?? 'captcha_gate'
record.lastProbeAt = now
record.nextProbeAt = nextRegularProbeAt(now)
record.lastAuthFailureAt = now
record.suspectedExpiredAt = null
record.confirmFailureCount = 0
return commitRecord(record, previousSignature)
}
function applyNetworkResult(
record: AccountHealthRecord,
result: AuthProbeResult,
now: number,
trigger: 'scheduled' | 'manual' | 'preflight' | 'confirm',
): AccountHealthSnapshot {
const previousSignature = snapshotSignature(record)
const hadSuspicion = Boolean(record.suspectedExpiredAt)
record.probeState = 'network_error'
record.authReason = result.reason ?? 'network_error'
record.lastProbeAt = now
if (hadSuspicion && trigger === 'confirm') {
record.confirmFailureCount += 1
record.authState = record.authState === 'unknown' ? 'unknown' : 'expiring_soon'
record.nextProbeAt = now + confirmBackoffMs(record.confirmFailureCount)
} else {
record.nextProbeAt = now + NETWORK_RETRY_MS
if (record.authState === 'active') {
record.authState = 'expiring_soon'
}
}
return commitRecord(record, previousSignature)
}
function networkProbeResult(): AuthProbeResult {
return {
verdict: 'network_error',
reason: 'network_error',
profile: null,
sessionFingerprint: null,
}
}
async function probeAdapter(
adapter: PlatformAdapter,
account: PublishAccountIdentity,
partition: string,
): Promise<AuthProbeResult> {
return await adapter.probe(account, partition).catch<AuthProbeResult>(() => networkProbeResult())
}
async function recheckChallengeResult(
adapter: PlatformAdapter,
account: PublishAccountIdentity,
partition: string,
result: AuthProbeResult,
): Promise<AuthProbeResult> {
if (result.verdict !== 'challenge_required') {
return result
}
let confirmed = result
for (let attempt = 0; attempt < CHALLENGE_RECHECK_ATTEMPTS; attempt += 1) {
confirmed = await probeAdapter(adapter, account, partition)
if (confirmed.verdict !== 'challenge_required') {
return confirmed
}
}
return confirmed
}
function applyProbeResult(
record: AccountHealthRecord,
result: AuthProbeResult,
now: number,
trigger: ProbeTrigger,
): AccountHealthSnapshot {
switch (result.verdict) {
case 'active':
return applyActiveResult(record, result, now)
case 'expired':
return applyExpiredResult(record, result, now)
case 'challenge_required':
return applyChallengeResult(record, result, now)
default:
return applyNetworkResult(record, result, now, trigger)
}
}
async function performProbeLocked(
account: PublishAccountIdentity,
options: {
trigger: ProbeTrigger
allowSilentRefresh: boolean
},
): Promise<AccountHealthSnapshot> {
const record = ensureRecord(account)
const adapter = getPlatformAdapter(account.platform)
const probeAuthRevision = record.authRevision
const previousSignature = snapshotSignature(record)
record.probeState = 'probing'
commitRecord(record, previousSignature)
const partition = resolveKnownPartition(account.id)
if (!partition) {
return applyExpiredResult(
record,
{
verdict: 'expired',
reason: 'missing_partition',
profile: null,
sessionFingerprint: null,
},
Date.now(),
)
}
if (options.allowSilentRefresh) {
const refreshed = await adapter.silentRefresh(account, partition).catch(() => false)
if (record.authRevision !== probeAuthRevision) {
return toPublicSnapshot(record)
}
if (!refreshed) {
const currentSignature = snapshotSignature(record)
record.authReason = 'silent_refresh_failed'
commitRecord(record, currentSignature)
}
}
const result = await probeAdapter(adapter, account, partition)
if (record.authRevision !== probeAuthRevision) {
return toPublicSnapshot(record)
}
const confirmedResult = await recheckChallengeResult(adapter, account, partition, result)
if (record.authRevision !== probeAuthRevision) {
return toPublicSnapshot(record)
}
return applyProbeResult(record, confirmedResult, Date.now(), options.trigger)
}
function canUseCachedProbe(
record: AccountHealthRecord,
options: { trigger: ProbeTrigger; force: boolean },
now = Date.now(),
): boolean {
if (options.force) {
return false
}
if (
options.trigger === 'manual' &&
record.lastProbeAt &&
now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS
) {
return true
}
if (
(options.trigger === 'manual' || options.trigger === 'scheduled') &&
record.authState === 'active' &&
record.lastVerifiedAt &&
now - record.lastVerifiedAt < MANUAL_PROBE_CACHE_TTL_MS &&
record.probeState !== 'network_error'
) {
return true
}
return false
}
function markProbeQueued(record: AccountHealthRecord): AccountHealthSnapshot {
const previousSignature = snapshotSignature(record)
record.probeState = 'queued'
return commitRecord(record, previousSignature)
}
function isProbeInFlight(record: AccountHealthRecord): boolean {
return record.probeState === 'queued' || record.probeState === 'probing'
}
function isStaleActiveRecord(record: AccountHealthRecord, now = Date.now()): boolean {
return (
record.authState === 'active' &&
Boolean(record.lastVerifiedAt) &&
now - (record.lastVerifiedAt ?? 0) >= STALE_AFTER_MS
)
}
function maxAttemptsForTrigger(trigger: ProbeTrigger): number {
return trigger === 'preflight' ? 1 : ACCOUNT_PROBE_RETRY_BACKOFF_MS.length + 1
}
function enqueueProbeJob(job: AccountProbeJob): void {
if (!probeQueue.includes(job.account.id)) {
probeQueue.push(job.account.id)
}
drainProbeQueue()
}
function removeQueuedProbeJob(accountId: string): void {
const job = probeJobs.get(accountId)
if (!job || job.status !== 'queued') {
return
}
probeJobs.delete(accountId)
for (let index = probeQueue.length - 1; index >= 0; index -= 1) {
if (probeQueue[index] === accountId) {
probeQueue.splice(index, 1)
}
}
}
function scheduleProbeRetry(job: AccountProbeJob): void {
const retryIndex = Math.min(
Math.max(job.attempt - 2, 0),
ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1,
)
const backoff =
ACCOUNT_PROBE_RETRY_BACKOFF_MS[retryIndex] ??
ACCOUNT_PROBE_RETRY_BACKOFF_MS[ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1]
job.status = 'queued'
setTimeout(() => enqueueProbeJob(job), backoff)
}
function drainProbeQueue(): void {
while (activeProbeCount < MAX_CONCURRENT_ACCOUNT_PROBES && probeQueue.length > 0) {
const accountId = probeQueue.shift()
if (!accountId) {
continue
}
const job = probeJobs.get(accountId)
if (!job || job.status !== 'queued') {
continue
}
job.status = 'running'
activeProbeCount += 1
void runProbeJob(job)
}
}
async function runProbeJob(job: AccountProbeJob): Promise<void> {
try {
const snapshot = await withAccountLock(job.account.id, async () => {
const record = ensureRecord(job.account)
if (canUseCachedProbe(record, job)) {
return toPublicSnapshot(record)
}
return await performProbeLocked(job.account, {
trigger: job.trigger,
allowSilentRefresh: job.allowSilentRefresh,
})
})
if (snapshot.probeState === 'network_error' && job.attempt < job.maxAttempts) {
job.attempt += 1
scheduleProbeRetry(job)
return
}
probeJobs.delete(job.account.id)
job.resolve(snapshot)
} catch (error) {
probeJobs.delete(job.account.id)
job.reject(error)
} finally {
activeProbeCount = Math.max(0, activeProbeCount - 1)
drainProbeQueue()
}
}
function enqueueAccountProbe(
account: PublishAccountIdentity,
options: {
trigger?: ProbeTrigger
allowSilentRefresh?: boolean
force?: boolean
} = {},
): Promise<AccountHealthSnapshot> {
syncTrackedAccountsInternal([account], false)
const trigger = options.trigger ?? 'manual'
const force = Boolean(options.force)
const record = ensureRecord(account)
if (canUseCachedProbe(record, { trigger, force })) {
return Promise.resolve(toPublicSnapshot(record))
}
const existing = probeJobs.get(account.id)
if (existing) {
if (force) {
existing.force = true
}
existing.allowSilentRefresh = existing.allowSilentRefresh || Boolean(options.allowSilentRefresh)
if (trigger === 'manual') {
existing.trigger = 'manual'
}
return existing.promise
}
markProbeQueued(record)
let resolve!: (snapshot: AccountHealthSnapshot) => void
let reject!: (error: unknown) => void
const promise = new Promise<AccountHealthSnapshot>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
const job: AccountProbeJob = {
account,
trigger,
allowSilentRefresh: Boolean(options.allowSilentRefresh),
force,
status: 'queued',
attempt: 1,
maxAttempts: maxAttemptsForTrigger(trigger),
resolve,
reject,
promise,
}
probeJobs.set(account.id, job)
enqueueProbeJob(job)
return promise
}
async function runDueProbes(): Promise<void> {
const now = Date.now()
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
const record = records.get(account.id)
if (!record || isProbeInFlight(record)) {
return false
}
return Boolean(
(record.nextProbeAt && record.nextProbeAt <= now) || isStaleActiveRecord(record, now),
)
})
for (const account of dueAccounts) {
const record = records.get(account.id)
if (!record) {
continue
}
const trigger = record.suspectedExpiredAt ? 'confirm' : 'scheduled'
void enqueueAccountProbe(account, {
trigger,
allowSilentRefresh: true,
}).catch((error) => {
console.warn('[desktop-account-health] scheduled probe failed', {
accountId: account.id,
platform: account.platform,
message: error instanceof Error ? error.message : String(error),
})
})
}
}
function parseVerifiedAt(value: string | null | undefined): number | null {
if (!value) {
return null
}
const timestamp = Date.parse(value)
return Number.isNaN(timestamp) ? null : timestamp
}
function fallbackAuthState(
health: DesktopAccountInfo['health'] | null | undefined,
): AccountAuthState {
switch (health) {
case 'live':
return 'unknown'
case 'captcha':
return 'challenge_required'
case 'expired':
return 'expired'
default:
return 'unknown'
}
}
export function initAccountHealth(): void {
ensureInitialized()
ensureProbeTimer()
}
export function syncTrackedAccounts(accounts: PublishAccountIdentity[]): void {
syncTrackedAccountsInternal(accounts, true)
}
function syncTrackedAccountsInternal(
accounts: PublishAccountIdentity[],
pruneMissing: boolean,
): void {
initAccountHealth()
let removed = false
if (pruneMissing) {
const nextIds = new Set(accounts.map((account) => account.id))
for (const [accountId] of trackedAccounts.entries()) {
if (!nextIds.has(accountId)) {
trackedAccounts.delete(accountId)
records.delete(accountId)
removed = true
emitRuntimeInvalidated('account-health', { accountId })
}
}
}
for (const account of accounts) {
trackedAccounts.set(account.id, account)
ensureRecord(account)
}
persistRecords()
if (removed) {
emitRuntimeInvalidated('account-health')
}
if (pruneMissing) {
queueMicrotask(() => {
void runDueProbes()
})
}
}
export function forgetTrackedAccountHealth(accountId: string): void {
trackedAccounts.delete(accountId)
if (records.delete(accountId)) {
persistRecords()
emitRuntimeInvalidated('account-health', { accountId })
}
}
export function markTrackedAccountBound(
account: PublishAccountIdentity,
profile: AccountHealthProfile | null = null,
): AccountHealthSnapshot {
const record = ensureRecord(account)
const previousSignature = snapshotSignature(record)
const now = Date.now()
const resolvedProfile: AccountHealthProfile = {
displayName: profile?.displayName ?? account.displayName ?? null,
avatarUrl: profile?.avatarUrl ?? null,
subjectUid: profile?.subjectUid ?? account.platformUid ?? null,
}
trackedAccounts.set(account.id, account)
record.authRevision += 1
record.platform = account.platform
record.authState = 'active'
record.probeState = 'idle'
record.authReason = 'bind_success'
record.lastVerifiedAt = now
record.lastProbeAt = now
record.nextProbeAt = nextRegularProbeAt(now)
record.lastAuthFailureAt = null
record.profile = resolvedProfile
record.sessionFingerprint = resolvedProfile.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 markTrackedAccountMissingPartition(
account: PublishAccountIdentity,
): AccountHealthSnapshot {
const record = ensureRecord(account)
const previousSignature = snapshotSignature(record)
const now = Date.now()
trackedAccounts.delete(account.id)
record.authRevision += 1
record.platform = account.platform
record.authState = 'expired'
record.probeState = 'idle'
record.authReason = 'missing_partition'
record.lastProbeAt = now
record.nextProbeAt = null
record.lastAuthFailureAt = now
record.profile = null
record.sessionFingerprint = null
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)
return record ? toPublicSnapshot(record) : null
}
export function getProjectedAccountHealth(input: {
accountId: string
platform: string
health?: DesktopAccountInfo['health']
verifiedAt?: string | null
}) {
const snapshot = getAccountHealthSnapshot(input.accountId)
if (!snapshot) {
return {
health: input.health ?? 'risk',
authState: fallbackAuthState(input.health),
probeState: 'idle' as AccountProbeState,
authReason: null as AccountAuthReason,
lastVerifiedAt: parseVerifiedAt(input.verifiedAt),
lastProbeAt: null,
nextProbeAt: null,
lastAuthFailureAt: null,
profile: null,
sessionFingerprint: null,
partition: resolvePartition(input.accountId),
}
}
const health =
snapshot.authState === 'active'
? ('live' as const)
: snapshot.authState === 'challenge_required'
? ('captcha' as const)
: snapshot.authState === 'expired' || snapshot.authState === 'revoked'
? ('expired' as const)
: ('risk' as const)
return {
...snapshot,
health,
}
}
export async function probeTrackedAccount(
account: PublishAccountIdentity,
options: {
trigger?: ProbeTrigger
allowSilentRefresh?: boolean
force?: boolean
} = {},
): Promise<AccountHealthSnapshot> {
return await enqueueAccountProbe(account, {
trigger: options.trigger ?? 'manual',
allowSilentRefresh: Boolean(options.allowSilentRefresh),
force: Boolean(options.force),
})
}
export async function ensureAccountReady(
account: PublishAccountIdentity,
): Promise<AccountHealthSnapshot> {
syncTrackedAccountsInternal([account], false)
const snapshot = getAccountHealthSnapshot(account.id)
const isFresh = snapshot?.lastVerifiedAt
? Date.now() - snapshot.lastVerifiedAt < PREFLIGHT_MAX_AGE_MS
: false
if (
snapshot &&
snapshot.authState === 'active' &&
snapshot.probeState !== 'network_error' &&
isFresh
) {
return snapshot
}
if (
snapshot &&
['expired', 'challenge_required', 'revoked'].includes(snapshot.authState) &&
!(account.platform === 'baijiahao' && snapshot.authState === 'challenge_required')
) {
return snapshot
}
return await withAccountLock(account.id, async () => {
const lockedSnapshot = getAccountHealthSnapshot(account.id)
const lockedFresh = lockedSnapshot?.lastVerifiedAt
? Date.now() - lockedSnapshot.lastVerifiedAt < PREFLIGHT_MAX_AGE_MS
: false
if (
lockedSnapshot &&
lockedSnapshot.authState === 'active' &&
lockedSnapshot.probeState !== 'network_error' &&
lockedFresh
) {
return lockedSnapshot
}
return await performProbeLocked(account, {
trigger: 'preflight',
allowSilentRefresh: true,
})
})
}
export async function reportAccountFailure(
account: PublishAccountIdentity,
input: PlatformFailureInput,
): Promise<AccountHealthSnapshot | null> {
syncTrackedAccountsInternal([account], false)
const adapter = getPlatformAdapter(account.platform)
const classification = adapter.classifyFailure(input)
if (classification === 'ok') {
return getAccountHealthSnapshot(account.id)
}
return await withAccountLock(account.id, async () => {
const record = ensureRecord(account)
const previousSignature = snapshotSignature(record)
const now = Date.now()
if (classification === 'challenge' || classification === 'risk_control') {
record.probeState = 'probing'
commitRecord(record, previousSignature)
const partition = resolveKnownPartition(account.id)
const result = partition
? await recheckChallengeResult(adapter, account, partition, {
verdict: 'challenge_required',
reason: classification === 'risk_control' ? 'risk_control' : 'captcha_gate',
profile: null,
sessionFingerprint: null,
})
: {
verdict: 'expired' as const,
reason: 'missing_partition' as const,
profile: null,
sessionFingerprint: null,
}
return applyProbeResult(record, result, Date.now(), 'confirm')
}
if (classification === 'network') {
record.probeState = 'network_error'
record.authReason = 'network_error'
record.lastProbeAt = now
record.nextProbeAt = now + NETWORK_RETRY_MS
return commitRecord(record, previousSignature)
}
record.lastAuthFailureAt = now
record.suspectedExpiredAt = record.suspectedExpiredAt ?? now
if (record.authState === 'active') {
record.authState = 'expiring_soon'
}
record.nextProbeAt = now
commitRecord(record, previousSignature)
return await performProbeLocked(account, {
trigger: 'confirm',
allowSilentRefresh: true,
})
})
}