diff --git a/apps/backend/src/services/session/session-service-config.js b/apps/backend/src/services/session/session-service-config.js new file mode 100644 index 00000000..8e31f658 --- /dev/null +++ b/apps/backend/src/services/session/session-service-config.js @@ -0,0 +1,17 @@ +// @ts-check + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { runtimeConfig } from '../../config/runtime.js' +import { resolveDefaultViewport } from './session-browser-config.js' + +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..') + +export const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions') +export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' +export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() +export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 +export const DEFAULT_VIEWPORT = resolveDefaultViewport() +export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) diff --git a/apps/backend/src/services/session/session-service-runtime.js b/apps/backend/src/services/session/session-service-runtime.js new file mode 100644 index 00000000..8cdba2e0 --- /dev/null +++ b/apps/backend/src/services/session/session-service-runtime.js @@ -0,0 +1,90 @@ +// @ts-check + +import { chromium } from 'playwright' + +import { runtimeConfig } from '../../config/runtime.js' +import { + buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase, + resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, +} from './session-browser-config.js' +import { CHROME_PATH } from './session-service-config.js' + +let browserPromise = null + +export class SessionServiceError extends Error { + constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) { + super(message) + this.name = 'SessionServiceError' + this.statusCode = statusCode + this.errorCode = errorCode + } +} + +export async function warmupTencentBrowser() { + const browserLaunch = resolveBrowserLaunchOptions() + + if (!browserLaunch.prewarm) { + return { + warmed: false, + reason: 'disabled', + browserLaunch, + } + } + + await ensureBrowser() + + return { + warmed: true, + reason: 'ready', + browserLaunch, + } +} + +export async function ensureBrowser() { + if (!browserPromise) { + const launchOptions = resolveBrowserLaunchOptions() + browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions)) + .then((browser) => { + browser.on('disconnected', () => { + browserPromise = null + }) + return browser + }) + .catch((error) => { + browserPromise = null + throw error + }) + } + + return browserPromise +} + +export function resolveBrowserLaunchOptions() { + return resolveBrowserLaunchOptionsBase({ + browserConfig: runtimeConfig.browser, + chromePath: CHROME_PATH, + }) +} + +export async function closeBrowserIfIdle({ activeSessionCount = 0 } = {}) { + if (activeSessionCount > 0 || !browserPromise) { + return + } + + if (resolveBrowserLaunchOptions().keepAlive) { + return + } + + try { + const browser = await browserPromise + await browser.close() + } catch { + // ignore browser close failures + } finally { + browserPromise = null + } +} + +function buildChromiumLaunchOptions(launchOptions) { + return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH }) +} diff --git a/apps/backend/src/services/session/session-service-store.js b/apps/backend/src/services/session/session-service-store.js new file mode 100644 index 00000000..21d300d8 --- /dev/null +++ b/apps/backend/src/services/session/session-service-store.js @@ -0,0 +1,159 @@ +// @ts-check + +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { + DATA_ROOT, + REDEEM_SUCCESS_AUTO_CLOSE_MS, +} from './session-service-config.js' +import { SessionServiceError } from './session-service-runtime.js' +import { + resolveInitialQrSessionState, + SESSION_TTL_MS, +} from './session-state.js' + +const sessions = new Map() + +export async function registerTencentBrowserSession({ loginType, browserContext, page }) { + const sessionId = createSessionId() + const sessionDir = path.join(DATA_ROOT, sessionId) + await fs.mkdir(sessionDir, { recursive: true }) + + const now = new Date().toISOString() + const session = { + sessionId, + loginType, + status: 'created', + notice: '浏览器会话已创建', + createdAt: now, + updatedAt: now, + expiresAt: Date.now() + SESSION_TTL_MS, + autoCloseAt: 0, + autoCloseTimer: null, + sessionDir, + browserContext, + page, + qrImageBase64: '', + qrImagePath: '', + qrUpdatedAt: '', + lastState: null, + lastRedeem: null, + lastReview: null, + lastError: '', + presentationSyncAttempts: 0, + } + + sessions.set(sessionId, session) + return session +} + +export function listActiveTencentBrowserSessions() { + return [...sessions.values()] +} + +export async function getRequiredSession(sessionId) { + const key = String(sessionId || '').trim() + + if (!key) { + throw new SessionServiceError('缺少 sessionId', { + statusCode: 400, + errorCode: 'missing_session_id', + }) + } + + const session = sessions.get(key) + + if (!session) { + throw new SessionServiceError('浏览器会话不存在或已过期', { + statusCode: 404, + errorCode: 'session_not_found', + }) + } + + return session +} + +export function applyInitialQrSessionState(session) { + resetTencentSessionAutoClose(session) + Object.assign(session, resolveInitialQrSessionState(session.loginType)) +} + +export function armRedeemedSessionAutoClose(session, { closeSession }) { + resetTencentSessionAutoClose(session) + + const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS + session.autoCloseAt = autoCloseAt + session.expiresAt = autoCloseAt + session.autoCloseTimer = setTimeout(() => { + void closeSession(session, { markClosed: true }).catch(() => { + // ignore delayed auto-close failures + }) + }, REDEEM_SUCCESS_AUTO_CLOSE_MS) +} + +export function resetTencentSessionAutoClose(session) { + if (session?.autoCloseTimer) { + clearTimeout(session.autoCloseTimer) + session.autoCloseTimer = null + } + + if (session) { + session.autoCloseAt = 0 + } +} + +export async function cleanupExpiredTencentBrowserSessions({ closeSession }) { + const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now()) + + await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) +} + +export async function closeSession( + session, + { + markClosed, + persistSessionState, + closeBrowserIfIdle, + }, +) { + resetTencentSessionAutoClose(session) + sessions.delete(session.sessionId) + + try { + await session.browserContext?.close() + } catch { + // ignore browser context close failures + } + + session.browserContextClosed = true + session.updatedAt = new Date().toISOString() + + if (markClosed) { + session.status = 'closed' + session.notice = '浏览器会话已关闭' + await persistSessionState(session) + } + + await closeBrowserIfIdle() +} + +export async function persistSessionState(session, { buildSessionPayload }) { + await fs.mkdir(session.sessionDir, { recursive: true }) + await fs.writeFile( + path.join(session.sessionDir, 'session.json'), + JSON.stringify(buildSessionPayload(session), null, 2), + 'utf8', + ) +} + +function createSessionId() { + let sessionId = '' + + do { + sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` + } while (sessions.has(sessionId)) + + return sessionId +} diff --git a/apps/backend/src/services/session/session.js b/apps/backend/src/services/session/session.js index cd080399..71980268 100644 --- a/apps/backend/src/services/session/session.js +++ b/apps/backend/src/services/session/session.js @@ -1,19 +1,8 @@ // @ts-nocheck -import crypto from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { chromium } from 'playwright' - -import { runtimeConfig } from '../../config/runtime.js' import { - buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase, getLoginTypeLabel, normalizeLoginType, - resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, - resolveDefaultViewport, } from './session-browser-config.js' import { logBrowserSessionDebug } from './session-login-shared.js' import { @@ -58,63 +47,41 @@ import { resolveSessionNotice, resolveSessionStatus, resolveTencentSessionExpiresAt, - SESSION_TTL_MS, } from './session-state.js' - -const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) -const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..') -const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions') -const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' -const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() -export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 -const DEFAULT_VIEWPORT = resolveDefaultViewport() -const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) - -const sessions = new Map() - -let browserPromise = null +import { + ACTIVITY_URL, + DEFAULT_VIEWPORT, + REDEEM_SUCCESS_AUTO_CLOSE_MS, + SESSION_DEBUG_ENABLED, +} from './session-service-config.js' +import { + closeBrowserIfIdle, + ensureBrowser, + resolveBrowserLaunchOptions, + SessionServiceError, + warmupTencentBrowser, +} from './session-service-runtime.js' +import { + applyInitialQrSessionState, + armRedeemedSessionAutoClose, + cleanupExpiredTencentBrowserSessions, + closeSession as closeStoredSession, + getRequiredSession, + listActiveTencentBrowserSessions, + persistSessionState as persistStoredSessionState, + registerTencentBrowserSession, + resetTencentSessionAutoClose, +} from './session-service-store.js' export { resolveTencentSessionExpiresAt } - -class SessionServiceError extends Error { - constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) { - super(message) - this.name = 'SessionServiceError' - this.statusCode = statusCode - this.errorCode = errorCode - } -} - -export async function warmupTencentBrowser() { - const browserLaunch = resolveBrowserLaunchOptions() - - if (!browserLaunch.prewarm) { - return { - warmed: false, - reason: 'disabled', - browserLaunch, - } - } - - await ensureBrowser() - - return { - warmed: true, - reason: 'ready', - browserLaunch, - } -} +export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser } export async function createTencentBrowserSession(payload = {}) { - await cleanupExpiredTencentBrowserSessions() + await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) const loginType = normalizeLoginType(payload.loginType || payload.qrType) const browser = await ensureBrowser() - const sessionId = createSessionId() - const sessionDir = path.join(DATA_ROOT, sessionId) - await fs.mkdir(sessionDir, { recursive: true }) - const browserContext = await browser.newContext({ viewport: DEFAULT_VIEWPORT, deviceScaleFactor: 1, @@ -123,30 +90,12 @@ export async function createTencentBrowserSession(payload = {}) { const page = await browserContext.newPage() page.setDefaultTimeout(20_000) - const session = { - sessionId, + const session = await registerTencentBrowserSession({ loginType, - status: 'created', - notice: '浏览器会话已创建', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - expiresAt: Date.now() + SESSION_TTL_MS, - autoCloseAt: 0, - autoCloseTimer: null, - sessionDir, browserContext, page, - qrImageBase64: '', - qrImagePath: '', - qrUpdatedAt: '', - lastState: null, - lastRedeem: null, - lastReview: null, - lastError: '', - presentationSyncAttempts: 0, - } - - sessions.set(sessionId, session) + }) + const sessionId = session.sessionId try { await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) @@ -180,7 +129,7 @@ export async function getTencentBrowserSessionSummary(sessionId) { } export async function reloadTencentBrowserSession(sessionId) { - await cleanupExpiredTencentBrowserSessions() + await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) const session = await getRequiredSession(sessionId) @@ -222,7 +171,7 @@ export async function reloadTencentBrowserSession(sessionId) { } export async function refreshTencentBrowserSession(sessionId, { includeQrImage = true } = {}) { - await cleanupExpiredTencentBrowserSessions() + await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) const session = await getRequiredSession(sessionId) @@ -349,7 +298,7 @@ export async function redeemTencentBrowserSession(sessionId, payload = {}) { activityUrl: ACTIVITY_URL, }) - armRedeemedSessionAutoClose(session) + armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession }) await persistSessionState(session) return { @@ -360,7 +309,7 @@ export async function redeemTencentBrowserSession(sessionId, payload = {}) { export async function closeTencentBrowserSession(sessionId) { const session = await getRequiredSession(sessionId) - await closeSession(session, { markClosed: true }) + await closeManagedSession(session, { markClosed: true }) return { sessionId, closed: true, @@ -368,9 +317,9 @@ export async function closeTencentBrowserSession(sessionId) { } export async function closeAllTencentBrowserSessions({ markClosed = true } = {}) { - const activeSessions = [...sessions.values()] + const activeSessions = listActiveTencentBrowserSessions() - await Promise.all(activeSessions.map((session) => closeSession(session, { markClosed }))) + await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed }))) return { closedCount: activeSessions.length, @@ -405,132 +354,8 @@ export async function getTencentBrowserSessionReviewScreenshotPath(sessionId) { return screenshotPath } -async function ensureBrowser() { - if (!browserPromise) { - const launchOptions = resolveBrowserLaunchOptions() - browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions)) - .then((browser) => { - browser.on('disconnected', () => { - browserPromise = null - }) - return browser - }) - .catch((error) => { - browserPromise = null - throw error - }) - } - - return browserPromise -} - -function resolveBrowserLaunchOptions() { - return resolveBrowserLaunchOptionsBase({ - browserConfig: runtimeConfig.browser, - chromePath: CHROME_PATH, - }) -} - -function buildChromiumLaunchOptions(launchOptions) { - return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH }) -} - -function createSessionId() { - let sessionId = '' - - do { - sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` - } while (sessions.has(sessionId)) - - return sessionId -} - -async function getRequiredSession(sessionId) { - const key = String(sessionId || '').trim() - - if (!key) { - throw new SessionServiceError('缺少 sessionId', { - statusCode: 400, - errorCode: 'missing_session_id', - }) - } - - const session = sessions.get(key) - - if (!session) { - throw new SessionServiceError('浏览器会话不存在或已过期', { - statusCode: 404, - errorCode: 'session_not_found', - }) - } - - return session -} - -function applyInitialQrSessionState(session) { - resetTencentSessionAutoClose(session) - Object.assign(session, resolveInitialQrSessionState(session.loginType)) -} - -function armRedeemedSessionAutoClose(session) { - resetTencentSessionAutoClose(session) - - const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS - session.autoCloseAt = autoCloseAt - session.expiresAt = autoCloseAt - session.autoCloseTimer = setTimeout(() => { - void closeSession(session, { markClosed: true }).catch(() => { - // ignore delayed auto-close failures - }) - }, REDEEM_SUCCESS_AUTO_CLOSE_MS) -} - -function resetTencentSessionAutoClose(session) { - if (session?.autoCloseTimer) { - clearTimeout(session.autoCloseTimer) - session.autoCloseTimer = null - } - - if (session) { - session.autoCloseAt = 0 - } -} - -async function cleanupExpiredTencentBrowserSessions() { - const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now()) - - await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) -} - -async function closeSession(session, { markClosed }) { - resetTencentSessionAutoClose(session) - sessions.delete(session.sessionId) - - try { - await session.browserContext?.close() - } catch { - // ignore browser context close failures - } - - session.browserContextClosed = true - session.updatedAt = new Date().toISOString() - - if (markClosed) { - session.status = 'closed' - session.notice = '浏览器会话已关闭' - await persistSessionState(session) - } - - await closeBrowserIfIdle() -} - async function persistSessionState(session) { - await fs.mkdir(session.sessionDir, { recursive: true }) - await fs.writeFile( - path.join(session.sessionDir, 'session.json'), - JSON.stringify(buildSessionPayload(session), null, 2), - 'utf8', - ) + await persistStoredSessionState(session, { buildSessionPayload }) } function buildSessionPayload(session, { includeQrImage = true } = {}) { @@ -546,21 +371,12 @@ async function fillRedeemCodeInBrowser(page, code) { await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) } -async function closeBrowserIfIdle() { - if (sessions.size > 0 || !browserPromise) { - return - } - - if (resolveBrowserLaunchOptions().keepAlive) { - return - } - - try { - const browser = await browserPromise - await browser.close() - } catch { - // ignore browser close failures - } finally { - browserPromise = null - } +async function closeManagedSession(session, { markClosed }) { + await closeStoredSession(session, { + markClosed, + persistSessionState, + closeBrowserIfIdle: () => closeBrowserIfIdle({ + activeSessionCount: listActiveTencentBrowserSessions().length, + }), + }) }