tt-help-cli-ycl 1.4.131 → 1.4.134

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.131",
3
+ "version": "1.4.134",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,14 +1,11 @@
1
1
  /**
2
- * daemon _handleFailover 三级响应单测(2026-08-22,无需 Clash 运行)
2
+ * daemon _handleFailover 单测(2026-08-25,时间锚点方案,无需 Clash 运行)
3
3
  *
4
4
  * 验证:
5
- * - 探针健康 → 窗口内 failover 仅检查不切换(前 N-1 次)
6
- * - 探针不健康 → 立即切换(无视窗口)
7
- * - 壳页 → 视为链路正常,仅检查不切换(与巡检口径一致)
8
- * - 窗口过期 → 计数重置
9
- * - 窗口内到达 failoverEscapeAfter 次 → 按业务级封禁轮换一次
10
- * - 实际切换后 → 窗口与计数重置
11
- * - escapeAfter 可配置
5
+ * - 探针不健康 → 立即切换(无视冷却)
6
+ * - 探针健康(含壳页)→ 距上次切换 < 冷却期仅检查不切换
7
+ * - 探针健康 → 距上次切换 >= 冷却期 → 按业务级封禁轮换一次
8
+ * - 切换后(含手动/巡检/failover)更新 _lastSwitchAt 锚点,冷却期内不再切
12
9
  * - 候选集为空 → 触发 delay 初测且不切换
13
10
  *
14
11
  * 运行: node --test scripts/test-daemon-failover.mjs
@@ -31,7 +28,9 @@ const state = {
31
28
  nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
32
29
  currentNode: "🇭🇰 香港01",
33
30
  switches: [],
34
- closes: 0,
31
+ closes: 0, // closeAllConnections(全断)调用次数
32
+ idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
33
+ failIdleClose: false, // 模拟优雅断连失败
35
34
  };
36
35
 
37
36
  function resetState() {
@@ -48,6 +47,8 @@ function resetState() {
48
47
  state.currentNode = "🇭🇰 香港01";
49
48
  state.switches = [];
50
49
  state.closes = 0;
50
+ state.idleCloses = 0;
51
+ state.failIdleClose = false;
51
52
  }
52
53
 
53
54
  // ── 注册模块 mock(必须在 import daemon.js 之前) ──
@@ -85,6 +86,11 @@ before(async () => {
85
86
  closeAllConnections: async () => {
86
87
  state.closes += 1;
87
88
  },
89
+ closeIdleConnections: async () => {
90
+ if (state.failIdleClose) throw new Error("mock 优雅断连失败");
91
+ state.idleCloses += 1;
92
+ return { closed: 3, active: 2 };
93
+ },
88
94
  },
89
95
  });
90
96
  mock.module("../src/clash/ipc.js", {
@@ -116,29 +122,32 @@ function makeCtx(opts = {}) {
116
122
  return { d, c };
117
123
  }
118
124
 
119
- test("F1 探针健康:前 2 次 failover 仅检查不切换,第 3 次轮换", async () => {
125
+ test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点", async () => {
120
126
  resetState();
121
127
  const { d, c } = makeCtx();
122
- await d._handleFailover({ reason: "F1-1" }, c);
123
- await d._handleFailover({ reason: "F1-2" }, c);
124
- assert.equal(state.switches.length, 0, "前 2 次不应切换");
125
- assert.equal(state.closes, 0, "前 2 次不应断开连接");
126
- await d._handleFailover({ reason: "F1-3" }, c);
127
- assert.deepEqual(state.switches, ["🇲🇾 马来02"], "第 3 次按轮换切换到候选");
128
- assert.equal(state.closes, 1, "切换后应断开旧连接");
128
+ c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
129
+ await d._handleFailover({ reason: "F1" }, c);
130
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
131
+ assert.equal(state.idleCloses, 1, "切换后应优雅断连(空闲回收)");
132
+ assert.ok(
133
+ c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
134
+ "切换后应更新锚点",
135
+ );
129
136
  });
130
137
 
131
- test("F2 探针不健康:立即切换(无视窗口)", async () => {
138
+ test("F2 探针不健康:立即切换(无视冷却)", async () => {
132
139
  resetState();
133
140
  const { d, c } = makeCtx();
141
+ c._lastSwitchAt = Date.now(); // 刚切过,但探针不健康时不看锚点
134
142
  state.health = { ...state.health, healthy: false, error: "timeout" };
135
143
  await d._handleFailover({ reason: "F2" }, c);
136
144
  assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
137
145
  });
138
146
 
139
- test("F3 壳页视为链路正常:仅检查不切换", async () => {
147
+ test("F3 壳页视为链路正常:冷却期内仅检查不切换", async () => {
140
148
  resetState();
141
149
  const { d, c } = makeCtx();
150
+ c._lastSwitchAt = Date.now(); // 冷却期内
142
151
  // 壳页签名:200 + 无跳转 + 无 error + 无 title
143
152
  state.health = {
144
153
  healthy: false,
@@ -150,41 +159,45 @@ test("F3 壳页视为链路正常:仅检查不切换", async () => {
150
159
  error: null,
151
160
  };
152
161
  await d._handleFailover({ reason: "F3" }, c);
153
- assert.equal(state.switches.length, 0, "壳页不应触发切换");
162
+ assert.equal(state.switches.length, 0, "冷却期内壳页不应触发切换");
154
163
  });
155
164
 
156
- test("F4 窗口过期:计数重置,不累计到逃生阈值", async () => {
165
+ test("F4 冷却期约束:切换后冷却期内不连续切换,过期后再切", async () => {
157
166
  resetState();
158
- const { d, c } = makeCtx({ failoverCooldownMs: 150 });
159
- await d._handleFailover({ reason: "F4-1" }, c); // count=1
160
- await new Promise((r) => setTimeout(r, 200)); // 窗口过期
161
- await d._handleFailover({ reason: "F4-2" }, c); // 重置后 count=1
162
- await d._handleFailover({ reason: "F4-3" }, c); // count=2
163
- assert.equal(state.switches.length, 0, "窗口过期后不应累计旧计数");
164
- await d._handleFailover({ reason: "F4-4" }, c); // count=3 → 轮换
165
- assert.equal(state.switches.length, 1, "第 3 次才应轮换");
167
+ const { d, c } = makeCtx();
168
+ // 首次 failover(无锚点)→ 按冷却期外处理 → 轮换
169
+ await d._handleFailover({ reason: "F4-1" }, c);
170
+ assert.equal(state.switches.length, 1, "首次应轮换切换");
171
+ // 锚点刚更新,冷却期内再次 failover(探针健康)→ 不切
172
+ await d._handleFailover({ reason: "F4-2" }, c);
173
+ assert.equal(state.switches.length, 1, "冷却期内不应再切换");
174
+ // 冷却期过后 → 再次轮换
175
+ c._lastSwitchAt = Date.now() - 600_001;
176
+ await d._handleFailover({ reason: "F4-3" }, c);
177
+ assert.equal(state.switches.length, 2, "冷却期过后应再切换");
166
178
  });
167
179
 
168
- test("F5 切换后窗口重置:重新从仅检查开始", async () => {
180
+ test("F5 手动切换同样更新锚点:切换后冷却期内 failover 不再切", async () => {
169
181
  resetState();
170
182
  const { d, c } = makeCtx();
171
- await d._handleFailover({ reason: "F5-1" }, c); // skip
172
- await d._handleFailover({ reason: "F5-2" }, c); // skip
173
- await d._handleFailover({ reason: "F5-3" }, c); // switch
174
- assert.equal(state.switches.length, 1, "第 3 次应切换");
175
- await d._handleFailover({ reason: "F5-4" }, c); // 重置后 count=1 → skip
176
- assert.equal(state.switches.length, 1, "切换后应重置窗口,不连续切换");
177
- await d._handleFailover({ reason: "F5-5" }, c); // count=2 → skip
178
- await d._handleFailover({ reason: "F5-6" }, c); // count=3 → switch
179
- assert.equal(state.switches.length, 2, "第 6 次才应再次轮换");
183
+ // 手动切换(用户主动切,立即执行)
184
+ await d._handleSwitchNode({ node: "🇲🇾 马来02", proxyPort: 7897 }, c);
185
+ assert.equal(state.switches.length, 1, "手动切换应立即执行");
186
+ assert.ok(
187
+ c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
188
+ "手动切换应更新锚点",
189
+ );
190
+ // 手动切换后冷却期内,探针健康的 failover 不再切
191
+ await d._handleFailover({ reason: "F5-2" }, c);
192
+ assert.equal(
193
+ state.switches.length,
194
+ 1,
195
+ "手动切换后冷却期内 failover 不应再切",
196
+ );
180
197
  });
181
198
 
182
- test("F6 escapeAfter 可配置:=1 时首次健康 failover 即轮换", async () => {
183
- resetState();
184
- const { d, c } = makeCtx({ failoverEscapeAfter: 1 });
185
- await d._handleFailover({ reason: "F6" }, c);
186
- assert.equal(state.switches.length, 1, "escapeAfter=1 应首次即切换");
187
- });
199
+ // F6 已移除:failoverEscapeAfter 计数方案已弃用(2026-08-25 改为时间锚点),
200
+ // 不再存在"连报 N 次"阈值;字段保留仅为兼容外部传参,不影响行为。
188
201
 
189
202
  test("F7 候选集为空:触发 delay 初测且不切换", async () => {
190
203
  resetState();
@@ -195,3 +208,24 @@ test("F7 候选集为空:触发 delay 初测且不切换", async () => {
195
208
  assert.equal(state.switches.length, 0, "无候选不应切换");
196
209
  assert.ok(c._lastProbeAt, "应触发过 delay 初测");
197
210
  });
211
+
212
+ test("F8 优雅断连失败:回退全断,切换仍生效", async () => {
213
+ resetState();
214
+ const { d, c } = makeCtx();
215
+ c._lastSwitchAt = Date.now() - 600_001; // 冷却期外
216
+ state.failIdleClose = true; // 优雅断连抛错
217
+ await d._handleFailover({ reason: "F8" }, c);
218
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换仍应执行");
219
+ assert.equal(state.idleCloses, 0, "优雅断连失败未计数");
220
+ assert.equal(state.closes, 1, "失败后应回退全断");
221
+ });
222
+
223
+ test("F9 disconnectMode=full:直接全断", async () => {
224
+ resetState();
225
+ const { d, c } = makeCtx({ disconnectMode: "full" });
226
+ c._lastSwitchAt = Date.now() - 600_001;
227
+ await d._handleFailover({ reason: "F9" }, c);
228
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换应执行");
229
+ assert.equal(state.idleCloses, 0, "full 模式不走优雅断连");
230
+ assert.equal(state.closes, 1, "full 模式直接全断");
231
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 探测模块集成测试(真实浏览器 + 真实 TikTok)
3
+ *
4
+ * 用法:
5
+ * node scripts/test-probe-live.mjs [ports] [points]
6
+ * 例: node scripts/test-probe-live.mjs 4222
7
+ * node scripts/test-probe-live.mjs 4222 video,comment
8
+ *
9
+ * 说明:
10
+ * - 连接本地已启动的 CDP 端口(不 kill 业务浏览器)
11
+ * - 会对 TikTok 真实发起访问(探测本身是访问行为)
12
+ * - 断言输出结构 + 至少能完成调度;业务可用性结果只打印不判成败
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import { runProbe, POINT_NAMES } from "../src/lib/probe/index.js";
16
+
17
+ const ports = (process.argv[2] || "4222")
18
+ .split(",")
19
+ .map((s) => Number(s.trim()))
20
+ .filter((n) => Number.isInteger(n) && n > 0);
21
+ const points = process.argv[3]
22
+ ? process.argv[3].split(",").map((s) => s.trim()).filter(Boolean)
23
+ : POINT_NAMES;
24
+
25
+ console.error(`[live-test] ports=${ports.join(",")} points=${points.join(",")}`);
26
+ console.error(`[live-test] 说明:将对 TikTok 真实访问,属正常探测行为\n`);
27
+
28
+ const t0 = Date.now();
29
+ const result = await runProbe({ ports, points, log: console.error });
30
+ const totalMs = Date.now() - t0;
31
+
32
+ console.error(`\n[结果] 总耗时 ${totalMs}ms`);
33
+ console.log(JSON.stringify(result, null, 2));
34
+
35
+ // ── 结构断言(不判业务成败,只判调度/输出正确) ──
36
+ let failed = 0;
37
+ try {
38
+ assert.ok(Array.isArray(result.ports), "ports 应为数组");
39
+ assert.equal(result.ports.length, ports.length, "端口数应一致");
40
+ assert.ok(result.ports.every((p) => Number.isInteger(p.port)), "端口号应为整数");
41
+ assert.ok(
42
+ result.ports.every((p) => typeof p.loggedIn === "boolean"),
43
+ "loggedIn 应为布尔",
44
+ );
45
+ // 每端口包含请求的功能点键
46
+ for (const p of result.ports) {
47
+ for (const name of points) {
48
+ assert.ok(
49
+ name in p.points,
50
+ `端口 ${p.port} 应包含功能点 ${name}`,
51
+ );
52
+ const r = p.points[name];
53
+ assert.equal(typeof r.ok, "boolean", `${name}.ok 应为布尔`);
54
+ assert.equal(typeof r.count, "number", `${name}.count 应为数字`);
55
+ assert.equal(typeof r.elapsedMs, "number", `${name}.elapsedMs 应为数字`);
56
+ assert.ok(
57
+ typeof r.error === "string" || r.error === null,
58
+ `${name}.error 应为 string|null`,
59
+ );
60
+ }
61
+ }
62
+ console.error(`\n[断言] 结构校验全部通过 ✓`);
63
+ } catch (e) {
64
+ failed = 1;
65
+ console.error(`\n[断言] 失败: ${e.message}`);
66
+ }
67
+
68
+ // 汇总业务结果
69
+ console.error("\n[业务汇总]");
70
+ for (const p of result.ports) {
71
+ for (const name of points) {
72
+ const r = p.points[name];
73
+ if (!r) continue;
74
+ const tag = r.skipped
75
+ ? `跳过(${r.skipReason})`
76
+ : r.ok
77
+ ? `✓ ${r.count}`
78
+ : `✗ ${r.error}`;
79
+ console.error(` port ${p.port} ${name.padEnd(8)} ${tag} [${r.elapsedMs}ms]`);
80
+ }
81
+ }
82
+
83
+ process.exit(failed);
@@ -0,0 +1,315 @@
1
+ /**
2
+ * 探测模块单元测试(离线,不连浏览器)
3
+ *
4
+ * 覆盖:
5
+ * - common: classifyError / withTimeout / cleanMessage / makePointResult
6
+ * - targets: DEFAULT_TARGETS / resolveTargets
7
+ * - index: POINT_NAMES / normalizePoints / runProbe 空端口与异常端口
8
+ * - points: comment 无视频 skipped、follow 未登录 skipped、POINT_NAME 注册
9
+ *
10
+ * 运行: node scripts/test-probe.mjs
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import {
14
+ classifyError,
15
+ withTimeout,
16
+ cleanMessage,
17
+ makePointResult,
18
+ isTransientKind,
19
+ retryOnTransient,
20
+ } from "../src/lib/probe/common.js";
21
+ import { DEFAULT_TARGETS, resolveTargets } from "../src/lib/probe/targets.js";
22
+ import {
23
+ POINTS,
24
+ POINT_NAMES,
25
+ normalizePoints,
26
+ runProbe,
27
+ } from "../src/lib/probe/index.js";
28
+ import {
29
+ POINT_NAME as COMMENT_POINT_NAME,
30
+ probeComment,
31
+ } from "../src/lib/probe/points/comment.js";
32
+ import {
33
+ POINT_NAME as FOLLOW_POINT_NAME,
34
+ probeFollow,
35
+ } from "../src/lib/probe/points/follow.js";
36
+
37
+ let passed = 0;
38
+ const failures = [];
39
+ const allTests = [];
40
+ function test(name, fn) {
41
+ allTests.push(
42
+ Promise.resolve().then(() => {
43
+ try {
44
+ fn();
45
+ passed++;
46
+ console.log(`✓ ${name}`);
47
+ } catch (e) {
48
+ failures.push({ name, error: e });
49
+ console.error(`✗ ${name}: ${e.message}`);
50
+ }
51
+ }),
52
+ );
53
+ }
54
+ function testAsync(name, fn) {
55
+ allTests.push(
56
+ Promise.resolve().then(async () => {
57
+ try {
58
+ await fn();
59
+ passed++;
60
+ console.log(`✓ ${name}`);
61
+ } catch (e) {
62
+ failures.push({ name, error: e });
63
+ console.error(`✗ ${name}: ${e.message}`);
64
+ }
65
+ }),
66
+ );
67
+ }
68
+
69
+ // ── common.classifyError ──
70
+ test("classifyError: 被封 → blocked", () => {
71
+ assert.equal(classifyError(new Error("被封: abc")).kind, "blocked");
72
+ });
73
+ test("classifyError: CDN限流 → rate_limited", () => {
74
+ assert.equal(classifyError(new Error("CDN限流: abc")).kind, "rate_limited");
75
+ });
76
+ test("classifyError: Access Denied → rate_limited", () => {
77
+ assert.equal(classifyError("Access Denied").kind, "rate_limited");
78
+ });
79
+ test("classifyError: 验证码 → captcha", () => {
80
+ assert.equal(classifyError("验证码出现").kind, "captcha");
81
+ });
82
+ test("classifyError: captcha → captcha", () => {
83
+ assert.equal(classifyError("captcha verify").kind, "captcha");
84
+ });
85
+ test("classifyError: Timeout → timeout", () => {
86
+ assert.equal(classifyError(new Error("Timeout 123")).kind, "timeout");
87
+ });
88
+ test("classifyError: 超时 → timeout", () => {
89
+ assert.equal(classifyError("请求超时").kind, "timeout");
90
+ });
91
+ test("classifyError: login_required → login_required", () => {
92
+ assert.equal(classifyError("login_required").kind, "login_required");
93
+ });
94
+ test("classifyError: 未登录 → login_required", () => {
95
+ assert.equal(classifyError("请先登录").kind, "login_required");
96
+ });
97
+ test("classifyError: region → region_blocked", () => {
98
+ assert.equal(
99
+ classifyError("not available in your region").kind,
100
+ "region_blocked",
101
+ );
102
+ });
103
+ test("classifyError: 普通错误 → error", () => {
104
+ assert.equal(classifyError("boom something").kind, "error");
105
+ });
106
+ test("classifyError: 空 → error + 未知错误", () => {
107
+ const c = classifyError(null);
108
+ assert.equal(c.kind, "error");
109
+ assert.equal(c.message, "未知错误");
110
+ });
111
+
112
+ // ── common.cleanMessage ──
113
+ test("cleanMessage: 去 ANSI + 首行 + 限长", () => {
114
+ const out = cleanMessage("\x1b[31mline1\x1b[0m\nline2\n".repeat(50));
115
+ assert.ok(!out.includes("\x1b"));
116
+ assert.ok(!out.includes("\n"));
117
+ assert.ok(out.length <= 300);
118
+ });
119
+
120
+ // ── common.withTimeout ──
121
+ testAsync("withTimeout: 正常完成返回结果", async () => {
122
+ const r = await withTimeout(Promise.resolve("ok"), 2000, "t");
123
+ assert.equal(r, "ok");
124
+ });
125
+ testAsync("withTimeout: 超时 reject", async () => {
126
+ let timedOut = false;
127
+ try {
128
+ await withTimeout(new Promise(() => {}), 30, "超时测试");
129
+ } catch (e) {
130
+ timedOut = true;
131
+ assert.ok(e.message.includes("超时测试"));
132
+ }
133
+ assert.ok(timedOut, "应超时 reject");
134
+ });
135
+
136
+ // ── common.makePointResult ──
137
+ test("makePointResult: 默认空 → kind=empty", () => {
138
+ const r = makePointResult({ ok: false });
139
+ assert.equal(r.kind, "empty");
140
+ assert.equal(r.count, 0);
141
+ assert.equal(r.skipped, false);
142
+ });
143
+ test("makePointResult: ok → kind=null + extra 合并", () => {
144
+ const r = makePointResult({ ok: true, count: 3, extra: { a: 1 } });
145
+ assert.equal(r.kind, null);
146
+ assert.equal(r.a, 1);
147
+ });
148
+ test("makePointResult: skipped + 显式 kind", () => {
149
+ const r = makePointResult({
150
+ ok: false,
151
+ skipped: true,
152
+ skipReason: "no_video",
153
+ kind: "login_required",
154
+ });
155
+ assert.equal(r.kind, "login_required");
156
+ assert.equal(r.skipReason, "no_video");
157
+ });
158
+
159
+ // ── common.isTransientKind / retryOnTransient ──
160
+ test("isTransientKind: 瞬时型为 true", () => {
161
+ for (const k of ["timeout", "empty", "error", "waf_challenge"]) {
162
+ assert.equal(isTransientKind(k), true, `${k} 应为瞬时`);
163
+ }
164
+ });
165
+ test("isTransientKind: 确定型与空为 false", () => {
166
+ for (const k of [
167
+ "blocked",
168
+ "rate_limited",
169
+ "captcha",
170
+ "login_required",
171
+ "region_blocked",
172
+ null,
173
+ undefined,
174
+ "",
175
+ ]) {
176
+ assert.equal(isTransientKind(k), false, `${k} 不应为瞬时`);
177
+ }
178
+ });
179
+ testAsync("retryOnTransient: 成功即返回(只调 1 次)", async () => {
180
+ let calls = 0;
181
+ const r = await retryOnTransient(async () => {
182
+ calls++;
183
+ return makePointResult({ ok: true, count: 3 });
184
+ }, { retries: 2, retryDelayMs: 0 });
185
+ assert.equal(r.ok, true);
186
+ assert.equal(calls, 1);
187
+ });
188
+ testAsync("retryOnTransient: 确定性失败不重试(只调 1 次)", async () => {
189
+ let calls = 0;
190
+ const r = await retryOnTransient(async () => {
191
+ calls++;
192
+ return makePointResult({ ok: false, kind: "blocked" });
193
+ }, { retries: 2, retryDelayMs: 0 });
194
+ assert.equal(r.ok, false);
195
+ assert.equal(r.kind, "blocked");
196
+ assert.equal(calls, 1);
197
+ });
198
+ testAsync("retryOnTransient: 瞬时失败后第 2 次成功", async () => {
199
+ let calls = 0;
200
+ const r = await retryOnTransient(async () => {
201
+ calls++;
202
+ if (calls === 1) return makePointResult({ ok: false, kind: "waf_challenge" });
203
+ return makePointResult({ ok: true, count: 5 });
204
+ }, { retries: 2, retryDelayMs: 0 });
205
+ assert.equal(r.ok, true);
206
+ assert.equal(calls, 2);
207
+ });
208
+ testAsync("retryOnTransient: 瞬时失败耗尽返回最后结果", async () => {
209
+ let calls = 0;
210
+ const r = await retryOnTransient(async () => {
211
+ calls++;
212
+ return makePointResult({ ok: false, kind: "timeout" });
213
+ }, { retries: 2, retryDelayMs: 0 });
214
+ assert.equal(r.ok, false);
215
+ assert.equal(r.kind, "timeout");
216
+ assert.equal(calls, 3); // 1 次初始 + 2 次重试
217
+ });
218
+ testAsync("retryOnTransient: 重试间有间隔延迟", async () => {
219
+ const t0 = Date.now();
220
+ let calls = 0;
221
+ await retryOnTransient(
222
+ async () => {
223
+ calls++;
224
+ return makePointResult({ ok: false, kind: "empty" });
225
+ },
226
+ { retries: 1, retryDelayMs: 150 },
227
+ );
228
+ assert.equal(calls, 2);
229
+ assert.ok(Date.now() - t0 >= 150, "应有重试间隔");
230
+ });
231
+
232
+ // ── targets ──
233
+ test("targets: 默认值", () => {
234
+ assert.equal(DEFAULT_TARGETS.author, "tiktok");
235
+ assert.equal(DEFAULT_TARGETS.searchQuery, "dog");
236
+ assert.equal(DEFAULT_TARGETS.video, null);
237
+ });
238
+ test("targets: resolveTargets 覆盖 + 保留默认", () => {
239
+ const t = resolveTargets({ author: "foo", video: "x" });
240
+ assert.equal(t.author, "foo");
241
+ assert.equal(t.searchQuery, "dog");
242
+ assert.equal(t.video, "x");
243
+ });
244
+ test("targets: resolveTargets 空覆盖 → 全默认", () => {
245
+ const t = resolveTargets();
246
+ assert.deepEqual(t, DEFAULT_TARGETS);
247
+ });
248
+ test("targets: resolveTargets null → 全默认", () => {
249
+ const t = resolveTargets(null);
250
+ assert.deepEqual(t, DEFAULT_TARGETS);
251
+ });
252
+
253
+ // ── index.normalizePoints ──
254
+ test("normalizePoints: 空 → 全部功能点", () => {
255
+ assert.deepEqual(normalizePoints(), [...POINT_NAMES]);
256
+ assert.deepEqual(normalizePoints([]), [...POINT_NAMES]);
257
+ assert.deepEqual(normalizePoints(null), [...POINT_NAMES]);
258
+ });
259
+ test("normalizePoints: 全非法 → 全部功能点", () => {
260
+ assert.deepEqual(normalizePoints(["bad", "xxx"]), [...POINT_NAMES]);
261
+ });
262
+ test("normalizePoints: 过滤非法 + 去重 + 保序", () => {
263
+ assert.deepEqual(normalizePoints(["video", "video", "bad", "comment"]), [
264
+ "video",
265
+ "comment",
266
+ ]);
267
+ });
268
+ test("POINTS 注册表完整", () => {
269
+ assert.equal(POINT_NAMES.length, 5);
270
+ for (const n of POINT_NAMES) assert.equal(typeof POINTS[n], "function");
271
+ });
272
+
273
+ // ── points 注册与离线可判定分支 ──
274
+ test("points: 各模块 POINT_NAME 常量正确", () => {
275
+ assert.equal(COMMENT_POINT_NAME, "comment");
276
+ assert.equal(FOLLOW_POINT_NAME, "follow");
277
+ });
278
+ testAsync("probeComment: 无视频 → skipped(no_video)", async () => {
279
+ const r = await probeComment(null, {
280
+ target: { video: null },
281
+ videoHref: null,
282
+ });
283
+ assert.equal(r.skipped, true);
284
+ assert.equal(r.skipReason, "no_video");
285
+ assert.equal(r.ok, false);
286
+ });
287
+ testAsync("probeFollow: 未登录 → skipped(not_logged_in)", async () => {
288
+ const r = await probeFollow(null, { target: {}, loggedIn: false });
289
+ assert.equal(r.skipped, true);
290
+ assert.equal(r.skipReason, "not_logged_in");
291
+ assert.equal(r.kind, "login_required");
292
+ });
293
+
294
+ // ── runProbe 空端口 / 非法端口 ──
295
+ testAsync("runProbe: 空端口 → 空结果结构", async () => {
296
+ const r = await runProbe({ ports: [], log: () => {} });
297
+ assert.deepEqual(r.ports, []);
298
+ assert.ok(Array.isArray(r.points));
299
+ assert.equal(typeof r.checkedAt, "string");
300
+ assert.equal(typeof r.durationMs, "number");
301
+ });
302
+ testAsync("runProbe: 非法端口被过滤", async () => {
303
+ const r = await runProbe({ ports: [0, -1, NaN, "abc"], log: () => {} });
304
+ assert.deepEqual(r.ports, []);
305
+ });
306
+ // 注:真实端口连接(存在/不存在端口)属集成测试范畴(会启动/连接 Edge),见 scripts/test-probe-live.mjs
307
+
308
+ // ── 汇总(等全部测试完成) ──
309
+ await Promise.all(allTests);
310
+ console.log(`\n${passed} 通过, ${failures.length} 失败`);
311
+ if (failures.length > 0) {
312
+ for (const f of failures)
313
+ console.error(` FAIL: ${f.name}\n ${f.error.stack}`);
314
+ process.exit(1);
315
+ }
package/src/clash/api.js CHANGED
@@ -165,6 +165,45 @@ export async function closeConnection(id) {
165
165
  export async function closeAllConnections() {
166
166
  return request("/connections", { method: "DELETE" });
167
167
  }
168
+
169
+ /**
170
+ * 优雅断连:只关闭"空闲"连接(两次采样间无流量增长),活跃连接放行让其自然完成
171
+ * 切换节点后调用:正在进行的请求不被打断,空闲 keep-alive 被回收使新请求立即走新节点
172
+ * @param {object} [opts]
173
+ * @param {number} [opts.sampleMs=1000] - 两次采样间隔(毫秒)
174
+ * @returns {Promise<{closed:number, active:number}>} closed=已断开空闲数, active=放行的活跃/新建数
175
+ */
176
+ export async function closeIdleConnections({ sampleMs = 1000 } = {}) {
177
+ const snap1 = await getConnections();
178
+ await new Promise((r) => setTimeout(r, sampleMs));
179
+ const snap2 = await getConnections();
180
+ const map1 = new Map((snap1.connections || []).map((c) => [c.id, c]));
181
+ const idleIds = [];
182
+ let active = 0;
183
+ for (const c of snap2.connections || []) {
184
+ const c1 = map1.get(c.id);
185
+ // 新建连接(快照1没有):可能是刚发起的请求,保守放行避免打断
186
+ if (!c1) {
187
+ active++;
188
+ continue;
189
+ }
190
+ if (c1.download === c.download && c1.upload === c.upload)
191
+ idleIds.push(c.id);
192
+ else active++;
193
+ }
194
+ let closed = 0;
195
+ await Promise.all(
196
+ idleIds.map(async (id) => {
197
+ try {
198
+ await closeConnection(id);
199
+ closed++;
200
+ } catch {
201
+ /* 单个失败忽略,其余继续 */
202
+ }
203
+ }),
204
+ );
205
+ return { closed, active };
206
+ }
168
207
  export async function getRules() {
169
208
  return request("/rules");
170
209
  }
