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:
2026-04-27 21:33:55 +08:00
parent 051976e4a9
commit c3feb7477a
15 changed files with 817 additions and 129 deletions
+199 -14
View File
@@ -30,6 +30,12 @@ const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const;
const NETWORK_RETRY_MS = 2 * 60_000; const NETWORK_RETRY_MS = 2 * 60_000;
const STALE_AFTER_MS = 45 * 60_000; const STALE_AFTER_MS = 45 * 60_000;
const PREFLIGHT_MAX_AGE_MS = 15 * 60_000; const PREFLIGHT_MAX_AGE_MS = 15 * 60_000;
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";
interface PersistedAccountHealthRecord { interface PersistedAccountHealthRecord {
accountId: string; accountId: string;
@@ -56,11 +62,27 @@ interface AccountHealthRecord {
confirmFailureCount: number; 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 trackedAccounts = new Map<string, PublishAccountIdentity>();
const records = new Map<string, AccountHealthRecord>(); const records = new Map<string, AccountHealthRecord>();
const accountLocks = new Map<string, Promise<void>>(); const accountLocks = new Map<string, Promise<void>>();
const probeJobs = new Map<string, AccountProbeJob>();
const probeQueue: string[] = [];
let initialized = false; let initialized = false;
let probeTimer: ReturnType<typeof setInterval> | null = null; let probeTimer: ReturnType<typeof setInterval> | null = null;
let activeProbeCount = 0;
function randomInt(min: number, max: number): number { function randomInt(min: number, max: number): number {
if (max <= min) { if (max <= min) {
@@ -193,7 +215,7 @@ function commitRecord(record: AccountHealthRecord, previousSignature: string | n
const nextSignature = snapshotSignature(record); const nextSignature = snapshotSignature(record);
if (previousSignature !== nextSignature) { if (previousSignature !== nextSignature) {
emitRuntimeInvalidated("account-health"); emitRuntimeInvalidated("account-health", { accountId: record.accountId });
} }
return toPublicSnapshot(record); return toPublicSnapshot(record);
@@ -342,7 +364,7 @@ function applyNetworkResult(
async function performProbeLocked( async function performProbeLocked(
account: PublishAccountIdentity, account: PublishAccountIdentity,
options: { options: {
trigger: "scheduled" | "manual" | "preflight" | "confirm"; trigger: ProbeTrigger;
allowSilentRefresh: boolean; allowSilentRefresh: boolean;
}, },
): Promise<AccountHealthSnapshot> { ): Promise<AccountHealthSnapshot> {
@@ -384,6 +406,165 @@ async function performProbeLocked(
} }
} }
function canUseCachedProbe(
record: AccountHealthRecord,
options: { trigger: ProbeTrigger; force: boolean },
now = Date.now(),
): boolean {
if (options.force) {
return Boolean(record.lastProbeAt && now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS);
}
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 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;
}
async function runDueProbes(): Promise<void> { async function runDueProbes(): Promise<void> {
const now = Date.now(); const now = Date.now();
const dueAccounts = [...trackedAccounts.values()].filter((account) => { const dueAccounts = [...trackedAccounts.values()].filter((account) => {
@@ -398,10 +579,14 @@ async function runDueProbes(): Promise<void> {
} }
const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled"; const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled";
void withAccountLock(account.id, async () => { void enqueueAccountProbe(account, {
await performProbeLocked(account, { trigger,
trigger, allowSilentRefresh: false,
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),
}); });
}); });
} }
@@ -454,6 +639,7 @@ function syncTrackedAccountsInternal(
trackedAccounts.delete(accountId); trackedAccounts.delete(accountId);
records.delete(accountId); records.delete(accountId);
removed = true; removed = true;
emitRuntimeInvalidated("account-health", { accountId });
} }
} }
} }
@@ -475,7 +661,7 @@ export function forgetTrackedAccountHealth(
trackedAccounts.delete(accountId); trackedAccounts.delete(accountId);
if (records.delete(accountId)) { if (records.delete(accountId)) {
persistRecords(); persistRecords();
emitRuntimeInvalidated("account-health"); emitRuntimeInvalidated("account-health", { accountId });
} }
} }
@@ -526,16 +712,15 @@ export function getProjectedAccountHealth(input: {
export async function probeTrackedAccount( export async function probeTrackedAccount(
account: PublishAccountIdentity, account: PublishAccountIdentity,
options: { options: {
trigger?: "scheduled" | "manual" | "preflight" | "confirm"; trigger?: ProbeTrigger;
allowSilentRefresh?: boolean; allowSilentRefresh?: boolean;
force?: boolean;
} = {}, } = {},
): Promise<AccountHealthSnapshot> { ): Promise<AccountHealthSnapshot> {
syncTrackedAccountsInternal([account], false); return await enqueueAccountProbe(account, {
return await withAccountLock(account.id, async () => { trigger: options.trigger ?? "manual",
return await performProbeLocked(account, { allowSilentRefresh: Boolean(options.allowSilentRefresh),
trigger: options.trigger ?? "manual", force: Boolean(options.force),
allowSilentRefresh: Boolean(options.allowSilentRefresh),
});
}); });
} }
@@ -7,6 +7,7 @@ export type AccountAuthState =
| "revoked"; | "revoked";
export type AccountProbeState = export type AccountProbeState =
| "queued"
| "idle" | "idle"
| "probing" | "probing"
| "network_error"; | "network_error";
+13 -1
View File
@@ -24,12 +24,13 @@ import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy";
import { onRuntimeInvalidated } from "./runtime-events"; import { onRuntimeInvalidated } from "./runtime-events";
import { import {
noteRuntimeAccountBound, noteRuntimeAccountBound,
requestRuntimeAccountProbe,
refreshRuntimeAccounts, refreshRuntimeAccounts,
releaseRuntimeSession, releaseRuntimeSession,
syncRuntimeSession, syncRuntimeSession,
unbindRuntimeAccount, unbindRuntimeAccount,
} from "./runtime-controller"; } from "./runtime-controller";
import { createRuntimeSnapshot } from "./runtime-snapshot"; import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from "./runtime-snapshot";
import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id"; import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id";
import { initScheduler } from "./scheduler"; import { initScheduler } from "./scheduler";
import { initSessionRegistry } from "./session-registry"; import { initSessionRegistry } from "./session-registry";
@@ -154,6 +155,10 @@ function captureRuntimeSnapshot() {
return snapshot; return snapshot;
} }
function captureRuntimeAccountSnapshot(accountId: string) {
return createRuntimeAccountSnapshot(accountId);
}
async function ensureMainWindow(): Promise<ElectronBrowserWindow> { async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
const existing = currentMainWindow(); const existing = currentMainWindow();
if (existing) { if (existing) {
@@ -314,6 +319,9 @@ function registerBridgeHandlers(): void {
}), }),
); );
ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot()); ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot());
ipcMain.handle("desktop:runtime-account-snapshot", (_event, accountId: string) =>
captureRuntimeAccountSnapshot(accountId),
);
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => { safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo; const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
noteRuntimeAccountBound(account); noteRuntimeAccountBound(account);
@@ -328,6 +336,10 @@ function registerBridgeHandlers(): void {
await refreshRuntimeAccounts(); await refreshRuntimeAccounts();
return null; return null;
}); });
safeHandle("desktop:probe-runtime-account", async (_event, accountId: string) => {
requestRuntimeAccountProbe(accountId);
return captureRuntimeAccountSnapshot(accountId);
});
safeHandle( safeHandle(
"desktop:open-publish-account-console", "desktop:open-publish-account-console",
async ( async (
@@ -4,6 +4,7 @@ import { ApiClientError } from "@geo/http-client";
import type { import type {
DesktopArticleContent, DesktopArticleContent,
DesktopAccountInfo, DesktopAccountInfo,
DesktopAccountHealthReport,
DesktopClientInfo, DesktopClientInfo,
MonitoringLeaseTask, MonitoringLeaseTask,
MonitoringSourceItem, MonitoringSourceItem,
@@ -112,6 +113,7 @@ import {
noteTransportHeartbeat, noteTransportHeartbeat,
noteTransportPull, noteTransportPull,
offlineDesktopClient, offlineDesktopClient,
reportDesktopAccountHealth,
resumeMonitoringTasks, resumeMonitoringTasks,
revokeDesktopClient, revokeDesktopClient,
setTransportAuthState, setTransportAuthState,
@@ -151,6 +153,8 @@ const publishReservedSlots = 1;
const minimumForegroundTotalConcurrency = 2; const minimumForegroundTotalConcurrency = 2;
const maximumRuntimeTotalConcurrency = 4; const maximumRuntimeTotalConcurrency = 4;
const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用"; const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用";
const accountHealthReportDebounceMs = 1_000;
const accountHealthReportMaxBatchSize = 100;
interface RuntimeTaskRecord { interface RuntimeTaskRecord {
id: string; id: string;
@@ -233,6 +237,8 @@ interface RuntimeState {
publishFallbackBackoffUntil: number; publishFallbackBackoffUntil: number;
heartbeatTimer: ReturnType<typeof setInterval> | null; heartbeatTimer: ReturnType<typeof setInterval> | null;
pullTimer: ReturnType<typeof setInterval> | null; pullTimer: ReturnType<typeof setInterval> | null;
accountHealthReportTimer: ReturnType<typeof setTimeout> | null;
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>;
dispatchWsClient: DispatchWsClient | null; dispatchWsClient: DispatchWsClient | null;
dispatchWsConnected: boolean; dispatchWsConnected: boolean;
lastHeartbeatAt: number; lastHeartbeatAt: number;
@@ -258,6 +264,8 @@ const state: RuntimeState = {
publishFallbackBackoffUntil: 0, publishFallbackBackoffUntil: 0,
heartbeatTimer: null, heartbeatTimer: null,
pullTimer: null, pullTimer: null,
accountHealthReportTimer: null,
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
dispatchWsClient: null, dispatchWsClient: null,
dispatchWsConnected: false, dispatchWsConnected: false,
lastHeartbeatAt: 0, lastHeartbeatAt: 0,
@@ -496,6 +504,10 @@ function stopRuntime(): void {
clearInterval(state.pullTimer); clearInterval(state.pullTimer);
state.pullTimer = null; state.pullTimer = null;
} }
if (state.accountHealthReportTimer) {
clearTimeout(state.accountHealthReportTimer);
state.accountHealthReportTimer = null;
}
if (state.dispatchWsClient) { if (state.dispatchWsClient) {
state.dispatchWsClient.stop(); state.dispatchWsClient.stop();
state.dispatchWsClient = null; state.dispatchWsClient = null;
@@ -519,6 +531,7 @@ function clearRuntimeState(): void {
state.tasks.clear(); state.tasks.clear();
state.accounts = []; state.accounts = [];
state.accountProfiles.clear(); state.accountProfiles.clear();
state.accountHealthReportQueue.clear();
state.activeExecutions.clear(); state.activeExecutions.clear();
state.lastHeartbeatAt = 0; state.lastHeartbeatAt = 0;
state.lastHeartbeatStatus = "idle"; state.lastHeartbeatStatus = "idle";
@@ -897,7 +910,6 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
try { try {
const accounts = await listDesktopAccounts(); const accounts = await listDesktopAccounts();
const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null;
state.lastAccountsSyncAt = Date.now(); state.lastAccountsSyncAt = Date.now();
state.lastError = null; state.lastError = null;
@@ -906,22 +918,11 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account)); const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account));
syncTrackedAccounts(identities); syncTrackedAccounts(identities);
if (source === "manual") {
await Promise.allSettled(
identities.map((account) =>
probeTrackedAccount(account, {
trigger: "manual",
allowSilentRefresh: true,
}),
),
);
}
state.accountProfiles.clear(); 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 normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
const isAIPlatform = isAIPlatformId(account.platform); const isAIPlatform = isAIPlatformId(account.platform);
const identity = accountIdentityFromDesktopAccount(account);
const projected = getProjectedAccountHealth({ const projected = getProjectedAccountHealth({
accountId: account.id, accountId: account.id,
platform: account.platform, platform: account.platform,
@@ -961,41 +962,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at, 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; return resolvedAccount;
})); });
const duplicateAccounts: DesktopAccountInfo[] = []; const duplicateAccounts: DesktopAccountInfo[] = [];
const dedupedAccounts: DesktopAccountInfo[] = []; const dedupedAccounts: DesktopAccountInfo[] = [];
const seenAccountKeys = new Set<string>(); 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> { export async function refreshRuntimeAccounts(): Promise<void> {
await syncAccounts("manual"); 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 { export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
const normalizedAccount: DesktopAccountInfo = { const normalizedAccount: DesktopAccountInfo = {
...account, ...account,
@@ -3,14 +3,19 @@ type RuntimeInvalidationReason = "account-health";
type RuntimeInvalidationListener = (event: { type RuntimeInvalidationListener = (event: {
reason: RuntimeInvalidationReason; reason: RuntimeInvalidationReason;
at: number; at: number;
accountId?: string;
}) => void; }) => void;
const listeners = new Set<RuntimeInvalidationListener>(); const listeners = new Set<RuntimeInvalidationListener>();
export function emitRuntimeInvalidated(reason: RuntimeInvalidationReason): void { export function emitRuntimeInvalidated(
reason: RuntimeInvalidationReason,
details: { accountId?: string } = {},
): void {
const event = { const event = {
reason, reason,
at: Date.now(), at: Date.now(),
...details,
} as const; } as const;
for (const listener of listeners) { for (const listener of listeners) {
@@ -31,4 +36,3 @@ export function onRuntimeInvalidated(listener: RuntimeInvalidationListener): ()
listeners.delete(listener); listeners.delete(listener);
}; };
} }
@@ -32,11 +32,83 @@ function parseTimestamp(value: string | null | undefined): number | null {
return Number.isNaN(timestamp) ? null : timestamp; return Number.isNaN(timestamp) ? null : timestamp;
} }
type RuntimeControllerSnapshot = ReturnType<typeof getRuntimeControllerSnapshot>;
type RuntimeControllerAccount = RuntimeControllerSnapshot["accounts"][number];
function createRuntimeAccountView(
controller: RuntimeControllerSnapshot,
account: RuntimeControllerAccount,
context: {
hotViews: ReturnType<typeof listHotViews>;
primaryClientId: string;
clientOnline: boolean;
lastAccountsSyncAt: number;
now: number;
},
) {
const sessionHandle = getSessionHandle(account.id);
const isHot = context.hotViews.some((item) => item.accountId === account.id);
const accountQueueDepth = controller.tasks.filter((task) =>
task.accountId === account.id && ["queued", "in_progress"].includes(task.status),
).length;
const projected = getProjectedAccountHealth({
accountId: account.id,
platform: account.platform,
health: account.health,
verifiedAt: account.verified_at,
});
return {
id: account.id,
platform: account.platform,
displayName: projected.profile?.displayName ?? account.display_name,
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
avatarUrl: projected.profile?.avatarUrl ?? account.avatar_url ?? null,
health: projected.health,
authState: projected.authState,
probeState: projected.probeState,
authReason: projected.authReason,
tags: account.tags,
syncVersion: account.sync_version,
clientId: account.client_id ?? context.primaryClientId,
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
lastSyncAt:
context.lastAccountsSyncAt
|| parseTimestamp(account.verified_at)
|| context.now,
lastVerifiedAt: projected.lastVerifiedAt,
lastProbeAt: projected.lastProbeAt,
nextProbeAt: projected.nextProbeAt,
queueDepth: accountQueueDepth,
online: context.clientOnline,
};
}
export function createRuntimeSnapshot() { export function createRuntimeSnapshot() {
return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot()); return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot());
} }
function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeControllerSnapshot>) { export function createRuntimeAccountSnapshot(accountId: string) {
const controller = getRuntimeControllerSnapshot();
const now = Date.now();
const primaryClientId = controller.client?.id ?? controller.session?.desktop_client?.id ?? "desktop-self";
const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed";
const account = controller.accounts.find((item) => item.id === accountId);
if (!account) {
return null;
}
return createRuntimeAccountView(controller, account, {
hotViews: listHotViews(),
primaryClientId,
clientOnline,
lastAccountsSyncAt: controller.lastAccountsSyncAt,
now,
});
}
function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
const now = Date.now(); const now = Date.now();
// Electron Session instances are not safe to ship across IPC to the renderer. // Electron Session instances are not safe to ship across IPC to the renderer.
const sessions = listSessionHandleSnapshots(); const sessions = listSessionHandleSnapshots();
@@ -70,45 +142,15 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
}, },
] as const; ] as const;
const accounts = controller.accounts.map((account) => { const accounts = controller.accounts.map((account) =>
const sessionHandle = getSessionHandle(account.id); createRuntimeAccountView(controller, account, {
const isHot = hotViews.some((item) => item.accountId === account.id); hotViews,
const accountQueueDepth = controller.tasks.filter((task) => primaryClientId,
task.accountId === account.id && ["queued", "in_progress"].includes(task.status), clientOnline,
).length; lastAccountsSyncAt: controller.lastAccountsSyncAt,
const projected = getProjectedAccountHealth({ now,
accountId: account.id, }),
platform: account.platform, );
health: account.health,
verifiedAt: account.verified_at,
});
return {
id: account.id,
platform: account.platform,
displayName: projected.profile?.displayName ?? account.display_name,
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
avatarUrl: projected.profile?.avatarUrl ?? account.avatar_url ?? null,
health: projected.health,
authState: projected.authState,
probeState: projected.probeState,
authReason: projected.authReason,
tags: account.tags,
syncVersion: account.sync_version,
clientId: account.client_id ?? primaryClientId,
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
lastSyncAt:
controller.lastAccountsSyncAt
|| parseTimestamp(account.verified_at)
|| now,
lastVerifiedAt: projected.lastVerifiedAt,
lastProbeAt: projected.lastProbeAt,
nextProbeAt: projected.nextProbeAt,
queueDepth: accountQueueDepth,
online: clientOnline,
};
});
const tasks = controller.tasks.map((task) => ({ ...task })); const tasks = controller.tasks.map((task) => ({ ...task }));
@@ -16,6 +16,8 @@ import type {
LeaseDesktopTaskRequest, LeaseDesktopTaskRequest,
LeaseDesktopTaskResponse, LeaseDesktopTaskResponse,
ListDesktopPublishTasksParams, ListDesktopPublishTasksParams,
ReportDesktopAccountHealthRequest,
ReportDesktopAccountHealthResponse,
MonitoringLeaseTasksPayload, MonitoringLeaseTasksPayload,
MonitoringLeaseTasksResponse, MonitoringLeaseTasksResponse,
MonitoringResumeTasksPayload, MonitoringResumeTasksPayload,
@@ -65,6 +67,16 @@ let transportSession: DesktopTransportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080", baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
clientToken: null, clientToken: null,
}; };
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000;
interface AccountUpsertCacheEntry {
signature: string;
expiresAt: number;
response: DesktopAccountInfo;
inFlight: Promise<DesktopAccountInfo> | null;
}
const accountUpsertCache = new Map<string, AccountUpsertCacheEntry>();
const transportState = { const transportState = {
baseURL: transportSession.baseURL, baseURL: transportSession.baseURL,
@@ -135,6 +147,43 @@ function createDesktopClient(baseURL: string): ApiClient {
return client; return client;
} }
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string {
return [
payload.platform.trim(),
payload.platform_uid.trim(),
].join("::");
}
function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string {
return stableJson({
platform: payload.platform.trim(),
platform_uid: payload.platform_uid.trim(),
account_fingerprint: payload.account_fingerprint ?? null,
display_name: payload.display_name.trim(),
avatar_url: payload.avatar_url ?? null,
health: payload.health,
tags: payload.tags ?? [],
if_sync_version: payload.if_sync_version ?? null,
});
}
function clearAccountUpsertCache(): void {
accountUpsertCache.clear();
}
function currentRequestDebugMode(): "main-process" | "renderer-devtools" { function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process"; return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
} }
@@ -319,6 +368,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
transportState.auth = "pending"; transportState.auth = "pending";
transportState.dispatchState = "idle"; transportState.dispatchState = "idle";
desktopApiClient = null; desktopApiClient = null;
clearAccountUpsertCache();
return null; return null;
} }
@@ -332,6 +382,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
transportState.dispatchState = session.client_token ? "connecting" : "idle"; transportState.dispatchState = session.client_token ? "connecting" : "idle";
transportState.initializedAt = Date.now(); transportState.initializedAt = Date.now();
desktopApiClient = createDesktopClient(transportSession.baseURL); desktopApiClient = createDesktopClient(transportSession.baseURL);
clearAccountUpsertCache();
return desktopApiClient; return desktopApiClient;
} }
@@ -425,10 +476,52 @@ export async function retryDesktopPublishTask(taskId: string): Promise<CreatePub
export async function upsertDesktopAccount( export async function upsertDesktopAccount(
payload: UpsertDesktopAccountRequest, payload: UpsertDesktopAccountRequest,
): Promise<DesktopAccountInfo> { ): Promise<DesktopAccountInfo> {
return desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>( const now = Date.now();
const cacheKey = accountUpsertCacheKey(payload);
const signature = accountUpsertSignature(payload);
const cached = accountUpsertCache.get(cacheKey);
if (cached && cached.signature === signature && cached.expiresAt > now) {
if (cached.inFlight) {
return cached.inFlight;
}
return cached.response;
}
const request = desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
"/api/desktop/accounts", "/api/desktop/accounts",
payload, payload,
); );
accountUpsertCache.set(cacheKey, {
signature,
expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS,
response: cached?.response ?? ({} as DesktopAccountInfo),
inFlight: request,
});
try {
const response = await request;
accountUpsertCache.set(cacheKey, {
signature,
expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS,
response,
inFlight: null,
});
return response;
} catch (error) {
if (accountUpsertCache.get(cacheKey)?.inFlight === request) {
accountUpsertCache.delete(cacheKey);
}
throw error;
}
}
export async function reportDesktopAccountHealth(
payload: ReportDesktopAccountHealthRequest,
): Promise<ReportDesktopAccountHealthResponse> {
return desktopPost<ReportDesktopAccountHealthResponse, ReportDesktopAccountHealthRequest>(
"/api/desktop/accounts/health-reports",
payload,
);
} }
export async function deleteDesktopAccount( export async function deleteDesktopAccount(
@@ -436,9 +529,11 @@ export async function deleteDesktopAccount(
ifSyncVersion: number, ifSyncVersion: number,
): Promise<DesktopAccountInfo> { ): Promise<DesktopAccountInfo> {
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) }); const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
return desktopDelete<DesktopAccountInfo>( const response = await desktopDelete<DesktopAccountInfo>(
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`, `/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
); );
clearAccountUpsertCache();
return response;
} }
export async function leaseDesktopTask( export async function leaseDesktopTask(
+6 -2
View File
@@ -92,10 +92,14 @@ const desktopBridge = {
ipcRenderer.invoke("desktop:client-id", scope) as Promise<string>, ipcRenderer.invoke("desktop:client-id", scope) as Promise<string>,
runtimeSnapshot: () => runtimeSnapshot: () =>
ipcRenderer.invoke("desktop:runtime-snapshot") as Promise<DesktopRuntimeSnapshot>, ipcRenderer.invoke("desktop:runtime-snapshot") as Promise<DesktopRuntimeSnapshot>,
runtimeAccountSnapshot: (accountId: string) =>
ipcRenderer.invoke("desktop:runtime-account-snapshot", accountId) as Promise<DesktopRuntimeSnapshot["accounts"][number] | null>,
bindPublishAccount: (platformId: string) => bindPublishAccount: (platformId: string) =>
ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise<DesktopAccountInfo>, ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise<DesktopAccountInfo>,
refreshRuntimeAccounts: () => refreshRuntimeAccounts: () =>
ipcRenderer.invoke("desktop:refresh-runtime-accounts") as Promise<null>, ipcRenderer.invoke("desktop:refresh-runtime-accounts") as Promise<null>,
probeRuntimeAccount: (accountId: string) =>
ipcRenderer.invoke("desktop:probe-runtime-account", accountId) as Promise<DesktopRuntimeSnapshot["accounts"][number] | null>,
openPublishAccountConsole: (account: { openPublishAccountConsole: (account: {
id: string; id: string;
platform: string; platform: string;
@@ -118,9 +122,9 @@ const desktopBridge = {
setWindowMode: (mode: "login" | "main") => setWindowMode: (mode: "login" | "main") =>
ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise<null>, ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise<null>,
onRuntimeInvalidated: ( onRuntimeInvalidated: (
listener: (event: { reason: "account-health"; at: number }) => void, listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void,
) => { ) => {
const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number }) => { const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number; accountId?: string }) => {
listener(payload); listener(payload);
}; };
ipcRenderer.on("desktop:runtime-invalidated", wrapped); ipcRenderer.on("desktop:runtime-invalidated", wrapped);
@@ -1,14 +1,16 @@
import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue"; import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue";
import type { DesktopRuntimeSnapshot } from "../types"; import type { DesktopRuntimeSnapshot, RuntimeAccount } from "../types";
const RUNTIME_POLL_INTERVAL_MS = 15_000; const RUNTIME_POLL_INTERVAL_MS = 15_000;
const INVALIDATION_DEBOUNCE_MS = 250;
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null); const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
const loading = shallowRef(false); const loading = shallowRef(false);
const error = shallowRef<string | null>(null); const error = shallowRef<string | null>(null);
let intervalHandle: ReturnType<typeof setInterval> | null = null; let intervalHandle: ReturnType<typeof setInterval> | null = null;
let invalidationRefreshHandle: ReturnType<typeof setTimeout> | null = null;
let subscribers = 0; let subscribers = 0;
let visibilityListenerBound = false; let visibilityListenerBound = false;
let runtimeInvalidationUnsubscribe: (() => void) | null = null; let runtimeInvalidationUnsubscribe: (() => void) | null = null;
@@ -17,8 +19,54 @@ function isPageVisible(): boolean {
return typeof document === "undefined" || document.visibilityState === "visible"; return typeof document === "undefined" || document.visibilityState === "visible";
} }
async function refreshRuntimeSnapshot() { function accountSummaryPatch(
loading.value = true; current: DesktopRuntimeSnapshot,
accounts: RuntimeAccount[],
): DesktopRuntimeSnapshot["summary"] {
const healthCounts = accounts.reduce<Record<string, number>>((acc, item) => {
acc[item.health] = (acc[item.health] ?? 0) + 1;
return acc;
}, {});
const blockingAccountCount = accounts.filter((item) =>
["expired", "revoked", "challenge_required"].includes(item.authState),
).length;
return {
...current.summary,
accountsBound: accounts.length,
healthCounts,
issuesOpen:
current.tasks.filter((item) => ["unknown", "failed"].includes(item.status)).length
+ blockingAccountCount,
};
}
function patchRuntimeAccount(accountId: string, account: RuntimeAccount | null): void {
const current = snapshot.value;
if (!current) {
return;
}
const existingIndex = current.accounts.findIndex((item) => item.id === accountId);
const accounts =
account && existingIndex >= 0
? current.accounts.map((item, index) => (index === existingIndex ? account : item))
: account
? [account, ...current.accounts]
: current.accounts.filter((item) => item.id !== accountId);
snapshot.value = {
...current,
generatedAt: Date.now(),
summary: accountSummaryPatch(current, accounts),
accounts,
};
}
async function refreshRuntimeSnapshot(options: { silent?: boolean } = {}) {
if (!options.silent) {
loading.value = true;
}
error.value = null; error.value = null;
try { try {
@@ -32,7 +80,9 @@ async function refreshRuntimeSnapshot() {
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "runtime snapshot unavailable"; error.value = err instanceof Error ? err.message : "runtime snapshot unavailable";
} finally { } finally {
loading.value = false; if (!options.silent) {
loading.value = false;
}
} }
} }
@@ -55,6 +105,48 @@ async function refreshAccounts() {
} }
} }
async function refreshRuntimeAccount(accountId: string) {
if (!window.desktopBridge?.app?.runtimeAccountSnapshot) {
await refreshRuntimeSnapshot({ silent: true });
return;
}
try {
const account = await window.desktopBridge.app.runtimeAccountSnapshot(accountId);
patchRuntimeAccount(accountId, account);
} catch (err) {
error.value = err instanceof Error ? err.message : "runtime account snapshot unavailable";
}
}
async function probeAccount(accountId: string) {
if (!window.desktopBridge?.app?.probeRuntimeAccount) {
await refreshAccounts();
return;
}
try {
const account = await window.desktopBridge.app.probeRuntimeAccount(accountId);
patchRuntimeAccount(accountId, account);
} catch (err) {
error.value = err instanceof Error ? err.message : "probe account failed";
throw err;
}
}
function scheduleSilentSnapshotRefresh() {
if (invalidationRefreshHandle) {
clearTimeout(invalidationRefreshHandle);
}
invalidationRefreshHandle = setTimeout(() => {
invalidationRefreshHandle = null;
if (isPageVisible()) {
void refreshRuntimeSnapshot({ silent: true });
}
}, INVALIDATION_DEBOUNCE_MS);
}
function stopPolling() { function stopPolling() {
if (!intervalHandle) { if (!intervalHandle) {
return; return;
@@ -75,13 +167,13 @@ function syncPollingState() {
} }
intervalHandle = setInterval(() => { intervalHandle = setInterval(() => {
void refreshRuntimeSnapshot(); void refreshRuntimeSnapshot({ silent: true });
}, RUNTIME_POLL_INTERVAL_MS); }, RUNTIME_POLL_INTERVAL_MS);
} }
function handleVisibilityChange() { function handleVisibilityChange() {
if (isPageVisible()) { if (isPageVisible()) {
void refreshRuntimeSnapshot(); void refreshRuntimeSnapshot({ silent: true });
} }
syncPollingState(); syncPollingState();
@@ -101,10 +193,17 @@ function bindRuntimeInvalidationListener() {
return; return;
} }
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated(() => { runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => {
if (isPageVisible()) { if (!isPageVisible()) {
void refreshRuntimeSnapshot(); return;
} }
if (event.accountId) {
void refreshRuntimeAccount(event.accountId);
return;
}
scheduleSilentSnapshotRefresh();
}); });
} }
@@ -120,6 +219,10 @@ function unbindVisibilityListener() {
function unbindRuntimeInvalidationListener() { function unbindRuntimeInvalidationListener() {
runtimeInvalidationUnsubscribe?.(); runtimeInvalidationUnsubscribe?.();
runtimeInvalidationUnsubscribe = null; runtimeInvalidationUnsubscribe = null;
if (invalidationRefreshHandle) {
clearTimeout(invalidationRefreshHandle);
invalidationRefreshHandle = null;
}
} }
export function useDesktopRuntime() { export function useDesktopRuntime() {
@@ -151,6 +254,7 @@ export function useDesktopRuntime() {
error: readonly(error), error: readonly(error),
refresh: refreshRuntimeSnapshot, refresh: refreshRuntimeSnapshot,
refreshAccounts, refreshAccounts,
probeAccount,
hasSnapshot: computed(() => snapshot.value !== null), hasSnapshot: computed(() => snapshot.value !== null),
}; };
} }
+3 -1
View File
@@ -26,8 +26,10 @@ declare global {
user_id: number; user_id: number;
}): Promise<string>; }): Promise<string>;
runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>; runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>;
runtimeAccountSnapshot(accountId: string): Promise<DesktopRuntimeSnapshot["accounts"][number] | null>;
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>; bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>;
refreshRuntimeAccounts(): Promise<null>; refreshRuntimeAccounts(): Promise<null>;
probeRuntimeAccount(accountId: string): Promise<DesktopRuntimeSnapshot["accounts"][number] | null>;
openPublishAccountConsole(account: { openPublishAccountConsole(account: {
id: string; id: string;
platform: string; platform: string;
@@ -42,7 +44,7 @@ declare global {
releaseRuntimeSession(revoke?: boolean): Promise<null>; releaseRuntimeSession(revoke?: boolean): Promise<null>;
setWindowMode(mode: "login" | "main"): Promise<null>; setWindowMode(mode: "login" | "main"): Promise<null>;
onRuntimeInvalidated( onRuntimeInvalidated(
listener: (event: { reason: "account-health"; at: number }) => void, listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void,
): () => void; ): () => void;
onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void; onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void;
}; };
@@ -1,5 +1,5 @@
type ClientErrorTone = "error" | "warning"; type ClientErrorTone = "error" | "warning";
type ClientActionKind = "bind-account" | "open-console" | "unbind-account"; type ClientActionKind = "bind-account" | "open-console" | "probe-account" | "unbind-account";
interface ClientErrorPresentation { interface ClientErrorPresentation {
tone: ClientErrorTone; tone: ClientErrorTone;
@@ -42,7 +42,9 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
? "账号绑定失败" ? "账号绑定失败"
: kind === "unbind-account" : kind === "unbind-account"
? "账号解绑失败" ? "账号解绑失败"
: "打开平台失败", : kind === "probe-account"
? "账号校验失败"
: "打开平台失败",
); );
if (message === "desktop_account_bind_window_closed") { if (message === "desktop_account_bind_window_closed") {
@@ -110,6 +112,14 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
}; };
} }
if (kind === "probe-account") {
return {
tone: "error",
title: "账号校验失败",
content: message,
};
}
if (kind === "unbind-account") { if (kind === "unbind-account") {
return { return {
tone: "error", tone: "error",
+1 -1
View File
@@ -20,7 +20,7 @@ export interface RuntimeAccount {
avatarUrl: string | null; avatarUrl: string | null;
health: "live" | "expired" | "captcha" | "risk"; health: "live" | "expired" | "captcha" | "risk";
authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked"; authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
probeState: "idle" | "probing" | "network_error"; probeState: "queued" | "idle" | "probing" | "network_error";
authReason: authReason:
| "bind_success" | "bind_success"
| "probe_success" | "probe_success"
@@ -10,7 +10,7 @@ import { formatDateTime, formatRelativeTime, formatVerifiedAtLabel, titleCaseTok
import { desktopPublishMediaCatalog } from "../lib/media-catalog"; import { desktopPublishMediaCatalog } from "../lib/media-catalog";
import type { RuntimeAccount } from "../types"; import type { RuntimeAccount } from "../types";
const { snapshot, refresh, refreshAccounts, loading } = useDesktopRuntime(); const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime();
const selectedPlatform = ref("all"); const selectedPlatform = ref("all");
const selectedStatus = ref("all"); const selectedStatus = ref("all");
@@ -18,6 +18,7 @@ const searchQuery = ref("");
const bindPendingPlatformId = ref<string | null>(null); const bindPendingPlatformId = ref<string | null>(null);
const consolePendingAccountId = ref<string | null>(null); const consolePendingAccountId = ref<string | null>(null);
const unbindPendingAccountId = ref<string | null>(null); const unbindPendingAccountId = ref<string | null>(null);
const probePendingAccountIds = ref(new Set<string>());
const actionSuccess = ref<string | null>(null); const actionSuccess = ref<string | null>(null);
type AccountAuthState = "authorized" | "expired" | "attention" | "risk"; type AccountAuthState = "authorized" | "expired" | "attention" | "risk";
@@ -84,6 +85,13 @@ function authState(account: AccountRow): AccountAuthState {
} }
function authStateLabel(account: AccountRow): string { function authStateLabel(account: AccountRow): string {
if (account.probeState === "queued") {
return "等待校验";
}
if (account.probeState === "probing") {
return "校验中";
}
switch (authState(account)) { switch (authState(account)) {
case "authorized": case "authorized":
return "已授权"; return "已授权";
@@ -96,6 +104,23 @@ function authStateLabel(account: AccountRow): string {
} }
} }
function authTagColor(account: AccountRow): string {
if (account.probeState === "queued" || account.probeState === "probing") {
return "processing";
}
switch (authState(account)) {
case "authorized":
return "success";
case "expired":
return "error";
case "attention":
return "warning";
default:
return "default";
}
}
function authStateTone(account: AccountRow) { function authStateTone(account: AccountRow) {
switch (authState(account)) { switch (authState(account)) {
case "authorized": case "authorized":
@@ -128,6 +153,9 @@ function verificationTimeLabel(account: AccountRow): string {
if (account.lastVerifiedAt) { if (account.lastVerifiedAt) {
return formatVerifiedAtLabel(account.lastVerifiedAt); return formatVerifiedAtLabel(account.lastVerifiedAt);
} }
if (account.probeState === "queued") {
return "等待校验";
}
if (account.probeState === "probing") { if (account.probeState === "probing") {
return "正在校验"; return "正在校验";
} }
@@ -221,6 +249,32 @@ async function unbindAccount(account: AccountRow) {
} }
} }
async function verifyAccount(account: AccountRow) {
probePendingAccountIds.value = new Set([...probePendingAccountIds.value, account.id]);
actionSuccess.value = null;
try {
await probeAccount(account.id);
} catch (error) {
showClientActionError("probe-account", error);
} finally {
const next = new Set(probePendingAccountIds.value);
next.delete(account.id);
probePendingAccountIds.value = next;
}
}
function isProbePending(account: AccountRow): boolean {
return (
probePendingAccountIds.value.has(account.id)
|| account.probeState === "queued"
|| account.probeState === "probing"
);
}
const tableVirtual = computed(() => filteredAccounts.value.length > 20);
const tableScroll = computed(() => (tableVirtual.value ? { x: 960, y: 640 } : { x: 960 }));
const tableColumns = [ const tableColumns = [
{ title: "昵称 / UID", key: "name", dataIndex: "name" }, { title: "昵称 / UID", key: "name", dataIndex: "name" },
{ title: "平台", key: "platform", dataIndex: "platform", width: 140 }, { title: "平台", key: "platform", dataIndex: "platform", width: 140 },
@@ -359,7 +413,15 @@ const tableColumns = [
</div> </div>
</div> </div>
<a-table class="modern-table" :columns="tableColumns" :data-source="filteredAccounts" :pagination="false"> <a-table
class="modern-table"
row-key="id"
:columns="tableColumns"
:data-source="filteredAccounts"
:pagination="false"
:scroll="tableScroll"
:virtual="tableVirtual"
>
<template #emptyText> <template #emptyText>
<div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配账号,请重置过滤或绑定新账号" /></div> <div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配账号,请重置过滤或绑定新账号" /></div>
</template> </template>
@@ -393,7 +455,7 @@ const tableColumns = [
</template> </template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
<a-tag :color="authState(record) === 'authorized' ? 'success' : authState(record) === 'expired' ? 'error' : authState(record) === 'attention' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;"> <a-tag :color="authTagColor(record)" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
{{ authStateLabel(record) }} {{ authStateLabel(record) }}
</a-tag> </a-tag>
</template> </template>
@@ -411,7 +473,9 @@ const tableColumns = [
<div class="cell-primary-secondary"> <div class="cell-primary-secondary">
<div class="info-stack"> <div class="info-stack">
<span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span> <span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span>
<span class="subtitle">{{ verificationTimeLabel(record) }} · {{ record.partition }}</span> <span class="subtitle subtitle--partition" :title="`${verificationTimeLabel(record)} · ${record.partition}`">
{{ verificationTimeLabel(record) }} · {{ record.partition }}
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -423,8 +487,8 @@ const tableColumns = [
<template #icon><LinkOutlined /></template> <template #icon><LinkOutlined /></template>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip title="重新授权" placement="top"> <a-tooltip title="重新校验" placement="top">
<a-button type="text" class="action-btn" :loading="bindPendingPlatformId === record.platform" @click="bindPlatform(record.platform)"> <a-button type="text" class="action-btn" :loading="isProbePending(record)" @click="verifyAccount(record)">
<template #icon><SyncOutlined /></template> <template #icon><SyncOutlined /></template>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
@@ -712,6 +776,13 @@ const tableColumns = [
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
} }
.info-stack .subtitle--partition {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-avatar-wrap { .user-avatar-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -64,7 +64,11 @@ function authLabel(account: AccountRow | null) {
case "revoked": case "revoked":
return "已停用"; return "已停用";
default: default:
return account.probeState === "probing" ? "校验中" : "待校验"; return account.probeState === "queued"
? "等待校验"
: account.probeState === "probing"
? "校验中"
: "待校验";
} }
} }
@@ -84,7 +88,9 @@ function authColor(account: AccountRow | null) {
case "expiring_soon": case "expiring_soon":
return account.probeState === "network_error" ? "warning" : "default"; return account.probeState === "network_error" ? "warning" : "default";
default: default:
return "default"; return account.probeState === "queued" || account.probeState === "probing"
? "processing"
: "default";
} }
} }
@@ -108,6 +114,9 @@ function verificationLabel(account: AccountRow): string {
if (account.lastVerifiedAt) { if (account.lastVerifiedAt) {
return formatVerifiedAtLabel(account.lastVerifiedAt); return formatVerifiedAtLabel(account.lastVerifiedAt);
} }
if (account.probeState === "queued") {
return "等待首轮校验";
}
if (account.probeState === "probing") { if (account.probeState === "probing") {
return "正在执行首轮校验"; return "正在执行首轮校验";
} }
+30
View File
@@ -126,6 +126,13 @@ export interface DesktopAccountInfo {
display_name: string; display_name: string;
avatar_url: string | null; avatar_url: string | null;
health: "live" | "expired" | "captcha" | "risk"; health: "live" | "expired" | "captcha" | "risk";
runtime_health?: "live" | "expired" | "captcha" | "risk" | null;
runtime_verified_at?: string | null;
runtime_checked_at?: string | null;
runtime_auth_state?: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked" | null;
runtime_probe_state?: "queued" | "idle" | "probing" | "network_error" | null;
runtime_auth_reason?: string | null;
health_source?: "database" | "runtime";
client_id: string | null; client_id: string | null;
account_fingerprint: string | null; account_fingerprint: string | null;
verified_at: string | null; verified_at: string | null;
@@ -158,6 +165,29 @@ export interface PatchDesktopAccountRequest {
if_sync_version: number; if_sync_version: number;
} }
export interface DesktopAccountHealthReport {
account_id: string;
platform: string;
platform_uid: string;
health: "live" | "expired" | "captcha" | "risk";
auth_state: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
probe_state: "queued" | "idle" | "probing" | "network_error";
auth_reason?: string | null;
display_name?: string | null;
avatar_url?: string | null;
verified_at?: string | null;
checked_at: string;
}
export interface ReportDesktopAccountHealthRequest {
reports: DesktopAccountHealthReport[];
}
export interface ReportDesktopAccountHealthResponse {
accepted_count: number;
buffered_count: number;
}
export interface DesktopTaskInfo { export interface DesktopTaskInfo {
id: string; id: string;
job_id: string; job_id: string;