tt-help-cli-ycl 1.4.172 → 1.4.175
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/reset-login-pool.mjs +66 -0
- package/scripts/test-login-pool-e2e-real.mjs +134 -0
- package/scripts/test-login-pool-ui.mjs +211 -0
- package/scripts/test-login-pool.mjs +381 -0
- package/scripts/test-subscription-allocator.mjs +52 -0
- package/scripts/test-watchdog-subs-retry.mjs +123 -0
- package/src/clash/runtime.js +8 -4
- package/src/cli/explore.js +269 -23
- package/src/cli/search.js +241 -56
- package/src/cli/watchdog.js +34 -0
- package/src/lib/args.js +15 -2
- package/src/lib/browser/cdp.js +12 -2
- package/src/lib/browser/health-checker.js +16 -13
- package/src/lib/linux-env-init.js +258 -0
- package/src/lib/login-pool-client.js +156 -0
- package/src/scraper/modules/follow-extractor.js +23 -5
- package/src/scraper/modules/guess-extractor.js +4 -1
- package/src/watch/login-pool.js +755 -0
- package/src/watch/public/app.js +87 -6
- package/src/watch/public/index.html +15 -1
- package/src/watch/server.js +252 -0
- package/src/watch/subscription-allocator.js +18 -5
- package/src/watch/subscription-center.js +18 -2
- package/src/watch/watchdog-server.js +113 -0
- package/src/watchdog/agent.js +87 -281
- package/src/watchdog/login-apply.js +232 -0
- package/src/watchdog/login-import.js +177 -0
- package/src/watchdog/upgrader.js +59 -5
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享登录池 — TikTok 账号登录态集中存储 + 独占租约分配 + 心跳下发
|
|
3
|
+
*
|
|
4
|
+
* Phase 0(本期): 纯服务端能力,节点行为零改动
|
|
5
|
+
* - 账号登录态 CRUD(~/.tt-help-watchdog-logins.json,cookies 明文落盘,接口输出一律脱敏)
|
|
6
|
+
* - 独占租约: 同一账号全局只允许一个 active lease(防同账号双机并行劣化——10-25 实证坑)
|
|
7
|
+
* - 指派 = 排队 applyLogin/clearLogin 命令,随目标节点心跳一次性取走(复用 loginRecheck 模式)
|
|
8
|
+
* - 直接取号 = POST /api/login-pool/acquire(Phase 2,explore 等业务进程自取自注入,
|
|
9
|
+
* 幂等续租 + force 原子换号,响应带明文 cookies,不经命令队列)
|
|
10
|
+
* - 续租/回收: 持有节点每次心跳自动续租;节点掉线超过租约 TTL 自动回收回池
|
|
11
|
+
* - 健康采集: 心跳 body.loginPool.ports 被动入库(Phase 1 agent 开始上报;判据=业务出活,
|
|
12
|
+
* 不做接口探测——裸 fetch item_list 假阴性,10-25 教训)
|
|
13
|
+
*
|
|
14
|
+
* 与订阅中心同构的约定:
|
|
15
|
+
* - watch 主进程收心跳(唯一写入热路径),standalone 复用同一路由做展示/CRUD
|
|
16
|
+
* - 文件原子写(tmp + rename),mtime 变化时自动重读(跨进程可见)
|
|
17
|
+
* - 安全: cookies 等同账号密码。日志只打 username/计数;所有 API 输出脱敏
|
|
18
|
+
* (仅 cookieCount/hasSessionid/sessionExpiresAt);命令队列 10 分钟未被取走即作废并回收租约
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
statSync,
|
|
28
|
+
} from "fs";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
import { homedir } from "os";
|
|
31
|
+
|
|
32
|
+
const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
33
|
+
const POOL_FILE = join(homeDir, ".tt-help-watchdog-logins.json");
|
|
34
|
+
|
|
35
|
+
// ── 常量 ──
|
|
36
|
+
const SAVE_DEBOUNCE_MS = 3 * 1000;
|
|
37
|
+
const LEASE_TTL_MS = 12 * 60 * 60 * 1000; // 租约时长:持有节点心跳自动续,掉线超时回收
|
|
38
|
+
const CMD_TTL_MS = 10 * 60 * 1000; // 下发命令 10 分钟未被取走作废(节点离线),租约自动回收
|
|
39
|
+
const HISTORY_MAX = 20; // 每账号保留最近 20 条分配历史
|
|
40
|
+
const POOL_MAX = 256; // 池容量上限
|
|
41
|
+
const VALID_STATUS = ["pool", "leased", "suspect", "retired"];
|
|
42
|
+
|
|
43
|
+
// ── 内存状态 ──
|
|
44
|
+
let _accounts = []; // 账号注册表(含明文 cookies,仅服务端内存/文件持有)
|
|
45
|
+
let _loaded = false;
|
|
46
|
+
let _fileMtime = 0;
|
|
47
|
+
let _saveTimer = null;
|
|
48
|
+
|
|
49
|
+
// 下发命令队列:跨进程共享(关键!201 拓扑 assign/take 在 watch(3011),心跳取走在 standalone(17302),
|
|
50
|
+
// 进程内存队列互相看不见)→ 队列随池文件持久化(原子写+mtime 重载),双进程/单进程部署通吃
|
|
51
|
+
let _queue = []; // [{ userId, action, accountId, port, username, cookies?, proxyHint?, queuedAt }]
|
|
52
|
+
let _lastFileWriteAt = 0;
|
|
53
|
+
let _dirty = false; // 内存有未落盘变更;无变更不写文件(双进程下冗余写会踩掉对方的写入窗口)
|
|
54
|
+
|
|
55
|
+
// ── 工具 ──
|
|
56
|
+
|
|
57
|
+
function now() {
|
|
58
|
+
return Date.now();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function newId() {
|
|
62
|
+
return "acc-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pushHistory(acc, event, detail = "") {
|
|
66
|
+
acc.history = acc.history || [];
|
|
67
|
+
acc.history.unshift({ at: now(), event, detail: String(detail).slice(0, 200) });
|
|
68
|
+
if (acc.history.length > HISTORY_MAX) acc.history.length = HISTORY_MAX;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 从 cookies 数组提取账号名(无显式 username 时的兜底) */
|
|
72
|
+
export function deriveUsername(cookies) {
|
|
73
|
+
for (const c of Array.isArray(cookies) ? cookies : []) {
|
|
74
|
+
if (c && (c.name === "unique_id" || c.name === "uniqueId") && c.value) return c.value;
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findSessionid(cookies) {
|
|
80
|
+
for (const c of Array.isArray(cookies) ? cookies : []) {
|
|
81
|
+
if (c && c.name === "sessionid") return c;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 校验 cookies 形参:必须是 [{name, value, domain?, ...}] 非空数组 */
|
|
87
|
+
function normalizeCookies(input) {
|
|
88
|
+
if (!Array.isArray(input)) return null;
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const c of input) {
|
|
91
|
+
if (!c || typeof c.name !== "string" || typeof c.value !== "string") return null;
|
|
92
|
+
out.push({
|
|
93
|
+
name: c.name,
|
|
94
|
+
value: c.value,
|
|
95
|
+
domain: typeof c.domain === "string" ? c.domain : ".tiktok.com",
|
|
96
|
+
path: typeof c.path === "string" ? c.path : "/",
|
|
97
|
+
secure: c.secure !== false,
|
|
98
|
+
httpOnly: c.httpOnly !== false,
|
|
99
|
+
expires: typeof c.expires === "number" ? c.expires : undefined,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out.length > 0 ? out : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 文件读写(与订阅中心同模式:mtime 缓存 + 原子替换) ──
|
|
106
|
+
|
|
107
|
+
function ensureLoaded() {
|
|
108
|
+
let mtime = 0;
|
|
109
|
+
try {
|
|
110
|
+
mtime = statSync(POOL_FILE).mtimeMs;
|
|
111
|
+
} catch {}
|
|
112
|
+
if (_loaded && mtime === _fileMtime) return;
|
|
113
|
+
_loaded = true;
|
|
114
|
+
_fileMtime = mtime;
|
|
115
|
+
try {
|
|
116
|
+
if (existsSync(POOL_FILE)) {
|
|
117
|
+
const data = JSON.parse(readFileSync(POOL_FILE, "utf-8"));
|
|
118
|
+
_accounts = Array.isArray(data.accounts) ? data.accounts : [];
|
|
119
|
+
_queue = Array.isArray(data.queue) ? data.queue : [];
|
|
120
|
+
// useCount 迁移:老账号无该字段时按历史回填一次(assigned/acquired 各计一次使用;
|
|
121
|
+
// history 上限 20 条,回填值为下限估计)
|
|
122
|
+
for (const a of _accounts) {
|
|
123
|
+
if (a.useCount === undefined) {
|
|
124
|
+
a.useCount = (a.history || []).filter(
|
|
125
|
+
(h) => h.event === "assigned" || h.event === "acquired",
|
|
126
|
+
).length;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (mtime)
|
|
130
|
+
console.error(
|
|
131
|
+
`[登录池] 已加载: ${_accounts.length} 个账号, 待发命令 ${_queue.length} 条 (${POOL_FILE})`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.error(`[登录池] 文件加载失败(忽略,按空池启动): ${e.message}`);
|
|
136
|
+
_accounts = [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function saveNow() {
|
|
141
|
+
if (_saveTimer) {
|
|
142
|
+
clearTimeout(_saveTimer);
|
|
143
|
+
_saveTimer = null;
|
|
144
|
+
}
|
|
145
|
+
if (!_dirty) return; // 无变更不写
|
|
146
|
+
// 双进程写冲突规避(watch 3011 与 standalone 17302 共享此文件):
|
|
147
|
+
// 磁盘已被对方更新且我们没见过那份 → 重载磁盘状态、放弃本次写。
|
|
148
|
+
// 操作性变更(入池/指派/取号)都在 ensureLoaded→mutate→save 毫秒级窗口内完成,
|
|
149
|
+
// 可能被丢的只有低价值状态(如续租时间戳),下一轮心跳会重新写回。
|
|
150
|
+
try {
|
|
151
|
+
const m = statSync(POOL_FILE).mtimeMs;
|
|
152
|
+
if (_fileMtime && m !== _fileMtime) {
|
|
153
|
+
_loaded = false;
|
|
154
|
+
_dirty = false; // 磁盘为准,放弃本次未落盘变更(操作性变更窗口在毫秒级,低价值状态下轮心跳重写)
|
|
155
|
+
ensureLoaded();
|
|
156
|
+
console.error("[登录池] 检测到跨进程更新,已重载磁盘状态(放弃本次写)");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
} catch {}
|
|
160
|
+
// 内容比对去重:序列化后与磁盘一致 → 只清脏标记不动文件(mtime 不变 = 不抢对方的写入窗口)
|
|
161
|
+
const json = JSON.stringify({ accounts: _accounts, queue: _queue }, null, 2);
|
|
162
|
+
try {
|
|
163
|
+
if (readFileSync(POOL_FILE, "utf-8") === json) {
|
|
164
|
+
_dirty = false;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
} catch {}
|
|
168
|
+
const tmp = POOL_FILE + ".tmp";
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(tmp, json, "utf-8");
|
|
171
|
+
renameSync(tmp, POOL_FILE); // 原子替换,防并发读撕裂
|
|
172
|
+
_lastFileWriteAt = now();
|
|
173
|
+
_dirty = false;
|
|
174
|
+
try {
|
|
175
|
+
_fileMtime = statSync(POOL_FILE).mtimeMs;
|
|
176
|
+
} catch {}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
try {
|
|
179
|
+
unlinkSync(tmp);
|
|
180
|
+
} catch {}
|
|
181
|
+
console.error(`[登录池] 保存失败: ${e.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function scheduleSave() {
|
|
186
|
+
_dirty = true; // 无变更不写:双进程下冗余写会踩掉对方的写入窗口
|
|
187
|
+
if (_saveTimer) return;
|
|
188
|
+
_saveTimer = setTimeout(saveNow, SAVE_DEBOUNCE_MS);
|
|
189
|
+
if (_saveTimer.unref) _saveTimer.unref();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 脱敏视图(所有 API 输出唯一形态;明文 cookies 永不出服务端内存/心跳下发通道) ──
|
|
193
|
+
|
|
194
|
+
function sessionExpiresAt(acc) {
|
|
195
|
+
const s = findSessionid(acc.cookies);
|
|
196
|
+
if (!s || typeof s.expires !== "number" || s.expires <= 0) return null;
|
|
197
|
+
// cookie 规范的 expires 是秒;归一化为毫秒,前端 new Date() 直接可用
|
|
198
|
+
return s.expires < 1e12 ? Math.round(s.expires * 1000) : s.expires;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function publicView(acc) {
|
|
202
|
+
const lease = acc.lease
|
|
203
|
+
? {
|
|
204
|
+
userId: acc.lease.userId,
|
|
205
|
+
port: acc.lease.port,
|
|
206
|
+
assignedAt: acc.lease.assignedAt,
|
|
207
|
+
expireAt: acc.lease.expireAt,
|
|
208
|
+
lastHealthAt: acc.lease.lastHealthAt || null,
|
|
209
|
+
lastBusinessOkAt: acc.lease.lastBusinessOkAt || null,
|
|
210
|
+
emptyStreak: acc.lease.emptyStreak || 0,
|
|
211
|
+
lastAppliedAt: acc.lease.lastAppliedAt || null,
|
|
212
|
+
lastAppliedOk: acc.lease.lastAppliedOk != null ? acc.lease.lastAppliedOk : null,
|
|
213
|
+
persisted: acc.lease.persisted != null ? acc.lease.persisted : null,
|
|
214
|
+
appliedUsername: acc.lease.appliedUsername || null,
|
|
215
|
+
appName: acc.lease.appName || null,
|
|
216
|
+
pendingDelivery: hasPendingCommand(acc.id),
|
|
217
|
+
}
|
|
218
|
+
: null;
|
|
219
|
+
return {
|
|
220
|
+
id: acc.id,
|
|
221
|
+
username: acc.username,
|
|
222
|
+
note: acc.note || "",
|
|
223
|
+
proxyHint: acc.proxyHint || "",
|
|
224
|
+
status: acc.status,
|
|
225
|
+
cookieCount: Array.isArray(acc.cookies) ? acc.cookies.length : 0,
|
|
226
|
+
hasSessionid: !!findSessionid(acc.cookies),
|
|
227
|
+
sessionExpiresAt: sessionExpiresAt(acc),
|
|
228
|
+
useCount: acc.useCount || 0,
|
|
229
|
+
lastImportFrom: acc.lastImportFrom || null,
|
|
230
|
+
// 最近使用镜像(脱离 lease 独立存在,释放/回收后仍可见)
|
|
231
|
+
lastHealthAt: acc.lastHealthAt || null,
|
|
232
|
+
lastBusinessOkAt: acc.lastBusinessOkAt || null,
|
|
233
|
+
lastUsedAt: acc.lastUsedAt || null,
|
|
234
|
+
lease,
|
|
235
|
+
history: acc.history || [],
|
|
236
|
+
createdAt: acc.createdAt,
|
|
237
|
+
updatedAt: acc.updatedAt,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 命令队列(随池文件跨进程共享;心跳取走即清空) ──
|
|
242
|
+
|
|
243
|
+
function hasPendingCommand(accountId) {
|
|
244
|
+
return _queue.some((c) => c.accountId === accountId);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 清除某账号的所有待发命令(租约终止/状态迁移时调用,防止旧命令把登录态下发到已不持有租约的节点) */
|
|
248
|
+
function purgeCommandsFor(accountId) {
|
|
249
|
+
const kept = _queue.filter((c) => c.accountId !== accountId);
|
|
250
|
+
if (kept.length !== _queue.length) {
|
|
251
|
+
_queue = kept;
|
|
252
|
+
scheduleSave();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function enqueueCommand(userId, cmd) {
|
|
257
|
+
// 同账号同端口幂等:替换未取走的旧命令
|
|
258
|
+
const idx = _queue.findIndex(
|
|
259
|
+
(c) => c.userId === userId && c.accountId === cmd.accountId && c.port === cmd.port,
|
|
260
|
+
);
|
|
261
|
+
if (idx >= 0) _queue.splice(idx, 1);
|
|
262
|
+
cmd.queuedAt = now();
|
|
263
|
+
cmd.userId = userId;
|
|
264
|
+
_queue.push(cmd);
|
|
265
|
+
_dirty = true;
|
|
266
|
+
saveNow(); // 立即落盘(不走 3s 防抖):命令是跨进程投递的关键载荷
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function dropStaleCommands() {
|
|
270
|
+
const t = now();
|
|
271
|
+
const kept = [];
|
|
272
|
+
let expired = false;
|
|
273
|
+
for (const cmd of _queue) {
|
|
274
|
+
if (t - cmd.queuedAt > CMD_TTL_MS) {
|
|
275
|
+
expired = true;
|
|
276
|
+
// 节点离线未取走:回收租约回池,避免账号被"幽灵租约"占死
|
|
277
|
+
const acc = _accounts.find((a) => a.id === cmd.accountId);
|
|
278
|
+
if (acc && acc.status === "leased" && acc.lease) {
|
|
279
|
+
pushHistory(acc, "deliveryExpired", `命令未被 ${acc.lease.userId} 取走,租约回收`);
|
|
280
|
+
acc.status = "pool";
|
|
281
|
+
acc.lastUsedAt = t;
|
|
282
|
+
acc.lease = null;
|
|
283
|
+
acc.updatedAt = t;
|
|
284
|
+
scheduleSave();
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
kept.push(cmd);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (expired || kept.length !== _queue.length) {
|
|
291
|
+
_queue = kept;
|
|
292
|
+
scheduleSave();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── 租约维护 ──
|
|
297
|
+
|
|
298
|
+
/** 回收过期租约(节点掉线 > TTL)。返回回收数量。 */
|
|
299
|
+
export function reapExpiredLeases() {
|
|
300
|
+
ensureLoaded();
|
|
301
|
+
dropStaleCommands();
|
|
302
|
+
const t = now();
|
|
303
|
+
let reaped = 0;
|
|
304
|
+
for (const acc of _accounts) {
|
|
305
|
+
if (acc.status === "leased" && acc.lease && t - (acc.lease.lastHealthAt || acc.lease.assignedAt) > LEASE_TTL_MS) {
|
|
306
|
+
pushHistory(acc, "leaseExpired", `${acc.lease.userId} 掉线超时,自动回收`);
|
|
307
|
+
acc.status = "pool";
|
|
308
|
+
acc.lastUsedAt = t;
|
|
309
|
+
acc.lease = null;
|
|
310
|
+
acc.updatedAt = t;
|
|
311
|
+
purgeCommandsFor(acc.id);
|
|
312
|
+
reaped++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (reaped > 0) scheduleSave();
|
|
316
|
+
return reaped;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── 心跳集成(watch 主进程心跳热路径调用) ──
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 心跳处理:续租 + 采集健康数据 + 返回待下发命令。
|
|
323
|
+
* @param userId 节点 userId(body.config.userId)
|
|
324
|
+
* @param body 心跳 body(可含 loginPool: { ports: { "9222": { username, lastBusinessOkAt, emptyStreak } } })
|
|
325
|
+
* @returns loginPoolCommands(无则 undefined)
|
|
326
|
+
*/
|
|
327
|
+
export function heartbeatLoginPool(userId, body) {
|
|
328
|
+
ensureLoaded();
|
|
329
|
+
const t = now();
|
|
330
|
+
const poolBody = body && body.loginPool;
|
|
331
|
+
const report = poolBody && poolBody.ports;
|
|
332
|
+
|
|
333
|
+
// 0) 节点执行回报(applyLogin/clearLogin 结果):写回租约与历史
|
|
334
|
+
if (Array.isArray(poolBody && poolBody.applied)) {
|
|
335
|
+
for (const r of poolBody.applied) {
|
|
336
|
+
const acc = _accounts.find((a) => a.id === r.accountId);
|
|
337
|
+
if (!acc) continue;
|
|
338
|
+
if (r.action === "applyLogin") {
|
|
339
|
+
if (acc.lease && acc.lease.userId === userId) {
|
|
340
|
+
acc.lease.lastAppliedAt = r.at || t;
|
|
341
|
+
acc.lease.lastAppliedOk = !!r.ok;
|
|
342
|
+
acc.lease.persisted = !!r.persisted;
|
|
343
|
+
if (r.ok && r.username) acc.lease.appliedUsername = r.username;
|
|
344
|
+
}
|
|
345
|
+
pushHistory(
|
|
346
|
+
acc,
|
|
347
|
+
r.ok ? (r.persisted ? "applied" : "appliedVolatile") : "applyFailed",
|
|
348
|
+
`${userId}:${r.port}${r.ok ? " @" + (r.username || "") : " " + (r.error || "")}`,
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
pushHistory(acc, "cleared", `${userId}:${r.port}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
scheduleSave();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 1) 续租该节点持有的所有租约 + 采集健康数据
|
|
358
|
+
for (const acc of _accounts) {
|
|
359
|
+
if (acc.status === "leased" && acc.lease && acc.lease.userId === userId) {
|
|
360
|
+
acc.lease.lastHealthAt = t;
|
|
361
|
+
acc.lastHealthAt = t; // 镜像到账号本体,释放后仍可查
|
|
362
|
+
acc.lastUsedAt = t;
|
|
363
|
+
_dirty = true; // 续租也是变更:配合下方低频持久化落盘
|
|
364
|
+
if (report && report[acc.lease.port]) {
|
|
365
|
+
const h = report[acc.lease.port];
|
|
366
|
+
if (typeof h.lastBusinessOkAt === "number") {
|
|
367
|
+
acc.lease.lastBusinessOkAt = h.lastBusinessOkAt;
|
|
368
|
+
acc.lastBusinessOkAt = h.lastBusinessOkAt; // 镜像:最近一次业务出活
|
|
369
|
+
}
|
|
370
|
+
if (typeof h.emptyStreak === "number") acc.lease.emptyStreak = h.emptyStreak;
|
|
371
|
+
if (h.username && !acc.username) acc.username = h.username;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 1.5) 心跳续租的低频持久化:只在我们真的持有账号状态时才写,且每 5 分钟最多一次
|
|
377
|
+
// (冷启动空状态下绝不落盘,防盖掉对方刚写的数据;TTL 12h,5min 漂移可忽略)
|
|
378
|
+
if (_loaded && _accounts.length > 0 && t - _lastFileWriteAt > 300000) scheduleSave();
|
|
379
|
+
|
|
380
|
+
// 2) 取走待下发命令(一次性;跨进程:命令由 watch 进程经池文件投递)
|
|
381
|
+
const list = _queue.filter((c) => c.userId === userId);
|
|
382
|
+
let commands;
|
|
383
|
+
if (list.length > 0) {
|
|
384
|
+
_queue = _queue.filter((c) => c.userId !== userId);
|
|
385
|
+
commands = list.map((c) => ({
|
|
386
|
+
action: c.action,
|
|
387
|
+
accountId: c.accountId,
|
|
388
|
+
port: c.port,
|
|
389
|
+
username: c.username,
|
|
390
|
+
cookies: c.cookies, // 仅在心跳响应通道下发,不经任何 GET API
|
|
391
|
+
proxyHint: c.proxyHint || "",
|
|
392
|
+
}));
|
|
393
|
+
for (const c of list) {
|
|
394
|
+
const acc = _accounts.find((a) => a.id === c.accountId);
|
|
395
|
+
if (acc)
|
|
396
|
+
pushHistory(
|
|
397
|
+
acc,
|
|
398
|
+
c.action === "applyLogin" ? "delivered" : "clearDelivered",
|
|
399
|
+
`${userId}:${c.port}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
scheduleSave();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 3) 低频回收(每次心跳顺带检查,成本低)
|
|
406
|
+
reapExpiredLeases();
|
|
407
|
+
|
|
408
|
+
return commands && commands.length > 0 ? { commands } : undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── CRUD / 分配 API ──
|
|
412
|
+
|
|
413
|
+
/** 取号响应视图:publicView + 明文 cookies(仅 POST /api/login-pool/acquire 返回,属下发通道同级) */
|
|
414
|
+
function fullView(acc) {
|
|
415
|
+
return { ...publicView(acc), cookies: acc.cookies };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 池视图(脱敏) */
|
|
419
|
+
export function getLoginPoolView() {
|
|
420
|
+
ensureLoaded();
|
|
421
|
+
reapExpiredLeases();
|
|
422
|
+
return {
|
|
423
|
+
accounts: _accounts.map(publicView),
|
|
424
|
+
total: _accounts.length,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 入池:{ username, cookies, note?, proxyHint? } */
|
|
429
|
+
export function addAccount({ username, cookies, note, proxyHint } = {}) {
|
|
430
|
+
ensureLoaded();
|
|
431
|
+
const norm = normalizeCookies(cookies);
|
|
432
|
+
if (!norm) throw new Error("cookies 必须是非空数组,且每项含 name/value");
|
|
433
|
+
if (_accounts.length >= POOL_MAX) throw new Error(`池已满(上限 ${POOL_MAX})`);
|
|
434
|
+
const derived = deriveUsername(norm);
|
|
435
|
+
const name = (username || derived || "").trim();
|
|
436
|
+
if (!name) throw new Error("无法确定账号名:请传 username(或 cookies 含 unique_id)");
|
|
437
|
+
const dup = _accounts.find((a) => a.username === name && a.status !== "retired");
|
|
438
|
+
if (dup) throw new Error(`账号 ${name} 已在池中(id=${dup.id}, status=${dup.status})`);
|
|
439
|
+
const acc = {
|
|
440
|
+
id: newId(),
|
|
441
|
+
username: name,
|
|
442
|
+
note: (note || "").slice(0, 200),
|
|
443
|
+
proxyHint: (proxyHint || "").slice(0, 100),
|
|
444
|
+
cookies: norm,
|
|
445
|
+
status: "pool",
|
|
446
|
+
lease: null,
|
|
447
|
+
useCount: 0,
|
|
448
|
+
history: [],
|
|
449
|
+
createdAt: now(),
|
|
450
|
+
updatedAt: now(),
|
|
451
|
+
};
|
|
452
|
+
pushHistory(acc, "added", `入池 ${norm.length} 条 cookies`);
|
|
453
|
+
_accounts.push(acc);
|
|
454
|
+
scheduleSave();
|
|
455
|
+
return publicView(acc);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 更新元数据/状态:{ id, note?, proxyHint?, status? };status 仅允许 leased→suspect/retired/pool 的服务端安全迁移 */
|
|
459
|
+
export function updateAccount({ id, note, proxyHint, status } = {}) {
|
|
460
|
+
ensureLoaded();
|
|
461
|
+
const acc = _accounts.find((a) => a.id === id);
|
|
462
|
+
if (!acc) throw new Error(`账号不存在: ${id}`);
|
|
463
|
+
if (note !== undefined) acc.note = String(note).slice(0, 200);
|
|
464
|
+
if (proxyHint !== undefined) acc.proxyHint = String(proxyHint).slice(0, 100);
|
|
465
|
+
if (status !== undefined) {
|
|
466
|
+
if (!VALID_STATUS.includes(status)) throw new Error(`非法 status: ${status}`);
|
|
467
|
+
if (acc.status === "leased" && status === "pool") {
|
|
468
|
+
pushHistory(acc, "released", "手动释放");
|
|
469
|
+
acc.lease = null;
|
|
470
|
+
purgeCommandsFor(acc.id);
|
|
471
|
+
} else if (acc.status === "leased" && status !== "pool") {
|
|
472
|
+
pushHistory(acc, "force" + status[0].toUpperCase() + status.slice(1), "从 leased 强制迁移");
|
|
473
|
+
acc.lease = null;
|
|
474
|
+
purgeCommandsFor(acc.id);
|
|
475
|
+
}
|
|
476
|
+
pushHistory(acc, "status", `${acc.status} → ${status}`);
|
|
477
|
+
acc.status = status;
|
|
478
|
+
}
|
|
479
|
+
acc.updatedAt = now();
|
|
480
|
+
scheduleSave();
|
|
481
|
+
return publicView(acc);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 删除(retired 才可删;其余先 retired) */
|
|
485
|
+
export function deleteAccount(id) {
|
|
486
|
+
ensureLoaded();
|
|
487
|
+
const idx = _accounts.findIndex((a) => a.id === id);
|
|
488
|
+
if (idx < 0) throw new Error(`账号不存在: ${id}`);
|
|
489
|
+
const acc = _accounts[idx];
|
|
490
|
+
if (acc.status === "leased") throw new Error("账号在租约中,先 release 再删除");
|
|
491
|
+
_accounts.splice(idx, 1);
|
|
492
|
+
scheduleSave();
|
|
493
|
+
return { id, username: acc.username };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 指派:{ id, userId, port }
|
|
498
|
+
* 独占约束:① 账号必须 status=pool(suspect 需 force)② 该账号不得已有 lease ③ 目标 (userId,port) 不得持有其他账号
|
|
499
|
+
*/
|
|
500
|
+
export function assignAccount({ id, userId, port, force } = {}) {
|
|
501
|
+
ensureLoaded();
|
|
502
|
+
const acc = _accounts.find((a) => a.id === id);
|
|
503
|
+
if (!acc) throw new Error(`账号不存在: ${id}`);
|
|
504
|
+
if (!userId || !port) throw new Error("userId 和 port 必填");
|
|
505
|
+
port = Number(port);
|
|
506
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error(`非法端口: ${port}`);
|
|
507
|
+
if (acc.status === "leased" && acc.lease) throw new Error(`账号已租给 ${acc.lease.userId}:${acc.lease.port},先 release`);
|
|
508
|
+
if (acc.status === "retired") throw new Error("账号已下架(retired),不可指派");
|
|
509
|
+
if (acc.status === "suspect" && !force) throw new Error("账号为 suspect 状态,确认可用请带 force=true");
|
|
510
|
+
|
|
511
|
+
// 目标端口独占检查
|
|
512
|
+
const occupant = _accounts.find(
|
|
513
|
+
(a) => a.status === "leased" && a.lease && a.lease.userId === userId && a.lease.port === port,
|
|
514
|
+
);
|
|
515
|
+
if (occupant) {
|
|
516
|
+
if (!force) throw new Error(`${userId}:${port} 已持有 ${occupant.username}(id=${occupant.id}),替换请带 force=true`);
|
|
517
|
+
// 自动替换:旧账号回收(清其遗留命令)+ 节点下发 clearLogin
|
|
518
|
+
purgeCommandsFor(occupant.id);
|
|
519
|
+
enqueueCommand(userId, {
|
|
520
|
+
action: "clearLogin",
|
|
521
|
+
accountId: occupant.id,
|
|
522
|
+
port,
|
|
523
|
+
username: occupant.username,
|
|
524
|
+
});
|
|
525
|
+
pushHistory(occupant, "evicted", `被 ${acc.username} 替换下机`);
|
|
526
|
+
occupant.status = "pool";
|
|
527
|
+
occupant.lastUsedAt = now();
|
|
528
|
+
occupant.lease = null;
|
|
529
|
+
occupant.updatedAt = now();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const t = now();
|
|
533
|
+
acc.status = "leased";
|
|
534
|
+
acc.useCount = (acc.useCount || 0) + 1;
|
|
535
|
+
acc.lease = {
|
|
536
|
+
userId,
|
|
537
|
+
port,
|
|
538
|
+
assignedAt: t,
|
|
539
|
+
expireAt: t + LEASE_TTL_MS,
|
|
540
|
+
lastHealthAt: t,
|
|
541
|
+
lastBusinessOkAt: null,
|
|
542
|
+
emptyStreak: null,
|
|
543
|
+
};
|
|
544
|
+
acc.updatedAt = t;
|
|
545
|
+
pushHistory(acc, "assigned", `${userId}:${port}${force ? " (force)" : ""}`);
|
|
546
|
+
enqueueCommand(userId, {
|
|
547
|
+
action: "applyLogin",
|
|
548
|
+
accountId: acc.id,
|
|
549
|
+
port,
|
|
550
|
+
username: acc.username,
|
|
551
|
+
cookies: acc.cookies,
|
|
552
|
+
proxyHint: acc.proxyHint || "",
|
|
553
|
+
});
|
|
554
|
+
scheduleSave();
|
|
555
|
+
return publicView(acc);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 释放:{ id, clearOnNode? }
|
|
560
|
+
* clearOnNode=true 时同时下发 clearLogin(清理节点端口上的登录态,默认 false —— 换机不清理,
|
|
561
|
+
* 便于回滚;真要下机清理时显式传)
|
|
562
|
+
*/
|
|
563
|
+
export function releaseAccount({ id, clearOnNode } = {}) {
|
|
564
|
+
ensureLoaded();
|
|
565
|
+
const acc = _accounts.find((a) => a.id === id);
|
|
566
|
+
if (!acc) throw new Error(`账号不存在: ${id}`);
|
|
567
|
+
if (acc.status !== "leased" || !acc.lease) throw new Error("账号不在租约中");
|
|
568
|
+
const { userId, port } = acc.lease;
|
|
569
|
+
acc.status = "pool";
|
|
570
|
+
acc.lastUsedAt = now();
|
|
571
|
+
acc.lease = null;
|
|
572
|
+
acc.updatedAt = now();
|
|
573
|
+
pushHistory(acc, "released", `${userId}:${port}${clearOnNode ? " (清理节点)" : ""}`);
|
|
574
|
+
purgeCommandsFor(acc.id); // 先清该账号遗留命令,再按需下发 clearLogin
|
|
575
|
+
if (clearOnNode) {
|
|
576
|
+
enqueueCommand(userId, {
|
|
577
|
+
action: "clearLogin",
|
|
578
|
+
accountId: acc.id,
|
|
579
|
+
port,
|
|
580
|
+
username: acc.username,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
scheduleSave();
|
|
584
|
+
return publicView(acc);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 自动取号(客户端拉取):节点程序需要账号时按 API 自取。
|
|
589
|
+
* { userId, port, username?, force? } — 传 username 优先指派该账号,否则按入池先后取首个 pool 态账号。
|
|
590
|
+
* 复用 assignAccount 的独占约束与命令下发,账号从 pool → leased。
|
|
591
|
+
*/
|
|
592
|
+
export function takeAccount({ userId, port, username, force } = {}) {
|
|
593
|
+
ensureLoaded();
|
|
594
|
+
reapExpiredLeases();
|
|
595
|
+
if (!userId || !port) throw new Error("userId 和 port 必填");
|
|
596
|
+
let acc;
|
|
597
|
+
if (username) {
|
|
598
|
+
acc = _accounts.find((a) => a.username === username);
|
|
599
|
+
if (!acc) throw new Error(`池中无此账号: ${username}`);
|
|
600
|
+
} else {
|
|
601
|
+
acc = _accounts.find((a) => a.status === "pool");
|
|
602
|
+
if (!acc) {
|
|
603
|
+
const leased = _accounts.filter((a) => a.status === "leased").length;
|
|
604
|
+
throw new Error(`池中暂无空闲账号(共 ${_accounts.length} 个,其中 ${leased} 个在租约中)`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return assignAccount({ id: acc.id, userId, port, force });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 直接取号(Phase 2,以号为主键):业务进程自取账号,拿到明文 cookies 后
|
|
612
|
+
* 在自有浏览器实例上原地注入,不经心跳命令队列、不依赖节点 watchdog。
|
|
613
|
+
*
|
|
614
|
+
* 语义(2026-10-26 用户定稿:以号为主键,无幂等/无端口键):
|
|
615
|
+
* - 取号 = 挑最久未用的空闲号 → 标 leased → 响应带明文 cookies
|
|
616
|
+
* - 独占由池状态机保证(take 即 leased,同号不双机并行);
|
|
617
|
+
* 切换 = 先取新号、验证通过后再还旧号(无空窗,池空保旧)
|
|
618
|
+
* - 崩溃/异常未还号的租约由 TTL 兑底回收(或账号中心手动释放),不做幂等恢复
|
|
619
|
+
* - port 可选:仅作展示元数据(浏览器口),不参与任何键/约束
|
|
620
|
+
* - appName: 租约元数据仅记录展示(explore/search...),不参与分配
|
|
621
|
+
*/
|
|
622
|
+
export function acquireAccount({ userId, port, appName } = {}) {
|
|
623
|
+
ensureLoaded();
|
|
624
|
+
reapExpiredLeases();
|
|
625
|
+
if (!userId) throw new Error("userId 必填");
|
|
626
|
+
let portNum = null;
|
|
627
|
+
if (port !== undefined && port !== null && port !== "") {
|
|
628
|
+
portNum = Number(port);
|
|
629
|
+
if (!Number.isInteger(portNum) || portNum < 1024 || portNum > 65535) {
|
|
630
|
+
throw new Error(`非法端口: ${port}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const t = now();
|
|
634
|
+
|
|
635
|
+
// ⑥ 双键排序:先用次数少的优先(永不用过的最优先),同次数再按最近使用时间升序
|
|
636
|
+
const pool = _accounts.filter((a) => a.status === "pool");
|
|
637
|
+
const sortFn =
|
|
638
|
+
(a, b) =>
|
|
639
|
+
(a.useCount || 0) - (b.useCount || 0) ||
|
|
640
|
+
(a.lastUsedAt || a.createdAt) - (b.lastUsedAt || b.createdAt);
|
|
641
|
+
// ⑥ 错峰冷却:刚归还的号(lastUsedAt 在冷却窗内)先排除,避免同号被连续调用;
|
|
642
|
+
// 全部在冷却时退化为全池排序(有得用总比没用好)。从未用过的号无 lastUsedAt,不受冷却影响
|
|
643
|
+
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
644
|
+
const cooled = pool
|
|
645
|
+
.filter((a) => !a.lastUsedAt || a.lastUsedAt <= t - COOLDOWN_MS)
|
|
646
|
+
.sort(sortFn);
|
|
647
|
+
const acc = cooled[0] || pool.slice().sort(sortFn)[0];
|
|
648
|
+
if (!acc) {
|
|
649
|
+
const leased = _accounts.filter((a) => a.status === "leased").length;
|
|
650
|
+
throw new Error(`池中暂无空闲账号(共 ${_accounts.length} 个,其中 ${leased} 个在租约中)`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
acc.status = "leased";
|
|
654
|
+
acc.useCount = (acc.useCount || 0) + 1;
|
|
655
|
+
acc.lastUsedAt = t; // 最近使用时间(取号即「开始使用」)
|
|
656
|
+
acc.lease = {
|
|
657
|
+
userId,
|
|
658
|
+
port: portNum,
|
|
659
|
+
appName: appName ? String(appName).slice(0, 50) : null,
|
|
660
|
+
assignedAt: t,
|
|
661
|
+
expireAt: t + LEASE_TTL_MS,
|
|
662
|
+
lastHealthAt: t,
|
|
663
|
+
lastBusinessOkAt: null,
|
|
664
|
+
emptyStreak: null,
|
|
665
|
+
};
|
|
666
|
+
acc.updatedAt = t;
|
|
667
|
+
pushHistory(acc, "acquired", `${userId}${portNum ? ":" + portNum : ""} 直接取号`);
|
|
668
|
+
|
|
669
|
+
// ③ 一程序实例恒持一号:自动释放该使用者名下的其他旧租约
|
|
670
|
+
// (换号 = 取新+还旧一步完成;崩溃重启后的首次取号自愈清僵尸租约;
|
|
671
|
+
// 旧号释放后 lastUsedAt=now 垫底,不会被下一个消费者立刻拿走)
|
|
672
|
+
const releasedOthers = [];
|
|
673
|
+
for (const other of _accounts) {
|
|
674
|
+
if (
|
|
675
|
+
other.id !== acc.id &&
|
|
676
|
+
other.status === "leased" &&
|
|
677
|
+
other.lease &&
|
|
678
|
+
other.lease.userId === userId
|
|
679
|
+
) {
|
|
680
|
+
pushHistory(other, "released", `自动释放(${userId} 取新号)`);
|
|
681
|
+
other.status = "pool";
|
|
682
|
+
other.lastUsedAt = t;
|
|
683
|
+
other.lease = null;
|
|
684
|
+
other.updatedAt = t;
|
|
685
|
+
purgeCommandsFor(other.id);
|
|
686
|
+
releasedOthers.push(other.username);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (releasedOthers.length > 0) {
|
|
690
|
+
pushHistory(
|
|
691
|
+
acc,
|
|
692
|
+
"autoRelease",
|
|
693
|
+
`已自动释放同使用者旧号: ${releasedOthers.join(", ")}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
scheduleSave();
|
|
697
|
+
return fullView(acc);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* 导入登录态(节点 → 账号中心):
|
|
702
|
+
* 看门狗启动同步调用。按 username 去重:
|
|
703
|
+
* - 已在池 → 幂等返回现状(不覆盖 cookies、不动租约)
|
|
704
|
+
* - 新号 → 入池为【空闲】(不自动租——导入只是登记,租约只由真实消费产生;
|
|
705
|
+
* pool 模式消费方启动时自会 acquire,那时才变「使用中」)
|
|
706
|
+
* username 必须来自 passport 活体验证(调用方已验),cookies 必须非空。
|
|
707
|
+
*/
|
|
708
|
+
export function importAccount({ userId, port, username, cookies, note, proxyHint } = {}) {
|
|
709
|
+
ensureLoaded();
|
|
710
|
+
reapExpiredLeases();
|
|
711
|
+
if (!userId) throw new Error("userId 必填");
|
|
712
|
+
if (!username) throw new Error("username 必填(须为 passport 验证过的登录号)");
|
|
713
|
+
const norm = normalizeCookies(cookies);
|
|
714
|
+
if (!norm) throw new Error("cookies 必须是非空数组,且每项含 name/value");
|
|
715
|
+
let portNum = null;
|
|
716
|
+
if (port !== undefined && port !== null && port !== "") {
|
|
717
|
+
portNum = Number(port);
|
|
718
|
+
if (!Number.isInteger(portNum) || portNum < 1024 || portNum > 65535) {
|
|
719
|
+
throw new Error(`非法端口: ${port}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const t = now();
|
|
723
|
+
|
|
724
|
+
const dup = _accounts.find((a) => a.username === username && a.status !== "retired");
|
|
725
|
+
if (dup) {
|
|
726
|
+
// 幂等:不覆盖 cookies、不动租约
|
|
727
|
+
return { account: publicView(dup), dedup: true };
|
|
728
|
+
}
|
|
729
|
+
if (_accounts.length >= POOL_MAX) throw new Error(`池已满(上限 ${POOL_MAX})`);
|
|
730
|
+
|
|
731
|
+
const acc = {
|
|
732
|
+
id: newId(),
|
|
733
|
+
username,
|
|
734
|
+
note: (note || "").slice(0, 200),
|
|
735
|
+
proxyHint: (proxyHint || "").slice(0, 100),
|
|
736
|
+
cookies: norm,
|
|
737
|
+
status: "pool",
|
|
738
|
+
lease: null,
|
|
739
|
+
useCount: 0, // 导入是登记不是消费;次数只在 acquire/assign 时 +1
|
|
740
|
+
lastImportFrom: { userId, port: portNum, at: t }, // 来源元数据(供节点热路径跳过 + 账号中心展示),非租约
|
|
741
|
+
history: [],
|
|
742
|
+
createdAt: t,
|
|
743
|
+
updatedAt: t,
|
|
744
|
+
};
|
|
745
|
+
pushHistory(acc, "imported", `${userId} 节点同步导入`);
|
|
746
|
+
_accounts.push(acc);
|
|
747
|
+
scheduleSave();
|
|
748
|
+
return { account: fullView(acc), dedup: false };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** 内部:注入账号(测试/迁移工具用,跳过命令队列直接改状态) */
|
|
752
|
+
export function _accountsInternal() {
|
|
753
|
+
ensureLoaded();
|
|
754
|
+
return _accounts;
|
|
755
|
+
}
|