Browse Source

其他类型的节点识别,分组测试和发送订阅报告

Taio_O 1 week ago
parent
commit
90fa5d311c

+ 6 - 4
BOT_USAGE.md

@@ -46,7 +46,9 @@ node start.js
 ### 订阅管理
 - `/subscriptions` - 查看所有订阅列表
 - `/add_subscription` - 添加订阅链接
-- `/remove_subscription` - 删除订阅
+- `/remove_subscription` - 查看删除选项
+- `/delete_1` - 删除第1个订阅
+- `/delete_2` - 删除第2个订阅(以此类推)
 - `/update_subscriptions` - 手动更新所有订阅
 
 ### 测速功能
@@ -64,9 +66,9 @@ trojan://...
 ```
 
 ### 2. 删除订阅
-1. 发送 `/remove_subscription`
-2. 选择要删除的订阅编号
-3. 发送数字(如:1)确认删除
+1. 发送 `/remove_subscription` 查看订阅列表
+2. 使用 `/delete_1` 删除第1个订阅
+3. 使用 `/delete_2` 删除第2个订阅,以此类推
 
 ### 3. 查看状态
 发送 `/status` 查看:

+ 5 - 0
public/index.html

@@ -46,6 +46,11 @@
                             <i class="bi bi-gear"></i> 系统设置
                         </a>
                     </li>
+                    <li class="nav-item">
+                        <a class="nav-link" href="subscription-test-results.html" target="_blank">
+                            <i class="bi bi-collection"></i> 订阅测试
+                        </a>
+                    </li>
                 </ul>
                 <div class="navbar-nav">
                     <span class="navbar-text" id="status-indicator">

+ 1 - 0
public/subscription-test-results.html

@@ -0,0 +1 @@
+ 

+ 146 - 0
src/api/routes.js

@@ -179,6 +179,152 @@ router.post('/import/clash', async (req, res) => {
   }
 });
 
+// 获取按订阅分组的测试结果
+router.get('/subscriptions/test-results', async (req, res) => {
+  try {
+    const { Subscription } = require('../models');
+    
+    // 获取所有活跃订阅
+    const subscriptions = await Subscription.findAll({
+      where: { isActive: true },
+      order: [['createdAt', 'ASC']]
+    });
+
+    const results = [];
+
+    for (const subscription of subscriptions) {
+      // 获取该订阅的节点
+      const nodes = await Node.findAll({
+        where: { 
+          subscriptionId: subscription.id,
+          isActive: true 
+        },
+        order: [['name', 'ASC']]
+      });
+
+      if (nodes.length === 0) {
+        results.push({
+          subscription: {
+            id: subscription.id,
+            name: subscription.name,
+            nodeCount: 0
+          },
+          nodes: [],
+          summary: {
+            totalNodes: 0,
+            onlineNodes: 0,
+            offlineNodes: 0,
+            successRate: 0,
+            averageLatency: null,
+            averageSpeed: null
+          }
+        });
+        continue;
+      }
+
+      // 获取最近的测试结果
+      const testResults = await TestResult.findAll({
+        where: { nodeId: nodes.map(n => n.id) },
+        order: [['testTime', 'DESC']],
+        limit: nodes.length
+      });
+
+      // 按节点分组最新的测试结果
+      const latestResults = new Map();
+      testResults.forEach(result => {
+        if (!latestResults.has(result.nodeId)) {
+          latestResults.set(result.nodeId, result);
+        }
+      });
+
+      // 计算摘要
+      const onlineNodes = nodes.filter(node => {
+        const result = latestResults.get(node.id);
+        return result && result.isSuccess;
+      });
+
+      const offlineNodes = nodes.filter(node => {
+        const result = latestResults.get(node.id);
+        return !result || !result.isSuccess;
+      });
+
+      const successRate = nodes.length > 0 ? Math.round((onlineNodes.length / nodes.length) * 100) : 0;
+
+      // 计算平均延迟
+      const successfulResults = Array.from(latestResults.values()).filter(r => r.isSuccess && r.latency);
+      const averageLatency = successfulResults.length > 0 
+        ? Math.round(successfulResults.reduce((sum, r) => sum + r.latency, 0) / successfulResults.length)
+        : null;
+
+      // 获取最佳节点
+      const bestNodes = Array.from(latestResults.values())
+        .filter(r => r.isSuccess && r.latency)
+        .sort((a, b) => a.latency - b.latency)
+        .slice(0, 3)
+        .map(result => {
+          const node = nodes.find(n => n.id === result.nodeId);
+          return {
+            name: node ? node.name : 'Unknown',
+            latency: result.latency
+          };
+        });
+
+      results.push({
+        subscription: {
+          id: subscription.id,
+          name: subscription.name,
+          nodeCount: nodes.length
+        },
+        nodes: nodes.map(node => {
+          const result = latestResults.get(node.id);
+          return {
+            id: node.id,
+            name: node.name,
+            type: node.type,
+            server: node.server,
+            port: node.port,
+            status: node.status,
+            lastTestTime: node.lastTestTime,
+            lastTestResult: node.lastTestResult,
+            averageLatency: node.averageLatency,
+            failureCount: node.failureCount,
+            testResult: result ? {
+              isSuccess: result.isSuccess,
+              latency: result.latency,
+              testTime: result.testTime,
+              errorMessage: result.errorMessage
+            } : null
+          };
+        }),
+        summary: {
+          totalNodes: nodes.length,
+          onlineNodes: onlineNodes.length,
+          offlineNodes: offlineNodes.length,
+          successRate,
+          averageLatency,
+          bestNodes
+        }
+      });
+    }
+
+    res.json({
+      success: true,
+      data: {
+        subscriptions: results,
+        totalSubscriptions: results.length,
+        totalNodes: results.reduce((sum, r) => sum + r.summary.totalNodes, 0),
+        totalOnlineNodes: results.reduce((sum, r) => sum + r.summary.onlineNodes, 0),
+        overallSuccessRate: results.length > 0 
+          ? Math.round(results.reduce((sum, r) => sum + r.summary.successRate, 0) / results.length)
+          : 0
+      }
+    });
+  } catch (error) {
+    logger.error('获取订阅测试结果失败', { error: error.message });
+    res.status(500).json({ success: false, error: error.message });
+  }
+});
+
 // 订阅管理API
 router.post('/subscription/update', async (req, res) => {
   try {

+ 15 - 0
src/app.js

@@ -101,6 +101,21 @@ app.use('*', (req, res) => {
   });
 });
 
+// 处理未捕获的Promise拒绝
+process.on('unhandledRejection', (reason, promise) => {
+  logger.error('未处理的Promise拒绝', {
+    reason: {
+      code: reason.code,
+      message: reason.message
+    },
+    promise: {
+      isFulfilled: promise.isFulfilled,
+      isRejected: promise.isRejected,
+      rejectionReason: promise.rejectionReason
+    }
+  });
+});
+
 // 启动应用
 async function startApp() {
   try {

+ 70 - 43
src/core/botManager.js

@@ -59,7 +59,9 @@ class BotManager {
             try {
               this.bot.stopPolling();
               setTimeout(() => {
-                this.bot.startPolling();
+                this.bot.startPolling().catch(reconnectError => {
+                  logger.error('Telegram机器人重连失败', { error: reconnectError.message });
+                });
                 logger.info('Telegram机器人重连成功');
               }, 5000);
             } catch (reconnectError) {
@@ -133,6 +135,11 @@ class BotManager {
       await this.handleRemoveSubscription(msg);
     });
 
+    // 处理删除订阅的具体命令
+    this.bot.onText(/\/delete_(\d+)/, async (msg, match) => {
+      await this.handleDeleteSubscription(msg, parseInt(match[1]));
+    });
+
     // 处理 /update_subscriptions 命令
     this.bot.onText(/\/update_subscriptions/, async (msg) => {
       await this.handleUpdateSubscriptions(msg);
@@ -143,9 +150,19 @@ class BotManager {
       await this.handleTestSpeed(msg);
     });
 
-    // 处理普通消息(用于添加订阅链接
+    // 处理普通消息(只处理订阅链接,不处理其他消息
     this.bot.on('message', async (msg) => {
-      await this.handleMessage(msg);
+      // 只处理文本消息
+      if (!msg.text) return;
+      
+      // 如果是命令,不处理(由命令处理器处理)
+      if (msg.text.startsWith('/')) return;
+      
+      // 只处理订阅链接
+      if (this.isSubscriptionUrl(msg.text)) {
+        await this.addSubscription(msg.chat.id, msg.text);
+      }
+      // 其他消息不回复
     });
   }
 
@@ -195,9 +212,10 @@ class BotManager {
 • /test_speed - 手动触发测速
 
 💡 *使用提示:*
-• 发送订阅链接可直接添加订阅
-• 支持多种格式:Clash、Shadowsocks、Vmess等
-• 系统会自动解析并更新节点信息`;
+• 直接发送订阅链接即可添加订阅
+• 使用 /remove_subscription 查看订阅列表
+• 使用 /delete_1 删除第1个订阅
+• 支持多种格式:Clash、Shadowsocks、Vmess等`;
 
     await this.sendMessage(chatId, message);
   }
@@ -238,7 +256,8 @@ class BotManager {
 💡 *使用示例:*
 • 发送:\`https://example.com/subscription\`
 • 发送:\`ss://...\`
-• 发送:\`vmess://...\``;
+• 发送:\`vmess://...\`
+• 删除:\`/delete_1\` 删除第1个订阅`;
 
     await this.sendMessage(chatId, message);
   }
