From 635e0138feb1e62d8713edcec3e928543ac8f870 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 4 May 2026 14:18:55 +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=E9=A1=B5=E9=9D=A2=E4=BA=A4=E4=BA=92=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=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-page.js | 188 ++++++++++++++++++ apps/backend/src/services/session/session.js | 167 +--------------- .../src/services/session/session.test.js | 87 ++++++++ 3 files changed, 285 insertions(+), 157 deletions(-) create mode 100644 apps/backend/src/services/session/session-page.js diff --git a/apps/backend/src/services/session/session-page.js b/apps/backend/src/services/session/session-page.js new file mode 100644 index 00000000..03bc3093 --- /dev/null +++ b/apps/backend/src/services/session/session-page.js @@ -0,0 +1,188 @@ +// @ts-nocheck + +import fs from 'node:fs/promises' +import path from 'node:path' + +import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js' +import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js' +import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js' + +export async function captureSessionQr( + session, + { + ensureLoginReady = true, + ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, + captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry, + readFile = fs.readFile, + now = () => new Date().toISOString(), + } = {}, +) { + if (ensureLoginReady) { + await ensureLoginTabImpl(session.page, session.loginType) + } + + const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) + await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, { + ensureLoginTab: ensureLoginTabImpl, + }) + + session.qrImagePath = qrImagePath + session.qrImageBase64 = await readFile(qrImagePath, 'base64') + session.qrUpdatedAt = now() + session.updatedAt = session.qrUpdatedAt +} + +export async function captureQrImageWithRetry( + page, + loginType, + qrImagePath, + { + captureQqQrImage: captureQqQrImageImpl = captureQqQrImage, + captureWxQrImage: captureWxQrImageImpl = captureWxQrImage, + ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, + isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError, + logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug, + } = {}, +) { + const maxAttempts = loginType === 'qq' ? 4 : 3 + let lastError = null + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + logBrowserSessionDebugImpl('captureQrImageWithRetry.start', { + loginType, + attempt, + maxAttempts, + qrImagePath, + }) + + if (loginType === 'qq') { + await captureQqQrImageImpl(page, qrImagePath) + } else { + await captureWxQrImageImpl(page, qrImagePath) + } + + logBrowserSessionDebugImpl('captureQrImageWithRetry.success', { + loginType, + attempt, + qrImagePath, + }) + + return + } catch (error) { + lastError = error + logBrowserSessionDebugImpl('captureQrImageWithRetry.error', { + loginType, + attempt, + message: error instanceof Error ? error.message : String(error), + }) + + if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) { + throw error + } + + await page.waitForTimeout(500) + await ensureLoginTabImpl(page, loginType) + } + } + + if (lastError) { + throw lastError + } + + throw new Error('二维码截图失败') +} + +export async function ensureLoginTab( + page, + loginType, + { + ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady, + ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady, + } = {}, +) { + const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab' + const loginButton = page.locator('#unlogin') + let interacted = false + + if (await loginButton.count()) { + try { + await loginButton.first().click({ timeout: 2_000 }) + interacted = true + } catch { + // ignore when the login layer is already open + } + } + + const tab = page.locator(tabSelector).first() + + if (await tab.count()) { + try { + await tab.click({ timeout: 2_000 }) + interacted = true + } catch { + // ignore tab click failures + } + } + + if (interacted) { + await page.waitForTimeout(250) + } + + if (loginType === 'qq') { + await ensureQqLoginReadyImpl(page) + return + } + + if (loginType === 'wx') { + await ensureWxLoginReadyImpl(page) + } +} + +export async function extractHostState(page) { + return page.evaluate(() => { + const isVisible = (element) => { + if (!element) { + return false + } + + const style = window.getComputedStyle(element) + + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' + } + + const unlogin = document.querySelector('#unlogin') + const logined = document.querySelector('#logined') + + return { + unloginVisible: isVisible(unlogin), + loginedVisible: isVisible(logined), + loginText: String(logined?.textContent || '').trim(), + } + }) +} + +export async function reloadActivityPageForPresentation(page, activityUrl) { + try { + await page.reload({ waitUntil: 'domcontentloaded' }) + } catch { + await page.goto(activityUrl, { waitUntil: 'domcontentloaded' }) + } + + await page.waitForTimeout(2_500) +} + +export async function extractQrState( + page, + loginType, + { + extractQqQrState: extractQqQrStateImpl = extractQqQrState, + extractWxQrState: extractWxQrStateImpl = extractWxQrState, + } = {}, +) { + if (loginType === 'qq') { + return extractQqQrStateImpl(page) + } + + return extractWxQrStateImpl(page) +} diff --git a/apps/backend/src/services/session/session.js b/apps/backend/src/services/session/session.js index b04be7c1..aea05d91 100644 --- a/apps/backend/src/services/session/session.js +++ b/apps/backend/src/services/session/session.js @@ -15,10 +15,7 @@ import { resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, resolveDefaultViewport, } from './session-browser-config.js' -import { - isRetryableQrCaptureError, - logBrowserSessionDebug, -} from './session-login-shared.js' +import { logBrowserSessionDebug } from './session-login-shared.js' import { loadCookieMapFromContext, } from './session-cookie-map.js' @@ -26,10 +23,12 @@ import { buildSessionPayload as buildSessionPayloadBase, } from './session-payload.js' import { - captureQqQrImage, - ensureQqLoginReady, - extractQqQrState, -} from './session-qq.js' + captureSessionQr, + ensureLoginTab, + extractHostState, + extractQrState, + reloadActivityPageForPresentation, +} from './session-page.js' import { saveRedeemArtifacts, } from './session-proof.js' @@ -45,9 +44,7 @@ import { submitRedeemInBrowser, } from './session-redeem.js' import { - captureWxQrImage, ensureWxLoginReady, - extractWxQrState, } from './session-wx.js' import { recognizeTencentCaptcha } from './ocr.js' import { @@ -191,7 +188,7 @@ export async function reloadTencentBrowserSession(sessionId) { } try { - await reloadActivityPageForPresentation(session.page) + await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) const cookieMap = await loadCookieMapFromContext(session.browserContext) const credentialReady = hasRedeemCredential(cookieMap) @@ -278,7 +275,7 @@ export async function refreshTencentBrowserSession(sessionId, { includeQrImage = if (!session.qrImageBase64 || session.status === 'expired') { if (session.status === 'expired') { - await reloadActivityPageForPresentation(session.page) + await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) await ensureLoginTab(session.page, session.loginType) } @@ -462,109 +459,6 @@ async function getRequiredSession(sessionId) { return session } -async function captureSessionQr(session, { ensureLoginReady = true } = {}) { - if (ensureLoginReady) { - await ensureLoginTab(session.page, session.loginType) - } - - const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) - await captureQrImageWithRetry(session.page, session.loginType, qrImagePath) - - session.qrImagePath = qrImagePath - session.qrImageBase64 = await fs.readFile(qrImagePath, 'base64') - session.qrUpdatedAt = new Date().toISOString() - session.updatedAt = new Date().toISOString() -} - -async function captureQrImageWithRetry(page, loginType, qrImagePath) { - const maxAttempts = loginType === 'qq' ? 4 : 3 - let lastError = null - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - logBrowserSessionDebug('captureQrImageWithRetry.start', { - loginType, - attempt, - maxAttempts, - qrImagePath, - }) - - if (loginType === 'qq') { - await captureQqQrImage(page, qrImagePath) - } else { - await captureWxQrImage(page, qrImagePath) - } - - logBrowserSessionDebug('captureQrImageWithRetry.success', { - loginType, - attempt, - qrImagePath, - }) - - return - } catch (error) { - lastError = error - logBrowserSessionDebug('captureQrImageWithRetry.error', { - loginType, - attempt, - message: error instanceof Error ? error.message : String(error), - }) - - if (!isRetryableQrCaptureError(error) || attempt === maxAttempts) { - throw error - } - - await page.waitForTimeout(500) - await ensureLoginTab(page, loginType) - } - } - - if (lastError) { - throw lastError - } - - throw new Error('二维码截图失败') -} - -async function ensureLoginTab(page, loginType) { - const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab' - const loginButton = page.locator('#unlogin') - let interacted = false - - if (await loginButton.count()) { - try { - await loginButton.first().click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore when the login layer is already open - } - } - - const tab = page.locator(tabSelector).first() - - if (await tab.count()) { - try { - await tab.click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore tab click failures - } - } - - if (interacted) { - await page.waitForTimeout(250) - } - - if (loginType === 'qq') { - await ensureQqLoginReady(page) - return - } - - if (loginType === 'wx') { - await ensureWxLoginReady(page) - } -} - function applyInitialQrSessionState(session) { resetTencentSessionAutoClose(session) Object.assign(session, resolveInitialQrSessionState(session.loginType)) @@ -594,29 +488,6 @@ function resetTencentSessionAutoClose(session) { } } -async function extractHostState(page) { - return page.evaluate(() => { - const isVisible = (element) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const unlogin = document.querySelector('#unlogin') - const logined = document.querySelector('#logined') - - return { - unloginVisible: isVisible(unlogin), - loginedVisible: isVisible(logined), - loginText: String(logined?.textContent || '').trim(), - } - }) -} - async function ensureLoggedInPresentation( session, { hostState = null, credentialReady = false } = {}, @@ -638,7 +509,7 @@ async function ensureLoggedInPresentation( } session.presentationSyncAttempts += 1 - await reloadActivityPageForPresentation(session.page) + await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) nextHostState = await extractHostState(session.page) return { @@ -647,24 +518,6 @@ async function ensureLoggedInPresentation( } } -async function reloadActivityPageForPresentation(page) { - try { - await page.reload({ waitUntil: 'domcontentloaded' }) - } catch { - await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) - } - - await page.waitForTimeout(2_500) -} - -async function extractQrState(page, loginType) { - if (loginType === 'qq') { - return extractQqQrState(page) - } - - return extractWxQrState(page) -} - async function ensureActivityInfoReady(page, { triggerRender = false, timeoutMs = 8_000 } = {}) { if (triggerRender) { await triggerActivityRoleRender(page) diff --git a/apps/backend/src/services/session/session.test.js b/apps/backend/src/services/session/session.test.js index 1cba3ecd..44416c25 100644 --- a/apps/backend/src/services/session/session.test.js +++ b/apps/backend/src/services/session/session.test.js @@ -6,6 +6,10 @@ import { parseViewportSpec, } from './session-browser-config.js' import { normalizeTencentCookieMap } from './session-cookie-map.js' +import { + captureQrImageWithRetry, + ensureLoginTab, +} from './session-page.js' import { buildArtifactsPayload, buildRedeemPayload, @@ -206,3 +210,86 @@ test('ensureReviewScreenshot writes screenshot state and skips unchanged signatu assert.equal(await ensureReviewScreenshot(session, activityInfo), false) assert.equal(screenshotCalls.length, 1) }) + +test('ensureLoginTab clicks login UI and delegates to qq readiness', async () => { + const calls = [] + const makeLocator = (name) => ({ + async count() { + return 1 + }, + first() { + const firstLocator = { + async count() { + return 1 + }, + async click(options) { + calls.push({ type: 'click', name, options }) + }, + } + return firstLocator + }, + }) + + const page = { + locator(selector) { + if (selector === '#unlogin') { + return makeLocator('loginButton') + } + if (selector === '.qc-tab') { + return makeLocator('qqTab') + } + throw new Error(`unexpected selector: ${selector}`) + }, + async waitForTimeout(ms) { + calls.push({ type: 'wait', ms }) + }, + } + + await ensureLoginTab(page, 'qq', { + async ensureQqLoginReady() { + calls.push({ type: 'ensure', name: 'qq' }) + }, + }) + + assert.deepEqual(calls, [ + { type: 'click', name: 'loginButton', options: { timeout: 2_000 } }, + { type: 'click', name: 'qqTab', options: { timeout: 2_000 } }, + { type: 'wait', ms: 250 }, + { type: 'ensure', name: 'qq' }, + ]) +}) + +test('captureQrImageWithRetry retries once after retryable error', async () => { + const calls = [] + const page = { + async waitForTimeout(ms) { + calls.push({ type: 'wait', ms }) + }, + } + + let attempt = 0 + await captureQrImageWithRetry(page, 'wx', '/tmp/wx-qr.png', { + async captureWxQrImage() { + attempt += 1 + calls.push({ type: 'capture', attempt }) + if (attempt === 1) { + throw new Error('Timeout while waiting for qr') + } + }, + async ensureLoginTab(_page, loginType) { + calls.push({ type: 'ensureLoginTab', loginType }) + }, + isRetryableQrCaptureError(error) { + return String(error.message).includes('Timeout') + }, + logBrowserSessionDebug() {}, + }) + + assert.equal(attempt, 2) + assert.deepEqual(calls, [ + { type: 'capture', attempt: 1 }, + { type: 'wait', ms: 500 }, + { type: 'ensureLoginTab', loginType: 'wx' }, + { type: 'capture', attempt: 2 }, + ]) +})