tt-help-cli-ycl 1.4.169 → 1.4.172

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/scripts/test-subscription-allocator.mjs +220 -0
  3. package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
  4. package/scripts/test-subscription-auto-alloc.mjs +259 -0
  5. package/scripts/test-subscription-center-e2e.mjs +134 -0
  6. package/scripts/test-subscription-center-smoke.mjs +154 -0
  7. package/scripts/test-subscription-center-ui.mjs +111 -0
  8. package/scripts/vnc-login.sh +68 -0
  9. package/src/cli/info.js +6 -1
  10. package/src/lib/browser/cdp.js +7 -1
  11. package/src/watch/public/app.js +713 -39
  12. package/src/watch/public/index.html +74 -3
  13. package/src/watch/subscription-allocator.js +255 -0
  14. package/src/watch/subscription-center.js +590 -0
  15. package/src/watch/watchdog-server.js +210 -0
  16. package/src/watchdog/agent.js +104 -10
  17. package/scripts/test-api-interceptor-429.mjs +0 -122
  18. package/scripts/test-api-interceptor-captcha.mjs +0 -176
  19. package/scripts/test-claim-fix.mjs +0 -216
  20. package/scripts/test-clash-discover.mjs +0 -126
  21. package/scripts/test-clash-hysteresis.mjs +0 -131
  22. package/scripts/test-clash-multichannel.mjs +0 -120
  23. package/scripts/test-client-error-monitor.mjs +0 -261
  24. package/scripts/test-country-fix.mjs +0 -341
  25. package/scripts/test-daemon-failover.mjs +0 -301
  26. package/scripts/test-daemon-switch-history.mjs +0 -141
  27. package/scripts/test-daemon-whitelist.mjs +0 -308
  28. package/scripts/test-explore-captcha-wait.mjs +0 -170
  29. package/scripts/test-photo-post.mjs +0 -156
  30. package/scripts/test-precheck-perf.mjs +0 -270
  31. package/scripts/test-precise-index.mjs +0 -114
  32. package/scripts/test-probe-live.mjs +0 -83
  33. package/scripts/test-probe.mjs +0 -315
  34. package/scripts/test-product-fill-cdp.mjs +0 -336
  35. package/scripts/test-product-fill-db.mjs +0 -216
  36. package/scripts/test-product-fill-http.mjs +0 -117
  37. package/scripts/test-proxy-switch-restart.mjs +0 -198
  38. package/scripts/test-refill-order.mjs +0 -218
  39. package/scripts/test-sample-all-fail.mjs +0 -142
  40. package/scripts/test-seller-any-country.mjs +0 -167
  41. package/scripts/test-stats-smoke.mjs +0 -43
  42. package/scripts/test-upgrade-spawn.mjs +0 -179
  43. package/scripts/test-webserver-error-classify.mjs +0 -409
  44. package/scripts/test-wmi-free-upgrade.mjs +0 -98
  45. package/src/cli/test-real-attach.js +0 -0
  46. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -644,14 +644,63 @@
644
644
  <div class="label">命令池</div>
645
645
  <div class="value slate" id="wdCmdCount">0</div>
646
646
  </div>
647
+ <div class="stat-card clickable" onclick="wdToggleAllCards()" style="background:rgba(96,165,250,0.05)">
648
+ <div class="label" id="wdToggleAllLabel" style="font-size:18px">⊞</div>
649
+ <div class="value" style="font-size:11px;color:#60a5fa">全部展开</div>
650
+ </div>
647
651
  <div class="stat-card clickable" onclick="fetchWatchdogData()" style="background:rgba(96,165,250,0.05)">
648
652
  <div class="label" style="font-size:18px">🔄</div>
649
653
  <div class="value" style="font-size:11px;color:#60a5fa">刷新</div>
650
654
  </div>
651
655
  </div>