@@ -381,7 +400,7 @@ class BotManager {
       }
 
       let message = `🗑️ *删除订阅*\n\n`;
-      message += `请选择要删除的订阅(发送数字):\n\n`;
+      message += `请选择要删除的订阅:\n\n`;
       
       subscriptions.forEach((sub, index) => {
         message += `${index + 1}. *${sub.name}*\n`;
@@ -389,7 +408,9 @@ class BotManager {
         message += `   🔗 \`${sub.url}\`\n\n`;
       });
 
-      message += `💡 发送数字(如:1)来删除对应订阅`;
+      message += `💡 使用 /delete_1 删除第1个订阅\n`;
+      message += `💡 使用 /delete_2 删除第2个订阅\n`;
+      message += `💡 以此类推...`;
 
       await this.sendMessage(chatId, message);
     } catch (error) {
@@ -458,40 +479,7 @@ class BotManager {
     }
   }
 
-  /**
-   * 处理普通消息
-   */
-  async handleMessage(msg) {
-    const chatId = msg.chat.id;
-    const text = msg.text;
-    
-    if (!this.isAuthorized(chatId)) {
-      await this.sendMessage(chatId, '❌ 您没有权限使用此机器人');
-      return;
-    }
-
-    // 检查是否是订阅链接
-    if (this.isSubscriptionUrl(text)) {
-      await this.addSubscription(chatId, text);
-      return;
-    }
-
-    // 检查是否是删除订阅的数字
-    if (/^\d+$/.test(text)) {
-      await this.removeSubscriptionByIndex(chatId, parseInt(text));
-      return;
-    }
 
-    // 其他消息
-    await this.sendMessage(chatId, 
-      `💡 *消息处理*\n\n` +
-      `收到消息:\`${text}\`\n\n` +
-      `💡 *使用提示:*\n` +
-      `• 发送订阅链接可直接添加订阅\n` +
-      `• 使用 /help 查看所有命令\n` +
-      `• 使用 /subscriptions 查看订阅列表`
-    );
-  }
 
   /**
    * 检查是否是订阅链接
@@ -539,7 +527,46 @@ class BotManager {
   }
 
   /**
-   * 根据索引删除订阅
+   * 处理删除订阅命令
+   */
+  async handleDeleteSubscription(msg, index) {
+    const chatId = msg.chat.id;
+    
+    if (!this.isAuthorized(chatId)) {
+      await this.sendMessage(chatId, '❌ 您没有权限使用此机器人');
+      return;
+    }
+
+    try {
+      const subscriptions = await Subscription.findAll({
+        where: { isActive: true },
+        order: [['createdAt', 'DESC']]
+      });
+
+      if (index < 1 || index > subscriptions.length) {
+        await this.sendMessage(chatId, '❌ 无效的订阅编号');
+        return;
+      }
+
+      const subscription = subscriptions[index - 1];
+      
+      // 标记为非活跃
+      await subscription.update({ isActive: false });
+      
+      const message = `🗑️ *订阅删除成功*\n\n` +
+        `📡 订阅名称:*${subscription.name}*\n` +
+        `🔗 URL:\`${subscription.url}\`\n` +
+        `📊 节点数:${subscription.nodeCount || 0}`;
+
+      await this.sendMessage(chatId, message);
+    } catch (error) {
+      logger.error('删除订阅失败', { error: error.message, index });
+      await this.sendMessage(chatId, '❌ 删除订阅失败:' + error.message);
+    }
+  }
+
+  /**
+   * 根据索引删除订阅(保留兼容性)
    */
   async removeSubscriptionByIndex(chatId, index) {
     try {

+ 151 - 4
src/core/multiSubscriptionManager.js

@@ -280,8 +280,10 @@ class MultiSubscriptionManager {
 
       return { updated, added, removed, actualNodeCount };
     } catch (error) {
-      // 回滚事务
-      await transaction.rollback();
+      // 检查事务状态,只有在事务仍然活跃时才回滚
+      if (transaction && !transaction.finished) {
+        await transaction.rollback();
+      }
       logger.error('更新订阅节点失败', { 
         error: error.message,
         subscriptionId: subscription.id 
@@ -446,7 +448,7 @@ class MultiSubscriptionManager {
     const proxies = [];
     const proxyKeys = new Set(); // 用于去重
     
-          // 开始解析Shadowsocks内容
+    logger.info('开始解析代理链接内容');
     
     let ssCount = 0;
     let vmessCount = 0;
@@ -479,12 +481,52 @@ class MultiSubscriptionManager {
         }
       } else if (line.startsWith('vmess://')) {
         vmessCount++;
+        try {
+          const proxy = this.parseVmessUrl(line);
+          if (proxy) {
+            const key = `${proxy.name}-${proxy.server}-${proxy.port}`;
+            
+            // 检查是否重复
+            if (proxyKeys.has(key)) {
+              duplicateCount++;
+              logger.debug(`跳过重复的VMess节点: ${proxy.name} (${proxy.server}:${proxy.port})`);
+              continue;
+            }
+            
+            proxyKeys.add(key);
+            proxies.push(proxy);
+          } else {
+            errorCount++;
+          }
+        } catch (error) {
+          errorCount++;
+        }
       } else if (line.startsWith('trojan://')) {
         trojanCount++;
+        try {
+          const proxy = this.parseTrojanUrl(line);
+          if (proxy) {
+            const key = `${proxy.name}-${proxy.server}-${proxy.port}`;
+            
+            // 检查是否重复
+            if (proxyKeys.has(key)) {
+              duplicateCount++;
+              logger.debug(`跳过重复的Trojan节点: ${proxy.name} (${proxy.server}:${proxy.port})`);
+              continue;
+            }
+            
+            proxyKeys.add(key);
+            proxies.push(proxy);
+          } else {
+            errorCount++;
+          }
+        } catch (error) {
+          errorCount++;
+        }
       }
     }
     
-          // 解析完成 - SS: ${ssCount}个, VMess: ${vmessCount}个, Trojan: ${trojanCount}个
+    logger.info(`解析完成 - SS: ${ssCount}个, VMess: ${vmessCount}个, Trojan: ${trojanCount}个, 成功解析: ${proxies.length}个, 错误: ${errorCount}个, 重复: ${duplicateCount}个`);
     
     if (proxies.length === 0) {
       logger.warn('没有找到任何有效的代理节点');
@@ -569,6 +611,111 @@ class MultiSubscriptionManager {
     }
   }
 
+  /**
+   * 解析VMess URL
+   */
+  parseVmessUrl(url) {
+    try {
+      // 移除vmess://前缀
+      const base64Part = url.substring(8);
+      
+      // Base64解码
+      const decoded = Buffer.from(base64Part, 'base64').toString('utf8');
+      
+      // 解析JSON格式的VMess配置
+      const config = JSON.parse(decoded);
+      
+      if (!config.add || !config.port || !config.id) {
+        logger.warn('VMess配置缺少必要字段', { config });
+        return null;
+      }
+      
+      // 清理服务器地址(移除可能的\r字符)
+      const cleanServer = config.add.replace(/\r/g, '');
+      const cleanRemark = (config.ps || '').replace(/\r/g, '');
+      
+      return {
+        name: cleanRemark || `${cleanServer}:${config.port}`,
+        type: 'vmess',
+        server: cleanServer,
+        port: parseInt(config.port),
+        uuid: config.id,
+        alterId: parseInt(config.aid) || 0,
+        network: config.net || 'tcp',
+        tls: config.tls === 'tls',
+        sni: config.sni || config.host || cleanServer,
+        wsPath: config.path || '/',
+        wsHeaders: config.host ? JSON.stringify({ Host: config.host }) : null
+      };
+    } catch (error) {
+      logger.warn('解析VMess URL失败', { url: url.substring(0, 50) + '...', error: error.message });
+      return null;
+    }
+  }
+
+  /**
+   * 解析Trojan URL
+   */
+  parseTrojanUrl(url) {
+    try {
+      // 移除trojan://前缀
+      const urlPart = url.substring(9);
+      
+      // 分离密码和服务器信息
+      const atIndex = urlPart.lastIndexOf('@');
+      if (atIndex === -1) {
+        logger.warn('Trojan URL格式错误,缺少@符号', { url: url.substring(0, 50) + '...' });
+        return null;
+      }
+      
+      const password = urlPart.substring(0, atIndex);
+      const serverPart = urlPart.substring(atIndex + 1);
+      
+      // 分离服务器地址、端口和备注
+      const hashIndex = serverPart.indexOf('#');
+      let serverInfo, remark;
+      
+      if (hashIndex !== -1) {
+        serverInfo = serverPart.substring(0, hashIndex);
+        remark = decodeURIComponent(serverPart.substring(hashIndex + 1));
+      } else {
+        serverInfo = serverPart;
+        remark = '';
+      }
+      
+      // 解析服务器地址和端口
+      const colonIndex = serverInfo.lastIndexOf(':');
+      if (colonIndex === -1) {
+        logger.warn('Trojan URL格式错误,缺少端口号', { serverInfo });
+        return null;
+      }
+      
+      const server = serverInfo.substring(0, colonIndex);
+      const port = parseInt(serverInfo.substring(colonIndex + 1));
+      
+      if (isNaN(port) || port <= 0 || port > 65535) {
+        logger.warn('Trojan URL端口号无效', { port: serverInfo.substring(colonIndex + 1) });
+        return null;
+      }
+      
+      // 清理服务器地址(移除可能的\r字符)
+      const cleanServer = server.replace(/\r/g, '');
+      const cleanRemark = remark.replace(/\r/g, '');
+      
+      return {
+        name: cleanRemark || `${cleanServer}:${port}`,
+        type: 'trojan',
+        server: cleanServer,
+        port: port,
+        password: password,
+        sni: cleanServer // Trojan默认使用服务器地址作为SNI
+      };
+    } catch (error) {
+      logger.warn('解析Trojan URL失败', { url: url.substring(0, 50) + '...', error: error.message });
+      return null;
+    }
+  }
+
   /**
    * 触发测速
    */

+ 23 - 2
src/core/notifier.js

@@ -75,6 +75,25 @@ class TelegramNotifier {
     });
   }
 
+  /**
+   * 发送订阅测试通知
+   */
+  async sendSubscriptionNotification(subscription, title, message, summary) {
+    if (!this.enabled) return;
+
+    await this.sendNotification({
+      type: 'subscription',
+      title,
+      message,
+      subscriptionId: subscription.id,
+      metadata: {
+        subscriptionName: subscription.name,
+        subscriptionId: subscription.id,
+        summary: summary
+      }
+    });
+  }
+
   /**
    * 发送系统通知
    */
@@ -85,8 +104,10 @@ class TelegramNotifier {
       type: 'system',
       title,
       message,
-      nodeId: null,
-      metadata
+      metadata: {
+        ...metadata,
+        timestamp: new Date().toISOString()
+      }
     });
   }
 

