Ethanfly 14 hours ago
parent
commit
ad09129ae0

+ 5 - 0
README.md

@@ -226,6 +226,11 @@ ENCRYPTION_KEY=your-encryption-key-32-chars-long!
 | `DB_USERNAME` | 数据库用户名 | root |
 | `DB_PASSWORD` | 数据库密码 | - |
 | `DB_DATABASE` | 数据库名称 | media_manager |
+| `REDIS_HOST` | Redis 主机 | localhost |
+| `REDIS_PORT` | Redis 端口 | 6379 |
+| `REDIS_PASSWORD` | Redis 密码 | - |
+| `REDIS_DB` | Redis 数据库编号 | 0 |
+| `USE_REDIS_QUEUE` | 启用 Redis 任务队列 | false |
 | `JWT_SECRET` | JWT 密钥 | - |
 | `JWT_ACCESS_EXPIRES_IN` | Access Token 过期时间 | 15m |
 | `JWT_REFRESH_EXPIRES_IN` | Refresh Token 过期时间 | 7d |

+ 27 - 0
client/src/api/dashboard.ts

@@ -0,0 +1,27 @@
+import request from './request';
+
+export interface WorksStats {
+  totalCount: number;
+  totalPlayCount: number;
+  totalLikeCount: number;
+  totalCommentCount: number;
+  totalShareCount: number;
+}
+
+export interface CommentsStats {
+  totalCount: number;
+  unreadCount: number;
+  todayCount: number;
+}
+
+export const dashboardApi = {
+  // 获取作品统计
+  getWorksStats(): Promise<WorksStats> {
+    return request.get('/api/works/stats');
+  },
+
+  // 获取评论统计
+  getCommentsStats(): Promise<CommentsStats> {
+    return request.get('/api/comments/stats');
+  },
+};

+ 34 - 2
client/src/views/Dashboard/index.vue

@@ -103,10 +103,11 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, watch, markRaw, nextTick } from 'vue';
+import { ref, onMounted, onUnmounted, onActivated, watch, markRaw, nextTick } from 'vue';
 import { User, VideoPlay, ChatDotRound, TrendCharts } from '@element-plus/icons-vue';
 import * as echarts from 'echarts';
 import { accountsApi } from '@/api/accounts';
+import { dashboardApi } from '@/api/dashboard';
 import { PLATFORMS } from '@media-manager/shared';
 import type { PlatformAccount, PublishTask, PlatformType } from '@media-manager/shared';
 import { useTabsStore } from '@/stores/tabs';
