feat(desktop-client): queue account probes and report health to server
Replace serial probe-on-lock with a bounded probe queue (concurrency 3) that adds a "queued" probe state, manual cooldown, and retry backoff. After each probe the runtime debounces a batched POST to the new /desktop/accounts/health-reports endpoint so the server learns about live, expired, and risk transitions without waiting for a re-bind. Adds an account-scoped IPC (probeRuntimeAccount + runtimeAccountSnapshot) so the renderer can refresh a single row instead of replaying the full snapshot, and exposes the resulting "重新校验" action in AccountsView/AiPlatformsView. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { ApiClientError } from "@geo/http-client";
|
||||
import type {
|
||||
DesktopArticleContent,
|
||||
DesktopAccountInfo,
|
||||
DesktopAccountHealthReport,
|
||||
DesktopClientInfo,
|
||||
MonitoringLeaseTask,
|
||||
MonitoringSourceItem,
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
noteTransportHeartbeat,
|
||||
noteTransportPull,
|
||||
offlineDesktopClient,
|
||||
reportDesktopAccountHealth,
|
||||
resumeMonitoringTasks,
|
||||
revokeDesktopClient,
|
||||
setTransportAuthState,
|
||||
@@ -151,6 +153,8 @@ const publishReservedSlots = 1;
|
||||
const minimumForegroundTotalConcurrency = 2;
|
||||
const maximumRuntimeTotalConcurrency = 4;
|
||||
const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用";
|
||||
const accountHealthReportDebounceMs = 1_000;
|
||||
const accountHealthReportMaxBatchSize = 100;
|
||||
|
||||
interface RuntimeTaskRecord {
|
||||
id: string;
|
||||
@@ -233,6 +237,8 @@ interface RuntimeState {
|
||||
publishFallbackBackoffUntil: number;
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
accountHealthReportTimer: ReturnType<typeof setTimeout> | null;
|
||||
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>;
|
||||
dispatchWsClient: DispatchWsClient | null;
|
||||
dispatchWsConnected: boolean;
|
||||
lastHeartbeatAt: number;
|
||||
@@ -258,6 +264,8 @@ const state: RuntimeState = {
|
||||
publishFallbackBackoffUntil: 0,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
accountHealthReportTimer: null,
|
||||
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||||
dispatchWsClient: null,
|
||||
dispatchWsConnected: false,
|
||||
lastHeartbeatAt: 0,
|
||||
@@ -496,6 +504,10 @@ function stopRuntime(): void {
|
||||
clearInterval(state.pullTimer);
|
||||
state.pullTimer = null;
|
||||
}
|
||||
if (state.accountHealthReportTimer) {
|
||||
clearTimeout(state.accountHealthReportTimer);
|
||||
state.accountHealthReportTimer = null;
|
||||
}
|
||||
if (state.dispatchWsClient) {
|
||||
state.dispatchWsClient.stop();
|
||||
state.dispatchWsClient = null;
|
||||
@@ -519,6 +531,7 @@ function clearRuntimeState(): void {
|
||||
state.tasks.clear();
|
||||
state.accounts = [];
|
||||
state.accountProfiles.clear();
|
||||
state.accountHealthReportQueue.clear();
|
||||
state.activeExecutions.clear();
|
||||
state.lastHeartbeatAt = 0;
|
||||
state.lastHeartbeatStatus = "idle";
|
||||
@@ -897,7 +910,6 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
|
||||
try {
|
||||
const accounts = await listDesktopAccounts();
|
||||
const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null;
|
||||
state.lastAccountsSyncAt = Date.now();
|
||||
state.lastError = null;
|
||||
|
||||
@@ -906,22 +918,11 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
|
||||
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account));
|
||||
syncTrackedAccounts(identities);
|
||||
if (source === "manual") {
|
||||
await Promise.allSettled(
|
||||
identities.map((account) =>
|
||||
probeTrackedAccount(account, {
|
||||
trigger: "manual",
|
||||
allowSilentRefresh: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
state.accountProfiles.clear();
|
||||
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
|
||||
const syncedAccounts: Array<DesktopAccountInfo | null> = accounts.map((account) => {
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const isAIPlatform = isAIPlatformId(account.platform);
|
||||
const identity = accountIdentityFromDesktopAccount(account);
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
@@ -961,41 +962,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||
};
|
||||
|
||||
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
|
||||
const shouldMirrorHealth =
|
||||
resolvedAccount.health !== account.health
|
||||
&& isDefinitiveAuthState(projected.authState);
|
||||
|
||||
if (shouldRelinkClient || shouldMirrorHealth) {
|
||||
try {
|
||||
const updated = await upsertDesktopAccount({
|
||||
platform: resolvedAccount.platform,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: projected.profile?.displayName ?? resolvedAccount.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
||||
account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid,
|
||||
health: resolvedAccount.health,
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? resolvedAccount.verified_at,
|
||||
tags: resolvedAccount.tags,
|
||||
});
|
||||
|
||||
resolvedAccount = {
|
||||
...updated,
|
||||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||||
health: resolvedAccount.health,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] account relink failed", {
|
||||
accountId: resolvedAccount.id,
|
||||
platform: resolvedAccount.platform,
|
||||
platformUid: resolvedAccount.platform_uid,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedAccount;
|
||||
}));
|
||||
});
|
||||
const duplicateAccounts: DesktopAccountInfo[] = [];
|
||||
const dedupedAccounts: DesktopAccountInfo[] = [];
|
||||
const seenAccountKeys = new Set<string>();
|
||||
@@ -1061,10 +1029,161 @@ async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]):
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccountHealthReport(account: DesktopAccountInfo): DesktopAccountHealthReport {
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
return {
|
||||
account_id: account.id,
|
||||
platform: account.platform,
|
||||
platform_uid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
health: projected.health,
|
||||
auth_state: projected.authState,
|
||||
probe_state: projected.probeState,
|
||||
auth_reason: projected.authReason,
|
||||
display_name: projected.profile?.displayName ?? account.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function enqueueAccountHealthReport(accountId: string): void {
|
||||
const account = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.accountHealthReportQueue.set(account.id, buildAccountHealthReport(account));
|
||||
if (state.accountHealthReportTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.accountHealthReportTimer = setTimeout(() => {
|
||||
state.accountHealthReportTimer = null;
|
||||
void flushAccountHealthReports();
|
||||
}, accountHealthReportDebounceMs);
|
||||
}
|
||||
|
||||
async function flushAccountHealthReports(): Promise<void> {
|
||||
if (state.accountHealthReportQueue.size === 0 || !canRunLive(state.session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reports = [...state.accountHealthReportQueue.values()].slice(0, accountHealthReportMaxBatchSize);
|
||||
for (const report of reports) {
|
||||
state.accountHealthReportQueue.delete(report.account_id);
|
||||
}
|
||||
|
||||
try {
|
||||
await reportDesktopAccountHealth({ reports });
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] account health report failed", {
|
||||
count: reports.length,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
for (const report of reports) {
|
||||
state.accountHealthReportQueue.set(report.account_id, report);
|
||||
}
|
||||
} finally {
|
||||
if (state.accountHealthReportQueue.size > 0 && !state.accountHealthReportTimer) {
|
||||
state.accountHealthReportTimer = setTimeout(() => {
|
||||
state.accountHealthReportTimer = null;
|
||||
void flushAccountHealthReports();
|
||||
}, accountHealthReportDebounceMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mirrorProjectedAccountProfile(accountId: string): Promise<void> {
|
||||
const account = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
if (!isDefinitiveAuthState(projected.authState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDisplayName = projected.profile?.displayName ?? account.display_name;
|
||||
const nextAvatarUrl = projected.profile?.avatarUrl ?? account.avatar_url;
|
||||
const shouldMirrorProfile =
|
||||
account.display_name !== nextDisplayName
|
||||
|| account.avatar_url !== nextAvatarUrl;
|
||||
if (!shouldMirrorProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const updated = await upsertDesktopAccount({
|
||||
platform: account.platform,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: nextDisplayName,
|
||||
avatar_url: nextAvatarUrl,
|
||||
account_fingerprint: account.account_fingerprint ?? normalizedPlatformUid,
|
||||
health: account.health,
|
||||
verified_at: account.verified_at,
|
||||
tags: account.tags,
|
||||
});
|
||||
|
||||
state.accounts = state.accounts.map((item) =>
|
||||
item.id === account.id
|
||||
? {
|
||||
...updated,
|
||||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||||
health: account.health,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
state.accountProfiles.set(account.id, {
|
||||
platformUid: normalizedPlatformUid,
|
||||
displayName: nextDisplayName,
|
||||
avatarUrl: nextAvatarUrl,
|
||||
});
|
||||
refreshAccountNames();
|
||||
}
|
||||
|
||||
export async function refreshRuntimeAccounts(): Promise<void> {
|
||||
await syncAccounts("manual");
|
||||
}
|
||||
|
||||
export function requestRuntimeAccountProbe(
|
||||
accountId: string,
|
||||
options: { account?: PublishAccountIdentity; force?: boolean } = {},
|
||||
): void {
|
||||
const existingAccount = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
const account = options.account ?? (existingAccount ? accountIdentityFromDesktopAccount(existingAccount) : null);
|
||||
if (!account) {
|
||||
throw new Error("runtime_account_not_found");
|
||||
}
|
||||
|
||||
void probeTrackedAccount(account, {
|
||||
trigger: "manual",
|
||||
allowSilentRefresh: true,
|
||||
force: options.force ?? true,
|
||||
})
|
||||
.then(async () => {
|
||||
enqueueAccountHealthReport(account.id);
|
||||
await mirrorProjectedAccountProfile(account.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[desktop-runtime] account probe failed", {
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
||||
const normalizedAccount: DesktopAccountInfo = {
|
||||
...account,
|
||||
|
||||
Reference in New Issue
Block a user