@@ -26,7 +26,7 @@ const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
26
26
  let _healthyFile = HEALTHY_FILE;
27
27
 
28
28
  export const CONFIG = {
29
- testUrl: "https://www.tiktok.com/explore",
29
+ testUrl: "https://www.tiktok.com/@tiktok",
30
30
  proxyUrl: "http://127.0.0.1:7897",
31
31
  apiPort: 9097,
32
32
  groupName: "GLOBAL",
@@ -20,9 +20,10 @@ function fmtCountries(v) {
20
20
  return list.length ? list.join(",") : "无";
21
21
  }
22
22
 
23
- // failover 检查窗口(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
23
+ // failover 冷却期(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
24
+ // 锚点 = 最近一次实际切换节点的时间(_lastSwitchAt):冷却期内探针健康不切换
24
25
  const FAILOVER_COOLDOWN_MS = 600_000;
25
- // 窗口内探针健康时的切换阈值(默认 3:前 2 次仅检查,第 3 次按业务级封禁轮换)
26
+ // 已弃用(2026-08-25 改为时间锚点方案):旧版"窗口内连报 N 次"计数,字段保留仅为兼容外部传参
26
27
  const FAILOVER_ESCAPE_AFTER = 3;
27
28
 
28
29
  // ══════════════════════════════════════════════
@@ -41,6 +42,10 @@ export class ClashDaemon {
41
42
  opts.failoverEscapeAfter > 0
42
43
  ? opts.failoverEscapeAfter
43
44
  : FAILOVER_ESCAPE_AFTER;
45
+ // 切换后断连策略:graceful=优雅断连(只断空闲连接,活跃放行),full=全断(紧急回退)
46
+ this.disconnectMode = opts.disconnectMode === "full" ? "full" : "graceful";
47
+ this.disconnectSampleMs =
48
+ opts.disconnectSampleMs > 0 ? opts.disconnectSampleMs : 1000;
44
49
  this.dryRun = !!opts.dryRun;
45
50
  // 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
46
51
  this._discoverOpts = {
@@ -97,6 +102,36 @@ export class ClashDaemon {
97
102
  c._switchHistory.set(nodeName, Date.now());
98
103
  }
99
104
 
105
+ /** 记录最近一次实际切换节点的时间(failover/巡检/手动切换共用锚点,冷却期判断依据) */
106
+ _markSwitchTime(c) {
107
+ if (c) c._lastSwitchAt = Date.now();
108
+ }
109
+
110
+ /**
111
+ * 切换后断连(策略可配):
112
+ * graceful(默认)— 优雅断连:只关"空闲"连接(两次采样无流量增长),
113
+ * 活跃连接放行自然完成,不打断正在进行的请求;
114
+ * 失败回退全断,确保切换即时生效。
115
+ * full — 全量断连(旧行为,紧急回退)。
116
+ */
117
+ async _disconnect(c) {
118
+ if (this.disconnectMode === "full") {
119
+ await api.closeAllConnections().catch(() => {});
120
+ return;
121
+ }
122
+ try {
123
+ const r = await api.closeIdleConnections({
124
+ sampleMs: this.disconnectSampleMs,
125
+ });
126
+ this._log(
127
+ `[${c.proxyPort}] 优雅断连: 空闲 ${r.closed} 断 / 活跃 ${r.active} 放行`,
128
+ );
129
+ } catch (e) {
130
+ this._log(`[${c.proxyPort}] 优雅断连失败,回退全断: ${e.message}`);
131
+ await api.closeAllConnections().catch(() => {});
132
+ }
133
+ }
134
+
100
135
  /**
101
136
  * 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
102
137
  * 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
@@ -428,7 +463,8 @@ export class ClashDaemon {
428
463
  // 全部候选不可用,恢复原节点
429
464
  await checker.switchToNode(node).catch(() => {});
430
465
  this._markSwitch(c, node);
431
- await api.closeAllConnections().catch(() => {});
466
+ this._markSwitchTime(c);
467
+ await this._disconnect(c);
432
468
  this._log(
433
469
  `✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
434
470
  );
@@ -444,9 +480,10 @@ export class ClashDaemon {
444
480
  this._log(
445
481
  `⚠ [${c.proxyPort}] 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
446
482
  );
447
- // 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
448
- // 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
449
- await api.closeAllConnections().catch(() => {});
483
+ // 切换后优雅断连:回收空闲 keep-alive 使新请求走新节点;活跃请求放行不打断
484
+ // 旧连接若不回收会继续走旧节点导致"时好时坏"(实测可存活 8+ 分钟)
485
+ await this._disconnect(c);
486
+ this._markSwitchTime(c);
450
487
  this._writeHealth(c, {
451
488
  node: target,
452
489
  healthy: h2.healthy,
@@ -585,9 +622,10 @@ export class ClashDaemon {
585
622
  });
586
623
  }
587
624
  this._markSwitch(c, cmd.node);
625
+ this._markSwitchTime(c);
588
626
  const h = checker.checkNodeHealth();
589
- // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
590
- await api.closeAllConnections().catch(() => {});
627
+ // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
628
+ await this._disconnect(c);
591
629
  this._log(
592
630
  `🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
593
631
  );
@@ -609,40 +647,36 @@ export class ClashDaemon {
609
647
  * 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
610
648
  * 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
611
649
  *
612
- * failover 三级响应(2026-08-22,替代原 45s 冷却):
650
+ * failover 处理(2026-08-25,改为时间锚点方案):
613
651
  * 1. 每次 failover 先检查当前节点(消除冷却期外盲切);
614
- * 2. 探针不健康 → 立即切换(有依据才切,无视窗口);
615
- * 3. 探针健康 → 窗口内默认只检查不切换;同一窗口内 failover 到达
616
- * failoverEscapeAfter 次(默认 3)→ 视为业务级封禁(curl 探针感知不到
617
- * TikTok 业务风控)→ 按轮换逻辑切一次。
618
- * 实际切换(成功或恢复原节点)或触发候选初测后重置窗口与计数,给新节点干净
619
- * 观察窗口;窗口 = failoverCooldownMs(默认 10 分钟,与候选集初测周期对齐)。
652
+ * 2. 探针不健康 → 立即切换(有依据才切,无视冷却);
653
+ * 3. 探针健康(含壳页)→ 距上次实际切换(含巡检/手动/failover,见
654
+ * _lastSwitchAt)< failoverCooldownMs(默认 10 分钟)→ 仅检查不切换;
655
+ * 距上次切换 >= 冷却期 → 视为业务级封禁(curl 探针感知不到 TikTok 业务
656
+ * 风控)→ 按轮换逻辑切一次。
657
+ * 每次实际切换(成功采用候选或恢复原节点)后更新 _lastSwitchAt 锚点,保证
658
+ * 10 分钟内最多切 1 次,给新节点干净观察期。
620
659
  * 按控制端独立记录(多控制端互不影响);对外接口(命令动作/IPC/health)不变。
621
660
  */
622
661
  async _handleFailover(cmd, c) {
623
662
  try {
624
663
  const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
625
664
  const now = Date.now();
626
- const w =
627
- c._failoverWindow || (c._failoverWindow = { startAt: 0, count: 0 });
628
- if (now - w.startAt >= this.failoverCooldownMs) {
629
- w.startAt = now;
630
- w.count = 0;
631
- }
632
- w.count += 1;
633
665
 
634
666
  // 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
635
667
  const h = checker.checkNodeHealth();
636
668
  const probeOk = !!h && (h.healthy || this._isShellPage(h));
637
- if (probeOk && w.count < this.failoverEscapeAfter) {
638
- this._log(
639
- `🔄 [${c.proxyPort}] 探针健康,窗口内第 ${w.count}/${this.failoverEscapeAfter} 次 failover 仅检查不切换 [${reason}]`,
640
- );
641
- return;
642
- }
643
669
  if (probeOk) {
670
+ // 探针健康:受冷却期约束(锚点 = 最近一次实际切换节点的时间 _lastSwitchAt)
671
+ const gap = now - (c._lastSwitchAt || 0);
672
+ if (gap < this.failoverCooldownMs) {
673
+ this._log(
674
+ `🔄 [${c.proxyPort}] 探针健康,距上次切换 ${Math.round(gap / 1000)}s(<${this.failoverCooldownMs / 60000}min 冷却),仅检查不切换 [${reason}]`,
675
+ );
676
+ return;
677
+ }
644
678
  this._log(
645
- `🔄 [${c.proxyPort}] 窗口内第 ${w.count} 次 failover:探针健康但业务持续报错,按业务级封禁轮换 [${reason}]`,
679
+ `🔄 [${c.proxyPort}] 探针健康但业务持续报错,距上次切换已过冷却期,按业务级封禁轮换 [${reason}]`,
646
680
  );
647
681
  } else {
648
682
  this._log(
@@ -658,11 +692,15 @@ export class ClashDaemon {
658
692
  );
659
693
  const { nodes } = checker.loadHealthyList();
660
694
  if (!nodes.length) {
661
- this._log("🔄 候选集为空,触发 delay 初测");
662
- // 初测即处置:重置窗口,避免健康探测每轮重复触发初测
663
- w.startAt = now;
664
- w.count = 0;
665
- await this._runDelayProbeController(c);
695
+ const gap = now - (c._lastProbeAt || 0);
696
+ if (gap >= this.benchmarkMs) {
697
+ this._log("🔄 候选集为空,触发 delay 初测");
698
+ await this._runDelayProbeController(c);
699
+ } else {
700
+ this._log(
701
+ `🔄 候选集为空(初测冷却中 ${Math.round((this.benchmarkMs - gap) / 1000)}s)`,
702
+ );
703
+ }
666
704
  return;
667
705
  }
668
706
  // 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
@@ -685,14 +723,13 @@ export class ClashDaemon {
685
723
  }
686
724
  this._log(` ↺ ${cand} 内容验证未通过,下一个`);
687
725
  }
688
- // 本次 failover 已尽力处置(成功切换或恢复原节点):重置窗口,给新节点干净观察窗口
689
- w.startAt = now;
690
- w.count = 0;
726
+ // 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
727
+ this._markSwitchTime(c);
691
728
 
692
729
  if (!target) {
693
730
  await checker.switchToNode(node).catch(() => {});
694
731
  this._markSwitch(c, node);
695
- await api.closeAllConnections().catch(() => {});
732
+ await this._disconnect(c);
696
733
  this._log(
697
734
  `✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
698
735
  );
@@ -704,8 +741,8 @@ export class ClashDaemon {
704
741
  });
705
742
  }
706
743
  this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
707
- // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
708
- await api.closeAllConnections().catch(() => {});
744
+ // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
745
+ await this._disconnect(c);
709
746
  this._writeHealth(c, {
710
747
  node: target,
711
748
  healthy: h2.healthy,
@@ -833,5 +870,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
833
870
  failoverCooldownMs: parseInt(getArg("--failover-cooldown-ms")) || undefined,
834
871
  failoverEscapeAfter:
835
872
  parseInt(getArg("--failover-escape-after")) || undefined,
873
+ disconnectMode: getArg("--disconnect-mode") || undefined,
874
+ disconnectSampleMs: parseInt(getArg("--disconnect-sample-ms")) || undefined,
836
875
  }).start();
837
876
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { getDb } from "./db-schema.js";
8
- import { getRawJobsCount, getUserDbCount } from "./db-crud.js";
8
+ import { getRawJobsCount } from "./db-crud.js";
9
9
 
10
10
  export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
11
11
  const db = getDb();
@@ -126,15 +126,15 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
126
126
  )
127
127
  .get().total;
128
128
 
129
+ // 注:dbTotalUsers(COUNT(*) FROM users)已移除 ——
130
+ // users 表 3116 万行全表扫约 3s,且仪表盘前端未使用该字段
129
131
  const rawJobsCount = skipSlow ? 0 : getRawJobsCount();
130
- const dbUsersCount = skipSlow ? 0 : getUserDbCount();
131
132
 
132
133
  return {
133
134
  totalUsers: aggregateRow.total,
134
135
  tagCount,
135
136
  commentTaskCount,
136
137
  rawJobs: rawJobsCount,
137
- dbTotalUsers: dbUsersCount,
138
138
  jobsTotal: aggregateRow.total,
139
139
  jobsBaseTotal: jobsBaseCount,
140
140
  jobsPending: aggregateRow.pending,
@@ -26,61 +26,59 @@ function getLocalIP() {
26
26
  const __dirname = dirname(__filename);
27
27
  const publicDir = join(__dirname, "public");
28
28
 
29
- // Stats 缓存:避免每次请求都扫描 100 万+ 条 jobs 数据
29
+ // Stats 缓存:避免每次请求都扫描百万行级大表数据
30
30
  let statsCache = null;
31
31
  let statsCacheTime = 0;
32
- const STATS_CACHE_TTL = 15000; // 15 秒缓存
32
+ // 5 分钟缓存:users 表 3116 万行,COUNT 全表扫约 3s;jobs 162 万行,聚合约 2s。
33
+ // 原 15 秒 TTL 导致每 15 秒一次同步大查询,冻结事件循环 5~30s(成簇超时的根因)
34
+ const STATS_CACHE_TTL = 300000; // 5 分钟
33
35
 
34
36
  // 用户列表短缓存:同一参数组合 3 秒内复用,无论多少客户端同时查只算一次
35
37
  const usersCache = new Map(); // key -> { result, time }
36
38
  const USERS_CACHE_TTL = 3000; // 3 秒
37
39
 
38
- // 慢计数实时查询(数据库已分拆,直接查无压力)
40
+ // 慢计数查询(分片执行)。
41
+ // 注:dbTotalUsers(COUNT(*) FROM users)已移除 —— users 表 3116 万行全表扫约 3s,
42
+ // 且该字段前端未使用;users 表为判重索引,数据不能删,只能从统计里去掉。
39
43
  const SLOW_COUNT_SQLS = {
40
44
  rawJobs: "SELECT COUNT(*) as c FROM raw_jobs",
41
45
  commentTaskCount:
42
46
  "SELECT COUNT(*) as c FROM videos WHERE user_update_count IS NULL OR user_update_count <= 0",
43
- dbTotalUsers: "SELECT COUNT(*) as c FROM users",
44
47
  tagCount: "SELECT COUNT(*) as c FROM tags",
45
48
  tagHighScore: "SELECT COUNT(*) as c FROM tags WHERE score >= 70",
46
49
  };
47
50
 
48
- function computeSlowCounts(st) {
49
- const result = {};
50
- for (const [key, sql] of Object.entries(SLOW_COUNT_SQLS)) {
51
- try {
52
- const r = st.rawQuery(sql);
53
- result[key] = r?.rows?.[0]?.c || 0;
54
- } catch (e) {
55
- console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
56
- result[key] = 0;
57
- }
58
- }
59
- return result;
60
- }
61
-
62
51
  let statsRefreshing = false; // 防止并发刷新
63
52
 
64
- function computeStatsIncremental(st) {
65
- const now = Date.now();
66
- // 缓存有效 → 直接返回
67
- if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
68
- return statsCache;
69
- }
70
- // 缓存过期但存在 → 先返回旧数据,后台异步刷新
71
- if (statsCache && !statsRefreshing) {
72
- statsRefreshing = true;
73
- setImmediate(async () => {
53
+ // 分片执行统计刷新:每个 SQL 单独一片,片间 setImmediate 让出事件循环,
54
+ // 避免多个大查询连续执行冻结事件循环 5~30 秒(此前成簇超时的直接原因)
55
+ function refreshStatsInShards(st) {
56
+ const slowKeys = Object.keys(SLOW_COUNT_SQLS);
57
+ const slowCounts = {};
58
+
59
+ function runSlowShard(i) {
60
+ if (i < slowKeys.length) {
61
+ const key = slowKeys[i];
62
+ try {
63
+ const r = st.rawQuery(SLOW_COUNT_SQLS[key]);
64
+ slowCounts[key] = r?.rows?.[0]?.c || 0;
65
+ } catch (e) {
66
+ console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
67
+ slowCounts[key] = 0;
68
+ }
69
+ setImmediate(() => runSlowShard(i + 1));
70
+ return;
71
+ }
72
+ // 慢计数全部完成 → dashboard 聚合作为最后一片执行
73
+ setImmediate(() => {
74
74
  try {
75
- const slowCounts = computeSlowCounts(st);
76
75
  const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
77
76
  skipSlowCounts: true,
78
77
  });
79
- stats.rawJobs = slowCounts.rawJobs;
80
- stats.commentTaskCount = slowCounts.commentTaskCount;
81
- stats.dbTotalUsers = slowCounts.dbTotalUsers;
82
- stats.tagCount = slowCounts.tagCount;
83
- stats.tagHighScore = slowCounts.tagHighScore;
78
+ stats.rawJobs = slowCounts.rawJobs || 0;
79
+ stats.commentTaskCount = slowCounts.commentTaskCount || 0;
80
+ stats.tagCount = slowCounts.tagCount || 0;
81
+ stats.tagHighScore = slowCounts.tagHighScore || 0;
84
82
  statsCache = stats;
85
83
  statsCacheTime = Date.now();
86
84
  } catch (e) {
@@ -89,21 +87,35 @@ function computeStatsIncremental(st) {
89
87
  statsRefreshing = false;
90
88
  }
91
89
  });
90
+ }
91
+
92
+ setImmediate(() => runSlowShard(0));
93
+ }
94
+
95
+ function computeStatsIncremental(st) {
96
+ const now = Date.now();
97
+ // 缓存有效 → 直接返回
98
+ if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
99
+ return statsCache;
100
+ }
101
+ // 缓存过期但存在 → 先返回旧数据,后台分片刷新
102
+ if (statsCache && !statsRefreshing) {
103
+ statsRefreshing = true;
104
+ refreshStatsInShards(st);
92
105
  return statsCache;
93
106
  }
94
- // 首次加载 → 同步计算
95
- const slowCounts = computeSlowCounts(st);
107
+ // 首次加载 → 同步计算 dashboard 基础统计(启动后仅一次),
108
+ // 慢计数随后台分片补全,不阻塞本次响应之外的请求
96
109
  const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
97
110
  skipSlowCounts: true,
98
111
  });
99
- stats.rawJobs = slowCounts.rawJobs;
100
- stats.commentTaskCount = slowCounts.commentTaskCount;
101
- stats.dbTotalUsers = slowCounts.dbTotalUsers;
102
- stats.tagCount = slowCounts.tagCount;
103
- stats.tagHighScore = slowCounts.tagHighScore;
104
112
  statsCache = stats;
105
113
  statsCacheTime = now;
106
- return stats;
114
+ if (!statsRefreshing) {
115
+ statsRefreshing = true;
116
+ refreshStatsInShards(st);
117
+ }
118
+ return statsCache;
107
119
  }
108
120
 
109
121
  function readBody(req) {
@@ -251,6 +251,11 @@ export class WatchdogAgent {
251
251
  this._graceEnded = false; // 宽限期是否已结束
252
252
  // 业务日志错误统计 → 触发节点切换
253
253
  this._errorStates = new Map(); // cmdId -> {consecutive, firstErrorAt, lastErrorAt}
254
+ // 进程(重新)启动 → 重置该命令的错误计数(新进程实例从干净观察窗口开始,
255
+ // 避免把上一个进程实例的错误延续到新实例而误触发节点切换)
256
+ this.processManager.on("process-started", ({ cmdId }) => {
257
+ this._errorStates.delete(cmdId);
258
+ });
254
259
  this._errorTriggerTimer = null;
255
260
  this._errorTriggerEnabled = false; // 默认不启用(后端下发开启才生效)
256
261
  this._errorTriggerCount = ERROR_TRIGGER_COUNT;
@@ -377,6 +377,8 @@ export class ProcessManager extends EventEmitter {
377
377
  logs: [],
378
378
  lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
379
379
  });
380
+ // 通知上层:进程(重新)启动,可重置该命令的跨实例状态(如错误计数)
381
+ this.emit("process-started", { cmdId: id, cmdDef });
380
382
  }
381
383
 
382
384
  /**
@@ -420,7 +422,6 @@ export class ProcessManager extends EventEmitter {
420
422
  } catch {}
421
423
  }, 2000);
422
424
  }
423
-
424
425
  } catch (e) {
425
426
  console.error(`[watchdog] 停止命令 ${cmdId} 失败: ${e.message}`);
426
427
  }
@@ -487,7 +488,9 @@ export class ProcessManager extends EventEmitter {
487
488
  const result = [];
488
489
  for (const [cmdId, info] of this.processes) {
489
490
  if (info.stopped || !info.logs || info.logs.length === 0) continue;
490
- // 日志可能被 LLM 分析清空过,偏移需收敛到当前长度
491
+ // 日志可能被 LLM 分析清空过(_errSeen 未同步):长度小于偏移说明
492
+ // 是清空后重建的日志,重置偏移,确保新日志从头扫描不遗漏
493
+ if ((info._errSeen || 0) > info.logs.length) info._errSeen = 0;
491
494
  const seen = Math.min(info._errSeen || 0, info.logs.length);
492
495
  const newLines = info.logs.slice(seen);
493
496
  info._errSeen = info.logs.length;
@@ -515,6 +518,9 @@ export class ProcessManager extends EventEmitter {
515
518
  });
516
519
  // 清空已读取的日志,避免重复分析
517
520
  info.logs = [];
521
+ // 同步重置增量扫描偏移:否则 scanErrorLogs 会把清空后新日志
522
+ // 误判为"已扫描"(logs.length < _errSeen 时 slice 到空),造成错误漏统计
523
+ info._errSeen = 0;
518
524
  }
519
525
  return result;
520
526
  }