@@ -234,8 +235,30 @@ function initChart() {
 
 async function loadData() {
   try {
-    accounts.value = await accountsApi.getAccounts();
+    // 并行获取所有数据
+    const [accountsData, worksStats, commentsStats] = await Promise.all([
+      accountsApi.getAccounts(),
+      dashboardApi.getWorksStats().catch(() => null),
+      dashboardApi.getCommentsStats().catch(() => null),
+    ]);
+    
+    accounts.value = accountsData;
+    
+    // 更新统计数据
     stats.value[0].value = accounts.value.length;
+    
+    if (worksStats) {
+      stats.value[1].value = worksStats.totalCount || 0;
+      // 格式化播放量
+      const playCount = worksStats.totalPlayCount || 0;
+      stats.value[3].value = playCount >= 10000 
+        ? (playCount / 10000).toFixed(1) + '万' 
+        : playCount.toString();
+    }
+    
+    if (commentsStats) {
+      stats.value[2].value = commentsStats.todayCount || 0;
+    }
   } catch {
     // 错误已在拦截器中处理
   }
@@ -270,6 +293,15 @@ onMounted(async () => {
   window.addEventListener('resize', handleResize);
 });
 
+// 页面激活时自动刷新数据(从其他标签页切换回来时)
+onActivated(() => {
+  loadData();
+  // 确保图表正确显示
+  nextTick(() => {
+    handleResize();
+  });
+});
+
 onUnmounted(() => {
   window.removeEventListener('resize', handleResize);
   resizeObserver?.disconnect();

+ 20 - 3
client/src/views/Works/index.vue

@@ -70,7 +70,7 @@
         >
           <div class="work-cover">
             <img :src="getSecureCoverUrl(work.coverUrl)" :alt="work.title" @error="handleImageError" />
-            <span class="work-duration">{{ work.duration }}</span>
+            <span class="work-duration">{{ formatDuration(work.duration) }}</span>
             <el-tag 
               class="work-status" 
               :type="getStatusType(work.status)" 
@@ -144,7 +144,7 @@
           </div>
           <div class="detail-row">
             <label>时长:</label>
-            <span>{{ currentWork.duration }}</span>
+            <span>{{ formatDuration(currentWork.duration) }}</span>
           </div>
           <div class="detail-stats">
             <div class="stat-item">
@@ -178,7 +178,7 @@
         <el-button 
           type="danger" 
           @click="deletePlatformWork(currentWork!)"
-          v-if="currentWork?.platform === 'douyin'"
+          v-if="currentWork?.platform === 'douyin' || currentWork?.platform === 'xiaohongshu'"
         >
           <el-icon><Delete /></el-icon>
           删除平台作品
@@ -478,6 +478,23 @@ function formatNumber(num: number) {
   return num?.toString() || '0';
 }
 
+// 格式化时长(秒转换为 时:分:秒 或 分:秒)
+function formatDuration(seconds: number | string | undefined): string {
+  if (!seconds && seconds !== 0) return '-';
+  
+  const totalSeconds = typeof seconds === 'string' ? parseInt(seconds, 10) : seconds;
+  if (isNaN(totalSeconds) || totalSeconds < 0) return '-';
+  
+  const hours = Math.floor(totalSeconds / 3600);
+  const minutes = Math.floor((totalSeconds % 3600) / 60);
+  const secs = totalSeconds % 60;
+  
+  if (hours > 0) {
+    return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+  }
+  return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+}
+
 // 将 HTTP 图片 URL 转换为 HTTPS(小红书等平台的图片 URL 可能是 HTTP)
 function getSecureCoverUrl(url: string): string {
   if (!url) return '';

+ 174 - 2
pnpm-lock.yaml

@@ -114,6 +114,9 @@ importers:
       bcryptjs:
         specifier: ^2.4.3
         version: 2.4.3
+      bullmq:
+        specifier: ^5.66.5
+        version: 5.66.5
       compression:
         specifier: ^1.7.4
         version: 1.8.1
@@ -132,6 +135,9 @@ importers:
       helmet:
         specifier: ^7.1.0
         version: 7.2.0
+      ioredis:
+        specifier: ^5.9.2
+        version: 5.9.2
       jsonwebtoken:
         specifier: ^9.0.2
         version: 9.0.3
@@ -158,7 +164,7 @@ importers:
         version: 4.7.1
       typeorm:
         specifier: ^0.3.19
-        version: 0.3.28(mysql2@3.16.0)(redis@4.7.1)
+        version: 0.3.28(ioredis@5.9.2)(mysql2@3.16.0)(redis@4.7.1)
       uuid:
         specifier: ^9.0.1
         version: 9.0.1
@@ -803,6 +809,9 @@ packages:
     resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
     engines: {node: '>= 16'}
 
+  '@ioredis/commands@1.5.0':
+    resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
+
   '@isaacs/cliui@8.0.2':
     resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
     engines: {node: '>=12'}
@@ -818,6 +827,36 @@ packages:
     resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
     engines: {node: '>= 10.0.0'}
 
+  '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
+    resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
+    resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
+    resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
+    resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
+    cpu: [arm]
+    os: [linux]
+
+  '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
+    resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
+    cpu: [x64]
+    os: [linux]
+
+  '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
+    resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
+    cpu: [x64]
+    os: [win32]
+
   '@nodelib/fs.scandir@2.1.5':
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -1578,6 +1617,9 @@ packages:
   builder-util@24.13.1:
     resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==}
 
+  bullmq@5.66.5:
+    resolution: {integrity: sha512-DC1E7P03L+TfNHv+2SGxwNYvtb0oJPODWSKkWdfis0heU5zFW16vjM7fCjwlxMdGWw2w28EI3mTRfYLEHeQQSw==}
+
   busboy@1.6.0:
     resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
     engines: {node: '>=10.16.0'}
@@ -2356,6 +2398,14 @@ packages:
   inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
 
+  ioredis@5.9.1:
+    resolution: {integrity: sha512-BXNqFQ66oOsR82g9ajFFsR8ZKrjVvYCLyeML9IvSMAsP56XH2VXBdZjmI11p65nXXJxTEt1hie3J2QeFJVgrtQ==}
+    engines: {node: '>=12.22.0'}
+
+  ioredis@5.9.2:
+    resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==}
+    engines: {node: '>=12.22.0'}
+
   ipaddr.js@1.9.1:
     resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
     engines: {node: '>= 0.10'}
@@ -2527,6 +2577,9 @@ packages:
   lodash.includes@4.3.0:
     resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
 
+  lodash.isarguments@3.1.0:
+    resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
+
   lodash.isboolean@3.0.3:
     resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
 
@@ -2702,6 +2755,13 @@ packages:
   ms@2.1.3:
     resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
 
+  msgpackr-extract@3.0.3:
+    resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
+    hasBin: true
+
+  msgpackr@1.11.5:
+    resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
+
   muggle-string@0.3.1:
     resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==}
 
@@ -2734,6 +2794,9 @@ packages:
     resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
     engines: {node: '>= 0.6'}
 
+  node-abort-controller@3.1.1:
+    resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
+
   node-addon-api@1.7.2:
     resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
 
@@ -2754,6 +2817,10 @@ packages:
       encoding:
         optional: true
 
+  node-gyp-build-optional-packages@5.2.2:
+    resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
+    hasBin: true
+
   node-schedule@2.1.1:
     resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==}
     engines: {node: '>=6'}
@@ -3001,6 +3068,14 @@ packages:
     resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
     engines: {node: '>= 14.18.0'}
 
+  redis-errors@1.2.0:
+    resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
+    engines: {node: '>=4'}
+
+  redis-parser@3.0.0:
+    resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
+    engines: {node: '>=4'}
+
   redis@4.7.1:
     resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
 
@@ -3214,6 +3289,9 @@ packages:
   stack-trace@0.0.10:
     resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
 
+  standard-as-callback@2.1.0:
+    resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
+
   stat-mode@1.0.0:
     resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
     engines: {node: '>= 6'}
@@ -4114,6 +4192,8 @@ snapshots:
 
   '@intlify/shared@9.14.5': {}
 
+  '@ioredis/commands@1.5.0': {}
+
   '@isaacs/cliui@8.0.2':
     dependencies:
       string-width: 5.1.2
@@ -4138,6 +4218,24 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
+    optional: true
+
+  '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
+    optional: true
+
   '@nodelib/fs.scandir@2.1.5':
     dependencies:
       '@nodelib/fs.stat': 2.0.5
@@ -4959,6 +5057,18 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  bullmq@5.66.5:
+    dependencies:
+      cron-parser: 4.9.0
+      ioredis: 5.9.1
+      msgpackr: 1.11.5
+      node-abort-controller: 3.1.1
+      semver: 7.7.3
+      tslib: 2.8.1
+      uuid: 11.1.0
+    transitivePeerDependencies:
+      - supports-color
+
   busboy@1.6.0:
     dependencies:
       streamsearch: 1.1.0
@@ -5938,6 +6048,34 @@ snapshots:
 
   inherits@2.0.4: {}
 
+  ioredis@5.9.1:
+    dependencies:
+      '@ioredis/commands': 1.5.0
+      cluster-key-slot: 1.1.2
+      debug: 4.4.3
+      denque: 2.1.0
+      lodash.defaults: 4.2.0
+      lodash.isarguments: 3.1.0
+      redis-errors: 1.2.0
+      redis-parser: 3.0.0
+      standard-as-callback: 2.1.0
+    transitivePeerDependencies:
+      - supports-color
+
+  ioredis@5.9.2:
+    dependencies:
+      '@ioredis/commands': 1.5.0
+      cluster-key-slot: 1.1.2
+      debug: 4.4.3
+      denque: 2.1.0
+      lodash.defaults: 4.2.0
+      lodash.isarguments: 3.1.0
+      redis-errors: 1.2.0
+      redis-parser: 3.0.0
+      standard-as-callback: 2.1.0
+    transitivePeerDependencies:
+      - supports-color
+
   ipaddr.js@1.9.1: {}
 
   is-binary-path@2.1.0:
@@ -6097,6 +6235,8 @@ snapshots:
 
   lodash.includes@4.3.0: {}
 
+  lodash.isarguments@3.1.0: {}
+
   lodash.isboolean@3.0.3: {}
 
   lodash.isinteger@4.0.4: {}
@@ -6240,6 +6380,22 @@ snapshots:
 
   ms@2.1.3: {}
 
+  msgpackr-extract@3.0.3:
+    dependencies:
+      node-gyp-build-optional-packages: 5.2.2
+    optionalDependencies:
+      '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
+      '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
+      '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
+      '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
+      '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
+      '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
+    optional: true
+
+  msgpackr@1.11.5:
+    optionalDependencies:
+      msgpackr-extract: 3.0.3
+
   muggle-string@0.3.1: {}
 
   multer@1.4.5-lts.2:
@@ -6276,6 +6432,8 @@ snapshots:
 
   negotiator@0.6.4: {}
 
+  node-abort-controller@3.1.1: {}
+
   node-addon-api@1.7.2:
     optional: true
 
@@ -6288,6 +6446,11 @@ snapshots:
     dependencies:
       whatwg-url: 5.0.0
 
+  node-gyp-build-optional-packages@5.2.2:
+    dependencies:
+      detect-libc: 2.1.2
+    optional: true
+
   node-schedule@2.1.1:
     dependencies:
       cron-parser: 4.9.0
@@ -6527,6 +6690,12 @@ snapshots:
 
   readdirp@4.1.2: {}
 
+  redis-errors@1.2.0: {}
+
+  redis-parser@3.0.0:
+    dependencies:
+      redis-errors: 1.2.0
+
   redis@4.7.1:
     dependencies:
       '@redis/bloom': 1.2.0(@redis/client@1.6.1)
@@ -6808,6 +6977,8 @@ snapshots:
 
   stack-trace@0.0.10: {}
 
+  standard-as-callback@2.1.0: {}
+
   stat-mode@1.0.0: {}
 
   statuses@2.0.2: {}
@@ -6955,7 +7126,7 @@ snapshots:
 
   typedarray@0.0.6: {}
 
-  typeorm@0.3.28(mysql2@3.16.0)(redis@4.7.1):
+  typeorm@0.3.28(ioredis@5.9.2)(mysql2@3.16.0)(redis@4.7.1):
     dependencies:
       '@sqltools/formatter': 1.2.5
       ansis: 4.2.0
@@ -6973,6 +7144,7 @@ snapshots:
       uuid: 11.1.0
       yargs: 17.7.2
     optionalDependencies:
+      ioredis: 5.9.2
       mysql2: 3.16.0
       redis: 4.7.1
     transitivePeerDependencies:

+ 9 - 1
server/env.example

@@ -32,7 +32,7 @@ DB_PASSWORD=your_mysql_password
 DB_DATABASE=media_manager
 
 # ----------------------------------------
-# Redis 配置 (可选,用于缓存)
+# Redis 配置 (可选,用于缓存和任务队列)
 # ----------------------------------------
 # Redis 主机地址
 REDIS_HOST=localhost
@@ -47,6 +47,14 @@ REDIS_PASSWORD=
 REDIS_DB=0
 
 # ----------------------------------------
+# 任务队列配置
+# ----------------------------------------
+# 启用 Redis 任务队列 (true/false)
+# 启用后任务将持久化到 Redis,支持分布式和断点续传
+# 不启用则使用内存队列(重启后任务丢失)
+USE_REDIS_QUEUE=false
+
+# ----------------------------------------
 # JWT 认证配置
 # ----------------------------------------
 # JWT 密钥 (生产环境请使用强随机字符串)

+ 23 - 21
server/package.json

@@ -13,43 +13,45 @@
   },
   "dependencies": {
     "@media-manager/shared": "workspace:*",
-    "express": "^4.18.2",
-    "cors": "^2.8.5",
-    "helmet": "^7.1.0",
-    "morgan": "^1.10.0",
+    "bcryptjs": "^2.4.3",
+    "bullmq": "^5.66.5",
     "compression": "^1.7.4",
+    "cors": "^2.8.5",
+    "dotenv": "^16.3.1",
+    "express": "^4.18.2",
     "express-validator": "^7.0.1",
+    "helmet": "^7.1.0",
+    "ioredis": "^5.9.2",
     "jsonwebtoken": "^9.0.2",
-    "bcryptjs": "^2.4.3",
-    "mysql2": "^3.6.5",
-    "typeorm": "^0.3.19",
-    "redis": "^4.6.12",
-    "ws": "^8.16.0",
+    "morgan": "^1.10.0",
     "multer": "^1.4.5-lts.1",
+    "mysql2": "^3.6.5",
     "node-schedule": "^2.1.1",
     "openai": "^4.24.1",
     "playwright": "^1.41.1",
-    "dotenv": "^16.3.1",
+    "redis": "^4.6.12",
+    "typeorm": "^0.3.19",
+    "uuid": "^9.0.1",
     "winston": "^3.11.0",
-    "uuid": "^9.0.1"
+    "ws": "^8.16.0"
   },
   "devDependencies": {
-    "@types/express": "^4.17.21",
-    "@types/cors": "^2.8.17",
-    "@types/morgan": "^1.9.9",
+    "@types/bcryptjs": "^2.4.6",
     "@types/compression": "^1.7.5",
+    "@types/cors": "^2.8.17",
+    "@types/express": "^4.17.21",
     "@types/jsonwebtoken": "^9.0.5",
-    "@types/bcryptjs": "^2.4.6",
+    "@types/morgan": "^1.9.9",
     "@types/multer": "^1.4.11",
     "@types/node": "^20.10.6",
     "@types/node-schedule": "^2.1.5",
-    "@types/ws": "^8.5.10",
     "@types/uuid": "^9.0.7",
-    "tsx": "^4.7.0",
-    "typescript": "^5.3.3",
-    "rimraf": "^5.0.5",
-    "eslint": "^8.56.0",
+    "@types/ws": "^8.5.10",
     "@typescript-eslint/eslint-plugin": "^6.18.0",
-    "@typescript-eslint/parser": "^6.18.0"
+    "@typescript-eslint/parser": "^6.18.0",
+    "eslint": "^8.56.0",
+    "rimraf": "^5.0.5",
+    "tsx": "^4.7.0",
+    "typescript": "^5.3.3"
   }
 }

