tt-help-cli-ycl 1.4.175 → 1.4.177
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-node.sh +48 -24
- package/scripts/test-login-import-skip.mjs +52 -0
- package/scripts/test-login-pool.mjs +1 -1
- package/src/watch/login-pool.js +8 -2
- package/src/watch/public/app.js +3 -38
- package/src/watch/public/index.html +2 -2
- package/src/watchdog/login-import.js +44 -26
package/package.json
CHANGED
package/scripts/deploy-node.sh
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# 将本地 tiktok-cli
|
|
2
|
+
# 将本地 tiktok-cli 打包部署到采集节点(不经 npm registry)—— npm publish 前真机测试标准通道
|
|
3
3
|
# 用法:
|
|
4
|
-
# ./scripts/deploy-node.sh
|
|
5
|
-
# ./scripts/deploy-node.sh tiktok-210
|
|
6
|
-
# ./scripts/deploy-node.sh
|
|
7
|
-
#
|
|
8
|
-
# 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)
|
|
9
|
-
# 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210
|
|
4
|
+
# ./scripts/deploy-node.sh <ssh别名> [--linux] [--bump] [--restart]
|
|
5
|
+
# ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验(Windows 节点)
|
|
6
|
+
# ./scripts/deploy-node.sh f11 --linux --restart # Linux 节点(如 F-11),重启 systemd watchdog
|
|
7
|
+
# ./scripts/deploy-node.sh tiktok-210 --bump --restart # 先把 package.json 版本号 +1 再打包,安装后重启业务进程
|
|
8
|
+
# 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启
|
|
9
|
+
# 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210/f11,经跳板机或直连),BatchMode 免密可用
|
|
10
10
|
# 说明:
|
|
11
|
+
# - 测试包【不要加 --bump】:同版本号覆盖安装,autoUpgrade 视为最新不动作;测完用官方包还原
|
|
12
|
+
# (带新版本号的测试包会让节点永远认为「已是最新」,发布同号正式版时再也升不上去)
|
|
13
|
+
# - Linux 重启前提:节点 /etc/sudoers.d 已放行 NOPASSWD systemctl restart tt-watchdog
|
|
11
14
|
# - 无需停进程再安装:采集客户端不加载 better-sqlite3(只有 videostats/watch 服务端用),无文件锁
|
|
12
15
|
# - watchdog 自升级:npm 覆盖安装后守护进程经 upgrader.js 自动重启,受管命令随后重建
|
|
13
16
|
# - 节点上保留 tgz 安装包,回滚时 npm install -g 旧版 tgz 即可
|
|
14
17
|
set -euo pipefail
|
|
15
18
|
|
|
16
|
-
ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--bump] [--restart]}"
|
|
19
|
+
ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--linux] [--bump] [--restart]}"
|
|
17
20
|
shift || true
|
|
18
21
|
DO_BUMP=0
|
|
19
22
|
DO_RESTART=0
|
|
23
|
+
DO_LINUX=0
|
|
20
24
|
for a in "$@"; do
|
|
21
25
|
case "$a" in
|
|
22
26
|
--bump) DO_BUMP=1 ;;
|
|
23
27
|
--restart) DO_RESTART=1 ;;
|
|
28
|
+
--linux) DO_LINUX=1 ;;
|
|
24
29
|
*) echo "未知参数: $a"; exit 1 ;;
|
|
25
30
|
esac
|
|
26
31
|
done
|
|
@@ -49,22 +54,33 @@ TGZ=$(npm pack 2>/dev/null | tail -1)
|
|
|
49
54
|
echo "包: $TGZ ($(du -h "$TGZ" | cut -f1))"
|
|
50
55
|
|
|
51
56
|
echo "==> scp → $ALIAS"
|
|
57
|
+
# grep 对空输出返回 1,pipefail 下会误杀脚本;scp 真实成败由远程版本校验兕底
|
|
52
58
|
scp -o ConnectTimeout=25 -o BatchMode=yes "$TGZ" "$ALIAS:$TGZ" 2>&1 |
|
|
53
|
-
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null
|
|
54
|
-
echo "已传输到
|
|
59
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null || true
|
|
60
|
+
echo "已传输到 ~/$TGZ"
|
|
55
61
|
|
|
56
62
|
echo "==> 远程 npm install -g"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
if [ "$DO_LINUX" = 1 ]; then
|
|
64
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
|
|
65
|
+
"npm install -g \$HOME/$TGZ 2>&1" |
|
|
66
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|^Warning|Permanently added' |
|
|
67
|
+
tail -3
|
|
68
|
+
else
|
|
69
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
|
|
70
|
+
"npm install -g %USERPROFILE%\\$TGZ 2>&1" |
|
|
71
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
|
|
72
|
+
tail -3
|
|
73
|
+
fi
|
|
61
74
|
|
|
62
75
|
echo "==> 校验远程版本"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
tail -1 | tr -d '\r'
|
|
67
|
-
|
|
76
|
+
VER_FILTER='post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added'
|
|
77
|
+
if [ "$DO_LINUX" = 1 ]; then
|
|
78
|
+
REMOTE_VER=$(ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>/dev/null" 2>/dev/null |
|
|
79
|
+
grep -vE "$VER_FILTER" | tail -1 | tr -d '\r')
|
|
80
|
+
else
|
|
81
|
+
REMOTE_VER=$(ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
|
|
82
|
+
grep -vE "$VER_FILTER" | tail -1 | tr -d '\r')
|
|
83
|
+
fi
|
|
68
84
|
if [ "$REMOTE_VER" = "$VER" ]; then
|
|
69
85
|
echo "✓ 远程版本 $REMOTE_VER"
|
|
70
86
|
else
|
|
@@ -73,11 +89,19 @@ else
|
|
|
73
89
|
fi
|
|
74
90
|
|
|
75
91
|
if [ "$DO_RESTART" = 1 ]; then
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
if [ "$DO_LINUX" = 1 ]; then
|
|
93
|
+
echo "==> 重启 watchdog(systemd)"
|
|
94
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
|
|
95
|
+
"sudo -n systemctl restart tt-watchdog && sleep 2 && systemctl is-active tt-watchdog" 2>&1 |
|
|
96
|
+
grep -vE 'post-quantum|openssh.com/pq|Permanently added' | tail -1
|
|
97
|
+
else
|
|
98
|
+
echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
|
|
99
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
|
|
100
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
|
|
101
|
+
head -10 || true
|
|
102
|
+
echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
|
|
103
|
+
echo "(如需重启 watchdog 本体:杀 PID + schtasks /run \tt-watchdog)"
|
|
104
|
+
fi
|
|
81
105
|
fi
|
|
82
106
|
|
|
83
107
|
echo "==> 完成"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 登录导入跳过逻辑单元测试 — syncLoginStates 本地 marker 永久跳过 + fetch 省略
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* 1. 全部端口在 marker → 不发中心 fetch(不可达服务端也不报错)→ 全部跳过「本地已导入」
|
|
6
|
+
* 2. 部分端口在 marker → marker 口跳过,未导入口因总超时放弃(不起 Edge)
|
|
7
|
+
*
|
|
8
|
+
* 跑法: HOME=$(mktemp -d) node scripts/test-login-import-skip.mjs
|
|
9
|
+
*/
|
|
10
|
+
import assert from "assert";
|
|
11
|
+
import { writeFileSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
|
|
14
|
+
const HOME = process.env.HOME;
|
|
15
|
+
if (!HOME || HOME === "/") {
|
|
16
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-import-skip.mjs");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { syncLoginStates } = await import("../src/watchdog/login-import.js");
|
|
21
|
+
|
|
22
|
+
let pass = 0, fail = 0;
|
|
23
|
+
const check = (name, cond, extra = "") => {
|
|
24
|
+
if (cond) { pass++; console.log(` ✓ ${name}`); }
|
|
25
|
+
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// 预置 marker:9222/9223 已导入过
|
|
29
|
+
writeFileSync(join(HOME, ".tt-help-watchdog-login-imported.json"), JSON.stringify({ 9222: "userA", 9223: "userB" }));
|
|
30
|
+
|
|
31
|
+
// 不可达服务端:若代码仍尝试 fetch,会等最多 15s 超时(用耗时断言识别)
|
|
32
|
+
const UNREACHABLE = "http://127.0.0.1:1";
|
|
33
|
+
|
|
34
|
+
console.log("\n[1] 全口 marker:零 fetch、全跳过");
|
|
35
|
+
{
|
|
36
|
+
const t0 = Date.now();
|
|
37
|
+
const s = await syncLoginStates({ serverUrl: UNREACHABLE, userId: "T", ports: [9222, 9223], portTimeoutMs: 1000, totalTimeoutMs: 60000 });
|
|
38
|
+
check("两口全部 skipped", s.skipped.length === 2 && s.imported.length === 0 && s.failed.length === 0, JSON.stringify(s));
|
|
39
|
+
check("原因=本地已导入", s.skipped.every((x) => x.reason === "本地已导入"), JSON.stringify(s.skipped));
|
|
40
|
+
check("未发中心 fetch(<2s,fetch 会撞 15s 级等待)", Date.now() - t0 < 2000, `${Date.now() - t0}ms`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log("\n[2] 混合:marker 口跳过,新口进不了 Edge(总超时拦截)");
|
|
44
|
+
{
|
|
45
|
+
const s = await syncLoginStates({ serverUrl: UNREACHABLE, userId: "T", ports: [9222, 9444], portTimeoutMs: 1000, totalTimeoutMs: 0 });
|
|
46
|
+
const sk = s.skipped.find((x) => x.port === 9222);
|
|
47
|
+
check("marker 口 9222 跳过", sk && sk.reason === "本地已导入", JSON.stringify(s));
|
|
48
|
+
check("新口 9444 未起 Edge(总超时放弃)", s.failed.some((x) => x.port === 9444 && x.reason === "总超时"), JSON.stringify(s.failed));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`\n结果: ${pass} 过 / ${fail} 挂`);
|
|
52
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -370,7 +370,7 @@ console.log("\n[8] 登录态导入 import");
|
|
|
370
370
|
const imp2 = importAccount({ userId: "F-8", port: 9301, username: "userD", cookies: [{ name: "sessionid", value: "OTHER-SESSION" }], note: "另机重复会话" });
|
|
371
371
|
check("重复导入幂等(dedup=true)", imp2.dedup === true);
|
|
372
372
|
check("不覆盖池内 cookies", !JSON.stringify(imp2.account).includes("OTHER-SESSION"));
|
|
373
|
-
check("
|
|
373
|
+
check("幂等补写来源元数据(最近导入者,供节点热路径跳过)", imp2.account.lastImportFrom.userId === "F-8" && imp2.account.lastImportFrom.port === 9301);
|
|
374
374
|
|
|
375
375
|
let threw = false;
|
|
376
376
|
try { importAccount({ userId: "F-1", username: "x" }); } catch (e) { threw = true; check("缺 cookies 拒绝", /cookies/.test(e.message)); }
|
package/src/watch/login-pool.js
CHANGED
|
@@ -723,7 +723,10 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
|
|
|
723
723
|
|
|
724
724
|
const dup = _accounts.find((a) => a.username === username && a.status !== "retired");
|
|
725
725
|
if (dup) {
|
|
726
|
-
// 幂等:不覆盖 cookies
|
|
726
|
+
// 幂等:不覆盖 cookies、不动租约;仅补写来源元数据,
|
|
727
|
+
// 否则已在池的号永远不带 lastImportFrom,节点热路径匹配不上会每次启动全量重导
|
|
728
|
+
dup.lastImportFrom = importMeta(userId, portNum, t);
|
|
729
|
+
scheduleSave();
|
|
727
730
|
return { account: publicView(dup), dedup: true };
|
|
728
731
|
}
|
|
729
732
|
if (_accounts.length >= POOL_MAX) throw new Error(`池已满(上限 ${POOL_MAX})`);
|
|
@@ -737,7 +740,7 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
|
|
|
737
740
|
status: "pool",
|
|
738
741
|
lease: null,
|
|
739
742
|
useCount: 0, // 导入是登记不是消费;次数只在 acquire/assign 时 +1
|
|
740
|
-
lastImportFrom:
|
|
743
|
+
lastImportFrom: importMeta(userId, portNum, t),
|
|
741
744
|
history: [],
|
|
742
745
|
createdAt: t,
|
|
743
746
|
updatedAt: t,
|
|
@@ -748,6 +751,9 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
|
|
|
748
751
|
return { account: fullView(acc), dedup: false };
|
|
749
752
|
}
|
|
750
753
|
|
|
754
|
+
/** 来源元数据(供节点热路径跳过 + 账号中心展示),非租约;新入池与幂等补写共用此定义 */
|
|
755
|
+
const importMeta = (userId, portNum, at) => ({ userId, port: portNum, at });
|
|
756
|
+
|
|
751
757
|
/** 内部:注入账号(测试/迁移工具用,跳过命令队列直接改状态) */
|
|
752
758
|
export function _accountsInternal() {
|
|
753
759
|
ensureLoaded();
|
package/src/watch/public/app.js
CHANGED
|
@@ -5563,33 +5563,16 @@ function renderLoginSection(c, userId, loginPorts) {
|
|
|
5563
5563
|
"padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
|
|
5564
5564
|
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
5565
5565
|
title:
|
|
5566
|
-
"
|
|
5566
|
+
"配置启动时导入登录态的 CDP 端口(逗号分隔,留空清空)\n当前: " +
|
|
5567
5567
|
(configured.length ? configured.join(",") : "无"),
|
|
5568
5568
|
},
|
|
5569
5569
|
(configured.length ? configured.length + "端口" : "未配置") + " ⚙️",
|
|
5570
5570
|
);
|
|
5571
5571
|
|
|
5572
|
-
// 重检按钮:仅已配置端口的在线客户端显示(手动触发立即检测,结果随心跳自动刷新)
|
|
5573
|
-
var recheckBtn = "";
|
|
5574
|
-
if (configured.length && c.clientId) {
|
|
5575
|
-
recheckBtn = tag(
|
|
5576
|
-
"button",
|
|
5577
|
-
{
|
|
5578
|
-
onclick: "wdRecheckLogin('" + esc(c.clientId) + "')",
|
|
5579
|
-
style:
|
|
5580
|
-
"padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
|
|
5581
|
-
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
5582
|
-
title: "立即重检上次未通过的端口(全部已登录则跳过;结果随心跳自动刷新)",
|
|
5583
|
-
},
|
|
5584
|
-
"重检",
|
|
5585
|
-
);
|
|
5586
|
-
}
|
|
5587
|
-
|
|
5588
5572
|
var head =
|
|
5589
5573
|
styled("span", "font-size:14px", "🔑") +
|
|
5590
|
-
styled("span", "color:#888", "
|
|
5591
|
-
cfgBtn
|
|
5592
|
-
recheckBtn;
|
|
5574
|
+
styled("span", "color:#888", "导入端口:") +
|
|
5575
|
+
cfgBtn;
|
|
5593
5576
|
|
|
5594
5577
|
var wrapStyle =
|
|
5595
5578
|
"margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
|
|
@@ -5772,24 +5755,6 @@ async function wdSetLoginPorts() {
|
|
|
5772
5755
|
}
|
|
5773
5756
|
}
|
|
5774
5757
|
|
|
5775
|
-
// 手动下发登录重检:服务端一次性队列随心跳通知客户端,下一跳立即检测
|
|
5776
|
-
async function wdRecheckLogin(clientId) {
|
|
5777
|
-
try {
|
|
5778
|
-
var res = await fetch("/api/watchdog/recheck-login", {
|
|
5779
|
-
method: "POST",
|
|
5780
|
-
headers: { "Content-Type": "application/json" },
|
|
5781
|
-
body: JSON.stringify({ clientId: clientId }),
|
|
5782
|
-
});
|
|
5783
|
-
var d = await res.json();
|
|
5784
|
-
if (d.ok) {
|
|
5785
|
-
alert("已下发重检,结果随心跳自动刷新");
|
|
5786
|
-
} else {
|
|
5787
|
-
alert("失败: " + (d.error || "未知错误"));
|
|
5788
|
-
}
|
|
5789
|
-
} catch (e) {
|
|
5790
|
-
alert("下发失败: " + e.message);
|
|
5791
|
-
}
|
|
5792
|
-
}
|
|
5793
5758
|
|
|
5794
5759
|
// ── 错误自动切节点配置区域(看门狗面板中每个客户端卡片) ──
|
|
5795
5760
|
|
|
@@ -988,9 +988,9 @@
|
|
|
988
988
|
<div id="wdLoginPortsModal"
|
|
989
989
|
style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
|
|
990
990
|
<div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:480px">
|
|
991
|
-
<div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑
|
|
991
|
+
<div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑 导入端口 — <span id="wdLoginPortsUserId"></span>
|
|
992
992
|
</div>
|
|
993
|
-
<div style="color:#888;font-size:12px;margin-bottom:12px"
|
|
993
|
+
<div style="color:#888;font-size:12px;margin-bottom:12px">配置该客户端启动时导入登录态的 CDP 端口(逗号分隔,如 <b
|
|
994
994
|
style="color:#4ade80">9222,9223,9225</b>)。<br>客户端每隔一段时间自动检测并上报结果,留空保存可清空配置。</div>
|
|
995
995
|
<input id="wdLoginPortsInput" placeholder="9222,9223,9225"
|
|
996
996
|
style="width:100%;padding:8px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:6px;font-size:13px;box-sizing:border-box">
|
|
@@ -50,7 +50,7 @@ function withTimeout(promise, ms, label) {
|
|
|
50
50
|
]);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/** 单口导入:起/附 Edge → 验证 → 导出 cookies → 上传。未登录返回 skip */
|
|
53
|
+
/** 单口导入:起/附 Edge → 验证 → 导出 cookies → 上传。未登录返回 skip;不负责 marker(由 syncLoginStates 统一记账) */
|
|
54
54
|
async function importPort({ serverUrl, userId, port, proxyServer }) {
|
|
55
55
|
// 探测该口是否已有 Edge 在跑:同步自己起的,导出完关掉;本来就跑着的不动
|
|
56
56
|
let selfStarted = false;
|
|
@@ -91,9 +91,6 @@ async function importPort({ serverUrl, userId, port, proxyServer }) {
|
|
|
91
91
|
);
|
|
92
92
|
const j = await res.json().catch(() => null);
|
|
93
93
|
if (!res.ok || !j || j.error) throw new Error((j && j.error) || `HTTP ${res.status}`);
|
|
94
|
-
// 成功(含 dedup:中心已有即视为已同步)→ 写本地标识,永久只导一次
|
|
95
|
-
marker[port] = j.account.username;
|
|
96
|
-
saveMarker(marker);
|
|
97
94
|
return { ok: true, dedup: !!j.dedup, username: j.account.username, status: j.account.status };
|
|
98
95
|
} finally {
|
|
99
96
|
await page.close().catch(() => {}); // 只关自建页,不清光页面(POC 坑②)
|
|
@@ -106,6 +103,10 @@ async function importPort({ serverUrl, userId, port, proxyServer }) {
|
|
|
106
103
|
|
|
107
104
|
/**
|
|
108
105
|
* 同步入口:逐口导入本节点登录态。
|
|
106
|
+
* 跳过策略(按优先级,零成本跳过不受总超时影响):
|
|
107
|
+
* 1. 服务端 lastImportFrom 认领(本节点+本口导入过)→ 免重复起 Edge
|
|
108
|
+
* 2. 本地 marker(成功导入过 = 永久跳过,与账号中心状态无关)
|
|
109
|
+
* marker 的读/判/写全部收拢在本函数(importPort 只做单口机构)。
|
|
109
110
|
* @param {object} opts { serverUrl, userId, ports[], proxyServer?, portTimeoutMs?, totalTimeoutMs? }
|
|
110
111
|
* @returns {Promise<{imported: Array, skipped: Array, failed: Array}>}
|
|
111
112
|
*/
|
|
@@ -122,36 +123,23 @@ export async function syncLoginStates(opts = {}) {
|
|
|
122
123
|
if (!serverUrl || !userId || !Array.isArray(ports) || ports.length === 0) return summary;
|
|
123
124
|
const deadline = Date.now() + totalTimeoutMs;
|
|
124
125
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
try {
|
|
129
|
-
const r = await fetch(`${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`, {
|
|
130
|
-
signal: AbortSignal.timeout(15000),
|
|
131
|
-
});
|
|
132
|
-
const j = await r.json();
|
|
133
|
-
for (const a of j.accounts || []) {
|
|
134
|
-
const m = a.lastImportFrom;
|
|
135
|
-
if (m && m.userId === userId && ports.includes(Number(m.port))) {
|
|
136
|
-
imported.add(Number(m.port));
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
} catch (e) {
|
|
140
|
-
console.error(`[登录导入] 中心视图获取失败(继续逐口尝试): ${e.message}`);
|
|
141
|
-
}
|
|
126
|
+
// 本地标识:成功导入过一次即永久跳过
|
|
127
|
+
const marker = loadMarker();
|
|
128
|
+
const serverConfirmed = await serverImportedPorts({ serverUrl, userId, ports, marker });
|
|
142
129
|
|
|
143
130
|
for (const port of ports) {
|
|
144
131
|
const p = Number(port);
|
|
132
|
+
const reason = skipReason(p, marker, serverConfirmed);
|
|
133
|
+
if (reason) {
|
|
134
|
+
summary.skipped.push({ port: p, reason });
|
|
135
|
+
console.error(`[登录导入] 端口 ${p}: ${reason},跳过`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
145
138
|
if (Date.now() > deadline) {
|
|
146
139
|
summary.failed.push({ port: p, reason: "总超时" });
|
|
147
140
|
console.error(`[登录导入] 端口 ${p}: 总超时,剩余端口放弃`);
|
|
148
141
|
continue;
|
|
149
142
|
}
|
|
150
|
-
if (imported.has(p)) {
|
|
151
|
-
summary.skipped.push({ port: p, reason: "本节点此前已导入" });
|
|
152
|
-
console.error(`[登录导入] 端口 ${p}: 此前已导入,跳过`);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
143
|
console.error(`[登录导入] 端口 ${p}: 检测并导入...`);
|
|
156
144
|
try {
|
|
157
145
|
const r = await withTimeout(
|
|
@@ -160,6 +148,8 @@ export async function syncLoginStates(opts = {}) {
|
|
|
160
148
|
`端口 ${p} 导入`,
|
|
161
149
|
);
|
|
162
150
|
if (r.ok) {
|
|
151
|
+
marker[p] = r.username; // 成功(含 dedup:中心已有即视为已同步)→ 写本地标识,永久只导一次
|
|
152
|
+
saveMarker(marker);
|
|
163
153
|
summary.imported.push({ port: p, username: r.username, dedup: r.dedup });
|
|
164
154
|
console.error(
|
|
165
155
|
`[登录导入] 端口 ${p}: @${r.username} ${r.dedup ? "已在池,幂等跳过" : "新入池(空闲)✓"}`,
|
|
@@ -175,3 +165,31 @@ export async function syncLoginStates(opts = {}) {
|
|
|
175
165
|
}
|
|
176
166
|
return summary;
|
|
177
167
|
}
|
|
168
|
+
|
|
169
|
+
/** 服务端认领:中心 lastImportFrom 指向本节点本口的端口集合;全口都有本地 marker 时不发 fetch */
|
|
170
|
+
async function serverImportedPorts({ serverUrl, userId, ports, marker }) {
|
|
171
|
+
const confirmed = new Set();
|
|
172
|
+
if (ports.every((x) => marker[Number(x)])) return confirmed; // 本地标识已全覆盖,无需问中心
|
|
173
|
+
try {
|
|
174
|
+
const r = await fetch(`${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`, {
|
|
175
|
+
signal: AbortSignal.timeout(15000),
|
|
176
|
+
});
|
|
177
|
+
const j = await r.json();
|
|
178
|
+
for (const a of j.accounts || []) {
|
|
179
|
+
const m = a.lastImportFrom;
|
|
180
|
+
if (m && m.userId === userId && ports.includes(Number(m.port))) {
|
|
181
|
+
confirmed.add(Number(m.port));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error(`[登录导入] 中心视图获取失败(继续逐口尝试): ${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
return confirmed;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 跳过判定:返回原因字符串(跳过)或 null(需导入) */
|
|
191
|
+
function skipReason(p, marker, serverConfirmed) {
|
|
192
|
+
if (serverConfirmed.has(p)) return "本节点此前已导入";
|
|
193
|
+
if (marker[p]) return "本地已导入";
|
|
194
|
+
return null;
|
|
195
|
+
}
|