tt-help-cli-ycl 1.4.184 → 1.4.185
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/scripts/test-candidate-retry.mjs +496 -0
- package/scripts/test-login-pool-acquire-order.mjs +92 -26
- package/scripts/test-login-pool-ui.mjs +2 -1
- package/scripts/test-login-pool.mjs +97 -54
- package/src/cli/explore.js +93 -29
- package/src/cli/search.js +89 -27
- package/src/lib/login-pool-client.js +67 -0
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 登录池取号排序 + 换因计数 + 连败计数 + 清空日志 单测
|
|
3
|
+
* (2026-11-04 对齐 7be35e8 账号中心简化 + 1cda80f 连败计数改算法)
|
|
3
4
|
*
|
|
4
5
|
* 覆盖:
|
|
5
6
|
* B2 取号排序五条: 非同类优先生效 / 全同类仍发号 / 全冷却退全池 / 从未用过最前 / lastAppName 空值中性
|
|
6
7
|
* B1/B3 lastAppName 落账号本体(release 后保留)+ acquired history detail 带 appName
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* prev prevAppName 前一任程序记录
|
|
9
|
+
* C2 换号原因: lastSwitchReason/switchStreak(同因连击 +1 / 换因重置 1 / 未知 code 原样存 / 不带 reason 不动)
|
|
10
|
+
* E 连败计数 errStreak: 挂在被换掉的旧号(banned/captcha/network +1;scheduled 清零;中性/无因不动)
|
|
11
|
+
* C1 清空日志: history 清成一条留痕 + useCount/switchStats/换因计数/errStreak 清零 + 不动 lastUsedAt/lastAppName
|
|
9
12
|
*
|
|
10
|
-
* 跑法:
|
|
13
|
+
* 跑法: node scripts/test-login-pool-acquire-order.mjs
|
|
14
|
+
* (自建临时 HOME 并在 import 前覆盖环境变量——绝不读写真实 ~/.tt-help-watchdog-logins.json)
|
|
11
15
|
*/
|
|
12
16
|
import assert from "assert";
|
|
17
|
+
import { mkdtempSync } from "fs";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { join } from "path";
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
// ── 测试基建:临时 HOME 隔离 ──
|
|
22
|
+
// login-pool.js 的 POOL_FILE 在模块加载期由 os.homedir() 计算(POSIX 下 homedir() 实时读
|
|
23
|
+
// process.env.HOME),故必须在动态 import 之前覆盖 HOME/USERPROFILE;生产代码零改动即可隔离。
|
|
24
|
+
process.env.HOME = mkdtempSync(join(tmpdir(), "loginpool-order-test-"));
|
|
25
|
+
process.env.USERPROFILE = process.env.HOME; // Windows 语义兜底
|
|
19
26
|
|
|
20
27
|
const {
|
|
21
28
|
getLoginPoolView,
|
|
@@ -117,7 +124,7 @@ console.log("\n[B2-4] 全冷却退全池兜底");
|
|
|
117
124
|
acquireAccount({ userId: "O4b", appName: "explore" });
|
|
118
125
|
releaseAccount({ id: findAcc("ordE1").id });
|
|
119
126
|
releaseAccount({ id: findAcc("ordF1").id });
|
|
120
|
-
// 全部压进 30min
|
|
127
|
+
// 全部压进 30min 休息窗内
|
|
121
128
|
backdate("ordE1", 5 * 60 * 1000);
|
|
122
129
|
backdate("ordF1", 8 * 60 * 1000);
|
|
123
130
|
const got = acquireAccount({ userId: "O4c", appName: "explore" });
|
|
@@ -129,13 +136,16 @@ console.log("\n[B2-4] 全冷却退全池兜底");
|
|
|
129
136
|
// ── B2-5: lastAppName 空值中性 ──
|
|
130
137
|
console.log("\n[B2-5] lastAppName 空值按非同类 / 请求无 appName 时键中性化");
|
|
131
138
|
{
|
|
132
|
-
//
|
|
139
|
+
// 两个号都先 acquire+release(都盖 lastUsedAt → K1 打平),再清空其一的 lastAppName,
|
|
140
|
+
// 隔离出纯粹的「lastAppName 空 = 按非同类」K2 分支
|
|
133
141
|
add("ordExploreApp");
|
|
134
142
|
add("ordEmptyApp");
|
|
135
|
-
acquireAccount({ userId: "O5", appName: "explore" });
|
|
143
|
+
acquireAccount({ userId: "O5", appName: "explore" }); // ordExploreApp(创建序在前)
|
|
144
|
+
acquireAccount({ userId: "O5a", appName: "search" }); // ordEmptyApp(唯一剩余 pool)
|
|
136
145
|
releaseAccount({ id: findAcc("ordExploreApp").id });
|
|
146
|
+
releaseAccount({ id: findAcc("ordEmptyApp").id });
|
|
137
147
|
for (const a of _accountsInternal()) {
|
|
138
|
-
if (a.username === "ordEmptyApp") a.lastAppName = null; //
|
|
148
|
+
if (a.username === "ordEmptyApp") a.lastAppName = null; // 模拟存量账号(无 lastAppName)
|
|
139
149
|
}
|
|
140
150
|
backdate("ordEmptyApp", 32 * 60 * 1000); // eligible、lastAppName 空
|
|
141
151
|
backdate("ordExploreApp", 31 * 60 * 1000); // eligible、同类 explore
|
|
@@ -180,7 +190,6 @@ console.log("\n[prev] 前一任程序记录(页面「上次使用」列数据
|
|
|
180
190
|
const got = acquireAccount({ userId: "P2", appName: "search" });
|
|
181
191
|
check("第二次取号后 lastAppName=当前程序", got.lastAppName === "search", `got ${got.lastAppName}`);
|
|
182
192
|
check("prevAppName=前一任(explore→search 切换可追溯)", got.prevAppName === "explore", `got ${got.prevAppName}`);
|
|
183
|
-
const first = getLoginPoolView().accounts.find((x) => x.username === "ordBatchA");
|
|
184
193
|
// 从未换过程序的账号 prevAppName 应为 null(首次取号无前一任)
|
|
185
194
|
add("ordPrevFirst");
|
|
186
195
|
const g2 = acquireAccount({ userId: "P3", appName: "explore" });
|
|
@@ -188,45 +197,99 @@ console.log("\n[prev] 前一任程序记录(页面「上次使用」列数据
|
|
|
188
197
|
retireAll();
|
|
189
198
|
}
|
|
190
199
|
|
|
191
|
-
// ── C2:
|
|
192
|
-
console.log("\n[C2]
|
|
200
|
+
// ── C2: 换号原因(lastSwitchReason/switchStreak,2026-11-03 替代 switchStats 终身累加) ──
|
|
201
|
+
console.log("\n[C2] 换因计数:最近原因+连续次数(同因连击+1 / 换因重置1 / 未知code原样存 / 不带不动)");
|
|
193
202
|
{
|
|
194
203
|
add("ordReason1");
|
|
195
204
|
let got = acquireAccount({ userId: "O7", appName: "explore", reason: "banned" });
|
|
196
|
-
check("显式上报 banned
|
|
205
|
+
check("显式上报 banned → lastSwitchReason=banned ×1",
|
|
206
|
+
got.lastSwitchReason === "banned" && got.switchStreak === 1,
|
|
207
|
+
JSON.stringify({ r: got.lastSwitchReason, s: got.switchStreak }));
|
|
197
208
|
const hist = got.history.find((h) => h.event === "acquired");
|
|
198
209
|
check("acquired history detail 带原因", hist && hist.detail.includes("原因 banned"), JSON.stringify(hist && hist.detail));
|
|
199
210
|
|
|
200
211
|
releaseAccount({ id: got.id });
|
|
201
212
|
backdate("ordReason1", 31 * 60 * 1000);
|
|
202
213
|
got = acquireAccount({ userId: "O7b", appName: "explore", reason: "banned" });
|
|
203
|
-
check("
|
|
214
|
+
check("同因连击 +1(banned ×2)", got.lastSwitchReason === "banned" && got.switchStreak === 2,
|
|
215
|
+
JSON.stringify({ r: got.lastSwitchReason, s: got.switchStreak }));
|
|
204
216
|
|
|
205
217
|
releaseAccount({ id: got.id });
|
|
206
218
|
backdate("ordReason1", 31 * 60 * 1000);
|
|
207
219
|
got = acquireAccount({ userId: "O7c", appName: "explore", reason: "weird_reason" });
|
|
208
|
-
check("
|
|
220
|
+
check("换因重置为 1,未知 code 原样存不拒绝",
|
|
221
|
+
got.lastSwitchReason === "weird_reason" && got.switchStreak === 1,
|
|
222
|
+
JSON.stringify({ r: got.lastSwitchReason, s: got.switchStreak }));
|
|
223
|
+
|
|
224
|
+
releaseAccount({ id: got.id });
|
|
225
|
+
backdate("ordReason1", 31 * 60 * 1000);
|
|
226
|
+
got = acquireAccount({ userId: "O7d", appName: "explore" }); // 不带 reason
|
|
227
|
+
check("不带 reason 不计(保留上次值,无「未上报」桶)",
|
|
228
|
+
got.lastSwitchReason === "weird_reason" && got.switchStreak === 1,
|
|
229
|
+
JSON.stringify({ r: got.lastSwitchReason, s: got.switchStreak }));
|
|
230
|
+
retireAll();
|
|
231
|
+
}
|
|
209
232
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
233
|
+
// ── E: 连败计数 errStreak(1cda80f:挂在被换掉的旧号上,按换号原因码分类) ──
|
|
234
|
+
console.log("\n[E] errStreak 连败计数:错误类+1 / scheduled 清零 / 中性与无因不动");
|
|
235
|
+
{
|
|
236
|
+
add("ordErrP");
|
|
237
|
+
let g = acquireAccount({ userId: "E1", appName: "explore" });
|
|
238
|
+
check("前置:E1 持 ordErrP", g.username === "ordErrP", `取到 ${g.username}`);
|
|
239
|
+
add("ordErrA");
|
|
240
|
+
g = acquireAccount({ userId: "E1", appName: "explore", reason: "banned" }); // 唯一 pool=ordErrA
|
|
241
|
+
check("换号取到新号 ordErrA", g.username === "ordErrA", `取到 ${g.username}`);
|
|
242
|
+
check("banned 换号:旧号 ordErrP errStreak=+1", findAcc("ordErrP").errStreak === 1, `got ${findAcc("ordErrP").errStreak}`);
|
|
243
|
+
check("新号不背旧账(ordErrA errStreak=0)", (findAcc("ordErrA").errStreak || 0) === 0);
|
|
244
|
+
check("旧号释放历史附原因码", (findAcc("ordErrP").history || []).some((h) => h.event === "released" && h.detail.includes("banned")));
|
|
245
|
+
|
|
246
|
+
// 同号连败:ordErrP 回到 E1 手上再被换掉一次 → 1→2(连续非累计基数=每次释放事件各计)
|
|
247
|
+
backdate("ordErrP", 31 * 60 * 1000);
|
|
248
|
+
g = acquireAccount({ userId: "E1", appName: "explore" }); // 唯一 eligible pool=ordErrP;ordErrA 无因中性释放
|
|
249
|
+
check("E1 换回 ordErrP", g.username === "ordErrP", `取到 ${g.username}`);
|
|
250
|
+
check("无 reason 释放 ordErrA:errStreak 中性不动", (findAcc("ordErrA").errStreak || 0) === 0);
|
|
251
|
+
add("ordErrB");
|
|
252
|
+
g = acquireAccount({ userId: "E1", appName: "explore", reason: "banned" }); // 旧号=ordErrP
|
|
253
|
+
check("ordErrP 连败 +1 → 2(同因连击累加)", findAcc("ordErrP").errStreak === 2, `got ${findAcc("ordErrP").errStreak}`);
|
|
254
|
+
|
|
255
|
+
// scheduled=干满周期健康证明 → 清零
|
|
256
|
+
backdate("ordErrP", 31 * 60 * 1000);
|
|
257
|
+
g = acquireAccount({ userId: "E1", appName: "explore" }); // 再持 P;ordErrB 无因中性释放
|
|
258
|
+
add("ordErrC");
|
|
259
|
+
g = acquireAccount({ userId: "E1", appName: "explore", reason: "scheduled" }); // 旧号=ordErrP
|
|
260
|
+
check("scheduled 换号:ordErrP errStreak 清零", findAcc("ordErrP").errStreak === 0, `got ${findAcc("ordErrP").errStreak}`);
|
|
261
|
+
|
|
262
|
+
// 中性原因(inject_fail)不动
|
|
263
|
+
backdate("ordErrP", 31 * 60 * 1000);
|
|
264
|
+
g = acquireAccount({ userId: "E1", appName: "explore" }); // 再持 P;ordErrC 无因中性释放
|
|
265
|
+
add("ordErrD");
|
|
266
|
+
g = acquireAccount({ userId: "E1", appName: "explore", reason: "inject_fail" }); // 旧号=ordErrP
|
|
267
|
+
check("inject_fail 中性:errStreak 保持 0", findAcc("ordErrP").errStreak === 0, `got ${findAcc("ordErrP").errStreak}`);
|
|
214
268
|
retireAll();
|
|
215
269
|
}
|
|
216
270
|
|
|
217
271
|
// ── C1: 清空日志 ──
|
|
218
|
-
console.log("\n[C1] 清空日志:留痕一条 + useCount/switchStats 清零 + 不动休息窗/最近程序");
|
|
272
|
+
console.log("\n[C1] 清空日志:留痕一条 + useCount/switchStats/换因计数/errStreak 清零 + 不动休息窗/最近程序");
|
|
219
273
|
{
|
|
220
274
|
add("ordClear");
|
|
221
275
|
let got = acquireAccount({ userId: "O8", appName: "explore", reason: "banned" });
|
|
222
276
|
assert(got.useCount === 1);
|
|
223
|
-
|
|
277
|
+
// 注入连败基数与遗留 switchStats,验证清空归零
|
|
278
|
+
for (const a of _accountsInternal()) {
|
|
279
|
+
if (a.username === "ordClear") { a.errStreak = 3; a.switchStats = { legacy: 9 }; }
|
|
280
|
+
}
|
|
224
281
|
releaseAccount({ id: got.id });
|
|
282
|
+
// 基线在 release 之后读取(release 会重盖 lastUsedAt,拿 acquire 时刻的值比较会跨毫秒必挂——
|
|
283
|
+
// 本断言语义是「清日志不改 lastUsedAt」,比较对象应是 pre-clear vs post-clear)
|
|
284
|
+
const lastUsedBefore = findAcc("ordClear").lastUsedAt;
|
|
225
285
|
const cleared = clearAccountHistory({ id: got.id, by: "page" });
|
|
226
286
|
check("history 清成一条留痕", cleared.history.length === 1, `len=${cleared.history.length}`);
|
|
227
287
|
check("留痕含来源 page", cleared.history[0].detail.includes("page"), JSON.stringify(cleared.history[0]));
|
|
228
288
|
check("useCount 归零", cleared.useCount === 0);
|
|
229
|
-
check("switchStats
|
|
289
|
+
check("switchStats 清空(遗留字段一并清)", !cleared.switchStats || Object.keys(cleared.switchStats).length === 0);
|
|
290
|
+
check("lastSwitchReason 清空 + switchStreak 归零", cleared.lastSwitchReason === null && cleared.switchStreak === 0,
|
|
291
|
+
JSON.stringify({ r: cleared.lastSwitchReason, s: cleared.switchStreak }));
|
|
292
|
+
check("errStreak 归零", (cleared.errStreak || 0) === 0, `got ${cleared.errStreak}`);
|
|
230
293
|
check("不动 lastUsedAt(休息窗保留)", cleared.lastUsedAt === lastUsedBefore,
|
|
231
294
|
`before=${lastUsedBefore} after=${cleared.lastUsedAt}`);
|
|
232
295
|
check("不动 lastAppName", cleared.lastAppName === "explore");
|
|
@@ -248,6 +311,7 @@ console.log("\n[批量清空] clearAllAccountHistory 一键全清");
|
|
|
248
311
|
const snap = {};
|
|
249
312
|
for (const a of _accountsInternal()) {
|
|
250
313
|
if (a.username === "ordBatchA") snap.A = { lastUsedAt: a.lastUsedAt, lastAppName: a.lastAppName };
|
|
314
|
+
if (a.username === "ordBatchB") a.errStreak = 2; // 注入连败基数,验证批量清零
|
|
251
315
|
}
|
|
252
316
|
const r = clearAllAccountHistory({ by: "page" });
|
|
253
317
|
check("返回清空数量(含历史遗留账号)", r.cleared >= 2, `cleared=${r.cleared}`);
|
|
@@ -256,6 +320,8 @@ console.log("\n[批量清空] clearAllAccountHistory 一键全清");
|
|
|
256
320
|
check(`${u}: history 清成一条批量留痕`, a.history.length === 1 && a.history[0].detail.includes("批量"),
|
|
257
321
|
JSON.stringify(a.history));
|
|
258
322
|
check(`${u}: useCount/switchStats 清零`, a.useCount === 0 && Object.keys(a.switchStats || {}).length === 0);
|
|
323
|
+
check(`${u}: 换因计数/errStreak 清零`, a.lastSwitchReason === null && a.switchStreak === 0 && (a.errStreak || 0) === 0,
|
|
324
|
+
JSON.stringify({ r: a.lastSwitchReason, s: a.switchStreak, e: a.errStreak }));
|
|
259
325
|
}
|
|
260
326
|
const a = findAcc("ordBatchA");
|
|
261
327
|
check("批量清空不动 lastUsedAt/lastAppName", a.lastUsedAt === snap.A.lastUsedAt && a.lastAppName === snap.A.lastAppName);
|
|
@@ -157,7 +157,8 @@ check("状态徽章=使用中", row1.includes("使用中"));
|
|
|
157
157
|
check("使用者=机器:口", row1.includes("F-11:9222"));
|
|
158
158
|
const poolSrc = await page.content();
|
|
159
159
|
check("独立页无 cookie 明文", !poolSrc.includes("SECRET-SID-XYZ"));
|
|
160
|
-
|
|
160
|
+
// d77a82c 批量改造:行内按钮改回「重登录」(与新增「检测」列解撞名);「检测」列已由上方新列断言覆盖
|
|
161
|
+
check("独立页重登录按钮已点亮(与检测列解撞名,d77a82c)", await page.$$eval("#content button:not([disabled])", (bs) => bs.some((b) => b.textContent.trim() === "重登录")));
|
|
161
162
|
check("独立页含新增账号/批量检测按钮", await page.$$eval(".bar button", (bs) => {
|
|
162
163
|
const t = bs.map((b) => b.textContent).join();
|
|
163
164
|
return t.includes("新增账号") && t.includes("批量检测");
|
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 共享登录池单元测试 — 模块级直驱(不起 HTTP)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1. 入池(cookies 归一化/username
|
|
4
|
+
* 覆盖(2026-11-04 对齐 7be35e8 账号中心简化 + 1cda80f 连败计数改算法):
|
|
5
|
+
* 1. 入池(cookies 归一化/username 兜底/去重)+ 视图脱敏(明文永不出)
|
|
6
6
|
* 2. 指派独占约束(同账号双租拒绝 / 目标端口占用拒绝 / force 替换 / suspect 需 force)
|
|
7
7
|
* 3. 心跳下发(applyLogin/clearLogin 一次性取走,cookies 仅在心跳响应出现)
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
8
|
+
* 4. 心跳零状态变更(续租/健康采集已物理删除)+ 节点执行回报写回(applied/applyFailed/cleared)
|
|
9
|
+
* 5. 僵尸回收(lease.assignedAt 硬顶 2h → zombieReaped,reapZombieLeases)+ 命令超时作废(deliveryExpired)
|
|
10
10
|
* 6. release(含 clearOnNode)+ update 状态迁移 + delete 保护
|
|
11
|
+
* 7. 取号排序(K1/K2/K3 + 30min 休息窗)· 自动释放(一实例一号)· retired 排除
|
|
12
|
+
* 8. 登录态导入 import(登记语义 + lastImportFrom)
|
|
13
|
+
* 9. 重登录 relogin(X 方案:使用中仅换 cookies;suspect 回池 / retired 复活)
|
|
14
|
+
* 10. 巡检 healthTargets/recordHealth/checkPoolAccounts + prepareRelogin(X 方案守卫同步放宽)
|
|
11
15
|
*
|
|
12
|
-
* 跑法:
|
|
16
|
+
* 跑法: node scripts/test-login-pool.mjs
|
|
17
|
+
* (自建临时 HOME 并在 import 前覆盖环境变量——绝不读写真实 ~/.tt-help-watchdog-logins.json)
|
|
13
18
|
*/
|
|
14
19
|
import assert from "assert";
|
|
15
|
-
import { readFileSync, existsSync } from "fs";
|
|
20
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
21
|
+
import { tmpdir } from "os";
|
|
16
22
|
import { join } from "path";
|
|
17
23
|
|
|
24
|
+
// ── 测试基建:临时 HOME 隔离 ──
|
|
25
|
+
// login-pool.js 的 POOL_FILE 在模块加载期由 os.homedir() 计算(POSIX 下 homedir() 实时读
|
|
26
|
+
// process.env.HOME),故必须在动态 import 之前覆盖 HOME/USERPROFILE;生产代码零改动即可隔离。
|
|
27
|
+
process.env.HOME = mkdtempSync(join(tmpdir(), "loginpool-test-"));
|
|
28
|
+
process.env.USERPROFILE = process.env.HOME; // Windows 语义兜底
|
|
18
29
|
const HOME = process.env.HOME;
|
|
19
|
-
if (!HOME || HOME === "/") {
|
|
20
|
-
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-pool.mjs");
|
|
21
|
-
process.exit(1);
|
|
22
|
-
}
|
|
23
30
|
|
|
24
31
|
const {
|
|
25
32
|
getLoginPoolView,
|
|
@@ -31,7 +38,7 @@ const {
|
|
|
31
38
|
acquireAccount,
|
|
32
39
|
importAccount,
|
|
33
40
|
heartbeatLoginPool,
|
|
34
|
-
|
|
41
|
+
reapZombieLeases,
|
|
35
42
|
reloginAccount,
|
|
36
43
|
prepareRelogin,
|
|
37
44
|
healthTargets,
|
|
@@ -197,19 +204,27 @@ console.log("\n[3] 心跳下发");
|
|
|
197
204
|
check("GET 视图持续无明文", !JSON.stringify(getLoginPoolView()).includes("sid-b"));
|
|
198
205
|
}
|
|
199
206
|
|
|
200
|
-
// ── 4.
|
|
201
|
-
console.log("\n[4]
|
|
207
|
+
// ── 4. 心跳零状态变更 + 节点执行回报(7be35e8:续租/健康采集已删,心跳唯一写路径=applied 写回) ──
|
|
208
|
+
console.log("\n[4] 心跳零状态变更 + 节点执行回报");
|
|
202
209
|
{
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
const cBefore = getLoginPoolView().accounts.find((x) => x.username === "userC"); // 在租(F-2:9224)
|
|
211
|
+
const snapBefore = JSON.stringify(getLoginPoolView());
|
|
212
|
+
|
|
213
|
+
// 无命令、无回报的心跳(携带旧版健康采集 payload)→ 返回 undefined 且池状态一个字节都不动
|
|
214
|
+
const hb = heartbeatLoginPool("F-ZERO", {
|
|
215
|
+
loginPool: { ports: { 9224: { username: "userC", lastBusinessOkAt: Date.now() - 60000, emptyStreak: 2 } } },
|
|
206
216
|
});
|
|
217
|
+
check("无命令无回报的心跳返回 undefined", hb === undefined, JSON.stringify(hb));
|
|
218
|
+
const snapAfter = JSON.stringify(getLoginPoolView());
|
|
219
|
+
check("心跳零状态变更(整池快照逐字节一致)", snapBefore === snapAfter);
|
|
220
|
+
|
|
207
221
|
let b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
208
|
-
check("
|
|
209
|
-
|
|
210
|
-
check("
|
|
222
|
+
check("健康采集已删:lastBusinessOkAt/emptyStreak 不再入库",
|
|
223
|
+
b.lease.lastBusinessOkAt === undefined && b.lease.emptyStreak === undefined);
|
|
224
|
+
check("心跳不刷新 lastUsedAt/lastHealthAt(盖章/镜像已删)",
|
|
225
|
+
b.lastUsedAt === cBefore.lastUsedAt && b.lastHealthAt === (cBefore.lastHealthAt || null));
|
|
211
226
|
|
|
212
|
-
// 节点 applyLogin
|
|
227
|
+
// 节点 applyLogin 成功回报(含持久化)→ 写回租约(心跳唯一写路径)
|
|
213
228
|
heartbeatLoginPool("F-2", {
|
|
214
229
|
loginPool: {
|
|
215
230
|
applied: [{ action: "applyLogin", accountId: b.id, port: 9224, username: "userC", ok: true, persisted: true, at: Date.now() }],
|
|
@@ -228,31 +243,55 @@ console.log("\n[4] 续租 + 健康采集 + 节点执行回报");
|
|
|
228
243
|
b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
229
244
|
check("失败回报覆盖状态(最新为准)", b.lease.lastAppliedOk === false);
|
|
230
245
|
check("历史记录 applyFailed", b.history.some((h) => h.event === "applyFailed"));
|
|
246
|
+
|
|
247
|
+
// clearLogin 执行回报
|
|
248
|
+
heartbeatLoginPool("F-2", {
|
|
249
|
+
loginPool: {
|
|
250
|
+
applied: [{ action: "clearLogin", accountId: b.id, port: 9224, at: Date.now() }],
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
b = getLoginPoolView().accounts.find((x) => x.username === "userC");
|
|
254
|
+
check("clearLogin 回报记 cleared", b.history.some((h) => h.event === "cleared"));
|
|
231
255
|
}
|
|
232
256
|
|
|
233
|
-
// ── 5.
|
|
234
|
-
console.log("\n[5]
|
|
257
|
+
// ── 5. 僵尸回收(assignedAt 硬顶 2h,reapZombieLeases)+ 命令超时作废 ──
|
|
258
|
+
console.log("\n[5] 僵尸回收(2h 硬顶)+ 命令超时作废");
|
|
235
259
|
{
|
|
236
|
-
//
|
|
237
|
-
// 给 A 重新指派,然后人为把 lease.lastHealthAt 拨回 13 小时前
|
|
260
|
+
// 5.1 边界内(≤2h)不回收
|
|
238
261
|
const aLeased = assignAccount({ id: accA.id, userId: "F-3", port: 9225 });
|
|
239
262
|
check("A 再指派成功", aLeased.status === "leased");
|
|
240
|
-
// 取走 F-3
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
check("超时租约被回收", reaped === 1, `reaped=${reaped}`);
|
|
263
|
+
heartbeatLoginPool("F-3", {}); // 取走 F-3 命令,避免干扰
|
|
264
|
+
|
|
265
|
+
// 内部状态直改(_accountsInternal 供测试直改内存):assignedAt 拨回 1h59m
|
|
266
|
+
const aInternal = _accountsInternal().find((x) => x.username === "userA");
|
|
267
|
+
aInternal.lease.assignedAt = Date.now() - (2 * 60 * 60 * 1000 - 60 * 1000);
|
|
268
|
+
check("持有 1h59m 未超顶不回收", reapZombieLeases() === 0);
|
|
269
|
+
check("边界内租约保持", getLoginPoolView().accounts.find((x) => x.username === "userA").status === "leased");
|
|
270
|
+
|
|
271
|
+
// 5.2 超顶(>2h)→ zombieReaped 回收回池
|
|
272
|
+
aInternal.lease.assignedAt = Date.now() - (2 * 60 * 60 * 1000 + 60 * 1000);
|
|
273
|
+
const reaped = reapZombieLeases();
|
|
274
|
+
check("超 2h 租约被回收", reaped === 1, `reaped=${reaped}`);
|
|
253
275
|
const aView = getLoginPoolView().accounts.find((x) => x.username === "userA");
|
|
254
276
|
check("回收后回池", aView.status === "pool" && !aView.lease);
|
|
255
|
-
check("
|
|
277
|
+
check("历史留痕 zombieReaped", aView.history.some((h) => h.event === "zombieReaped"));
|
|
278
|
+
check("回收盖 lastUsedAt(休息窗从回收起算)", aView.lastUsedAt > 0 && Math.abs(aView.lastUsedAt - Date.now()) < 60000);
|
|
279
|
+
|
|
280
|
+
// 5.3 命令 10min 未被取走 → deliveryExpired + 租约回收(区别于僵尸路径)
|
|
281
|
+
assignAccount({ id: aView.id, userId: "F-5", port: 9227 }); // 排队命令,故意不心跳取走
|
|
282
|
+
await new Promise((r) => setTimeout(r, 3200)); // 等防抖落盘,保证磁盘=内存(外部改文件前的安全窗)
|
|
283
|
+
const file = join(HOME, ".tt-help-watchdog-logins.json");
|
|
284
|
+
const disk = JSON.parse(readFileSync(file, "utf-8"));
|
|
285
|
+
const cmd = disk.queue.find((c) => c.userId === "F-5");
|
|
286
|
+
check("待发命令已随池文件持久化", !!cmd);
|
|
287
|
+
cmd.queuedAt = Date.now() - 11 * 60 * 1000; // 拨回 11 分钟前(mtime 变化 → 模块自动重载)
|
|
288
|
+
writeFileSync(file, JSON.stringify(disk, null, 2), "utf-8");
|
|
289
|
+
const zombieCount = reapZombieLeases(); // 内部先 dropStaleCommands
|
|
290
|
+
check("超时命令走 deliveryExpired 路径(僵尸计数 0)", zombieCount === 0, `zombie=${zombieCount}`);
|
|
291
|
+
const aAfter = getLoginPoolView().accounts.find((x) => x.username === "userA");
|
|
292
|
+
check("超时命令作废并回收租约回池", aAfter.status === "pool" && !aAfter.lease, `status=${aAfter.status}`);
|
|
293
|
+
check("历史留痕 deliveryExpired", aAfter.history.some((h) => h.event === "deliveryExpired"));
|
|
294
|
+
check("作废后 F-5 心跳收不到命令", heartbeatLoginPool("F-5", {}) === undefined);
|
|
256
295
|
}
|
|
257
296
|
|
|
258
297
|
// ── 6. release / update / delete ──
|
|
@@ -294,7 +333,7 @@ console.log("\n[6] release / update / delete");
|
|
|
294
333
|
}
|
|
295
334
|
}
|
|
296
335
|
|
|
297
|
-
// ── 7.
|
|
336
|
+
// ── 7. 取号排序(K1/K2/K3 + 30min 休息窗)· 自动释放(一实例一号)· retired 排除 ──
|
|
298
337
|
console.log("\n[7] 取号·导入·自动释放");
|
|
299
338
|
{
|
|
300
339
|
// 清池重建:全量 release→retired→delete,再导入 4 个新号(uc 全 0,createdAt 递增)
|
|
@@ -311,25 +350,25 @@ console.log("\n[7] 取号·导入·自动释放");
|
|
|
311
350
|
mk("userE"); mk("userF"); mk("userG"); mk("userH");
|
|
312
351
|
check("重建后池=4", getLoginPoolView().total === 4);
|
|
313
352
|
|
|
314
|
-
// 7.1
|
|
353
|
+
// 7.1 K1 从未用过最前(组内 K3 退化为 createdAt 升序)→ userE
|
|
315
354
|
const t1 = acquireAccount({ userId: "n1" });
|
|
316
355
|
check("永不用过的优先(userE)", t1.username === "userE", `got ${t1.username}`);
|
|
317
356
|
check("取号次数 +1", t1.useCount === 1);
|
|
318
357
|
|
|
319
|
-
// 7.2
|
|
358
|
+
// 7.2 其次 userF [自动释放:n2 无旧租约,无操作]
|
|
320
359
|
const t2 = acquireAccount({ userId: "n2" });
|
|
321
360
|
check("其次取 userF", t2.username === "userF", `got ${t2.username}`);
|
|
322
361
|
|
|
323
|
-
// 7.3
|
|
362
|
+
// 7.3 休息窗(30min):还回 userE(lastUsedAt=now → 窗内不可领取)→ 不立即复用
|
|
324
363
|
releaseAccount({ id: t1.id });
|
|
325
364
|
const t3 = acquireAccount({ userId: "n3" });
|
|
326
365
|
check("刚还的号不立即复用(取 userG)", t3.username === "userG", `got ${t3.username}`);
|
|
327
366
|
|
|
328
|
-
// 7.4
|
|
367
|
+
// 7.4 最后一个非休息窗空闲号(userH)
|
|
329
368
|
const t4 = acquireAccount({ userId: "n4" });
|
|
330
369
|
check("取 userH", t4.username === "userH", `got ${t4.username}`);
|
|
331
370
|
|
|
332
|
-
// 7.5
|
|
371
|
+
// 7.5 自动释放:池内放入新号 → n1 再取 → 拿新号 + n1 名下旧租约(userE)自动归还
|
|
333
372
|
importAccount({ userId: "x", username: "userZ", cookies: [{ name: "sessionid", value: "sid-z" }] });
|
|
334
373
|
const t5 = acquireAccount({ userId: "n1" });
|
|
335
374
|
check("n1 取到新放入的 userZ", t5.username === "userZ", `got ${t5.username}`);
|
|
@@ -343,7 +382,7 @@ console.log("\n[7] 取号·导入·自动释放");
|
|
|
343
382
|
|
|
344
383
|
// 7.7 retired 不参与取号:停用 userZ → 取号拿到别的
|
|
345
384
|
const zId = getLoginPoolView().accounts.find((x) => x.username === "userZ").id;
|
|
346
|
-
releaseAccount({ id: zId })
|
|
385
|
+
releaseAccount({ id: zId });
|
|
347
386
|
updateAccount({ id: zId, status: "retired" });
|
|
348
387
|
const t6 = acquireAccount({ userId: "n5" });
|
|
349
388
|
check("retired 不被取用", t6.username !== "userZ", `got ${t6.username}`);
|
|
@@ -383,7 +422,7 @@ console.log("\n[8] 登录态导入 import");
|
|
|
383
422
|
check("缺 cookies 确实抛错", threw);
|
|
384
423
|
}
|
|
385
424
|
|
|
386
|
-
console.log("\n[9] 重登录 relogin
|
|
425
|
+
console.log("\n[9] 重登录 relogin(X 方案:使用中仅换 cookies + 守卫 + 复活)");
|
|
387
426
|
{
|
|
388
427
|
for (const a of getLoginPoolView().accounts) {
|
|
389
428
|
try { releaseAccount({ id: a.id }); } catch {}
|
|
@@ -405,7 +444,7 @@ console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
|
|
|
405
444
|
const after = JSON.parse(JSON.stringify(_accountsInternal().find((a) => a.username === "userD")));
|
|
406
445
|
check("覆盖刷新(新 cookies 落池)", JSON.stringify(after.cookies).includes("sid-d-new") && JSON.stringify(after.cookies).includes("poc_marker"));
|
|
407
446
|
check("历史含 relogin 事件", (after.history || []).some((h) => h.event === "relogin"));
|
|
408
|
-
check("
|
|
447
|
+
check("空闲刷新不改状态不复活", r.revived === false && r.wasSuspect === false && r.account.status === "pool");
|
|
409
448
|
|
|
410
449
|
let threw = false;
|
|
411
450
|
try { reloginAccount({ username: "ghost", cookies: D2, userId: "cli" }); } catch (e) { threw = /不在池中/.test(e.message); }
|
|
@@ -417,10 +456,14 @@ console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
|
|
|
417
456
|
try { reloginAccount({ username: "userD", userId: "cli" }); } catch (e) { threw = /cookies/.test(e.message); }
|
|
418
457
|
check("缺 cookies 拒绝", threw);
|
|
419
458
|
|
|
459
|
+
// X 方案(2026-11-03):使用中也可重登录——仅替换池内 cookies,状态与租约原样保留
|
|
420
460
|
acquireAccount({ userId: "T1", port: 9400 });
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
check("
|
|
461
|
+
r = reloginAccount({ username: "userD", cookies: D2, userId: "cli", note: "使用中刷新" });
|
|
462
|
+
check("使用中可重登录(X 方案)", !!r && r.account.status === "leased", JSON.stringify(r && r.account && r.account.status));
|
|
463
|
+
check("使用中重登录不动租约", r.account.lease && r.account.lease.userId === "T1" && r.account.lease.port === 9400);
|
|
464
|
+
const afterUse = JSON.parse(JSON.stringify(_accountsInternal().find((a) => a.username === "userD")));
|
|
465
|
+
check("使用中重登录 cookies 已覆盖", JSON.stringify(afterUse.cookies).includes("poc_marker"));
|
|
466
|
+
check("使用中重登录历史注明仅换 cookies", (afterUse.history || []).some((h) => h.event === "relogin" && h.detail.includes("使用中")));
|
|
424
467
|
releaseAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id });
|
|
425
468
|
|
|
426
469
|
updateAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id, status: "suspect" });
|
|
@@ -470,7 +513,7 @@ console.log("\n[10] 巡检 healthTargets/recordHealth/checkPoolAccounts(注入
|
|
|
470
513
|
const aliveAcc = getLoginPoolView().accounts.find((a) => a.username === "u-alive");
|
|
471
514
|
check("活号不写 history(防刷屏)", !(aliveAcc.history || []).some((h) => h.event === "health"));
|
|
472
515
|
|
|
473
|
-
acquireAccount({ userId: "T2", port: 9401 }); //
|
|
516
|
+
acquireAccount({ userId: "T2", port: 9401 }); // 租走空闲号(u-alive;u-dead 是 suspect 不会被取)
|
|
474
517
|
const leased = getLoginPoolView().accounts.find((a) => a.status === "leased");
|
|
475
518
|
const rec = recordHealth({ id: leased.id, ok: "dead", detail: "单测" });
|
|
476
519
|
check("leased 死号仅记录不迁移状态", rec.status === "leased" && leased.username === rec.username);
|
|
@@ -478,10 +521,10 @@ console.log("\n[10] 巡检 healthTargets/recordHealth/checkPoolAccounts(注入
|
|
|
478
521
|
const notLeased = getLoginPoolView().accounts.find((a) => a.status !== "leased").username;
|
|
479
522
|
const prep = prepareRelogin({ username: notLeased });
|
|
480
523
|
check("prepare 返回明文 cookies", Array.isArray(prep.account.cookies) && prep.account.cookies.length > 0);
|
|
524
|
+
// X 方案:prepareRelogin 守卫与 reloginAccount 同步放宽——使用中也放行(仅在池即可)
|
|
525
|
+
const prepLeased = prepareRelogin({ username: leased.username });
|
|
526
|
+
check("prepare 使用中也返回 cookies(X 方案守卫放宽)", Array.isArray(prepLeased.account.cookies) && prepLeased.account.cookies.length > 0);
|
|
481
527
|
let threw = false;
|
|
482
|
-
try { prepareRelogin({ username: leased.username }); } catch (e) { threw = /使用中/.test(e.message); }
|
|
483
|
-
check("prepare 使用中拒绝", threw);
|
|
484
|
-
threw = false;
|
|
485
528
|
try { prepareRelogin({ username: "ghost" }); } catch (e) { threw = /不在池中/.test(e.message); }
|
|
486
529
|
check("prepare 不在池拒绝", threw);
|
|
487
530
|
}
|