656
+ <!-- 订阅中心(Phase 1: 池子管理 + 到期/流量探测 + 引用只读展示,不改节点行为) -->
657
+ <div id="wdSubsSection"
658
+ style="margin-bottom:16px;border:1px solid #2a2a3d;border-radius:10px;background:#16161f;padding:12px">
659
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
660
+ <div id="wdSubsHeader" onclick="wdSubsToggle()" title="点击展开/收起订阅列表"
661
+ style="cursor:pointer;user-select:none;font-size:14px;color:#fbbf24">🛜 订阅中心
662
+ <span id="wdSubsArrow" style="color:#666;font-size:11px;margin-left:2px"></span>
663
+ <span id="wdSubsSummary" style="color:#666;font-size:12px;margin-left:8px"></span>
664
+ </div>
665
+ <button onclick="wdSubShowAddModal()"
666
+ style="padding:4px 12px;font-size:12px;background:#fbbf24;color:#000;border:none;border-radius:6px;cursor:pointer;font-weight:600">+
667
+ 新增订阅</button>
668
+ </div>
669
+ <div id="wdSubsList" style="color:#666;font-size:12px">加载中...</div>
670
+ </div>
652
671
  <div id="wdClientsList" style="color:#666;font-size:12px">加载中...</div>
653
672
  </div>
654
673
 
674
+ <!-- 订阅中心 新增/编辑弹窗 -->
675
+ <div id="wdSubModal"
676
+ style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
677
+ <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
678
+ <div style="font-size:16px;color:#fbbf24;margin-bottom:4px" id="wdSubModalTitle">+ 新增订阅</div>
679
+ <div style="color:#888;font-size:12px;margin-bottom:12px">订阅地址只保存在服务端订阅池,节点不受影响;保存后约 6 小时内自动探测一次,也可在列表里点「检测」立即探测。订阅地址含 token(等于密码),请勿外泄。</div>
680
+ <div style="margin-bottom:10px">
681
+ <div style="color:#94a3b8;font-size:12px;margin-bottom:4px">名称(选填,默认自动编号)</div>
682
+ <input id="wdSubName" placeholder="如:机场A-月付"
683
+ style="width:100%;box-sizing:border-box;padding:8px 10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px">
684
+ </div>
685
+ <div style="margin-bottom:10px">
686
+ <div style="color:#94a3b8;font-size:12px;margin-bottom:4px">订阅地址(http/https)</div>
687
+ <textarea id="wdSubUrl" rows="3" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx"
688
+ style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
689
+ </div>
690
+ <div style="margin-bottom:10px">
691
+ <div style="color:#94a3b8;font-size:12px;margin-bottom:4px">备注(选填:重置日 / 套餐名等)</div>
692
+ <input id="wdSubNote" placeholder="如:每月1号重置流量"
693
+ style="width:100%;box-sizing:border-box;padding:8px 10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px">
694
+ </div>
695
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
696
+ <button onclick="wdSubCloseModal()"
697
+ style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
698
+ <button onclick="wdSubSave()"
699
+ style="padding:8px 16px;background:#fbbf24;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存</button>
700
+ </div>
701
+ </div>
702
+ </div>
703
+
655
704
  <!-- 命令编辑弹窗 -->
656
705
  <div id="wdCmdModal"
657
706
  style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
@@ -785,15 +834,37 @@
785
834
  <div id="wdProxyModal"
786
835
  style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
787
836
  <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
