205 lines
5.2 KiB
TypeScript
205 lines
5.2 KiB
TypeScript
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'
|
|
|
|
type BrowserContextLike = {
|
|
close?: () => Promise<void>
|
|
}
|
|
|
|
export type TencentBrowserSession = Record<string, any> & {
|
|
sessionId: string
|
|
loginType: string
|
|
status: string
|
|
notice: string
|
|
createdAt: string
|
|
updatedAt: string
|
|
expiresAt: number
|
|
autoCloseAt: number
|
|
autoCloseTimer: ReturnType<typeof setTimeout> | null
|
|
sessionDir: string
|
|
browserContext?: BrowserContextLike | null
|
|
page?: unknown
|
|
browserContextClosed?: boolean
|
|
}
|
|
|
|
type RegisterSessionInput = {
|
|
loginType: string
|
|
browserContext: BrowserContextLike
|
|
page: unknown
|
|
}
|
|
|
|
type CloseSessionDependency = {
|
|
closeSession: (session: TencentBrowserSession, options: { markClosed: boolean }) => Promise<void>
|
|
}
|
|
|
|
type CloseSessionOptions = {
|
|
markClosed: boolean
|
|
persistSessionState: (session: TencentBrowserSession) => Promise<unknown>
|
|
closeBrowserIfIdle: () => Promise<unknown>
|
|
}
|
|
|
|
type PersistSessionStateOptions = {
|
|
buildSessionPayload: (session: TencentBrowserSession) => unknown
|
|
}
|
|
|
|
const sessions = new Map<string, TencentBrowserSession>()
|
|
|
|
export async function registerTencentBrowserSession({
|
|
loginType,
|
|
browserContext,
|
|
page,
|
|
}: RegisterSessionInput): Promise<TencentBrowserSession> {
|
|
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(): TencentBrowserSession[] {
|
|
return [...sessions.values()]
|
|
}
|
|
|
|
export async function getRequiredSession(sessionId: unknown): Promise<TencentBrowserSession> {
|
|
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: TencentBrowserSession): void {
|
|
resetTencentSessionAutoClose(session)
|
|
Object.assign(session, resolveInitialQrSessionState(session.loginType))
|
|
}
|
|
|
|
export function armRedeemedSessionAutoClose(session: TencentBrowserSession, { closeSession }: CloseSessionDependency): void {
|
|
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?: TencentBrowserSession | null): void {
|
|
if (session?.autoCloseTimer) {
|
|
clearTimeout(session.autoCloseTimer)
|
|
session.autoCloseTimer = null
|
|
}
|
|
|
|
if (session) {
|
|
session.autoCloseAt = 0
|
|
}
|
|
}
|
|
|
|
export async function cleanupExpiredTencentBrowserSessions({ closeSession }: CloseSessionDependency): Promise<void> {
|
|
const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now())
|
|
|
|
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
|
|
}
|
|
|
|
export async function closeSession(
|
|
session: TencentBrowserSession,
|
|
{
|
|
markClosed,
|
|
persistSessionState,
|
|
closeBrowserIfIdle,
|
|
}: CloseSessionOptions,
|
|
): Promise<void> {
|
|
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: TencentBrowserSession,
|
|
{ buildSessionPayload }: PersistSessionStateOptions,
|
|
): Promise<void> {
|
|
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(): string {
|
|
let sessionId = ''
|
|
|
|
do {
|
|
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
|
|
} while (sessions.has(sessionId))
|
|
|
|
return sessionId
|
|
}
|