tt-help-cli-ycl 1.4.179 → 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/scripts/test-login-pool-ui.mjs +1 -1
- package/scripts/test-protocol-bridge.mjs +39 -8
- package/scripts/verify-protocol-quote.mjs +35 -0
- package/src/cli/explore.js +34 -9
- package/src/cli/login-pool.js +78 -30
- package/src/cli/protocol-bridge.js +3 -3
- package/src/lib/args.js +7 -2
- package/src/lib/constants.js +2 -2
- package/src/lib/login-pool-client.js +8 -3
- package/src/watch/login-pool.js +100 -18
- package/src/watch/server.js +61 -13
- package/src/watch/data-store.js.bak +0 -5091
- package/src/watch/data-store.js.bak2 +0 -5019
package/package.json
CHANGED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Linux 节点零→一部署(tiktok watchdog 集群)—— 给 ssh 地址+账号+密码即可部署一台新 Linux 节点
|
|
3
|
+
# SOP 文档(铁律 T1~T6 / 手工核对单): ~/knowledge/topics/linux-node-deploy.md
|
|
4
|
+
# 用法:
|
|
5
|
+
# DEPLOY_PW='<密码>' bash deploy-linux-node.sh --host <IP> --user <user> --node-id <F-13> [选项]
|
|
6
|
+
# 选项:
|
|
7
|
+
# --port N ssh 端口(默认 22)
|
|
8
|
+
# --public 节点在集群内网之外(server 用公网入口 117.71.53.99:17301,如 instabox 沙箱)
|
|
9
|
+
# --server URL 显式指定 watch 服务端(默认内网 http://172.18.154.201:3001)
|
|
10
|
+
# --pubkey FILE 部署公钥到节点(默认 ~/.ssh/id_ed25519.pub,替换式部署+备份;--no-pubkey 跳过)
|
|
11
|
+
# --append-pubkey 追加公钥而非替换(默认替换并备份 authorized_keys)
|
|
12
|
+
# --dry-run 只读体检:连通性/sudo/已装组件/属主/配置/服务状态,不做任何变更
|
|
13
|
+
# --timeout SEC 整体超时(默认 2400s,国内源 Edge 大包 ~15min)
|
|
14
|
+
# 行为与安全:
|
|
15
|
+
# - 幂等:已存在的 ~/.tt-help.json 只合并不覆盖、已有 systemd 单元不动、已有 /MAINTENANCE.md 不动
|
|
16
|
+
# - 远程脚本以 root 后台跑(sudo -S 喂一次密码),日志 /tmp/deploy-linux-node.log,结束打 DEPLOY_DONE/DEPLOY_FAILED
|
|
17
|
+
# - 密码只经 stdin/环境变量传递,不落日志不落脚本
|
|
18
|
+
# - 部署完成后订阅分配、业务命令 = 用户后台决定(agent 红线 8,脚本不碰)
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
HOST="" ; PORT=22 ; SSH_USER="" ; NODE_ID="" ; SERVER="" ; PUBKEY="$HOME/.ssh/id_ed25519.pub"
|
|
22
|
+
PUBKEY_MODE="replace" ; DRY=0 ; TIMEOUT=2400 ; NO_PUBKEY=0 ; PW="${DEPLOY_PW:-}"
|
|
23
|
+
|
|
24
|
+
usage() { grep '^# \|^# 用法' "$0" | sed 's/^# \{0,1\}//'; exit 1; }
|
|
25
|
+
die() { echo "✗ $*" >&2; exit 1; }
|
|
26
|
+
|
|
27
|
+
while [ $# -gt 0 ]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--host) HOST="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;;
|
|
30
|
+
--user) SSH_USER="$2"; shift 2 ;; --node-id) NODE_ID="$2"; shift 2 ;;
|
|
31
|
+
--public) SERVER="http://117.71.53.99:17301"; shift ;;
|
|
32
|
+
--server) SERVER="$2"; shift 2 ;;
|
|
33
|
+
--pubkey) PUBKEY="$2"; shift 2 ;; --no-pubkey) NO_PUBKEY=1; shift ;;
|
|
34
|
+
--append-pubkey) PUBKEY_MODE="append"; shift ;;
|
|
35
|
+
--dry-run) DRY=1; shift ;; --timeout) TIMEOUT="$2"; shift 2 ;;
|
|
36
|
+
-h|--help) usage ;; *) die "未知参数: $1(-h 看用法)" ;;
|
|
37
|
+
esac
|
|
38
|
+
done
|
|
39
|
+
[ -n "$HOST" ] && [ -n "$SSH_USER" ] && [ -n "$NODE_ID" ] || { echo "缺 --host/--user/--node-id"; usage; }
|
|
40
|
+
[ -n "$SERVER" ] || SERVER="http://172.18.154.201:3001"
|
|
41
|
+
[ -n "$PW" ] || die "密码未设置:用 DEPLOY_PW 环境变量传入(不进命令行历史)"
|
|
42
|
+
command -v sshpass >/dev/null || die "本机缺 sshpass(ubuntu26 已装;其他机器 sudo apt install sshpass)"
|
|
43
|
+
[ -f "$PUBKEY" ] || { [ $NO_PUBKEY -eq 1 ] || die "公钥文件不存在: $PUBKEY(或加 --no-pubkey 跳过)"; }
|
|
44
|
+
|
|
45
|
+
SSH_OPTS=(-p "$PORT" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10)
|
|
46
|
+
RLOG=/tmp/deploy-linux-node.log ; RSCRIPT=/tmp/deploy-linux-node-remote.sh
|
|
47
|
+
|
|
48
|
+
ssh_run() { sshpass -e ssh "${SSH_OPTS[@]}" "$SSH_USER@$HOST" "$@"; }
|
|
49
|
+
export SSHPASS="$PW"
|
|
50
|
+
|
|
51
|
+
echo "== [0/9] 连通性与 sudo 探测 =="
|
|
52
|
+
HOME_DIR=$(ssh_run 'echo $HOME') || die "ssh 连不上 $HOST:$PORT"
|
|
53
|
+
HN=$(ssh_run 'hostname') || die "取 hostname 失败"
|
|
54
|
+
CLIENT_ID="${NODE_ID}@${HN}"
|
|
55
|
+
ssh_run "printf '%s\n' \"\$SSHPASS\" | sudo -S -p '' true" 2>/dev/null || \
|
|
56
|
+
ssh_run "printf '%s\n' '$PW' | sudo -S -p '' true" || die "sudo 密码验证失败(密码是否=账号密码?)"
|
|
57
|
+
OSINFO=$(ssh_run '. /etc/os-release 2>/dev/null; echo "$ID $VERSION_ID"; uname -m')
|
|
58
|
+
echo " 节点: $HN ($OSINFO), home=$HOME_DIR, clientId=$CLIENT_ID, server=$SERVER"
|
|
59
|
+
case "$OSINFO" in ubuntu*|debian*) ;; *) die "仅支持 Debian/Ubuntu 系(实测: $OSINFO)" ;; esac
|
|
60
|
+
|
|
61
|
+
# ---------- 公钥部署 ----------
|
|
62
|
+
if [ $NO_PUBKEY -eq 0 ] && [ $DRY -eq 0 ]; then
|
|
63
|
+
echo "== [0.5] 部署公钥($PUBKEY_MODE 模式)=="
|
|
64
|
+
ssh_run "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak.\$(date +%s) 2>/dev/null; true"
|
|
65
|
+
if [ "$PUBKEY_MODE" = replace ]; then
|
|
66
|
+
cat "$PUBKEY" | ssh_run "cat > ~/.ssh/authorized_keys"
|
|
67
|
+
else
|
|
68
|
+
cat "$PUBKEY" | ssh_run "cat >> ~/.ssh/authorized_keys"
|
|
69
|
+
fi
|
|
70
|
+
ssh_run "chmod 600 ~/.ssh/authorized_keys && sort -u ~/.ssh/authorized_keys -o ~/.ssh/authorized_keys"
|
|
71
|
+
echo " ✓ ubuntu26 公钥已部署(免密后续可用: ssh $SSH_USER@$HOST)"
|
|
72
|
+
elif [ $DRY -eq 1 ]; then
|
|
73
|
+
echo "== [0.5] dry-run: 跳过公钥部署 =="
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# ---------- 生成远程脚本(变量经 $1..$5 传入,不拼接密码) ----------
|
|
77
|
+
RLOCAL=$(mktemp /tmp/deploy-linux-node-remote.XXXXXX.sh)
|
|
78
|
+
cat > "$RLOCAL" <<'REMOTE_EOF'
|
|
79
|
+
#!/bin/bash
|
|
80
|
+
# 远程执行体(root)。参数: $1=dry-run标记 $2=SERVER $3=NODE_ID $4=CLIENT_ID $5=SSH_USER
|
|
81
|
+
DRY="$1"; SERVER="$2"; NODE_ID="$3"; CLIENT_ID="$4"; U="$5"
|
|
82
|
+
UHOME="/home/$U"
|
|
83
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
84
|
+
step() { echo "[STEP $1/$2] $3"; }
|
|
85
|
+
ok(){ echo " ✓ $*"; }
|
|
86
|
+
fail(){ echo "DEPLOY_FAILED: $*"; exit 1; }
|
|
87
|
+
|
|
88
|
+
if [ "$DRY" = dry ]; then
|
|
89
|
+
echo "===== DRY-RUN 体检报告 ====="
|
|
90
|
+
command -v node >/dev/null && ok "node $(node -v)" || echo " ✗ node 未装"
|
|
91
|
+
command -v microsoft-edge-stable >/dev/null && ok "edge $(microsoft-edge-stable --version 2>/dev/null | head -1)" || echo " ✗ Edge 未装"
|
|
92
|
+
dpkg -s xvfb >/dev/null 2>&1 && ok "xvfb 已装" || echo " ✗ xvfb 未装(铁律 T2)"
|
|
93
|
+
if [ -d /usr/local/lib/node_modules ]; then
|
|
94
|
+
OWN=$(stat -c %U /usr/local/lib/node_modules)
|
|
95
|
+
[ "$OWN" = "$U" ] && ok "全局包属主=$OWN(T1 ✓)" || echo " ✗ 全局包属主=$OWN(T1 违反,需 chown)"
|
|
96
|
+
fi
|
|
97
|
+
[ -f "$UHOME/.tt-help.json" ] && ok "配置存在: $(cat "$UHOME/.tt-help.json")" || echo " ✗ ~/.tt-help.json 不存在(T3)"
|
|
98
|
+
if [ -f /etc/systemd/system/tt-watchdog.service ]; then
|
|
99
|
+
ok "systemd 单元已存在: $(systemctl is-active tt-watchdog 2>/dev/null || echo inactive)(已存在则部署体不会覆盖)"
|
|
100
|
+
grep -q -- --no-daemon /etc/systemd/system/tt-watchdog.service && echo " ✗ 单元含 --no-daemon(铁律 T4 违反)" || true
|
|
101
|
+
else
|
|
102
|
+
echo " - systemd 单元不存在(部署体将创建)"
|
|
103
|
+
fi
|
|
104
|
+
[ -f /MAINTENANCE.md ] && { ok "/MAINTENANCE.md 存在,属主=$(stat -c %U /MAINTENANCE.md)"; [ "$(stat -c %U /MAINTENANCE.md)" = "$U" ] || echo " ✗ 属主非 $U(铁律 T6 违反)"; } || echo " - /MAINTENANCE.md 不存在(部署体将创建)"
|
|
105
|
+
HB=$(curl -s --max-time 5 "$SERVER/api/watchdog/clients" | grep -o "$CLIENT_ID" | head -1)
|
|
106
|
+
[ -n "$HB" ] && ok "服务端已见 $CLIENT_ID 在线" || echo " - 服务端未见 $CLIENT_ID(新机属正常)"
|
|
107
|
+
echo "===== DRY-RUN 结束(无任何变更)====="
|
|
108
|
+
exit 0
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
step 1 9 "apt 基础包: nodejs npm xvfb"
|
|
112
|
+
apt-get update -qq || fail "apt update"
|
|
113
|
+
apt-get install -y -qq nodejs npm xvfb curl ca-certificates || fail "apt 基础包安装"
|
|
114
|
+
ok "node $(node -v)"
|
|
115
|
+
|
|
116
|
+
step 2 9 "Microsoft Edge"
|
|
117
|
+
if command -v microsoft-edge-stable >/dev/null; then ok "已装 $(microsoft-edge-stable --version 2>/dev/null|head -1)"; else
|
|
118
|
+
if command -v gpg >/dev/null; then
|
|
119
|
+
curl -fSsL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-edge.gpg || fail "edge key"
|
|
120
|
+
SIGNED_BY="/usr/share/keyrings/microsoft-edge.gpg"
|
|
121
|
+
else
|
|
122
|
+
curl -fSsL https://packages.microsoft.com/keys/microsoft.asc -o /usr/share/keyrings/microsoft-edge.asc || fail "edge key(asc)"
|
|
123
|
+
SIGNED_BY="/usr/share/keyrings/microsoft-edge.asc"
|
|
124
|
+
fi
|
|
125
|
+
cat > /etc/apt/sources.list.d/microsoft-edge.sources <<EOF2
|
|
126
|
+
Types: deb
|
|
127
|
+
URIs: https://packages.microsoft.com/repos/edge-stable
|
|
128
|
+
Suites: stable
|
|
129
|
+
Components: main
|
|
130
|
+
Architectures: amd64
|
|
131
|
+
Signed-By: $SIGNED_BY
|
|
132
|
+
EOF2
|
|
133
|
+
apt-get update -qq && apt-get install -y -qq microsoft-edge-stable || fail "edge 安装"
|
|
134
|
+
ok "edge $(microsoft-edge-stable --version 2>/dev/null|head -1)"
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
step 3 9 "npm i -g tt-help-cli-ycl"
|
|
138
|
+
command -v tt-help >/dev/null && ok "已装 $(tt-help --version 2>/dev/null|tail -1)" || { npm install -g tt-help-cli-ycl || fail "npm i -g"; ok "$(tt-help --version|tail -1)"; }
|
|
139
|
+
|
|
140
|
+
step 4 9 "属主修正(铁律 T1)"
|
|
141
|
+
chown -R "$U:$U" /usr/local/lib/node_modules && chown "$U:$U" /usr/local/bin && chown -h "$U:$U" /usr/local/bin/tt-help 2>/dev/null
|
|
142
|
+
ok "node_modules/bin/tt-help 链接 → $U"
|
|
143
|
+
|
|
144
|
+
step 5 9 "~/.tt-help.json 三字段合并(铁律 T3,只补不覆盖)"
|
|
145
|
+
node -e 'const fs=require("fs");const p=process.argv[1];let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){};c.server=process.argv[2];c.userId=process.argv[3];c.watchdogClientId=process.argv[4];fs.writeFileSync(p,JSON.stringify(c,null,2))' \
|
|
146
|
+
"$UHOME/.tt-help.json" "$SERVER" "$NODE_ID" "$CLIENT_ID" || fail "写配置"
|
|
147
|
+
chown "$U:$U" "$UHOME/.tt-help.json" && chmod 600 "$UHOME/.tt-help.json"
|
|
148
|
+
ok "$(cat "$UHOME/.tt-help.json" | tr -d '\n')"
|
|
149
|
+
|
|
150
|
+
step 6 9 "/MAINTENANCE.md(铁律 T6,已存在则不动)"
|
|
151
|
+
if [ ! -f /MAINTENANCE.md ]; then
|
|
152
|
+
cat > /MAINTENANCE.md <<EOF3
|
|
153
|
+
# 节点维护档案(SSOT)
|
|
154
|
+
|
|
155
|
+
> 建档:$(date +%F)(deploy-linux-node.sh 自动建档)。权限 600,勿入仓库/知识库。
|
|
156
|
+
|
|
157
|
+
## 1. 身份
|
|
158
|
+
|
|
159
|
+
- 角色:TikTok 采集集群 Linux 节点,设备号 **$NODE_ID**
|
|
160
|
+
- 主机名:$HN;ssh 端口见接入记录;公钥:ubuntu26 id_ed25519
|
|
161
|
+
|
|
162
|
+
## 2. 账号
|
|
163
|
+
|
|
164
|
+
- **账号**: $U(sudo 组)
|
|
165
|
+
- **密码**: 见集群接入时用户提供(本文件不回填密码,由建档会话补写本节)
|
|
166
|
+
- sudo 非交互模板:\`printf '%s\n' '<密码>' | sudo -S -p '' <cmd>\`
|
|
167
|
+
|
|
168
|
+
## 3. 软件栈
|
|
169
|
+
|
|
170
|
+
- 由 deploy-linux-node.sh 部署:nodejs+npm / Edge / xvfb / tt-help / tt-watchdog.service
|
|
171
|
+
- server=$SERVER clientId=$CLIENT_ID
|
|
172
|
+
|
|
173
|
+
## 4. 维护记录
|
|
174
|
+
|
|
175
|
+
- $(date +%F) 初始部署(deploy-linux-node.sh);订阅分配/业务命令待用户后台配置
|
|
176
|
+
EOF3
|
|
177
|
+
chown "$U:$U" /MAINTENANCE.md && chmod 600 /MAINTENANCE.md
|
|
178
|
+
ok "已建档(密码节待会话补写)"
|
|
179
|
+
else
|
|
180
|
+
ok "已存在,跳过(属主 $(stat -c %U /MAINTENANCE.md),非 $U 需手动 chown)"
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
step 7 9 "systemd tt-watchdog.service(新版命令,无 --no-daemon)"
|
|
184
|
+
if [ -f /etc/systemd/system/tt-watchdog.service ]; then
|
|
185
|
+
ok "单元已存在,不动(当前 $(systemctl is-active tt-watchdog 2>/dev/null || echo inactive))"
|
|
186
|
+
else
|
|
187
|
+
cat > /etc/systemd/system/tt-watchdog.service <<EOF4
|
|
188
|
+
[Unit]
|
|
189
|
+
Description=tt-help watchdog ($CLIENT_ID)
|
|
190
|
+
After=network-online.target
|
|
191
|
+
Wants=network-online.target
|
|
192
|
+
|
|
193
|
+
[Service]
|
|
194
|
+
User=$U
|
|
195
|
+
ExecStart=/usr/local/bin/tt-help watchdog --server $SERVER --client-id $CLIENT_ID --interval 15
|
|
196
|
+
Restart=always
|
|
197
|
+
RestartSec=10
|
|
198
|
+
|
|
199
|
+
[Install]
|
|
200
|
+
WantedBy=multi-user.target
|
|
201
|
+
EOF4
|
|
202
|
+
systemctl daemon-reload && systemctl enable --now tt-watchdog || fail "systemd 启动"
|
|
203
|
+
ok "已 enable --now"
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
step 8 9 "服务端建订阅条目(铁律 T5,失败不阻塞)"
|
|
207
|
+
curl -s --max-time 10 -X POST "$SERVER/api/watchdog/set-proxy-auto" -H 'Content-Type: application/json' \
|
|
208
|
+
-d "{\"userId\":\"$NODE_ID\",\"count\":1}" >/dev/null && ok "set-proxy-auto OK" || echo " ⚠ set-proxy-auto 失败(服务端不可达?后台补配)"
|
|
209
|
+
|
|
210
|
+
step 9 9 "验证:服务在线 + 心跳到服务端"
|
|
211
|
+
sleep 5
|
|
212
|
+
systemctl is-active --quiet tt-watchdog || fail "tt-watchdog 未 active: $(journalctl -u tt-watchdog -n 5 --no-pager 2>/dev/null | tr -d '\n')"
|
|
213
|
+
ok "tt-watchdog active"
|
|
214
|
+
HB=0
|
|
215
|
+
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
216
|
+
curl -s --max-time 5 "$SERVER/api/watchdog/clients" | grep -q "$CLIENT_ID" && { HB=1; break; }
|
|
217
|
+
sleep 10
|
|
218
|
+
done
|
|
219
|
+
[ $HB -eq 1 ] && ok "服务端已见 $CLIENT_ID 在线" || echo " ⚠ 心跳未见于服务端(检查 server 可达性/防火墙),进程本机已 active"
|
|
220
|
+
tt-help --version | tail -1
|
|
221
|
+
echo "DEPLOY_DONE clientId=$CLIENT_ID"
|
|
222
|
+
REMOTE_EOF
|
|
223
|
+
|
|
224
|
+
if [ $DRY -eq 1 ]; then
|
|
225
|
+
echo "== dry-run:上传体检脚本 =="
|
|
226
|
+
sshpass -e scp -P "$PORT" -o StrictHostKeyChecking=accept-new "$RLOCAL" "$SSH_USER@$HOST:$RSCRIPT" || die "scp 失败"
|
|
227
|
+
ssh_run "SSHPASS='$PW' bash $RSCRIPT dry '$SERVER' '$NODE_ID' '$CLIENT_ID' '$SSH_USER'"
|
|
228
|
+
rm -f "$RLOCAL"; exit 0
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
echo "== [1/9] 上传远程执行体 =="
|
|
232
|
+
sshpass -e scp -P "$PORT" -o StrictHostKeyChecking=accept-new "$RLOCAL" "$SSH_USER@$HOST:$RSCRIPT" || die "scp 失败"
|
|
233
|
+
|
|
234
|
+
echo "== [2/9] 后台启动部署(root,日志 $RLOG)=="
|
|
235
|
+
ssh_run "printf '%s\n' \"\$SSHPASS\" | sudo -S -p '' bash -c 'setsid nohup bash $RSCRIPT run \"$SERVER\" \"$NODE_ID\" \"$CLIENT_ID\" \"$SSH_USER\" </dev/null >$RLOG 2>&1 &'" \
|
|
236
|
+
|| ssh_run "printf '%s\n' '$PW' | sudo -S -p '' bash -c 'setsid nohup bash $RSCRIPT run \"$SERVER\" \"$NODE_ID\" \"$CLIENT_ID\" \"$SSH_USER\" </dev/null >$RLOG 2>&1 &'" \
|
|
237
|
+
|| die "后台拉起失败"
|
|
238
|
+
|
|
239
|
+
echo "== [3/9] 轮询进度(每 15s,超时 ${TIMEOUT}s)=="
|
|
240
|
+
LAST=""
|
|
241
|
+
i=0
|
|
242
|
+
while [ $i -lt $((TIMEOUT / 15)) ]; do
|
|
243
|
+
sleep 15; i=$((i+1))
|
|
244
|
+
LOG=$(ssh_run "cat $RLOG 2>/dev/null") || { echo " (日志读取失败,重试)"; continue; }
|
|
245
|
+
CUR=$(echo "$LOG" | grep -E '^\[STEP' | tail -1)
|
|
246
|
+
[ -n "$CUR" ] && [ "$CUR" != "$LAST" ] && { echo " $CUR"; LAST="$CUR"; }
|
|
247
|
+
if echo "$LOG" | grep -q '^DEPLOY_DONE'; then
|
|
248
|
+
echo "$LOG" | grep -E '^ ✓|DEPLOY_DONE|⚠'
|
|
249
|
+
echo; echo "✅ 部署完成。接下来(用户后台决定,agent 不代做):订阅分配 / 业务命令下发"
|
|
250
|
+
rm -f "$RLOCAL"; exit 0
|
|
251
|
+
fi
|
|
252
|
+
if echo "$LOG" | grep -q '^DEPLOY_FAILED'; then
|
|
253
|
+
echo "$LOG" | tail -15; rm -f "$RLOCAL"; die "$(echo "$LOG" | grep '^DEPLOY_FAILED')"
|
|
254
|
+
fi
|
|
255
|
+
done
|
|
256
|
+
echo "$LOG" | tail -10; rm -f "$RLOCAL"; die "超时(${TIMEOUT}s),登节点看 $RLOG"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 登录池批量改造 smoke — 取号排序 + reason 聚合 + 清空日志(2026-10-30 清单 B1/B2/B3/C1/C2 服务端)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* B2 取号排序五条: 非同类优先生效 / 全同类仍发号 / 全冷却退全池 / 从未用过最前 / lastAppName 空值中性
|
|
6
|
+
* B1/B3 lastAppName 落账号本体(release 后保留)+ acquired history detail 带 appName
|
|
7
|
+
* C2 reason 聚合: 显式上报计数、未知 code 原样存、不带 reason 不计(无「未上报」桶)
|
|
8
|
+
* C1 清空日志: history 清成一条留痕 + useCount/switchStats 清零 + 不动 lastUsedAt/lastAppName
|
|
9
|
+
*
|
|
10
|
+
* 跑法: HOME=$(mktemp -d) node scripts/test-login-pool-acquire-order.mjs
|
|
11
|
+
*/
|
|
12
|
+
import assert from "assert";
|
|
13
|
+
|
|
14
|
+
const HOME = process.env.HOME;
|
|
15
|
+
if (!HOME || HOME === "/") {
|
|
16
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-pool-acquire-order.mjs");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
getLoginPoolView,
|
|
22
|
+
addAccount,
|
|
23
|
+
releaseAccount,
|
|
24
|
+
acquireAccount,
|
|
25
|
+
clearAccountHistory,
|
|
26
|
+
clearAllAccountHistory,
|
|
27
|
+
_accountsInternal,
|
|
28
|
+
} = await import("../src/watch/login-pool.js");
|
|
29
|
+
|
|
30
|
+
let pass = 0;
|
|
31
|
+
let fail = 0;
|
|
32
|
+
const check = (name, cond, extra = "") => {
|
|
33
|
+
if (cond) {
|
|
34
|
+
pass++;
|
|
35
|
+
console.log(` ✓ ${name}`);
|
|
36
|
+
} else {
|
|
37
|
+
fail++;
|
|
38
|
+
console.log(` ✗ ${name} ${extra}`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const MIN_IDLE_MS = 30 * 60 * 1000;
|
|
42
|
+
const findAcc = (username) =>
|
|
43
|
+
getLoginPoolView().accounts.find((x) => x.username === username);
|
|
44
|
+
const backdate = (username, msAgo) => {
|
|
45
|
+
for (const a of _accountsInternal()) {
|
|
46
|
+
if (a.username === username) a.lastUsedAt = Date.now() - msAgo;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
// 节间隔离:把目前为止全部账号下架(retired 不参与取号),每节干净开局
|
|
50
|
+
const retireAll = () => {
|
|
51
|
+
for (const a of _accountsInternal()) {
|
|
52
|
+
a.status = "retired";
|
|
53
|
+
a.lease = null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const mkCookies = (sid, uid) => [
|
|
58
|
+
{ name: "sessionid", value: sid, domain: ".tiktok.com", expires: Date.now() / 1000 + 86400 },
|
|
59
|
+
{ name: "unique_id", value: uid, domain: ".tiktok.com" },
|
|
60
|
+
];
|
|
61
|
+
const add = (uid) => addAccount({ cookies: mkCookies("sid-" + uid, uid) });
|
|
62
|
+
|
|
63
|
+
// ── B2-1: 从未用过最前(K1 优先于 K2 非同类)──
|
|
64
|
+
console.log("\n[B2-1] 从未用过最前(K1)");
|
|
65
|
+
{
|
|
66
|
+
add("ordUsed");
|
|
67
|
+
add("ordNever");
|
|
68
|
+
// 先 acquire 再 release:只为给账号盖 lastAppName 标记(lease 已还,回池)
|
|
69
|
+
acquireAccount({ userId: "O1", appName: "search" });
|
|
70
|
+
releaseAccount({ id: findAcc("ordUsed").id });
|
|
71
|
+
backdate("ordUsed", 31 * 60 * 1000); // eligible 且非同类(search≠explore)
|
|
72
|
+
// ordNever: lastUsedAt=null(无限冷),无 lastAppName
|
|
73
|
+
const got = acquireAccount({ userId: "O1b", appName: "explore" });
|
|
74
|
+
check("从未用过的号优先于 eligible 的非同类旧号", got.username === "ordNever", `取到 ${got.username}`);
|
|
75
|
+
retireAll();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── B2-2: 非同类优先生效(K2 优先于 K3)──
|
|
79
|
+
console.log("\n[B2-2] 非同类优先生效(K2)");
|
|
80
|
+
{
|
|
81
|
+
add("ordSameApp");
|
|
82
|
+
add("ordDiffApp");
|
|
83
|
+
acquireAccount({ userId: "O2", appName: "explore" });
|
|
84
|
+
acquireAccount({ userId: "O2b", appName: "search" });
|
|
85
|
+
releaseAccount({ id: findAcc("ordSameApp").id });
|
|
86
|
+
releaseAccount({ id: findAcc("ordDiffApp").id });
|
|
87
|
+
backdate("ordSameApp", 40 * 60 * 1000); // eligible、同类(explore)、更冷
|
|
88
|
+
backdate("ordDiffApp", 31 * 60 * 1000); // eligible、非同类(search)、更新
|
|
89
|
+
const got = acquireAccount({ userId: "O2c", appName: "explore" });
|
|
90
|
+
check("非同类(search)优先于更冷的同类(explore)——K2 压过 K3", got.username === "ordDiffApp",
|
|
91
|
+
`取到 ${got.username}`);
|
|
92
|
+
retireAll();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── B2-3: 全同类仍发号(K3)──
|
|
96
|
+
console.log("\n[B2-3] 全同类仍发号(K3 兜底排序)");
|
|
97
|
+
{
|
|
98
|
+
add("ordC1");
|
|
99
|
+
add("ordD1");
|
|
100
|
+
acquireAccount({ userId: "O3", appName: "explore" });
|
|
101
|
+
acquireAccount({ userId: "O3b", appName: "explore" });
|
|
102
|
+
releaseAccount({ id: findAcc("ordC1").id });
|
|
103
|
+
releaseAccount({ id: findAcc("ordD1").id });
|
|
104
|
+
backdate("ordC1", 60 * 60 * 1000);
|
|
105
|
+
backdate("ordD1", 35 * 60 * 1000);
|
|
106
|
+
const got = acquireAccount({ userId: "O3c", appName: "explore" });
|
|
107
|
+
check("全同类仍发号,按 lastUsedAt 升序取最冷", got.username === "ordC1", `取到 ${got.username}`);
|
|
108
|
+
retireAll();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── B2-4: 全冷却退全池(eligible 空集兜底)──
|
|
112
|
+
console.log("\n[B2-4] 全冷却退全池兜底");
|
|
113
|
+
{
|
|
114
|
+
add("ordE1");
|
|
115
|
+
add("ordF1");
|
|
116
|
+
acquireAccount({ userId: "O4", appName: "explore" });
|
|
117
|
+
acquireAccount({ userId: "O4b", appName: "explore" });
|
|
118
|
+
releaseAccount({ id: findAcc("ordE1").id });
|
|
119
|
+
releaseAccount({ id: findAcc("ordF1").id });
|
|
120
|
+
// 全部压进 30min 冷却窗内
|
|
121
|
+
backdate("ordE1", 5 * 60 * 1000);
|
|
122
|
+
backdate("ordF1", 8 * 60 * 1000);
|
|
123
|
+
const got = acquireAccount({ userId: "O4c", appName: "explore" });
|
|
124
|
+
check("eligible 空集时全池兜底仍发号", !!got && got.status === "leased", `got=${got && got.username}`);
|
|
125
|
+
check("兜底排序仍按 lastUsedAt 升序(取窗内更冷的 ordF1)", got.username === "ordF1", `取到 ${got.username}`);
|
|
126
|
+
retireAll();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── B2-5: lastAppName 空值中性 ──
|
|
130
|
+
console.log("\n[B2-5] lastAppName 空值按非同类 / 请求无 appName 时键中性化");
|
|
131
|
+
{
|
|
132
|
+
// 先加 ordExploreApp(创建序在前 → 首个 acquire 取到它并盖上 explore)
|
|
133
|
+
add("ordExploreApp");
|
|
134
|
+
add("ordEmptyApp");
|
|
135
|
+
acquireAccount({ userId: "O5", appName: "explore" });
|
|
136
|
+
releaseAccount({ id: findAcc("ordExploreApp").id });
|
|
137
|
+
for (const a of _accountsInternal()) {
|
|
138
|
+
if (a.username === "ordEmptyApp") a.lastAppName = null; // 模拟改造前的存量账号(无 lastAppName)
|
|
139
|
+
}
|
|
140
|
+
backdate("ordEmptyApp", 32 * 60 * 1000); // eligible、lastAppName 空
|
|
141
|
+
backdate("ordExploreApp", 31 * 60 * 1000); // eligible、同类 explore
|
|
142
|
+
let got = acquireAccount({ userId: "O5b", appName: "explore" });
|
|
143
|
+
check("lastAppName 空按非同类(优先于同类旧号)", got.username === "ordEmptyApp", `取到 ${got.username}`);
|
|
144
|
+
|
|
145
|
+
// 请求无 appName → K2 中性化,纯 K3(取更冷的)
|
|
146
|
+
add("ordNet1");
|
|
147
|
+
add("ordNet2");
|
|
148
|
+
acquireAccount({ userId: "O5c", appName: "explore" });
|
|
149
|
+
acquireAccount({ userId: "O5d", appName: "search" });
|
|
150
|
+
releaseAccount({ id: findAcc("ordNet1").id });
|
|
151
|
+
releaseAccount({ id: findAcc("ordNet2").id });
|
|
152
|
+
backdate("ordNet1", 31 * 60 * 1000);
|
|
153
|
+
backdate("ordNet2", 45 * 60 * 1000);
|
|
154
|
+
got = acquireAccount({ userId: "O5e" }); // 不带 appName
|
|
155
|
+
check("请求无 appName 时 K2 中性化(纯 lastUsedAt 升序)", got.username === "ordNet2", `取到 ${got.username}`);
|
|
156
|
+
retireAll();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── B1/B3: lastAppName 落本体 + history 带 appName ──
|
|
160
|
+
console.log("\n[B1/B3] lastAppName 落账号本体(release 后保留)+ history detail 带 appName");
|
|
161
|
+
{
|
|
162
|
+
add("ordMeta");
|
|
163
|
+
const got = acquireAccount({ userId: "O6", port: 9222, appName: "explore" });
|
|
164
|
+
check("acquire 落 lastAppName", got.lastAppName === "explore", `got ${got.lastAppName}`);
|
|
165
|
+
const hist = got.history.find((h) => h.event === "acquired");
|
|
166
|
+
check("acquired history detail 带 appName", hist && hist.detail.includes("explore"),
|
|
167
|
+
JSON.stringify(hist && hist.detail));
|
|
168
|
+
releaseAccount({ id: got.id });
|
|
169
|
+
const after = findAcc("ordMeta");
|
|
170
|
+
check("release 后 lastAppName 保留", after.lastAppName === "explore");
|
|
171
|
+
retireAll();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── prevAppName: 前一任程序 ──
|
|
175
|
+
console.log("\n[prev] 前一任程序记录(页面「上次使用」列数据源)");
|
|
176
|
+
{
|
|
177
|
+
add("ordPrev");
|
|
178
|
+
acquireAccount({ userId: "P1", appName: "explore" });
|
|
179
|
+
releaseAccount({ id: findAcc("ordPrev").id });
|
|
180
|
+
const got = acquireAccount({ userId: "P2", appName: "search" });
|
|
181
|
+
check("第二次取号后 lastAppName=当前程序", got.lastAppName === "search", `got ${got.lastAppName}`);
|
|
182
|
+
check("prevAppName=前一任(explore→search 切换可追溯)", got.prevAppName === "explore", `got ${got.prevAppName}`);
|
|
183
|
+
const first = getLoginPoolView().accounts.find((x) => x.username === "ordBatchA");
|
|
184
|
+
// 从未换过程序的账号 prevAppName 应为 null(首次取号无前一任)
|
|
185
|
+
add("ordPrevFirst");
|
|
186
|
+
const g2 = acquireAccount({ userId: "P3", appName: "explore" });
|
|
187
|
+
check("首次取号无前一任(prevAppName=null)", g2.prevAppName === null, `got ${g2.prevAppName}`);
|
|
188
|
+
retireAll();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── C2: reason 聚合 ──
|
|
192
|
+
console.log("\n[C2] switchStats 聚合:显式计数 / 未知 code 原样存 / 不带不计");
|
|
193
|
+
{
|
|
194
|
+
add("ordReason1");
|
|
195
|
+
let got = acquireAccount({ userId: "O7", appName: "explore", reason: "banned" });
|
|
196
|
+
check("显式上报 banned 计数 1", got.switchStats && got.switchStats.banned === 1, JSON.stringify(got.switchStats));
|
|
197
|
+
const hist = got.history.find((h) => h.event === "acquired");
|
|
198
|
+
check("acquired history detail 带原因", hist && hist.detail.includes("原因 banned"), JSON.stringify(hist && hist.detail));
|
|
199
|
+
|
|
200
|
+
releaseAccount({ id: got.id });
|
|
201
|
+
backdate("ordReason1", 31 * 60 * 1000);
|
|
202
|
+
got = acquireAccount({ userId: "O7b", appName: "explore", reason: "banned" });
|
|
203
|
+
check("同账号再上报 banned 累计为 2", got.switchStats.banned === 2, JSON.stringify(got.switchStats));
|
|
204
|
+
|
|
205
|
+
releaseAccount({ id: got.id });
|
|
206
|
+
backdate("ordReason1", 31 * 60 * 1000);
|
|
207
|
+
got = acquireAccount({ userId: "O7c", appName: "explore", reason: "weird_reason" });
|
|
208
|
+
check("未知 code 原样存不拒绝", got.switchStats.weird_reason === 1, JSON.stringify(got.switchStats));
|
|
209
|
+
|
|
210
|
+
add("ordNoReason");
|
|
211
|
+
got = acquireAccount({ userId: "O7d", appName: "explore" });
|
|
212
|
+
const noBucket = !got.switchStats || Object.keys(got.switchStats).length === 0;
|
|
213
|
+
check("不带 reason 不计数(无「未上报」桶)", noBucket, JSON.stringify(got.switchStats));
|
|
214
|
+
retireAll();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── C1: 清空日志 ──
|
|
218
|
+
console.log("\n[C1] 清空日志:留痕一条 + useCount/switchStats 清零 + 不动休息窗/最近程序");
|
|
219
|
+
{
|
|
220
|
+
add("ordClear");
|
|
221
|
+
let got = acquireAccount({ userId: "O8", appName: "explore", reason: "banned" });
|
|
222
|
+
assert(got.useCount === 1);
|
|
223
|
+
const lastUsedBefore = got.lastUsedAt;
|
|
224
|
+
releaseAccount({ id: got.id });
|
|
225
|
+
const cleared = clearAccountHistory({ id: got.id, by: "page" });
|
|
226
|
+
check("history 清成一条留痕", cleared.history.length === 1, `len=${cleared.history.length}`);
|
|
227
|
+
check("留痕含来源 page", cleared.history[0].detail.includes("page"), JSON.stringify(cleared.history[0]));
|
|
228
|
+
check("useCount 归零", cleared.useCount === 0);
|
|
229
|
+
check("switchStats 清空", !cleared.switchStats || Object.keys(cleared.switchStats).length === 0);
|
|
230
|
+
check("不动 lastUsedAt(休息窗保留)", cleared.lastUsedAt === lastUsedBefore,
|
|
231
|
+
`before=${lastUsedBefore} after=${cleared.lastUsedAt}`);
|
|
232
|
+
check("不动 lastAppName", cleared.lastAppName === "explore");
|
|
233
|
+
// 清空后休息窗仍生效(30min 内不因清日志而被取走)
|
|
234
|
+
add("ordClearFresh");
|
|
235
|
+
got = acquireAccount({ userId: "O8b", appName: "explore" });
|
|
236
|
+
check("清日志不影响休息窗(清空号未被取走)", got.username === "ordClearFresh", `取到 ${got.username}`);
|
|
237
|
+
retireAll();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── 批量清空(一键全清)──
|
|
241
|
+
console.log("\n[批量清空] clearAllAccountHistory 一键全清");
|
|
242
|
+
{
|
|
243
|
+
add("ordBatchA");
|
|
244
|
+
add("ordBatchB");
|
|
245
|
+
let g = acquireAccount({ userId: "OB1", appName: "explore", reason: "banned" });
|
|
246
|
+
releaseAccount({ id: g.id });
|
|
247
|
+
g = acquireAccount({ userId: "OB2", appName: "search", reason: "captcha" });
|
|
248
|
+
const snap = {};
|
|
249
|
+
for (const a of _accountsInternal()) {
|
|
250
|
+
if (a.username === "ordBatchA") snap.A = { lastUsedAt: a.lastUsedAt, lastAppName: a.lastAppName };
|
|
251
|
+
}
|
|
252
|
+
const r = clearAllAccountHistory({ by: "page" });
|
|
253
|
+
check("返回清空数量(含历史遗留账号)", r.cleared >= 2, `cleared=${r.cleared}`);
|
|
254
|
+
for (const u of ["ordBatchA", "ordBatchB"]) {
|
|
255
|
+
const a = findAcc(u);
|
|
256
|
+
check(`${u}: history 清成一条批量留痕`, a.history.length === 1 && a.history[0].detail.includes("批量"),
|
|
257
|
+
JSON.stringify(a.history));
|
|
258
|
+
check(`${u}: useCount/switchStats 清零`, a.useCount === 0 && Object.keys(a.switchStats || {}).length === 0);
|
|
259
|
+
}
|
|
260
|
+
const a = findAcc("ordBatchA");
|
|
261
|
+
check("批量清空不动 lastUsedAt/lastAppName", a.lastUsedAt === snap.A.lastUsedAt && a.lastAppName === snap.A.lastAppName);
|
|
262
|
+
// 休息窗保留:全池刚被清空的账号不会被立刻取走(除从未用过的)
|
|
263
|
+
add("ordBatchFresh");
|
|
264
|
+
g = acquireAccount({ userId: "OB3", appName: "explore" });
|
|
265
|
+
check("批量清空后休息窗仍生效(取到从未用过的 ordBatchFresh)", g.username === "ordBatchFresh", `取到 ${g.username}`);
|
|
266
|
+
retireAll();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
270
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 登录池批量改造 smoke — 缺陷修复部分(2026-10-30 清单 A1/A2)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* A1 heartbeatLoginPool 续租匹配:带实例后缀 userId({节点}-{端口}-{实例})的租约
|
|
6
|
+
* 能被纯节点名心跳续租(阳性),且不误续其他节点(阴性,"- " 边界);
|
|
7
|
+
* acquireAccount「自动释放同使用者旧租约」保持精确相等(一实例一号不变量)
|
|
8
|
+
* A2 updateAccount 手动释放(leased→pool)盖 lastUsedAt:陈旧 lastUsedAt 不再绕过休息窗
|
|
9
|
+
*
|
|
10
|
+
* 跑法: HOME=$(mktemp -d) node scripts/test-login-pool-renew-fix.mjs
|
|
11
|
+
*/
|
|
12
|
+
import assert from "assert";
|
|
13
|
+
|
|
14
|
+
const HOME = process.env.HOME;
|
|
15
|
+
if (!HOME || HOME === "/") {
|
|
16
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-pool-renew-fix.mjs");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
getLoginPoolView,
|
|
22
|
+
addAccount,
|
|
23
|
+
updateAccount,
|
|
24
|
+
acquireAccount,
|
|
25
|
+
heartbeatLoginPool,
|
|
26
|
+
_accountsInternal,
|
|
27
|
+
} = await import("../src/watch/login-pool.js");
|
|
28
|
+
|
|
29
|
+
let pass = 0;
|
|
30
|
+
let fail = 0;
|
|
31
|
+
const check = (name, cond, extra = "") => {
|
|
32
|
+
if (cond) {
|
|
33
|
+
pass++;
|
|
34
|
+
console.log(` ✓ ${name}`);
|
|
35
|
+
} else {
|
|
36
|
+
fail++;
|
|
37
|
+
console.log(` ✗ ${name} ${extra}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
41
|
+
const findAcc = (username) =>
|
|
42
|
+
getLoginPoolView().accounts.find((x) => x.username === username);
|
|
43
|
+
|
|
44
|
+
const mkCookies = (sid, uid) => [
|
|
45
|
+
{ name: "sessionid", value: sid, domain: ".tiktok.com", expires: Date.now() / 1000 + 86400 },
|
|
46
|
+
{ name: "unique_id", value: uid, domain: ".tiktok.com" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ── A1: 续租匹配 ──
|
|
50
|
+
console.log("\n[A1] heartbeatLoginPool 续租匹配(后缀租约 + 边界阴性)");
|
|
51
|
+
{
|
|
52
|
+
addAccount({ cookies: mkCookies("sid-1", "userRenewSuffix") }); // 租约 userId=F-1-9222-4(业务实例)
|
|
53
|
+
addAccount({ cookies: mkCookies("sid-2", "userRenewPlain") }); // 租约 userId=F-1(watchdog 指派语义)
|
|
54
|
+
addAccount({ cookies: mkCookies("sid-3", "userOtherNode") }); // 租约 userId=F-11-8222-2(别的节点)
|
|
55
|
+
|
|
56
|
+
let suffix = acquireAccount({ userId: "F-1-9222-4", port: 9222, appName: "explore", username: "userRenewSuffix" });
|
|
57
|
+
suffix = findAcc("userRenewSuffix");
|
|
58
|
+
acquireAccount({ userId: "F-1", username: "userRenewPlain" });
|
|
59
|
+
acquireAccount({ userId: "F-11-8222-2", username: "userOtherNode" });
|
|
60
|
+
|
|
61
|
+
const plainBefore = findAcc("userRenewPlain").lease.lastHealthAt;
|
|
62
|
+
const otherBefore = findAcc("userOtherNode").lease.lastHealthAt;
|
|
63
|
+
await sleep(15); // 保证时间戳可分辨
|
|
64
|
+
|
|
65
|
+
// 纯节点名 F-1 心跳:应续租 F-1(精确)与 F-1-9222-4(前缀),不得碰 F-11-*
|
|
66
|
+
heartbeatLoginPool("F-1", {});
|
|
67
|
+
let s = findAcc("userRenewSuffix");
|
|
68
|
+
let p = findAcc("userRenewPlain");
|
|
69
|
+
let o = findAcc("userOtherNode");
|
|
70
|
+
check("阳性: 带后缀租约 F-1-9222-4 被纯节点名 F-1 续租", s.lease.lastHealthAt > suffix.lease.assignedAt,
|
|
71
|
+
`lastHealthAt=${s.lease.lastHealthAt} assignedAt=${suffix.lease.assignedAt}`);
|
|
72
|
+
check("阳性: 精确相等 F-1 租约仍然续租", p.lease.lastHealthAt > plainBefore);
|
|
73
|
+
check("镜像: lastUsedAt/lastHealthAt 随续租刷新", s.lastUsedAt > 0 && s.lastHealthAt > 0);
|
|
74
|
+
check("阴性: F-1 不续租 F-11-8222-2(节点名前缀边界)", o.lease.lastHealthAt === otherBefore,
|
|
75
|
+
`应为 ${otherBefore},实际 ${o.lease.lastHealthAt}`);
|
|
76
|
+
|
|
77
|
+
// F-11 心跳续自己的后缀租约
|
|
78
|
+
await sleep(15);
|
|
79
|
+
heartbeatLoginPool("F-11", {});
|
|
80
|
+
o = findAcc("userOtherNode");
|
|
81
|
+
check("F-11 心跳续 F-11-8222-2", o.lease.lastHealthAt > otherBefore);
|
|
82
|
+
|
|
83
|
+
// 不变量: acquireAccount 自动释放同使用者旧租约必须精确相等
|
|
84
|
+
// (F-1 再取号只释放 F-1 的租约,F-1-9222-4/F-11-8222-2 的租约不动)
|
|
85
|
+
addAccount({ cookies: mkCookies("sid-4", "userNewTake") });
|
|
86
|
+
const t0 = findAcc("userOtherNode").lease.assignedAt;
|
|
87
|
+
acquireAccount({ userId: "F-1", username: "userNewTake" });
|
|
88
|
+
const others = getLoginPoolView().accounts;
|
|
89
|
+
check("不变量: F-1 取新号不释放后缀租约 F-1-9222-4",
|
|
90
|
+
others.find((x) => x.username === "userRenewSuffix").status === "leased");
|
|
91
|
+
check("不变量: F-1 取新号不释放 F-11-8222-2(精确相等,非前缀)",
|
|
92
|
+
others.find((x) => x.username === "userOtherNode").status === "leased" &&
|
|
93
|
+
others.find((x) => x.username === "userOtherNode").lease.assignedAt === t0);
|
|
94
|
+
check("不变量: F-1 精确相等的旧租约被自动释放",
|
|
95
|
+
others.find((x) => x.username === "userRenewPlain").status === "pool");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── A2: 手动释放盖 lastUsedAt ──
|
|
99
|
+
console.log("\n[A2] updateAccount 手动释放(leased→pool)补盖 lastUsedAt");
|
|
100
|
+
{
|
|
101
|
+
addAccount({ cookies: mkCookies("sid-5", "userStaleRelease") });
|
|
102
|
+
addAccount({ cookies: mkCookies("sid-6", "userFreshCandidate") });
|
|
103
|
+
|
|
104
|
+
acquireAccount({ userId: "A2-NODE", username: "userStaleRelease" });
|
|
105
|
+
// 模拟持有节点掉线:lastUsedAt 停在 2h 前(无心跳续租场景)
|
|
106
|
+
const stale = Date.now() - 2 * 60 * 60 * 1000;
|
|
107
|
+
for (const a of _accountsInternal()) {
|
|
108
|
+
if (a.username === "userStaleRelease") a.lastUsedAt = stale;
|
|
109
|
+
}
|
|
110
|
+
updateAccount({ id: findAcc("userStaleRelease").id, status: "pool" }); // 手动释放
|
|
111
|
+
const released = findAcc("userStaleRelease");
|
|
112
|
+
check("手动释放后 lastUsedAt 被盖为当前时间(不再停在取号时刻)",
|
|
113
|
+
released.lastUsedAt > Date.now() - 60 * 1000, `lastUsedAt=${released.lastUsedAt}`);
|
|
114
|
+
|
|
115
|
+
// 休息窗生效:30min 内另一个取号应拿从未用过的候选,而不是刚手动释放的号
|
|
116
|
+
const got = acquireAccount({ userId: "A2-OTHER", username: "userFreshCandidate" });
|
|
117
|
+
check("手动释放的号 30min 内不可再取(休息窗生效)", got.username === "userFreshCandidate",
|
|
118
|
+
`取到 ${got.username}`);
|
|
119
|
+
check("手动释放的号仍在池中未被取走", findAcc("userStaleRelease").status === "pool");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
123
|
+
process.exit(fail > 0 ? 1 : 0);
|