tt-help-cli-ycl 1.4.140 → 1.4.142

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.
@@ -24,9 +24,10 @@ import {
24
24
  import { writeFileSync, existsSync, readFileSync } from "fs";
25
25
  import { spawn } from "child_process";
26
26
  import { join, dirname } from "path";
27
- import { homedir, tmpdir } from "os";
27
+ import { homedir, tmpdir, hostname as osHostname } from "os";
28
28
  import { fileURLToPath } from "url";
29
29
  import { pushSelfLog, getSelfLogs } from "./self-log-buffer.js";
30
+ import { ClientErrorMonitor } from "./client-error-monitor.js";
30
31
 
31
32
  // ── 看门狗自身日志入环(供后台页面按需拉取,经 report-logs 回传落盘) ──
32
33
  // 包装 console.error:本进程所有 [watchdog]/[llm-analyzer]/[upgrade] 等日志
@@ -111,6 +112,7 @@ const NETWORK_ERROR_PATTERNS = [
111
112
  ];
112
113
  const NETWORK_ERROR_RE = new RegExp(NETWORK_ERROR_PATTERNS.join("|"), "i");
113
114
  const ERROR_SCAN_INTERVAL_MS = 30_000; // 日志扫描间隔
115
+ const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 → 重启全部命令)
114
116
  const ERROR_TRIGGER_COUNT = 10; // 连续错误阈值
115
117
  const ERROR_MIN_MS = 5 * 60 * 1000; // 最小持续时间(时间只做最小值判断)
116
118
  const ERROR_BREAK_MS = 120_000; // 两次错误间隔超过则视为连续中断,重置计数
@@ -262,6 +264,7 @@ export class WatchdogAgent {
262
264
  this.thresholdTimer = null;
263
265
  this.llmAnalyzerTimer = null;
264
266
  this.processManager = new ProcessManager();
267
+ this.clientErrorMonitor = null; // 客户端异常监控(watch 异常列表 → 本机命中强切 + 清理)
265
268
  this.consecutiveFailures = 0;
266
269
  this.maxFailures = 10;
267
270
  this.upgradeChecker = null;
@@ -287,6 +290,11 @@ export class WatchdogAgent {
287
290
  this._errorStates.delete(cmdId);
288
291
  });
289
292
  this._errorTriggerTimer = null;
293
+ this._switchCheckTimer = null; // 代理切换感知定时器
294
+ this._restartSeqBaseline = null; // 已消费的切换序号基线 Map<通道,seq>(首次读取初始化)
295
+ this._switchRestartEnabled = false; // 代理切换重启默认不启用(后端下发开启才生效)
296
+ this._switchRestartConfigKey = null; // 上次配置指纹(幂等打印用)
297
+ this._autoUpgradeEnabled = true; // 自动升级默认开启(后端下发关闭才停用)
290
298
  this._errorTriggerEnabled = false; // 默认不启用(后端下发开启才生效)
291
299
  this._errorTriggerCount = ERROR_TRIGGER_COUNT;
292
300
  this._errorTriggerMinMs = ERROR_MIN_MS;
@@ -345,6 +353,11 @@ export class WatchdogAgent {
345
353
  this._checkProcessErrors();
346
354
  }, ERROR_SCAN_INTERVAL_MS);
347
355
 
356
+ // 代理切换感知:daemon 每次实际切换 restartSeq+1,变化即强制重启全部命令
357
+ this._switchCheckTimer = setInterval(() => {
358
+ this._checkProxySwitch();
359
+ }, SWITCH_SCAN_INTERVAL_MS);
360
+
348
361
  // 登录检测调度:每 60s 检查一次是否到点(首次 60s 后检测,之后每 loginCheckIntervalMs)