+ 266 - 100
src/core/scheduler.js

@@ -78,7 +78,7 @@ class Scheduler {
   }
 
   /**
-   * 运行速度测试
+   * 运行速度测试 - 按订阅分组
    */
   async runSpeedTest() {
     if (this.isRunning) {
@@ -90,63 +90,49 @@ class Scheduler {
     const startTime = Date.now();
 
     try {
-      // 获取所有启用的节点
-      const nodes = await Node.findAll({
+      // 获取所有活跃的订阅
+      const { Subscription } = require('../models');
+      const subscriptions = await Subscription.findAll({
         where: { isActive: true },
-        order: [['group', 'ASC'], ['name', 'ASC']]
+        order: [['createdAt', 'ASC']]
       });
 
-      if (nodes.length === 0) {
-        logger.warn('没有找到启用的节点');
+      if (subscriptions.length === 0) {
+        logger.warn('没有找到活跃的订阅');
         return;
       }
 
-      // 批量测试节点
-      const testResults = await this.speedTester.testNodes(nodes);
+      logger.info(`开始按订阅分组测试,共 ${subscriptions.length} 个订阅`);
 
-      // 检查是否有高延迟节点需要重测
-      const highLatencyNodes = [];
-      const highLatencyResults = [];
-      
-      for (let i = 0; i < nodes.length; i++) {
-        const result = testResults[i];
-        if (result && result.isSuccess && result.latency && result.latency > 2000) {
-          highLatencyNodes.push(nodes[i]);
-          highLatencyResults.push(result);
+      const allResults = [];
+      const subscriptionResults = [];
+
+      // 按订阅分组测试
+      for (const subscription of subscriptions) {
+        try {
+          const subscriptionResult = await this.testSubscriptionNodes(subscription);
+          subscriptionResults.push(subscriptionResult);
+          allResults.push(...subscriptionResult.testResults);
+        } catch (error) {
+          logger.error(`订阅测试失败: ${subscription.name}`, { error: error.message });
         }
       }
 
-      // 如果有高延迟节点,进行重测
-      if (highLatencyNodes.length > 0) {
-        logger.info(`重测 ${highLatencyNodes.length} 个高延迟节点`);
-        
-        // 重测高延迟节点
-        const retestResults = await this.speedTester.testNodes(highLatencyNodes);
-        
-        // 更新测试结果,使用重测的结果
-        for (let i = 0; i < highLatencyNodes.length; i++) {
-          const node = highLatencyNodes[i];
-          const retestResult = retestResults[i];
-          const originalIndex = testResults.findIndex(r => r.nodeId === node.id);
-          
-          if (originalIndex !== -1 && retestResult) {
-            // 如果重测结果更好,使用重测结果
-            if (retestResult.isSuccess && retestResult.latency && retestResult.latency <= 2000) {
-              testResults[originalIndex] = retestResult;
-            }
-          }
+      // 发送每个订阅的单独报告
+      for (const result of subscriptionResults) {
+        if (result.nodes.length > 0) {
+          await this.sendSubscriptionTestSummary(result);
         }
       }
 
-      // 处理测试结果
-      await this.processTestResults(nodes, testResults);
-
-      // 生成并发送摘要
-      await this.sendTestSummary(nodes, testResults);
+      // 发送总体报告
+      await this.sendOverallTestSummary(subscriptionResults);
 
       const duration = Date.now() - startTime;
-      const successCount = testResults.filter(r => r.isSuccess).length;
-      logger.info(`测速完成: ${successCount}/${nodes.length}个成功, 耗时${duration}ms`);
+      const totalNodes = allResults.length;
+      const successCount = allResults.filter(r => r.isSuccess).length;
+      
+      logger.info(`分组测速完成: ${successCount}/${totalNodes}个成功, 耗时${duration}ms`);
 
     } catch (error) {
       logger.error('定时速度测试失败', { error: error.message });
@@ -162,6 +148,243 @@ class Scheduler {
     }
   }
 
+  /**
+   * 测试单个订阅的所有节点
+   */
+  async testSubscriptionNodes(subscription) {
+    logger.info(`开始测试订阅: ${subscription.name}`);
+
+    // 获取该订阅的所有活跃节点
+    const nodes = await Node.findAll({
+      where: { 
+        subscriptionId: subscription.id,
+        isActive: true 
+      },
+      order: [['name', 'ASC']]
+    });
+
+    if (nodes.length === 0) {
+      logger.warn(`订阅 ${subscription.name} 没有活跃节点`);
+      return {
+        subscription,
+        nodes: [],
+        testResults: [],
+        summary: {
+          totalNodes: 0,
+          onlineNodes: 0,
+          offlineNodes: 0,
+          successRate: 0,
+          averageLatency: null,
+          averageSpeed: null
+        }
+      };
+    }
+
+    logger.info(`订阅 ${subscription.name} 有 ${nodes.length} 个节点需要测试`);
+
+    // 批量测试节点
+    const testResults = await this.speedTester.testNodes(nodes);
+
+    // 检查是否有高延迟节点需要重测
+    const highLatencyNodes = [];
+    const highLatencyResults = [];
+    
+    for (let i = 0; i < nodes.length; i++) {
+      const result = testResults[i];
+      if (result && result.isSuccess && result.latency && result.latency > 2000) {
+        highLatencyNodes.push(nodes[i]);
+        highLatencyResults.push(result);
+      }
+    }
+
+    // 如果有高延迟节点,进行重测
+    if (highLatencyNodes.length > 0) {
+      logger.info(`订阅 ${subscription.name} 重测 ${highLatencyNodes.length} 个高延迟节点`);
+      
+      // 重测高延迟节点
+      const retestResults = await this.speedTester.testNodes(highLatencyNodes);
+      
+      // 更新测试结果,使用重测的结果
+      for (let i = 0; i < highLatencyNodes.length; i++) {
+        const node = highLatencyNodes[i];
+        const retestResult = retestResults[i];
+        const originalIndex = testResults.findIndex(r => r.nodeId === node.id);
+        
+        if (originalIndex !== -1 && retestResult) {
+          // 如果重测结果更好,使用重测结果
+          if (retestResult.isSuccess && retestResult.latency && retestResult.latency <= 2000) {
+            testResults[originalIndex] = retestResult;
+          }
+        }
+      }
+    }
+
+    // 处理测试结果
+    await this.processTestResults(nodes, testResults);
+
+    // 生成订阅摘要
+    const summary = this.generateSubscriptionSummary(nodes, testResults);
+
+    return {
+      subscription,
+      nodes,
+      testResults,
+      summary
+    };
+  }
+
+  /**
+   * 生成订阅测试摘要
+   */
+  generateSubscriptionSummary(nodes, testResults) {
+    const totalNodes = nodes.length;
+    const onlineNodes = testResults.filter(r => r.isSuccess).length;
+    const offlineNodes = totalNodes - onlineNodes;
+    const successRate = totalNodes > 0 ? Math.round((onlineNodes / totalNodes) * 100) : 0;
+
+    // 计算平均延迟和速度
+    const successfulResults = testResults.filter(r => r.isSuccess && r.latency);
+    const averageLatency = successfulResults.length > 0 
+      ? Math.round(successfulResults.reduce((sum, r) => sum + r.latency, 0) / successfulResults.length)
+      : null;
+
+    const speedResults = testResults.filter(r => r.isSuccess && r.downloadSpeed);
+    const averageSpeed = speedResults.length > 0
+      ? Math.round((speedResults.reduce((sum, r) => sum + r.downloadSpeed, 0) / speedResults.length) * 100) / 100
+      : null;
+
+    // 获取故障节点列表
+    const failedNodes = nodes.filter(node => {
+      const result = testResults.find(r => r.nodeId === node.id);
+      return result && !result.isSuccess;
+    }).slice(0, 5); // 最多显示5个
+
+    // 获取最佳节点列表
+    const bestNodes = testResults
+      .filter(r => r.isSuccess && r.latency)
+      .sort((a, b) => a.latency - b.latency)
+      .slice(0, 3)
+      .map(result => {
+        const node = nodes.find(n => n.id === result.nodeId);
+        return {
+          name: node ? node.name : 'Unknown',
+          latency: result.latency
+        };
+      });
+
+    return {
+      totalNodes,
+      onlineNodes,
+      offlineNodes,
+      successRate,
+      averageLatency,
+      averageSpeed,
+      failedNodes,
+      bestNodes,
+      testTime: new Date().toLocaleString('zh-CN')
+    };
+  }
+
+  /**
+   * 发送订阅测试摘要
+   */
+  async sendSubscriptionTestSummary(subscriptionResult) {
+    const { subscription, summary } = subscriptionResult;
+    
+    if (summary.totalNodes === 0) {
+      return;
+    }
+
+    const title = `📊 订阅测试报告: ${subscription.name}`;
+    
+    const lines = [
+      `*订阅名称:* ${subscription.name}`,
+      `*测试时间:* ${summary.testTime}`,
+      `*节点总数:* ${summary.totalNodes}个`,
+      `*在线节点:* ${summary.onlineNodes}个`,
+      `*离线节点:* ${summary.offlineNodes}个`,
+      `*成功率:* ${summary.successRate}%`
+    ];
+
+    if (summary.averageLatency) {
+      lines.push(`*平均延迟:* ${summary.averageLatency}ms`);
+    }
+
+    if (summary.averageSpeed) {
+      lines.push(`*平均速度:* ${summary.averageSpeed}Mbps`);
+    }
+
+    if (summary.bestNodes.length > 0) {
+      lines.push(`\n🏆 *最佳节点:*`);
+      summary.bestNodes.forEach((node, index) => {
+        lines.push(`${index + 1}. ${node.name} - ${node.latency}ms`);
+      });
+    }
+
+    if (summary.failedNodes.length > 0) {
+      lines.push(`\n❌ *故障节点:*`);
+      summary.failedNodes.forEach((node, index) => {
+        lines.push(`${index + 1}. ${node.name}`);
+      });
+    }
+
+    const message = lines.join('\n');
+
+    await this.notifier.sendSubscriptionNotification(subscription, title, message, summary);
+  }
+
+  /**
+   * 发送总体测试摘要
+   */
+  async sendOverallTestSummary(subscriptionResults) {
+    const totalSubscriptions = subscriptionResults.length;
+    const totalNodes = subscriptionResults.reduce((sum, r) => sum + r.summary.totalNodes, 0);
+    const totalOnlineNodes = subscriptionResults.reduce((sum, r) => sum + r.summary.onlineNodes, 0);
+    const totalOfflineNodes = totalNodes - totalOnlineNodes;
+    const overallSuccessRate = totalNodes > 0 ? Math.round((totalOnlineNodes / totalNodes) * 100) : 0;
+
+    // 计算总体平均延迟
+    const allSuccessfulResults = subscriptionResults.flatMap(r => 
+      r.testResults.filter(result => result.isSuccess && result.latency)
+    );
+    const overallAverageLatency = allSuccessfulResults.length > 0
+      ? Math.round(allSuccessfulResults.reduce((sum, r) => sum + r.latency, 0) / allSuccessfulResults.length)
+      : null;
+
+    const title = '📈 总体测试报告';
+    const lines = [
+      `*测试时间:* ${new Date().toLocaleString('zh-CN')}`,
+      `*订阅数量:* ${totalSubscriptions}个`,
+      `*节点总数:* ${totalNodes}个`,
+      `*在线节点:* ${totalOnlineNodes}个`,
+      `*离线节点:* ${totalOfflineNodes}个`,
+      `*总体成功率:* ${overallSuccessRate}%`
+    ];
+
+    if (overallAverageLatency) {
+      lines.push(`*总体平均延迟:* ${overallAverageLatency}ms`);
+    }
+
+    // 按订阅显示摘要
+    lines.push('\n📋 *各订阅状态:*');
+    subscriptionResults.forEach(result => {
+      const { subscription, summary } = result;
+      const status = summary.successRate >= 80 ? '🟢' : summary.successRate >= 50 ? '🟡' : '🔴';
+      lines.push(`${status} ${subscription.name}: ${summary.onlineNodes}/${summary.totalNodes} (${summary.successRate}%)`);
+    });
+
+    const message = lines.join('\n');
+
+    await this.notifier.sendSystemNotification(title, message, {
+      totalSubscriptions,
+      totalNodes,
+      totalOnlineNodes,
+      totalOfflineNodes,
+      overallSuccessRate,
+      overallAverageLatency
+    });
+  }
+
   /**
    * 处理测试结果
    */
@@ -237,63 +460,6 @@ class Scheduler {
     }
   }
 
-  /**
-   * 发送测试摘要
-   */
-  async sendTestSummary(nodes, testResults) {
-    const totalNodes = nodes.length;
-    const onlineNodes = testResults.filter(r => r.isSuccess).length;
-    const offlineNodes = totalNodes - onlineNodes;
-    const successRate = totalNodes > 0 ? Math.round((onlineNodes / totalNodes) * 100) : 0;
-
-    // 计算平均延迟和速度
-    const successfulResults = testResults.filter(r => r.isSuccess && r.latency);
-    const averageLatency = successfulResults.length > 0 
-      ? Math.round(successfulResults.reduce((sum, r) => sum + r.latency, 0) / successfulResults.length)
-      : null;
-
-    const speedResults = testResults.filter(r => r.isSuccess && r.downloadSpeed);
-    const averageSpeed = speedResults.length > 0
-      ? Math.round((speedResults.reduce((sum, r) => sum + r.downloadSpeed, 0) / speedResults.length) * 100) / 100
-      : null;
-
-    // 获取故障节点列表
-    const failedNodes = nodes.filter(node => {
-      const result = testResults.find(r => r.nodeId === node.id);
-      return result && !result.isSuccess;
-    }).slice(0, 10); // 最多显示10个
-
-    // 获取最佳节点列表
-    const bestNodes = testResults
-      .filter(r => r.isSuccess && r.latency)
-      .sort((a, b) => a.latency - b.latency)
-      .slice(0, 5)
-      .map(result => {
-        const node = nodes.find(n => n.id === result.nodeId);
-        return {
-          name: node ? node.name : 'Unknown',
-          latency: result.latency
-        };
-      });
-
-
-
-    const summary = {
-      totalNodes,
-      onlineNodes,
-      offlineNodes,
-      successRate,
-      averageLatency,
-      averageSpeed,
-      failedNodes,
-      bestNodes,
-      testTime: new Date().toLocaleString('zh-CN')
-    };
-
-    // 每次测速完成后都发送详细的测试总结报告
-    await this.notifier.sendSummaryNotification(summary);
-  }
-
   /**
    * 清理旧数据
    */

+ 1 - 1
src/models/Notification.js

@@ -13,7 +13,7 @@ const Notification = sequelize.define('Notification', {
     comment: '节点ID (可选,系统通知为null)'
   },
   type: {
-    type: DataTypes.ENUM('failure', 'recovery', 'system', 'summary'),
+    type: DataTypes.ENUM('failure', 'recovery', 'system', 'summary', 'subscription'),
     allowNull: false,
     comment: '通知类型'
   },