tt-help-cli-ycl 1.4.183 → 1.4.184

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 客户端错误上报封装(方案甲:客户端报错自带字段)
3
+ *
4
+ * 背景:/api/error-report 的旧 username 字段语义混乱(在手池号/"account-switch"
5
+ * 占位/被探索目标/"unknown" 四种混传),且代理节点完全没报。本封装统一附加:
6
+ * - poolUsername:报错时在手池号(getState 注入,无在手号空串,不传 "unknown")
7
+ * - proxyNode:当前 clash 节点名(三级 fallback:端口匹配通道 → 主通道 → 空串)
8
+ * 旧 username 字段原样透传(兼容存量页面显示),语义不动。
9
+ *
10
+ * 用法:
11
+ * const errorReporter = createErrorReporter({
12
+ * apiPost, serverUrl, userId,
13
+ * proxyUrl: resolvedProxy, // 可选,缺省用 ~/.tt-help.json 的 proxy
14
+ * getState: () => ({ poolUsername: poolAccount?.username || "" }),
15
+ * });
16
+ * await errorReporter.report({ errorType, errorMessage, stage, errorStack, username });
17
+ *
18
+ * 单测:scripts/test-error-report.mjs
19
+ */
20
+
21
+ import { proxy as configuredProxy } from "./constants.js";
22
+ import {
23
+ getClashHealth,
24
+ getClashHealthChannels,
25
+ } from "../watchdog/reporter.js";
26
+
27
+ /** 从代理地址提取端口(http://127.0.0.1:7897 → 7897;解析不出返回 0) */
28
+ export function parseProxyPort(url) {
29
+ if (!url) return 0;
30
+ try {
31
+ const u = new URL(url);
32
+ const p = parseInt(u.port, 10);
33
+ if (p > 0) return p;
34
+ } catch {
35
+ /* 非 URL 形态走正则兑底 */
36
+ }
37
+ const m = String(url).match(/:(\d+)/);
38
+ return m ? parseInt(m[1], 10) || 0 : 0;
39
+ }
40
+
41
+ /**
42
+ * 解析当前 clash 节点名(三级 fallback):
43
+ * 1. 与业务代理端口匹配且非 stale 的通道(clash-health-{port}.json)的 node
44
+ * 2. 主通道(clash-health.json,daemon 对主控制端双写)非 stale 的 node
45
+ * 3. 读不到(无 clash daemon / 文件 stale / 无 node 字段)→ ""
46
+ *
47
+ * @param {string|null} proxyUrl - 业务代理地址(--proxy 或 ~/.tt-help.json 的 proxy)
48
+ * @param {{channels?: Array<object>, health?: object|null}} [deps] - 测试注入用
49
+ * @returns {string}
50
+ */
51
+ export function resolveProxyNode(proxyUrl, deps = {}) {
52
+ const channels = deps.channels ?? getClashHealthChannels();
53
+ const health = deps.health !== undefined ? deps.health : getClashHealth();
54
+ const port = parseProxyPort(proxyUrl);
55
+ if (port > 0) {
56
+ const ch = channels.find((c) => c && c.port === port && !c.stale);
57
+ if (ch && ch.node) return String(ch.node);
58
+ }
59
+ if (health && !health.stale && health.node) return String(health.node);
60
+ return "";
61
+ }
62
+
63
+ /**
64
+ * 创建错误上报器
65
+ *
66
+ * @param {object} opts
67
+ * @param {(url: string, body: object) => Promise<any>} opts.apiPost - API 客户端 POST(createApiClient 产出)
68
+ * @param {string} opts.serverUrl - watch 服务端地址
69
+ * @param {string} opts.userId - 客户端用户编号(机器级;单次调用可用 userId 覆盖)
70
+ * @param {string|null} [opts.proxyUrl] - 业务代理地址(缺省读 ~/.tt-help.json 的 proxy)
71
+ * @param {() => {poolUsername?: string}} [opts.getState] - 报错时刻状态(CLI 闭包注入在手池号)
72
+ * @returns {{ report: (info: object) => Promise<any> }}
73
+ */
74
+ export function createErrorReporter({
75
+ apiPost,
76
+ serverUrl,
77
+ userId,
78
+ proxyUrl,
79
+ getState,
80
+ }) {
81
+ if (!apiPost || !serverUrl) {
82
+ throw new Error("createErrorReporter 需要 apiPost 与 serverUrl");
83
+ }
84
+ // proxyUrl 未显式传(undefined)才回退配置文件;显式传 null 表示确无代理
85
+ const effectiveProxyUrl = proxyUrl === undefined ? configuredProxy : proxyUrl;
86
+
87
+ return {
88
+ /**
89
+ * 上报一条客户端异常
90
+ * @param {object} info
91
+ * @param {string} [info.userId] - 单次覆盖(如 search 每轮标签搜索的随机 clientId)
92
+ * @param {string} info.errorType
93
+ * @param {string} [info.errorMessage]
94
+ * @param {string} [info.stage]
95
+ * @param {string} [info.errorStack]
96
+ * @param {string} [info.username] - 旧字段原样透传(兼容),语义不动
97
+ */
98
+ async report({
99
+ userId: callUserId,
100
+ errorType,
101
+ errorMessage,
102
+ stage,
103
+ errorStack,
104
+ username,
105
+ } = {}) {
106
+ const state = getState ? getState() || {} : {};
107
+ return apiPost(`${serverUrl}/api/error-report`, {
108
+ userId: callUserId || userId,
109
+ username: username || "",
110
+ errorType: errorType || "other",
111
+ errorMessage: errorMessage || "",
112
+ stage: stage || "",
113
+ errorStack: errorStack || "",
114
+ poolUsername: state.poolUsername || "",
115
+ proxyNode: resolveProxyNode(effectiveProxyUrl),
116
+ });
117
+ },
118
+ };
119
+ }
@@ -193,3 +193,59 @@ export async function ensurePoolLogin(page, account) {
193
193
  }));
