|
@@ -0,0 +1,523 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 多平台登录服务 - 抽象基类
|
|
|
|
|
+ * @module services/login/BaseLoginService
|
|
|
|
|
+ *
|
|
|
|
|
+ * 登录流程:
|
|
|
|
|
+ * 1. 打开登录页面,等待用户扫码
|
|
|
|
|
+ * 2. 检测登录成功(URL检测 + AI静默检测)
|
|
|
|
|
+ * 3. 收集完整账号信息(包括跳转作品页获取作品数)
|
|
|
|
|
+ * 4. 所有信息获取完成后,才发送成功事件,前端显示保存按钮
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
|
|
|
+import { EventEmitter } from 'events';
|
|
|
|
|
+import { logger } from '../../utils/logger.js';
|
|
|
|
|
+import { CookieManager } from '../../automation/cookie.js';
|
|
|
|
|
+import { AILoginAssistant, type AILoginMonitorResult } from '../AILoginAssistant.js';
|
|
|
|
|
+import { aiService } from '../../ai/index.js';
|
|
|
|
|
+import type { PlatformType } from '@media-manager/shared';
|
|
|
|
|
+import type {
|
|
|
|
|
+ PlatformLoginConfig,
|
|
|
|
|
+ LoginSession,
|
|
|
|
|
+ LoginSessionStatus,
|
|
|
|
|
+ AccountInfo,
|
|
|
|
|
+ LoginResultEvent,
|
|
|
|
|
+} from './types.js';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * API 拦截配置
|
|
|
|
|
+ */
|
|
|
|
|
+export interface ApiInterceptConfig {
|
|
|
|
|
+ /** URL 匹配模式 */
|
|
|
|
|
+ urlPattern: string | RegExp;
|
|
|
|
|
+ /** 数据存储的 key */
|
|
|
|
|
+ dataKey: string;
|
|
|
|
|
+ /** 可选的数据处理函数 */
|
|
|
|
|
+ handler?: (response: any) => any;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 登录服务抽象基类
|
|
|
|
|
+ */
|
|
|
|
|
+export abstract class BaseLoginService extends EventEmitter {
|
|
|
|
|
+ protected readonly config: PlatformLoginConfig;
|
|
|
|
|
+ protected sessions: Map<string, LoginSession> = new Map();
|
|
|
|
|
+
|
|
|
|
|
+ protected readonly LOGIN_TIMEOUT = 5 * 60 * 1000; // 5分钟
|
|
|
|
|
+ protected readonly CHECK_INTERVAL = 1000; // URL检测间隔
|
|
|
|
|
+ protected readonly AI_CHECK_INTERVAL = 5000; // AI检测间隔
|
|
|
|
|
+
|
|
|
|
|
+ constructor(config: PlatformLoginConfig) {
|
|
|
|
|
+ super();
|
|
|
|
|
+ this.config = config;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get platform(): PlatformType {
|
|
|
|
|
+ return this.config.platform;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get displayName(): string {
|
|
|
|
|
+ return this.config.displayName;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 核心登录流程 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 开始登录会话
|
|
|
|
|
+ */
|
|
|
|
|
+ async startLoginSession(userId?: number): Promise<{ sessionId: string; message: string }> {
|
|
|
|
|
+ const sessionId = `login_${this.config.platform}_${Date.now()}`;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 1. 启动浏览器
|
|
|
|
|
+ const browser = await chromium.launch({
|
|
|
|
|
+ headless: false,
|
|
|
|
|
+ args: ['--disable-blink-features=AutomationControlled', '--window-size=1300,900'],
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const context = await browser.newContext({
|
|
|
|
|
+ viewport: null,
|
|
|
|
|
+ locale: 'zh-CN',
|
|
|
|
|
+ timezoneId: 'Asia/Shanghai',
|
|
|
|
|
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const page = await context.newPage();
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 创建会话
|
|
|
|
|
+ const session: LoginSession = {
|
|
|
|
|
+ id: sessionId,
|
|
|
|
|
+ userId,
|
|
|
|
|
+ platform: this.config.platform,
|
|
|
|
|
+ browser,
|
|
|
|
|
+ context,
|
|
|
|
|
+ page,
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ createdAt: new Date(),
|
|
|
|
|
+ apiData: {},
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ this.sessions.set(sessionId, session);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 设置 API 拦截(在导航前设置)
|
|
|
|
|
+ this.setupApiIntercept(session);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 打开登录页面
|
|
|
|
|
+ logger.info(`[${this.displayName}] 打开登录页面: ${this.config.loginUrl}`);
|
|
|
|
|
+ await page.goto(this.config.loginUrl, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 启动登录状态监控(静默监控,不发送事件给前端)
|
|
|
|
|
+ this.startLoginMonitor(sessionId);
|
|
|
|
|
+
|
|
|
|
|
+ // 6. 设置超时
|
|
|
|
|
+ this.setupTimeout(sessionId);
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`[${this.displayName}] 登录会话已启动: ${sessionId}`);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ message: `已打开${this.displayName}登录页面,请扫码登录`,
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`[${this.displayName}] 启动登录失败:`, error);
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== API 拦截 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取需要拦截的 API 配置(子类重写)
|
|
|
|
|
+ */
|
|
|
|
|
+ protected getApiInterceptConfigs(): ApiInterceptConfig[] {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 设置 API 拦截
|
|
|
|
|
+ */
|
|
|
|
|
+ protected setupApiIntercept(session: LoginSession): void {
|
|
|
|
|
+ const configs = this.getApiInterceptConfigs();
|
|
|
|
|
+ if (configs.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ session.page.on('response', async (response) => {
|
|
|
|
|
+ const url = response.url();
|
|
|
|
|
+
|
|
|
|
|
+ for (const config of configs) {
|
|
|
|
|
+ const matched = typeof config.urlPattern === 'string'
|
|
|
|
|
+ ? url.includes(config.urlPattern)
|
|
|
|
|
+ : config.urlPattern.test(url);
|
|
|
|
|
+
|
|
|
|
|
+ if (matched) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const contentType = response.headers()['content-type'] || '';
|
|
|
|
|
+ if (contentType.includes('application/json')) {
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ const processedData = config.handler ? config.handler(data) : data;
|
|
|
|
|
+
|
|
|
|
|
+ if (!session.apiData) session.apiData = {};
|
|
|
|
|
+ session.apiData[config.dataKey] = processedData;
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`[${this.displayName}] API 拦截成功: ${config.dataKey}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.warn(`[${this.displayName}] API 数据处理失败: ${url}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`[${this.displayName}] 已设置 ${configs.length} 个 API 拦截`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 登录状态监控(静默) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 启动登录监控(静默,不发送事件给前端)
|
|
|
|
|
+ */
|
|
|
|
|
+ protected startLoginMonitor(sessionId: string): void {
|
|
|
|
|
+ // URL 监控
|
|
|
|
|
+ this.startUrlMonitor(sessionId);
|
|
|
|
|
+
|
|
|
|
|
+ // AI 静默监控(如果可用)- 只用于检测登录状态,不发送分析结果
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (session && aiService.isAvailable()) {
|
|
|
|
|
+ this.startAiMonitor(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * URL 监控 - 检测是否跳转到成功页面
|
|
|
|
|
+ */
|
|
|
|
|
+ protected startUrlMonitor(sessionId: string): void {
|
|
|
|
|
+ const checkInterval = setInterval(async () => {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+
|
|
|
|
|
+ // 会话不存在或已处理,停止监控
|
|
|
|
|
+ if (!session || session.status !== 'pending') {
|
|
|
|
|
+ clearInterval(checkInterval);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const currentUrl = session.page.url();
|
|
|
|
|
+
|
|
|
|
|
+ // 检测是否跳转到成功页面
|
|
|
|
|
+ const isSuccess = this.config.successIndicators.some(indicator =>
|
|
|
|
|
+ currentUrl.includes(indicator)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (isSuccess) {
|
|
|
|
|
+ logger.info(`[${this.displayName}] URL 检测到登录成功: ${currentUrl}`);
|
|
|
|
|
+ clearInterval(checkInterval);
|
|
|
|
|
+ await this.handleLoginSuccess(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 浏览器可能已关闭
|
|
|
|
|
+ clearInterval(checkInterval);
|
|
|
|
|
+ this.handleBrowserClosed(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, this.CHECK_INTERVAL);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * AI 静默监控 - 只用于检测登录状态,不发送分析结果给前端
|
|
|
|
|
+ */
|
|
|
|
|
+ protected startAiMonitor(sessionId: string): void {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (!session) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 创建 AI 助手
|
|
|
|
|
+ session.aiAssistant = new AILoginAssistant(session.page, session.platform, session.id);
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`[${this.displayName}] 启动 AI 静默监控`);
|
|
|
|
|
+
|
|
|
|
|
+ session.aiAssistant.startMonitoring(
|
|
|
|
|
+ async (result: AILoginMonitorResult) => {
|
|
|
|
|
+ const currentSession = this.sessions.get(sessionId);
|
|
|
|
|
+ if (!currentSession || currentSession.status !== 'pending') {
|
|
|
|
|
+ currentSession?.aiAssistant?.stopMonitoring();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 【重要】不发送 aiAnalysis 事件给前端,AI 只在后台静默检测
|
|
|
|
|
+
|
|
|
|
|
+ // AI 检测到登录成功
|
|
|
|
|
+ if (result.status === 'logged_in' && result.analysis.isLoggedIn) {
|
|
|
|
|
+ logger.info(`[${this.displayName}] AI 检测到登录成功`);
|
|
|
|
|
+ currentSession.aiAssistant?.stopMonitoring();
|
|
|
|
|
+ await this.handleLoginSuccess(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // AI 检测到需要验证(只记录日志,不通知前端)
|
|
|
|
|
+ if (result.status === 'verification_needed') {
|
|
|
|
|
+ logger.info(`[${this.displayName}] AI 检测到需要验证: ${result.analysis.verificationType}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ this.AI_CHECK_INTERVAL
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 登录成功处理 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 处理登录成功
|
|
|
|
|
+ * 【重要】先收集完所有账号信息,再发送成功事件
|
|
|
|
|
+ */
|
|
|
|
|
+ protected async handleLoginSuccess(sessionId: string): Promise<void> {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (!session || session.status !== 'pending') return;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 停止 AI 监控
|
|
|
|
|
+ session.aiAssistant?.stopMonitoring();
|
|
|
|
|
+
|
|
|
|
|
+ // 更新状态为正在获取信息(但不发送事件给前端)
|
|
|
|
|
+ session.status = 'fetching';
|
|
|
|
|
+ logger.info(`[${this.displayName}] 登录成功,开始收集账号信息...`);
|
|
|
|
|
+
|
|
|
|
|
+ // 等待页面稳定
|
|
|
|
|
+ await this.waitPageStable(session.page);
|
|
|
|
|
+
|
|
|
|
|
+ // 【核心】收集完整账号信息(各平台实现,包括跳转作品页获取作品数)
|
|
|
|
|
+ const accountInfo = await this.collectAccountInfo(session);
|
|
|
|
|
+
|
|
|
|
|
+ if (!accountInfo || !accountInfo.accountId) {
|
|
|
|
|
+ throw new Error('无法获取账号ID');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取并加密 Cookie
|
|
|
|
|
+ const cookies = await session.context.cookies();
|
|
|
|
|
+ const encryptedCookies = CookieManager.encrypt(JSON.stringify(cookies));
|
|
|
|
|
+
|
|
|
|
|
+ // 更新会话
|
|
|
|
|
+ session.status = 'success';
|
|
|
|
|
+ session.cookies = encryptedCookies;
|
|
|
|
|
+ session.accountInfo = accountInfo;
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`[${this.displayName}] 所有账号信息收集完成:`, {
|
|
|
|
|
+ accountId: accountInfo.accountId,
|
|
|
|
|
+ accountName: accountInfo.accountName,
|
|
|
|
|
+ fansCount: accountInfo.fansCount,
|
|
|
|
|
+ worksCount: accountInfo.worksCount,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 【重要】只有在这里才发送成功事件,前端才显示保存按钮
|
|
|
|
|
+ this.emit('loginResult', {
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ userId: session.userId,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ cookies: encryptedCookies,
|
|
|
|
|
+ accountInfo,
|
|
|
|
|
+ rawCookies: cookies,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 注意:不关闭浏览器,让用户确认保存后再关闭
|
|
|
|
|
+ // await this.closeSession(sessionId);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`[${this.displayName}] 收集账号信息失败:`, error);
|
|
|
|
|
+ session.status = 'failed';
|
|
|
|
|
+ session.error = String(error);
|
|
|
|
|
+
|
|
|
|
|
+ this.emit('loginResult', {
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ userId: session.userId,
|
|
|
|
|
+ status: 'failed',
|
|
|
|
|
+ error: String(error),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ await this.closeSession(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 等待页面稳定
|
|
|
|
|
+ */
|
|
|
|
|
+ protected async waitPageStable(page: Page): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 超时继续
|
|
|
|
|
+ }
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 收集账号信息(子类必须实现)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 要求:
|
|
|
|
|
+ * 1. 获取基本信息(头像、昵称、账号ID、粉丝数等)
|
|
|
|
|
+ * 2. 跳转到作品管理页获取作品数
|
|
|
|
|
+ * 3. 返回完整的账号信息
|
|
|
|
|
+ */
|
|
|
|
|
+ protected abstract collectAccountInfo(session: LoginSession): Promise<AccountInfo | null>;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 等待 API 数据
|
|
|
|
|
+ */
|
|
|
|
|
+ protected async waitForApiData(session: LoginSession, dataKey: string, timeout = 10000): Promise<any> {
|
|
|
|
|
+ const start = Date.now();
|
|
|
|
|
+ while (Date.now() - start < timeout) {
|
|
|
|
|
+ if (session.apiData?.[dataKey]) {
|
|
|
|
|
+ return session.apiData[dataKey];
|
|
|
|
|
+ }
|
|
|
|
|
+ await session.page.waitForTimeout(500);
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 导航并等待 API 数据
|
|
|
|
|
+ */
|
|
|
|
|
+ protected async navigateAndWaitForApi(
|
|
|
|
|
+ session: LoginSession,
|
|
|
|
|
+ url: string,
|
|
|
|
|
+ dataKey: string,
|
|
|
|
|
+ timeout = 15000
|
|
|
|
|
+ ): Promise<any> {
|
|
|
|
|
+ // 清除旧数据
|
|
|
|
|
+ if (session.apiData) {
|
|
|
|
|
+ delete session.apiData[dataKey];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await session.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
|
|
|
+ return this.waitForApiData(session, dataKey, timeout);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 工具方法 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析数字字符串(支持 万、亿、w)
|
|
|
|
|
+ */
|
|
|
|
|
+ protected parseNumber(str?: string | number): number {
|
|
|
|
|
+ if (typeof str === 'number') return str;
|
|
|
|
|
+ if (!str) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ const text = String(str);
|
|
|
|
|
+ const match = text.match(/(\d+(?:\.\d+)?)\s*(万|亿|w)?/i);
|
|
|
|
|
+ if (!match) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ let num = parseFloat(match[1]);
|
|
|
|
|
+ if (match[2]) {
|
|
|
|
|
+ if (match[2] === '万' || match[2].toLowerCase() === 'w') num *= 10000;
|
|
|
|
|
+ if (match[2] === '亿') num *= 100000000;
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.floor(num);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 会话管理 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 设置超时
|
|
|
|
|
+ */
|
|
|
|
|
+ protected setupTimeout(sessionId: string): void {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (session && session.status === 'pending') {
|
|
|
|
|
+ session.status = 'timeout';
|
|
|
|
|
+ session.error = '登录超时';
|
|
|
|
|
+
|
|
|
|
|
+ logger.warn(`[${this.displayName}] 登录超时: ${sessionId}`);
|
|
|
|
|
+ this.emit('loginResult', {
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ userId: session.userId,
|
|
|
|
|
+ status: 'timeout',
|
|
|
|
|
+ error: '登录超时',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ this.closeSession(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, this.LOGIN_TIMEOUT);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 处理浏览器关闭
|
|
|
|
|
+ */
|
|
|
|
|
+ protected handleBrowserClosed(sessionId: string): void {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (session && session.status === 'pending') {
|
|
|
|
|
+ session.status = 'failed';
|
|
|
|
|
+ session.error = '浏览器已关闭';
|
|
|
|
|
+
|
|
|
|
|
+ this.emit('loginResult', {
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ userId: session.userId,
|
|
|
|
|
+ status: 'failed',
|
|
|
|
|
+ error: '浏览器已关闭',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取会话状态
|
|
|
|
|
+ */
|
|
|
|
|
+ getSessionStatus(sessionId: string): {
|
|
|
|
|
+ status: LoginSessionStatus;
|
|
|
|
|
+ cookies?: string;
|
|
|
|
|
+ accountInfo?: AccountInfo;
|
|
|
|
|
+ error?: string;
|
|
|
|
|
+ userId?: number;
|
|
|
|
|
+ } | null {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (!session) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: session.status,
|
|
|
|
|
+ cookies: session.cookies,
|
|
|
|
|
+ accountInfo: session.accountInfo,
|
|
|
|
|
+ error: session.error,
|
|
|
|
|
+ userId: session.userId,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取会话用户ID
|
|
|
|
|
+ */
|
|
|
|
|
+ getSessionUserId(sessionId: string): number | undefined {
|
|
|
|
|
+ return this.sessions.get(sessionId)?.userId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 取消会话
|
|
|
|
|
+ */
|
|
|
|
|
+ async cancelSession(sessionId: string): Promise<void> {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (!session) return;
|
|
|
|
|
+
|
|
|
|
|
+ session.status = 'cancelled';
|
|
|
|
|
+ await this.closeSession(sessionId);
|
|
|
|
|
+ logger.info(`[${this.displayName}] 会话已取消: ${sessionId}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 关闭会话(用户确认保存后调用)
|
|
|
|
|
+ */
|
|
|
|
|
+ async closeSession(sessionId: string): Promise<void> {
|
|
|
|
|
+ const session = this.sessions.get(sessionId);
|
|
|
|
|
+ if (!session) return;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ session.aiAssistant?.stopMonitoring();
|
|
|
|
|
+ await session.aiAssistant?.destroy().catch(() => {});
|
|
|
|
|
+ await session.page?.close().catch(() => {});
|
|
|
|
|
+ await session.context?.close().catch(() => {});
|
|
|
|
|
+ await session.browser?.close().catch(() => {});
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`[${this.displayName}] 关闭会话失败:`, error);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 延迟删除会话(方便查询状态)
|
|
|
|
|
+ setTimeout(() => this.sessions.delete(sessionId), 60000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 清理所有会话
|
|
|
|
|
+ */
|
|
|
|
|
+ async cleanup(): Promise<void> {
|
|
|
|
|
+ for (const sessionId of this.sessions.keys()) {
|
|
|
|
|
+ await this.closeSession(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ this.sessions.clear();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|