788
- <div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span></div>
789
- <div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一行 = 一个独立 mihomo 实例,最多 8 行</b>;<b style="color:#fbbf24">同一地址可配置多行</b>(各自独立使用不同节点):按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”tabs 可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(&lt;7天)或超量(剩余&lt;10%)在 tab 上标红。</div>
837
+ <div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span> <span
838
+ id="wdProxyModeBadge"></span></div>
839
+ <!-- 自动分配视图(app.js 按模式切换显示) -->
840
+ <div id="wdProxyAutoView" style="display:none">
841
+ <div style="color:#888;font-size:12px;margin-bottom:10px">⚡ 自动分配模式:服务端订阅池按「配满 + 粘性槽位 + 跨机器摊开」自动分配订阅,每次心跳惰性重算(池内订阅到期/耗尽/禁用后自动换血,无需手动干预)。<b
842
+ style="color:#fbbf24">实例 N = 槽位 N</b>:代理端口 7897+N-1、API 端口 9097+N-1。</div>
843
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
844
+ <span style="color:#e0e0e0;font-size:12px">实例数量:</span>
845
+ <input id="wdProxyCountInput" type="number" min="1" max="8"
846
+ style="width:70px;padding:6px 8px;background:#16161e;border:1px solid #333;border-radius:6px;color:#e0e0e0;font-size:13px">
847
+ <span style="color:#666;font-size:11px">(1~8,每条一个独立 mihomo 实例;修改后按粘性槽位重分配)</span>
848
+ </div>
849
+ <div id="wdProxyAllocWarn"></div>
850
+ <div id="wdProxyAllocTable"
851
+ style="padding:8px;background:#16161e;border:1px solid #333;border-radius:8px;min-height:60px;font-size:12px">加载中...</div>
852
+ </div>
853
+ <!-- 手动编辑视图 -->
854
+ <div id="wdProxyManualHint" style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一行 = 一个独立 mihomo 实例,最多 8 行</b>;<b style="color:#fbbf24">同一地址可配置多行</b>(各自独立使用不同节点):按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”tabs 可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(&lt;7天)或超量(剩余&lt;10%)在 tab 上标红。</div>
790
855
  <textarea id="wdProxySubsText" rows="6" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx\nhttps://another-airport.com/sub?token=yyyy"
791
856
  style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
792
857
  <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
793
858
  <button onclick="wdCloseProxyModal()"
794
859
  style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
795
- <button onclick="wdSaveProxySettings()"
860
+ <button id="wdProxySwitchAutoBtn" onclick="wdSwitchProxyAuto()" title="现有列表原样转为自动分配(count=当前条数,切换无感);之后由服务端订阅池自动换血/摊开"
861
+ style="display:none;padding:8px 16px;background:#333;color:#4ade80;border:1px solid #4ade80;border-radius:6px;cursor:pointer;font-size:12px">⚡ 切换为自动分配</button>
862
+ <button id="wdProxySwitchManualBtn" onclick="wdSwitchProxyManual()" title="当前分配结果原样转回手动列表,分配器不再触碰"
863
+ style="display:none;padding:8px 16px;background:#333;color:#ccc;border:1px solid #555;border-radius:6px;cursor:pointer;font-size:12px">切回手动编辑</button>
864
+ <button id="wdProxySaveManualBtn" onclick="wdSaveProxySettings()"
796
865
  style="padding:8px 16px;background:#fbbf24;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存并热应用</button>
866
+ <button id="wdProxySaveAutoBtn" onclick="wdSaveProxyCount()"
867
+ style="display:none;padding:8px 16px;background:#fbbf24;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存数量并下发</button>
797
868
  </div>
798
869
  </div>
799
870
  </div>