349
362
  this.loginCheckTimer = setInterval(() => {
350
363
  this._maybeRunLoginCheck();
@@ -358,6 +371,9 @@ export class WatchdogAgent {
358
371
 
359
372
  // 自动升级检测(仅全局安装模式)
360
373
  this._startUpgradeChecker();
374
+
375
+ // 客户端异常监控(watch 服务端异常列表 → 本机命中强切 + 清理)
376
+ this._startClientErrorMonitor();
361
377
  }
362
378
 
363
379
  /**
@@ -381,6 +397,10 @@ export class WatchdogAgent {
381
397
  clearInterval(this._errorTriggerTimer);
382
398
  this._errorTriggerTimer = null;
383
399
  }
400
+ if (this._switchCheckTimer) {
401
+ clearInterval(this._switchCheckTimer);
402
+ this._switchCheckTimer = null;
403
+ }
384
404
  if (this.loginCheckTimer) {
385
405
  clearInterval(this.loginCheckTimer);
386
406
  this.loginCheckTimer = null;
@@ -393,6 +413,10 @@ export class WatchdogAgent {
393
413
  this.upgradeChecker.stop();
394
414
  this.upgradeChecker = null;
395
415
  }
416
+ if (this.clientErrorMonitor) {
417
+ this.clientErrorMonitor.stop();
418
+ this.clientErrorMonitor = null;
419
+ }
396
420
  // 停止 clash daemon(标记主动关闭,exit 时不再自动重启)
397
421
  this._daemonStopping = true;
398
422
  if (this._daemonRestartTimer) {
@@ -404,6 +428,80 @@ export class WatchdogAgent {
404
428
  console.error("[watchdog] 看门狗已停止");
405
429
  }
406
430
 
431
+ /**
432
+ * 代理切换感知:读 daemon health 的 restartSeq,任一通道变大即发生过切换,
433
+ * 强制重启全部被监控命令。默认不启用(后端开关开启才重启);
434
+ * 基线始终跟随 seq(无论开关状态):开启后只响应"新切换",不补触发历史切换。
435
+ * 变小 = daemon 重启序号归零,只更新基线不触发(防 watchdog/daemon 重启误伤)。
436
+ */
437
+ async _checkProxySwitch() {
438
+ try {
439
+ const health = getClashHealth();
440
+ if (!health) return; // daemon 未就绪,暂不初始化基线
441
+ const seqMap = new Map();
442
+ seqMap.set("main", Number(health.restartSeq) || 0);
443
+ for (const ch of getClashHealthChannels()) {
444
+ seqMap.set(`ch${ch.port}`, Number(ch.restartSeq) || 0);
445
+ }
446
+ if (this._restartSeqBaseline === null) {
447
+ this._restartSeqBaseline = seqMap; // 首次读取建基线(watchdog 重启不误触发)
448
+ return;
449
+ }
450
+ let switched = false;
451
+ for (const [key, seq] of seqMap) {
452
+ if (seq > (this._restartSeqBaseline.get(key) || 0)) {
453
+ switched = true;
454
+ break;
455
+ }
456
+ }
457
+ this._restartSeqBaseline = seqMap;
458
+ if (!switched) return;
459
+ if (!this._switchRestartEnabled) {
460
+ console.error(
461
+ `[watchdog] 检测到代理切换但重启功能未启用(后台可开启),跳过重启`,
462
+ );
463
+ return;
464
+ }
465
+ const reason = health.lastSwitch
466
+ ? `代理切换(${health.lastSwitch})`
467
+ : "代理切换";
468
+ console.error(`[watchdog] 检测到${reason},强制重启全部命令...`);
469
+ await this.processManager.restartAll(reason);
470
+ } catch (e) {
471
+ console.error(`[watchdog] 代理切换感知检查失败: ${e.message}`);
472
+ }
473
+ }
474
+
475
+ /** 应用服务端下发的代理切换重启配置(默认不启用,后端下发开启才生效) */
476
+ _applySwitchRestartConfig(cfg) {
477
+ this._switchRestartEnabled = !!cfg?.enabled;
478
+ const key = `${this._switchRestartEnabled}`;
479
+ if (key === this._switchRestartConfigKey) return;
480
+ this._switchRestartConfigKey = key;
481
+ console.error(
482
+ `[watchdog] 代理切换重启: ${this._switchRestartEnabled ? "已启用" : "已禁用"}(切换后${this._switchRestartEnabled ? "" : "不"}强制重启命令)`,
483
+ );
484
+ }
485
+
486
+ /**
487
+ * 应用服务端下发的自动升级开关
488
+ * 未配置(null/undefined)= 默认开启;后端显式 {enabled:false} 才停用升级检测器
489
+ */
490
+ _applyAutoUpgradeConfig(cfg) {
491
+ const enabled = cfg ? !!cfg.enabled : true;
492
+ if (enabled === this._autoUpgradeEnabled) return; // 幂等,避免每次心跳重复启停
493
+ this._autoUpgradeEnabled = enabled;
494
+ console.error(
495
+ `[watchdog] 自动升级: ${enabled ? "已开启" : "已关闭"}${cfg ? "" : "(未配置,默认开启)"}`,
496
+ );
497
+ if (enabled) {
498
+ if (!this.upgradeChecker) this._startUpgradeChecker();
499
+ } else if (this.upgradeChecker) {
500
+ this.upgradeChecker.stop();
501
+ this.upgradeChecker = null;
502
+ }
503
+ }
504
+
407
505
  /** 应用服务端下发的错误触发切换配置(控制启用与参数,默认不启用) */
408
506
  _applyErrorTriggerConfig(cfg) {
409
507
  this._errorTriggerEnabled = !!cfg?.enabled;
@@ -511,6 +609,55 @@ export class WatchdogAgent {
511
609
  }
512
610
  }
513
611
 
612
+ /** 下发强切 failover(force: 无视 daemon 冷却期,直接轮换候选) */
613
+ async _forceFailover(reason) {
614
+ try {
615
+ const ipc = await import("../clash/ipc.js");
616
+ ipc.writeCommand({
617
+ action: "failover",
618
+ force: true,
619
+ reason: reason || "客户端异常强切",
620
+ });
621
+ console.error(
622
+ `[watchdog] 已下发强切命令 (原因: ${reason || "客户端异常"})`,
623
+ );
624
+ } catch (e) {
625
+ console.error(`[watchdog] 下发强切失败: ${e.message}`);
626
+ }
627
+ }
628
+
629
+ /**
630
+ * 启动客户端异常监控:定时拉取 watch 服务端异常列表,
631
+ * 命中本机条目(reportCount > 8 且最近 2 分钟内)→ 强切代理 + 删除全部本机条目。
632
+ */
633
+ _startClientErrorMonitor() {
634
+ const cfg = getClientConfig();
635
+ const server = cfg.server || "";
636
+ if (!server) {
637
+ console.error(
638
+ "[watchdog] 未配置 watch 服务端地址 (server),客户端异常监控跳过",
639
+ );
640
+ return;
641
+ }
642
+ this.clientErrorMonitor = new ClientErrorMonitor({
643
+ serverUrl: server,
644
+ hostname: osHostname(),
645
+ intervalMs: 60_000,
646
+ onAlert: async (mine) => {
647
+ const ids = mine.map((e) => e.userId).join(",");
648
+ console.error(
649
+ `[watchdog] 客户端异常监控命中 ${mine.length} 条本机异常 (${ids}),强制切换代理并清理`,
650
+ );
651
+ await this._forceFailover("client-errors");
652
+ const removed = await this.clientErrorMonitor.removeErrors(mine);
653
+ console.error(
654
+ `[watchdog] 已清理本机异常条目 ${removed}/${mine.length}`,
655
+ );
656
+ },
657
+ });
658
+ this.clientErrorMonitor.start();
659
+ }
660
+
514
661
  /** 构建心跳请求体 */
515
662
  _buildHeartbeatBody() {
516
663
  const sys = getSystemInfo();
@@ -609,6 +756,14 @@ export class WatchdogAgent {
609
756
  if (data.errorTrigger) {
610
757
  this._applyErrorTriggerConfig(data.errorTrigger);
611
758
  }
759
+ // 服务端下发的代理切换重启配置(默认不启用,后端下发开启才生效)
760
+ if (data.switchRestart) {
761
+ this._applySwitchRestartConfig(data.switchRestart);
762
+ }
763
+ // 服务端下发的自动升级开关(未配置默认开启;后端显式关闭才停用)
764
+ if (data.autoUpgrade !== undefined) {
765
+ this._applyAutoUpgradeConfig(data.autoUpgrade);
766
+ }
612
767
  // 服务端下发的登录检测端口列表
613
768
  if (Array.isArray(data.loginPorts)) {
614
769
  const key = data.loginPorts.join(",");
@@ -873,6 +1028,7 @@ export class WatchdogAgent {
873
1028
  */
874
1029
  _startUpgradeChecker() {
875
1030
  if (!isGlobalInstall()) return;
1031
+ if (!this._autoUpgradeEnabled) return;
876
1032
 
877
1033
  this.upgradeChecker = startUpgradeChecker({
878
1034
  onUpdateAvailable: async ({ current, latest }) => {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 客户端异常监控模块 — 看门狗侧
3
+ *
4
+ * 职责:
5
+ * 1. 定时拉取 watch 服务端 /api/client-errors
6
+ * 2. 按 hostname 过滤出本机条目(异常条目由业务进程上报时携带 hostname)
7
+ * 3. 逐条过独立检测函数,命中 → onAlert(本机全部条目, 命中条目)
8
+ * 4. 提供 removeErrors 删除接口(处理完成后的清理)
9
+ *
10
+ * 检测函数第一版:reportCount > 8 且最近一次上报在 2 分钟内。
11
+ */
12
+
13
+ import os from "os";
14
+
15
+ /** 检测阈值:累计上报次数 > 8 */
16
+ export const DETECT_REPORT_COUNT_THRESHOLD = 8;
17
+ /** 检测窗口:最近一次上报距今 < 2 分钟 */
18
+ export const DETECT_FRESH_MS = 2 * 60 * 1000;
19
+
20
+ /**
21
+ * 独立检测函数(第一版)
22
+ * @param {object} err - /api/client-errors 返回的单条异常
23
+ * @param {number} [now] - 当前时间戳(测试注入用)
24
+ * @returns {boolean}
25
+ */
26
+ export function detectClientError(err, now = Date.now()) {
27
+ if (!err || typeof err.reportCount !== "number") return false;
28
+ const fresh = now - err.timestamp < DETECT_FRESH_MS;
29
+ return err.reportCount > DETECT_REPORT_COUNT_THRESHOLD && fresh;
30
+ }
31
+
32
+ /**
33
+ * 客户端异常监控器
34
+ */
35
+ export class ClientErrorMonitor {
36
+ /**
37
+ * @param {object} opts
38
+ * @param {string} opts.serverUrl - watch 服务端地址(~/.tt-help.json 的 server)
39
+ * @param {string} [opts.hostname] - 本机 hostname(默认 os.hostname())
40
+ * @param {number} [opts.intervalMs] - 拉取周期,默认 60s
41
+ * @param {(err: object, now: number) => boolean} [opts.detect] - 检测函数,默认 detectClientError
42
+ * @param {(mine: Array, hit: Array) => Promise|void} [opts.onAlert] - 命中回调
43
+ */
44
+ constructor({ serverUrl, hostname, intervalMs = 60_000, detect, onAlert }) {
45
+ this.serverUrl = serverUrl ? String(serverUrl).replace(/\/+$/, "") : "";
46
+ this.hostname = hostname || os.hostname();
47
+ this.intervalMs = intervalMs;
48
+ this.detect = detect || detectClientError;
49
+ this.onAlert = onAlert || null;
50
+ this.timer = null;
51
+ this.running = false;
52
+ this._polling = false; // 防重入
53
+ }
54
+
55
+ /** 启动轮询(立即拉一次,之后按周期) */
56
+ start() {
57
+ if (this.running || !this.serverUrl) return;
58
+ this.running = true;
59
+ this.timer = setInterval(
60
+ () => this._poll().catch(() => {}),
61
+ this.intervalMs,
62
+ );
63
+ this._poll().catch(() => {});
64
+ }
65
+
66
+ stop() {
67
+ this.running = false;
68
+ if (this.timer) {
69
+ clearInterval(this.timer);
70
+ this.timer = null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 拉取一次并评估
76
+ * @returns {Promise<Array|null>} 本机命中的条目;无可评估内容时返回 null
77
+ */
78
+ async _poll() {
79
+ if (this._polling) return null;
80
+ this._polling = true;
81
+ try {
82
+ const resp = await fetch(`${this.serverUrl}/api/client-errors`, {
83
+ signal: AbortSignal.timeout(10000),
84
+ });
85
+ if (!resp.ok) return null;
86
+ const data = await resp.json();
87
+ const clients = Array.isArray(data.clients) ? data.clients : [];
88
+ // hostname 为空的旧条目(升级前上报)不参与本机关联
89
+ const mine = clients.filter(
90
+ (c) => c.hostname && c.hostname === this.hostname,
91
+ );
92
+ if (mine.length === 0) return null;
93
+ const now = Date.now();
94
+ const hit = mine.filter((c) => this.detect(c, now));
95
+ if (hit.length > 0 && this.onAlert) {
96
+ try {
97
+ await this.onAlert(mine, hit);
98
+ } catch {
99
+ /* 回调异常不中断轮询 */
100
+ }
101
+ }
102
+ return hit;
103
+ } catch {
104
+ return null; // 网络/解析失败下轮重试
105
+ } finally {
106
+ this._polling = false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 删除指定异常条目(处理完成后的清理)
112
+ * @param {Array<{userId:string}>} errors
113
+ * @returns {Promise<number>} 成功删除条数
114
+ */
115
+ async removeErrors(errors) {
116
+ let removed = 0;
117
+ for (const e of errors || []) {
118
+ if (!e || !e.userId) continue;
119
+ try {
120
+ const resp = await fetch(
121
+ `${this.serverUrl}/api/client-error/${encodeURIComponent(e.userId)}`,
122
+ { method: "DELETE", signal: AbortSignal.timeout(10000) },
123
+ );
124
+ if (resp.ok) removed++;
125
+ } catch {
126
+ /* 单条失败不影响其余 */
127
+ }
128
+ }
129
+ return removed;
130
+ }
131
+ }
@@ -23,6 +23,7 @@ const MAX_RESTART = 3;
23
23
  const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
24
24
  const IS_WIN = process.platform === "win32";
25
25
  const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
26
+ const STOP_STAGGER_MS = 10000; // 强制重启时每个命令停止间隔 10 秒(杀 Edge/进程树会瞬时占资源,错峰释放)
26
27
  const STALE_LOG_TIMEOUT_MS = 15 * 60 * 1000; // 15 分钟无日志输出视为假死
27
28
 
28
29
  // === 控制台日志格式化(仅显示层增强,不影响任何功能链路) ===
@@ -718,10 +719,58 @@ export class ProcessManager extends EventEmitter {
718
719
  }
719
720
  }
720
721
 
722
+ /**
723
+ * 强制重启所有运行中的命令(先停全部,再错峰逐个重启)
724
+ * 用途:代理切换后强制所有业务命令重建连接、走新出口节点
725
+ * @param {string} [reason] - 重启原因(日志用)
726
+ * @returns {Promise<{restarted:number}>}
727
+ */
728
+ async restartAll(reason) {
729
+ const toRestart = [];
730
+ for (const [, info] of this.processes) {
731
+ if (!info.stopped && info.child && info.cmdDef) {
732
+ toRestart.push(info.cmdDef);
733
+ }
734
+ }
735
+ if (!toRestart.length) {
736
+ console.error(`[watchdog] ${reason},无运行中的命令,跳过重启`);
737
+ return { restarted: 0 };
738
+ }
739
+ console.error(
740
+ `[watchdog] ${reason},强制重启全部命令 (${toRestart.length}个)...`,
741
+ );
742
+ // 错峰停止:第一个立即停,之后每个间隔 STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
743
+ for (let i = 0; i < toRestart.length; i++) {
744
+ if (i > 0) await new Promise((r) => setTimeout(r, STOP_STAGGER_MS));
745
+ if (this.shutdown) {
746
+ console.error("[watchdog] 看门狗已停止,中止停止命令");
747
+ break;
748
+ }
749
+ this.stopCommand(toRestart[i].id);
750
+ console.error(
751
+ `[watchdog] 命令 ${toRestart[i].id} 已停止 (${i + 1}/${toRestart.length})`,
752
+ );
753
+ }
754
+ // 错峰逐个重启:第一个立即起,之后每个间隔 SPAWN_STAGGER_MS
755
+ for (let i = 0; i < toRestart.length; i++) {
756
+ if (i > 0) await new Promise((r) => setTimeout(r, SPAWN_STAGGER_MS));
757
+ if (this.shutdown) {
758
+ console.error("[watchdog] 看门狗已停止,中止重启命令");
759
+ break;
760
+ }
761
+ this.startCommand(toRestart[i]);
762
+ console.error(
763
+ `[watchdog] 命令 ${toRestart[i].id} 已重启 (${i + 1}/${toRestart.length})`,
764
+ );
765
+ }
766
+ return { restarted: toRestart.length };
767
+ }
768
+
721
769
  /**
722
770
  * 停止所有进程
723
771
  */
724
772
  stopAll() {
773
+ this.shutdown = true;
725
774
  console.error("[watchdog] 正在停止所有子进程...");
726
775
  for (const [cmdId] of this.processes) {
727
776
  this.stopCommand(cmdId);
@@ -741,6 +741,7 @@ async function notifyCaptchaToWatch(url, maxRetries = 3) {
741
741
  "TikTok Shop captcha detected, browser opened - manual intervention required",
742
742
  stage: "product_info",
743
743
  errorStack: url,
744
+ hostname: os.hostname(),
744
745
  }),
745
746
  });
746
747
  console.error("[webserver] Captcha alert sent to watch server");