拆分腾讯会话纯函数与序列化逻辑

This commit is contained in:
yml
2026-05-04 13:38:58 +08:00
parent 899d898a8e
commit ab33272cac
5 changed files with 446 additions and 272 deletions
+30 -272
View File
@@ -3,16 +3,25 @@
import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
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 {
isRetryableQrCaptureError,
logBrowserSessionDebug,
} from './session-login-shared.js'
import {
buildSessionPayload as buildSessionPayloadBase,
} from './session-payload.js'
import {
captureQqQrImage,
ensureQqLoginReady,
@@ -35,14 +44,20 @@ import {
extractWxQrState,
} from './session-wx.js'
import { recognizeTencentCaptcha } from './ocr.js'
import {
hasRedeemCredential,
resolveSessionNotice,
resolveSessionStatus,
resolveTencentSessionExpiresAt,
SESSION_TTL_MS,
shouldRefreshLoggedInPresentation,
} 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()
const DEFAULT_LOGIN_TYPE = 'qq'
const SESSION_TTL_MS = 30 * 60 * 1000
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
const DEFAULT_VIEWPORT = resolveDefaultViewport()
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
@@ -51,6 +66,8 @@ const sessions = new Map()
let browserPromise = null
export { resolveTencentSessionExpiresAt }
class SessionServiceError extends Error {
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
super(message)
@@ -396,83 +413,14 @@ async function ensureBrowser() {
}
function resolveBrowserLaunchOptions() {
const explicitHeadless = runtimeConfig.browser.headless
const explicitDevtools = runtimeConfig.browser.devtools
const explicitKeepAlive = runtimeConfig.browser.keepAlive
const explicitPrewarm = runtimeConfig.browser.prewarm
const explicitSlowMo = runtimeConfig.browser.slowMoMs
const isProductionLike = process.env.NODE_ENV === 'production' || process.env.CI === '1'
const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike
const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless
const slowMoMs = Number.isFinite(explicitSlowMo)
? Math.max(0, Math.min(explicitSlowMo, 2_000))
: headless
? 0
: 150
const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike
const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive
return {
headless,
devtools,
usesBundledChromium: !CHROME_PATH,
keepAlive,
prewarm,
slowMoMs,
}
return resolveBrowserLaunchOptionsBase({
browserConfig: runtimeConfig.browser,
chromePath: CHROME_PATH,
})
}
function buildChromiumLaunchOptions(launchOptions) {
const options = {
headless: launchOptions.headless,
devtools: launchOptions.devtools,
slowMo: launchOptions.slowMoMs,
}
if (CHROME_PATH) {
return {
...options,
executablePath: CHROME_PATH,
}
}
return options
}
function resolveDefaultViewport() {
const fromVncResolution = parseViewportSpec(process.env.TENCENT_BROWSER_VNC_RESOLUTION)
if (fromVncResolution) {
return fromVncResolution
}
return { width: 1600, height: 900 }
}
function parseViewportSpec(rawValue) {
const text = String(rawValue || '').trim()
if (!text) {
return null
}
const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i)
if (!match) {
return null
}
const width = Number(match[1])
const height = Number(match[2])
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) {
return null
}
return { width, height }
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
}
function createSessionId() {
@@ -485,21 +433,6 @@ function createSessionId() {
return sessionId
}
function normalizeLoginType(value) {
const normalized = String(value || DEFAULT_LOGIN_TYPE).trim().toLowerCase()
if (['qq', 'qc'].includes(normalized)) {
return 'qq'
}
if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) {
return 'wx'
}
return DEFAULT_LOGIN_TYPE
}
function getLoginTypeLabel(loginType) {
return loginType === 'wx' ? '微信' : 'QQ'
}
async function getRequiredSession(sessionId) {
const key = String(sessionId || '').trim()
@@ -633,15 +566,6 @@ function applyInitialQrSessionState(session) {
session.expiresAt = Date.now() + SESSION_TTL_MS
}
export function resolveTencentSessionExpiresAt(session, nowMs = Date.now()) {
const autoCloseAt = Number(session?.autoCloseAt || 0)
if (autoCloseAt > 0) {
return autoCloseAt
}
return nowMs + SESSION_TTL_MS
}
function armRedeemedSessionAutoClose(session) {
resetTencentSessionAutoClose(session)
@@ -719,12 +643,6 @@ async function ensureLoggedInPresentation(
}
}
function shouldRefreshLoggedInPresentation(hostState) {
const loginText = String(hostState?.loginText || '').trim()
return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText)
}
async function reloadActivityPageForPresentation(page) {
try {
await page.reload({ waitUntil: 'domcontentloaded' })
@@ -920,52 +838,6 @@ async function extractActivityInfo(page) {
}
}
function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) {
if (hostState.loginedVisible || credentialReady) {
if (credentialReady && activityInfo?.role?.ready) {
return 'ready_to_redeem'
}
return 'logged_in'
}
if (qrState.expired) {
return 'expired'
}
if (qrState.scanned) {
return 'scanned'
}
return 'waiting_scan'
}
function resolveSessionNotice({ hostState, qrState, credentialReady, activityInfo, loginType }) {
const loginTypeLabel = getLoginTypeLabel(loginType)
if (credentialReady) {
if (activityInfo?.role?.ready && activityInfo.role.roleName) {
return `登录已完成,当前角色 ${activityInfo.role.roleName}`
}
return '登录已完成,正在同步角色信息'
}
if (hostState.loginedVisible) {
return '页面已识别登录态'
}
if (qrState.expired) {
return '二维码已失效,准备刷新'
}
if (qrState.scanned) {
return '二维码已扫描,请在手机上确认登录'
}
return `请使用${loginTypeLabel}扫码登录`
}
async function cleanupExpiredTencentBrowserSessions() {
const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now())
@@ -1004,122 +876,12 @@ async function persistSessionState(session) {
}
function buildSessionPayload(session, { includeQrImage = true } = {}) {
const activityInfo = session.lastState?.activityInfo || null
const payload = {
sessionId: session.sessionId,
loginType: session.loginType,
return buildSessionPayloadBase(session, {
activityUrl: ACTIVITY_URL,
status: session.status,
notice: session.notice,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
expiresAt: new Date(session.expiresAt).toISOString(),
qrUpdatedAt: session.qrUpdatedAt,
credentialReady: Boolean(session.lastState?.credentialReady),
activityInfo,
review: buildReviewPayload(session.lastReview),
redeem: buildRedeemPayload(session.lastRedeem),
artifacts: buildArtifactsPayload(session),
}
if (includeQrImage) {
payload.qrImageBase64 = session.qrImageBase64
}
if (!SESSION_DEBUG_ENABLED) {
return payload
}
return {
...payload,
cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [],
state: session.lastState,
browserDebug: resolveBrowserLaunchOptions(),
qrImagePath: session.qrImagePath,
review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }),
redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }),
artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }),
}
}
function buildReviewPayload(review, { includeInternalPaths = false } = {}) {
if (!review) {
return null
}
const payload = {
capturedAt: review.capturedAt,
roleId: review.roleId || '',
roleName: review.roleName || '',
}
if (!includeInternalPaths) {
return payload
}
return {
...payload,
screenshotPath: review.screenshotPath || '',
signature: review.signature || '',
}
}
function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) {
if (!redeem) {
return null
}
const payload = {
code: redeem.code,
area: redeem.area,
proofMode: redeem.proofMode || 'full',
attempts: Array.isArray(redeem.attempts)
? redeem.attempts.map((attempt) => ({
attempt: attempt.attempt,
redeem: attempt.redeem || null,
}))
: [],
final: redeem.final
? {
attempt: redeem.final.attempt,
redeem: redeem.final.redeem || null,
}
: null,
finishedAt: redeem.finishedAt,
}
if (!includeInternalPaths) {
return payload
}
return {
...payload,
screenshotPath: redeem.screenshotPath || '',
htmlPath: redeem.htmlPath || '',
resultPath: redeem.resultPath || '',
}
}
function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) {
const payload = {
hasQrImage: Boolean(session.qrImageBase64),
hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath),
hasScreenshot: Boolean(session.lastRedeem?.screenshotPath),
}
if (!includeInternalPaths) {
return payload
}
return {
...payload,
sessionDir: session.sessionDir,
qrImagePath: session.qrImagePath,
screenshotPath: session.lastRedeem?.screenshotPath || '',
htmlPath: session.lastRedeem?.htmlPath || '',
resultPath: session.lastRedeem?.resultPath || '',
reviewScreenshotPath: session.lastReview?.screenshotPath || '',
}
includeQrImage,
sessionDebugEnabled: SESSION_DEBUG_ENABLED,
browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null,
})
}
async function ensureReviewScreenshot(session, activityInfo) {
@@ -1187,10 +949,6 @@ async function loadCookieMapFromContext(browserContext) {
return cookieMap
}
function hasRedeemCredential(cookieMap) {
return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid)
}
async function fillRedeemCodeInBrowser(page, code) {
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
}