tt-help-cli-ycl 1.4.177 → 1.4.178
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-login-pool-ui.mjs +11 -2
- package/scripts/test-login-pool.mjs +109 -0
- package/src/cli/login-pool.js +207 -0
- package/src/lib/args.js +69 -0
- package/src/lib/constants.js +15 -4
- package/src/main.js +3 -0
- package/src/npm-main.js +3 -0
- package/src/watch/login-health.js +108 -0
- package/src/watch/login-pool.js +103 -0
- package/src/watch/public/app.js +29 -1
- package/src/watch/server.js +123 -34
package/package.json
CHANGED
|
@@ -150,14 +150,23 @@ await page.waitForFunction(() => document.querySelector("#content table"), { tim
|
|
|
150
150
|
const headers = await page.textContent("#content table tr:first-child");
|
|
151
151
|
check(
|
|
152
152
|
"独立页新列齐全",
|
|
153
|
-
["账号", "状态", "使用者", "最近使用", "次数", "有效期", "操作", "历史"].every((h) => headers.includes(h)),
|
|
153
|
+
["账号", "状态", "使用者", "最近使用", "次数", "有效期", "检测", "操作", "历史"].every((h) => headers.includes(h)),
|
|
154
154
|
);
|
|
155
155
|
const row1 = await page.textContent("#content table tr:nth-child(2)");
|
|
156
156
|
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
|
-
check("
|
|
160
|
+
check("独立页重登录按钮已点亮(不再是置灰占位)", await page.$$eval("#content button:not([disabled])", (bs) => bs.some((b) => b.textContent.includes("重登录"))));
|
|
161
|
+
check("独立页含新增账号/批量检测按钮", await page.$$eval(".bar button", (bs) => {
|
|
162
|
+
const t = bs.map((b) => b.textContent).join();
|
|
163
|
+
return t.includes("新增账号") && t.includes("批量检测");
|
|
164
|
+
}));
|
|
165
|
+
const barSrc = await page.content();
|
|
166
|
+
check("页面含命令化文案(协议桥后置,先复制命令)", barSrc.includes("tt-help login-pool"));
|
|
167
|
+
check("模拟取号/入池粘cookies按钮已移除", !barSrc.includes("模拟取号") && !barSrc.includes("入池(粘cookies)"));
|
|
168
|
+
check("自动刷新无条件化(无勾选框)", !barSrc.includes('id="auto"'));
|
|
169
|
+
check("统计 chips 存在", await page.$$eval("#stats .chip", (cs) => cs.length >= 5));
|
|
161
170
|
|
|
162
171
|
// 有效期归一显示:应为 ~90 天后的日期(而非 1970)
|
|
163
172
|
const day90 = new Date(Date.now() + 90 * 86400000).toLocaleDateString("zh-CN");
|
|
@@ -32,7 +32,13 @@ const {
|
|
|
32
32
|
importAccount,
|
|
33
33
|
heartbeatLoginPool,
|
|
34
34
|
reapExpiredLeases,
|
|
35
|
+
reloginAccount,
|
|
36
|
+
prepareRelogin,
|
|
37
|
+
healthTargets,
|
|
38
|
+
recordHealth,
|
|
39
|
+
_accountsInternal,
|
|
35
40
|
} = await import("../src/watch/login-pool.js");
|
|
41
|
+
const { checkPoolAccounts } = await import("../src/watch/login-health.js");
|
|
36
42
|
|
|
37
43
|
let pass = 0;
|
|
38
44
|
let fail = 0;
|
|
@@ -377,5 +383,108 @@ console.log("\n[8] 登录态导入 import");
|
|
|
377
383
|
check("缺 cookies 确实抛错", threw);
|
|
378
384
|
}
|
|
379
385
|
|
|
386
|
+
console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
|
|
387
|
+
{
|
|
388
|
+
for (const a of getLoginPoolView().accounts) {
|
|
389
|
+
try { releaseAccount({ id: a.id }); } catch {}
|
|
390
|
+
try { updateAccount({ id: a.id, status: "retired" }); } catch {}
|
|
391
|
+
try { deleteAccount(a.id); } catch {}
|
|
392
|
+
}
|
|
393
|
+
const D1 = [
|
|
394
|
+
{ name: "sessionid", value: "sid-d-old", domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 },
|
|
395
|
+
{ name: "unique_id", value: "userD", domain: ".tiktok.com" },
|
|
396
|
+
];
|
|
397
|
+
importAccount({ userId: "F-9", username: "userD", cookies: D1 });
|
|
398
|
+
const D2 = [
|
|
399
|
+
{ name: "sessionid", value: "sid-d-new", domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 * 2 },
|
|
400
|
+
{ name: "unique_id", value: "userD", domain: ".tiktok.com" },
|
|
401
|
+
{ name: "poc_marker", value: "x", domain: ".tiktok.com" },
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
let r = reloginAccount({ username: "userD", cookies: D2, userId: "cli", note: "单测" });
|
|
405
|
+
const after = JSON.parse(JSON.stringify(_accountsInternal().find((a) => a.username === "userD")));
|
|
406
|
+
check("覆盖刷新(新 cookies 落池)", JSON.stringify(after.cookies).includes("sid-d-new") && JSON.stringify(after.cookies).includes("poc_marker"));
|
|
407
|
+
check("历史含 relogin 事件", (after.history || []).some((h) => h.event === "relogin"));
|
|
408
|
+
check("正常刷新不改状态不复活", r.revived === false && r.wasSuspect === false && r.account.status === "pool");
|
|
409
|
+
|
|
410
|
+
let threw = false;
|
|
411
|
+
try { reloginAccount({ username: "ghost", cookies: D2, userId: "cli" }); } catch (e) { threw = /不在池中/.test(e.message); }
|
|
412
|
+
check("不在池拒绝", threw);
|
|
413
|
+
threw = false;
|
|
414
|
+
try { reloginAccount({ username: "userD", cookies: D2 }); } catch (e) { threw = /userId/.test(e.message); }
|
|
415
|
+
check("缺 userId 拒绝", threw);
|
|
416
|
+
threw = false;
|
|
417
|
+
try { reloginAccount({ username: "userD", userId: "cli" }); } catch (e) { threw = /cookies/.test(e.message); }
|
|
418
|
+
check("缺 cookies 拒绝", threw);
|
|
419
|
+
|
|
420
|
+
acquireAccount({ userId: "T1", port: 9400 });
|
|
421
|
+
threw = false;
|
|
422
|
+
try { reloginAccount({ username: "userD", cookies: D2, userId: "cli" }); } catch (e) { threw = /使用中/.test(e.message); }
|
|
423
|
+
check("使用中拒绝重登录", threw);
|
|
424
|
+
releaseAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id });
|
|
425
|
+
|
|
426
|
+
updateAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id, status: "suspect" });
|
|
427
|
+
r = reloginAccount({ username: "userD", cookies: D2, userId: "cli" });
|
|
428
|
+
check("suspect → 刷新后回 pool", r.wasSuspect === true && r.account.status === "pool");
|
|
429
|
+
|
|
430
|
+
updateAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id, status: "retired" });
|
|
431
|
+
r = reloginAccount({ username: "userD", cookies: D2, userId: "cli" });
|
|
432
|
+
check("retired → 重登录复活", r.revived === true && r.account.status === "pool");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log("\n[10] 巡检 healthTargets/recordHealth/checkPoolAccounts(注入假探测)");
|
|
436
|
+
{
|
|
437
|
+
for (const a of getLoginPoolView().accounts) {
|
|
438
|
+
try { releaseAccount({ id: a.id }); } catch {}
|
|
439
|
+
try { updateAccount({ id: a.id, status: "retired" }); } catch {}
|
|
440
|
+
try { deleteAccount(a.id); } catch {}
|
|
441
|
+
}
|
|
442
|
+
const mk = (sid) => [
|
|
443
|
+
{ name: "sessionid", value: sid, domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 },
|
|
444
|
+
{ name: "unique_id", value: "u-" + sid, domain: ".tiktok.com" },
|
|
445
|
+
];
|
|
446
|
+
importAccount({ userId: "F-9", username: "u-alive", cookies: mk("alive-1") });
|
|
447
|
+
importAccount({ userId: "F-9", username: "u-dead", cookies: mk("dead-1") });
|
|
448
|
+
const retired = importAccount({ userId: "F-9", username: "u-retired", cookies: mk("ret-1") });
|
|
449
|
+
updateAccount({ id: retired.account.id, status: "retired" });
|
|
450
|
+
|
|
451
|
+
const targets = healthTargets();
|
|
452
|
+
check("巡检目标默认含 pool/suspect 排除 retired", targets.length === 2 && targets.every((t) => t.username !== "u-retired"));
|
|
453
|
+
check("巡检目标带 cookies/proxyHint", targets.every((t) => Array.isArray(t.cookies) && t.cookies.length > 0));
|
|
454
|
+
|
|
455
|
+
// 假探测:u-alive 判活,u-dead 判死(带标记 cookie 验证探测拿到的是池内 cookies)
|
|
456
|
+
const fakeProbe = async (cookies) => {
|
|
457
|
+
const isAlive = JSON.stringify(cookies).includes("alive-1");
|
|
458
|
+
check(isAlive ? "探测[活]拿到的是池内 cookies" : "探测[死]拿到的是池内 cookies", Array.isArray(cookies));
|
|
459
|
+
return isAlive
|
|
460
|
+
? { alive: true, username: "u-alive", expired: false, httpStatus: 200, error: null, ms: 5 }
|
|
461
|
+
: { alive: false, username: null, expired: true, httpStatus: 200, error: null, ms: 5 };
|
|
462
|
+
};
|
|
463
|
+
const sum = await checkPoolAccounts({ probeFn: fakeProbe, pacingMs: 0 });
|
|
464
|
+
check("巡检计数 alive/dead", sum.checked === 2 && sum.alive === 1 && sum.dead === 1);
|
|
465
|
+
check("死号自动 pool→suspect", sum.migrated === 1 && sum.results.find((x) => x.username === "u-dead").migrated === true);
|
|
466
|
+
|
|
467
|
+
const deadAcc = getLoginPoolView().accounts.find((a) => a.username === "u-dead");
|
|
468
|
+
check("死号 lastHealth=dead + 时间戳", deadAcc.lastHealth === "dead" && deadAcc.lastHealthAt > 0);
|
|
469
|
+
check("死号历史含 health 事件", (deadAcc.history || []).some((h) => h.event === "health"));
|
|
470
|
+
const aliveAcc = getLoginPoolView().accounts.find((a) => a.username === "u-alive");
|
|
471
|
+
check("活号不写 history(防刷屏)", !(aliveAcc.history || []).some((h) => h.event === "health"));
|
|
472
|
+
|
|
473
|
+
acquireAccount({ userId: "T2", port: 9401 }); // 租走最久未用的(u-alive 或 u-dead 中 LRU 者)
|
|
474
|
+
const leased = getLoginPoolView().accounts.find((a) => a.status === "leased");
|
|
475
|
+
const rec = recordHealth({ id: leased.id, ok: "dead", detail: "单测" });
|
|
476
|
+
check("leased 死号仅记录不迁移状态", rec.status === "leased" && leased.username === rec.username);
|
|
477
|
+
|
|
478
|
+
const notLeased = getLoginPoolView().accounts.find((a) => a.status !== "leased").username;
|
|
479
|
+
const prep = prepareRelogin({ username: notLeased });
|
|
480
|
+
check("prepare 返回明文 cookies", Array.isArray(prep.account.cookies) && prep.account.cookies.length > 0);
|
|
481
|
+
let threw = false;
|
|
482
|
+
try { prepareRelogin({ username: leased.username }); } catch (e) { threw = /使用中/.test(e.message); }
|
|
483
|
+
check("prepare 使用中拒绝", threw);
|
|
484
|
+
threw = false;
|
|
485
|
+
try { prepareRelogin({ username: "ghost" }); } catch (e) { threw = /不在池中/.test(e.message); }
|
|
486
|
+
check("prepare 不在池拒绝", threw);
|
|
487
|
+
}
|
|
488
|
+
|
|
380
489
|
console.log(`\n═══ 结果: ${pass} 通过, ${fail} 失败 ═══`);
|
|
381
490
|
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handleLoginPool — 账号中心一条命令:新登录收号 / 重登录 / 巡检触发
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* tt-help login-pool add [--server URL] [--proxy URL|--no-proxy] [--note S] [--dir D] [--timeout S]
|
|
6
|
+
* tt-help login-pool relogin <username> [--server URL] [--proxy URL|--no-proxy] [--dir D] [--timeout S]
|
|
7
|
+
* tt-help login-pool check [--server URL] [--proxy URL] [--ids id1,id2] [--json]
|
|
8
|
+
*
|
|
9
|
+
* add: 起独立 Edge(临时目录+代理)→ 人工在窗口里登录 TikTok → 轮询 passport 检出
|
|
10
|
+
* → 导出 cookies → POST /api/login-pool/import(幂等,同号跳过)
|
|
11
|
+
* relogin: POST /prepare 取池内旧 cookies → 注入本机 Edge 试复活(快路径零操作)
|
|
12
|
+
* → 会话已死则提示人工在窗口登录目标账号 → 身份校验 → POST /relogin 覆盖刷新
|
|
13
|
+
* check: 服务端无浏览器 HTTP 探测各号登录态(默认空闲+疑似),死号自动标疑似
|
|
14
|
+
*
|
|
15
|
+
* 协议桥(页面一键拉起本命令)为独立后续项,本命令即其执行端。
|
|
16
|
+
*/
|
|
17
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { chromium } from "playwright";
|
|
21
|
+
import { gotoTiktok, whoAmI } from "../lib/login-pool-client.js";
|
|
22
|
+
import { proxy as configuredProxy } from "../lib/constants.js";
|
|
23
|
+
|
|
24
|
+
const POLL_INTERVAL_MS = 3000;
|
|
25
|
+
const CDP_FALLBACK_NOTE = "(关掉窗口或 Ctrl+C 可中止)";
|
|
26
|
+
|
|
27
|
+
function log(t) {
|
|
28
|
+
console.error(`[登录池] ${t}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** HTTP POST,默认 30s 超时(check 场景调用方自带更长超时) */
|
|
32
|
+
async function post(base, path, body, timeoutMs = 30 * 1000) {
|
|
33
|
+
const res = await fetch(`${String(base).replace(/\/+$/, "")}/api/login-pool/${path}`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify(body || {}),
|
|
37
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
38
|
+
});
|
|
39
|
+
const j = await res.json().catch(() => null);
|
|
40
|
+
if (!res.ok || !j || j.error) throw new Error((j && j.error) || `HTTP ${res.status}`);
|
|
41
|
+
return j;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 起独立 Edge 会话(launchPersistentContext: 跨平台自动找 Edge;临时目录随用随清) */
|
|
45
|
+
async function openEdgeSession({ proxy, dir } = {}) {
|
|
46
|
+
const userDataDir = dir || mkdtempSync(join(tmpdir(), "tt-login-pool-"));
|
|
47
|
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
48
|
+
channel: "msedge",
|
|
49
|
+
headless: false,
|
|
50
|
+
...(proxy ? { proxy: { server: proxy } } : {}),
|
|
51
|
+
viewport: { width: 1280, height: 900 },
|
|
52
|
+
args: ["--no-first-run", "--no-default-browser-check", "--disable-sync"],
|
|
53
|
+
});
|
|
54
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
55
|
+
return {
|
|
56
|
+
context,
|
|
57
|
+
page,
|
|
58
|
+
userDataDir,
|
|
59
|
+
cleanup: async () => {
|
|
60
|
+
await context.close().catch(() => {}); // persistent context close = 关浏览器
|
|
61
|
+
if (!dir) rmSync(userDataDir, { recursive: true, force: true });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 轮询等登录:注入 preCookies(重登录快路径)后循环 passport 检测 */
|
|
67
|
+
async function waitForLogin({ page, preCookies, toPlaywrightCookies, timeoutMs }) {
|
|
68
|
+
const t0 = Date.now();
|
|
69
|
+
if (preCookies) {
|
|
70
|
+
await page.context().addCookies(preCookies);
|
|
71
|
+
}
|
|
72
|
+
await gotoTiktok(page);
|
|
73
|
+
for (;;) {
|
|
74
|
+
const id = await whoAmI(page);
|
|
75
|
+
if (id.hasAccount) return id;
|
|
76
|
+
if (Date.now() - t0 > timeoutMs) {
|
|
77
|
+
throw new Error(`等待登录超时(${Math.round(timeoutMs / 1000)}s)${CDP_FALLBACK_NOTE}`);
|
|
78
|
+
}
|
|
79
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** add 子命令 */
|
|
84
|
+
async function cmdAdd(opts) {
|
|
85
|
+
const { serverUrl, proxy, note, timeoutMs, dir } = opts;
|
|
86
|
+
log("启动 Edge,请在打开的窗口里登录 TikTok(等待中,随时可关窗口中止)…");
|
|
87
|
+
const session = await openEdgeSession({ proxy, dir });
|
|
88
|
+
try {
|
|
89
|
+
const id = await waitForLogin({ page: session.page, timeoutMs });
|
|
90
|
+
log(`检测到登录: @${id.username},导出 cookies 并入池…`);
|
|
91
|
+
const cookies = await session.context.cookies();
|
|
92
|
+
const r = await post(serverUrl, "import", {
|
|
93
|
+
userId: "cli-login-pool",
|
|
94
|
+
username: id.username,
|
|
95
|
+
cookies,
|
|
96
|
+
note: note || "命令行新登录收号",
|
|
97
|
+
});
|
|
98
|
+
if (r.dedup) {
|
|
99
|
+
log(`✓ @${id.username} 已在池,幂等跳过(不覆盖)`);
|
|
100
|
+
} else {
|
|
101
|
+
log(`✓ @${id.username} 新入池(空闲),共 ${r.account.cookieCount} 条 cookies`);
|
|
102
|
+
}
|
|
103
|
+
log(`完成。服务端: ${serverUrl}`);
|
|
104
|
+
} finally {
|
|
105
|
+
await session.cleanup();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** relogin 子命令 */
|
|
110
|
+
async function cmdRelogin(opts) {
|
|
111
|
+
const { serverUrl, proxy, username, timeoutMs, dir } = opts;
|
|
112
|
+
if (!username) throw new Error("用法: tt-help login-pool relogin <username>");
|
|
113
|
+
const prep = await post(serverUrl, "prepare", { username });
|
|
114
|
+
const oldCookies = prep.account.cookies;
|
|
115
|
+
log(`目标 @${username}(池内 ${oldCookies.length} 条 cookies)。启动 Edge,先试旧登录态复活…`);
|
|
116
|
+
const toPlaywrightCookies = (cookies) =>
|
|
117
|
+
(cookies || []).map((c) => ({
|
|
118
|
+
name: c.name,
|
|
119
|
+
value: c.value,
|
|
120
|
+
domain: c.domain || ".tiktok.com",
|
|
121
|
+
path: c.path || "/",
|
|
122
|
+
secure: c.secure !== false,
|
|
123
|
+
httpOnly: c.httpOnly !== false,
|
|
124
|
+
...(typeof c.expires === "number" && c.expires > 0 ? { expires: c.expires } : {}),
|
|
125
|
+
}));
|
|
126
|
+
const session = await openEdgeSession({ proxy, dir });
|
|
127
|
+
try {
|
|
128
|
+
let id = await waitForLogin({ page: session.page, preCookies: toPlaywrightCookies(oldCookies), timeoutMs: 20000 });
|
|
129
|
+
if (id.hasAccount && id.username === username) {
|
|
130
|
+
log("✓ 旧登录态仍存活(快路径,零操作)");
|
|
131
|
+
} else {
|
|
132
|
+
// 死号分支:清掉死 cookies,提示人工在窗口登录目标账号
|
|
133
|
+
log(`旧登录态已失效(${id.expired ? "会话过期" : "未登录"})。请在窗口里登录 @${username}…`);
|
|
134
|
+
await session.context.clearCookies();
|
|
135
|
+
await gotoTiktok(session.page);
|
|
136
|
+
id = await waitForLogin({ page: session.page, timeoutMs });
|
|
137
|
+
}
|
|
138
|
+
if (id.username !== username) {
|
|
139
|
+
throw new Error(`身份不符:窗口登录的是 @${id.username},目标是 @${username},已中止(不更新池)`);
|
|
140
|
+
}
|
|
141
|
+
const cookies = await session.context.cookies();
|
|
142
|
+
const r = await post(serverUrl, "relogin", { username, cookies, userId: "cli-login-pool", note: "命令行重登录" });
|
|
143
|
+
log(
|
|
144
|
+
`✓ @${username} 重登录完成: cookies 已覆盖刷新(${r.account.cookieCount} 条)${r.revived ? ",已停用账号复活回池" : r.wasSuspect ? ",疑似已清回池" : ""}`,
|
|
145
|
+
);
|
|
146
|
+
} finally {
|
|
147
|
+
await session.cleanup();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** check 子命令:触发服务端巡检(探测在服务端做,cookies 不出服务端) */
|
|
152
|
+
async function cmdCheck(opts) {
|
|
153
|
+
const { serverUrl, proxy, ids, json } = opts;
|
|
154
|
+
log(`触发服务端巡检${proxy ? `(出口 ${proxy})` : ""}…`);
|
|
155
|
+
const r = await post(serverUrl, "check", { ids: ids || undefined, proxy: proxy || undefined }, 15 * 60 * 1000);
|
|
156
|
+
if (json) {
|
|
157
|
+
console.log(JSON.stringify(r, null, 2));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
log(`巡检完成: 探测 ${r.checked},活 ${r.alive} / 死 ${r.dead} / 异常 ${r.error}${r.migrated ? `,死号标疑似 ${r.migrated} 个` : ""}`);
|
|
161
|
+
for (const x of r.results || []) {
|
|
162
|
+
if (x.health !== "alive") {
|
|
163
|
+
log(` ${x.health === "dead" ? "✗" : "?"} @${x.username} → ${x.status}${x.migrated ? "(pool→suspect)" : ""}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {object} parsed parseLoginPoolArgs 结果
|
|
170
|
+
* { action, username, serverUrl, proxy(null=用配置默认), noProxy, note, dir, timeoutSec, ids, showHelp, json }
|
|
171
|
+
*/
|
|
172
|
+
export async function handleLoginPool(parsed) {
|
|
173
|
+
const {
|
|
174
|
+
action,
|
|
175
|
+
username,
|
|
176
|
+
serverUrl,
|
|
177
|
+
proxy: proxyArg,
|
|
178
|
+
noProxy,
|
|
179
|
+
note,
|
|
180
|
+
dir,
|
|
181
|
+
timeoutSec = 300,
|
|
182
|
+
ids,
|
|
183
|
+
json,
|
|
184
|
+
showHelp,
|
|
185
|
+
} = parsed;
|
|
186
|
+
if (showHelp || !action) {
|
|
187
|
+
console.error(
|
|
188
|
+
[
|
|
189
|
+
"用法:",
|
|
190
|
+
" tt-help login-pool add [--server URL] [--proxy URL|--no-proxy] [--note S] [--dir D] [--timeout S]",
|
|
191
|
+
" tt-help login-pool relogin <username> [--server URL] [--proxy URL|--no-proxy] [--dir D] [--timeout S]",
|
|
192
|
+
" tt-help login-pool check [--server URL] [--proxy URL] [--ids id1,id2] [--json]",
|
|
193
|
+
"",
|
|
194
|
+
" add 新登录收号:弹出 Edge 人工登录 TikTok,检出后自动入池(幂等)",
|
|
195
|
+
" relogin 重登录:先试旧登录态复活(零操作),死了引导人工登录,身份校验后覆盖刷新",
|
|
196
|
+
" check 巡检:服务端 HTTP 探测各号登录态,死号自动标疑似",
|
|
197
|
+
].join("\n"),
|
|
198
|
+
);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const proxy = noProxy ? null : proxyArg || configuredProxy || null;
|
|
202
|
+
const opts = { serverUrl, proxy, note, dir, timeoutMs: timeoutSec * 1000, ids, json };
|
|
203
|
+
if (action === "add") return cmdAdd(opts);
|
|
204
|
+
if (action === "relogin") return cmdRelogin({ ...opts, username: username || parsed.username });
|
|
205
|
+
if (action === "check") return cmdCheck(opts);
|
|
206
|
+
throw new Error(`未知子命令: ${action}(支持 add / relogin / check)`);
|
|
207
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -1167,6 +1167,71 @@ function parseLoginCheckArgs(args) {
|
|
|
1167
1167
|
};
|
|
1168
1168
|
}
|
|
1169
1169
|
|
|
1170
|
+
function parseLoginPoolArgs(args) {
|
|
1171
|
+
let action = null;
|
|
1172
|
+
let username = null;
|
|
1173
|
+
let serverUrl = defaultServer;
|
|
1174
|
+
let proxy = null;
|
|
1175
|
+
let noProxy = false;
|
|
1176
|
+
let note = null;
|
|
1177
|
+
let dir = null;
|
|
1178
|
+
let timeoutSec = 300;
|
|
1179
|
+
let ids = null;
|
|
1180
|
+
let json = false;
|
|
1181
|
+
let showHelp = false;
|
|
1182
|
+
|
|
1183
|
+
for (let i = 0; i < args.length; i++) {
|
|
1184
|
+
const arg = args[i];
|
|
1185
|
+
if (arg === "--server" && i + 1 < args.length) {
|
|
1186
|
+
serverUrl = args[++i];
|
|
1187
|
+
} else if (arg === "--proxy" && i + 1 < args.length) {
|
|
1188
|
+
proxy = args[++i];
|
|
1189
|
+
} else if (arg === "--no-proxy") {
|
|
1190
|
+
noProxy = true;
|
|
1191
|
+
} else if (arg === "--note" && i + 1 < args.length) {
|
|
1192
|
+
note = args[++i];
|
|
1193
|
+
} else if (arg === "--dir" && i + 1 < args.length) {
|
|
1194
|
+
dir = args[++i];
|
|
1195
|
+
} else if (arg === "--timeout" && i + 1 < args.length) {
|
|
1196
|
+
timeoutSec = parseInt(args[++i], 10) || 300;
|
|
1197
|
+
} else if (arg === "--ids" && i + 1 < args.length) {
|
|
1198
|
+
ids = args[++i].split(",").map((s) => s.trim()).filter(Boolean);
|
|
1199
|
+
} else if (arg === "--json") {
|
|
1200
|
+
json = true;
|
|
1201
|
+
} else if (arg === "-h" || arg === "--help") {
|
|
1202
|
+
showHelp = true;
|
|
1203
|
+
} else if (!arg.startsWith("-")) {
|
|
1204
|
+
if (!action) action = arg;
|
|
1205
|
+
else if (!username) username = arg;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return {
|
|
1210
|
+
subcommand: "login-pool",
|
|
1211
|
+
action,
|
|
1212
|
+
username,
|
|
1213
|
+
serverUrl,
|
|
1214
|
+
proxy,
|
|
1215
|
+
noProxy,
|
|
1216
|
+
note,
|
|
1217
|
+
dir,
|
|
1218
|
+
timeoutSec,
|
|
1219
|
+
ids,
|
|
1220
|
+
json,
|
|
1221
|
+
showHelp,
|
|
1222
|
+
urls: [],
|
|
1223
|
+
outputFormat: "json",
|
|
1224
|
+
exploreCount: 0,
|
|
1225
|
+
showConfig: false,
|
|
1226
|
+
showVersion: false,
|
|
1227
|
+
customProxy: null,
|
|
1228
|
+
configAction: null,
|
|
1229
|
+
configValue: null,
|
|
1230
|
+
pipeMode: false,
|
|
1231
|
+
filterStr: null,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1170
1235
|
function parseOpenArgs(args) {
|
|
1171
1236
|
let openPort = null;
|
|
1172
1237
|
let openList = false;
|
|
@@ -1721,6 +1786,10 @@ export function parseArgs() {
|
|
|
1721
1786
|
return parseLoginCheckArgs(args.slice(1));
|
|
1722
1787
|
}
|
|
1723
1788
|
|
|
1789
|
+
if (args.length > 0 && args[0] === "login-pool") {
|
|
1790
|
+
return parseLoginPoolArgs(args.slice(1));
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1724
1793
|
if (args.length > 0 && args[0] === "comments") {
|
|
1725
1794
|
return parseCommentsArgs(args.slice(1));
|
|
1726
1795
|
}
|
package/src/lib/constants.js
CHANGED
|
@@ -291,10 +291,21 @@ const HELP_TEXT = [
|
|
|
291
291
|
" 示例: tt-help login-check 9222,9223,9224 --proxy http://127.0.0.1:7897",
|
|
292
292
|
" tt-help login-check --json",
|
|
293
293
|
"",
|
|
294
|
-
"
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
"
|
|
294
|
+
" tt-help login-pool <add|relogin|check> [选项]",
|
|
295
|
+
" 账号中心:新登录收号 / 重登录 / 巡检(与网页 /login-pool 同一套账号库)",
|
|
296
|
+
" add 弹出独立 Edge,人工登录 TikTok 后自动导出登录态入池(幂等)",
|
|
297
|
+
" relogin <username> 先试旧登录态复活(零操作),失效则引导人工登录,覆盖刷新池内 cookies",
|
|
298
|
+
" 选项:",
|
|
299
|
+
" --server <URL> 账号中心地址(watch 服务端)",
|
|
300
|
+
" --proxy <地址> Edge/探测代理,默认配置文件 proxy;--no-proxy 直连",
|
|
301
|
+
" --note <备注> add 备注信息",
|
|
302
|
+
" --dir <路径> 自定义 Edge 用户目录(默认临时目录用后即清)",
|
|
303
|
+
" --timeout <秒> 等待人工登录上限,默认 300",
|
|
304
|
+
" --ids <id1,id2> check 只探测指定账号",
|
|
305
|
+
" --json check 输出 JSON",
|
|
306
|
+
" 示例: tt-help login-pool add --server http://172.18.154.201:3011",
|
|
307
|
+
" tt-help login-pool relogin user8868337530661 --server http://172.18.154.201:3011",
|
|
308
|
+
" tt-help login-pool check --server http://172.18.154.201:3011 --proxy http://127.0.0.1:7897",
|
|
298
309
|
" 示例: tt-help videostats data/result.db -p 3",
|
|
299
310
|
"",
|
|
300
311
|
" db-import --db <db路径> [--users users.json] [--done done.json] [--videos videos.json]",
|
package/src/main.js
CHANGED
|
@@ -27,6 +27,7 @@ import { handleSearch } from "./cli/search.js";
|
|
|
27
27
|
import { handleWatchdog } from "./cli/watchdog.js";
|
|
28
28
|
import { handleClashDaemon } from "./cli/clash-daemon.js";
|
|
29
29
|
import { handleLoginCheck } from "./cli/login-check.js";
|
|
30
|
+
import { handleLoginPool } from "./cli/login-pool.js";
|
|
30
31
|
|
|
31
32
|
async function main() {
|
|
32
33
|
const parsed = parseArgs();
|
|
@@ -80,6 +81,8 @@ async function main() {
|
|
|
80
81
|
return handleClashDaemon(parsed);
|
|
81
82
|
case "login-check":
|
|
82
83
|
return handleLoginCheck(parsed);
|
|
84
|
+
case "login-pool":
|
|
85
|
+
return handleLoginPool(parsed);
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
const {
|
package/src/npm-main.js
CHANGED
|
@@ -23,6 +23,7 @@ import { handleRawSync } from "./cli/raw-sync.js";
|
|
|
23
23
|
import { handleSearch } from "./cli/search.js";
|
|
24
24
|
import { handleWatchdog } from "./cli/watchdog.js";
|
|
25
25
|
import { handleLoginCheck } from "./cli/login-check.js";
|
|
26
|
+
import { handleLoginPool } from "./cli/login-pool.js";
|
|
26
27
|
|
|
27
28
|
function exitUnsupportedCommand(command) {
|
|
28
29
|
console.error(
|
|
@@ -79,6 +80,8 @@ async function main() {
|
|
|
79
80
|
return handleWatchdog(parsed);
|
|
80
81
|
case "login-check":
|
|
81
82
|
return handleLoginCheck(parsed);
|
|
83
|
+
case "login-pool":
|
|
84
|
+
return handleLoginPool(parsed);
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
const {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 登录态批量巡检(账号中心)— 服务端无浏览器纯 HTTP 探测
|
|
3
|
+
*
|
|
4
|
+
* 原理(POC 实证 2026-10-28,见知识库 worklog):
|
|
5
|
+
* undici + Cookie 头 + 真实 UA + 代理出口,直探 passport/web/account/info/。
|
|
6
|
+
* 浏览器里必须页面上下文 fetch(CORS),服务端无此限制。阳性对照(真实 cookies)
|
|
7
|
+
* 判活 / 阴性对照(垃圾 sessionid)判死均已验证,单次 ~700ms。
|
|
8
|
+
*
|
|
9
|
+
* 判据边界:探测只回答「登录态还活着吗」,不回答「号还能不能干活」——
|
|
10
|
+
* 风控限流/影子封禁只有真实业务出活能暴露。
|
|
11
|
+
*/
|
|
12
|
+
import { request, ProxyAgent } from "undici";
|
|
13
|
+
import { healthTargets, recordHealth } from "./login-pool.js";
|
|
14
|
+
|
|
15
|
+
const ACCOUNT_INFO_URL = "https://www.tiktok.com/passport/web/account/info/";
|
|
16
|
+
const UA =
|
|
17
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36 Edg/155.0.0.0";
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 20 * 1000;
|
|
19
|
+
const DEFAULT_PACING_MS = 250;
|
|
20
|
+
|
|
21
|
+
/** proxyHint 归一化:纯数字 → http://127.0.0.1:<port>;http(s):// 开头 → 原样;其余忽略 */
|
|
22
|
+
function normalizeProxy(hint) {
|
|
23
|
+
const s = String(hint || "").trim();
|
|
24
|
+
if (!s) return null;
|
|
25
|
+
if (/^\d{2,5}$/.test(s)) return `http://127.0.0.1:${s}`;
|
|
26
|
+
if (/^https?:\/\//.test(s)) return s;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 无浏览器探测单个账号登录态
|
|
32
|
+
* @param {Array} cookies 池内 cookies 数组 [{name,value,domain?...}]
|
|
33
|
+
* @param {object} opts { proxy?: string|null, timeoutMs?: number }
|
|
34
|
+
* @returns {Promise<{alive: boolean, username: ?string, expired: boolean, httpStatus: number, error: ?string, ms: number}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function probeLogin(cookies, { proxy = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
37
|
+
const cookieHeader = (cookies || [])
|
|
38
|
+
.filter((c) => c && c.name && c.value != null)
|
|
39
|
+
.filter((c) => !c.domain || String(c.domain).includes("tiktok"))
|
|
40
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
41
|
+
.join("; ");
|
|
42
|
+
const t0 = Date.now();
|
|
43
|
+
try {
|
|
44
|
+
const res = await request(ACCOUNT_INFO_URL, {
|
|
45
|
+
method: "GET",
|
|
46
|
+
headers: {
|
|
47
|
+
cookie: cookieHeader,
|
|
48
|
+
"user-agent": UA,
|
|
49
|
+
accept: "application/json",
|
|
50
|
+
"accept-language": "en-US,en;q=0.9",
|
|
51
|
+
},
|
|
52
|
+
...(proxy ? { dispatcher: new ProxyAgent(proxy) } : {}),
|
|
53
|
+
headersTimeout: timeoutMs,
|
|
54
|
+
bodyTimeout: timeoutMs,
|
|
55
|
+
});
|
|
56
|
+
const body = await res.body.json().catch(() => null);
|
|
57
|
+
const ms = Date.now() - t0;
|
|
58
|
+
const d = (body && body.data) || null;
|
|
59
|
+
return {
|
|
60
|
+
httpStatus: res.status,
|
|
61
|
+
alive: !!d && !!(d.username || d.user_id || d.sec_user_id),
|
|
62
|
+
username: (d && d.username) || null,
|
|
63
|
+
expired: (d && d.name === "session_expired") || (body && body.error_code === 13),
|
|
64
|
+
error: body && !d ? JSON.stringify(body).slice(0, 120) : null,
|
|
65
|
+
ms,
|
|
66
|
+
};
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { httpStatus: 0, alive: false, username: null, expired: false, error: String(e).slice(0, 120), ms: Date.now() - t0 };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 批量巡检:默认探 pool+suspect(leased 由消费方验证兜底、retired 不探测),指定 ids 精确取。
|
|
74
|
+
* 结果逐号 recordHealth 落账(dead 且在池 → suspect);串行 + pacing,对 TikTok 友好。
|
|
75
|
+
* @param {object} opts { ids?: string[], proxy?: string|null(缺省用各号 proxyHint), pacingMs?, probeFn?(测试注入) }
|
|
76
|
+
* @returns {Promise<{checked: number, alive: number, dead: number, error: number, migrated: number, results: Array}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function checkPoolAccounts(opts = {}) {
|
|
79
|
+
const { ids, proxy = null, pacingMs = DEFAULT_PACING_MS, probeFn = probeLogin } = opts;
|
|
80
|
+
const targets = healthTargets({ ids });
|
|
81
|
+
const summary = { checked: 0, alive: 0, dead: 0, error: 0, migrated: 0, results: [] };
|
|
82
|
+
for (let i = 0; i < targets.length; i++) {
|
|
83
|
+
const t = targets[i];
|
|
84
|
+
const exit = proxy || normalizeProxy(t.proxyHint);
|
|
85
|
+
const r = await probeFn(t.cookies, { proxy: exit });
|
|
86
|
+
const ok = r.error ? "error" : r.alive ? "alive" : "dead";
|
|
87
|
+
let rec = { username: t.username, health: ok, status: t.status, ms: r.ms };
|
|
88
|
+
try {
|
|
89
|
+
const applied = recordHealth({
|
|
90
|
+
id: t.id,
|
|
91
|
+
ok,
|
|
92
|
+
detail: r.error ? `探测异常 ${r.error}` : r.alive ? "" : r.expired ? "会话过期" : "未登录",
|
|
93
|
+
});
|
|
94
|
+
rec.status = applied.status;
|
|
95
|
+
if (applied.migrated) rec.migrated = true;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
rec.error = e.message;
|
|
98
|
+
}
|
|
99
|
+
summary[ok]++;
|
|
100
|
+
summary.checked++;
|
|
101
|
+
if (rec.migrated) summary.migrated++;
|
|
102
|
+
summary.results.push(rec);
|
|
103
|
+
if (i < targets.length - 1 && pacingMs > 0) {
|
|
104
|
+
await new Promise((res) => setTimeout(res, pacingMs));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return summary;
|
|
108
|
+
}
|
package/src/watch/login-pool.js
CHANGED
|
@@ -229,6 +229,7 @@ function publicView(acc) {
|
|
|
229
229
|
lastImportFrom: acc.lastImportFrom || null,
|
|
230
230
|
// 最近使用镜像(脱离 lease 独立存在,释放/回收后仍可见)
|
|
231
231
|
lastHealthAt: acc.lastHealthAt || null,
|
|
232
|
+
lastHealth: acc.lastHealth || null,
|
|
232
233
|
lastBusinessOkAt: acc.lastBusinessOkAt || null,
|
|
233
234
|
lastUsedAt: acc.lastUsedAt || null,
|
|
234
235
|
lease,
|
|
@@ -754,6 +755,108 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
|
|
|
754
755
|
/** 来源元数据(供节点热路径跳过 + 账号中心展示),非租约;新入池与幂等补写共用此定义 */
|
|
755
756
|
const importMeta = (userId, portNum, at) => ({ userId, port: portNum, at });
|
|
756
757
|
|
|
758
|
+
/**
|
|
759
|
+
* 重登录:按 username 覆盖刷新 cookies(区别于 import 的「已在池即幂等跳过」)。
|
|
760
|
+
* 守卫:目标必须已在池(新号走 import/add);使用中(leased)拒绝——先等节点换号/手动释放;
|
|
761
|
+
* retired 重登录=复活回 pool;suspect 刷新后回 pool;空闲则原地刷新。
|
|
762
|
+
* username 必须来自 passport 活体验证(调用方已验,且须与目标一致——身份不符由调用方拦截),
|
|
763
|
+
* cookies 必须非空。
|
|
764
|
+
*/
|
|
765
|
+
export function reloginAccount({ username, cookies, userId, note } = {}) {
|
|
766
|
+
ensureLoaded();
|
|
767
|
+
reapExpiredLeases();
|
|
768
|
+
if (!username) throw new Error("username 必填(重登录目标)");
|
|
769
|
+
if (!userId) throw new Error("userId 必填(操作来源)");
|
|
770
|
+
const norm = normalizeCookies(cookies);
|
|
771
|
+
if (!norm) throw new Error("cookies 必须是非空数组,且每项含 name/value");
|
|
772
|
+
const acc = _accounts.find((a) => a.username === username);
|
|
773
|
+
if (!acc) throw new Error(`账号 ${username} 不在池中(重登录仅针对已有账号,新号走 import/add)`);
|
|
774
|
+
if (acc.status === "leased") {
|
|
775
|
+
const w = acc.lease;
|
|
776
|
+
throw new Error(
|
|
777
|
+
`账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
const revived = acc.status === "retired";
|
|
781
|
+
const wasSuspect = acc.status === "suspect";
|
|
782
|
+
acc.cookies = norm;
|
|
783
|
+
acc.status = "pool";
|
|
784
|
+
acc.updatedAt = now();
|
|
785
|
+
pushHistory(
|
|
786
|
+
acc,
|
|
787
|
+
"relogin",
|
|
788
|
+
`${revived ? "重登录复活(已停用→池)" : wasSuspect ? "重登录(疑似→池)" : "重登录刷新"} · ${userId}${note ? ` · ${String(note).slice(0, 80)}` : ""}`,
|
|
789
|
+
);
|
|
790
|
+
scheduleSave();
|
|
791
|
+
return { account: publicView(acc), revived, wasSuspect };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 巡检目标:默认取 pool+suspect(leased 由消费方验证兜底、retired 不探测);
|
|
796
|
+
* 指定 ids 时按 id 精确取(仍排除 retired)。返回含明文 cookies,仅限服务端进程内使用。
|
|
797
|
+
*/
|
|
798
|
+
export function healthTargets({ ids } = {}) {
|
|
799
|
+
ensureLoaded();
|
|
800
|
+
reapExpiredLeases();
|
|
801
|
+
const idSet = Array.isArray(ids) && ids.length ? new Set(ids) : null;
|
|
802
|
+
return _accounts
|
|
803
|
+
.filter((a) => a.status !== "retired")
|
|
804
|
+
.filter((a) => (idSet ? idSet.has(a.id) : a.status === "pool" || a.status === "suspect"))
|
|
805
|
+
.map((a) => ({
|
|
806
|
+
id: a.id,
|
|
807
|
+
username: a.username,
|
|
808
|
+
status: a.status,
|
|
809
|
+
proxyHint: a.proxyHint || "",
|
|
810
|
+
cookies: a.cookies,
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* 巡检结果落账:lastHealthAt + lastHealth('alive'|'dead'|'error')。
|
|
816
|
+
* dead 且在池 → 迁移 suspect(业务不再自动取到死号);alive 不写 history(避免刷屏,
|
|
817
|
+
* HISTORY_MAX 只有 20 条);dead/error 才记 history。leased/retired 仅记录不迁移状态。
|
|
818
|
+
*/
|
|
819
|
+
export function recordHealth({ id, ok, detail } = {}) {
|
|
820
|
+
ensureLoaded();
|
|
821
|
+
const acc = _accounts.find((x) => x.id === id);
|
|
822
|
+
if (!acc) throw new Error(`账号不存在: ${id}`);
|
|
823
|
+
acc.lastHealthAt = now();
|
|
824
|
+
acc.lastHealth = ok; // 'alive' | 'dead' | 'error'
|
|
825
|
+
let migrated = false;
|
|
826
|
+
if (ok === "dead" && acc.status === "pool") {
|
|
827
|
+
acc.status = "suspect";
|
|
828
|
+
migrated = true;
|
|
829
|
+
}
|
|
830
|
+
if (ok !== "alive" || migrated) {
|
|
831
|
+
pushHistory(
|
|
832
|
+
acc,
|
|
833
|
+
"health",
|
|
834
|
+
`${ok}${detail ? ` · ${String(detail).slice(0, 80)}` : ""}${migrated ? " · pool→suspect" : ""}`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
scheduleSave();
|
|
838
|
+
return { username: acc.username, status: acc.status, migrated };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* 重登录预备:返回目标账号明文 cookies(relogin 命令注入旧登录态用)。
|
|
843
|
+
* 守卫与 reloginAccount 一致:必须在池、使用中拒绝。
|
|
844
|
+
*/
|
|
845
|
+
export function prepareRelogin({ username } = {}) {
|
|
846
|
+
ensureLoaded();
|
|
847
|
+
reapExpiredLeases();
|
|
848
|
+
if (!username) throw new Error("username 必填");
|
|
849
|
+
const acc = _accounts.find((a) => a.username === username);
|
|
850
|
+
if (!acc) throw new Error(`账号 ${username} 不在池中`);
|
|
851
|
+
if (acc.status === "leased") {
|
|
852
|
+
const w = acc.lease;
|
|
853
|
+
throw new Error(
|
|
854
|
+
`账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
return { account: fullView(acc) };
|
|
858
|
+
}
|
|
859
|
+
|
|
757
860
|
/** 内部:注入账号(测试/迁移工具用,跳过命令队列直接改状态) */
|
|
758
861
|
export function _accountsInternal() {
|
|
759
862
|
ensureLoaded();
|
package/src/watch/public/app.js
CHANGED
|
@@ -6044,7 +6044,14 @@ function renderProxySection(c, userId) {
|
|
|
6044
6044
|
if (wdSubsConfigured(userId).length > 0) {
|
|
6045
6045
|
return renderSubsOverview(c, userId, [], []);
|
|
6046
6046
|
}
|
|
6047
|
-
|
|
6047
|
+
var legacy = renderProxyLegacy(c, userId);
|
|
6048
|
+
if (legacy) return legacy;
|
|
6049
|
+
// 新节点引导(2026-10-28):无通道 + 未配置订阅 + daemon 未跑过 clash 时,原逻辑返回 "",
|
|
6050
|
+
// 后台看不到任何订阅配置入口——而 proxySettings[userId] 条目只有配置动作才会创建
|
|
6051
|
+
// (注册/心跳从不建,订阅分配器对无条目直接 return null),构成死环(F-11/IB-1 都踩过)。
|
|
6052
|
+
// 渲染最小引导行:配置弹窗对空条目天然兼容(手动列表 / 切自动分配均可用),
|
|
6053
|
+
// 建条目后客户端下个心跳拿到订阅并自动拉起 mihomo,本区块随之变为正常视图。
|
|
6054
|
+
return renderSubsBootstrap(userId);
|
|
6048
6055
|
}
|
|
6049
6056
|
|
|
6050
6057
|
/** 兼容旧版:仅主控制端(legacy clashHealth) */
|
|
@@ -6061,6 +6068,27 @@ function renderProxyLegacy(c, userId) {
|
|
|
6061
6068
|
);
|
|
6062
6069
|
}
|
|
6063
6070
|
|
|
6071
|
+
/** 新节点引导行:订阅完全未配置且 daemon 无 clash 数据时,仍保留配置入口(打破 UI 死环) */
|
|
6072
|
+
function renderSubsBootstrap(userId) {
|
|
6073
|
+
return styled(
|
|
6074
|
+
"div",
|
|
6075
|
+
"margin-top:6px;padding:6px 10px;background:#111;border-radius:6px;display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
|
|
6076
|
+
styled("span", "color:#888", "🛜 订阅配置:") +
|
|
6077
|
+
styled("span", "color:#6b7280", "未配置(保存后下个心跳自动下发并拉起 mihomo)") +
|
|
6078
|
+
tag(
|
|
6079
|
+
"button",
|
|
6080
|
+
{
|
|
6081
|
+
onclick: "wdShowProxyModal('" + esc(userId) + "')",
|
|
6082
|
+
style:
|
|
6083
|
+
"padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
|
|
6084
|
+
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
6085
|
+
title: "配置该客户端的代理订阅地址(保存后自动热应用,无需重启)",
|
|
6086
|
+
},
|
|
6087
|
+
"配置 ⚙️",
|
|
6088
|
+
),
|
|
6089
|
+
);
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6064
6092
|
/** 外部代理通道(Verge 等非内置实例):节点行 + 节点列表 + 自身订阅摘要 */
|
|
6065
6093
|
function renderExternalChannel(ch, userId, withConfig) {
|
|
6066
6094
|
if (!ch || !ch.node) return "";
|
package/src/watch/server.js
CHANGED
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
takeAccount,
|
|
21
21
|
acquireAccount,
|
|
22
22
|
importAccount,
|
|
23
|
+
reloginAccount,
|
|
24
|
+
prepareRelogin,
|
|
23
25
|
} from "./login-pool.js";
|
|
26
|
+
import { checkPoolAccounts } from "./login-health.js";
|
|
24
27
|
|
|
25
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
29
|
|
|
@@ -643,37 +646,52 @@ function serveLoginPoolPage(res) {
|
|
|
643
646
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
644
647
|
<title>账号中心 · 登录池</title>
|
|
645
648
|
<style>
|
|
646
|
-
body { font-family: system-ui, sans-serif; margin:
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
button
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
.
|
|
649
|
+
body { font-family: system-ui, sans-serif; margin: 0; background: #f3f4f6; color: #1f2329; }
|
|
650
|
+
.wrap { max-width: 1280px; margin: 0 auto; padding: 22px 24px 48px; }
|
|
651
|
+
h1 { font-size: 20px; margin: 0 0 2px; }
|
|
652
|
+
.sub { color: #9ca3af; font-size: 12px; margin-bottom: 14px; }
|
|
653
|
+
.bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
654
|
+
button { padding: 6px 14px; border: 1px solid #d0d3d9; border-radius: 8px; background: #fff; cursor: pointer; font-size: 13px; transition: all .15s; }
|
|
655
|
+
button:hover { background: #f0f2f5; border-color: #b8bdc7; }
|
|
656
|
+
button:disabled { color: #9ca3af; cursor: not-allowed; background: #f5f5f5; border-color: #e5e7eb; }
|
|
657
|
+
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; font-weight: 600; }
|
|
658
|
+
button.primary:hover:not(:disabled) { background: #1d4ed8; }
|
|
659
|
+
.chips { display: inline-flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
|
|
660
|
+
.chip { background: #fff; border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 12px; font-size: 12px; color: #6b7280; }
|
|
661
|
+
.chip b { color: #1f2329; }
|
|
662
|
+
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: auto; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
|
663
|
+
table { border-collapse: collapse; width: 100%; }
|
|
664
|
+
th, td { padding: 9px 12px; font-size: 13px; text-align: left; vertical-align: top; border-bottom: 1px solid #f0f1f3; }
|
|
665
|
+
thead th { background: #f9fafb; color: #6b7280; font-weight: 600; white-space: nowrap; border-bottom: 1px solid #e5e7eb; }
|
|
666
|
+
tbody tr:hover { background: #f8faff; }
|
|
667
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
668
|
+
.badge { display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|
656
669
|
.b-leased { background: #dbeafe; color: #2563eb; }
|
|
657
670
|
.b-pool { background: #dcfce7; color: #16a34a; }
|
|
658
671
|
.b-suspect { background: #fef3c7; color: #d97706; }
|
|
659
672
|
.b-retired { background: #e5e7eb; color: #6b7280; }
|
|
660
673
|
.muted { color: #9ca3af; font-size: 12px; }
|
|
661
674
|
.warn { color: #dc2626; }
|
|
662
|
-
#msg {
|
|
675
|
+
#msg { font-size: 13px; color: #16a34a; }
|
|
676
|
+
.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid #cbd5e1; border-top-color: #2563eb; border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; margin-right: 6px; }
|
|
677
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
663
678
|
details { margin-top: 2px; }
|
|
664
679
|
summary { cursor: pointer; }
|
|
680
|
+
td button { padding: 3px 10px; font-size: 12px; border-radius: 6px; margin: 1px 0; }
|
|
665
681
|
</style>
|
|
666
682
|
</head>
|
|
667
683
|
<body>
|
|
684
|
+
<div class="wrap">
|
|
668
685
|
<h1>账号中心 · 登录池</h1>
|
|
686
|
+
<div class="sub">登录态统一存放与租借:业务取号注入 / 节点启动导入 / 巡检与重登录</div>
|
|
669
687
|
<div class="bar">
|
|
670
688
|
<button onclick="load()">刷新</button>
|
|
671
|
-
<
|
|
672
|
-
<button onclick="
|
|
673
|
-
<button onclick="takeDlg()" title="调试:走命令队列的 take(watchdog 注入流)">模拟取号</button>
|
|
689
|
+
<button onclick="addCmdDlg()" title="生成一条命令,在任意装有 tt-help 的机器执行:弹出 Edge 人工登录 TikTok 后自动入池">新增账号</button>
|
|
690
|
+
<button id="btn-check" class="primary" onclick="checkNow()" title="逐号 HTTP 探测登录态(空闲+疑似),死号自动标疑似">批量检测</button>
|
|
674
691
|
<span id="msg"></span>
|
|
692
|
+
<span class="chips" id="stats"></span>
|
|
675
693
|
</div>
|
|
676
|
-
<div id="content">加载中…</div>
|
|
694
|
+
<div id="content" class="card">加载中…</div>
|
|
677
695
|
<script>
|
|
678
696
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\\'':'''}[c]));
|
|
679
697
|
const fmt = t => t ? new Date(t).toLocaleString('zh-CN', {hour12:false}) : '—';
|
|
@@ -715,6 +733,10 @@ async function load() {
|
|
|
715
733
|
const who = l
|
|
716
734
|
? esc(l.userId) + (l.port ? ':' + esc(l.port) : '') + (l.appName ? ' <span class="muted">· ' + esc(l.appName) + '</span>' : '')
|
|
717
735
|
: '<span class="muted">—</span>';
|
|
736
|
+
const health = a.lastHealth === 'alive' ? '<span style="color:#16a34a">✓</span>'
|
|
737
|
+
: a.lastHealth === 'dead' ? '<span class="warn">✗</span>'
|
|
738
|
+
: a.lastHealth === 'error' ? '<span class="muted">?</span>'
|
|
739
|
+
: '<span class="muted">—</span>';
|
|
718
740
|
const meta = [];
|
|
719
741
|
if (a.note) meta.push(esc(a.note));
|
|
720
742
|
if (a.proxyHint) meta.push('出口 ' + esc(a.proxyHint));
|
|
@@ -728,6 +750,7 @@ async function load() {
|
|
|
728
750
|
'<td title="最后使用: ' + fmt(a.lastUsedAt) + '\\n最后出活: ' + biz + '">' + rel(a.lastUsedAt) + '</td>' +
|
|
729
751
|
'<td>' + (a.useCount || 0) + '</td>' +
|
|
730
752
|
'<td' + (expired ? ' class="warn"' : '') + ' title="sessionid 到期: ' + fmt(a.sessionExpiresAt) + '(cookie 承诺寿命,非实时有效性)">' + day(a.sessionExpiresAt) + (expired ? '(已过)' : '') + '</td>' +
|
|
753
|
+
'<td title="上次检测: ' + fmt(a.lastHealthAt) + (a.lastHealth === 'dead' ? '\\n死号已标疑似,可重登录复活' : a.lastHealth === 'alive' ? '\\n登录态存活' : '') + '">' + health + ' <span class="muted">' + rel(a.lastHealthAt) + '</span></td>' +
|
|
731
754
|
'<td>' +
|
|
732
755
|
(a.status === 'leased' ? '<button onclick="releaseDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')">释放</button> ' : '') +
|
|
733
756
|
(a.status === 'pool' ? '<button onclick="assignDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')" title="手动指派给节点端口(watchdog 注入流)">指派</button> ' : '') +
|
|
@@ -735,7 +758,7 @@ async function load() {
|
|
|
735
758
|
(a.status === 'retired' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',false)">启用</button> ' : '') +
|
|
736
759
|
(a.status !== 'retired' ? '<button onclick="disable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',' + (a.status === 'leased') + ')">停用</button> ' : '') +
|
|
737
760
|
(a.status === 'retired' ? '<button onclick="del(\\'' + a.id + '\\')">删除</button> ' : '') +
|
|
738
|
-
'<button
|
|
761
|
+
'<button onclick="reloginDlg(\\'' + esc(a.username) + '\\')" title="生成重登录命令:本机执行后旧登录态自动复活(或人工登录),验证身份后覆盖刷新池内 cookies">重登录</button>' +
|
|
739
762
|
'</td>' +
|
|
740
763
|
'<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>' +
|
|
741
764
|
(a.history || []).slice(0, 8).map(h => '<div class="muted">' + fmt(h.at) + ' ' + esc(h.event) + ' ' + esc(h.detail) + '</div>').join('') + '</details></td>' +
|
|
@@ -743,27 +766,70 @@ async function load() {
|
|
|
743
766
|
}).join('');
|
|
744
767
|
const nLeased = j.accounts.filter(a => a.status === 'leased').length;
|
|
745
768
|
const nFree = j.accounts.filter(a => a.status === 'pool').length;
|
|
769
|
+
const nSus = j.accounts.filter(a => a.status === 'suspect').length;
|
|
770
|
+
const nRet = j.accounts.filter(a => a.status === 'retired').length;
|
|
771
|
+
document.getElementById('stats').innerHTML =
|
|
772
|
+
'<span class="chip">共 <b>' + j.total + '</b></span>' +
|
|
773
|
+
'<span class="chip">使用中 <b>' + nLeased + '</b></span>' +
|
|
774
|
+
'<span class="chip">空闲 <b>' + nFree + '</b></span>' +
|
|
775
|
+
'<span class="chip">疑似 <b>' + nSus + '</b></span>' +
|
|
776
|
+
'<span class="chip">已停用 <b>' + nRet + '</b></span>';
|
|
746
777
|
document.getElementById('content').innerHTML =
|
|
747
|
-
'<table><tr><th>#</th><th>账号</th><th>状态</th><th>使用者</th><th>最近使用</th><th>次数</th><th>有效期</th><th>操作</th><th>历史</th></tr>' + rows + '</table>' +
|
|
748
|
-
'<p class="muted"
|
|
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 明文不出服务端</p></div>';
|
|
749
780
|
} catch (e) { msg('加载失败: ' + e.message, true); }
|
|
750
781
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
try {
|
|
755
|
-
let cookies = JSON.parse(cookiesRaw);
|
|
756
|
-
if (cookies && !Array.isArray(cookies) && Array.isArray(cookies.cookies)) cookies = cookies.cookies;
|
|
757
|
-
const note = prompt('备注(可空,如来源 F-11:9223):');
|
|
758
|
-
await api('/api/login-pool/add', { username: username || undefined, cookies, note: note || undefined });
|
|
759
|
-
msg('入池成功'); load();
|
|
760
|
-
} catch (e) { msg('入池失败: ' + e.message, true); }
|
|
782
|
+
function copyCmd(cmd) {
|
|
783
|
+
if (navigator.clipboard) navigator.clipboard.writeText(cmd).catch(() => {});
|
|
784
|
+
prompt('复制命令(在装有 tt-help 的机器上执行):', cmd);
|
|
761
785
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
786
|
+
function addCmdDlg() {
|
|
787
|
+
copyCmd('tt-help login-pool add --server ' + location.origin);
|
|
788
|
+
msg('命令已生成:执行成功后点刷新查看新号');
|
|
789
|
+
}
|
|
790
|
+
function reloginDlg(name) {
|
|
791
|
+
copyCmd('tt-help login-pool relogin ' + name + ' --server ' + location.origin);
|
|
792
|
+
msg('重登录命令已生成(' + name + '):执行成功后点刷新');
|
|
793
|
+
}
|
|
794
|
+
function setProgress(t) {
|
|
795
|
+
const el = document.getElementById('msg');
|
|
796
|
+
el.innerHTML = '<span class="spin"></span>' + esc(t);
|
|
797
|
+
el.style.color = '#2563eb';
|
|
798
|
+
}
|
|
799
|
+
let checking = false;
|
|
800
|
+
async function checkNow() {
|
|
801
|
+
if (checking) return;
|
|
802
|
+
let targets;
|
|
803
|
+
try {
|
|
804
|
+
const j = await api('/api/login-pool');
|
|
805
|
+
targets = j.accounts.filter(a => a.status === 'pool' || a.status === 'suspect');
|
|
806
|
+
} catch (e) { msg('加载失败: ' + e.message, true); return; }
|
|
807
|
+
if (!targets.length) { msg('没有待检测账号(空闲+疑似为空,使用中的由业务自验)'); return; }
|
|
808
|
+
if (!confirm('批量检测 ' + targets.length + ' 个账号的登录态?(死号自动标疑似)')) return;
|
|
809
|
+
checking = true;
|
|
810
|
+
const btn = document.getElementById('btn-check');
|
|
811
|
+
btn.disabled = true; btn.textContent = '检测中…';
|
|
812
|
+
let done = 0, alive = 0, dead = 0, err = 0, mig = 0;
|
|
813
|
+
try {
|
|
814
|
+
for (const a of targets) {
|
|
815
|
+
setProgress('巡检中 ' + (done + 1) + '/' + targets.length + ' · 正在检测 @' + a.username + ' …');
|
|
816
|
+
let health = 'error';
|
|
817
|
+
try {
|
|
818
|
+
const r = await api('/api/login-pool/check', { ids: [a.id] });
|
|
819
|
+
const x = r.results && r.results[0];
|
|
820
|
+
if (x) { health = x.health; if (x.migrated) mig++; }
|
|
821
|
+
} catch (e) { health = 'error'; }
|
|
822
|
+
if (health === 'alive') alive++; else if (health === 'dead') dead++; else err++;
|
|
823
|
+
done++;
|
|
824
|
+
setProgress('巡检中 ' + done + '/' + targets.length + ' · 活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err);
|
|
825
|
+
load();
|
|
826
|
+
if (done < targets.length) await new Promise(r => setTimeout(r, 300));
|
|
827
|
+
}
|
|
828
|
+
msg('巡检完成: 探测 ' + done + ',活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err + (mig ? ',死号标疑似 ' + mig + ' 个' : ''));
|
|
829
|
+
} finally {
|
|
830
|
+
checking = false;
|
|
831
|
+
btn.disabled = false; btn.textContent = '批量检测';
|
|
832
|
+
}
|
|
767
833
|
}
|
|
768
834
|
async function assignDlg(id, name) {
|
|
769
835
|
const userId = prompt('指派给节点 userId(如 F-11):'); if (!userId) return;
|
|
@@ -795,8 +861,9 @@ async function del(id) {
|
|
|
795
861
|
catch (e) { msg('失败: ' + e.message, true); }
|
|
796
862
|
}
|
|
797
863
|
load();
|
|
798
|
-
setInterval(() =>
|
|
864
|
+
setInterval(() => load(), 15000);
|
|
799
865
|
</script>
|
|
866
|
+
</div>
|
|
800
867
|
</body>
|
|
801
868
|
</html>`;
|
|
802
869
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -1616,6 +1683,28 @@ export function startWatchServer(
|
|
|
1616
1683
|
);
|
|
1617
1684
|
sendJSON(res, 200, { ok: true, dedup: imp.dedup, account: ret });
|
|
1618
1685
|
return;
|
|
1686
|
+
} else if (lpAction === "relogin") {
|
|
1687
|
+
// 重登录覆盖刷新(以号为主键):目标必须已在池;使用中拒绝;retired 复活、suspect 回池
|
|
1688
|
+
const rel = reloginAccount(body);
|
|
1689
|
+
ret = rel.account;
|
|
1690
|
+
console.error(
|
|
1691
|
+
`[登录池 ${ts}] RELOGIN: ${ret.username} ← ${body.userId}${rel.revived ? "(复活)" : rel.wasSuspect ? "(疑似→池)" : ""} (${ret.cookieCount} 条 cookies)`,
|
|
1692
|
+
);
|
|
1693
|
+
sendJSON(res, 200, { ok: true, revived: rel.revived, wasSuspect: rel.wasSuspect, account: ret });
|
|
1694
|
+
return;
|
|
1695
|
+
} else if (lpAction === "prepare") {
|
|
1696
|
+
// 重登录预备:返回目标账号明文 cookies(relogin 命令注入旧登录态用;与 acquire 同级信任)
|
|
1697
|
+
const prep = prepareRelogin(body);
|
|
1698
|
+
sendJSON(res, 200, { ok: true, account: prep.account });
|
|
1699
|
+
return;
|
|
1700
|
+
} else if (lpAction === "check") {
|
|
1701
|
+
// 巡检:无浏览器纯 HTTP 探测登录态(默认 pool+suspect,指定 ids 精确取)
|
|
1702
|
+
const sum = await checkPoolAccounts({ ids: body.ids, proxy: body.proxy });
|
|
1703
|
+
console.error(
|
|
1704
|
+
`[登录池 ${ts}] CHECK: 探测 ${sum.checked},alive ${sum.alive} / dead ${sum.dead} / error ${sum.error},migrated ${sum.migrated}`,
|
|
1705
|
+
);
|
|
1706
|
+
sendJSON(res, 200, { ok: true, ...sum });
|
|
1707
|
+
return;
|
|
1619
1708
|
} else if (lpAction === "assign") {
|
|
1620
1709
|
ret = assignAccount(body);
|
|
1621
1710
|
console.error(`[登录池 ${ts}] ASSIGN: ${ret.username} → ${body.userId}:${body.port}`);
|