194
194
  return { ...r, injected: true };
195
195
  }
196
+
197
+ // ── 崩溃熔断与恢复重试(纯函数/纯常量,2026-11-04 F16 实录驱动)──
198
+ // 背景:渲染进程崩溃(Target/Page crashed)曾被误判为「账号注入失败」→ 换号循环烧池
199
+ // (单日 28 号)。浏览器级失败须熔断退避(不取号保护账号池),账号级失败才候选轮换。
200
+ // 本组常量/函数保持零依赖(login-pool-client 同时被 watchdog 侧引用,勿引入 playwright 链)。
201
+
202
+ /** 崩溃熔断退避基数:第 1 轮 60s */
203
+ export const CRASH_BACKOFF_BASE_MS = 60 * 1000;
204
+ /** 崩溃熔断退避上限:15 分钟 */
205
+ export const CRASH_BACKOFF_MAX_MS = 15 * 60 * 1000;
206
+
207
+ /**
208
+ * 崩溃熔断退避时长:第 n 轮 = base × 2^(n-1),封顶 max;n 非正整数返回 0。
209
+ * 注入验证成功即清零轮数(清零由调用方闭包状态负责)。
210
+ */
211
+ export function crashBackoffMs(crashStreak, base = CRASH_BACKOFF_BASE_MS, max = CRASH_BACKOFF_MAX_MS) {
212
+ if (!Number.isInteger(crashStreak) || crashStreak <= 0) return 0;
213
+ return Math.min(base * 2 ** (crashStreak - 1), max);
214
+ }
215
+
216
+ /** 登录态恢复首败后追加重试次数(共 3 次尝试,全败才弃号转 recover_fail) */
217
+ export const RESTORE_RETRY_MAX_RETRIES = 2;
218
+ /** 恢复重试间隔 */
219
+ export const RESTORE_RETRY_DELAY_MS = 10 * 1000;
220
+
221
+ /**
222
+ * 登录态恢复重试驱动(纯逻辑,I/O 与谓词均注入,可单测;本文件保持零依赖):
223
+ * 首次尝试已失败(firstFailure 为其失败原因),谓词判定为导航类瞬态失败则再试
224
+ * maxRetries 次、间隔 delayMs;账号级失败不重试直接返回。
225
+ * 调用方须传入 isRetryable(客户端用 page.js 的 isRetryableRestoreFailure)。
226
+ * @param {() => Promise<{ok: boolean, reason?: string}>} attemptFn 重试尝试
227
+ * @param {string} firstFailure 首次失败原因
228
+ * @param {object} opts {isRetryable, maxRetries, delayMs, sleep, shouldAbort}
229
+ * (sleep/shouldAbort 供调用方接入分片中断)
230
+ * @returns {Promise<{ok: boolean, reason?: string, attempts: number, aborted?: boolean}>}
231
+ */
232
+ export async function retryRestoreOnNavFailure(attemptFn, firstFailure, opts = {}) {
233
+ const isRetryable = opts.isRetryable;
234
+ const maxRetries = opts.maxRetries ?? RESTORE_RETRY_MAX_RETRIES;
235
+ const delayMs = opts.delayMs ?? RESTORE_RETRY_DELAY_MS;
236
+ const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
237
+ const shouldAbort = opts.shouldAbort || (() => false);
238
+ let reason = firstFailure;
239
+ let attempts = 1; // 首次尝试已发生
240
+ while (typeof isRetryable === "function" && attempts <= maxRetries && isRetryable(reason)) {
241
+ await sleep(delayMs);
242
+ if (shouldAbort()) return { ok: false, reason, attempts, aborted: true };
243
+ attempts++;
244
+ const r = await Promise.resolve()
245
+ .then(attemptFn)
246
+ .catch((e) => ({ ok: false, reason: e?.message || String(e) }));
247
+ if (r.ok) return { ...r, attempts };
248
+ reason = r.reason;
249
+ }
250
+ return { ok: false, reason, attempts };
251
+ }
@@ -2391,6 +2391,23 @@ export function createStore(filePath, options = {}) {
2391
2391
  return { saved: true };
2392
2392
  }
2393
2393
 
