tt-help-cli-ycl 1.4.79 → 1.4.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/clash/api.js +17 -0
- package/src/clash/benchmark/lib/clash-api.js +234 -0
- package/src/clash/benchmark/speedtest-result.json +431 -0
- package/src/clash/benchmark/speedtest-tiktok.mjs +384 -0
- package/src/clash/checker.js +218 -94
- package/src/clash/daemon.js +230 -68
- package/src/clash/ipc.js +23 -4
- package/src/cli/clash-daemon.js +2 -1
- package/src/cli/explore.js +15 -4
- package/src/cli/refresh.js +15 -6
- package/src/cli/search.js +16 -7
- package/src/cli/watchdog.js +8 -1
- package/src/cli/webserver.js +8 -2
- package/src/lib/args.js +12 -0
- package/src/lib/constants.js +24 -0
- package/src/lib/shop-scraper.mjs +34 -17
- package/src/watch/public/app.js +150 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/watchdog-server.js +32 -1
- package/src/watchdog/agent.js +72 -15
- package/src/watchdog/process-manager.js +57 -0
- package/src/webserver/server.mjs +37 -14
- package/src/clash/health-checker.js +0 -248
|
@@ -22,6 +22,7 @@ const MAX_RESTART = 3;
|
|
|
22
22
|
const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
|
|
23
23
|
const IS_WIN = process.platform === "win32";
|
|
24
24
|
const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
|
|
25
|
+
const STALE_LOG_TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟无日志输出视为假死
|
|
25
26
|
|
|
26
27
|
// 从命令行中提取 CDP 端口列表(解析 --base-port 和 --port-count)
|
|
27
28
|
function extractCdpPorts(cmdLine) {
|
|
@@ -215,6 +216,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
215
216
|
if (info) {
|
|
216
217
|
info.logs.push(short);
|
|
217
218
|
if (info.logs.length > MAX_LOG_LINES) info.logs.shift();
|
|
219
|
+
info.lastLogTime = Date.now(); // 记录最后日志时间,用于假死检测
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
});
|
|
@@ -267,6 +269,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
267
269
|
restartCount: 0,
|
|
268
270
|
stopped: false,
|
|
269
271
|
logs: [],
|
|
272
|
+
lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
|
|
270
273
|
});
|
|
271
274
|
}
|
|
272
275
|
|
|
@@ -384,6 +387,60 @@ export class ProcessManager extends EventEmitter {
|
|
|
384
387
|
return result;
|
|
385
388
|
}
|
|
386
389
|
|
|
390
|
+
/**
|
|
391
|
+
* 检测假死进程:超过 STALE_LOG_TIMEOUT_MS 无日志输出则自动重启
|
|
392
|
+
* @returns {Array<{cmdId:string, silentMinutes:number}>} 被重启的进程列表
|
|
393
|
+
*/
|
|
394
|
+
checkStaleProcesses() {
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
const restarted = [];
|
|
397
|
+
|
|
398
|
+
for (const [cmdId, info] of this.processes) {
|
|
399
|
+
if (info.stopped || !info.child) continue;
|
|
400
|
+
// 启动不到 STALE_LOG_TIMEOUT_MS 的进程跳过(给启动留足时间)
|
|
401
|
+
const uptime = now - info.startTime;
|
|
402
|
+
if (uptime < STALE_LOG_TIMEOUT_MS) continue;
|
|
403
|
+
|
|
404
|
+
const lastLog = info.lastLogTime || info.startTime;
|
|
405
|
+
const silentMs = now - lastLog;
|
|
406
|
+
if (silentMs < STALE_LOG_TIMEOUT_MS) continue;
|
|
407
|
+
|
|
408
|
+
const silentMin = Math.round(silentMs / 60000);
|
|
409
|
+
console.error(
|
|
410
|
+
`[watchdog] 命令 ${cmdId} 已 ${silentMin} 分钟无日志输出(疑似假死),重启...`,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// 保存 cmdDef 和 restartCount,因为 stopCommand 会 delete
|
|
414
|
+
const cmdDef = info.cmdDef;
|
|
415
|
+
const restartCount = (info.restartCount || 0) + 1;
|
|
416
|
+
|
|
417
|
+
if (restartCount > MAX_RESTART) {
|
|
418
|
+
console.error(
|
|
419
|
+
`[watchdog] 命令 ${cmdId} 已达最大重启次数 (${MAX_RESTART}),不再自动重启`,
|
|
420
|
+
);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 停止旧进程
|
|
425
|
+
this.stopCommand(cmdId);
|
|
426
|
+
|
|
427
|
+
// 重启(继承 restartCount)
|
|
428
|
+
const def = { ...cmdDef, _staleRestart: true };
|
|
429
|
+
this.startCommand(def);
|
|
430
|
+
const newInfo = this.processes.get(cmdId);
|
|
431
|
+
if (newInfo) {
|
|
432
|
+
newInfo.restartCount = restartCount;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.error(
|
|
436
|
+
`[watchdog] 命令 ${cmdId} 假死重启完成 (${restartCount}/${MAX_RESTART})`,
|
|
437
|
+
);
|
|
438
|
+
restarted.push({ cmdId, silentMinutes: silentMin });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return restarted;
|
|
442
|
+
}
|
|
443
|
+
|
|
387
444
|
/**
|
|
388
445
|
* 与期望命令列表同步:
|
|
389
446
|
* - 启动期望列表中已启用但未运行的命令
|
package/src/webserver/server.mjs
CHANGED
|
@@ -72,11 +72,18 @@ const SCRAPER_CONFIG = {
|
|
|
72
72
|
maxRequestsPerPage: 50,
|
|
73
73
|
proxyServer: null,
|
|
74
74
|
captchaNotifyUrl: null,
|
|
75
|
+
enableLog: false,
|
|
75
76
|
};
|
|
76
77
|
|
|
78
|
+
// 仅 --log 模式下输出成功日志;错误日志始终输出
|
|
79
|
+
function apiLog(msg) {
|
|
80
|
+
if (SCRAPER_CONFIG.enableLog) console.error(msg);
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
let scraperInstance = null;
|
|
78
84
|
let scraperClosing = false;
|
|
79
85
|
let scraperInitPromise = null;
|
|
86
|
+
let restartPromise = null; // 防重入:同一时间只允许一个 restartScraper
|
|
80
87
|
let consecutiveErrors = 0;
|
|
81
88
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
82
89
|
let warmTimer = null;
|
|
@@ -127,20 +134,34 @@ function recordRequest(success) {
|
|
|
127
134
|
|
|
128
135
|
function isDegraded() {
|
|
129
136
|
// 仅当 scraper 正在恢复/关闭中才算降级,初始冷状态不算
|
|
130
|
-
return
|
|
137
|
+
return (
|
|
138
|
+
scraperClosing || scraperInitPromise !== null || restartPromise !== null
|
|
139
|
+
);
|
|
131
140
|
}
|
|
132
141
|
|
|
133
142
|
async function restartScraper(reason) {
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
// 防重入:已有重启在进行中,等它完成即可
|
|
144
|
+
if (restartPromise) {
|
|
145
|
+
console.error(`[webserver] 重启已在执行中,跳过本次: ${reason}`);
|
|
146
|
+
return restartPromise;
|
|
147
|
+
}
|
|
148
|
+
restartPromise = (async () => {
|
|
149
|
+
console.error(`[webserver] 重启 scraper: ${reason}`);
|
|
150
|
+
if (scraperInstance) {
|
|
151
|
+
try {
|
|
152
|
+
await scraperInstance.close();
|
|
153
|
+
} catch {}
|
|
154
|
+
scraperInstance = null;
|
|
155
|
+
}
|
|
156
|
+
scraperInitPromise = null;
|
|
157
|
+
consecutiveErrors = 0;
|
|
158
|
+
console.error(`[webserver] 重启 scraper 完成`);
|
|
159
|
+
})();
|
|
160
|
+
try {
|
|
161
|
+
await restartPromise;
|
|
162
|
+
} finally {
|
|
163
|
+
restartPromise = null;
|
|
140
164
|
}
|
|
141
|
-
scraperInitPromise = null;
|
|
142
|
-
consecutiveErrors = 0;
|
|
143
|
-
// 下次请求时懒初始化
|
|
144
165
|
}
|
|
145
166
|
|
|
146
167
|
async function warmScraper() {
|
|
@@ -215,7 +236,7 @@ async function handleTiktokUser(req, res) {
|
|
|
215
236
|
});
|
|
216
237
|
}
|
|
217
238
|
|
|
218
|
-
|
|
239
|
+
apiLog(
|
|
219
240
|
`[webserver] user OK | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | verified=${result.verified} followers=${result.followerCount}`,
|
|
220
241
|
);
|
|
221
242
|
recordRequest(true);
|
|
@@ -244,7 +265,7 @@ async function handleTiktokVideo(req, res) {
|
|
|
244
265
|
});
|
|
245
266
|
}
|
|
246
267
|
|
|
247
|
-
|
|
268
|
+
apiLog(
|
|
248
269
|
`[webserver] video OK | ${clientIP} | @${result.author?.uniqueId || "?"} | ${elapsed}ms | plays=${result.stats?.playCount}`,
|
|
249
270
|
);
|
|
250
271
|
recordRequest(true);
|
|
@@ -273,7 +294,7 @@ async function handleTiktokLookup(req, res) {
|
|
|
273
294
|
});
|
|
274
295
|
}
|
|
275
296
|
|
|
276
|
-
|
|
297
|
+
apiLog(
|
|
277
298
|
`[webserver] lookup OK | ${clientIP} | @${result.user?.uniqueId || "?"} | ${elapsed}ms | videoId=${result.video?.id}`,
|
|
278
299
|
);
|
|
279
300
|
recordRequest(true);
|
|
@@ -299,7 +320,7 @@ async function handleTiktokProduct(req, res) {
|
|
|
299
320
|
captchaFallback: false,
|
|
300
321
|
},
|
|
301
322
|
);
|
|
302
|
-
|
|
323
|
+
apiLog(
|
|
303
324
|
`[webserver] product OK | ${clientIP} | rating=${result.rating ?? "-"} sold=${result.soldCount ?? "-"} name=${(result.productName || "").slice(0, 40)}`,
|
|
304
325
|
);
|
|
305
326
|
recordRequest(true);
|
|
@@ -457,6 +478,7 @@ export async function startWebserver(
|
|
|
457
478
|
poolSize = 0,
|
|
458
479
|
proxyServer = null,
|
|
459
480
|
captchaNotifyUrl = null,
|
|
481
|
+
enableLog = false,
|
|
460
482
|
) {
|
|
461
483
|
if (poolSize > 0) {
|
|
462
484
|
SCRAPER_CONFIG.poolSize = poolSize;
|
|
@@ -467,6 +489,7 @@ export async function startWebserver(
|
|
|
467
489
|
if (captchaNotifyUrl) {
|
|
468
490
|
SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
|
|
469
491
|
}
|
|
492
|
+
SCRAPER_CONFIG.enableLog = !!enableLog;
|
|
470
493
|
const server = http.createServer(async (req, res) => {
|
|
471
494
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
472
495
|
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 节点健康检测模块
|
|
3
|
-
*
|
|
4
|
-
* 职责:
|
|
5
|
-
* 1. 检测 Clash Verge 进程是否存活
|
|
6
|
-
* 2. 检测当前节点对目标 URL 是否健康
|
|
7
|
-
* 3. 故障切换:从健康节点列表中随机选取并切换
|
|
8
|
-
* 4. 健康节点列表持久化
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { spawnSync } from "child_process";
|
|
12
|
-
import * as api from "./api.js";
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
import { tmpdir } from "os";
|
|
16
|
-
|
|
17
|
-
const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
18
|
-
|
|
19
|
-
// ====== 可配置参数 ======
|
|
20
|
-
export const CONFIG = {
|
|
21
|
-
testUrl: "https://www.tiktok.com/explore",
|
|
22
|
-
proxyUrl: "http://127.0.0.1:7897",
|
|
23
|
-
apiPort: 9097,
|
|
24
|
-
groupName: "GLOBAL",
|
|
25
|
-
curlTimeout: 12,
|
|
26
|
-
minPageSize: 50 * 1024,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// ====== 进程检测(跨平台 mac/win) ======
|
|
30
|
-
|
|
31
|
-
const IS_WIN = process.platform === "win32";
|
|
32
|
-
|
|
33
|
-
export function checkClashAlive() {
|
|
34
|
-
try {
|
|
35
|
-
if (IS_WIN) {
|
|
36
|
-
const result = spawnSync(
|
|
37
|
-
"tasklist",
|
|
38
|
-
["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
39
|
-
{
|
|
40
|
-
encoding: "utf-8",
|
|
41
|
-
timeout: 5000,
|
|
42
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
-
},
|
|
44
|
-
);
|
|
45
|
-
if (result.stdout && result.stdout.includes("verge-mihomo.exe"))
|
|
46
|
-
return true;
|
|
47
|
-
} else {
|
|
48
|
-
const result = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
49
|
-
encoding: "utf-8",
|
|
50
|
-
timeout: 5000,
|
|
51
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
-
});
|
|
53
|
-
if (result.status === 0 && result.stdout.trim()) return true;
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
/* fallthrough */
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const curlExe = IS_WIN ? "curl.exe" : "curl";
|
|
61
|
-
const result = spawnSync(
|
|
62
|
-
curlExe,
|
|
63
|
-
["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
64
|
-
{
|
|
65
|
-
encoding: "utf-8",
|
|
66
|
-
timeout: 5000,
|
|
67
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
-
},
|
|
69
|
-
);
|
|
70
|
-
return result.stdout.includes('"version"');
|
|
71
|
-
} catch {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ====== 节点健康检测 ======
|
|
77
|
-
|
|
78
|
-
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
79
|
-
const args = [
|
|
80
|
-
"-s",
|
|
81
|
-
"-w",
|
|
82
|
-
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
83
|
-
"--proxy",
|
|
84
|
-
CONFIG.proxyUrl,
|
|
85
|
-
"--max-time",
|
|
86
|
-
String(CONFIG.curlTimeout),
|
|
87
|
-
"-L",
|
|
88
|
-
"-A",
|
|
89
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
90
|
-
url,
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
const result = spawnSync("curl", args, {
|
|
94
|
-
encoding: "utf-8",
|
|
95
|
-
timeout: (CONFIG.curlTimeout + 5) * 1000,
|
|
96
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const stdout = result.stdout || "";
|
|
101
|
-
const sentinel = "__CURL_META__";
|
|
102
|
-
const idx = stdout.lastIndexOf(sentinel);
|
|
103
|
-
|
|
104
|
-
let body = "",
|
|
105
|
-
metaLine = "";
|
|
106
|
-
if (idx >= 0) {
|
|
107
|
-
body = stdout.substring(0, idx).trimEnd();
|
|
108
|
-
metaLine = stdout.substring(idx + sentinel.length).trim();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const parts = metaLine.split("|");
|
|
112
|
-
const httpCode = parseInt(parts[0]) || 0;
|
|
113
|
-
const timeTotal = parseFloat(parts[1]) || 0;
|
|
114
|
-
const sizeDownload = parseInt(parts[2]) || 0;
|
|
115
|
-
|
|
116
|
-
let title = "";
|
|
117
|
-
const titleMatch = body.match(/<title>([^<]*)<\/title>/i);
|
|
118
|
-
if (titleMatch) title = titleMatch[1].trim();
|
|
119
|
-
|
|
120
|
-
let error = null;
|
|
121
|
-
if (result.error) {
|
|
122
|
-
error = result.error.message || "spawn error";
|
|
123
|
-
} else if (result.status !== 0) {
|
|
124
|
-
error = `curl exit ${result.status}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const healthy =
|
|
128
|
-
error === null &&
|
|
129
|
-
httpCode === 200 &&
|
|
130
|
-
sizeDownload >= CONFIG.minPageSize &&
|
|
131
|
-
title.includes("TikTok") &&
|
|
132
|
-
!title.startsWith("About |") &&
|
|
133
|
-
!body.includes("SlardarWAF") &&
|
|
134
|
-
!body.includes("_wafchallengeid");
|
|
135
|
-
|
|
136
|
-
return { healthy, httpCode, timeTotal, sizeDownload, title, error };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ====== 健康节点列表持久化 ======
|
|
140
|
-
|
|
141
|
-
export function loadHealthyList() {
|
|
142
|
-
try {
|
|
143
|
-
if (existsSync(HEALTHY_FILE)) {
|
|
144
|
-
const data = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
|
|
145
|
-
return {
|
|
146
|
-
updated: data.updated || "",
|
|
147
|
-
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
} catch (e) {
|
|
151
|
-
console.error(`[health] 读取健康列表失败: ${e.message}`);
|
|
152
|
-
}
|
|
153
|
-
return { updated: "", nodes: [] };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function saveHealthyList(nodes) {
|
|
157
|
-
try {
|
|
158
|
-
const data = { updated: new Date().toISOString(), nodes };
|
|
159
|
-
writeFileSync(HEALTHY_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
160
|
-
} catch (e) {
|
|
161
|
-
console.error(`[health] 保存健康列表失败: ${e.message}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ====== 故障切换 ======
|
|
166
|
-
|
|
167
|
-
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
168
|
-
if (!healthyNodes || healthyNodes.length === 0) return null;
|
|
169
|
-
const candidates = healthyNodes.filter((n) => n !== currentNode);
|
|
170
|
-
if (candidates.length === 0) {
|
|
171
|
-
console.error(
|
|
172
|
-
`[health] 健康列表中无可用候选节点(共${healthyNodes.length}个),保持在当前节点`,
|
|
173
|
-
);
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
const idx = Math.floor(Math.random() * candidates.length);
|
|
177
|
-
return candidates[idx];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function switchToNode(nodeName) {
|
|
181
|
-
try {
|
|
182
|
-
await api.switchProxy(CONFIG.groupName, nodeName);
|
|
183
|
-
await new Promise((r) => setTimeout(r, 600));
|
|
184
|
-
const current = await api.getCurrentNode(CONFIG.groupName);
|
|
185
|
-
if (current !== nodeName) {
|
|
186
|
-
console.error(`[health] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
return true;
|
|
190
|
-
} catch (e) {
|
|
191
|
-
console.error(`[health] 切换节点失败: ${e.message}`);
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ====== 便捷入口 ======
|
|
197
|
-
|
|
198
|
-
export async function runHealthCheck() {
|
|
199
|
-
if (!checkClashAlive()) {
|
|
200
|
-
return { action: "skipped", detail: "Clash Verge 未运行" };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const health = checkNodeHealth();
|
|
204
|
-
if (health.healthy) {
|
|
205
|
-
return {
|
|
206
|
-
action: "ok",
|
|
207
|
-
detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB`,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const reason = health.error
|
|
212
|
-
? `错误: ${health.error}`
|
|
213
|
-
: `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
|
|
214
|
-
|
|
215
|
-
let currentNode = "";
|
|
216
|
-
try {
|
|
217
|
-
currentNode = await api.getCurrentNode(CONFIG.groupName);
|
|
218
|
-
} catch (e) {
|
|
219
|
-
return { action: "failed", detail: `无法获取当前节点: ${e.message}` };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const { nodes: healthyNodes } = loadHealthyList();
|
|
223
|
-
if (healthyNodes.length === 0) {
|
|
224
|
-
return { action: "failed", detail: `${reason} — 健康列表为空,无法切换` };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const target = pickFailoverNode(currentNode, healthyNodes);
|
|
228
|
-
if (!target) {
|
|
229
|
-
return { action: "failed", detail: `${reason} — 无可用候选节点` };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const switched = await switchToNode(target);
|
|
233
|
-
if (!switched) {
|
|
234
|
-
return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const newHealth = checkNodeHealth();
|
|
238
|
-
if (newHealth.healthy) {
|
|
239
|
-
return {
|
|
240
|
-
action: "switched",
|
|
241
|
-
detail: `${currentNode} → ${target} (${newHealth.timeTotal.toFixed(1)}s ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
return {
|
|
245
|
-
action: "switched",
|
|
246
|
-
detail: `${currentNode} → ${target} (切换后仍异常: ${newHealth.httpCode} ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
247
|
-
};
|
|
248
|
-
}
|