+ 11 - 3
server/python/app.py

@@ -209,14 +209,22 @@ def publish_video():
         # 执行发布
         result = asyncio.run(publisher.run(cookie_str, params))
         
-        return jsonify({
+        response_data = {
             "success": result.success,
             "platform": result.platform,
             "video_id": result.video_id,
             "video_url": result.video_url,
             "message": result.message,
-            "error": result.error
-        })
+            "error": result.error,
+            "need_captcha": result.need_captcha,
+            "captcha_type": result.captcha_type
+        }
+        
+        # 如果需要验证码,打印明确的日志
+        if result.need_captcha:
+            print(f"[Publish] 需要验证码: type={result.captcha_type}")
+        
+        return jsonify(response_data)
         
     except Exception as e:
         traceback.print_exc()

BIN
server/python/platforms/__pycache__/base.cpython-313.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-313.pyc


+ 2 - 0
server/python/platforms/base.py

@@ -39,6 +39,8 @@ class PublishResult:
     video_url: str = ""
     message: str = ""
     error: str = ""
+    need_captcha: bool = False  # 是否需要验证码
+    captcha_type: str = ""  # 验证码类型: phone, slider, image
 
 
 @dataclass

+ 124 - 0
server/python/platforms/douyin.py

@@ -52,6 +52,86 @@ class DouyinPublisher(BasePublisher):
         print(f"[{self.platform_name}] 视频出错了,重新上传中...")
         await self.page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(video_path)
     
+    async def check_captcha(self) -> dict:
+        """
+        检查页面是否需要验证码
+        返回: {'need_captcha': bool, 'captcha_type': str}
+        """
+        if not self.page:
+            return {'need_captcha': False, 'captcha_type': ''}
+        
+        try:
+            # 检查手机验证码弹窗
+            phone_captcha_selectors = [
+                'text="请输入验证码"',
+                'text="输入手机验证码"',
+                'text="获取验证码"',
+                'text="手机号验证"',
+                '[class*="captcha"][class*="phone"]',
+                '[class*="verify"][class*="phone"]',
+                '[class*="sms-code"]',
+                'input[placeholder*="验证码"]',
+            ]
+            for selector in phone_captcha_selectors:
+                try:
+                    if await self.page.locator(selector).count() > 0:
+                        print(f"[{self.platform_name}] 检测到手机验证码: {selector}", flush=True)
+                        return {'need_captcha': True, 'captcha_type': 'phone'}
+                except:
+                    pass
+            
+            # 检查滑块验证码
+            slider_captcha_selectors = [
+                '[class*="captcha"][class*="slider"]',
+                '[class*="slide-verify"]',
+                '[class*="drag-verify"]',
+                'text="按住滑块"',
+                'text="向右滑动"',
+                'text="拖动滑块"',
+            ]
+            for selector in slider_captcha_selectors:
+                try:
+                    if await self.page.locator(selector).count() > 0:
+                        print(f"[{self.platform_name}] 检测到滑块验证码: {selector}", flush=True)
+                        return {'need_captcha': True, 'captcha_type': 'slider'}
+                except:
+                    pass
+            
+            # 检查图片验证码
+            image_captcha_selectors = [
+                '[class*="captcha"][class*="image"]',
+                '[class*="verify-image"]',
+                'text="点击图片"',
+                'text="选择正确的"',
+            ]
+            for selector in image_captcha_selectors:
+                try:
+                    if await self.page.locator(selector).count() > 0:
+                        print(f"[{self.platform_name}] 检测到图片验证码: {selector}", flush=True)
+                        return {'need_captcha': True, 'captcha_type': 'image'}
+                except:
+                    pass
+            
+            # 检查登录弹窗(Cookie 过期)
+            login_selectors = [
+                'text="请先登录"',
+                'text="登录后继续"',
+                '[class*="login-modal"]',
+                '[class*="login-dialog"]',
+            ]
+            for selector in login_selectors:
+                try:
+                    if await self.page.locator(selector).count() > 0:
+                        print(f"[{self.platform_name}] 检测到需要登录: {selector}", flush=True)
+                        return {'need_captcha': True, 'captcha_type': 'login'}
+                except:
+                    pass
+            
+        except Exception as e:
+            print(f"[{self.platform_name}] 验证码检测异常: {e}", flush=True)
+        
+        return {'need_captcha': False, 'captcha_type': ''}
+    
     async def publish(self, cookies: str, params: PublishParams) -> PublishResult:
         """发布视频到抖音"""
         print(f"\n{'='*60}")
@@ -87,6 +167,19 @@ class DouyinPublisher(BasePublisher):
         await self.page.goto(self.publish_url)
         await self.page.wait_for_url(self.publish_url, timeout=30000)
         
+        # 等待页面加载,检查验证码
+        await asyncio.sleep(2)
+        captcha_result = await self.check_captcha()
+        if captcha_result['need_captcha']:
+            print(f"[{self.platform_name}] 检测到需要验证码: {captcha_result['captcha_type']}", flush=True)
+            return PublishResult(
+                success=False,
+                platform=self.platform_name,
+                error=f"需要{captcha_result['captcha_type']}验证码",
+                need_captcha=True,
+                captcha_type=captcha_result['captcha_type']
+            )
+        
         self.report_progress(15, "正在选择视频文件...")
         
         # 点击上传区域
@@ -196,6 +289,22 @@ class DouyinPublisher(BasePublisher):
         publish_clicked = False
         for i in range(30):
             try:
+                # 每次循环都检查验证码
+                captcha_result = await self.check_captcha()
+                if captcha_result['need_captcha']:
+                    print(f"[{self.platform_name}] 发布过程中检测到需要验证码: {captcha_result['captcha_type']}", flush=True)
+                    # 保存截图供调试
+                    screenshot_path = f"debug_captcha_{self.platform_name}_{i}.png"
+                    await self.page.screenshot(path=screenshot_path, full_page=True)
+                    print(f"[{self.platform_name}] 验证码截图保存到: {screenshot_path}", flush=True)
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error=f"发布过程中需要{captcha_result['captcha_type']}验证码",
+                        need_captcha=True,
+                        captcha_type=captcha_result['captcha_type']
+                    )
+                
                 publish_btn = self.page.get_by_role('button', name="发布", exact=True)
                 btn_count = await publish_btn.count()
                 print(f"[{self.platform_name}] 发布按钮数量: {btn_count}")
@@ -204,6 +313,21 @@ class DouyinPublisher(BasePublisher):
                     print(f"[{self.platform_name}] 点击发布按钮...")
                     await publish_btn.click()
                     publish_clicked = True