2394
+ /**
2395
+ * 自动恢复候选:updated_at 早于 maxAgeMs 的 error 行(B 方案:无次数上限全自动回流)。
2396
+ * 仅扫 jobs——commitJob 会把 error 行从 task_queue 搬到 jobs(与页面「批量重新处理」同口径),
2397
+ * 回流本身交给 resetJob(三表迁移 + 空池缓存失效 + 统计置脏全复用)。
2398
+ * @returns {string[]} unique_id 列表(无 DB 的 JSON 模式返回空)
2399
+ */
2400
+ function listStaleErrorJobs(maxAgeMs, limit = 500) {
2401
+ if (!getDb()) return [];
2402
+ const cutoff = Date.now() - maxAgeMs;
2403
+ return getDb()
2404
+ .prepare(
2405
+ `SELECT unique_id FROM jobs WHERE status = 'error' AND updated_at < ? ORDER BY updated_at LIMIT ?`,
2406
+ )
2407
+ .all(cutoff, limit)
2408
+ .map((r) => r.unique_id);
2409
+ }
2410
+
2394
2411
  function togglePin(uniqueId) {
2395
2412
  if (getDb()) {
2396
2413
  const user = getJob(uniqueId);
@@ -2686,6 +2703,8 @@ export function createStore(filePath, options = {}) {
2686
2703
  errorStack,
2687
2704
  hostname,
2688
2705
  clientId,
2706
+ poolUsername,
2707
+ proxyNode,
2689
2708
  ) {
2690
2709
  const existing = clientErrors.get(userId);
2691
2710
  if (existing) {
@@ -2708,6 +2727,9 @@ export function createStore(filePath, options = {}) {
2708
2727
  existing.reportCount = (existing.reportCount || 1) + 1;
2709
2728
  }
2710
2729
  if (username) existing.username = username;
2730
+ // 在手池号/代理节点(方案甲新增):非空才覆盖,同 username 语义
2731
+ if (poolUsername) existing.poolUsername = poolUsername;
2732
+ if (proxyNode) existing.proxyNode = proxyNode;
2711
2733
  } else {
2712
2734
  clientErrors.set(userId, {
2713
2735
  userId,
@@ -2718,6 +2740,8 @@ export function createStore(filePath, options = {}) {
2718
2740
  errorStack: errorStack || "",
2719
2741
  username,
2720
2742
  stage: stage || "",
2743
+ poolUsername: poolUsername || "",
2744
+ proxyNode: proxyNode || "",
2721
2745
  timestamp: Date.now(),
2722
2746
  reportCount: 1,
2723
2747
  captchaCount: errorType === "captcha" ? 1 : 0,
@@ -3816,6 +3840,7 @@ export function createStore(filePath, options = {}) {
3816
3840
  commitJob,
3817
3841
  commitNewExplore,
3818
3842
  resetJob,
3843
+ listStaleErrorJobs,
3819
3844
  togglePin,
3820
3845
  getNextRedoJob,
3821
3846
  commitRedoJob,
@@ -34,11 +34,17 @@ const POOL_FILE = join(homeDir, ".tt-help-watchdog-logins.json");
34
34
 
35
35
  // ── 常量 ──
36
36
  const SAVE_DEBOUNCE_MS = 3 * 1000;
37
- const LEASE_TTL_MS = 12 * 60 * 60 * 1000; // 租约时长:持有节点心跳自动续,掉线超时回收
37
+ const ZOMBIE_LEASE_MS = 2 * 60 * 60 * 1000; // 租约持有硬顶:从取号起算超 2h 视为僵尸回收(2026-11-03 砍掉心跳续期,无续期语义)
38
+ const ERROR_REASONS = new Set(["banned", "captcha", "network"]); // 换号原因中的错误类;scheduled=健康证明清零;inject_fail/recover_fail/无原因=中性忽略
38
39
  const CMD_TTL_MS = 10 * 60 * 1000; // 下发命令 10 分钟未被取走作废(节点离线),租约自动回收
39
40
  const HISTORY_MAX = 20; // 每账号保留最近 20 条分配历史
40
41
  const POOL_MAX = 256; // 池容量上限
41
42
  const VALID_STATUS = ["pool", "leased", "suspect", "retired"];
43
+ // faceBans(2026-11-02):按数据面标注可用性——登录态有效但 explore/search 某一面拿不到数据
44
+ // 的「面残缺号」。与 status 正交(leased 号也可标注,元数据不动租约,下次派发生效);
45
+ // 缺失/null = 未验证 = 默认可用,不做数据迁移,读取懒判空。
46
+ const VALID_FACES = ["explore", "search"];
47
+ const VALID_FACE_SOURCES = ["manual", "probe"]; // 标注来源:人工(页面/API)/探针(verify-env 账号闸)
42
48
 
43
49
  // ── 内存状态 ──
44
50
  let _accounts = []; // 账号注册表(含明文 cookies,仅服务端内存/文件持有)
@@ -68,6 +74,17 @@ function pushHistory(acc, event, detail = "") {
68
74
  if (acc.history.length > HISTORY_MAX) acc.history.length = HISTORY_MAX;
69
75
  }
70
76
 
77
+ /** 请求面判定:appName === 'search' → search 面;其余(含未知/缺省)一律 explore 面 */
78
+ export function faceOfAppName(appName) {
79
+ return appName === "search" ? "search" : "explore";
80
+ }
81
+
82
+ /** 该账号该面是否被禁用:仅显式 ban=true 才算;缺失/null/字段残缺 = 未验证 = 可用 */
83
+ export function isFaceBanned(acc, face) {
84
+ const e = acc && acc.faceBans && acc.faceBans[face];
85
+ return !!(e && e.ban === true);
86
+ }
87
+
71
88
  /** 从 cookies 数组提取账号名(无显式 username 时的兜底) */
72
89
  export function deriveUsername(cookies) {
73
90
  for (const c of Array.isArray(cookies) ? cookies : []) {
@@ -204,10 +221,6 @@ function publicView(acc) {
204
221
  userId: acc.lease.userId,
205
222
  port: acc.lease.port,
206
223
  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
224
  lastAppliedAt: acc.lease.lastAppliedAt || null,
212
225
  lastAppliedOk: acc.lease.lastAppliedOk != null ? acc.lease.lastAppliedOk : null,
213
226
  persisted: acc.lease.persisted != null ? acc.lease.persisted : null,
@@ -230,11 +243,17 @@ function publicView(acc) {
230
243
  // 最近使用镜像(脱离 lease 独立存在,释放/回收后仍可见)
231
244
  lastHealthAt: acc.lastHealthAt || null,
232
245
  lastHealth: acc.lastHealth || null,
233
- lastBusinessOkAt: acc.lastBusinessOkAt || null,
234
246
  lastUsedAt: acc.lastUsedAt || null,
235
247
  lastAppName: acc.lastAppName || null,
236
248
  prevAppName: acc.prevAppName || null,
237
- switchStats: acc.switchStats || {},
249
+ lastSwitchReason: acc.lastSwitchReason || null,
250
+ switchStreak: acc.switchStreak || 0,
251
+ errStreak: acc.errStreak || 0,
252
+ // 面标注(2026-11-02 faceBans):{ban,source,at,by?} | null,null=未验证=默认可用
253
+ faceBans: {
254
+ explore: (acc.faceBans && acc.faceBans.explore) || null,
255
+ search: (acc.faceBans && acc.faceBans.search) || null,
256
+ },
238
257
  lease,
239
258
  history: acc.history || [],
240
259
  createdAt: acc.createdAt,
@@ -300,14 +319,20 @@ function dropStaleCommands() {
300
319
  // ── 租约维护 ──
301
320
 
302
321
  /** 回收过期租约(节点掉线 > TTL)。返回回收数量。 */
303
- export function reapExpiredLeases() {
322
+ /**
323
+ * 僵尸租约回收(2026-11-03 替代原 reapExpiredLeases/12h TTL):无续期语义,
324
+ * 从 assignedAt(取号时刻)硬顶 ZOMBIE_LEASE_MS=2h,超时即回收。由各 API 入口
325
+ * 与心跳尾部调用(每次调用顺带执行一遍,成本低)。
326
+ * 返回回收数量。
327
+ */
328
+ export function reapZombieLeases() {
304
329
  ensureLoaded();
305
330
  dropStaleCommands();
306
331
  const t = now();
307
332
  let reaped = 0;
308
333
  for (const acc of _accounts) {
309
- if (acc.status === "leased" && acc.lease && t - (acc.lease.lastHealthAt || acc.lease.assignedAt) > LEASE_TTL_MS) {
310
- pushHistory(acc, "leaseExpired", `${acc.lease.userId} 掉线超时,自动回收`);
334
+ if (acc.status === "leased" && acc.lease && t - acc.lease.assignedAt > ZOMBIE_LEASE_MS) {
335
+ pushHistory(acc, "zombieReaped", `${acc.lease.userId}:${acc.lease.port} 持有超2h,僵尸回收`);
311
336
  acc.status = "pool";
312
337
  acc.lastUsedAt = t;
313
338
  acc.lease = null;
@@ -323,16 +348,17 @@ export function reapExpiredLeases() {
323
348
  // ── 心跳集成(watch 主进程心跳热路径调用) ──
324
349
 
325
350
  /**
326
- * 心跳处理:续租 + 采集健康数据 + 返回待下发命令。
351
+ * 心跳处理:写回命令执行回报 + 返回待下发命令。
352
+ * 2026-11-03 砍掉续租与全部盖章/镜像(lastHealthAt/lastUsedAt/lastBusinessOkAt/emptyStreak
353
+ * 不再被心跳触碰),僵尸租约由 reapZombieLeases 按 assignedAt 硬顶回收。
327
354
  * @param userId 节点 userId(body.config.userId)
328
- * @param body 心跳 body(可含 loginPool: { ports: { "9222": { username, lastBusinessOkAt, emptyStreak } } })
355
+ * @param body 心跳 body
329
356
  * @returns loginPoolCommands(无则 undefined)
330
357
  */
331
358
  export function heartbeatLoginPool(userId, body) {
332
359
  ensureLoaded();
333
360
  const t = now();
334
361
  const poolBody = body && body.loginPool;
335
- const report = poolBody && poolBody.ports;
336
362
 
337
363
  // 0) 节点执行回报(applyLogin/clearLogin 结果):写回租约与历史
338
364
  if (Array.isArray(poolBody && poolBody.applied)) {
@@ -358,37 +384,8 @@ export function heartbeatLoginPool(userId, body) {
358
384
  scheduleSave();
359
385
  }
360
386
 
361
- // 1) 续租该节点持有的所有租约 + 采集健康数据
362
- // 续租匹配(2026-10-30 修复):心跳 userId 是纯节点名(如 F-11),业务实例租约 userId
363
- // 带实例后缀(F-11-8222-2 = {节点}-{端口}-{实例})→ 精确相等 + "-" 边界前缀匹配都续。
364
- // 边界防节点名互为前缀:"F-1" 不得续 "F-11-*"(startsWith("F-1-") 为 false)。
365
- // ⚠️ 铁律:acquireAccount 里「自动释放同使用者旧租约」必须保持精确相等
366
- // (一实例一号不变量),与本处续租匹配是两套语义,不许跟着改成前缀。
367
- for (const acc of _accounts) {
368
- if (
369
- acc.status === "leased" &&
370
- acc.lease &&
371
- (acc.lease.userId === userId || acc.lease.userId.startsWith(userId + "-"))
372
- ) {
373
- acc.lease.lastHealthAt = t;
374
- acc.lastHealthAt = t; // 镜像到账号本体,释放后仍可查
375
- acc.lastUsedAt = t;
376
- _dirty = true; // 续租也是变更:配合下方低频持久化落盘
377
- if (report && report[acc.lease.port]) {
378
- const h = report[acc.lease.port];
379
- if (typeof h.lastBusinessOkAt === "number") {
380
- acc.lease.lastBusinessOkAt = h.lastBusinessOkAt;
381
- acc.lastBusinessOkAt = h.lastBusinessOkAt; // 镜像:最近一次业务出活
382
- }
383
- if (typeof h.emptyStreak === "number") acc.lease.emptyStreak = h.emptyStreak;
384
- if (h.username && !acc.username) acc.username = h.username;
385
- }
386
- }
387
- }
388
-
389
- // 1.5) 心跳续租的低频持久化:只在我们真的持有账号状态时才写,且每 5 分钟最多一次
390
- // (冷启动空状态下绝不落盘,防盖掉对方刚写的数据;TTL 12h,5min 漂移可忽略)
391
- if (_loaded && _accounts.length > 0 && t - _lastFileWriteAt > 300000) scheduleSave();
387
+ // (2026-11-03 砍掉原「1) 续租该节点持有的所有租约 + 采集健康数据」与
388
+ // 「1.5) 低频持久化」:心跳不再刷新/弄脏任何池状态,落盘只在操作性变更时发生。)
392
389
 
393
390
  // 2) 取走待下发命令(一次性;跨进程:命令由 watch 进程经池文件投递)
394
391
  const list = _queue.filter((c) => c.userId === userId);
@@ -415,8 +412,8 @@ export function heartbeatLoginPool(userId, body) {
415
412
  scheduleSave();
416
413
  }
417
414
 
418
- // 3) 低频回收(每次心跳顺带检查,成本低)
419
- reapExpiredLeases();
415
+ // 3) 僵尸租约回收(每次心跳顺带检查,成本低)
416
+ reapZombieLeases();
420
417
 
421
418
  return commands && commands.length > 0 ? { commands } : undefined;
422
419
  }
@@ -431,7 +428,7 @@ function fullView(acc) {
431
428
  /** 池视图(脱敏) */
432
429
  export function getLoginPoolView() {
433
430
  ensureLoaded();
434
- reapExpiredLeases();
431
+ reapZombieLeases();
435
432
  return {
436
433
  accounts: _accounts.map(publicView),
437
434
  total: _accounts.length,
@@ -506,6 +503,9 @@ export function clearAccountHistory({ id, by } = {}) {
506
503
  acc.history = [];
507
504
  acc.useCount = 0;
508
505
  acc.switchStats = {};
506
+ acc.lastSwitchReason = null;
507
+ acc.switchStreak = 0;
508
+ acc.errStreak = 0;
509
509
  acc.updatedAt = now();
510
510
  pushHistory(acc, "clearLogs", `清除日志与计数(来源 ${String(by || "page").slice(0, 80)})`);
511
511
  scheduleSave();
@@ -521,6 +521,9 @@ export function clearAllAccountHistory({ by } = {}) {
521
521
  acc.history = [];
522
522
  acc.useCount = 0;
523
523
  acc.switchStats = {};
524
+ acc.lastSwitchReason = null;
525
+ acc.switchStreak = 0;
526
+ acc.errStreak = 0;
524
527
  acc.updatedAt = t;
525
528
  pushHistory(acc, "clearLogs", `清除日志与计数(来源 ${String(by || "page").slice(0, 80)} · 批量)`);
526
529
  }
@@ -529,6 +532,41 @@ export function clearAllAccountHistory({ by } = {}) {
529
532
  return { cleared };
530
533
  }
531
534
 
535
+ /**
536
+ * 面标注(2026-11-02 faceBans):按面记录可用性,派号时按面过滤(见 acquireAccount)。
537
+ * { id|username, face, ban, source='manual', by } — 定位二选一;ban=true 标挂 / false 恢复;
538
+ * 与 status 正交:leased 号也可标注(不动租约,下次派发生效)。
539
+ * 写 faceBans[face] → pushHistory 落痕 → saveNow() 立即落盘(操作性变更不走防抖)。
540
+ */
541
+ export function setAccountFace({ id, username, face, ban, source = "manual", by } = {}) {
542
+ ensureLoaded();
543
+ if (!VALID_FACES.includes(face)) {
544
+ throw new Error(`非法 face: ${face}(仅限 ${VALID_FACES.join("/")})`);
545
+ }
546
+ if (!VALID_FACE_SOURCES.includes(source)) {
547
+ throw new Error(`非法 source: ${source}(仅限 ${VALID_FACE_SOURCES.join("/")})`);
548
+ }
549
+ if (typeof ban !== "boolean") {
550
+ throw new Error("ban 必填且为 boolean(true=标挂禁用 / false=恢复)");
551
+ }
552
+ const acc = _accounts.find((a) =>
553
+ id ? a.id === id : username ? a.username === username : false,
554
+ );
555
+ if (!acc) throw new Error(`账号不存在: ${id || username}`);
556
+ const t = now();
557
+ acc.faceBans = acc.faceBans || {};
558
+ acc.faceBans[face] = { ban, source, at: t, ...(by ? { by: String(by).slice(0, 80) } : {}) };
559
+ acc.updatedAt = t;
560
+ pushHistory(
561
+ acc,
562
+ ban ? "faceBan" : "faceRestore",
563
+ `${face}=${ban ? "禁用" : "恢复"} (${source}${by ? " · " + String(by).slice(0, 60) : ""})`,
564
+ );
565
+ _dirty = true; // 先置脏再落盘(与 enqueueCommand 同式):saveNow 的 !_dirty 早退会吞掉连续标注
566
+ saveNow();
567
+ return publicView(acc);
568
+ }
569
+
532
570
  /** 删除(retired 才可删;其余先 retired) */
533
571
  export function deleteAccount(id) {
534
572
  ensureLoaded();
@@ -584,10 +622,6 @@ export function assignAccount({ id, userId, port, force } = {}) {
584
622
  userId,
585
623
  port,
586
624
  assignedAt: t,
587
- expireAt: t + LEASE_TTL_MS,
588
- lastHealthAt: t,
589
- lastBusinessOkAt: null,
590
- emptyStreak: null,
591
625
  };
592
626
  acc.updatedAt = t;
593
627
  pushHistory(acc, "assigned", `${userId}:${port}${force ? " (force)" : ""}`);
@@ -639,7 +673,7 @@ export function releaseAccount({ id, clearOnNode } = {}) {
639
673
  */
640
674
  export function takeAccount({ userId, port, username, force } = {}) {
641
675
  ensureLoaded();
642
- reapExpiredLeases();
676
+ reapZombieLeases();
643
677
  if (!userId || !port) throw new Error("userId 和 port 必填");
644
678
  let acc;
645
679
  if (username) {
@@ -667,11 +701,12 @@ export function takeAccount({ userId, port, username, force } = {}) {
667
701
  * - port 可选:仅作展示元数据(浏览器口),不参与任何键/约束
668
702
  * - appName: 记录在租约与账号本体(lastAppName,释放后保留),参与取号排序 K2 非同类优先
669
703
  * - reason: 可选换号原因受控 code(banned/recover_fail/scheduled/inject_fail/captcha/network),
670
- * 仅显式上报才计入 switchStats 聚合(无「未上报」桶);未知 code 原样存不拒绝(旧客户端兼容)
704
+ * 仅显式上报才计入「最近换号原因 + 连续次数」(lastSwitchReason/switchStreak,2026-11-03
705
+ * 替代原 switchStats 终身累加);未知 code 原样存不拒绝(旧客户端兼容)
671
706
  */
672
707
  export function acquireAccount({ userId, port, appName, reason } = {}) {
673
708
  ensureLoaded();
674
- reapExpiredLeases();
709
+ reapZombieLeases();
675
710
  if (!userId) throw new Error("userId 必填");
676
711
  let portNum = null;
677
712
  if (port !== undefined && port !== null && port !== "") {
@@ -694,6 +729,8 @@ export function acquireAccount({ userId, port, appName, reason } = {}) {
694
729
  // 请求无 appName 时该键中性化全等,不参与)
695
730
  // K3 lastUsedAt 升序(从未用过组内退化为 createdAt 升序,兼容旧排序)
696
731
  const MIN_IDLE_MS = 30 * 60 * 1000;
732
+ // (2026-11-03 砍掉 faceBans 按面过滤:explore/search 双面派号未验证出价值,机制退役;
733
+ // faceBans 数据与 /api/login-pool/face 端点保留休眠,探针标注不受影响)
697
734
  const pool = _accounts.filter((a) => a.status === "pool");
698
735
  const sortFn = (a, b) => {
699
736
  // K1: 从未用过最前
@@ -715,7 +752,9 @@ export function acquireAccount({ userId, port, appName, reason } = {}) {
715
752
  const acc = eligible[0] || pool.slice().sort(sortFn)[0];
716
753
  if (!acc) {
717
754
  const leased = _accounts.filter((a) => a.status === "leased").length;
718
- throw new Error(`池中暂无空闲账号(共 ${_accounts.length} 个,其中 ${leased} 个在租约中)`);
755
+ throw new Error(
756
+ `池中暂无可用账号(共 ${_accounts.length} 个,租约中 ${leased} 个)`,
757
+ );
719
758
  }
720
759
 
721
760
  acc.status = "leased";
@@ -728,19 +767,19 @@ export function acquireAccount({ userId, port, appName, reason } = {}) {
728
767
  acc.lastAppName = appNameKey;
729
768
  }
730
769
  if (reasonKey) {
731
- // C2: 换号原因聚合(仅显式上报才计数,无「未上报」桶)
732
- acc.switchStats = acc.switchStats || {};
733
- acc.switchStats[reasonKey] = (acc.switchStats[reasonKey] || 0) + 1;
770
+ // 换号原因:只留「最近原因 + 连续次数」(2026-11-03 替代 switchStats 终身累加:
771
+ // 同原因连击 +1,换原因重置为 1。例:例行换号×2 → 被封×2,页面只显示 被封×2)
772
+ if (acc.lastSwitchReason === reasonKey) acc.switchStreak = (acc.switchStreak || 0) + 1;
773
+ else {
774
+ acc.lastSwitchReason = reasonKey;
775
+ acc.switchStreak = 1;
776
+ }
734
777
  }
735
778
  acc.lease = {
736
779
  userId,
737
780
  port: portNum,
738
781
  appName: appNameKey,
739
782
  assignedAt: t,
740
- expireAt: t + LEASE_TTL_MS,
741
- lastHealthAt: t,
742
- lastBusinessOkAt: null,
743
- emptyStreak: null,
744
783
  };
745
784
  acc.updatedAt = t;
746
785
  pushHistory(
@@ -760,7 +799,13 @@ export function acquireAccount({ userId, port, appName, reason } = {}) {
760
799
  other.lease &&
761
800
  other.lease.userId === userId
762
801
  ) {
763
- pushHistory(other, "released", `自动释放(${userId} 取新号)`);
802
+ // 连败计数 errStreak(2026-11-03 替代心跳通道 cmdFailStreak):记在被换掉的旧号头上——
803
+ // 原因码描述的是旧号的遭遇。错误(banned/captcha/network)+1;scheduled=干满周期
804
+ // 健康证明清零;inject_fail/recover_fail/无原因中性不动(inject_fail 候选号在取新号前
805
+ // 已单独 release,此处本就无旧租约,物理上天然忽略)
806
+ if (ERROR_REASONS.has(reasonKey)) other.errStreak = (other.errStreak || 0) + 1;
807
+ else if (reasonKey === "scheduled") other.errStreak = 0;
808
+ pushHistory(other, "released", `自动释放(${userId} 取新号${reasonKey ? " · " + reasonKey : ""})`);
764
809
  other.status = "pool";
765
810
  other.lastUsedAt = t;
766
811
  other.lease = null;
@@ -790,7 +835,7 @@ export function acquireAccount({ userId, port, appName, reason } = {}) {
790
835
  */
791
836
  export function importAccount({ userId, port, username, cookies, note, proxyHint } = {}) {
792
837
  ensureLoaded();
793
- reapExpiredLeases();
838
+ reapZombieLeases();
794
839
  if (!userId) throw new Error("userId 必填");
795
840
  if (!username) throw new Error("username 必填(须为 passport 验证过的登录号)");
796
841
  const norm = normalizeCookies(cookies);
@@ -846,28 +891,26 @@ const importMeta = (userId, portNum, at) => ({ userId, port: portNum, at });
846
891
  */
847
892
  export function reloginAccount({ username, cookies, userId, note } = {}) {
848
893
  ensureLoaded();
849
- reapExpiredLeases();
894
+ reapZombieLeases();
850
895
  if (!username) throw new Error("username 必填(重登录目标)");
851
896
  if (!userId) throw new Error("userId 必填(操作来源)");
852
897
  const norm = normalizeCookies(cookies);
853
898
  if (!norm) throw new Error("cookies 必须是非空数组,且每项含 name/value");
854
899
  const acc = _accounts.find((a) => a.username === username);
855
900
  if (!acc) throw new Error(`账号 ${username} 不在池中(重登录仅针对已有账号,新号走 import/add)`);
856
- if (acc.status === "leased") {
857
- const w = acc.lease;
858
- throw new Error(
859
- `账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
860
- );
861
- }
901
+ // 2026-11-03 改(X 方案):使用中也可重登录——只替换池内 cookies,状态与租约原样保留,
902
+ // 不再置 pool(避免租约挂着状态却变 pool 被别的节点取走);风险自担:本机重登录窗口
903
+ // 与在租节点同 sessionid 并用,可能触发风控踢会话,节点会自愈重取
862
904
  const revived = acc.status === "retired";
863
905
  const wasSuspect = acc.status === "suspect";
906
+ const inUse = !!acc.lease;
864
907
  acc.cookies = norm;
865
- acc.status = "pool";
908
+ if (!inUse) acc.status = "pool";
866
909
  acc.updatedAt = now();
867
910
  pushHistory(
868
911
  acc,
869
912
  "relogin",
870
- `${revived ? "重登录复活(已停用→池)" : wasSuspect ? "重登录(疑似→池)" : "重登录刷新"} · ${userId}${note ? ` · ${String(note).slice(0, 80)}` : ""}`,
913
+ `${inUse ? "重登录刷新(使用中,仅换 cookies)" : revived ? "重登录复活(已停用→池)" : wasSuspect ? "重登录(疑似→池)" : "重登录刷新"} · ${userId}${note ? ` · ${String(note).slice(0, 80)}` : ""}`,
871
914
  );
872
915
  scheduleSave();
873
916
  return { account: publicView(acc), revived, wasSuspect };
@@ -879,7 +922,7 @@ export function reloginAccount({ username, cookies, userId, note } = {}) {
879
922
  */
880
923
  export function healthTargets({ ids } = {}) {
881
924
  ensureLoaded();
882
- reapExpiredLeases();
925
+ reapZombieLeases();
883
926
  const idSet = Array.isArray(ids) && ids.length ? new Set(ids) : null;
884
927
  return _accounts
885
928
  .filter((a) => a.status !== "retired")
@@ -926,16 +969,10 @@ export function recordHealth({ id, ok, detail } = {}) {
926
969
  */
927
970
  export function prepareRelogin({ username } = {}) {
928
971
  ensureLoaded();
929
- reapExpiredLeases();
972
+ reapZombieLeases();
930
973
  if (!username) throw new Error("username 必填");
931
974
  const acc = _accounts.find((a) => a.username === username);
932
975
  if (!acc) throw new Error(`账号 ${username} 不在池中`);
933
- if (acc.status === "leased") {
934
- const w = acc.lease;
935
- throw new Error(
936
- `账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
937
- );
938
- }
939
976
  return { account: fullView(acc) };
940
977
  }
941
978
 
@@ -122,6 +122,10 @@ async function fetchClientErrors() {
122
122
  const msgDetail = c.errorMessage
123
123
  ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.errorMessage)}</span>`
124
124
  : "";
125
+ // 旧字段 username 语义混杂(池号/占位/目标用户),改造后挪进详情区小字保留可见性
126
+ const legacyUsernameDetail = c.username
127
+ ? `<br><span style="color:#666;font-size:10px">上报账号: @${escapeHtml(c.username)}</span>`
128
+ : "";
125
129
  const stackDetail = c.errorStack
126
130
  ? `<br><span style="color:#555;font-size:10px;max-width:250px;display:block;word-break:break-all">${escapeHtml(c.errorStack)}</span>`
127
131
  : "";
@@ -134,8 +138,9 @@ async function fetchClientErrors() {
134
138
  <td style="color:#f87171;font-weight:600">${c.reportCount || 1}</td>
135
139
  <td style="color:#f59e0b;font-weight:600">${captchaText}${captchaDetail}</td>
136
140
  <td style="color:#a78bfa;font-size:12px">${stageText}</td>
137
- <td style="color:#ccc;font-size:12px;max-width:300px;word-break:break-all">${msgDetail}${stackDetail}</td>
138
- <td style="color:#60a5fa">@${escapeHtml(c.username || "-")}</td>
141
+ <td style="color:#ccc;font-size:12px;max-width:300px;word-break:break-all">${msgDetail}${legacyUsernameDetail}${stackDetail}</td>
142
+ <td style="color:#34d399;font-size:12px">@${escapeHtml(c.poolUsername || "-")}</td>
143
+ <td style="color:#94a3b8;font-size:11px;font-family:monospace;max-width:120px;word-break:break-all">${escapeHtml(c.proxyNode || "-")}</td>
139
144
  <td style="color:#888;font-size:12px">${new Date(c.timestamp).toLocaleTimeString()}</td>
140
145
  <td><button class="btn-delete" onclick="deleteClientError('${escapeHtml(c.userId)}')" style="background:#991b1b;color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px">删除</button></td>
141
146
  </tr>`;
@@ -103,7 +103,8 @@
103
103
  <th>验证码次数</th>
104
104
  <th>阶段</th>
105
105
  <th>错误详情</th>
106
- <th>当时处理的 TikTok 用户</th>
106
+ <th>在手账号</th>
107
+ <th>代理节点</th>
107
108
  <th>时间</th>
108
109
  <th>操作</th>
109
110
  </tr>