瀏覽代碼

fix登录

Ethanfly 5 小時之前
父節點
當前提交
06624aef23

+ 20 - 0
client/dist-electron/main.js

@@ -140,6 +140,21 @@ function setupWebviewSessions() {
       contents.setUserAgent(
         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
       );
+      contents.on("will-navigate", (event, url) => {
+        if (!isAllowedUrl(url)) {
+          console.log("[WebView] 阻止导航到自定义协议:", url);
+          event.preventDefault();
+        }
+      });
+      contents.setWindowOpenHandler(({ url }) => {
+        if (!isAllowedUrl(url)) {
+          console.log("[WebView] 阻止打开自定义协议窗口:", url);
+          return { action: "deny" };
+        }
+        console.log("[WebView] 拦截新窗口,在当前页面打开:", url);
+        contents.loadURL(url);
+        return { action: "deny" };
+      });
       contents.session.setPermissionRequestHandler((_webContents, permission, callback) => {
         callback(true);
       });
@@ -151,6 +166,11 @@ function setupWebviewSessions() {
     }
   });
 }
+function isAllowedUrl(url) {
+  if (!url) return false;
+  const lowerUrl = url.toLowerCase();
+  return lowerUrl.startsWith("http://") || lowerUrl.startsWith("https://") || lowerUrl.startsWith("about:") || lowerUrl.startsWith("data:");
+}
 app.on("window-all-closed", () => {
 });
 app.on("before-quit", () => {

File diff suppressed because it is too large
+ 0 - 0
client/dist-electron/main.js.map


+ 30 - 0
client/electron/main.ts

@@ -188,6 +188,26 @@ function setupWebviewSessions() {
         'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
       );
 
+      // 拦截自定义协议链接(如 bitbrowser://)的导航
+      contents.on('will-navigate', (event: Event, url: string) => {
+        if (!isAllowedUrl(url)) {
+          console.log('[WebView] 阻止导航到自定义协议:', url);
+          event.preventDefault();
+        }
+      });
+
+      // 拦截新窗口打开(包括自定义协议)
+      contents.setWindowOpenHandler(({ url }: { url: string }) => {
+        if (!isAllowedUrl(url)) {
+          console.log('[WebView] 阻止打开自定义协议窗口:', url);
+          return { action: 'deny' };
+        }
+        // 对于正常的 http/https 链接,在当前 webview 中打开
+        console.log('[WebView] 拦截新窗口,在当前页面打开:', url);
+        contents.loadURL(url);
+        return { action: 'deny' };
+      });
+
       // 允许所有的权限请求(如摄像头、地理位置等)
       contents.session.setPermissionRequestHandler((_webContents: unknown, permission: string, callback: (granted: boolean) => void) => {
         // 允许所有权限请求
@@ -210,6 +230,16 @@ function setupWebviewSessions() {
   });
 }
 
+// 检查 URL 是否是允许的协议
+function isAllowedUrl(url: string): boolean {
+  if (!url) return false;
+  const lowerUrl = url.toLowerCase();
+  return lowerUrl.startsWith('http://') || 
+         lowerUrl.startsWith('https://') || 
+         lowerUrl.startsWith('about:') ||
+         lowerUrl.startsWith('data:');
+}
+
 // 阻止默认的 window-all-closed 行为,保持托盘运行
 app.on('window-all-closed', () => {
   // 不退出应用,保持托盘运行

File diff suppressed because it is too large
+ 1104 - 88
client/src/components/BrowserTab.vue


+ 108 - 0
server/src/ai/index.ts

@@ -1070,6 +1070,114 @@ ${platformHint}
   }
 
   /**
+   * 从页面截图和 HTML 中提取账号信息(增强版)
+   * @param imageBase64 页面截图的 Base64 编码
+   * @param html 页面 HTML 内容
+   * @param platform 平台名称
+   * @returns 账号信息提取结果
+   */
+  async extractAccountInfoWithHtml(
+    imageBase64: string,
+    html: string,
+    platform: string
+  ): Promise<AccountInfoExtraction> {
+    // 截取 HTML 的关键部分(避免太长)
+    const maxHtmlLength = 8000;
+    let htmlSnippet = html;
+    if (html.length > maxHtmlLength) {
+      // 提取可能包含账号信息的部分
+      const patterns = [
+        /视频号ID[::]\s*([a-zA-Z0-9_]+)/,
+        /finder-uniq-id[^>]*>([^<]+)/,
+        /data-clipboard-text="([^"]+)"/,
+        /class="[^"]*nickname[^"]*"[^>]*>([^<]+)/,
+        /class="[^"]*avatar[^"]*"/,
+      ];
+      
+      // 找到包含关键信息的片段
+      let relevantParts: string[] = [];
+      for (const pattern of patterns) {
+        const match = html.match(pattern);
+        if (match) {
+          const index = match.index || 0;
+          const start = Math.max(0, index - 200);
+          const end = Math.min(html.length, index + 500);
+          relevantParts.push(html.substring(start, end));
+        }
+      }
+      
+      if (relevantParts.length > 0) {
+        htmlSnippet = relevantParts.join('\n...\n');
+      } else {
+        htmlSnippet = html.substring(0, maxHtmlLength);
+      }
+    }
+
+    const prompt = `请分析以下${platform}平台的网页截图和 HTML 代码,提取账号信息。
+
+【HTML 代码片段】
+\`\`\`html
+${htmlSnippet}
+\`\`\`
+
+【提取目标】
+1. **账号ID**(最重要!):
+   - 视频号:查找 "视频号ID:xxx" 或 HTML 中的 data-clipboard-text 属性、finder-uniq-id 元素
+   - 示例:sphjl99GV2W1GgN(这种字母数字组合就是视频号ID)
+2. 账号名称/昵称
+3. 粉丝数量(视频号显示为"关注者 X")
+4. 作品数量(视频号显示为"视频 X")
+
+【特别注意】
+- 视频号ID 通常是一串字母数字组合,如 "sphjl99GV2W1GgN"
+- 在 HTML 中可能出现在 data-clipboard-text 属性中
+- 或者在 class 为 finder-uniq-id 的元素内
+
+请严格按照以下 JSON 格式返回:
+{
+  "found": true,
+  "accountId": "视频号ID(如 sphjl99GV2W1GgN,不要加前缀)",
+  "accountName": "账号名称",
+  "fansCount": "粉丝数(纯数字)",
+  "worksCount": "作品数(纯数字)"
+}`;
+
+    try {
+      const response = await this.analyzeImage({
+        imageBase64,
+        prompt,
+        maxTokens: 500,
+      });
+
+      logger.info(`[AIService] extractAccountInfoWithHtml response:`, response);
+
+      const jsonMatch = response.match(/\{[\s\S]*\}/);
+      if (jsonMatch) {
+        try {
+          const result = JSON.parse(jsonMatch[0]);
+          return {
+            found: Boolean(result.found),
+            accountName: result.accountName || undefined,
+            accountId: result.accountId || undefined,
+            avatarDescription: result.avatarDescription || undefined,
+            fansCount: result.fansCount || undefined,
+            worksCount: result.worksCount || undefined,
+            otherInfo: result.otherInfo || undefined,
+            navigationGuide: result.navigationGuide || undefined,
+          };
+        } catch (parseError) {
+          logger.error('extractAccountInfoWithHtml JSON parse error:', parseError);
+        }
+      }
+
+      return { found: false };
+    } catch (error) {
+      logger.error('extractAccountInfoWithHtml error:', error);
+      return { found: false };
+    }
+  }
+
+  /**
    * 获取页面操作指导
    * @param imageBase64 页面截图的 Base64 编码
    * @param platform 平台名称

+ 7 - 62
server/src/app.ts

@@ -17,7 +17,7 @@ import { logger } from './utils/logger.js';
 import { taskScheduler } from './scheduler/index.js';
 import { registerTaskExecutors } from './services/taskExecutors.js';
 import { taskQueueService } from './services/TaskQueueService.js';
-import { browserLoginService } from './services/BrowserLoginService.js';
+import { loginServiceManager } from './services/login/index.js';
 import { wsManager } from './websocket/index.js';
 
 const execAsync = promisify(exec);
@@ -244,67 +244,12 @@ async function bootstrap() {
 }
 
 /**
- * 设置浏览器登录服务的事件监听
- * 用于通过 WebSocket 推送 AI 分析结果给前端
+ * 设置登录服务的事件监听
+ * 只监听登录结果事件,在所有账号信息收集完成后才推送给前端
  */
 function setupBrowserLoginEvents(): void {
-  // AI 分析结果事件
-  browserLoginService.on('aiAnalysis', (data: {
-    sessionId: string;
-    userId?: number;
-    status: string;
-    analysis: {
-      isLoggedIn: boolean;
-      hasVerification: boolean;
-      verificationType?: string;
-      verificationDescription?: string;
-      pageDescription: string;
-      suggestedAction?: string;
-    };
-  }) => {
-    if (data.userId) {
-      wsManager.sendToUser(data.userId, 'login:aiAnalysis', {
-        sessionId: data.sessionId,
-        status: data.status,
-        analysis: data.analysis,
-      });
-    }
-  });
-
-  // 验证码检测事件
-  browserLoginService.on('verificationNeeded', (data: {
-    sessionId: string;
-    userId?: number;
-    verificationType?: string;
-    description?: string;
-    suggestedAction?: string;
-  }) => {
-    if (data.userId) {
-      wsManager.sendToUser(data.userId, 'login:verificationNeeded', {
-        sessionId: data.sessionId,
-        verificationType: data.verificationType,
-        description: data.description,
-        suggestedAction: data.suggestedAction,
-      });
-    }
-  });
-
-  // 导航建议事件
-  browserLoginService.on('navigationSuggestion', (data: {
-    sessionId: string;
-    userId?: number;
-    guide: unknown;
-  }) => {
-    if (data.userId) {
-      wsManager.sendToUser(data.userId, 'login:navigationSuggestion', {
-        sessionId: data.sessionId,
-        guide: data.guide,
-      });
-    }
-  });
-
-  // 登录结果事件(也通过 WebSocket 推送)
-  browserLoginService.on('loginResult', (data: {
+  // 登录结果事件 - 只有在所有账号信息收集完成后才会触发
+  loginServiceManager.on('loginResult', (data: {
     sessionId: string;
     userId?: number;
     status: string;
@@ -313,7 +258,7 @@ function setupBrowserLoginEvents(): void {
     error?: string;
     message?: string;
   }) => {
-    logger.info(`[BrowserLogin] Login result event: ${data.sessionId}, status: ${data.status}`);
+    logger.info(`[LoginService] 登录结果: ${data.sessionId}, 状态: ${data.status}`);
     if (data.userId) {
       wsManager.sendToUser(data.userId, 'login:result', {
         sessionId: data.sessionId,
@@ -325,7 +270,7 @@ function setupBrowserLoginEvents(): void {
     }
   });
 
-  logger.info('Browser login events registered');
+  logger.info('登录服务事件监听已注册');
 }
 
 // 优雅关闭

+ 5 - 5
server/src/routes/accounts.ts

@@ -1,7 +1,7 @@
 import { Router } from 'express';
 import { body, param, query } from 'express-validator';
 import { AccountService } from '../services/AccountService.js';
-import { browserLoginService } from '../services/BrowserLoginService.js';
+import { loginServiceManager } from '../services/login/index.js';
 import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
@@ -262,7 +262,7 @@ router.post(
   asyncHandler(async (req, res) => {
     const { platform } = req.body;
     const userId = req.user!.userId;
-    const result = await browserLoginService.startLoginSession(platform as PlatformType, userId);
+    const result = await loginServiceManager.startLoginSession(platform as PlatformType, userId);
     res.json({ success: true, data: result });
   })
 );
@@ -276,7 +276,7 @@ router.get(
   ],
   asyncHandler(async (req, res) => {
     const { sessionId } = req.params;
-    const status = browserLoginService.getSessionStatus(sessionId);
+    const status = loginServiceManager.getSessionStatus(sessionId);
     
     if (!status) {
       res.status(404).json({ success: false, error: { message: '会话不存在或已过期' } });
@@ -296,7 +296,7 @@ router.delete(
   ],
   asyncHandler(async (req, res) => {
     const { sessionId } = req.params;
-    await browserLoginService.cancelSession(sessionId);
+    await loginServiceManager.cancelSession(sessionId);
     res.json({ success: true, message: '会话已取消' });
   })
 );
@@ -313,7 +313,7 @@ router.post(
     const { sessionId } = req.params;
     const { platform, groupId } = req.body;
     
-    const status = browserLoginService.getSessionStatus(sessionId);
+    const status = loginServiceManager.getSessionStatus(sessionId);
     
     if (!status || status.status !== 'success' || !status.cookies) {
       res.status(400).json({ 

+ 43 - 18
server/src/services/AccountService.ts

@@ -169,9 +169,10 @@ export class AccountService {
     
     // 某些平台应优先使用 API 返回的真实 ID,而不是 Cookie 中的值
     // - 抖音:使用抖音号(unique_id,如 Ethanfly9392),而不是 Cookie 中的 passport_uid
+    // - 小红书:使用小红书号(red_num),而不是 Cookie 中的 userid
     // - 百家号:使用 new_uc_id,而不是 Cookie 中的 BDUSS
     // - 视频号/头条:使用 API 返回的真实账号 ID
-    const platformsPreferApiId: PlatformType[] = ['douyin', 'baijiahao', 'weixin_video', 'toutiao'];
+    const platformsPreferApiId: PlatformType[] = ['douyin', 'xiaohongshu', 'baijiahao', 'weixin_video', 'toutiao'];
     const preferApiId = platformsPreferApiId.includes(platform);
     
     // 确定最终的 accountId
@@ -881,45 +882,69 @@ export class AccountService {
   }
 
   /**
-   * 标准化 accountId 格式,确保带有平台前缀
-   * 例如:1833101008440434 -> baijiahao_1833101008440434
+   * 平台短前缀映射(统一使用短前缀)
+   */
+  private static readonly SHORT_PREFIX_MAP: Record<PlatformType, string> = {
+    'douyin': 'dy_',
+    'xiaohongshu': 'xhs_',
+    'weixin_video': 'sph_',
+    'baijiahao': 'bjh_',
+    'kuaishou': 'ks_',
+    'bilibili': 'bili_',
+    'toutiao': 'tt_',
+    'qie': 'qie_',
+    'dayuhao': 'dyh_',
+  };
+
+  /**
+   * 标准化 accountId 格式,统一使用短前缀
+   * 例如:Ethanfly9392 -> dy_Ethanfly9392
    */
   private normalizeAccountId(platform: PlatformType, accountId: string): string {
+    const shortPrefix = AccountService.SHORT_PREFIX_MAP[platform] || `${platform}_`;
+    
     if (!accountId) {
-      return `${platform}_${Date.now()}`;
+      return `${shortPrefix}${Date.now()}`;
     }
     
-    // 如果已经有平台前缀,直接返回
-    if (accountId.startsWith(`${platform}_`)) {
+    // 如果已经有正确的短前缀,直接返回
+    if (accountId.startsWith(shortPrefix)) {
       return accountId;
     }
     
-    // 如果有其他平台前缀(可能是错误的),替换为正确的前缀
-    const platformPrefixPattern = /^(douyin|kuaishou|xiaohongshu|weixin_video|bilibili|toutiao|baijiahao|qie|dayuhao)_/;
-    if (platformPrefixPattern.test(accountId)) {
-      return accountId.replace(platformPrefixPattern, `${platform}_`);
+    // 移除任何已有的前缀(短前缀或完整前缀)
+    const allShortPrefixes = Object.values(AccountService.SHORT_PREFIX_MAP);
+    const allFullPrefixes = Object.keys(AccountService.SHORT_PREFIX_MAP).map(p => `${p}_`);
+    const allPrefixes = [...allShortPrefixes, ...allFullPrefixes];
+    
+    let cleanId = accountId;
+    for (const prefix of allPrefixes) {
+      if (cleanId.startsWith(prefix)) {
+        cleanId = cleanId.slice(prefix.length);
+        break;
+      }
     }
     
-    // 没有前缀,添加平台前缀
-    return `${platform}_${accountId}`;
+    // 添加正确的短前缀
+    return `${shortPrefix}${cleanId}`;
   }
 
   /**
    * 检查 accountId 是否是基于时间戳生成的(不可靠的 ID)
-   * 时间戳 ID 格式通常是:platform_1737619200000
+   * 时间戳 ID 格式通常是:dy_1737619200000 或 douyin_1737619200000
    */
   private isTimestampBasedId(accountId: string): boolean {
     if (!accountId) return true;
     
-    // 检查是否匹配 platform_时间戳 格式
-    const timestampPattern = /^[a-z_]+_\d{13,}$/;
-    if (!timestampPattern.test(accountId)) {
+    // 检查是否匹配 前缀_时间戳 格式(支持短前缀和完整前缀)
+    const timestampPattern = /^[a-z_]+_(\d{13,})$/;
+    const match = accountId.match(timestampPattern);
+    if (!match) {
       return false;
     }
     
     // 提取数字部分,检查是否是合理的时间戳(2020年到2030年之间)
-    const match = accountId.match(/_(\d{13,})$/);
-    if (match) {
+    if (match[1]) {
       const timestamp = parseInt(match[1]);
       const minTimestamp = new Date('2020-01-01').getTime(); // 1577836800000
       const maxTimestamp = new Date('2030-01-01').getTime(); // 1893456000000

File diff suppressed because it is too large
+ 835 - 200
server/src/services/BrowserLoginService.ts


+ 122 - 0
server/src/services/login/BaijiahaoLoginService.ts

@@ -0,0 +1,122 @@
+/**
+ * 百家号登录服务
+ * @module services/login/BaijiahaoLoginService
+ * 
+ * 登录流程(按用户要求):
+ * 1. 打开 https://baijiahao.baidu.com/builder/theme/bjh/login
+ * 2. 等待用户扫码登录,跳转到 /builder/rc/home 或 AI 识别已登录
+ * 3. 监听 API /builder/app/appinfo 获取:头像(user.avatar)、昵称(user.name)、百家号ID(user.app_id)
+ * 4. 监听 API /cms-ui/rights/growth/get_info 获取:粉丝数(total_fans)
+ * 5. 跳转到 https://baijiahao.baidu.com/builder/rc/content 作品管理页
+ * 6. 监听 API /pcui/article/lists 获取作品列表,取 list 数量作为作品数
+ * 7. 完成后发送事件,账号ID使用 bjh_ 前缀
+ */
+
+import { logger } from '../../utils/logger.js';
+import { BaseLoginService, type ApiInterceptConfig } from './BaseLoginService.js';
+import type { AccountInfo, LoginSession } from './types.js';
+
+export class BaijiahaoLoginService extends BaseLoginService {
+  constructor() {
+    super({
+      platform: 'baijiahao',
+      displayName: '百家号',
+      loginUrl: 'https://baijiahao.baidu.com/builder/theme/bjh/login',
+      successIndicators: ['/builder/rc/home', 'baijiahao.baidu.com/builder/rc'],
+      cookieDomain: '.baidu.com',
+      accountIdPrefix: 'bjh_',
+    });
+  }
+
+  /**
+   * API 拦截配置
+   */
+  protected override getApiInterceptConfigs(): ApiInterceptConfig[] {
+    return [
+      // 应用信息接口 - 获取头像、昵称、app_id
+      {
+        urlPattern: '/builder/app/appinfo',
+        dataKey: 'appInfo',
+        handler: (data: any) => {
+          const user = data.data?.user || data.user || {};
+          return {
+            appId: user.app_id,
+            name: user.name,
+            avatar: user.avatar?.startsWith('http') ? user.avatar : `https:${user.avatar}`,
+            userId: user.userid,
+          };
+        },
+      },
+      // 成长信息接口 - 获取粉丝数
+      {
+        urlPattern: '/cms-ui/rights/growth/get_info',
+        dataKey: 'growthInfo',
+        handler: (data: any) => ({
+          totalFans: data.data?.total_fans || data.total_fans || 0,
+        }),
+      },
+      // 文章列表接口 - 获取作品数
+      {
+        urlPattern: '/pcui/article/lists',
+        dataKey: 'articleList',
+        handler: (data: any) => {
+          const list = data.data?.list || data.list || [];
+          return { list, count: list.length };
+        },
+      },
+    ];
+  }
+
+  /**
+   * 收集账号信息
+   */
+  protected override async collectAccountInfo(session: LoginSession): Promise<AccountInfo | null> {
+    try {
+      // 步骤3: 等待 appinfo API
+      logger.info('[百家号] 等待 appinfo API...');
+      let appInfo = await this.waitForApiData(session, 'appInfo', 10000);
+
+      if (!appInfo) {
+        logger.info('[百家号] 未拿到 appinfo,刷新页面重试...');
+        await session.page.reload({ waitUntil: 'domcontentloaded' });
+        appInfo = await this.waitForApiData(session, 'appInfo', 10000);
+      }
+
+      if (!appInfo?.appId) {
+        logger.error('[百家号] 无法获取百家号ID');
+        return null;
+      }
+
+      logger.info('[百家号] 基本信息:', appInfo);
+
+      // 步骤4: 等待 growth 信息(可能首页已触发)
+      logger.info('[百家号] 等待粉丝数据...');
+      const growthInfo = await this.waitForApiData(session, 'growthInfo', 5000);
+      const fansCount = growthInfo?.totalFans || 0;
+      logger.info(`[百家号] 粉丝数: ${fansCount}`);
+
+      // 步骤5+6: 跳转到作品管理页,等待文章列表 API
+      logger.info('[百家号] 跳转到作品管理页...');
+      const contentUrl = 'https://baijiahao.baidu.com/builder/rc/content';
+      const articleData = await this.navigateAndWaitForApi(session, contentUrl, 'articleList', 15000);
+
+      const worksCount = articleData?.count || 0;
+      logger.info(`[百家号] 作品数: ${worksCount}`);
+
+      // 步骤7: 组装账号信息,使用 bjh_ 前缀
+      return {
+        accountId: `bjh_${appInfo.appId}`,
+        accountName: appInfo.name || '百家号用户',
+        avatarUrl: appInfo.avatar || '',
+        fansCount,
+        worksCount,
+      };
+    } catch (error) {
+      logger.error('[百家号] 收集账号信息失败:', error);
+      return null;
+    }
+  }
+}
+
+// 导出单例
+export const baijiahaoLoginService = new BaijiahaoLoginService();

+ 523 - 0
server/src/services/login/BaseLoginService.ts

@@ -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();
+  }
+}

+ 167 - 0
server/src/services/login/DouyinLoginService.ts

@@ -0,0 +1,167 @@
+/**
+ * 抖音登录服务
+ * @module services/login/DouyinLoginService
+ * 
+ * 登录流程(按用户要求):
+ * 1. 打开 https://creator.douyin.com/
+ * 2. 等待用户扫码登录,跳转到 /creator-micro/home 或 AI 识别已登录
+ * 3. 从页面 HTML 提取:头像、昵称、抖音号、粉丝数、关注数、获赞数
+ * 4. 跳转到 https://creator.douyin.com/creator-micro/content/manage 作品管理页
+ * 5. 监听 API /janus/douyin/creator/pc/work_list 获取作品列表,取 items 数量作为作品数
+ * 6. 完成后发送事件,账号ID使用 dy_ 前缀
+ */
+
+import type { Page } from 'playwright';
+import { logger } from '../../utils/logger.js';
+import { BaseLoginService, type ApiInterceptConfig } from './BaseLoginService.js';
+import type { AccountInfo, LoginSession } from './types.js';
+
+export class DouyinLoginService extends BaseLoginService {
+  constructor() {
+    super({
+      platform: 'douyin',
+      displayName: '抖音',
+      loginUrl: 'https://creator.douyin.com/',
+      successIndicators: ['/creator-micro/home', 'creator.douyin.com/creator-micro'],
+      cookieDomain: '.douyin.com',
+      accountIdPrefix: 'dy_',
+    });
+  }
+
+  /**
+   * API 拦截配置 - 监听作品列表接口
+   */
+  protected override getApiInterceptConfigs(): ApiInterceptConfig[] {
+    return [
+      {
+        urlPattern: '/janus/douyin/creator/pc/work_list',
+        dataKey: 'workList',
+        handler: (data: any) => {
+          // 取 items 或 aweme_list 的数量作为作品数(不用 total)
+          const items = data.items || data.aweme_list || [];
+          return { items, count: items.length };
+        },
+      },
+    ];
+  }
+
+  /**
+   * 收集账号信息
+   */
+  protected override async collectAccountInfo(session: LoginSession): Promise<AccountInfo | null> {
+    try {
+      // 步骤3: 从首页 HTML 提取账号基本信息
+      logger.info('[抖音] 提取页面账号信息...');
+      const basicInfo = await this.extractInfoFromPage(session.page);
+
+      if (!basicInfo.douyinId) {
+        logger.error('[抖音] 无法获取抖音号');
+        return null;
+      }
+
+      logger.info('[抖音] 基本信息:', basicInfo);
+
+      // 步骤4+5: 跳转到作品管理页,等待 API 返回作品列表
+      logger.info('[抖音] 跳转到作品管理页...');
+      const worksUrl = 'https://creator.douyin.com/creator-micro/content/manage';
+      const workListData = await this.navigateAndWaitForApi(session, worksUrl, 'workList', 15000);
+
+      const worksCount = workListData?.count || 0;
+      logger.info(`[抖音] 作品数: ${worksCount}`);
+
+      // 步骤6: 组装账号信息,使用 dy_ 前缀
+      return {
+        accountId: `dy_${basicInfo.douyinId}`,
+        accountName: basicInfo.nickname || '抖音用户',
+        avatarUrl: basicInfo.avatar || '',
+        fansCount: basicInfo.fansCount || 0,
+        worksCount,
+        followingCount: basicInfo.followingCount,
+        likeCount: basicInfo.likeCount,
+      };
+    } catch (error) {
+      logger.error('[抖音] 收集账号信息失败:', error);
+      return null;
+    }
+  }
+
+  /**
+   * 从页面 HTML 提取账号信息
+   * 
+   * 页面 HTML 结构:
+   * <div class="container-vEyGlK">
+   *   <div class="avatar-XoPjK6"><img src="头像URL"></div>
+   *   <div class="name-_lSSDc">昵称</div>
+   *   <div class="unique_id-EuH8eA">抖音号:xxxxx</div>
+   *   <div id="guide_home_following">关注 <span class="number-No6ev9">612</span></div>
+   *   <div id="guide_home_fans">粉丝 <span class="number-No6ev9">64</span></div>
+   *   <div class="statics-item-MDWoNA">获赞 <span class="number-No6ev9">3</span></div>
+   * </div>
+   */
+  private async extractInfoFromPage(page: Page): Promise<{
+    douyinId?: string;
+    nickname?: string;
+    avatar?: string;
+    fansCount?: number;
+    followingCount?: number;
+    likeCount?: number;
+  }> {
+    return page.evaluate(() => {
+      const result: any = {};
+
+      // 解析数字(支持万、亿)
+      const parseNum = (text: string): number => {
+        const match = text.match(/(\d+(?:\.\d+)?)\s*(万|亿|w)?/i);
+        if (!match) return 0;
+        let num = parseFloat(match[1]);
+        if (match[2] === '万' || match[2]?.toLowerCase() === 'w') num *= 10000;
+        if (match[2] === '亿') num *= 100000000;
+        return Math.floor(num);
+      };
+
+      // 头像
+      const avatarImg = document.querySelector('.avatar-XoPjK6 img, [class*="avatar"] img') as HTMLImageElement;
+      if (avatarImg?.src) result.avatar = avatarImg.src;
+
+      // 昵称
+      const nameEl = document.querySelector('.name-_lSSDc, [class*="name-"]');
+      if (nameEl) result.nickname = nameEl.textContent?.trim();
+
+      // 抖音号
+      const idEl = document.querySelector('.unique_id-EuH8eA, [class*="unique_id"]');
+      if (idEl) {
+        const text = idEl.textContent || '';
+        const match = text.match(/抖音号[::]\s*(\S+)/);
+        if (match) result.douyinId = match[1];
+      }
+
+      // 关注数
+      const followingEl = document.querySelector('#guide_home_following');
+      if (followingEl) {
+        const numEl = followingEl.querySelector('[class*="number"]');
+        if (numEl) result.followingCount = parseNum(numEl.textContent || '0');
+      }
+
+      // 粉丝数
+      const fansEl = document.querySelector('#guide_home_fans');
+      if (fansEl) {
+        const numEl = fansEl.querySelector('[class*="number"]');
+        if (numEl) result.fansCount = parseNum(numEl.textContent || '0');
+      }
+
+      // 获赞数
+      const statsItems = document.querySelectorAll('.statics-item-MDWoNA, [class*="statics-item"]');
+      statsItems.forEach(item => {
+        if (item.textContent?.includes('获赞')) {
+          const numEl = item.querySelector('[class*="number"]');
+          if (numEl) result.likeCount = parseNum(numEl.textContent || '0');
+        }
+      });
+
+      return result;
+    });
+  }
+}
+
+// 导出单例
+export const douyinLoginService = new DouyinLoginService();

+ 235 - 0
server/src/services/login/LoginServiceManager.ts

@@ -0,0 +1,235 @@
+/**
+ * 登录服务管理器
+ * @module services/login/LoginServiceManager
+ * 
+ * 统一管理和调度各平台的登录服务
+ */
+
+import { EventEmitter } from 'events';
+import { logger } from '../../utils/logger.js';
+import type { PlatformType } from '@media-manager/shared';
+import type { BaseLoginService } from './BaseLoginService.js';
+import type { LoginResultEvent, AIAnalysisEvent, VerificationNeededEvent, AccountInfo, LoginSessionStatus } from './types.js';
+
+// 导入各平台登录服务
+import { douyinLoginService } from './DouyinLoginService.js';
+import { xiaohongshuLoginService } from './XiaohongshuLoginService.js';
+import { weixinVideoLoginService } from './WeixinVideoLoginService.js';
+import { baijiahaoLoginService } from './BaijiahaoLoginService.js';
+
+/**
+ * 支持的平台类型(用于登录)
+ */
+export type SupportedLoginPlatform = 'douyin' | 'xiaohongshu' | 'weixin' | 'weixin_video' | 'baijiahao';
+
+/**
+ * 登录服务管理器
+ * 
+ * @example
+ * ```typescript
+ * import { loginServiceManager } from './services/login';
+ * 
+ * // 启动登录会话
+ * const { sessionId, message } = await loginServiceManager.startLoginSession('douyin', userId);
+ * 
+ * // 监听登录结果
+ * loginServiceManager.on('loginResult', (event) => {
+ *   console.log('登录结果:', event);
+ *   // event.accountInfo 包含完整的账号信息
+ *   // accountId 格式: dy_xxx, xhs_xxx, sph_xxx, bjh_xxx
+ * });
+ * 
+ * // 取消会话
+ * await loginServiceManager.cancelSession(sessionId);
+ * ```
+ */
+export class LoginServiceManager extends EventEmitter {
+  /** 平台服务映射 */
+  private readonly services: Map<string, BaseLoginService>;
+
+  constructor() {
+    super();
+
+    // 注册各平台服务
+    this.services = new Map([
+      ['douyin', douyinLoginService],
+      ['xiaohongshu', xiaohongshuLoginService],
+      ['weixin', weixinVideoLoginService],
+      ['weixin_video', weixinVideoLoginService],
+      ['baijiahao', baijiahaoLoginService],
+    ]);
+
+    // 转发各服务的事件
+    this.setupEventForwarding();
+
+    logger.info('[LoginManager] 已初始化,支持平台:', this.getSupportedPlatforms());
+  }
+
+  /**
+   * 设置事件转发
+   */
+  private setupEventForwarding(): void {
+    const processedServices = new Set<BaseLoginService>();
+
+    for (const [, service] of this.services) {
+      // 避免重复注册(weixin 和 weixin_video 共用同一个服务)
+      if (processedServices.has(service)) continue;
+      processedServices.add(service);
+
+      // 转发登录结果事件
+      service.on('loginResult', (event: LoginResultEvent) => {
+        this.emit('loginResult', event);
+      });
+
+      // 转发 AI 分析事件
+      service.on('aiAnalysis', (event: AIAnalysisEvent) => {
+        this.emit('aiAnalysis', event);
+      });
+
+      // 转发验证需求事件
+      service.on('verificationNeeded', (event: VerificationNeededEvent) => {
+        this.emit('verificationNeeded', event);
+      });
+    }
+  }
+
+  /**
+   * 获取平台对应的登录服务
+   */
+  private getService(platform: PlatformType): BaseLoginService | undefined {
+    return this.services.get(platform);
+  }
+
+  /**
+   * 检查平台是否支持登录
+   */
+  isSupportedPlatform(platform: PlatformType): boolean {
+    return this.services.has(platform);
+  }
+
+  /**
+   * 获取支持的平台列表
+   */
+  getSupportedPlatforms(): string[] {
+    // 去重(weixin 和 weixin_video 算同一个)
+    return ['douyin', 'xiaohongshu', 'weixin_video', 'baijiahao'];
+  }
+
+  /**
+   * 获取平台显示名称
+   */
+  getPlatformDisplayName(platform: PlatformType): string {
+    const displayNames: Record<string, string> = {
+      douyin: '抖音',
+      xiaohongshu: '小红书',
+      weixin: '视频号',
+      weixin_video: '视频号',
+      baijiahao: '百家号',
+    };
+    return displayNames[platform] || platform;
+  }
+
+  /**
+   * 开始登录会话
+   * 
+   * @param platform - 平台类型
+   * @param userId - 可选的用户ID,用于 WebSocket 推送状态
+   * @returns 会话信息
+   * @throws Error 如果平台不支持
+   */
+  async startLoginSession(platform: PlatformType, userId?: number): Promise<{ sessionId: string; message: string }> {
+    const service = this.getService(platform);
+    
+    if (!service) {
+      throw new Error(`不支持的平台: ${platform}`);
+    }
+
+    logger.info(`[LoginManager] 启动登录会话: ${platform}, userId: ${userId}`);
+    return service.startLoginSession(userId);
+  }
+
+  /**
+   * 获取会话状态
+   */
+  getSessionStatus(sessionId: string): {
+    status: LoginSessionStatus;
+    cookies?: string;
+    accountInfo?: AccountInfo;
+    error?: string;
+    userId?: number;
+  } | null {
+    const platform = this.extractPlatformFromSessionId(sessionId);
+    if (!platform) return null;
+
+    const service = this.getService(platform as PlatformType);
+    return service?.getSessionStatus(sessionId) || null;
+  }
+
+  /**
+   * 获取会话的用户ID
+   */
+  getSessionUserId(sessionId: string): number | undefined {
+    const platform = this.extractPlatformFromSessionId(sessionId);
+    if (!platform) return undefined;
+
+    const service = this.getService(platform as PlatformType);
+    return service?.getSessionUserId(sessionId);
+  }
+
+  /**
+   * 取消登录会话
+   */
+  async cancelSession(sessionId: string): Promise<void> {
+    const platform = this.extractPlatformFromSessionId(sessionId);
+    if (!platform) {
+      logger.warn(`[LoginManager] 无法解析会话ID: ${sessionId}`);
+      return;
+    }
+
+    const service = this.getService(platform as PlatformType);
+    if (service) {
+      await service.cancelSession(sessionId);
+    }
+  }
+
+  /**
+   * 从会话ID中提取平台
+   * 会话ID格式: login_{platform}_{timestamp}
+   */
+  private extractPlatformFromSessionId(sessionId: string): string | null {
+    const match = sessionId.match(/^login_([^_]+)_\d+$/);
+    return match ? match[1] : null;
+  }
+
+  /**
+   * 清理指定平台的所有会话
+   */
+  async cleanupPlatform(platform: PlatformType): Promise<void> {
+    const service = this.getService(platform);
+    if (service) {
+      await service.cleanup();
+    }
+  }
+
+  /**
+   * 清理所有会话
+   */
+  async cleanup(): Promise<void> {
+    logger.info('[LoginManager] 清理所有登录会话');
+    
+    const cleanupPromises: Promise<void>[] = [];
+    const processedServices = new Set<BaseLoginService>();
+
+    for (const service of this.services.values()) {
+      if (!processedServices.has(service)) {
+        processedServices.add(service);
+        cleanupPromises.push(service.cleanup());
+      }
+    }
+
+    await Promise.all(cleanupPromises);
+  }
+}
+
+// 导出单例
+export const loginServiceManager = new LoginServiceManager();

+ 94 - 0
server/src/services/login/WeixinVideoLoginService.ts

@@ -0,0 +1,94 @@
+/**
+ * 微信视频号登录服务
+ * @module services/login/WeixinVideoLoginService
+ * 
+ * 登录流程(按用户要求):
+ * 1. 打开 https://channels.weixin.qq.com/login.html
+ * 2. 等待用户扫码登录,跳转到 /platform 或 AI 识别已登录
+ * 3. 监听 API /cgi-bin/mmfinderassistant-bin/auth/auth_data 获取:
+ *    - finderUser.headImgUrl (头像)
+ *    - finderUser.nickname (昵称)
+ *    - finderUser.uniqId (视频号ID)
+ *    - finderUser.fansCount (粉丝数)
+ *    - finderUser.feedsCount (作品数)
+ * 4. 完成后发送事件,账号ID使用 sph_ 前缀
+ */
+
+import { logger } from '../../utils/logger.js';
+import { BaseLoginService, type ApiInterceptConfig } from './BaseLoginService.js';
+import type { AccountInfo, LoginSession } from './types.js';
+
+export class WeixinVideoLoginService extends BaseLoginService {
+  constructor() {
+    super({
+      platform: 'weixin_video',
+      displayName: '视频号',
+      loginUrl: 'https://channels.weixin.qq.com/login.html',
+      successIndicators: ['/platform', 'channels.weixin.qq.com/platform'],
+      cookieDomain: '.weixin.qq.com',
+      accountIdPrefix: 'sph_',
+    });
+  }
+
+  /**
+   * API 拦截配置 - 视频号所有信息都在 auth_data 接口
+   */
+  protected override getApiInterceptConfigs(): ApiInterceptConfig[] {
+    return [
+      {
+        urlPattern: '/cgi-bin/mmfinderassistant-bin/auth/auth_data',
+        dataKey: 'authData',
+        handler: (data: any) => {
+          const user = data.data?.finderUser || data.finderUser || {};
+          return {
+            headImgUrl: user.headImgUrl,
+            nickname: user.nickname,
+            uniqId: user.uniqId,
+            fansCount: user.fansCount || 0,
+            feedsCount: user.feedsCount || 0,
+          };
+        },
+      },
+    ];
+  }
+
+  /**
+   * 收集账号信息
+   */
+  protected override async collectAccountInfo(session: LoginSession): Promise<AccountInfo | null> {
+    try {
+      // 步骤3: 等待 auth_data API 数据(包含所有信息)
+      logger.info('[视频号] 等待 auth_data API...');
+      let authData = await this.waitForApiData(session, 'authData', 10000);
+
+      // 如果没拿到,刷新页面重试
+      if (!authData) {
+        logger.info('[视频号] 未拿到数据,刷新页面重试...');
+        await session.page.reload({ waitUntil: 'domcontentloaded' });
+        authData = await this.waitForApiData(session, 'authData', 10000);
+      }
+
+      if (!authData?.uniqId) {
+        logger.error('[视频号] 无法获取视频号ID');
+        return null;
+      }
+
+      logger.info('[视频号] 账号信息:', authData);
+
+      // 步骤4: 组装账号信息,使用 sph_ 前缀
+      return {
+        accountId: `sph_${authData.uniqId}`,
+        accountName: authData.nickname || '视频号用户',
+        avatarUrl: authData.headImgUrl || '',
+        fansCount: authData.fansCount || 0,
+        worksCount: authData.feedsCount || 0,
+      };
+    } catch (error) {
+      logger.error('[视频号] 收集账号信息失败:', error);
+      return null;
+    }
+  }
+}
+
+// 导出单例
+export const weixinVideoLoginService = new WeixinVideoLoginService();

+ 108 - 0
server/src/services/login/XiaohongshuLoginService.ts

@@ -0,0 +1,108 @@
+/**
+ * 小红书登录服务
+ * @module services/login/XiaohongshuLoginService
+ * 
+ * 登录流程(按用户要求):
+ * 1. 打开 https://creator.xiaohongshu.com/login
+ * 2. 等待用户扫码登录,跳转到 /new/home 或 AI 识别已登录
+ * 3. 监听 API /api/galaxy/creator/home/personal_info 获取:头像(avatar)、昵称(name)、小红书号(red_num)、粉丝数(fans_count)
+ * 4. 跳转到 https://creator.xiaohongshu.com/new/note-manager 笔记管理页
+ * 5. 监听 API /web_api/sns/v5/creator/note/user/posted 获取笔记列表,取 notes 数量作为作品数
+ * 6. 完成后发送事件,账号ID使用 xhs_ 前缀
+ */
+
+import { logger } from '../../utils/logger.js';
+import { BaseLoginService, type ApiInterceptConfig } from './BaseLoginService.js';
+import type { AccountInfo, LoginSession } from './types.js';
+
+export class XiaohongshuLoginService extends BaseLoginService {
+  constructor() {
+    super({
+      platform: 'xiaohongshu',
+      displayName: '小红书',
+      loginUrl: 'https://creator.xiaohongshu.com/login',
+      successIndicators: ['/new/home', 'creator.xiaohongshu.com/new'],
+      cookieDomain: '.xiaohongshu.com',
+      accountIdPrefix: 'xhs_',
+    });
+  }
+
+  /**
+   * API 拦截配置
+   */
+  protected override getApiInterceptConfigs(): ApiInterceptConfig[] {
+    return [
+      // 个人信息接口
+      {
+        urlPattern: '/api/galaxy/creator/home/personal_info',
+        dataKey: 'personalInfo',
+        handler: (data: any) => {
+          const info = data.data || data;
+          return {
+            avatar: info.avatar,
+            name: info.name,
+            redNum: info.red_num,
+            fansCount: info.fans_count,
+          };
+        },
+      },
+      // 笔记列表接口
+      {
+        urlPattern: '/web_api/sns/v5/creator/note/user/posted',
+        dataKey: 'noteList',
+        handler: (data: any) => {
+          const notes = data.data?.notes || data.notes || [];
+          return { notes, count: notes.length };
+        },
+      },
+    ];
+  }
+
+  /**
+   * 收集账号信息
+   */
+  protected override async collectAccountInfo(session: LoginSession): Promise<AccountInfo | null> {
+    try {
+      // 步骤3: 等待个人信息 API 数据
+      logger.info('[小红书] 等待个人信息 API...');
+      let personalInfo = await this.waitForApiData(session, 'personalInfo', 10000);
+
+      // 如果没拿到,刷新页面重试
+      if (!personalInfo) {
+        logger.info('[小红书] 未拿到个人信息,刷新页面重试...');
+        await session.page.reload({ waitUntil: 'domcontentloaded' });
+        personalInfo = await this.waitForApiData(session, 'personalInfo', 10000);
+      }
+
+      if (!personalInfo?.redNum) {
+        logger.error('[小红书] 无法获取小红书号');
+        return null;
+      }
+
+      logger.info('[小红书] 个人信息:', personalInfo);
+
+      // 步骤4+5: 跳转到笔记管理页,等待笔记列表 API
+      logger.info('[小红书] 跳转到笔记管理页...');
+      const notesUrl = 'https://creator.xiaohongshu.com/new/note-manager';
+      const noteListData = await this.navigateAndWaitForApi(session, notesUrl, 'noteList', 15000);
+
+      const worksCount = noteListData?.count || 0;
+      logger.info(`[小红书] 笔记数: ${worksCount}`);
+
+      // 步骤6: 组装账号信息,使用 xhs_ 前缀
+      return {
+        accountId: `xhs_${personalInfo.redNum}`,
+        accountName: personalInfo.name || '小红书用户',
+        avatarUrl: personalInfo.avatar || '',
+        fansCount: personalInfo.fansCount || 0,
+        worksCount,
+      };
+    } catch (error) {
+      logger.error('[小红书] 收集账号信息失败:', error);
+      return null;
+    }
+  }
+}
+
+// 导出单例
+export const xiaohongshuLoginService = new XiaohongshuLoginService();

+ 49 - 0
server/src/services/login/index.ts

@@ -0,0 +1,49 @@
+/**
+ * 多平台登录服务模块
+ * @module services/login
+ * 
+ * 支持的平台:
+ * - 抖音 (douyin) - 账号ID前缀: dy_
+ * - 小红书 (xiaohongshu) - 账号ID前缀: xhs_
+ * - 微信视频号 (weixin_video) - 账号ID前缀: sph_
+ * - 百家号 (baijiahao) - 账号ID前缀: bjh_
+ * 
+ * @example
+ * ```typescript
+ * import { loginServiceManager } from './services/login';
+ * 
+ * // 启动登录
+ * const { sessionId } = await loginServiceManager.startLoginSession('douyin', userId);
+ * 
+ * // 监听结果
+ * loginServiceManager.on('loginResult', (event) => {
+ *   if (event.status === 'success') {
+ *     console.log('登录成功:', event.accountInfo);
+ *   }
+ * });
+ * ```
+ */
+
+// 类型导出
+export type {
+  PlatformLoginConfig,
+  LoginSessionStatus,
+  LoginSession,
+  AccountInfo,
+  LoginResultEvent,
+  AIAnalysisEvent,
+  VerificationNeededEvent,
+  LoginServiceEvents,
+} from './types.js';
+
+// 基类导出
+export { BaseLoginService, type ApiInterceptConfig } from './BaseLoginService.js';
+
+// 各平台服务导出
+export { DouyinLoginService, douyinLoginService } from './DouyinLoginService.js';
+export { XiaohongshuLoginService, xiaohongshuLoginService } from './XiaohongshuLoginService.js';
+export { WeixinVideoLoginService, weixinVideoLoginService } from './WeixinVideoLoginService.js';
+export { BaijiahaoLoginService, baijiahaoLoginService } from './BaijiahaoLoginService.js';
+
+// 管理器导出
+export { LoginServiceManager, loginServiceManager, type SupportedLoginPlatform } from './LoginServiceManager.js';

+ 158 - 0
server/src/services/login/types.ts

@@ -0,0 +1,158 @@
+/**
+ * 多平台登录服务 - 类型定义
+ * @module services/login/types
+ */
+
+import type { Browser, BrowserContext, Page } from 'playwright';
+import type { PlatformType } from '@media-manager/shared';
+import type { AILoginAssistant } from '../AILoginAssistant.js';
+
+// ==================== 配置类型 ====================
+
+/**
+ * 平台登录配置
+ */
+export interface PlatformLoginConfig {
+  /** 平台标识 */
+  platform: PlatformType;
+  /** 平台显示名称 */
+  displayName: string;
+  /** 登录页面 URL */
+  loginUrl: string;
+  /** 登录成功的 URL 特征 */
+  successIndicators: string[];
+  /** Cookie 域名 */
+  cookieDomain: string;
+  /** 账号ID前缀 */
+  accountIdPrefix: string;
+  /** 登录超时时间(可选) */
+  loginTimeout?: number;
+}
+
+// ==================== 会话类型 ====================
+
+/**
+ * 登录会话状态
+ */
+export type LoginSessionStatus = 
+  | 'pending'    // 等待扫码
+  | 'fetching'   // 正在获取账号信息
+  | 'success'    // 登录成功
+  | 'failed'     // 登录失败
+  | 'timeout'    // 登录超时
+  | 'cancelled'; // 已取消
+
+/**
+ * 登录会话
+ */
+export interface LoginSession {
+  /** 会话ID */
+  id: string;
+  /** 用户ID */
+  userId?: number;
+  /** 平台 */
+  platform: PlatformType;
+  /** 浏览器实例 */
+  browser: Browser;
+  /** 浏览器上下文 */
+  context: BrowserContext;
+  /** 页面 */
+  page: Page;
+  /** 状态 */
+  status: LoginSessionStatus;
+  /** 创建时间 */
+  createdAt: Date;
+  /** Cookie */
+  cookies?: string;
+  /** 账号信息 */
+  accountInfo?: AccountInfo;
+  /** 错误信息 */
+  error?: string;
+  /** API 拦截数据 */
+  apiData?: Record<string, any>;
+  /** AI 助手 */
+  aiAssistant?: AILoginAssistant;
+}
+
+// ==================== 账号信息类型 ====================
+
+/**
+ * 账号信息
+ */
+export interface AccountInfo {
+  /** 账号ID(带平台前缀:dy_xxx, xhs_xxx, sph_xxx, bjh_xxx) */
+  accountId: string;
+  /** 账号名称/昵称 */
+  accountName: string;
+  /** 头像URL */
+  avatarUrl?: string;
+  /** 粉丝数 */
+  fansCount?: number;
+  /** 作品数 */
+  worksCount?: number;
+  /** 关注数 */
+  followingCount?: number;
+  /** 获赞数 */
+  likeCount?: number;
+}
+
+// ==================== 事件类型 ====================
+
+/**
+ * 登录结果事件
+ */
+export interface LoginResultEvent {
+  /** 会话ID */
+  sessionId: string;
+  /** 用户ID */
+  userId?: number;
+  /** 状态 */
+  status: LoginSessionStatus | 'fetching';
+  /** 加密后的 Cookie */
+  cookies?: string;
+  /** 账号信息 */
+  accountInfo?: AccountInfo;
+  /** 原始 Cookie(用于调试) */
+  rawCookies?: any[];
+  /** 错误信息 */
+  error?: string;
+  /** 提示消息 */
+  message?: string;
+}
+
+/**
+ * AI 分析事件
+ */
+export interface AIAnalysisEvent {
+  sessionId: string;
+  userId?: number;
+  status: string;
+  analysis: {
+    isLoggedIn: boolean;
+    hasVerification: boolean;
+    verificationType?: string;
+    verificationDescription?: string;
+    pageDescription: string;
+    suggestedAction?: string;
+  };
+}
+
+/**
+ * 验证需求事件
+ */
+export interface VerificationNeededEvent {
+  sessionId: string;
+  userId?: number;
+  verificationType?: string;
+  description?: string;
+  suggestedAction?: string;
+}
+
+/**
+ * 登录服务事件定义
+ */
+export interface LoginServiceEvents {
+  loginResult: LoginResultEvent;
+  aiAnalysis: AIAnalysisEvent;
+  verificationNeeded: VerificationNeededEvent;
+}

Some files were not shown because too many files changed in this diff