+                    
+                    # 点击后等待并检查验证码
+                    await asyncio.sleep(2)
+                    captcha_result = await self.check_captcha()
+                    if captcha_result['need_captcha']:
+                        print(f"[{self.platform_name}] 点击发布后需要验证码: {captcha_result['captcha_type']}", flush=True)
+                        screenshot_path = f"debug_captcha_after_publish_{self.platform_name}.png"
+                        await self.page.screenshot(path=screenshot_path, full_page=True)
+                        return PublishResult(
+                            success=False,
+                            platform=self.platform_name,
+                            error=f"发布需要{captcha_result['captcha_type']}验证码",
+                            need_captcha=True,
+                            captcha_type=captcha_result['captcha_type']
+                        )
                 
                 await self.page.wait_for_url(
                     "https://creator.douyin.com/creator-micro/content/manage",

+ 6 - 1
server/src/app.ts

@@ -16,6 +16,7 @@ import { initRedis } from './config/redis.js';
 import { logger } from './utils/logger.js';
 import { taskScheduler } from './scheduler/index.js';
 import { registerTaskExecutors } from './services/taskExecutors.js';
+import { taskQueueService } from './services/TaskQueueService.js';
 
 const execAsync = promisify(exec);
 
@@ -183,6 +184,9 @@ async function bootstrap() {
   if (dbConnected) {
     registerTaskExecutors();
     taskScheduler.start();
+    
+    // 启动任务队列 Worker
+    taskQueueService.startWorker();
   }
 
   // 启动 HTTP 服务
@@ -197,9 +201,10 @@ async function bootstrap() {
 }
 
 // 优雅关闭
-process.on('SIGTERM', () => {
+process.on('SIGTERM', async () => {
   logger.info('SIGTERM received, shutting down gracefully');
   taskScheduler.stop();
+  await taskQueueService.close();
   httpServer.close(() => {
     logger.info('Server closed');
     process.exit(0);

+ 2 - 0
server/src/automation/platforms/base.ts

@@ -36,6 +36,8 @@ export interface PublishParams {
   description?: string;
   coverPath?: string;
   tags?: string[];
+  scheduledTime?: string | Date;  // 定时发布时间
+  location?: string;  // 位置信息
   extra?: Record<string, unknown>;
 }
 

File diff suppressed because it is too large
+ 291 - 278
server/src/automation/platforms/douyin.ts


+ 332 - 106
server/src/automation/platforms/xiaohongshu.ts

@@ -142,6 +142,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
 
   /**
    * 获取账号信息
+   * 通过拦截 API 响应获取准确数据
    */
   async getAccountInfo(cookies: string): Promise<AccountProfile> {
     try {
@@ -167,64 +168,98 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           fans?: number;
           notes?: number;
         };
+        homeData?: {
+          fans?: number;
+          notes?: number;
+        };
       } = {};
 
+      // 用于等待 API 响应的 Promise
+      let resolvePersonalInfo: () => void;
+      let resolveNotesCount: () => void;
+      const personalInfoPromise = new Promise<void>((resolve) => { resolvePersonalInfo = resolve; });
+      const notesCountPromise = new Promise<void>((resolve) => { resolveNotesCount = resolve; });
+
+      // 设置超时自动 resolve
+      setTimeout(() => resolvePersonalInfo(), 10000);
+      setTimeout(() => resolveNotesCount(), 10000);
+
       // 设置 API 响应监听器
       this.page.on('response', async (response) => {
         const url = response.url();
         try {
-          // 监听用户信息 API
-          if (url.includes('/api/galaxy/creator/home/personal_info') ||
-            url.includes('/api/sns/web/v1/user/selfinfo') ||
-            url.includes('/user/selfinfo')) {
+          // 监听用户信息 API - personal_info 接口
+          // URL: https://creator.xiaohongshu.com/api/galaxy/creator/home/personal_info
+          // 返回结构: { data: { name, avatar, fans_count, red_num, follow_count, faved_count } }
+          if (url.includes('/api/galaxy/creator/home/personal_info')) {
             const data = await response.json();
-            logger.info(`[Xiaohongshu API] User info response:`, JSON.stringify(data).slice(0, 500));
+            logger.info(`[Xiaohongshu API] Personal info:`, JSON.stringify(data).slice(0, 1000));
 
-            const userInfo = data?.data?.user_info || data?.data || data;
-            if (userInfo) {
+            if (data?.data) {
+              const info = data.data;
               capturedData.userInfo = {
-                nickname: userInfo.nickname || userInfo.name || userInfo.userName,
-                avatar: userInfo.image || userInfo.avatar || userInfo.images,
-                userId: userInfo.user_id || userInfo.userId,
-                redId: userInfo.red_id || userInfo.redId,
-                fans: userInfo.fans || userInfo.fansCount,
-                notes: userInfo.notes || userInfo.noteCount,
+                nickname: info.name,
+                avatar: info.avatar,
+                userId: info.red_num,  // 小红书号
+                redId: info.red_num,
+                fans: info.fans_count,
               };
-              logger.info(`[Xiaohongshu API] Captured user info:`, capturedData.userInfo);
+              logger.info(`[Xiaohongshu API] Captured personal info:`, capturedData.userInfo);
             }
+            resolvePersonalInfo();
           }
 
-          // 监听创作者主页数据
-          if (url.includes('/api/galaxy/creator/home/home_page') ||
-            url.includes('/api/galaxy/creator/data')) {
+          // 监听笔记列表 API (获取作品数) - 新版 edith API
+          // URL: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted
+          // 返回结构: { data: { tags: [{ name: "所有笔记", notes_count: 1 }] } }
+          if (url.includes('edith.xiaohongshu.com') && url.includes('/creator/note/user/posted')) {
             const data = await response.json();
-            logger.info(`[Xiaohongshu API] Creator home response:`, JSON.stringify(data).slice(0, 500));
-
-            if (data?.data) {
-              const homeData = data.data;
-              if (homeData.fans_count !== undefined) {
-                capturedData.userInfo = capturedData.userInfo || {};
-                capturedData.userInfo.fans = homeData.fans_count;
-              }
-              if (homeData.note_count !== undefined) {
-                capturedData.userInfo = capturedData.userInfo || {};
-                capturedData.userInfo.notes = homeData.note_count;
+            logger.info(`[Xiaohongshu API] Posted notes (edith):`, JSON.stringify(data).slice(0, 800));
+            
+            if (data?.data?.tags && Array.isArray(data.data.tags)) {
+              // 从 tags 数组中找到 "所有笔记" 的 notes_count
+              const allNotesTag = data.data.tags.find((tag: { id?: string; name?: string; notes_count?: number }) => 
+                tag.id?.includes('note_time') || tag.name === '所有笔记'
+              );
+              if (allNotesTag?.notes_count !== undefined) {
+                capturedData.homeData = capturedData.homeData || {};
+                capturedData.homeData.notes = allNotesTag.notes_count;
+                logger.info(`[Xiaohongshu API] Total notes from edith API: ${allNotesTag.notes_count}`);
               }
             }
+            resolveNotesCount();
           }
-        } catch {
+        } catch (e) {
           // 忽略非 JSON 响应
+          logger.debug(`[Xiaohongshu API] Failed to parse response: ${url}`);
         }
       });
 
-      // 访问创作者中心
-      logger.info('[Xiaohongshu] Navigating to creator center...');
-      await this.page.goto(this.creatorHomeUrl, {
-        waitUntil: 'domcontentloaded',
+      // 1. 先访问创作者首页获取用户信息
+      // URL: https://creator.xiaohongshu.com/new/home
+      // API: /api/galaxy/creator/home/personal_info
+      logger.info('[Xiaohongshu] Navigating to creator home...');
+      await this.page.goto('https://creator.xiaohongshu.com/new/home', {
+        waitUntil: 'networkidle',
         timeout: 30000,
       });
 
-      await this.page.waitForTimeout(3000);
+      // 等待 personal_info API 响应
+      await Promise.race([personalInfoPromise, this.page.waitForTimeout(5000)]);
+      logger.info(`[Xiaohongshu] After home page, capturedData.userInfo:`, capturedData.userInfo);
+
+      // 2. 再访问笔记管理页面获取作品数
+      // URL: https://creator.xiaohongshu.com/new/note-manager
+      // API: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted
+      logger.info('[Xiaohongshu] Navigating to note manager...');
+      await this.page.goto('https://creator.xiaohongshu.com/new/note-manager', {
+        waitUntil: 'networkidle',
+        timeout: 30000,
+      });
+
+      // 等待 notes API 响应
+      await Promise.race([notesCountPromise, this.page.waitForTimeout(5000)]);
+      logger.info(`[Xiaohongshu] After note manager, capturedData.homeData:`, capturedData.homeData);
 
       // 检查是否需要登录
       const currentUrl = this.page.url();
@@ -240,84 +275,59 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
         };
       }
 
-      // 等待 API 响应
-      await this.page.waitForTimeout(3000);
-
       // 使用捕获的数据
       if (capturedData.userInfo) {
-        if (capturedData.userInfo.nickname) {
-          accountName = capturedData.userInfo.nickname;
-        }
-        if (capturedData.userInfo.avatar) {
-          avatarUrl = capturedData.userInfo.avatar;
-        }
-        if (capturedData.userInfo.userId) {
-          accountId = `xiaohongshu_${capturedData.userInfo.userId}`;
-        } else if (capturedData.userInfo.redId) {
-          accountId = `xiaohongshu_${capturedData.userInfo.redId}`;
-        }
-        if (capturedData.userInfo.fans) {
-          fansCount = capturedData.userInfo.fans;
-        }
-        if (capturedData.userInfo.notes) {
-          worksCount = capturedData.userInfo.notes;
-        }
+        if (capturedData.userInfo.nickname) accountName = capturedData.userInfo.nickname;
+        if (capturedData.userInfo.avatar) avatarUrl = capturedData.userInfo.avatar;
+        if (capturedData.userInfo.userId) accountId = `xiaohongshu_${capturedData.userInfo.userId}`;
+        else if (capturedData.userInfo.redId) accountId = `xiaohongshu_${capturedData.userInfo.redId}`;
+        if (capturedData.userInfo.fans !== undefined) fansCount = capturedData.userInfo.fans;
       }
 
-      // 尝试获取作品列表
-      try {
-        await this.page.goto(this.contentManageUrl, {
-          waitUntil: 'domcontentloaded',
-          timeout: 30000,
-        });
-        await this.page.waitForTimeout(3000);
-
-        worksList = await this.page.evaluate(() => {
-          const items: WorkItem[] = [];
-          const cards = document.querySelectorAll('[class*="note-item"], [class*="content-item"]');
-
-          cards.forEach((card) => {
-            try {
-              const coverImg = card.querySelector('img');
-              const coverUrl = coverImg?.src || '';
-
-              const titleEl = card.querySelector('[class*="title"], [class*="desc"]');
-              const title = titleEl?.textContent?.trim() || '无标题';
-
-              const timeEl = card.querySelector('[class*="time"], [class*="date"]');
-              const publishTime = timeEl?.textContent?.trim() || '';
-
-              const statusEl = card.querySelector('[class*="status"]');
-              const status = statusEl?.textContent?.trim() || '';
-
-              // 获取数据指标
-              const statsEl = card.querySelector('[class*="stats"], [class*="data"]');
-              const statsText = statsEl?.textContent || '';
-              
-              const likeMatch = statsText.match(/(\d+)\s*赞/);
-              const commentMatch = statsText.match(/(\d+)\s*评/);
-              const collectMatch = statsText.match(/(\d+)\s*藏/);
-
-              items.push({
-                title,
-                coverUrl,
-                duration: '',
-                publishTime,
-                status,
-                playCount: 0,
-                likeCount: likeMatch ? parseInt(likeMatch[1]) : 0,
-                commentCount: commentMatch ? parseInt(commentMatch[1]) : 0,
-                shareCount: collectMatch ? parseInt(collectMatch[1]) : 0,
-              });
-            } catch {}
-          });
+      // homeData.notes 来自笔记列表 API,直接使用(优先级最高)
+      if (capturedData.homeData) {
+        if (capturedData.homeData.notes !== undefined) {
+          worksCount = capturedData.homeData.notes;
+          logger.info(`[Xiaohongshu] Using notes count from API: ${worksCount}`);
+        }
+      }
 
-          return items;
+      // 如果 API 没捕获到,尝试从页面 DOM 获取
+      if (fansCount === 0 || worksCount === 0) {
+        const statsData = await this.page.evaluate(() => {
+          const result = { fans: 0, notes: 0, name: '', avatar: '' };
+          
+          // 获取页面文本
+          const allText = document.body.innerText;
+          
+          // 尝试匹配粉丝数
+          const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/);
+          if (fansMatch) {
+            const numStr = fansMatch[1] || fansMatch[2];
+            result.fans = Math.floor(parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1));
+          }
+          
+          // 尝试匹配笔记数
+          const notesMatch = allText.match(/笔记[::\s]*(\d+)|(\d+)\s*篇?笔记|共\s*(\d+)\s*篇/);
+          if (notesMatch) {
+            result.notes = parseInt(notesMatch[1] || notesMatch[2] || notesMatch[3]);
+          }
+          
+          // 获取用户名
+          const nameEl = document.querySelector('[class*="nickname"], [class*="user-name"], [class*="creator-name"]');
+          if (nameEl) result.name = nameEl.textContent?.trim() || '';
+          
+          // 获取头像
+          const avatarEl = document.querySelector('[class*="avatar"] img, [class*="user-avatar"] img');
+          if (avatarEl) result.avatar = (avatarEl as HTMLImageElement).src || '';
+          
+          return result;
         });
-
-        logger.info(`[Xiaohongshu] Fetched ${worksList.length} works`);
-      } catch (e) {
-        logger.warn('[Xiaohongshu] Failed to fetch works list:', e);
+        
+        if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans;
+        if (worksCount === 0 && statsData.notes > 0) worksCount = statsData.notes;
+        if ((!accountName || accountName === '小红书账号') && statsData.name) accountName = statsData.name;
+        if (!avatarUrl && statsData.avatar) avatarUrl = statsData.avatar;
       }
 
       await this.closeBrowser();
@@ -1187,6 +1197,222 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
   }
 
   /**
+   * 删除已发布的作品
+   * 使用小红书笔记管理页面: https://creator.xiaohongshu.com/new/note-manager
+   */
+  async deleteWork(
+    cookies: string,
+    noteId: string,
+    onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
+  ): Promise<{ success: boolean; errorMessage?: string }> {
+    try {
+      // 使用无头浏览器后台运行
+      await this.initBrowser({ headless: true });
+      await this.setCookies(cookies);
+
+      if (!this.page) throw new Error('Page not initialized');
+
+      logger.info(`[Xiaohongshu Delete] Starting delete for note: ${noteId}`);
+
+      // 访问笔记管理页面(新版)
+      const noteManagerUrl = 'https://creator.xiaohongshu.com/new/note-manager';
+      await this.page.goto(noteManagerUrl, {
+        waitUntil: 'networkidle',
+        timeout: 60000,
+      });
+
+      await this.page.waitForTimeout(3000);
+
+      // 检查是否需要登录
+      const currentUrl = this.page.url();
+      if (currentUrl.includes('login') || currentUrl.includes('passport')) {
+        throw new Error('登录已过期,请重新登录');
+      }
+
+      logger.info(`[Xiaohongshu Delete] Current URL: ${currentUrl}`);
+
+      // 截图用于调试
+      try {
+        const screenshotPath = `uploads/debug/xhs_delete_page_${Date.now()}.png`;
+        await this.page.screenshot({ path: screenshotPath, fullPage: true });
+        logger.info(`[Xiaohongshu Delete] Page screenshot: ${screenshotPath}`);
+      } catch {}
+
+      // 在笔记管理页面找到对应的笔记行
+      // 页面结构:
+      // - 每条笔记是 div.note 元素
+      // - 笔记ID在 data-impression 属性的 JSON 中: noteId: "xxx"
+      // - 删除按钮是 span.control.data-del 内的 <span>删除</span>
+      let deleteClicked = false;
+      
+      // 方式1: 通过 data-impression 属性找到对应笔记,然后点击其删除按钮
+      logger.info(`[Xiaohongshu Delete] Looking for note with ID: ${noteId}`);
+      
+      // 查找所有笔记卡片
+      const noteCards = this.page.locator('div.note');
+      const noteCount = await noteCards.count();
+      logger.info(`[Xiaohongshu Delete] Found ${noteCount} note cards`);
+      
+      for (let i = 0; i < noteCount; i++) {
+        const card = noteCards.nth(i);
+        const impression = await card.getAttribute('data-impression').catch(() => '');
+        
+        // 检查 data-impression 中是否包含目标 noteId
+        if (impression && impression.includes(noteId)) {
+          logger.info(`[Xiaohongshu Delete] Found target note at index ${i}`);
+          
+          // 在该笔记卡片内查找删除按钮 (span.data-del)
+          const deleteBtn = card.locator('span.data-del, span.control.data-del').first();
+          if (await deleteBtn.count() > 0) {
+            await deleteBtn.click();
+            deleteClicked = true;
+            logger.info(`[Xiaohongshu Delete] Clicked delete button for note ${noteId}`);
+            break;
+          }
+        }
+      }
+
+      // 方式2: 如果方式1没找到,尝试直接用 evaluate 在 DOM 中查找
+      if (!deleteClicked) {
+        logger.info('[Xiaohongshu Delete] Trying evaluate method to find note by data-impression...');
+        deleteClicked = await this.page.evaluate((nid: string) => {
+          // 查找所有 div.note 元素
+          const notes = document.querySelectorAll('div.note');
+          console.log(`[XHS Delete] Found ${notes.length} note elements`);
+          
+          for (const note of notes) {
+            const impression = note.getAttribute('data-impression') || '';
+            if (impression.includes(nid)) {
+              console.log(`[XHS Delete] Found note with ID ${nid}`);
+              
+              // 查找删除按钮
+              const deleteBtn = note.querySelector('span.data-del') || 
+                               note.querySelector('.control.data-del');
+              if (deleteBtn) {
+                console.log(`[XHS Delete] Clicking delete button`);
+                (deleteBtn as HTMLElement).click();
+                return true;
+              }
+            }
+          }
+          
+          return false;
+        }, noteId);
+        
+        if (deleteClicked) {
+          logger.info('[Xiaohongshu Delete] Delete button clicked via evaluate');
+        }
+      }
+      
+      // 方式3: 如果还没找到,尝试点击第一个可见的删除按钮
+      if (!deleteClicked) {
+        logger.info('[Xiaohongshu Delete] Trying to click first visible delete button...');
+        
+        const allDeleteBtns = this.page.locator('span.data-del');
+        const btnCount = await allDeleteBtns.count();
+        logger.info(`[Xiaohongshu Delete] Found ${btnCount} delete buttons on page`);
+        
+        for (let i = 0; i < btnCount; i++) {
+          const btn = allDeleteBtns.nth(i);
+          if (await btn.isVisible().catch(() => false)) {
+            await btn.click();
+            deleteClicked = true;
+            logger.info(`[Xiaohongshu Delete] Clicked delete button ${i}`);
+            break;
+          }
+        }
+      }
+
+      if (!deleteClicked) {
+        // 截图调试
+        try {
+          const screenshotPath = `uploads/debug/xhs_delete_no_btn_${Date.now()}.png`;
+          await this.page.screenshot({ path: screenshotPath, fullPage: true });
+          logger.info(`[Xiaohongshu Delete] No delete button found, screenshot: ${screenshotPath}`);
+        } catch {}
+        throw new Error('未找到删除按钮');
+      }
+
+      await this.page.waitForTimeout(1000);
+
+      // 检查是否需要验证码
+      const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0;
+
+      if (captchaVisible && onCaptchaRequired) {
+        logger.info('[Xiaohongshu Delete] Captcha required');
+
+        // 点击发送验证码
+        const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first();
+        if (await sendCodeBtn.count() > 0) {
+          await sendCodeBtn.click();
+          logger.info('[Xiaohongshu Delete] Verification code sent');
+        }
+
+        // 通过回调获取验证码
+        const taskId = `delete_xhs_${noteId}_${Date.now()}`;
+        const code = await onCaptchaRequired({ taskId });
+
+        if (code) {
+          // 输入验证码
+          const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first();
+          if (await codeInput.count() > 0) {
+            await codeInput.fill(code);
+            logger.info('[Xiaohongshu Delete] Verification code entered');
+          }
+
+          // 点击确认按钮
+          const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first();
+          if (await confirmBtn.count() > 0) {
+            await confirmBtn.click();
+            await this.page.waitForTimeout(2000);
+          }
+        }
+      }
+
+      // 确认删除(可能有二次确认弹窗)
+      const confirmDeleteSelectors = [
+        'button:has-text("确认删除")',
+        'button:has-text("确定")',
+        'button:has-text("确认")',
+        '[class*="modal"] button[class*="primary"]',
+        '[class*="dialog"] button[class*="confirm"]',
+        '.d-button.red:has-text("确")',
+      ];
+
+      for (const selector of confirmDeleteSelectors) {
+        const confirmBtn = this.page.locator(selector).first();
+        if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) {
+          await confirmBtn.click();
+          logger.info(`[Xiaohongshu Delete] Confirm button clicked via: ${selector}`);
+          await this.page.waitForTimeout(1000);
+        }
+      }
+
+      // 等待删除完成
+      await this.page.waitForTimeout(2000);
+
+      // 检查是否删除成功(页面刷新或出现成功提示)
+      const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("删除成功")').count();
+      if (successToast > 0) {
+        logger.info('[Xiaohongshu Delete] Delete success toast found');
+      }
+
+      logger.info('[Xiaohongshu Delete] Delete completed');
+      await this.closeBrowser();
+
+      return { success: true };
+
+    } catch (error) {
+      logger.error('[Xiaohongshu Delete] Error:', error);
+      await this.closeBrowser();
+      return {
+        success: false,
+        errorMessage: error instanceof Error ? error.message : '删除失败',
+      };
+    }
+  }
+
+  /**
    * 获取数据统计
    */
   async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {

+ 79 - 13
server/src/scheduler/index.ts

@@ -158,7 +158,8 @@ export class TaskScheduler {
   }
   
   /**
-   * 刷新账号状态
+   * 刷新账号状态和信息
+   * 并行执行多个账号的刷新,提高效率
    */
   private async refreshAccounts(): Promise<void> {
     const accountRepository = AppDataSource.getRepository(PlatformAccount);
@@ -167,23 +168,88 @@ export class TaskScheduler {
       where: { status: 'active' },
     });
     
-    for (const account of accounts) {
-      if (!isPlatformSupported(account.platform)) continue;
+    logger.info(`Refreshing ${accounts.length} active accounts...`);
+    
+    // 并行刷新所有账号(限制并发数为5)
+    const concurrencyLimit = 5;
+    const results: Promise<void>[] = [];
+    
+    for (let i = 0; i < accounts.length; i++) {
+      const account = accounts[i];
       
-      try {
-        const adapter = getAdapter(account.platform);
-        const isLoggedIn = await adapter.checkLoginStatus(account.cookieData || '');
+      const refreshPromise = (async () => {
+        if (!isPlatformSupported(account.platform)) return;
         
-        if (!isLoggedIn) {
-          await accountRepository.update(account.id, { status: 'expired' });
-          wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
-            account: { id: account.id, status: 'expired' },
-          });
+        try {
+          const adapter = getAdapter(account.platform);
+          
+          // 先检查登录状态
+          const isLoggedIn = await adapter.checkLoginStatus(account.cookieData || '');
+          
+          if (!isLoggedIn) {
+            await accountRepository.update(account.id, { status: 'expired' });
+            wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
+              account: { id: account.id, status: 'expired' },
+            });
+            logger.info(`Account ${account.id} (${account.accountName}) expired`);
+            return;
+          }
+          
+          // 登录有效,获取最新账号信息
+          try {
+            const accountInfo = await adapter.getAccountInfo(account.cookieData || '');
+            
+            if (accountInfo) {
+              // 更新账号信息
+              const updateData: Partial<PlatformAccount> = {
+                fansCount: accountInfo.fansCount ?? account.fansCount,
+                worksCount: accountInfo.worksCount ?? account.worksCount,
+                updatedAt: new Date(),
+              };
+              
+              // 如果有头像更新
+              if (accountInfo.avatarUrl && accountInfo.avatarUrl !== account.avatarUrl) {
+                updateData.avatarUrl = accountInfo.avatarUrl;
+              }
+              
+              await accountRepository.update(account.id, updateData);
+              
+              // 通知前端账号已更新
+              wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
+                account: { 
+                  id: account.id, 
+                  ...updateData,
+                  status: 'active',
+                },
+              });
+              
+              logger.info(`Account ${account.id} (${account.accountName}) refreshed: fans=${accountInfo.fansCount}, works=${accountInfo.worksCount}`);
+            }
+          } catch (infoError) {
+            // 获取账号信息失败不影响登录状态
+            logger.warn(`Failed to get account info for ${account.id}:`, infoError);
+          }
+          
+        } catch (error) {
+          logger.error(`Check account ${account.id} status failed:`, error);
         }
-      } catch (error) {
-        logger.error(`Check account ${account.id} status failed:`, error);
+      })();
+      
+      results.push(refreshPromise);
+      
+      // 控制并发数
+      if (results.length >= concurrencyLimit) {
+        await Promise.all(results);
+        results.length = 0;
       }
     }
+    
+    // 等待剩余的任务完成
+    if (results.length > 0) {
+      await Promise.all(results);
+    }
+    
+    logger.info('Account refresh completed');
   }
   
   /**

+ 427 - 0
server/src/services/RedisTaskQueue.ts

@@ -0,0 +1,427 @@
+import { Queue, Worker, Job, QueueEvents } from 'bullmq';
+import IORedis from 'ioredis';
+import { v4 as uuidv4 } from 'uuid';
+import { 
+  Task, 
+  TaskType, 
+  TaskResult,
+  TaskProgressUpdate,
+  CreateTaskRequest,
+  TASK_WS_EVENTS,
+} from '@media-manager/shared';
+import { wsManager } from '../websocket/index.js';
+import { logger } from '../utils/logger.js';
+import { config } from '../config/index.js';
+
+// Redis 连接配置
+const redisConnection = new IORedis({
+  host: config.redis.host,
+  port: config.redis.port,
+  password: config.redis.password || undefined,
+  db: config.redis.db,
+  maxRetriesPerRequest: null,  // BullMQ 需要这个设置
+});
+
+// 任务执行器类型
+type TaskExecutor = (
+  task: Task, 
+  updateProgress: (update: Partial<TaskProgressUpdate>) => void
+) => Promise<TaskResult>;
+
+// 队列名称
+const QUEUE_NAME = 'media-manager-tasks';
+
+/**
+ * 基于 Redis (BullMQ) 的任务队列服务
+ * 支持分布式、持久化、并行处理
+ */
+class RedisTaskQueueService {
+  private queue: Queue;
+  private queueEvents: QueueEvents;
+  private worker: Worker | null = null;
+  
+  // 任务执行器 Map<TaskType, TaskExecutor>
+  private executors: Map<TaskType, TaskExecutor> = new Map();
+  
+  // 内存中缓存用户任务(用于快速查询)
+  private userTasks: Map<number, Task[]> = new Map();
+  
+  // 最大并行任务数
+  private concurrency = 5;
+
+  constructor() {
+    // 创建队列
+    this.queue = new Queue(QUEUE_NAME, {
+      connection: redisConnection,
+      defaultJobOptions: {
+        removeOnComplete: { count: 100 },  // 保留最近100个完成的任务
+        removeOnFail: { count: 50 },        // 保留最近50个失败的任务
+        attempts: 3,                        // 失败重试次数
+        backoff: {
+          type: 'exponential',
+          delay: 1000,
+        },
+      },
+    });
+
+    // 创建队列事件监听
+    this.queueEvents = new QueueEvents(QUEUE_NAME, {
+      connection: redisConnection.duplicate(),
+    });
+
+    this.setupEventListeners();
+    
+    logger.info('Redis Task Queue Service initialized');
+  }
+
+  /**
+   * 设置事件监听
+   */
+  private setupEventListeners(): void {
+    this.queueEvents.on('completed', async ({ jobId }) => {
+      logger.info(`Job ${jobId} completed`);
+    });
+
+    this.queueEvents.on('failed', async ({ jobId, failedReason }) => {
+      logger.error(`Job ${jobId} failed: ${failedReason}`);
+    });
+
+    this.queueEvents.on('progress', async ({ jobId, data }) => {
+      logger.debug(`Job ${jobId} progress:`, data);
+    });
+  }
+
+  /**
+   * 启动 Worker(处理任务)
+   */
+  startWorker(): void {
+    if (this.worker) {
+      logger.warn('Worker already running');
+      return;
+    }
+
+    this.worker = new Worker(
+      QUEUE_NAME,
+      async (job: Job) => {
+        return this.processJob(job);
+      },
+      {
+        connection: redisConnection.duplicate(),
+        concurrency: this.concurrency,  // 并行处理任务数
+      }
+    );
+
+    this.worker.on('completed', (job, result) => {
+      logger.info(`Worker completed job ${job.id}: ${result?.message || 'success'}`);
+    });
+
+    this.worker.on('failed', (job, error) => {
+      logger.error(`Worker failed job ${job?.id}:`, error);
+    });
+
+    this.worker.on('error', (error) => {
+      logger.error('Worker error:', error);
+    });
+
+    logger.info(`Redis Task Queue Worker started with concurrency: ${this.concurrency}`);
+  }
+
+  /**
+   * 停止 Worker
+   */
+  async stopWorker(): Promise<void> {
+    if (this.worker) {
+      await this.worker.close();
+      this.worker = null;
+      logger.info('Redis Task Queue Worker stopped');
+    }
+  }
+
+  /**
+   * 处理任务
+   */
+  private async processJob(job: Job): Promise<TaskResult> {
+    const taskData = job.data as Task & { userId: number };
+    const { userId } = taskData;
+    
+    const executor = this.executors.get(taskData.type);
+    if (!executor) {
+      throw new Error(`No executor registered for task type: ${taskData.type}`);
+    }
+
+    // 更新内存缓存中的任务状态
+    this.updateTaskInCache(userId, taskData.id, {
+      status: 'running',
+      startedAt: new Date().toISOString(),
+    });
+
+    // 通知前端任务开始
+    this.notifyUser(userId, TASK_WS_EVENTS.TASK_STARTED, { 
+      task: this.getTaskFromCache(userId, taskData.id) 
+    });
+
+    // 进度更新回调
+    const updateProgress = async (update: Partial<TaskProgressUpdate>) => {
+      // 更新 Job 进度
+      await job.updateProgress(update);
+      
+      // 更新内存缓存
+      this.updateTaskInCache(userId, taskData.id, {
+        progress: update.progress,
+        currentStep: update.currentStep,
+        currentStepIndex: update.currentStepIndex,
+      });
+
+      // 通知前端
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_PROGRESS, {
+        taskId: taskData.id,
+        progress: update.progress,
+        currentStep: update.currentStep,
+        currentStepIndex: update.currentStepIndex,
+        message: update.message,
+      });
+    };
+
+    try {
+      const result = await executor(taskData, updateProgress);
+      
+      // 更新缓存
+      this.updateTaskInCache(userId, taskData.id, {
+        status: 'completed',
+        progress: 100,
+        result,
+        completedAt: new Date().toISOString(),
+      });
+
+      // 通知前端
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_COMPLETED, { 
+        task: this.getTaskFromCache(userId, taskData.id) 
+      });
+
+      return result;
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '任务执行失败';
+      
+      // 更新缓存
+      this.updateTaskInCache(userId, taskData.id, {
+        status: 'failed',
+        error: errorMessage,
+        completedAt: new Date().toISOString(),
+      });
+
+      // 通知前端
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_FAILED, { 
+        task: this.getTaskFromCache(userId, taskData.id) 
+      });
+
+      throw error;
+    }
+  }
+
+  /**
+   * 注册任务执行器
+   */
+  registerExecutor(type: TaskType, executor: TaskExecutor): void {
+    this.executors.set(type, executor);
+    logger.info(`Task executor registered: ${type}`);
+  }
+
+  /**
+   * 创建新任务
+   */
+  async createTask(userId: number, request: CreateTaskRequest): Promise<Task & { userId: number }> {
+    const task: Task & { userId: number; [key: string]: unknown } = {
+      id: uuidv4(),
+      type: request.type,
+      title: request.title || this.getDefaultTitle(request.type),
+      description: request.description,
+      status: 'pending',
+      progress: 0,
+      priority: request.priority || 'normal',
+      createdAt: new Date().toISOString(),
+      accountId: request.accountId,
+      userId,
+      ...(request.data || {}),
+    };
+
+    // 添加到内存缓存
+    if (!this.userTasks.has(userId)) {
+      this.userTasks.set(userId, []);
+    }
+    this.userTasks.get(userId)!.push(task);
+
+    // 添加到 Redis 队列
+    const priority = request.priority === 'high' ? 1 : (request.priority === 'low' ? 3 : 2);
+    await this.queue.add(task.type, task, {
+      jobId: task.id,
+      priority,
+    });
+
+    // 通知前端
+    this.notifyUser(userId, TASK_WS_EVENTS.TASK_CREATED, { task });
+
+    logger.info(`Task created and queued: ${task.id} (${task.type}) for user ${userId}`);
+
+    return task;
+  }
+
+  /**
+   * 获取用户的所有任务
+   */
+  getUserTasks(userId: number): Task[] {
+    return this.userTasks.get(userId) || [];
+  }
+
+  /**
+   * 获取用户的活跃任务
+   */
+  getActiveTasks(userId: number): Task[] {
+    const tasks = this.userTasks.get(userId) || [];
+    return tasks.filter(t => t.status === 'pending' || t.status === 'running');
+  }
+
+  /**
+   * 取消任务
+   */
+  async cancelTask(userId: number, taskId: string): Promise<boolean> {
+    const tasks = this.userTasks.get(userId);
+    if (!tasks) return false;
+
+    const task = tasks.find(t => t.id === taskId);
+    if (!task) return false;
+
+    if (task.status === 'pending') {
+      // 从队列中移除
+      const job = await this.queue.getJob(taskId);
+      if (job) {
+        await job.remove();
+      }
+
+      // 更新缓存
+      task.status = 'cancelled';
+      task.completedAt = new Date().toISOString();
+
+      // 通知前端
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_CANCELLED, { task });
+      
+      logger.info(`Task cancelled: ${taskId}`);
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * 清理已完成的任务
+   */
+  cleanupCompletedTasks(userId: number, keepCount = 10): void {
+    const tasks = this.userTasks.get(userId);
+    if (!tasks) return;
+
+    const completedTasks = tasks.filter(t => 
+      t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'
+    );
+
+    if (completedTasks.length > keepCount) {
+      completedTasks.sort((a, b) => 
+        new Date(b.completedAt || 0).getTime() - new Date(a.completedAt || 0).getTime()
+      );
+      
+      const toRemove = completedTasks.slice(keepCount);
+      const toRemoveIds = new Set(toRemove.map(t => t.id));
+      
+      this.userTasks.set(userId, tasks.filter(t => !toRemoveIds.has(t.id)));
+    }
+  }
+
+  /**
+   * 发送任务列表给用户
+   */
+  sendTaskList(userId: number): void {
+    const tasks = this.getUserTasks(userId);
+    wsManager.sendToUser(userId, TASK_WS_EVENTS.TASK_LIST, {
+      event: 'list',
+      tasks,
+    });
+  }
+
+  /**
+   * 获取队列统计信息
+   */
+  async getQueueStats(): Promise<{
+    waiting: number;
+    active: number;
+    completed: number;
+    failed: number;
+    delayed: number;
+  }> {
+    const [waiting, active, completed, failed, delayed] = await Promise.all([
+      this.queue.getWaitingCount(),
+      this.queue.getActiveCount(),
+      this.queue.getCompletedCount(),
+      this.queue.getFailedCount(),
+      this.queue.getDelayedCount(),
+    ]);
+
+    return { waiting, active, completed, failed, delayed };
+  }
+
+  /**
+   * 更新内存缓存中的任务
+   */
+  private updateTaskInCache(userId: number, taskId: string, updates: Partial<Task>): void {
+    const tasks = this.userTasks.get(userId);
+    if (!tasks) return;
+
+    const task = tasks.find(t => t.id === taskId);
+    if (task) {
+      Object.assign(task, updates);
+    }
+  }
+
+  /**
+   * 从缓存获取任务
+   */
+  private getTaskFromCache(userId: number, taskId: string): Task | undefined {
+    const tasks = this.userTasks.get(userId);
+    return tasks?.find(t => t.id === taskId);
+  }
+
+  /**
+   * 通知用户
+   */
+  private notifyUser(userId: number, event: string, data: Record<string, unknown>): void {
+    wsManager.sendToUser(userId, event, {
+      event: event.split(':')[1],
+      ...data,
+    });
+  }
+
+  /**
+   * 获取默认任务标题
+   */
+  private getDefaultTitle(type: TaskType): string {
+    const titles: Record<TaskType, string> = {
+      sync_comments: '同步评论',
+      sync_works: '同步作品',
+      sync_account: '同步账号信息',
+      publish_video: '发布视频',
+      batch_reply: '批量回复评论',
+    };
+    return titles[type] || '未知任务';
+  }
+
+  /**
+   * 关闭连接
+   */
+  async close(): Promise<void> {
+    await this.stopWorker();
+    await this.queue.close();
+    await this.queueEvents.close();
+    await redisConnection.quit();
+    logger.info('Redis Task Queue Service closed');
+  }
+}
+
+// 导出单例
+export const redisTaskQueueService = new RedisTaskQueueService();

+ 61 - 9
server/src/services/TaskQueueService.ts

@@ -11,13 +11,17 @@ import {
 } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
 import { logger } from '../utils/logger.js';
+import { config } from '../config/index.js';
 
 // 任务执行器类型
-type TaskExecutor = (
+export type TaskExecutor = (
   task: Task, 
   updateProgress: (update: Partial<TaskProgressUpdate>) => void
 ) => Promise<TaskResult>;
 
+// 检查是否启用 Redis
+const USE_REDIS = process.env.USE_REDIS_QUEUE === 'true';
+
 /**
  * 全局异步任务队列服务
  * 管理所有后台任务的创建、执行、进度追踪
@@ -138,19 +142,21 @@ class TaskQueueService {
   }
 
   /**
-   * 尝试执行下一个任务
+   * 尝试执行下一个任务(支持并行执行多个任务)
    */
-  private async tryExecuteNext(userId: number): Promise<void> {
+  private tryExecuteNext(userId: number): void {
     const tasks = this.userTasks.get(userId);
     if (!tasks) return;
 
     // 检查当前运行中的任务数量
     const runningCount = tasks.filter(t => t.status === 'running').length;
-    if (runningCount >= this.maxConcurrentTasks) {
+    const availableSlots = this.maxConcurrentTasks - runningCount;
+    
+    if (availableSlots <= 0) {
       return;
     }
 
-    // 找到下一个待执行的任务(按优先级排序)
+    // 找到待执行的任务(按优先级排序)
     const pendingTasks = tasks.filter(t => t.status === 'pending');
     if (pendingTasks.length === 0) return;
 
@@ -160,8 +166,12 @@ class TaskQueueService {
       return priorityOrder[a.priority] - priorityOrder[b.priority];
     });
 
-    const nextTask = pendingTasks[0];
-    await this.executeTask(userId, nextTask);
+    // 并行启动多个任务(不使用 await,让它们并行执行)
+    const tasksToStart = pendingTasks.slice(0, availableSlots);
+    for (const task of tasksToStart) {
+      // 使用 void 来明确表示我们不等待这个 Promise
+      void this.executeTask(userId, task);
+    }
   }
 
   /**
@@ -259,7 +269,49 @@ class TaskQueueService {
       tasks,
     });
   }
+
+  /**
+   * 启动 Worker(内存队列模式下为空操作)
+   */
+  startWorker(): void {
+    logger.info('Memory Task Queue started (no worker needed)');
+  }
+
+  /**
+   * 停止 Worker
+   */
+  async stopWorker(): Promise<void> {
+    logger.info('Memory Task Queue stopped');
+  }
+
+  /**
+   * 关闭服务
+   */
+  async close(): Promise<void> {
+    logger.info('Memory Task Queue Service closed');
+  }
+}
+
+// 内存队列单例
+const memoryTaskQueueService = new TaskQueueService();
+
+// 根据配置选择队列实现
+let taskQueueService: TaskQueueService;
+
+if (USE_REDIS) {
+  // 动态导入 Redis 队列
+  import('./RedisTaskQueue.js').then(({ redisTaskQueueService }) => {
+    (taskQueueService as unknown) = redisTaskQueueService;
+    logger.info('Using Redis Task Queue');
+  }).catch((err) => {
+    logger.warn('Failed to load Redis Task Queue, falling back to memory queue:', err.message);
+    taskQueueService = memoryTaskQueueService;
+  });
+  // 初始设置为内存队列(在 Redis 加载完成前使用)
+  taskQueueService = memoryTaskQueueService;
+} else {
+  taskQueueService = memoryTaskQueueService;
+  logger.info('Using Memory Task Queue');
 }
 
-// 导出单例
-export const taskQueueService = new TaskQueueService();
+export { taskQueueService };

+ 18 - 2
server/src/services/WorkService.ts

@@ -316,12 +316,13 @@ export class WorkService {
 
   /**
    * 删除平台上的作品
+   * @returns 包含 accountId 用于后续刷新作品列表
    */
   async deletePlatformWork(
     userId: number, 
     workId: number,
     onCaptchaRequired?: (captchaInfo: { taskId: string }) => Promise<string>
-  ): Promise<{ success: boolean; errorMessage?: string }> {
+  ): Promise<{ success: boolean; errorMessage?: string; accountId?: number }> {
     const work = await this.workRepository.findOne({
       where: { id: workId, userId },
       relations: ['account'],
@@ -360,7 +361,22 @@ export class WorkService {
         logger.info(`Platform work ${workId} deleted successfully`);
       }
       
-      return result;
+      return { ...result, accountId: account.id };
+    }
+    
+    if (account.platform === 'xiaohongshu') {
+      const { XiaohongshuAdapter } = await import('../automation/platforms/xiaohongshu.js');
+      const adapter = new XiaohongshuAdapter();
+      
+      const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired);
+      
+      if (result.success) {
+        // 更新作品状态为已删除
+        await this.workRepository.update(workId, { status: 'deleted' });
+        logger.info(`Platform work ${workId} (xiaohongshu) deleted successfully`);
+      }
+      
+      return { ...result, accountId: account.id };
     }
     
     return { success: false, errorMessage: '暂不支持该平台删除功能' };

+ 18 - 2
server/src/services/taskExecutors.ts

@@ -159,7 +159,7 @@ async function deleteWorkExecutor(task: Task, updateProgress: ProgressUpdater):
   const result = await workService.deletePlatformWork(userId, taskData.workId);
 
   if (result.success) {
-    updateProgress({ progress: 80, currentStep: '删除本地记录...' });
+    updateProgress({ progress: 70, currentStep: '删除本地记录...' });
     
     // 平台删除成功后,删除本地记录
     try {
@@ -169,13 +169,29 @@ async function deleteWorkExecutor(task: Task, updateProgress: ProgressUpdater):
       logger.warn(`Failed to delete local work ${taskData.workId}:`, error);
       // 本地删除失败不影响整体结果
     }
+    
+    // 删除成功后,自动创建同步作品任务刷新作品列表
+    if (result.accountId) {
+      updateProgress({ progress: 90, currentStep: '刷新作品列表...' });
+      try {
+        taskQueueService.createTask(userId, {
+          type: 'sync_works',
+          title: '刷新作品列表',
+          accountId: result.accountId,
+        });
+        logger.info(`Created sync_works task for account ${result.accountId} after delete`);
+      } catch (syncError) {
+        logger.warn(`Failed to create sync_works task after delete:`, syncError);
+        // 同步任务创建失败不影响删除结果
+      }
+    }
   }
 
   updateProgress({ progress: 100, currentStep: result.success ? '删除完成' : '删除失败' });
 
   return {
     success: result.success,
-    message: result.success ? '作品已从平台删除,本地记录已清理' : (result.errorMessage || '删除失败'),
+    message: result.success ? '作品已从平台删除,正在刷新作品列表' : (result.errorMessage || '删除失败'),
   };
 }
 

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