@@ -0,0 +1,255 @@
1
+ /**
2
+ * 订阅自动分配器 — Phase 2 v0(服务端 only,客户端零改动)
3
+ *
4
+ * 模式约定(proxySettings[userId]):
5
+ * 手动模式(存量兼容): { subscriptions: [{name,url}] } —— 无 mode 字段,一个字节不动
6
+ * 自动模式: { mode:"auto", count, subscriptions, allocWarn, allocatedAt }
7
+ * subscriptions = 物化后的 alloc(槽位序 = 实例序),
8
+ * 仍走既有心跳下发 → agent 签名比对 → daemon 热应用链路。
9
+ *
10
+ * 规则卡(v0 拍板,见需求讨论记录):
11
+ * 1 配满 alloc 条数 = count 恒成立(可用 ≥ 1)
12
+ * 2 可用集 池子排除「已禁用/已到期/已耗尽」;临期、告急、探测失败未确认死、
13
+ * 未探测的新订阅都算可用
14
+ * 3 粘性 槽位原位保留——订阅还活着就不挪窝(端口 = 槽位序);
15
+ * 不在池子的存量 URL 状态未知,同样视为可用(迁移无感)
16
+ * 4 节点内摊开 同一机器槽位尽量用不同订阅;count > 可用去重数才回绕
17
+ * 5 跨机器错开 补槽时优先选全集群占用数最少的可用订阅
18
+ * (占用数 = 该订阅在所有机器 alloc 里出现的次数)
19
+ * 6 重复合法 回绕重复按池子顺序取
20
+ * 7 空保护 可用集为空 → alloc 原地不动 + 标黄,绝不下发空列表
21
+ * 8 决定性 纯函数 + 池子顺序决胜;心跳重跑输入不变则结果恒等,天然粘性
22
+ *
23
+ * 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数升序 → 池子顺序。
24
+ * 全局占用数每次现算(遍历所有节点 alloc,几十个节点毫秒级);分配时机 = 心跳惰性计算,
25
+ * 结果与存量不同才写回配置(下一行 sendJSON 读到的即是新 alloc,同一个心跳就下发)。
26
+ *
27
+ * 迁移 = 方案 A: set-proxy-auto 把现有 URL 列表原样转成 alloc(count = 列表长度),
28
+ * 切换瞬间分配结果 = 现状,节点完全无感;之后所有变化都走分配器。
29
+ *
30
+ * 安全约定: 日志只打订阅名/URL 前缀,不泄漏完整 token。
31
+ */
32
+
33
+ import { existsSync, readFileSync, writeFileSync } from "fs";
34
+ import { join } from "path";
35
+ import { homedir } from "os";
36
+ import { getSubscriptionView } from "./subscription-center.js";
37
+
38
+ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
39
+ const WATCHDOG_CONFIG_PATH = join(homeDir, ".tt-help-watchdog.json");
40
+
41
+ /** 实例上限:与 discover.js RUNTIME_MAX_INSTANCES=8、handleSetProxySettings MAX_SUBS 对齐 */
42
+ export const ALLOC_MAX_INSTANCES = 8;
43
+
44
+ // ── 可用性判定(规则 #2/#3) ──
45
+
46
+ /**
47
+ * 池子视图是否可用:排除「已禁用 / 已到期 / 已耗尽」。
48
+ * 临期、余量告急、探测失败未确认死、未知(未探测)都算可用——
49
+ * 与 Phase 1「报警保留、不自动剔除」一脉相承,只有确凿死亡才退出分配。
50
+ */
51
+ export function isSubAvailable(view) {
52
+ if (!view) return false;
53
+ if (view.enabled === false) return false;
54
+ const code = view.status && view.status.code;
55
+ return code !== "expired" && code !== "exhausted";
56
+ }
57
+
58
+ /** 存量槽位 URL 的粘性可用性:不在池子 = 状态未知 = 可用(迁移无感);在池子按池子状态 */
59
+ function isAllocUrlAvailable(url, viewByUrl) {
60
+ if (!url) return false;
61
+ const v = viewByUrl.get(url);
62
+ return !v || isSubAvailable(v);
63
+ }
64
+
65
+ // ── 纯函数分配器(规则 #1-#8) ──
66
+
67
+ function normEntry(it) {
68
+ if (typeof it === "string") return { name: "", url: it };
69
+ return { name: (it && it.name) || "", url: (it && it.url) || "" };
70
+ }
71
+
72
+ function scoreLess(a, b) {
73
+ for (let i = 0; i < a.length; i++) {
74
+ if (a[i] !== b[i]) return a[i] < b[i];
75
+ }
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * 分配(纯函数,决定性)
81
+ * @param {Array} available 可用订阅视图数组(池子顺序)
82
+ * @param {number} count 目标槽数(1..ALLOC_MAX_INSTANCES)
83
+ * @param {Array} prevAlloc 存量 alloc([{name,url}],条数任意)
84
+ * @param {Object} usage 全局占用数 {url: 出现次数}(其它节点 alloc 现算值)
85
+ * @param {Function} isAvailUrl (url) => 存量 URL 是否可原位保留
86
+ * @returns {{ alloc: Array<{name,url}>, warn: string|null }}
87
+ * warn: "pool-empty" 可用集为空且存在补不上的槽(规则 #7)
88
+ */
89
+ export function allocateSubscriptions({ available, count, prevAlloc, usage, isAvailUrl }) {
90
+ const prev = Array.isArray(prevAlloc) ? prevAlloc.map(normEntry) : [];
91
+ const n = Math.max(1, Math.min(ALLOC_MAX_INSTANCES, parseInt(count, 10) || 1));
92
+ const avail = Array.isArray(available) ? available : [];
93
+ const use = usage && typeof usage === "object" ? usage : {};
94
+
95
+ const nodeUsed = new Map(); // 本节点新 alloc 内的占用(含粘性保留槽,跨节点占用走 usage)
96
+ const globalUsed = (url) => (use[url] || 0) + (nodeUsed.get(url) || 0);
97
+
98
+ const result = new Array(n).fill(null);
99
+
100
+ // Pass 1 粘性(规则 #3):原位保留仍可用的槽位;count 缩小 = 砍尾部(规则 #1)
101
+ for (let i = 0; i < n && i < prev.length; i++) {
102
+ if (prev[i].url && isAvailUrl(prev[i].url)) {
103
+ result[i] = { ...prev[i] };
104
+ nodeUsed.set(prev[i].url, (nodeUsed.get(prev[i].url) || 0) + 1);
105
+ }
106
+ }
107
+
108
+ const hasGap = result.some((s) => !s);
109
+
110
+ // 规则 #7 空保护:可用集为空 → 原地不动(原样返回存量,不截断不换血)+ 标黄
111
+ if (!avail.length) {
112
+ return hasGap ? { alloc: prev, warn: "pool-empty" } : { alloc: result, warn: null };
113
+ }
114
+
115
+ // Pass 2 补空槽:打分取最小 — (本节点其它槽位已用) → 全局占用升序 → 池子顺序
116
+ for (let i = 0; i < n; i++) {
117
+ if (result[i]) continue;
118
+ let best = null;
119
+ let bestScore = null;
120
+ for (let k = 0; k < avail.length; k++) {
121
+ const s = avail[k];
122
+ const inNode = (nodeUsed.get(s.url) || 0) > 0;
123
+ const score = [inNode ? 1 : 0, globalUsed(s.url), k];
124
+ if (!bestScore || scoreLess(score, bestScore)) {
125
+ best = s;
126
+ bestScore = score;
127
+ }
128
+ }
129
+ if (!best) break; // 防御:avail 非空时不可达
130
+ result[i] = { name: best.name || "", url: best.url };
131
+ nodeUsed.set(best.url, (nodeUsed.get(best.url) || 0) + 1);
132
+ }
133
+
134
+ return { alloc: result, warn: null };
135
+ }
136
+
137
+ // ── 心跳惰性接线(watchdog-server 心跳路径调用,单写者) ──
138
+
139
+ function readCfg() {
140
+ try {
141
+ if (existsSync(WATCHDOG_CONFIG_PATH))
142
+ return JSON.parse(readFileSync(WATCHDOG_CONFIG_PATH, "utf-8"));
143
+ } catch {}
144
+ return {};
145
+ }
146
+
147
+ function writeCfg(cfg) {
148
+ try {
149
+ writeFileSync(WATCHDOG_CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf-8");
150
+ } catch (e) {
151
+ console.error(`[订阅分配] 配置写回失败: ${e.message}`);
152
+ }
153
+ }
154
+
155
+ /** 槽位变更的人类可读描述(日志只出现订阅名与 URL 归属host,不打 token) */
156
+ function urlHost(url) {
157
+ try {
158
+ return new URL(url).host;
159
+ } catch {
160
+ return String(url || "").slice(0, 30);
161
+ }
162
+ }
163
+
164
+ function describeChange(prev, next, viewByUrl) {
165
+ const parts = [];
166
+ const max = Math.max(prev.length, next.length);
167
+ for (let i = 0; i < max; i++) {
168
+ const o = prev[i];
169
+ const w = next[i];
170
+ if (o && w && o.url === w.url) continue;
171
+ if (o && !w) {
172
+ parts.push(`槽${i + 1} 「${o.name || urlHost(o.url)}」回收(count缩小)`);
173
+ continue;
174
+ }
175
+ const oLabel = o ? `「${o.name || urlHost(o.url)}」` : "空";
176
+ const wLabel = `「${w.name || urlHost(w.url)}」`;
177
+ let reason = "";
178
+ if (o) {
179
+ const v = viewByUrl.get(o.url);
180
+ reason = v && v.status ? `(${v.status.label})` : "";
181
+ }
182
+ parts.push(`槽${i + 1} ${oLabel}${reason} → ${wLabel}`);
183
+ }
184
+ return parts;
185
+ }
186
+
187
+ /**
188
+ * 心跳路径的分配器接入:userId 处于 auto 模式时重算 alloc,变化才写回配置。
189
+ * 必须在心跳响应构造(读取 proxySettings)之前调用——同一个心跳就把新 alloc 带下去。
190
+ * 输入不变则输出恒等(规则 #8),重复心跳零写盘。
191
+ * @returns {{changed:boolean, alloc?:Array, warn?:string|null}|null} 非 auto 模式返回 null
192
+ */
193
+ export function reconcileAutoAlloc(userId) {
194
+ if (!userId) return null;
195
+ const cfg = readCfg();
196
+ const ps = cfg.proxySettings && cfg.proxySettings[userId];
197
+ if (!ps || ps.mode !== "auto") return null;
198
+
199
+ const views = getSubscriptionView();
200
+ const viewByUrl = new Map(views.map((v) => [v.url, v]));
201
+ const available = views.filter(isSubAvailable);
202
+
203
+ // 全局占用数现算:其它节点 alloc 里该 URL 出现的次数(本节点走分配内计数)
204
+ const usage = {};
205
+ for (const [uid, entry] of Object.entries(cfg.proxySettings || {})) {
206
+ if (uid === userId) continue;
207
+ const list = entry && Array.isArray(entry.subscriptions) ? entry.subscriptions : [];
208
+ for (const it of list) {
209
+ const u = it && typeof it === "object" ? it.url : it;
210
+ if (u) usage[u] = (usage[u] || 0) + 1;
211
+ }
212
+ }
213
+
214
+ const reqCount = parseInt(ps.count, 10) || 1;
215
+ const count = Math.max(1, Math.min(ALLOC_MAX_INSTANCES, reqCount));
216
+ const prevAlloc = Array.isArray(ps.subscriptions) ? ps.subscriptions : [];
217
+
218
+ const { alloc, warn } = allocateSubscriptions({
219
+ available,
220
+ count,
221
+ prevAlloc,
222
+ usage,
223
+ isAvailUrl: (u) => isAllocUrlAvailable(u, viewByUrl),
224
+ });
225
+
226
+ // count-capped 由 set-proxy-auto 端点落 countCapped 标志(落盘的 count 已截断,
227
+ // 若按 count>MAX 判断会立刻丢失标红);pool-empty 由本次分配结果得出
228
+ const nextWarn = warn || (ps.countCapped ? "count-capped" : null);
229
+ const nextJson = JSON.stringify(alloc);
230
+ const prevJson = JSON.stringify(prevAlloc);
231
+ if (nextJson === prevJson && (ps.allocWarn || null) === nextWarn) {
232
+ return { changed: false, alloc, warn: nextWarn };
233
+ }
234
+
235
+ // 写回(读-改-写之间无 await,进程内原子;与既有 handler 同一写盘模式)
236
+ const prevWarn = ps.allocWarn || null;
237
+ ps.subscriptions = alloc;
238
+ ps.count = count;
239
+ ps.allocWarn = nextWarn;
240
+ ps.allocatedAt = Date.now();
241
+ writeCfg(cfg);
242
+
243
+ const diff = describeChange(prevAlloc, alloc, viewByUrl);
244
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
245
+ const warnNote =
246
+ nextWarn && nextWarn !== prevWarn
247
+ ? ` [${nextWarn === "pool-empty" ? "可用订阅为空,保持现状" : "数量超上限,已截断为" + ALLOC_MAX_INSTANCES}]`
248
+ : "";
249
+ console.error(
250
+ `[订阅分配 ${ts}] ${userId} alloc 更新: ${
251
+ diff.length ? diff.join("; ") : nextWarn !== prevWarn ? "无槽位变化" : "初始化 " + alloc.length + " 槽"
252
+ }${warnNote}`,
253
+ );
254
+ return { changed: true, alloc, warn: nextWarn };
255
+ }