From 1aac7387397b0c15c55658c0f03bb7b6f4276fa4 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 4 May 2026 14:23:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E8=85=BE=E8=AE=AF=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B4=BB=E5=8A=A8=E9=A1=B5=E9=87=87=E9=9B=86=E4=B8=8E?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/session/session-activity.js | 231 ++++++++++++++++++ apps/backend/src/services/session/session.js | 219 +---------------- .../src/services/session/session.test.js | 89 ++++++- 3 files changed, 326 insertions(+), 213 deletions(-) create mode 100644 apps/backend/src/services/session/session-activity.js diff --git a/apps/backend/src/services/session/session-activity.js b/apps/backend/src/services/session/session-activity.js new file mode 100644 index 00000000..5572f6fa --- /dev/null +++ b/apps/backend/src/services/session/session-activity.js @@ -0,0 +1,231 @@ +// @ts-nocheck + +import { extractHostState, reloadActivityPageForPresentation } from './session-page.js' +import { shouldRefreshLoggedInPresentation } from './session-state.js' + +export function createEmptyActivityInfo() { + return { + nickname: '', + role: { + ready: false, + roleName: '', + roleId: '', + area: '', + partition: '', + platId: '', + md5str: '', + checkparam: '', + }, + form: { + cdkeyInputId: '', + cdkeyValue: '', + verifyInputId: '', + verifyValue: '', + verifyImgId: '', + submitId: '', + }, + verify: { + visible: false, + src: '', + naturalWidth: 0, + naturalHeight: 0, + }, + popup: { + visible: false, + text: '', + detail: '', + }, + } +} + +export async function ensureLoggedInPresentation( + session, + { + hostState = null, + credentialReady = false, + activityUrl = '', + extractHostState: extractHostStateImpl = extractHostState, + reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation, + shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation, + } = {}, +) { + let nextHostState = hostState || (await extractHostStateImpl(session.page)) + + if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) { + return { + reloaded: false, + hostState: nextHostState, + } + } + + if (session.presentationSyncAttempts >= 2) { + return { + reloaded: false, + hostState: nextHostState, + } + } + + session.presentationSyncAttempts += 1 + await reloadActivityPageForPresentationImpl(session.page, activityUrl) + nextHostState = await extractHostStateImpl(session.page) + + return { + reloaded: true, + hostState: nextHostState, + } +} + +export async function ensureActivityInfoReady( + page, + { + triggerRender = false, + timeoutMs = 8_000, + extractActivityInfo: extractActivityInfoImpl = extractActivityInfo, + triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender, + now = () => Date.now(), + } = {}, +) { + if (triggerRender) { + await triggerActivityRoleRenderImpl(page) + } + + const startedAt = now() + let lastInfo = await extractActivityInfoImpl(page) + + while (now() - startedAt < timeoutMs) { + if (lastInfo?.role?.ready) { + return lastInfo + } + + await page.waitForTimeout(400) + lastInfo = await extractActivityInfoImpl(page) + } + + return lastInfo +} + +export async function triggerActivityRoleRender(page) { + try { + await page.evaluate(() => { + const maybeRender = window.renderCdkey + + if (typeof maybeRender === 'function') { + maybeRender() + } + }) + } catch { + // ignore render trigger failures; polling will still inspect the page + } +} + +export async function extractActivityInfo(page) { + try { + return await page.evaluate(() => { + const asString = (value) => (typeof value === 'string' ? value.trim() : '') + const isVisible = (element) => { + if (!element) { + return false + } + + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' + } + + const pick = (selector) => document.querySelector(selector) + const pickBest = (selector, predicate = null) => { + const matches = [...document.querySelectorAll(selector)] + const preferred = matches.find((element) => { + if (predicate && !predicate(element)) { + return false + } + + return isVisible(element) + }) + + if (preferred) { + return preferred + } + + if (predicate) { + return matches.find((element) => predicate(element)) || null + } + + return matches[0] || null + } + const textOf = (selector) => asString(pick(selector)?.textContent || '') + const inputOf = (selector) => { + const element = pickBest( + selector, + (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement, + ) + + if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { + return '' + } + + return asString(element.value || '') + } + + const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {} + const roleSelector = pick('[id^="milo_role_selector_"]') + const roleNameText = + textOf('#role_name') || + asString(roleSelector?.selectedOptions?.[0]?.textContent || '') + const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement) + const popup = pick('#pop2') + const cdkeyInput = pickBest( + '[id^="milo_cdkeyInfo_"]', + (node) => + (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && + !node.disabled && + node.type !== 'hidden', + ) + const verifyInput = pickBest( + '[id^="milo_verifyInput_"]', + (node) => + (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && + !node.disabled && + node.type !== 'hidden', + ) + const submitButton = pickBest( + '#milo_cdkey_submit, [id^="milo_cdkey_submit"]', + (node) => node instanceof HTMLElement && !node.hasAttribute('disabled'), + ) + + return { + nickname: textOf('#login_nickname_span'), + role: { + ready: Boolean(window.isRole || roleNameText), + roleName: roleNameText, + roleId: asString(roleData.sRoleId), + area: asString(roleData.sArea || '36'), + partition: asString(roleData.sPartition), + platId: asString(roleData.sPlatId), + md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr), + checkparam: asString(roleData.sCheckparam), + }, + form: { + cdkeyInputId: cdkeyInput?.id || '', + cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'), + verifyInputId: verifyInput?.id || '', + verifyValue: inputOf('[id^="milo_verifyInput_"]'), + verifyImgId: verifyImg?.id || '', + submitId: submitButton?.id || '', + }, + verify: { + visible: isVisible(verifyImg), + src: asString(verifyImg?.getAttribute('src')), + naturalWidth: Number(verifyImg?.naturalWidth || 0), + naturalHeight: Number(verifyImg?.naturalHeight || 0), + }, + popup: { + visible: isVisible(popup), + text: textOf('#PopText'), + detail: textOf('#PopText2'), + }, + } + }) + } catch { + return createEmptyActivityInfo() + } +} diff --git a/apps/backend/src/services/session/session.js b/apps/backend/src/services/session/session.js index aea05d91..cd080399 100644 --- a/apps/backend/src/services/session/session.js +++ b/apps/backend/src/services/session/session.js @@ -19,6 +19,11 @@ import { logBrowserSessionDebug } from './session-login-shared.js' import { loadCookieMapFromContext, } from './session-cookie-map.js' +import { + ensureActivityInfoReady, + ensureLoggedInPresentation, + extractActivityInfo, +} from './session-activity.js' import { buildSessionPayload as buildSessionPayloadBase, } from './session-payload.js' @@ -54,7 +59,6 @@ import { resolveSessionStatus, resolveTencentSessionExpiresAt, SESSION_TTL_MS, - shouldRefreshLoggedInPresentation, } from './session-state.js' const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -235,7 +239,11 @@ export async function refreshTencentBrowserSession(sessionId, { includeQrImage = const credentialReady = hasRedeemCredential(cookieMap) if (credentialReady) { - const presentation = await ensureLoggedInPresentation(session, { hostState, credentialReady }) + const presentation = await ensureLoggedInPresentation(session, { + hostState, + credentialReady, + activityUrl: ACTIVITY_URL, + }) hostState = presentation.hostState } else { session.presentationSyncAttempts = 0 @@ -488,213 +496,6 @@ function resetTencentSessionAutoClose(session) { } } -async function ensureLoggedInPresentation( - session, - { hostState = null, credentialReady = false } = {}, -) { - let nextHostState = hostState || (await extractHostState(session.page)) - - if (!credentialReady || !shouldRefreshLoggedInPresentation(nextHostState)) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - if (session.presentationSyncAttempts >= 2) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - session.presentationSyncAttempts += 1 - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - nextHostState = await extractHostState(session.page) - - return { - reloaded: true, - hostState: nextHostState, - } -} - -async function ensureActivityInfoReady(page, { triggerRender = false, timeoutMs = 8_000 } = {}) { - if (triggerRender) { - await triggerActivityRoleRender(page) - } - - const startedAt = Date.now() - let lastInfo = await extractActivityInfo(page) - - while (Date.now() - startedAt < timeoutMs) { - if (lastInfo?.role?.ready) { - return lastInfo - } - - await page.waitForTimeout(400) - lastInfo = await extractActivityInfo(page) - } - - return lastInfo -} - -async function triggerActivityRoleRender(page) { - try { - await page.evaluate(() => { - const maybeRender = window.renderCdkey - - if (typeof maybeRender === 'function') { - maybeRender() - } - }) - } catch { - // ignore render trigger failures; polling will still inspect the page - } -} - -async function extractActivityInfo(page) { - try { - return await page.evaluate(() => { - const asString = (value) => (typeof value === 'string' ? value.trim() : '') - const isVisible = (element) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const pick = (selector) => document.querySelector(selector) - const pickBest = (selector, predicate = null) => { - const matches = [...document.querySelectorAll(selector)] - const preferred = matches.find((element) => { - if (predicate && !predicate(element)) { - return false - } - - return isVisible(element) - }) - - if (preferred) { - return preferred - } - - if (predicate) { - return matches.find((element) => predicate(element)) || null - } - - return matches[0] || null - } - const textOf = (selector) => asString(pick(selector)?.textContent || '') - const inputOf = (selector) => { - const element = pickBest( - selector, - (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement, - ) - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return '' - } - - return asString(element.value || '') - } - - const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {} - const roleSelector = pick('[id^="milo_role_selector_"]') - const roleNameText = - textOf('#role_name') || - asString(roleSelector?.selectedOptions?.[0]?.textContent || '') - const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement) - const popup = pick('#pop2') - const cdkeyInput = pickBest( - '[id^="milo_cdkeyInfo_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const verifyInput = pickBest( - '[id^="milo_verifyInput_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const submitButton = pickBest( - '#milo_cdkey_submit, [id^="milo_cdkey_submit"]', - (node) => node instanceof HTMLElement && !node.hasAttribute('disabled'), - ) - - return { - nickname: textOf('#login_nickname_span'), - role: { - ready: Boolean(window.isRole || roleNameText), - roleName: roleNameText, - roleId: asString(roleData.sRoleId), - area: asString(roleData.sArea || '36'), - partition: asString(roleData.sPartition), - platId: asString(roleData.sPlatId), - md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr), - checkparam: asString(roleData.sCheckparam), - }, - form: { - cdkeyInputId: cdkeyInput?.id || '', - cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'), - verifyInputId: verifyInput?.id || '', - verifyValue: inputOf('[id^="milo_verifyInput_"]'), - verifyImgId: verifyImg?.id || '', - submitId: submitButton?.id || '', - }, - verify: { - visible: isVisible(verifyImg), - src: asString(verifyImg?.getAttribute('src')), - naturalWidth: Number(verifyImg?.naturalWidth || 0), - naturalHeight: Number(verifyImg?.naturalHeight || 0), - }, - popup: { - visible: isVisible(popup), - text: textOf('#PopText'), - detail: textOf('#PopText2'), - }, - } - }) - } catch { - return { - nickname: '', - role: { - ready: false, - roleName: '', - roleId: '', - area: '', - partition: '', - platId: '', - md5str: '', - checkparam: '', - }, - form: { - cdkeyInputId: '', - cdkeyValue: '', - verifyInputId: '', - verifyValue: '', - verifyImgId: '', - submitId: '', - }, - verify: { - visible: false, - src: '', - naturalWidth: 0, - naturalHeight: 0, - }, - popup: { - visible: false, - text: '', - detail: '', - }, - } - } -} - async function cleanupExpiredTencentBrowserSessions() { const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now()) diff --git a/apps/backend/src/services/session/session.test.js b/apps/backend/src/services/session/session.test.js index 44416c25..c5143d8e 100644 --- a/apps/backend/src/services/session/session.test.js +++ b/apps/backend/src/services/session/session.test.js @@ -6,14 +6,17 @@ import { parseViewportSpec, } from './session-browser-config.js' import { normalizeTencentCookieMap } from './session-cookie-map.js' +import { + createEmptyActivityInfo, + ensureActivityInfoReady, + ensureLoggedInPresentation, + extractActivityInfo, +} from './session-activity.js' import { captureQrImageWithRetry, ensureLoginTab, } from './session-page.js' -import { - buildArtifactsPayload, - buildRedeemPayload, -} from './session-payload.js' +import { buildArtifactsPayload, buildRedeemPayload } from './session-payload.js' import { ensureReviewScreenshot } from './session-review.js' import { resolveInitialQrSessionState } from './session-state.js' import { @@ -293,3 +296,81 @@ test('captureQrImageWithRetry retries once after retryable error', async () => { { type: 'capture', attempt: 2 }, ]) }) + +test('ensureLoggedInPresentation reloads page when logged-in host state is stale', async () => { + const session = { + page: { marker: 'page' }, + presentationSyncAttempts: 0, + } + const calls = [] + let hostCall = 0 + + const result = await ensureLoggedInPresentation(session, { + credentialReady: true, + activityUrl: 'https://example.com/activity', + async extractHostState() { + hostCall += 1 + return hostCall === 1 + ? { unloginVisible: true, loginedVisible: false, loginText: '' } + : { unloginVisible: false, loginedVisible: true, loginText: '已登录' } + }, + async reloadActivityPageForPresentation(_page, activityUrl) { + calls.push(activityUrl) + }, + }) + + assert.equal(session.presentationSyncAttempts, 1) + assert.deepEqual(calls, ['https://example.com/activity']) + assert.deepEqual(result, { + reloaded: true, + hostState: { unloginVisible: false, loginedVisible: true, loginText: '已登录' }, + }) +}) + +test('ensureActivityInfoReady triggers render and polls until role becomes ready', async () => { + const calls = [] + const page = { + async waitForTimeout(ms) { + calls.push({ type: 'wait', ms }) + }, + } + const snapshots = [ + { role: { ready: false, roleName: '' } }, + { role: { ready: true, roleName: '测试角色' } }, + ] + let nowValue = 0 + + const result = await ensureActivityInfoReady(page, { + triggerRender: true, + timeoutMs: 1000, + async triggerActivityRoleRender() { + calls.push({ type: 'trigger' }) + }, + async extractActivityInfo() { + calls.push({ type: 'extract' }) + return snapshots.shift() + }, + now() { + nowValue += 300 + return nowValue + }, + }) + + assert.deepEqual(calls, [ + { type: 'trigger' }, + { type: 'extract' }, + { type: 'wait', ms: 400 }, + { type: 'extract' }, + ]) + assert.deepEqual(result, { role: { ready: true, roleName: '测试角色' } }) +}) + +test('extractActivityInfo falls back to empty structure when page evaluation fails', async () => { + const page = { + async evaluate() { + throw new Error('page closed') + }, + } + + assert.deepEqual(await extractActivityInfo(page), createEmptyActivityInfo()) +})