3 Коммиты 1356e8575b ... 1e2d78d85b

Автор SHA1 Сообщение Дата
  ethanfly 1e2d78d85b refactor: 提取共享 cookieParser 工具函数,修复内存泄漏,优化性能 2 недель назад
  ethanfly 1d33e93bc4 fix: 修复权限校验缺失、时区不一致、不安全 null 查询等多个严重 Bug 2 недель назад
  ethanfly ad8dd2db1b fix: 修复 errorHandler 不处理非 Error 类型 throw 导致崩溃的问题 2 недель назад

+ 13 - 8
server/src/middleware/error.ts

@@ -23,32 +23,37 @@ export class AppError extends Error {
 }
 
 export function errorHandler(
-  err: Error | AppError,
+  err: unknown,
   _req: Request,
   res: Response,
   _next: NextFunction
 ): void {
-  if (err instanceof AppError) {
-    logger.warn(`AppError: ${err.message}`, { code: err.code, statusCode: err.statusCode });
-    res.status(err.statusCode).json({
+  // 处理非 Error 类型的 throw(如 throw 'string' 或 throw 123)
+  const normalizedError = err instanceof Error
+    ? err
+    : new AppError(String(err), HTTP_STATUS.INTERNAL_SERVER_ERROR, ERROR_CODES.UNKNOWN);
+
+  if (normalizedError instanceof AppError) {
+    logger.warn(`AppError: ${normalizedError.message}`, { code: normalizedError.code, statusCode: normalizedError.statusCode });
+    res.status(normalizedError.statusCode).json({
       success: false,
       error: {
-        code: err.code,
-        message: err.message,
+        code: normalizedError.code,
+        message: normalizedError.message,
       },
     });
     return;
   }
 
   // 未知错误
-  logger.error('Unexpected error:', err);
+  logger.error('Unexpected error:', normalizedError);
   res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
     success: false,
     error: {
       code: ERROR_CODES.UNKNOWN,
       message: process.env.NODE_ENV === 'production' 
         ? 'Internal server error' 
-        : err.message,
+        : normalizedError.message,
     },
   });
 }

+ 17 - 14
server/src/services/AccountService.ts

@@ -14,6 +14,7 @@ import type {
 import { wsManager } from '../websocket/index.js';
 import { WS_EVENTS } from '@media-manager/shared';
 import { CookieManager } from '../automation/cookie.js';
+import { parseCookieString } from '../utils/cookieParser.js';
 import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { aiService } from '../ai/index.js';
@@ -148,7 +149,7 @@ export class AccountService {
       const parsed = JSON.parse(decryptedCookies);
       if (Array.isArray(parsed) && parsed.length > 0) {
         logger.info(`[AccountService] Cookie 格式验证通过,共 ${parsed.length} 个 Cookie`);
-        const cookieNames = parsed.map((c: any) => c?.name).filter(Boolean);
+        const cookieNames = parsed.map((c: { name?: string }) => c?.name).filter(Boolean);
         // 记录关键 Cookie(用于调试)
         const keyNames = cookieNames.slice(0, 5).join(', ');
         logger.info(`[AccountService] 关键 Cookie: ${keyNames}`);
@@ -513,7 +514,7 @@ export class AccountService {
           cookieList = JSON.parse(decryptedCookies);
         } catch {
           // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
-          cookieList = this.parseCookieString(decryptedCookies, platform);
+        cookieList = parseCookieString(decryptedCookies, platform);
           if (cookieList.length === 0) {
             logger.error(`Invalid cookie format for account ${accountId}`);
             cookieParseError = true;
@@ -824,7 +825,7 @@ export class AccountService {
         cookieList = JSON.parse(decryptedCookies);
       } catch {
         // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
-        cookieList = this.parseCookieString(decryptedCookies, platform);
+        cookieList = parseCookieString(decryptedCookies, platform);
         if (cookieList.length === 0) {
           await this.accountRepository.update(accountId, { status: 'expired' });
           return { isValid: false, needReLogin: true, uncertain: false };
@@ -866,16 +867,16 @@ export class AccountService {
       where: { userId },
     });
 
+    const batchSize = 3; // Concurrency limit
     let refreshed = 0;
     let failed = 0;
 
-    for (const account of accounts) {
-      try {
-        await this.refreshAccount(userId, account.id);
-        refreshed++;
-      } catch (error) {
-        logger.error(`Failed to refresh account ${account.id}:`, error);
-        failed++;
+    for (let i = 0; i < accounts.length; i += batchSize) {
+      const batch = accounts.slice(i, i + batchSize);
+      const results = await Promise.allSettled(batch.map(a => this.refreshAccount(userId, a.id)));
+      for (const res of results) {
+        if (res.status === 'fulfilled') refreshed++;
+        else if (res.status === 'rejected') failed++;
       }
     }
 
@@ -949,7 +950,7 @@ export class AccountService {
             }));
         }
       } catch {
-        cookieList = this.parseCookieString(cookieData, platform);
+        cookieList = parseCookieString(cookieData, platform);
       }
 
       if (cookieList.length === 0) {
@@ -1131,9 +1132,11 @@ export class AccountService {
         cookieList = JSON.parse(cookieString);
       } catch {
         // 如果不是 JSON,尝试解析 "name=value; name2=value2" 格式
-        cookieList = this.parseCookieString(cookieString, platform).map(c => ({
-          name: c.name,
-          value: c.value,
+        cookieList = parseCookieString(cookieString, platform).map((c: { name?: string; value?: string; domain?: string; path?: string }) => ({
+          name: c.name ?? '',
+          value: c.value ?? '',
+          domain: c.domain ?? '',
+          path: c.path ?? '',
         }));
       }
 

+ 18 - 57
server/src/services/CommentService.ts

@@ -8,9 +8,12 @@ import type {
   PlatformType,
 } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
-import { headlessBrowserService, type CookieData } from './HeadlessBrowserService.js';
+import { headlessBrowserService } from './HeadlessBrowserService.js';
+import type { CookieData } from '../utils/cookieParser.js';
+import { parseCookieString } from '../utils/cookieParser.js';
 import { CookieManager } from '../automation/cookie.js';
 import { logger } from '../utils/logger.js';
+import { IsNull } from 'typeorm';
 
 interface GetCommentsParams {
   page: number;
@@ -254,7 +257,7 @@ export class CommentService {
           cookies = JSON.parse(decryptedCookies);
         } catch {
           // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
-          cookies = this.parseCookieString(decryptedCookies, account.platform);
+          cookies = parseCookieString(decryptedCookies, account.platform);
           if (cookies.length === 0) {
             logger.error(`Invalid cookie format for account ${account.id}`);
             continue;
@@ -430,6 +433,15 @@ export class CommentService {
           
           logger.info(`Final work mapping: videoId="${commentVideoId}", title="${commentVideoTitle}", workId=${workId}`);
           
+          // Batch prepare existing comments for this work to avoid N+1 queries
+          const batchPairs = workComment.comments.map(c => ({ accountId: account.id, authorName: c.authorName, content: c.content }));
+          const existingList = await this.commentRepository.find({ where: batchPairs as any[] });
+          const existingMap = new Map<string, any>();
+          for (const ex of existingList) {
+            const key = `${ex.authorName}|||${ex.content}`;
+            existingMap.set(key, ex);
+          }
+
           for (const comment of workComment.comments) {
             try {
               // 过滤无效评论内容 - 放宽限制,只过滤纯操作按钮文本
@@ -438,15 +450,8 @@ export class CommentService {
                 logger.debug(`Skipping invalid comment content: ${comment.content}`);
                 continue;
               }
-              
-              // 检查评论是否已存在(基于内容+作者+账号的去重)
-              const existing = await this.commentRepository
-                .createQueryBuilder('comment')
-                .where('comment.accountId = :accountId', { accountId: account.id })
-                .andWhere('comment.authorName = :authorName', { authorName: comment.authorName })
-                .andWhere('comment.content = :content', { content: comment.content })
-                .getOne();
-
+              const key = `${comment.authorName}|||${comment.content}`;
+              const existing = existingMap.get(key);
               if (!existing) {
                 const newComment = this.commentRepository.create({
                   userId,
@@ -518,7 +523,7 @@ export class CommentService {
       
       // 获取所有没有 workId 的评论
       const orphanedComments = await this.commentRepository.find({
-        where: { userId, workId: undefined as unknown as number },
+        where: { userId, workId: IsNull() },
       });
       
       if (orphanedComments.length === 0) return;
@@ -610,51 +615,7 @@ export class CommentService {
     }
   }
 
-  /**
-   * 将 cookie 字符串解析为 cookie 列表
-   */
-  private parseCookieString(cookieString: string, platform: string): CookieData[] {
-    // 获取平台对应的域名
-    const domainMap: Record<string, string> = {
-      douyin: '.douyin.com',
-      kuaishou: '.kuaishou.com',
-      xiaohongshu: '.xiaohongshu.com',
-      weixin_video: '.qq.com',
-      bilibili: '.bilibili.com',
-      toutiao: '.toutiao.com',
-      baijiahao: '.baidu.com',
-      qie: '.qq.com',
-      dayuhao: '.alibaba.com',
-    };
-    
-    const domain = domainMap[platform] || `.${platform}.com`;
-    
-    // 解析 "name=value; name2=value2" 格式的 cookie 字符串
-    const cookies: CookieData[] = [];
-    
-    const pairs = cookieString.split(';');
-    for (const pair of pairs) {
-      const trimmed = pair.trim();
-      if (!trimmed) continue;
-      
-      const eqIndex = trimmed.indexOf('=');
-      if (eqIndex === -1) continue;
-      
-      const name = trimmed.substring(0, eqIndex).trim();
-      const value = trimmed.substring(eqIndex + 1).trim();
-      
-      if (name && value) {
-        cookies.push({
-          name,
-          value,
-          domain,
-          path: '/',
-        });
-      }
-    }
-    
-    return cookies;
-  }
+  
 
   private formatComment(comment: Comment): CommentType {
     return {

+ 2 - 2
server/src/services/PublishService.ts

@@ -289,12 +289,12 @@ export class PublishService {
     onProgress?: (progress: number, message: string) => void
   ): Promise<void> {
     const task = await this.taskRepository.findOne({
-      where: { id: taskId },
+      where: { id: taskId, userId },
       relations: ['results'],
     });
 
     if (!task) {
-      throw new Error(`Task ${taskId} not found`);
+      throw new Error(`Task ${taskId} not found or does not belong to user ${userId}`);
     }
 
     // 更新任务状态为处理中

+ 2 - 2
server/src/services/TaskQueueService.ts

@@ -276,9 +276,9 @@ class TaskQueueService {
       const result = await Promise.race([
         executor(task, updateProgress, controller.signal),
         new Promise<never>((_, reject) => {
-          controller.signal.addEventListener('abort', () => {
+        controller.signal.addEventListener('abort', () => {
             reject(new DOMException('Task cancelled', 'AbortError'));
-          });
+        }, { once: true });
         }),
       ]);
 

+ 42 - 11
server/src/services/WorkDayStatisticsService.ts

@@ -143,16 +143,26 @@ export class WorkDayStatisticsService {
     let insertedCount = 0;
     let updatedCount = 0;
 
+    // 批量查询当天已有记录,避免 N+1 查询
+    const workIds = statistics.filter(s => s.workId).map(s => s.workId);
+    const existingRecords = workIds.length > 0
+      ? await this.statisticsRepository.find({
+          where: {
+            workId: In(workIds),
+            recordDate: today,
+          },
+        })
+      : [];
+
+    const existingMap = new Map<number, typeof existingRecords[0]>();
+    for (const record of existingRecords) {
+      existingMap.set(record.workId, record);
+    }
+
     for (const stat of statistics) {
       if (!stat.workId) continue;
 
-      // 检查当天是否已有记录
-      const existing = await this.statisticsRepository.findOne({
-        where: {
-          workId: stat.workId,
-          recordDate: today,
-        },
-      });
+      const existing = existingMap.get(stat.workId);
 
       if (existing) {
         // 更新已有记录
@@ -221,6 +231,25 @@ export class WorkDayStatisticsService {
   }
 
   /**
+   * 将任意 Date 规范化为中国时区(Asia/Shanghai)的日历日零点
+   * 与 getTodayInChina() 保持一致的时区处理方式
+   */
+  private normalizeToDateInChina(date: Date): Date {
+    const formatter = new Intl.DateTimeFormat('en-CA', {
+      timeZone: 'Asia/Shanghai',
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    });
+    const parts = formatter.formatToParts(date);
+    const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+    const y = parseInt(get('year'), 10);
+    const m = parseInt(get('month'), 10) - 1;
+    const d = parseInt(get('day'), 10);
+    return new Date(Date.UTC(y, m, d, 0, 0, 0, 0));
+  }
+
+  /**
    * 保存指定日期的作品日统计数据(按 workId + recordDate 维度 upsert)
    * 说明:recordDate 会被归零到当天 00:00:00(本地时间),避免主键冲突
    */
@@ -229,8 +258,8 @@ export class WorkDayStatisticsService {
     recordDate: Date,
     patch: Omit<StatisticsItem, 'workId'>
   ): Promise<SaveResult> {
-    const d = new Date(recordDate);
-    d.setHours(0, 0, 0, 0);
+    // 使用中国时区标准化日期,与 getTodayInChina() 一致
+    const d = this.normalizeToDateInChina(recordDate);
 
     const existing = await this.statisticsRepository.findOne({
       where: { workId, recordDate: d },
@@ -810,8 +839,10 @@ export class WorkDayStatisticsService {
     const chinaYesterday = new Date(chinaNow.getTime() - 24 * 60 * 60 * 1000);
 
     // 格式化为 YYYY-MM-DD,与 MySQL DATE 字段匹配
-    const todayStr = chinaNow.toISOString().split('T')[0];
-    const yesterdayStr = chinaYesterday.toISOString().split('T')[0];
+    const todayStr = this.formatDate(new Date());
+    const yesterdayDate = new Date();
+    yesterdayDate.setDate(yesterdayDate.getDate() - 1);
+    const yesterdayStr = this.formatDate(yesterdayDate);
     
     logger.info(`[WorkDayStatistics] getOverview - userId: ${userId}, today: ${todayStr}, yesterday: ${yesterdayStr}`);
 

+ 6 - 56
server/src/services/WorkService.ts

@@ -1,8 +1,9 @@
 import { AppDataSource, Work, PlatformAccount, Comment, WorkDayStatistics } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
-import { ERROR_CODES, HTTP_STATUS } from '@media-manager/shared';
+import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared';
 import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from '@media-manager/shared';
 import { logger } from '../utils/logger.js';
+import { parseCookieString } from '../utils/cookieParser.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
@@ -238,7 +239,7 @@ export class WorkService {
       logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from JSON format`);
     } catch {
       // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
-      cookieList = this.parseCookieString(decryptedCookies, platform);
+      cookieList = parseCookieString(decryptedCookies, platform);
       logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from string format`);
       if (cookieList.length === 0) {
         logger.error(`Invalid cookie format for account ${account.id}`);
@@ -299,7 +300,7 @@ export class WorkService {
       for (const workItem of accountInfo.worksList) {
         const titleForId = (workItem.title || '').trim();
         const publishTimeForId = (workItem.publishTime || '').trim();
-        const legacyFallbackId = `${platform}_${titleForId}_${publishTimeForId}`.substring(0, 100);
+        const legacyFallbackId = `${platform}_${titleForId}_${publishTimeForId}`;
         let canonicalVideoId = (workItem.videoId || '').trim() || legacyFallbackId;
 
         // 小红书每日同步只同步三个月内的作品(原为一年内,现改为三个月)
@@ -401,7 +402,7 @@ export class WorkService {
           });
         } else {
           // 创建新作品
-          const work = this.workRepository.create({
+          const newWork = this.workRepository.create({
             userId,
             accountId: account.id,
             platform,
@@ -425,7 +426,7 @@ export class WorkService {
             yesterdayCollectCount: workItem.collectCount || 0,
           });
 
-          await this.workRepository.save(work);
+          await this.workRepository.save(newWork);
         }
         syncedCount++;
         if (syncedCount === 1 || syncedCount === total || syncedCount % 10 === 0) {
@@ -822,57 +823,6 @@ export class WorkService {
   }
 
   /**
-   * 将 cookie 字符串解析为 cookie 列表
-   */
-  private parseCookieString(cookieString: string, platform: PlatformType): {
-    name: string;
-    value: string;
-    domain: string;
-    path: string;
-  }[] {
-    // 获取平台对应的域名
-    const domainMap: Record<string, string> = {
-      douyin: '.douyin.com',
-      kuaishou: '.kuaishou.com',
-      xiaohongshu: '.xiaohongshu.com',
-      weixin_video: '.qq.com',
-      bilibili: '.bilibili.com',
-      toutiao: '.toutiao.com',
-      baijiahao: '.baidu.com',
-      qie: '.qq.com',
-      dayuhao: '.alibaba.com',
-    };
-
-    const domain = domainMap[platform] || `.${platform}.com`;
-
-    // 解析 "name=value; name2=value2" 格式的 cookie 字符串
-    const cookies: { name: string; value: string; domain: string; path: string }[] = [];
-
-    const pairs = cookieString.split(';');
-    for (const pair of pairs) {
-      const trimmed = pair.trim();
-      if (!trimmed) continue;
-
-      const eqIndex = trimmed.indexOf('=');
-      if (eqIndex === -1) continue;
-
-      const name = trimmed.substring(0, eqIndex).trim();
-      const value = trimmed.substring(eqIndex + 1).trim();
-
-      if (name && value) {
-        cookies.push({
-          name,
-          value,
-          domain,
-          path: '/',
-        });
-      }
-    }
-
-    return cookies;
-  }
-
-  /**
    * 格式化作品
    */
   private formatWork(work: Work): WorkType {

+ 44 - 0
server/src/utils/cookieParser.ts

@@ -0,0 +1,44 @@
+// Shared cookie parser utilities
+
+export type CookieData = {
+  name: string;
+  value: string;
+  domain: string;
+  path: string;
+};
+
+// Domain map used by different platforms
+const domainMap: Record<string, string> = {
+  douyin: '.douyin.com',
+  kuaishou: '.kuaishou.com',
+  xiaohongshu: '.xiaohongshu.com',
+  weixin_video: '.qq.com',
+  bilibili: '.bilibili.com',
+  toutiao: '.toutiao.com',
+  baijiahao: '.baidu.com',
+  qie: '.qq.com',
+  dayuhao: '.alibaba.com',
+};
+
+export function parseCookieString(cookieString: string, platform: string): CookieData[] {
+  const domain = domainMap[platform] || `.${platform}.com`;
+
+  const cookies: CookieData[] = [];
+  const pairs = cookieString.split(';');
+  for (const pair of pairs) {
+    const trimmed = pair.trim();
+    if (!trimmed) continue;
+
+    const eqIndex = trimmed.indexOf('=');
+    if (eqIndex === -1) continue;
+
+    const name = trimmed.substring(0, eqIndex).trim();
+    const value = trimmed.substring(eqIndex + 1).trim();
+
+    if (name && value) {
+      cookies.push({ name, value, domain, path: '/' });
+    }
+  }
+
+  return cookies;
+}