2026-04-20 17:16:15 +08:00
|
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { dirname, join } from "node:path";
|
|
|
|
|
|
|
|
|
|
import { app } from "electron/main";
|
|
|
|
|
import type { DesktopAccountInfo } from "@geo/shared-types";
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
AccountAuthReason,
|
|
|
|
|
AccountAuthState,
|
|
|
|
|
AccountHealthProfile,
|
|
|
|
|
AccountHealthSnapshot,
|
|
|
|
|
AccountProbeState,
|
|
|
|
|
AuthProbeResult,
|
|
|
|
|
PlatformFailureClassification,
|
|
|
|
|
} from "./auth-types";
|
|
|
|
|
import { emitRuntimeInvalidated } from "./runtime-events";
|
|
|
|
|
import { getPersistedPartition, getSessionHandle } from "./session-registry";
|
|
|
|
|
import {
|
|
|
|
|
getPlatformAdapter,
|
|
|
|
|
type PlatformFailureInput,
|
|
|
|
|
} from "./platform-auth-adapters";
|
|
|
|
|
import type { PublishAccountIdentity } from "./account-binder";
|
|
|
|
|
|
|
|
|
|
const PROBE_TICK_MS = 10_000;
|
2026-04-29 15:58:25 +08:00
|
|
|
const STALE_AFTER_MS = 45 * 60_000;
|
2026-04-20 17:16:15 +08:00
|
|
|
const REGULAR_PROBE_MIN_MS = 30 * 60_000;
|
2026-04-29 15:58:25 +08:00
|
|
|
const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS;
|
2026-04-20 17:16:15 +08:00
|
|
|
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;
|
2026-04-27 21:33:55 +08:00
|
|
|
const MAX_CONCURRENT_ACCOUNT_PROBES = 3;
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
type ProbeTrigger = "scheduled" | "manual" | "preflight" | "confirm";
|
2026-04-20 17:16:15 +08:00
|
|
|
|
|
|
|
|
interface PersistedAccountHealthRecord {
|
|
|
|
|
accountId: string;
|
|
|
|
|
platform: string;
|
|
|
|
|
lastVerifiedAt: number | null;
|
|
|
|
|
lastAuthFailureAt: number | null;
|
|
|
|
|
profile: AccountHealthProfile | null;
|
|
|
|
|
sessionFingerprint: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AccountHealthRecord {
|
|
|
|
|
accountId: string;
|
|
|
|
|
platform: string;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:33:55 +08:00
|
|
|
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>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:16:15 +08:00
|
|
|
const trackedAccounts = new Map<string, PublishAccountIdentity>();
|
|
|
|
|
const records = new Map<string, AccountHealthRecord>();
|
|
|
|
|
const accountLocks = new Map<string, Promise<void>>();
|
2026-04-27 21:33:55 +08:00
|
|
|
const probeJobs = new Map<string, AccountProbeJob>();
|
|
|
|
|
const probeQueue: string[] = [];
|
2026-04-20 17:16:15 +08:00
|
|
|
let initialized = false;
|
|
|
|
|
let probeTimer: ReturnType<typeof setInterval> | null = null;
|
2026-04-27 21:33:55 +08:00
|
|
|
let activeProbeCount = 0;
|
2026-04-20 17:16:15 +08:00
|
|
|
|
|
|
|
|
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 nextInitialProbeAt(now = Date.now()): number {
|
2026-04-29 15:58:25 +08:00
|
|
|
return now;
|
2026-04-20 17:16:15 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-29 15:58:25 +08:00
|
|
|
authState: record.lastVerifiedAt ? "active" : "unknown",
|
2026-04-20 17:16:15 +08:00
|
|
|
probeState: "idle",
|
2026-04-29 15:58:25 +08:00
|
|
|
authReason: record.lastVerifiedAt ? "probe_success" : null,
|
2026-04-20 17:16:15 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:58:25 +08:00
|
|
|
function coerceDerivedAuthState(record: AccountHealthRecord): AccountAuthState {
|
2026-04-20 17:16:15 +08:00
|
|
|
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) {
|
2026-04-27 21:33:55 +08:00
|
|
|
emitRuntimeInvalidated("account-health", { accountId: record.accountId });
|
2026-04-20 17:16:15 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
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.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 = nextRegularProbeAt(now);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function performProbeLocked(
|
|
|
|
|
account: PublishAccountIdentity,
|
|
|
|
|
options: {
|
2026-04-27 21:33:55 +08:00
|
|
|
trigger: ProbeTrigger;
|
2026-04-20 17:16:15 +08:00
|
|
|
allowSilentRefresh: boolean;
|
|
|
|
|
},
|
|
|
|
|
): Promise<AccountHealthSnapshot> {
|
|
|
|
|
const record = ensureRecord(account);
|
|
|
|
|
const adapter = getPlatformAdapter(account.platform);
|
|
|
|
|
const previousSignature = snapshotSignature(record);
|
|
|
|
|
|
|
|
|
|
record.probeState = "probing";
|
|
|
|
|
commitRecord(record, previousSignature);
|
|
|
|
|
|
|
|
|
|
const partition = resolvePartition(account.id);
|
|
|
|
|
|
|
|
|
|
if (options.allowSilentRefresh) {
|
|
|
|
|
const refreshed = await adapter.silentRefresh(account, partition).catch(() => false);
|
|
|
|
|
if (!refreshed) {
|
|
|
|
|
const currentSignature = snapshotSignature(record);
|
|
|
|
|
record.authReason = "silent_refresh_failed";
|
|
|
|
|
commitRecord(record, currentSignature);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await adapter.probe(account, partition).catch<AuthProbeResult>(() => ({
|
|
|
|
|
verdict: "network_error",
|
|
|
|
|
reason: "network_error",
|
|
|
|
|
profile: null,
|
|
|
|
|
sessionFingerprint: null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
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, options.trigger);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:33:55 +08:00
|
|
|
function canUseCachedProbe(
|
|
|
|
|
record: AccountHealthRecord,
|
|
|
|
|
options: { trigger: ProbeTrigger; force: boolean },
|
|
|
|
|
now = Date.now(),
|
|
|
|
|
): boolean {
|
|
|
|
|
if (options.force) {
|
2026-04-29 17:11:50 +08:00
|
|
|
return false;
|
2026-04-27 21:33:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:58:25 +08:00
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:33:55 +08:00
|
|
|
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 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:16:15 +08:00
|
|
|
async function runDueProbes(): Promise<void> {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
|
|
|
|
|
const record = records.get(account.id);
|
2026-04-29 15:58:25 +08:00
|
|
|
if (!record || isProbeInFlight(record)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return Boolean((record.nextProbeAt && record.nextProbeAt <= now) || isStaleActiveRecord(record, now));
|
2026-04-20 17:16:15 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const account of dueAccounts) {
|
|
|
|
|
const record = records.get(account.id);
|
|
|
|
|
if (!record) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled";
|
2026-04-27 21:33:55 +08:00
|
|
|
void enqueueAccountProbe(account, {
|
|
|
|
|
trigger,
|
|
|
|
|
allowSilentRefresh: false,
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
console.warn("[desktop-account-health] scheduled probe failed", {
|
|
|
|
|
accountId: account.id,
|
|
|
|
|
platform: account.platform,
|
|
|
|
|
message: error instanceof Error ? error.message : String(error),
|
2026-04-20 17:16:15 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-27 21:33:55 +08:00
|
|
|
emitRuntimeInvalidated("account-health", { accountId });
|
2026-04-20 17:16:15 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const account of accounts) {
|
|
|
|
|
trackedAccounts.set(account.id, account);
|
|
|
|
|
ensureRecord(account);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
persistRecords();
|
|
|
|
|
if (removed) {
|
|
|
|
|
emitRuntimeInvalidated("account-health");
|
|
|
|
|
}
|
2026-04-29 15:58:25 +08:00
|
|
|
if (pruneMissing) {
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
void runDueProbes();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-20 17:16:15 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function forgetTrackedAccountHealth(
|
|
|
|
|
accountId: string,
|
|
|
|
|
): void {
|
|
|
|
|
trackedAccounts.delete(accountId);
|
|
|
|
|
if (records.delete(accountId)) {
|
|
|
|
|
persistRecords();
|
2026-04-27 21:33:55 +08:00
|
|
|
emitRuntimeInvalidated("account-health", { accountId });
|
2026-04-20 17:16:15 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:11:50 +08:00
|
|
|
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.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;
|
|
|
|
|
|
|
|
|
|
return commitRecord(record, previousSignature);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:16:15 +08:00
|
|
|
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: {
|
2026-04-27 21:33:55 +08:00
|
|
|
trigger?: ProbeTrigger;
|
2026-04-20 17:16:15 +08:00
|
|
|
allowSilentRefresh?: boolean;
|
2026-04-27 21:33:55 +08:00
|
|
|
force?: boolean;
|
2026-04-20 17:16:15 +08:00
|
|
|
} = {},
|
|
|
|
|
): Promise<AccountHealthSnapshot> {
|
2026-04-27 21:33:55 +08:00
|
|
|
return await enqueueAccountProbe(account, {
|
|
|
|
|
trigger: options.trigger ?? "manual",
|
|
|
|
|
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
|
|
|
|
force: Boolean(options.force),
|
2026-04-20 17:16:15 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:47:35 +08:00
|
|
|
if (
|
|
|
|
|
snapshot
|
|
|
|
|
&& ["expired", "challenge_required", "revoked"].includes(snapshot.authState)
|
|
|
|
|
&& !(account.platform === "baijiahao" && snapshot.authState === "challenge_required")
|
|
|
|
|
) {
|
2026-04-20 17:16:15 +08:00
|
|
|
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();
|
|
|
|
|
|
2026-04-23 15:26:24 +08:00
|
|
|
if (classification === "challenge" || classification === "risk_control") {
|
2026-04-20 17:16:15 +08:00
|
|
|
record.authState = "challenge_required";
|
|
|
|
|
record.probeState = "idle";
|
2026-04-23 15:26:24 +08:00
|
|
|
record.authReason = classification === "risk_control" ? "risk_control" : "captcha_gate";
|
2026-04-20 17:16:15 +08:00
|
|
|
record.lastAuthFailureAt = now;
|
|
|
|
|
record.lastProbeAt = now;
|
|
|
|
|
record.nextProbeAt = nextRegularProbeAt(now);
|
|
|
|
|
record.suspectedExpiredAt = null;
|
|
|
|
|
record.confirmFailureCount = 0;
|
|
|
|
|
return commitRecord(record, previousSignature);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|