Преглед изворни кода

fix: 修复权限校验缺失、时区不一致、不安全 null 查询等多个严重 Bug

- PublishService.executePublishTaskWithProgress 增加 userId 归属校验
- WorkDayStatisticsService 统一时区处理(getOverview + saveStatisticsForDate 改用 Intl.DateTimeFormat)
- WorkDayStatisticsService.saveStatistics N+1 查询优化为批量查询
- CommentService.fixOrphanedComments 使用 IsNull() 替代 undefined as unknown as number
- CommentService.syncComments 评论查重从 N+1 改为每作品 1 次批量查询

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
ethanfly пре 1 дан
родитељ
комит
1d33e93bc4

+ 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}`);
     }
 
     // 更新任务状态为处理中

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