feat(monitoring): decouple AI platforms from media_platforms and expand catalog to 6
Add ai_platforms table + shared catalog (yuanbao/kimi/wenxin/deepseek/doubao/qwen), drop FKs from platform_accounts and desktop_tasks to media_platforms, and wire target_account_id + platform into DesktopTaskEvent so the desktop runtime no longer has to infer them from local state. Desktop client gains generic AI page detection for binding, drops stale-business-date monitor tasks at the edge, and refactors AccountsView/AiPlatformsView around the shared catalog. Admin tracking view surfaces per-platform sampling status cards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
|
||||
import { BrowserWindow, app, nativeTheme, session as electronSession } from "electron/main";
|
||||
import type { Session, WebContents } from "electron/main";
|
||||
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||
import type { DesktopAccountInfo } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
@@ -679,6 +680,423 @@ function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount {
|
||||
};
|
||||
}
|
||||
|
||||
interface GenericAIPageState {
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
fingerprint: string | null;
|
||||
credentialFingerprint: string | null;
|
||||
currentPath: string | null;
|
||||
strongAuthSignalCount: number;
|
||||
loginSignalCount: number;
|
||||
loggedOutSignalCount: number;
|
||||
}
|
||||
|
||||
async function readGenericAIPageState(
|
||||
webContents: WebContents | null | undefined,
|
||||
): Promise<GenericAIPageState | null> {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await webContents.executeJavaScript(
|
||||
`(() => {
|
||||
try {
|
||||
const normalize = (value) => {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim().replace(/\\s+/g, " ");
|
||||
return trimmed || null;
|
||||
};
|
||||
const isVisible = (node) => {
|
||||
if (!(node instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const bodyText = normalize(document.body?.innerText || "");
|
||||
const stopWords = /^(登录|注册|设置|帮助|历史|聊天|新建|菜单|账户|账号|个人中心|联网搜索|深度思考|工具|更多|安装电脑版|下载电脑版|DeepSeek|元宝|混元|豆包|Kimi|Qwen|通义千问)$/i;
|
||||
const loginPattern = /(登录|注册|立即登录|扫码登录|微信登录|QQ登录|手机号登录|请先登录)/i;
|
||||
const loggedOutPattern = /(未登录|扫码登录|快捷登录|使用其他头像、昵称或账号|登录后继续|请先登录|立即登录|微信快捷登录)/i;
|
||||
const credentialKeyPattern = /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i;
|
||||
const isInsideAuthSurface = (node) => {
|
||||
if (!(node instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
let current = node;
|
||||
for (let depth = 0; current && depth < 8; depth += 1) {
|
||||
const descriptor = [
|
||||
current.getAttribute('role') || '',
|
||||
current.getAttribute('aria-modal') || '',
|
||||
current.getAttribute('data-testid') || '',
|
||||
current.id || '',
|
||||
current.className || '',
|
||||
].join(' ');
|
||||
if (/(dialog|modal|popup|popover|sheet|drawer|login|signin|auth|scan|qrcode|qrlogin)/i.test(descriptor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = normalize(current.textContent || '');
|
||||
if (text && text.length <= 120 && loggedOutPattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const pickGreetingName = () => {
|
||||
if (!bodyText) {
|
||||
return null;
|
||||
}
|
||||
const patterns = [
|
||||
/(?:^|\\n)\\s*(?:hi|hello|你好|嗨)[,,\\s]+([^\\n,,]{1,20})/i,
|
||||
/(?:^|\\n)\\s*欢迎回来[,,\\s]*([^\\n]{1,20})/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const matched = bodyText.match(pattern);
|
||||
const candidate = normalize(matched?.[1] || "");
|
||||
if (candidate && !stopWords.test(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const pickDisplayName = () => {
|
||||
const selectors = [
|
||||
'[data-testid*="user"]',
|
||||
'[data-testid*="profile"]',
|
||||
'[class*="user-name"]',
|
||||
'[class*="username"]',
|
||||
'[class*="nickname"]',
|
||||
'[class*="profile"] [class*="name"]',
|
||||
'[class*="avatar"] + *',
|
||||
'[class*="account"] [class*="name"]',
|
||||
'aside [class*="name"]',
|
||||
'nav [class*="name"]',
|
||||
];
|
||||
for (const selector of selectors) {
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
if (!isVisible(node)) continue;
|
||||
if (isInsideAuthSurface(node)) continue;
|
||||
const text = normalize(node.textContent || "");
|
||||
if (!text) continue;
|
||||
if (text.length < 2 || text.length > 48) continue;
|
||||
if (stopWords.test(text)) continue;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const pickAvatar = () => {
|
||||
const selectors = [
|
||||
'[class*="avatar"] img',
|
||||
'[data-testid*="avatar"] img',
|
||||
'[class*="profile"] img',
|
||||
'[class*="account"] img',
|
||||
'img[alt*="头像"]',
|
||||
];
|
||||
for (const selector of selectors) {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node || !isVisible(node)) continue;
|
||||
if (isInsideAuthSurface(node)) continue;
|
||||
const src = normalize(node && 'src' in node ? node.src : null);
|
||||
if (src && /(logo|icon|favicon)/i.test(src)) continue;
|
||||
if (src) return src;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const collectLoginSignals = () => {
|
||||
const matches = [];
|
||||
if (bodyText && loginPattern.test(bodyText)) {
|
||||
matches.push('body-text');
|
||||
}
|
||||
const selectors = ['button', 'a', '[role="button"]', '[aria-haspopup="dialog"]'];
|
||||
for (const selector of selectors) {
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
if (!isVisible(node)) continue;
|
||||
const text = normalize(node.textContent || "");
|
||||
if (!text || !loginPattern.test(text)) continue;
|
||||
matches.push(text);
|
||||
if (matches.length >= 4) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const collectLoggedOutSignals = () => {
|
||||
const matches = [];
|
||||
if (bodyText && loggedOutPattern.test(bodyText)) {
|
||||
matches.push('body-text');
|
||||
}
|
||||
const selectors = ['button', 'a', '[role="button"]', '[aria-haspopup="dialog"]', '[role="dialog"]'];
|
||||
for (const selector of selectors) {
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
if (!isVisible(node)) continue;
|
||||
const text = normalize(node.textContent || "");
|
||||
if (!text || !loggedOutPattern.test(text)) continue;
|
||||
matches.push(text);
|
||||
if (matches.length >= 4) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const collectCredentialStorage = (storage, prefix) => {
|
||||
const items = [];
|
||||
try {
|
||||
const limit = Math.min(storage.length, 40);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (!key || !credentialKeyPattern.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const value = storage.getItem(key);
|
||||
const text = String(value || '').slice(0, 240).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
items.push(prefix + ':' + key + '=' + text);
|
||||
}
|
||||
} catch {
|
||||
return items;
|
||||
}
|
||||
return items;
|
||||
};
|
||||
const collectStorage = (storage, prefix) => {
|
||||
const items = [];
|
||||
try {
|
||||
const limit = Math.min(storage.length, 40);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (!key || !/(user|uid|token|session|auth|account|profile|login|member|nick|name)/i.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const value = storage.getItem(key);
|
||||
items.push(prefix + ':' + key + '=' + String(value || '').slice(0, 160));
|
||||
}
|
||||
} catch {
|
||||
return items;
|
||||
}
|
||||
return items;
|
||||
};
|
||||
const displayName = pickGreetingName() || pickDisplayName();
|
||||
const avatarUrl = pickAvatar();
|
||||
const strongSignals = [];
|
||||
if (displayName) {
|
||||
strongSignals.push('displayName');
|
||||
}
|
||||
if (avatarUrl) {
|
||||
strongSignals.push('avatar');
|
||||
}
|
||||
if (bodyText && /(?:^|\\n)\\s*(?:hi|hello|你好|嗨)[,,\\s]+/i.test(bodyText)) {
|
||||
strongSignals.push('greeting');
|
||||
}
|
||||
const loginSignals = collectLoginSignals();
|
||||
const loggedOutSignals = collectLoggedOutSignals();
|
||||
const credentialFingerprintParts = [
|
||||
...collectCredentialStorage(window.localStorage, 'local'),
|
||||
...collectCredentialStorage(window.sessionStorage, 'session'),
|
||||
];
|
||||
const fingerprintParts = [
|
||||
document.cookie || '',
|
||||
window.location.origin || '',
|
||||
document.title || '',
|
||||
...collectStorage(window.localStorage, 'local'),
|
||||
...collectStorage(window.sessionStorage, 'session'),
|
||||
].filter(Boolean);
|
||||
return {
|
||||
displayName,
|
||||
avatarUrl,
|
||||
fingerprint: fingerprintParts.length ? fingerprintParts.join('||') : null,
|
||||
credentialFingerprint: credentialFingerprintParts.length
|
||||
? credentialFingerprintParts.join('||')
|
||||
: null,
|
||||
currentPath: normalize(window.location.pathname || ''),
|
||||
strongAuthSignalCount: strongSignals.length,
|
||||
loginSignalCount: loginSignals.length,
|
||||
loggedOutSignalCount: loggedOutSignals.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();`,
|
||||
true,
|
||||
).catch(() => null);
|
||||
|
||||
if (!state || typeof state !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typed = state as Partial<GenericAIPageState>;
|
||||
return {
|
||||
displayName: normalizeText(typed.displayName),
|
||||
avatarUrl: normalizeRemoteUrl(typed.avatarUrl),
|
||||
fingerprint: normalizeText(typed.fingerprint),
|
||||
credentialFingerprint: normalizeText(typed.credentialFingerprint),
|
||||
currentPath: normalizeText(typed.currentPath),
|
||||
strongAuthSignalCount:
|
||||
typeof typed.strongAuthSignalCount === "number" && Number.isFinite(typed.strongAuthSignalCount)
|
||||
? typed.strongAuthSignalCount
|
||||
: 0,
|
||||
loginSignalCount:
|
||||
typeof typed.loginSignalCount === "number" && Number.isFinite(typed.loginSignalCount)
|
||||
? typed.loginSignalCount
|
||||
: 0,
|
||||
loggedOutSignalCount:
|
||||
typeof typed.loggedOutSignalCount === "number" && Number.isFinite(typed.loggedOutSignalCount)
|
||||
? typed.loggedOutSignalCount
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isAIPlatformBinding(platformId: string): boolean {
|
||||
return aiPlatformCatalog.some((item) => item.id === platformId);
|
||||
}
|
||||
|
||||
function normalizeURLPath(pathname: string): string {
|
||||
const normalized = pathname.replace(/\/+$/, "");
|
||||
return normalized === "/" ? "" : normalized;
|
||||
}
|
||||
|
||||
function safeParseURL(input: string): URL | null {
|
||||
try {
|
||||
return new URL(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function genericAIConversationPath(pathname: string): boolean {
|
||||
return /^\/(?:chat|c|conversation)(?:\/|$)/i.test(pathname);
|
||||
}
|
||||
|
||||
function isLikelyGenericAICredentialName(name: string): boolean {
|
||||
return /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i.test(name);
|
||||
}
|
||||
|
||||
function isLikelyGenericAICredentialValue(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized.length >= 8;
|
||||
}
|
||||
|
||||
function genericAIPageLooksAuthenticated(currentURL: string, pageState: GenericAIPageState | null): boolean {
|
||||
const current = safeParseURL(currentURL);
|
||||
const currentPath = normalizeURLPath(current?.pathname ?? pageState?.currentPath ?? "");
|
||||
const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0;
|
||||
const loginSignalCount = pageState?.loginSignalCount ?? 0;
|
||||
const loggedOutSignalCount = pageState?.loggedOutSignalCount ?? 0;
|
||||
const onConversationPath = genericAIConversationPath(currentPath);
|
||||
|
||||
if (loggedOutSignalCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginSignalCount > 0 && strongAuthSignalCount === 0 && !onConversationPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginSignalCount > 0) {
|
||||
return strongAuthSignalCount >= 2 && !onConversationPath;
|
||||
}
|
||||
|
||||
return strongAuthSignalCount > 0 || onConversationPath;
|
||||
}
|
||||
|
||||
async function buildGenericAISessionFingerprint(
|
||||
platformId: string,
|
||||
session: Session,
|
||||
pageState?: GenericAIPageState | null,
|
||||
): Promise<string | null> {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (pageState?.credentialFingerprint) {
|
||||
parts.push(pageState.credentialFingerprint);
|
||||
}
|
||||
|
||||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null;
|
||||
if (platformMeta) {
|
||||
const urls = new Set([platformMeta.loginUrl, platformMeta.consoleUrl]);
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const cookies = await session.cookies.get({ url });
|
||||
const authCookies = cookies.filter((cookie) => {
|
||||
return isLikelyGenericAICredentialName(cookie.name)
|
||||
&& isLikelyGenericAICredentialValue(cookie.value);
|
||||
});
|
||||
if (authCookies.length > 0) {
|
||||
const serialized = authCookies
|
||||
.map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`)
|
||||
.sort()
|
||||
.join(";");
|
||||
parts.push(`${url}:${serialized}`);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return boundedIdentity(`${platformId}-session`, parts.join("||"));
|
||||
}
|
||||
|
||||
async function detectGenericAIPlatform(
|
||||
platformId: string,
|
||||
label: string,
|
||||
context: DetectContext,
|
||||
): Promise<DetectedAccount | null> {
|
||||
if (!context.webContents || context.webContents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentURL = context.webContents && !context.webContents.isDestroyed()
|
||||
? context.webContents.getURL()
|
||||
: "";
|
||||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null;
|
||||
if (!platformMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageState = await readGenericAIPageState(context.webContents).catch(() => null);
|
||||
if (!genericAIPageLooksAuthenticated(currentURL, pageState)) {
|
||||
return null;
|
||||
}
|
||||
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState);
|
||||
if (!fingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitizeDetectedAccount({
|
||||
platformUid: fingerprint,
|
||||
displayName: pageState?.displayName ?? `${label} 会话`,
|
||||
avatarUrl: pageState?.avatarUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function detectToutiao({ session, webContents }: DetectContext): Promise<DetectedAccount | null> {
|
||||
const pageResponse = await pageFetchJson<ToutiaoMediaInfoResponse>(
|
||||
webContents,
|
||||
@@ -875,11 +1293,37 @@ async function resolvePublishAccountProfileFromHandle(
|
||||
return await detectToutiaoFromSession(handle.session);
|
||||
}
|
||||
|
||||
const definition = publishBindingDefinitions[account.platform];
|
||||
const definition = platformBindingDefinitions[account.platform];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAIPlatformBinding(account.platform)) {
|
||||
const window = createBoundWindow(
|
||||
`${definition.label} 静默识别`,
|
||||
definition.consoleUrl,
|
||||
handle.session,
|
||||
{ show: false },
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForWindowNavigationSettled(window);
|
||||
if (window.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detected = await definition.detect({
|
||||
session: handle.session,
|
||||
webContents: window.webContents,
|
||||
}).catch(() => null);
|
||||
return toPublishAccountProfile(detected);
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detected = await definition.detect({
|
||||
session: handle.session,
|
||||
webContents: undefined as unknown as WebContents,
|
||||
@@ -994,6 +1438,30 @@ async function waitForWindowNavigationSettled(window: BrowserWindow, timeoutMs =
|
||||
}
|
||||
}
|
||||
|
||||
async function flushSessionPersistence(target: Session): Promise<void> {
|
||||
const maybeFlushStorageData = (target as Session & {
|
||||
flushStorageData?: () => Promise<void>;
|
||||
}).flushStorageData;
|
||||
|
||||
if (typeof maybeFlushStorageData === "function") {
|
||||
try {
|
||||
await maybeFlushStorageData.call(target);
|
||||
} catch (error) {
|
||||
console.warn("[desktop-session] flushStorageData failed", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await target.cookies.flushStore();
|
||||
} catch (error) {
|
||||
console.warn("[desktop-session] flushStore failed", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Session): Promise<boolean> {
|
||||
if (window.isDestroyed()) {
|
||||
return false;
|
||||
@@ -1046,10 +1514,6 @@ function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentURL.startsWith(loginURL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = new URL(currentURL);
|
||||
const login = new URL(loginURL);
|
||||
@@ -1057,8 +1521,13 @@ function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPath = current.pathname.replace(/\/+$/, "");
|
||||
const loginPath = login.pathname.replace(/\/+$/, "");
|
||||
const currentPath = normalizeURLPath(current.pathname);
|
||||
const loginPath = normalizeURLPath(login.pathname);
|
||||
if (!loginPath) {
|
||||
return /(^|[/?#=&._-])(login|signin|sign-in|signup|sign-up|register|auth|oauth|passport|scan|qrcode|qrlogin|wxlogin|sso)(?=$|[/?#=&._-])/i
|
||||
.test(`${current.pathname}${current.search}${current.hash}`);
|
||||
}
|
||||
|
||||
return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -1069,7 +1538,7 @@ async function verifyPublishAccountConsoleAccess(
|
||||
account: PublishAccountIdentity,
|
||||
handle: SessionHandle,
|
||||
): Promise<boolean> {
|
||||
const definition = publishBindingDefinitions[account.platform];
|
||||
const definition = platformBindingDefinitions[account.platform];
|
||||
if (!definition) {
|
||||
return false;
|
||||
}
|
||||
@@ -1096,6 +1565,14 @@ async function verifyPublishAccountConsoleAccess(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAIPlatformBinding(account.platform)) {
|
||||
const detected = await definition.detect({
|
||||
session: handle.session,
|
||||
webContents: window.webContents,
|
||||
}).catch(() => null);
|
||||
return detected !== null;
|
||||
}
|
||||
|
||||
return !looksLikeLoginRedirect(currentURL, definition.loginUrl);
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
@@ -1470,6 +1947,24 @@ const publishBindingDefinitions: Record<string, PublishPlatformBindingDefinition
|
||||
},
|
||||
};
|
||||
|
||||
const aiBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = Object.fromEntries(
|
||||
aiPlatformCatalog.map((platform) => [
|
||||
platform.id,
|
||||
{
|
||||
id: platform.id,
|
||||
label: platform.label,
|
||||
loginUrl: platform.loginUrl,
|
||||
consoleUrl: platform.consoleUrl,
|
||||
detect: (context: DetectContext) => detectGenericAIPlatform(platform.id, platform.label, context),
|
||||
},
|
||||
]),
|
||||
) as Record<string, PublishPlatformBindingDefinition>;
|
||||
|
||||
const platformBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = {
|
||||
...publishBindingDefinitions,
|
||||
...aiBindingDefinitions,
|
||||
};
|
||||
|
||||
function createBoundWindow(
|
||||
title: string,
|
||||
targetURL: string,
|
||||
@@ -1542,9 +2037,9 @@ function clearActiveBindOperation(platformId: string, window: BrowserWindow): vo
|
||||
}
|
||||
|
||||
export async function bindPublishAccount(platformId: string): Promise<DesktopAccountInfo> {
|
||||
const definition = publishBindingDefinitions[platformId];
|
||||
const definition = platformBindingDefinitions[platformId];
|
||||
if (!definition) {
|
||||
throw new Error(`desktop_publish_platform_not_supported:${platformId}`);
|
||||
throw new Error(`desktop_platform_not_supported:${platformId}`);
|
||||
}
|
||||
|
||||
cleanupInactiveBindOperations();
|
||||
@@ -1615,7 +2110,12 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
|
||||
detectReady = false;
|
||||
return;
|
||||
}
|
||||
detectReady = isWindowReadyForDetection(window);
|
||||
const currentURL = window.webContents.getURL();
|
||||
const fallbackReady =
|
||||
/^https?:\/\//i.test(currentURL)
|
||||
&& !currentURL.startsWith("data:text/html")
|
||||
&& !window.webContents.isLoading();
|
||||
detectReady = isWindowReadyForDetection(window) || fallbackReady;
|
||||
};
|
||||
|
||||
window.webContents.on("did-finish-load", syncDetectionState);
|
||||
@@ -1641,6 +2141,8 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
|
||||
console.info("[desktop-bind] detect tick", {
|
||||
platform: definition.id,
|
||||
detectReady,
|
||||
canProbe,
|
||||
loading: window.webContents.isLoading(),
|
||||
url: currentURL,
|
||||
});
|
||||
const detected = await definition
|
||||
@@ -1687,20 +2189,34 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
|
||||
normalizedDetected,
|
||||
);
|
||||
|
||||
const account = await upsertDesktopAccount({
|
||||
platform: definition.id,
|
||||
platform_uid: normalizedDetected.platformUid,
|
||||
display_name: normalizedDetected.displayName,
|
||||
avatar_url: normalizedDetected.avatarUrl,
|
||||
account_fingerprint: normalizedDetected.platformUid,
|
||||
health: "live",
|
||||
verified_at: new Date().toISOString(),
|
||||
tags: [],
|
||||
});
|
||||
await flushSessionPersistence(handle.session);
|
||||
|
||||
settleSuccess(account);
|
||||
try {
|
||||
const account = await upsertDesktopAccount({
|
||||
platform: definition.id,
|
||||
platform_uid: normalizedDetected.platformUid,
|
||||
display_name: normalizedDetected.displayName,
|
||||
avatar_url: normalizedDetected.avatarUrl,
|
||||
account_fingerprint: normalizedDetected.platformUid,
|
||||
health: "live",
|
||||
verified_at: new Date().toISOString(),
|
||||
tags: [],
|
||||
});
|
||||
|
||||
settleSuccess(account);
|
||||
} catch (error) {
|
||||
console.error("[desktop-bind] upsert failed", {
|
||||
platform: definition.id,
|
||||
url: currentURL,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (!window.isDestroyed()) {
|
||||
window.setTitle(`${definition.label} 绑定保存失败,保留窗口重试`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[desktop-bind] upsert failed", {
|
||||
console.error("[desktop-bind] bind failed", {
|
||||
platform: definition.id,
|
||||
url: currentURL,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -1739,9 +2255,9 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
|
||||
}
|
||||
|
||||
export async function openPublishAccountConsole(account: PublishAccountIdentity): Promise<void> {
|
||||
const definition = publishBindingDefinitions[account.platform];
|
||||
const definition = platformBindingDefinitions[account.platform];
|
||||
if (!definition) {
|
||||
throw new Error(`desktop_publish_platform_not_supported:${account.platform}`);
|
||||
throw new Error(`desktop_platform_not_supported:${account.platform}`);
|
||||
}
|
||||
|
||||
const handle = await ensurePublishAccountSessionHandle(account);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
JsonValue,
|
||||
LeaseDesktopTaskResponse,
|
||||
} from "@geo/shared-types";
|
||||
import { isAIPlatformId } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
doubaoAdapter,
|
||||
@@ -80,6 +81,7 @@ const heartbeatIntervalMs = 25_000;
|
||||
const pullIntervalMs = 60_000;
|
||||
const leaseExtendIntervalMs = 60_000;
|
||||
const maxActivityItems = 60;
|
||||
const monitorBusinessTimeZone = "Asia/Shanghai";
|
||||
|
||||
interface RuntimeTaskRecord {
|
||||
id: string;
|
||||
@@ -456,10 +458,10 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
const next: RuntimeTaskRecord = {
|
||||
id: event.task_id,
|
||||
jobId: event.job_id,
|
||||
title: existing?.title ?? defaultTaskTitle(event.kind, existing?.platform),
|
||||
title: existing?.title ?? defaultTaskTitle(event.kind, event.platform || existing?.platform),
|
||||
kind: event.kind,
|
||||
platform: existing?.platform ?? inferPlatformFromAccount(existing?.accountId ?? null),
|
||||
accountId: existing?.accountId ?? "",
|
||||
platform: event.platform || existing?.platform || inferPlatformFromAccount(event.target_account_id),
|
||||
accountId: event.target_account_id || existing?.accountId || "",
|
||||
accountName: existing?.accountName ?? "待同步账号",
|
||||
clientId: event.target_client_id,
|
||||
status: event.status,
|
||||
@@ -548,6 +550,7 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
state.accountProfiles.clear();
|
||||
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const isAIPlatform = isAIPlatformId(account.platform);
|
||||
const identity = {
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
@@ -560,7 +563,18 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (local.profile && !sameAccountPlatformUid(local.profile.platformUid, normalizedPlatformUid)) {
|
||||
if (local.profile) {
|
||||
state.accountProfiles.set(account.id, local.profile);
|
||||
}
|
||||
|
||||
const normalizedLocalPlatformUid = local.profile
|
||||
? (normalizeAccountPlatformUid(local.profile.platformUid) || local.profile.platformUid)
|
||||
: null;
|
||||
const hasPlatformUidMismatch = Boolean(
|
||||
normalizedLocalPlatformUid && !sameAccountPlatformUid(normalizedLocalPlatformUid, normalizedPlatformUid),
|
||||
);
|
||||
|
||||
if (hasPlatformUidMismatch && !isAIPlatform) {
|
||||
return {
|
||||
...account,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
@@ -571,6 +585,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
let resolvedAccount: DesktopAccountInfo = {
|
||||
...account,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: local.profile?.displayName ?? account.display_name,
|
||||
avatar_url: local.profile?.avatarUrl ?? account.avatar_url,
|
||||
health: local.availability === "expired" ? ("expired" as const) : ("live" as const),
|
||||
};
|
||||
|
||||
@@ -967,6 +983,24 @@ async function executeTaskAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
const staleBusinessDate = resolveStaleMonitoringBusinessDate(payload);
|
||||
if (staleBusinessDate) {
|
||||
return {
|
||||
status: "unknown",
|
||||
payload: {
|
||||
...payload,
|
||||
dropped_by_client: true,
|
||||
dropped_reason: "stale_business_date",
|
||||
},
|
||||
error: {
|
||||
code: "desktop_monitor_task_stale",
|
||||
message: "monitor task is stale and has been dropped by desktop client",
|
||||
business_date: staleBusinessDate,
|
||||
},
|
||||
summary: `${task.title} 已过业务日 ${staleBusinessDate},按漏采策略直接丢弃。`,
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = selectMonitorAdapter(task.platform);
|
||||
if (!adapter) {
|
||||
return buildScaffoldResult(task, payload, "monitor adapter is not implemented yet");
|
||||
@@ -1017,7 +1051,7 @@ function buildScaffoldResult(
|
||||
message: "desktop runtime adapter is not implemented for this platform",
|
||||
detail,
|
||||
},
|
||||
summary: `${task.title} 执行失败:当前平台的 desktop 发布适配器尚未实现。`,
|
||||
summary: `${task.title} 执行失败:当前平台的 desktop ${task.kind === "publish" ? "发布" : "监测"}适配器尚未实现。`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1062,6 +1096,50 @@ function toPositiveInt(value: JsonValue | null): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStaleMonitoringBusinessDate(payload: Record<string, JsonValue>): string | null {
|
||||
const businessDate = resolveMonitoringBusinessDate(payload);
|
||||
if (!businessDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return businessDate < currentMonitoringBusinessDate() ? businessDate : null;
|
||||
}
|
||||
|
||||
function resolveMonitoringBusinessDate(payload: Record<string, JsonValue>): string | null {
|
||||
const candidates = [
|
||||
payload.business_date,
|
||||
payload.businessDate,
|
||||
payload.metric_date,
|
||||
payload.date,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = candidate.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentMonitoringBusinessDate(now = new Date()): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: monitorBusinessTimeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
|
||||
const year = parts.find((part) => part.type === "year")?.value ?? "";
|
||||
const month = parts.find((part) => part.type === "month")?.value ?? "";
|
||||
const day = parts.find((part) => part.type === "day")?.value ?? "";
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function updateTaskProgress(taskId: string, summary: string): void {
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (!existing) {
|
||||
@@ -1354,7 +1432,9 @@ function isDesktopTaskEvent(value: unknown): value is DesktopTaskEventMessage {
|
||||
typeof event.type === "string"
|
||||
&& typeof event.task_id === "string"
|
||||
&& typeof event.job_id === "string"
|
||||
&& typeof event.target_account_id === "string"
|
||||
&& typeof event.target_client_id === "string"
|
||||
&& typeof event.platform === "string"
|
||||
&& typeof event.kind === "string"
|
||||
&& typeof event.status === "string"
|
||||
&& typeof event.updated_at === "string",
|
||||
|
||||
Reference in New Issue
Block a user