tt-help-cli-ycl 1.4.181 → 1.4.182
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/deploy-linux-node.sh +256 -0
- package/scripts/test-login-pool-acquire-order.mjs +270 -0
- package/scripts/test-login-pool-renew-fix.mjs +123 -0
- package/src/cli/explore.js +34 -9
- package/src/lib/login-pool-client.js +8 -3
- package/src/watch/login-pool.js +100 -18
- package/src/watch/server.js +60 -12
- package/src/watch/data-store.js.bak +0 -5091
- package/src/watch/data-store.js.bak2 +0 -5019
package/src/cli/explore.js
CHANGED
|
@@ -49,6 +49,17 @@ const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏
|
|
|
49
49
|
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
50
50
|
const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
|
|
51
51
|
|
|
52
|
+
// 换号原因受控 code(2026-10-30 定稿:服务端 switchStats 按账号聚合展示;
|
|
53
|
+
// 只用这组固定值上报,不传自由文本;startup 首次取号不带 reason)
|
|
54
|
+
const SWITCH_REASON = {
|
|
55
|
+
BANNED: "banned", // 关注/粉丝功能被封、被封累计
|
|
56
|
+
RECOVER_FAIL: "recover_fail", // 浏览器重启/登录态恢复失败
|
|
57
|
+
SCHEDULED: "scheduled", // 48min 周期例行换号
|
|
58
|
+
INJECT_FAIL: "inject_fail", // 候选号注入验证失败后的再取号
|
|
59
|
+
CAPTCHA: "captcha", // 验证码累计超时未解决
|
|
60
|
+
NETWORK: "network", // CDN 限流累计(网络侧限流)
|
|
61
|
+
};
|
|
62
|
+
|
|
52
63
|
async function withRetry(label, fn) {
|
|
53
64
|
let backoff = 1000;
|
|
54
65
|
while (true) {
|
|
@@ -349,13 +360,15 @@ export async function handleExplore(options) {
|
|
|
349
360
|
* 换号(以号为主键):先取新号(旧号仍在手,无空窗)→ 注入验证 → 还旧号。
|
|
350
361
|
* 候选最多试 5 个(注入失败的释放回池);池空/服务器不可达 → 保旧号返回 false,
|
|
351
362
|
* 由主循环「无在手账号」补取逻辑兑底。
|
|
363
|
+
* @param code 受控换号原因(SWITCH_REASON,上报服务端 switchStats;可选);
|
|
364
|
+
* 首选按外层 call site 上报,候选注入失败后的再取号 = inject_fail
|
|
352
365
|
*/
|
|
353
|
-
async function switchPoolAccount(reason) {
|
|
366
|
+
async function switchPoolAccount(reason, code) {
|
|
354
367
|
console.error(`\n[换号] ${reason},从账号中心取新账号...`);
|
|
355
368
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
356
369
|
let next = null;
|
|
357
370
|
try {
|
|
358
|
-
next = await poolClient.acquire();
|
|
371
|
+
next = await poolClient.acquire(attempt === 0 ? code : SWITCH_REASON.INJECT_FAIL);
|
|
359
372
|
} catch (e) {
|
|
360
373
|
console.error(
|
|
361
374
|
`[换号] 取新号失败(${e.message})${poolAccount ? `,保留当前账号 @${poolAccount.username}` : ",将在主循环补取"}`,
|
|
@@ -419,7 +432,10 @@ export async function handleExplore(options) {
|
|
|
419
432
|
return true;
|
|
420
433
|
}
|
|
421
434
|
console.error(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
|
|
422
|
-
return await switchPoolAccount(
|
|
435
|
+
return await switchPoolAccount(
|
|
436
|
+
`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
|
|
437
|
+
SWITCH_REASON.RECOVER_FAIL,
|
|
438
|
+
);
|
|
423
439
|
}
|
|
424
440
|
|
|
425
441
|
/** 内存超限:同号重启浏览器(不换号——内存是浏览器进程问题,不是账号问题) */
|
|
@@ -434,14 +450,17 @@ export async function handleExplore(options) {
|
|
|
434
450
|
Object.assign(page, rebuiltPage);
|
|
435
451
|
} catch (e) {
|
|
436
452
|
console.error(`[浏览器] 重启失败: ${e.message},转入换号流程`);
|
|
437
|
-
return await switchPoolAccount(
|
|
453
|
+
return await switchPoolAccount(
|
|
454
|
+
`浏览器重启失败(${e.message})`,
|
|
455
|
+
SWITCH_REASON.RECOVER_FAIL,
|
|
456
|
+
);
|
|
438
457
|
}
|
|
439
458
|
return await restorePoolLogin();
|
|
440
459
|
}
|
|
441
460
|
|
|
442
|
-
async function handleAccountSwitch(reason) {
|
|
461
|
+
async function handleAccountSwitch(reason, code) {
|
|
443
462
|
if (poolMode) {
|
|
444
|
-
await switchPoolAccount(reason);
|
|
463
|
+
await switchPoolAccount(reason, code);
|
|
445
464
|
return;
|
|
446
465
|
}
|
|
447
466
|
console.error(`\n[健康检查] ${reason},切换到下一个账户...`);
|
|
@@ -560,7 +579,7 @@ export async function handleExplore(options) {
|
|
|
560
579
|
errorStack: result.errorStack || "",
|
|
561
580
|
}),
|
|
562
581
|
),
|
|
563
|
-
switchAccount: (reason) => handleAccountSwitch(reason),
|
|
582
|
+
switchAccount: (reason) => handleAccountSwitch(reason, SWITCH_REASON.NETWORK),
|
|
564
583
|
});
|
|
565
584
|
|
|
566
585
|
/** 等待人工在浏览器中解决验证码:轮询检测,解决即继续;超时返回 false */
|
|
@@ -652,6 +671,7 @@ export async function handleExplore(options) {
|
|
|
652
671
|
} else if (elapsedMs >= ROTATE_INTERVAL_MS) {
|
|
653
672
|
await handleAccountSwitch(
|
|
654
673
|
`当前账号已运行 ${Math.round(elapsedMs / 60000)} 分钟`,
|
|
674
|
+
SWITCH_REASON.SCHEDULED,
|
|
655
675
|
);
|
|
656
676
|
} else if (processedCount % 10 === 0) {
|
|
657
677
|
const remainMin = Math.max(
|
|
@@ -713,6 +733,7 @@ export async function handleExplore(options) {
|
|
|
713
733
|
if (captchaCount >= 3) {
|
|
714
734
|
await handleAccountSwitch(
|
|
715
735
|
`验证码累计 ${captchaCount} 次(超时未解决)`,
|
|
736
|
+
SWITCH_REASON.CAPTCHA,
|
|
716
737
|
);
|
|
717
738
|
captchaCount = 0;
|
|
718
739
|
}
|
|
@@ -729,7 +750,7 @@ export async function handleExplore(options) {
|
|
|
729
750
|
console.error(
|
|
730
751
|
`\n[封禁检测] 关注/粉丝功能已 ${minutes} 分钟未成功获取,切换到下一个账户...`,
|
|
731
752
|
);
|
|
732
|
-
await handleAccountSwitch(
|
|
753
|
+
await handleAccountSwitch(`关注/粉丝功能被封`, SWITCH_REASON.BANNED);
|
|
733
754
|
}
|
|
734
755
|
}
|
|
735
756
|
|
|
@@ -914,7 +935,10 @@ export async function handleExplore(options) {
|
|
|
914
935
|
// 触发代理切换(api-interceptor 已内置 30/40/50s 重试,到这里仍封直接切代理)
|
|
915
936
|
await triggerProxySwitch(`账号被封 @${username}`, proxyPort);
|
|
916
937
|
if (blockedCount >= 3) {
|
|
917
|
-
await handleAccountSwitch(
|
|
938
|
+
await handleAccountSwitch(
|
|
939
|
+
`账号被封累计 ${blockedCount} 次`,
|
|
940
|
+
SWITCH_REASON.BANNED,
|
|
941
|
+
);
|
|
918
942
|
blockedCount = 0;
|
|
919
943
|
}
|
|
920
944
|
continue;
|
|
@@ -952,6 +976,7 @@ export async function handleExplore(options) {
|
|
|
952
976
|
// 累计3次验证码(均超时未解决),切换账户
|
|
953
977
|
await handleAccountSwitch(
|
|
954
978
|
`验证码累计 ${captchaCount} 次(超时未解决)`,
|
|
979
|
+
SWITCH_REASON.CAPTCHA,
|
|
955
980
|
);
|
|
956
981
|
captchaCount = 0;
|
|
957
982
|
}
|
|
@@ -37,9 +37,14 @@ async function poolPost(url, body) {
|
|
|
37
37
|
export function createLoginPoolClient({ serverUrl, userId, port, appName = "explore" }) {
|
|
38
38
|
const base = `${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`;
|
|
39
39
|
return {
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/** 取号:按服务端排序规则(2026-10-30:休息窗+同类/非同类)挑空闲号,响应带明文 cookies。
|
|
41
|
+
* 失败抛错(池空/服务器不可达)。
|
|
42
|
+
* reason 可选:换号原因受控 code(banned/recover_fail/scheduled/inject_fail/captcha/network),
|
|
43
|
+
* 服务端按账号聚合 switchStats;不带则不计(仅显式上报才计数,旧客户端兼容) */
|
|
44
|
+
async acquire(reason) {
|
|
45
|
+
const body = { userId, port, appName };
|
|
46
|
+
if (reason) body.reason = String(reason);
|
|
47
|
+
const r = await poolPost(`${base}/acquire`, body);
|
|
43
48
|
return r.account;
|
|
44
49
|
},
|
|
45
50
|
/** 还号:以号为主键,按 id 释放(谁持账号谁还) */
|
package/src/watch/login-pool.js
CHANGED
|
@@ -232,6 +232,9 @@ function publicView(acc) {
|
|
|
232
232
|
lastHealth: acc.lastHealth || null,
|
|
233
233
|
lastBusinessOkAt: acc.lastBusinessOkAt || null,
|
|
234
234
|
lastUsedAt: acc.lastUsedAt || null,
|
|
235
|
+
lastAppName: acc.lastAppName || null,
|
|
236
|
+
prevAppName: acc.prevAppName || null,
|
|
237
|
+
switchStats: acc.switchStats || {},
|
|
235
238
|
lease,
|
|
236
239
|
history: acc.history || [],
|
|
237
240
|
createdAt: acc.createdAt,
|
|
@@ -356,8 +359,17 @@ export function heartbeatLoginPool(userId, body) {
|
|
|
356
359
|
}
|
|
357
360
|
|
|
358
361
|
// 1) 续租该节点持有的所有租约 + 采集健康数据
|
|
362
|
+
// 续租匹配(2026-10-30 修复):心跳 userId 是纯节点名(如 F-11),业务实例租约 userId
|
|
363
|
+
// 带实例后缀(F-11-8222-2 = {节点}-{端口}-{实例})→ 精确相等 + "-" 边界前缀匹配都续。
|
|
364
|
+
// 边界防节点名互为前缀:"F-1" 不得续 "F-11-*"(startsWith("F-1-") 为 false)。
|
|
365
|
+
// ⚠️ 铁律:acquireAccount 里「自动释放同使用者旧租约」必须保持精确相等
|
|
366
|
+
// (一实例一号不变量),与本处续租匹配是两套语义,不许跟着改成前缀。
|
|
359
367
|
for (const acc of _accounts) {
|
|
360
|
-
if (
|
|
368
|
+
if (
|
|
369
|
+
acc.status === "leased" &&
|
|
370
|
+
acc.lease &&
|
|
371
|
+
(acc.lease.userId === userId || acc.lease.userId.startsWith(userId + "-"))
|
|
372
|
+
) {
|
|
361
373
|
acc.lease.lastHealthAt = t;
|
|
362
374
|
acc.lastHealthAt = t; // 镜像到账号本体,释放后仍可查
|
|
363
375
|
acc.lastUsedAt = t;
|
|
@@ -467,6 +479,9 @@ export function updateAccount({ id, note, proxyHint, status } = {}) {
|
|
|
467
479
|
if (!VALID_STATUS.includes(status)) throw new Error(`非法 status: ${status}`);
|
|
468
480
|
if (acc.status === "leased" && status === "pool") {
|
|
469
481
|
pushHistory(acc, "released", "手动释放");
|
|
482
|
+
// 手动释放也盖 lastUsedAt(2026-10-30 补洞):持有节点掉线等场景下 lastUsedAt
|
|
483
|
+
// 停在取号时刻,不盖的话释放后立刻可被取走,绕过 30 分钟休息窗
|
|
484
|
+
acc.lastUsedAt = now();
|
|
470
485
|
acc.lease = null;
|
|
471
486
|
purgeCommandsFor(acc.id);
|
|
472
487
|
} else if (acc.status === "leased" && status !== "pool") {
|
|
@@ -482,6 +497,38 @@ export function updateAccount({ id, note, proxyHint, status } = {}) {
|
|
|
482
497
|
return publicView(acc);
|
|
483
498
|
}
|
|
484
499
|
|
|
500
|
+
/** 清空日志(账号中心页面按钮):history 清成一条留痕 + useCount 归零 + switchStats 清空。
|
|
501
|
+
* 不动 lastUsedAt/lastAppName(30min 休息窗与非同类排序不受清日志影响)。 */
|
|
502
|
+
export function clearAccountHistory({ id, by } = {}) {
|
|
503
|
+
ensureLoaded();
|
|
504
|
+
const acc = _accounts.find((a) => a.id === id);
|
|
505
|
+
if (!acc) throw new Error(`账号不存在: ${id}`);
|
|
506
|
+
acc.history = [];
|
|
507
|
+
acc.useCount = 0;
|
|
508
|
+
acc.switchStats = {};
|
|
509
|
+
acc.updatedAt = now();
|
|
510
|
+
pushHistory(acc, "clearLogs", `清除日志与计数(来源 ${String(by || "page").slice(0, 80)})`);
|
|
511
|
+
scheduleSave();
|
|
512
|
+
return publicView(acc);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** 一键清空全部账号日志(页面批量按钮):与单账号同语义——各账号 history 清成一条
|
|
516
|
+
* 留痕 + useCount/switchStats 清零;不动 lastUsedAt/lastAppName。返回清空数量。 */
|
|
517
|
+
export function clearAllAccountHistory({ by } = {}) {
|
|
518
|
+
ensureLoaded();
|
|
519
|
+
const t = now();
|
|
520
|
+
for (const acc of _accounts) {
|
|
521
|
+
acc.history = [];
|
|
522
|
+
acc.useCount = 0;
|
|
523
|
+
acc.switchStats = {};
|
|
524
|
+
acc.updatedAt = t;
|
|
525
|
+
pushHistory(acc, "clearLogs", `清除日志与计数(来源 ${String(by || "page").slice(0, 80)} · 批量)`);
|
|
526
|
+
}
|
|
527
|
+
const cleared = _accounts.length;
|
|
528
|
+
if (cleared > 0) scheduleSave();
|
|
529
|
+
return { cleared };
|
|
530
|
+
}
|
|
531
|
+
|
|
485
532
|
/** 删除(retired 才可删;其余先 retired) */
|
|
486
533
|
export function deleteAccount(id) {
|
|
487
534
|
ensureLoaded();
|
|
@@ -613,14 +660,16 @@ export function takeAccount({ userId, port, username, force } = {}) {
|
|
|
613
660
|
* 在自有浏览器实例上原地注入,不经心跳命令队列、不依赖节点 watchdog。
|
|
614
661
|
*
|
|
615
662
|
* 语义(2026-10-26 用户定稿:以号为主键,无幂等/无端口键):
|
|
616
|
-
* - 取号 =
|
|
663
|
+
* - 取号 = 按 2026-10-30 定稿排序挑空闲号 → 标 leased → 响应带明文 cookies
|
|
617
664
|
* - 独占由池状态机保证(take 即 leased,同号不双机并行);
|
|
618
665
|
* 切换 = 先取新号、验证通过后再还旧号(无空窗,池空保旧)
|
|
619
666
|
* - 崩溃/异常未还号的租约由 TTL 兑底回收(或账号中心手动释放),不做幂等恢复
|
|
620
667
|
* - port 可选:仅作展示元数据(浏览器口),不参与任何键/约束
|
|
621
|
-
* - appName:
|
|
668
|
+
* - appName: 记录在租约与账号本体(lastAppName,释放后保留),参与取号排序 K2 非同类优先
|
|
669
|
+
* - reason: 可选换号原因受控 code(banned/recover_fail/scheduled/inject_fail/captcha/network),
|
|
670
|
+
* 仅显式上报才计入 switchStats 聚合(无「未上报」桶);未知 code 原样存不拒绝(旧客户端兼容)
|
|
622
671
|
*/
|
|
623
|
-
export function acquireAccount({ userId, port, appName } = {}) {
|
|
672
|
+
export function acquireAccount({ userId, port, appName, reason } = {}) {
|
|
624
673
|
ensureLoaded();
|
|
625
674
|
reapExpiredLeases();
|
|
626
675
|
if (!userId) throw new Error("userId 必填");
|
|
@@ -632,20 +681,38 @@ export function acquireAccount({ userId, port, appName } = {}) {
|
|
|
632
681
|
}
|
|
633
682
|
}
|
|
634
683
|
const t = now();
|
|
635
|
-
|
|
636
|
-
//
|
|
684
|
+
const appNameKey = appName ? String(appName).slice(0, 50) : null;
|
|
685
|
+
// reason:换号原因受控 code;未知原样存不拒绝(旧客户端不带 → 不计)
|
|
686
|
+
const reasonKey = reason ? String(reason).slice(0, 50) : null;
|
|
687
|
+
|
|
688
|
+
// 取号排序(2026-10-30 定稿,替换旧「useCount 升序 + 10min 冷却」):
|
|
689
|
+
// eligible = 从未用过(lastUsedAt 空)或 now − lastUsedAt ≥ MIN_IDLE_MS(30 分钟休息窗);
|
|
690
|
+
// eligible 空集 → 全池同排序兑底(有得用总比没用好),池空才报错。
|
|
691
|
+
// 排序键(useCount 退出排序,仅留展示):
|
|
692
|
+
// K1 从未用过最前(lastUsedAt null = 无限冷)
|
|
693
|
+
// K2 非同类优先(lastAppName ≠ 本次 appName;lastAppName 空按非同类;
|
|
694
|
+
// 请求无 appName 时该键中性化全等,不参与)
|
|
695
|
+
// K3 lastUsedAt 升序(从未用过组内退化为 createdAt 升序,兼容旧排序)
|
|
696
|
+
const MIN_IDLE_MS = 30 * 60 * 1000;
|
|
637
697
|
const pool = _accounts.filter((a) => a.status === "pool");
|
|
638
|
-
const sortFn =
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
698
|
+
const sortFn = (a, b) => {
|
|
699
|
+
// K1: 从未用过最前
|
|
700
|
+
const aUsed = a.lastUsedAt != null ? 1 : 0;
|
|
701
|
+
const bUsed = b.lastUsedAt != null ? 1 : 0;
|
|
702
|
+
if (aUsed !== bUsed) return aUsed - bUsed;
|
|
703
|
+
// K2: 非同类优先(仅当本次请求带 appName)
|
|
704
|
+
if (appNameKey) {
|
|
705
|
+
const aSame = a.lastAppName && a.lastAppName === appNameKey ? 1 : 0;
|
|
706
|
+
const bSame = b.lastAppName && b.lastAppName === appNameKey ? 1 : 0;
|
|
707
|
+
if (aSame !== bSame) return aSame - bSame;
|
|
708
|
+
}
|
|
709
|
+
// K3: lastUsedAt 升序
|
|
710
|
+
return (a.lastUsedAt ?? a.createdAt ?? 0) - (b.lastUsedAt ?? b.createdAt ?? 0);
|
|
711
|
+
};
|
|
712
|
+
const eligible = pool
|
|
713
|
+
.filter((a) => !a.lastUsedAt || t - a.lastUsedAt >= MIN_IDLE_MS)
|
|
647
714
|
.sort(sortFn);
|
|
648
|
-
const acc =
|
|
715
|
+
const acc = eligible[0] || pool.slice().sort(sortFn)[0];
|
|
649
716
|
if (!acc) {
|
|
650
717
|
const leased = _accounts.filter((a) => a.status === "leased").length;
|
|
651
718
|
throw new Error(`池中暂无空闲账号(共 ${_accounts.length} 个,其中 ${leased} 个在租约中)`);
|
|
@@ -654,10 +721,21 @@ export function acquireAccount({ userId, port, appName } = {}) {
|
|
|
654
721
|
acc.status = "leased";
|
|
655
722
|
acc.useCount = (acc.useCount || 0) + 1;
|
|
656
723
|
acc.lastUsedAt = t; // 最近使用时间(取号即「开始使用」)
|
|
724
|
+
if (appNameKey) {
|
|
725
|
+
// B1: 账号本体记录最近一次租用程序(K2 排序依据,释放后保留;请求不带 appName 不改写)
|
|
726
|
+
// prevAppName = 覆盖前的前一任(页面「上次使用」列展示,供在租账号看切换对比)
|
|
727
|
+
acc.prevAppName = acc.lastAppName || null;
|
|
728
|
+
acc.lastAppName = appNameKey;
|
|
729
|
+
}
|
|
730
|
+
if (reasonKey) {
|
|
731
|
+
// C2: 换号原因聚合(仅显式上报才计数,无「未上报」桶)
|
|
732
|
+
acc.switchStats = acc.switchStats || {};
|
|
733
|
+
acc.switchStats[reasonKey] = (acc.switchStats[reasonKey] || 0) + 1;
|
|
734
|
+
}
|
|
657
735
|
acc.lease = {
|
|
658
736
|
userId,
|
|
659
737
|
port: portNum,
|
|
660
|
-
appName:
|
|
738
|
+
appName: appNameKey,
|
|
661
739
|
assignedAt: t,
|
|
662
740
|
expireAt: t + LEASE_TTL_MS,
|
|
663
741
|
lastHealthAt: t,
|
|
@@ -665,7 +743,11 @@ export function acquireAccount({ userId, port, appName } = {}) {
|
|
|
665
743
|
emptyStreak: null,
|
|
666
744
|
};
|
|
667
745
|
acc.updatedAt = t;
|
|
668
|
-
pushHistory(
|
|
746
|
+
pushHistory(
|
|
747
|
+
acc,
|
|
748
|
+
"acquired",
|
|
749
|
+
`${userId}${portNum ? ":" + portNum : ""} 直接取号${appNameKey ? " · " + appNameKey : ""}${reasonKey ? " · 原因 " + reasonKey : ""}`,
|
|
750
|
+
);
|
|
669
751
|
|
|
670
752
|
// ③ 一程序实例恒持一号:自动释放该使用者名下的其他旧租约
|
|
671
753
|
// (换号 = 取新+还旧一步完成;崩溃重启后的首次取号自愈清僵尸租约;
|
package/src/watch/server.js
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
importAccount,
|
|
23
23
|
reloginAccount,
|
|
24
24
|
prepareRelogin,
|
|
25
|
+
clearAccountHistory,
|
|
26
|
+
clearAllAccountHistory,
|
|
25
27
|
} from "./login-pool.js";
|
|
26
28
|
import { checkPoolAccounts } from "./login-health.js";
|
|
27
29
|
|
|
@@ -687,7 +689,15 @@ function serveLoginPoolPage(res) {
|
|
|
687
689
|
<div class="bar">
|
|
688
690
|
<button onclick="load()">刷新</button>
|
|
689
691
|
<button onclick="addCmdDlg()" title="一键拉起本机 tt-help:弹出 Edge 登录 TikTok 后自动入池(本机需已安装 tt-help 并执行过 tt-help protocol register;未注册时自动回退为复制命令)">新增账号</button>
|
|
690
|
-
<
|
|
692
|
+
<div style="position:relative;display:inline-block;">
|
|
693
|
+
<button id="btn-check" class="primary" onclick="toggleCheckMenu()" title="逐号 HTTP 探测登录态,死号自动标疑似;范围限空闲+疑似(使用中的由业务自验兑底)">批量检测 ▾</button>
|
|
694
|
+
<div id="check-menu" style="display:none;position:absolute;top:100%;left:0;z-index:20;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.10);min-width:220px;overflow:hidden;">
|
|
695
|
+
<button style="display:block;width:100%;border:none;border-radius:0;text-align:left;padding:8px 12px;" onclick="checkNow('all')">所有(空闲+疑似)</button>
|
|
696
|
+
<button style="display:block;width:100%;border:none;border-radius:0;text-align:left;padding:8px 12px;border-top:1px solid #f0f1f3;" onclick="checkNow('abnormal')">有异常(✗死 / ?探测异常)</button>
|
|
697
|
+
<button style="display:block;width:100%;border:none;border-radius:0;text-align:left;padding:8px 12px;border-top:1px solid #f0f1f3;" onclick="checkNow('stale')">超 48h 未检测(含从未检测)</button>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
<button onclick="clearAllHist()" title="一键清空全部账号的日志与计数:各留一条留痕,useCount/换号统计清零(不影响休息窗/最近程序)">清空全部日志</button>
|
|
691
701
|
<span id="msg"></span>
|
|
692
702
|
<span class="chips" id="stats"></span>
|
|
693
703
|
</div>
|
|
@@ -697,6 +707,7 @@ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<
|
|
|
697
707
|
const fmt = t => t ? new Date(t).toLocaleString('zh-CN', {hour12:false}) : '—';
|
|
698
708
|
const day = t => t ? new Date(t).toLocaleDateString('zh-CN') : '—';
|
|
699
709
|
const ST = { leased: ['使用中','b-leased'], pool: ['空闲','b-pool'], suspect: ['疑似异常','b-suspect'], retired: ['已停用','b-retired'] };
|
|
710
|
+
const RS = { banned: '被封', inject_fail: '注入失败', scheduled: '例行换号', captcha: '验证码', network: '网络', recover_fail: '恢复失败' };
|
|
700
711
|
const ORDER = { leased: 0, pool: 1, suspect: 2, retired: 3 };
|
|
701
712
|
const rel = t => {
|
|
702
713
|
if (!t) return '—';
|
|
@@ -733,6 +744,12 @@ async function load() {
|
|
|
733
744
|
const who = l
|
|
734
745
|
? esc(l.userId) + (l.port ? ':' + esc(l.port) : '') + (l.appName ? ' <span class="muted">· ' + esc(l.appName) + '</span>' : '')
|
|
735
746
|
: '<span class="muted">—</span>';
|
|
747
|
+
// 上次使用列:在租=前一任程序(与使用者列对比看切换);空闲=最近一任
|
|
748
|
+
const lastApp = l ? (a.prevAppName || null) : (a.lastAppName || null);
|
|
749
|
+
const recent = [];
|
|
750
|
+
if (lastApp) recent.push(esc(lastApp));
|
|
751
|
+
const sw = Object.entries(a.switchStats || {});
|
|
752
|
+
if (sw.length) recent.push('<span class="muted">' + sw.map(kv => (RS[kv[0]] || esc(kv[0])) + '×' + kv[1]).join(' · ') + '</span>');
|
|
736
753
|
const health = a.lastHealth === 'alive' ? '<span style="color:#16a34a">✓</span>'
|
|
737
754
|
: a.lastHealth === 'dead' ? '<span class="warn">✗</span>'
|
|
738
755
|
: a.lastHealth === 'error' ? '<span class="muted">?</span>'
|
|
@@ -747,18 +764,18 @@ async function load() {
|
|
|
747
764
|
(meta.length ? '<div class="muted">' + meta.join(' · ') + '</div>' : '') + '</td>' +
|
|
748
765
|
'<td><span class="badge ' + st[1] + '">' + st[0] + '</span></td>' +
|
|
749
766
|
'<td>' + who + '</td>' +
|
|
767
|
+
'<td>' + (recent.length ? recent.join('<br>') : '<span class="muted">—</span>') + '</td>' +
|
|
750
768
|
'<td title="最后使用: ' + fmt(a.lastUsedAt) + '\\n最后出活: ' + biz + '">' + rel(a.lastUsedAt) + '</td>' +
|
|
751
769
|
'<td>' + (a.useCount || 0) + '</td>' +
|
|
752
770
|
'<td' + (expired ? ' class="warn"' : '') + ' title="sessionid 到期: ' + fmt(a.sessionExpiresAt) + '(cookie 承诺寿命,非实时有效性)">' + day(a.sessionExpiresAt) + (expired ? '(已过)' : '') + '</td>' +
|
|
753
771
|
'<td title="上次检测: ' + fmt(a.lastHealthAt) + (a.lastHealth === 'dead' ? '\\n死号已标疑似,可重登录复活' : a.lastHealth === 'alive' ? '\\n登录态存活' : '') + '">' + health + ' <span class="muted">' + rel(a.lastHealthAt) + '</span></td>' +
|
|
754
772
|
'<td>' +
|
|
755
|
-
|
|
756
|
-
(a.status === 'pool' ? '<button onclick="assignDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')" title="手动指派给节点端口(watchdog 注入流)">指派</button> ' : '') +
|
|
773
|
+
<!-- 释放/指派按钮已隐藏(2026-10-29:自动流转全覆盖、指派为 Phase-1 遗物零使用);API 与 releaseDlg/assignDlg 函数保留,随时可恢复 -->
|
|
757
774
|
(a.status === 'suspect' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',true)">启用</button> ' : '') +
|
|
758
775
|
(a.status === 'retired' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',false)">启用</button> ' : '') +
|
|
759
776
|
(a.status !== 'retired' ? '<button onclick="disable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',' + (a.status === 'leased') + ')">停用</button> ' : '') +
|
|
760
777
|
(a.status === 'retired' ? '<button onclick="del(\\'' + a.id + '\\')">删除</button> ' : '') +
|
|
761
|
-
'<button onclick="reloginDlg(\\'' + esc(a.username) + '\\')" title="一键拉起本机 tt-help
|
|
778
|
+
'<button onclick="reloginDlg(\\'' + esc(a.username) + '\\')" title="一键拉起本机 tt-help 重登录:窗口打开账号当前登录态,可退出重登或保持,回终端按回车提交更新池内 cookies">重登录</button>' +
|
|
762
779
|
'</td>' +
|
|
763
780
|
'<td><details data-acc="' + esc(a.id) + '" ' + (openHist.has(a.id) ? 'open' : '') + ' ontoggle="onHistToggle(this)"><summary class="muted">' + (a.history ? a.history.length : 0) + ' 条</summary>' +
|
|
764
781
|
(a.history || []).slice(0, 8).map(h => '<div class="muted">' + fmt(h.at) + ' ' + esc(h.event) + ' ' + esc(h.detail) + '</div>').join('') + '</details></td>' +
|
|
@@ -775,8 +792,9 @@ async function load() {
|
|
|
775
792
|
'<span class="chip">疑似 <b>' + nSus + '</b></span>' +
|
|
776
793
|
'<span class="chip">已停用 <b>' + nRet + '</b></span>';
|
|
777
794
|
document.getElementById('content').innerHTML =
|
|
778
|
-
'<table><tr><th>#</th><th>账号</th><th>状态</th><th>使用者</th><th>最近使用</th><th>次数</th><th>有效期</th><th>检测</th><th>操作</th><th>历史</th></tr>' + rows + '</table>' +
|
|
779
|
-
'<div style="padding: 10px 14px;"><p class="muted" style="margin:0">排序:使用中 → 空闲 → 疑似 → 已停用 · 每 15 秒自动刷新 · 有效期 = sessionid cookie 承诺寿命 · 检测 = 登录态探测(真实判据 = 业务出活)· cookies 明文不出服务端 · 「新增账号/重登录」一键拉起需本机装 tt-help 并执行过 <b>tt-help protocol register</b></p
|
|
795
|
+
'<table><tr><th>#</th><th>账号</th><th title="状态四态:使用中=租约在身 · 空闲=可被自动取号 · 疑似=检测判死暂不参与自动取号(可重登录复活)· 已停用=退出池">状态</th><th>使用者</th><th title="上次使用的程序:在租=当前取号的前一任(与使用者列对比看切换);空闲=最近一任。附换号原因聚合(仅显式上报才计数)">上次使用</th><th>最近使用</th><th>次数</th><th title="sessionid cookie 的承诺寿命,非实时有效性">有效期</th><th title="登录态探测(✓/✗/?/—);真实判据 = 业务出活">检测</th><th>操作</th><th>历史</th></tr>' + rows + '</table>' +
|
|
796
|
+
'<div style="padding: 10px 14px;"><p class="muted" style="margin:0">排序:使用中 → 空闲 → 疑似 → 已停用 · 每 15 秒自动刷新 · 有效期 = sessionid cookie 承诺寿命 · 检测 = 登录态探测(真实判据 = 业务出活)· cookies 明文不出服务端 · 「新增账号/重登录」一键拉起需本机装 tt-help 并执行过 <b>tt-help protocol register</b></p>' +
|
|
797
|
+
'<p class="muted" style="margin:6px 0 0">检测列图例:✓ 存活 · ✗ 已死(自动标疑似)· ? 探测异常(网络/超时,不代表号死)· — 未检测 · 时间 = 最近探测或续租</p></div>';
|
|
780
798
|
} catch (e) { msg('加载失败: ' + e.message, true); }
|
|
781
799
|
}
|
|
782
800
|
function copyCmd(cmd) {
|
|
@@ -817,15 +835,28 @@ function setProgress(t) {
|
|
|
817
835
|
el.style.color = '#2563eb';
|
|
818
836
|
}
|
|
819
837
|
let checking = false;
|
|
820
|
-
|
|
838
|
+
// 批量检测三选范围(2026-10-30):均仍限 pool+suspect(leased 不探,业务自验兑底)
|
|
839
|
+
const CHECK_SCOPES = {
|
|
840
|
+
all: { label: '所有(空闲+疑似)', pick: () => true },
|
|
841
|
+
abnormal: { label: '有异常(✗死 / ?探测异常)', pick: a => a.lastHealth === 'dead' || a.lastHealth === 'error' },
|
|
842
|
+
stale: { label: '超 48h 未检测(含从未检测)', pick: a => !a.lastHealthAt || a.lastHealthAt < Date.now() - 48 * 3600 * 1000 },
|
|
843
|
+
};
|
|
844
|
+
function toggleCheckMenu() {
|
|
845
|
+
const m = document.getElementById('check-menu');
|
|
846
|
+
m.style.display = m.style.display === 'none' ? 'block' : 'none';
|
|
847
|
+
}
|
|
848
|
+
async function checkNow(scope) {
|
|
849
|
+
const sc = CHECK_SCOPES[scope] || CHECK_SCOPES.all;
|
|
850
|
+
const menu = document.getElementById('check-menu');
|
|
851
|
+
if (menu) menu.style.display = 'none';
|
|
821
852
|
if (checking) return;
|
|
822
853
|
let targets;
|
|
823
854
|
try {
|
|
824
855
|
const j = await api('/api/login-pool');
|
|
825
|
-
targets = j.accounts.filter(a => a.status === 'pool' || a.status === 'suspect');
|
|
856
|
+
targets = j.accounts.filter(a => (a.status === 'pool' || a.status === 'suspect') && sc.pick(a));
|
|
826
857
|
} catch (e) { msg('加载失败: ' + e.message, true); return; }
|
|
827
|
-
if (!targets.length) { msg('
|
|
828
|
-
if (!confirm('批量检测 ' + targets.length + '
|
|
858
|
+
if (!targets.length) { msg('所选范围(' + sc.label + ')没有待检测账号(限空闲+疑似,使用中的由业务自验)'); return; }
|
|
859
|
+
if (!confirm('批量检测 ' + sc.label + ':共 ' + targets.length + ' 个账号?(死号自动标疑似)')) return;
|
|
829
860
|
checking = true;
|
|
830
861
|
const btn = document.getElementById('btn-check');
|
|
831
862
|
btn.disabled = true; btn.textContent = '检测中…';
|
|
@@ -848,9 +879,16 @@ async function checkNow() {
|
|
|
848
879
|
msg('巡检完成: 探测 ' + done + ',活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err + (mig ? ',死号标疑似 ' + mig + ' 个' : ''));
|
|
849
880
|
} finally {
|
|
850
881
|
checking = false;
|
|
851
|
-
btn.disabled = false; btn.textContent = '批量检测';
|
|
882
|
+
btn.disabled = false; btn.textContent = '批量检测 ▾';
|
|
852
883
|
}
|
|
853
884
|
}
|
|
885
|
+
async function clearAllHist() {
|
|
886
|
+
if (!confirm('清空所有账号的日志与计数?各账号留一条留痕,useCount/换号统计清零(不影响休息窗/最近程序)')) return;
|
|
887
|
+
try {
|
|
888
|
+
const r = await api('/api/login-pool/clear-history-all', { by: 'page' });
|
|
889
|
+
msg('已清空 ' + (r.cleared || 0) + ' 个账号的日志'); load();
|
|
890
|
+
} catch (e) { msg('清空失败: ' + e.message, true); }
|
|
891
|
+
}
|
|
854
892
|
async function assignDlg(id, name) {
|
|
855
893
|
const userId = prompt('指派给节点 userId(如 F-11):'); if (!userId) return;
|
|
856
894
|
const port = prompt('端口:'); if (!port) return;
|
|
@@ -1692,7 +1730,7 @@ export function startWatchServer(
|
|
|
1692
1730
|
// Phase 2 直取(以号为主键):响应含明文 cookies(业务进程自注入,不经心跳命令队列)
|
|
1693
1731
|
ret = acquireAccount(body);
|
|
1694
1732
|
console.error(
|
|
1695
|
-
`[登录池 ${ts}] ACQUIRE: ${ret.username} → ${body.userId}${body.port ? ":" + body.port : ""} (${ret.cookieCount} 条 cookies)`,
|
|
1733
|
+
`[登录池 ${ts}] ACQUIRE: ${ret.username} → ${body.userId}${body.port ? ":" + body.port : ""} (${ret.cookieCount} 条 cookies)${body.reason ? " 原因=" + body.reason : ""}`,
|
|
1696
1734
|
);
|
|
1697
1735
|
} else if (lpAction === "import") {
|
|
1698
1736
|
// 节点登录态导入(以号为主键,导入=入池登记为空闲,不自动租):按 username 去重
|
|
@@ -1734,6 +1772,16 @@ export function startWatchServer(
|
|
|
1734
1772
|
} else if (lpAction === "update") {
|
|
1735
1773
|
ret = updateAccount(body);
|
|
1736
1774
|
console.error(`[登录池 ${ts}] UPDATE: ${ret.username} status=${ret.status}`);
|
|
1775
|
+
} else if (lpAction === "clear-history") {
|
|
1776
|
+
// 清空日志(账号中心页面按钮):history 清成一条留痕 + useCount/switchStats 清零
|
|
1777
|
+
ret = clearAccountHistory(body);
|
|
1778
|
+
console.error(`[登录池 ${ts}] CLEAR-LOGS: ${ret.username} (by ${body.by || "page"})`);
|
|
1779
|
+
} else if (lpAction === "clear-history-all") {
|
|
1780
|
+
// 一键清空全部账号日志(页面批量按钮):各账号留痕一条 + useCount/switchStats 清零
|
|
1781
|
+
const sum = clearAllAccountHistory(body);
|
|
1782
|
+
console.error(`[登录池 ${ts}] CLEAR-LOGS-ALL: ${sum.cleared} 个账号 (by ${body.by || "page"})`);
|
|
1783
|
+
sendJSON(res, 200, { ok: true, cleared: sum.cleared });
|
|
1784
|
+
return;
|
|
1737
1785
|
} else if (lpAction === "delete") {
|
|
1738
1786
|
ret = deleteAccount(body.id);
|
|
1739
1787
|
console.error(`[登录池 ${ts}] DELETE: ${ret.username}`);
|