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
@@ -0,0 +1,590 @@
1
+ /**
2
+ * 订阅中心 — 订阅池注册表 + 到期/流量主动探测 + 集中展示
3
+ *
4
+ * Phase 1(本期): 纯服务端能力,节点行为零改动
5
+ * - 订阅池 CRUD(~/.tt-help-watchdog-subs.json,与 watchdog 配置分离)
6
+ * - 主动探测: 直接 GET 订阅 URL(Clash UA),解析 subscription-userinfo 响应头
7
+ * (upload/download/total/expire 随 HTTP 响应头返回,无需把订阅加载进任何内核)
8
+ * - 被动兜底: 各节点心跳上报的 mihomo provider 快照(按 userId+订阅名映射回 URL)
9
+ * - 状态推导: 正常/临期/已到期/余量告急/已耗尽/探测失败/未知/已禁用
10
+ * - 流量重置检测: 用量较上次快照下降 = 发生重置(自动记录)
11
+ *
12
+ * Worker 启动策略(单写者原则):
13
+ * 由第一重心跳惰性启动(ensureWorker 在 handleHeartbeat 中调用)。
14
+ * 生产上心跳只会到达一个进程(watch 主程序),探测 worker 因此只在那一个进程运行;
15
+ * standalone-server(17302)虽复用同一套路由但没有心跳,worker 不会启动,仅只读展示。
16
+ *
17
+ * 安全约定:
18
+ * 订阅 URL 含 token(等于密码),所有日志只打订阅名,不打 URL。
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
+ import { randomBytes } from "crypto";
32
+
33
+ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
34
+ const SUBS_FILE = join(homeDir, ".tt-help-watchdog-subs.json");
35
+ const WATCHDOG_CONFIG_PATH = join(homeDir, ".tt-help-watchdog.json");
36
+
37
+ // ── 常量 ──
38
+ const PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 每条订阅 6h 探测一次(机场风控安全线内)
39
+ const WORKER_TICK_MS = 30 * 1000; // worker 轮询节拍
40
+ const PROBE_TIMEOUT_MS = 30 * 1000; // 单次探测超时(实采:机场对热门 token 可能 >10s 才响应;同域其它 token 秒回)
41
+ const EXPIRE_WARN_MS = 7 * 24 * 60 * 60 * 1000; // 临期阈值 7 天
42
+ const QUOTA_WARN_RATIO = 0.1; // 余量告急阈值:剩余 <10%
43
+ const HISTORY_MAX_ENTRIES = 512; // 快照历史上限(≈128 天 @6h)
44
+ const HISTORY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 快照历史保留 30 天
45
+ const RESETS_MAX = 20; // 重置事件最多保留条数
46
+ const NODE_REPORT_STALE_MS = 26 * 60 * 60 * 1000; // 节点代报数据超过 26h 视为陈旧
47
+ const SUBS_MAX = 64; // 订阅池容量上限
48
+ const UA = "clash.meta/1.19.31"; // 机场按 UA 发货:浏览器 UA 拿到 HTML 页,Clash UA 才带头
49
+ const SAVE_DEBOUNCE_MS = 3 * 1000;
50
+
51
+ // ── 内存状态 ──
52
+ let _subs = []; // 注册表
53
+ let _loaded = false;
54
+ let _saveTimer = null;
55
+ let _workerTimer = null;
56
+ const _probing = new Set(); // 单飞行:正在探测的订阅 id
57
+
58
+ /** 订阅名归一化 — 与 src/clash/runtime.js normalizeSubscriptions 保持一致(映射节点上报用) */
59
+ export function normalizeSubName(name, fallback) {
60
+ let n = typeof name === "string" ? name.trim() : "";
61
+ n = n.replace(/[^\w-]/g, "").slice(0, 32);
62
+ if (!n || /^\d+$/.test(n)) n = ""; // 纯数字/空名回退自动命名
63
+ return n || fallback;
64
+ }
65
+
66
+ /** 与 runtime.normalizeSubscriptions 同规则展开服务端 proxySettings(sub-N 按行序兜底命名) */
67
+ function expandNodeSubs(list) {
68
+ const out = [];
69
+ for (const it of Array.isArray(list) ? list : []) {
70
+ const url =
71
+ typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
72
+ if (!url || !/^https?:\/\//.test(url)) continue;
73
+ const idx = out.length + 1;
74
+ let name = normalizeSubName(
75
+ typeof it === "object" && it.name ? it.name : "",
76
+ `sub-${idx}`,
77
+ );
78
+ if (out.some((s) => s.name === name)) name = `${name}-${idx}`;
79
+ out.push({ name, url });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // ── 存取 ──
85
+ // 多进程共存(watch 3001 / 3011 / standalone 可能同时加载本模块):
86
+ // 以文件 mtime 做缓存失效——任一进程写入(CRUD/探测/代报)后,其它进程下次访问自动重读,
87
+ // 避免「进程内一次加载」导致跨进程数据不可见(生产 3011 入池、3001 页面看不到的实采坑)。
88
+ let _fileMtime = 0;
89
+
90
+ function load() {
91
+ let mtime = 0;
92
+ try {
93
+ mtime = statSync(SUBS_FILE).mtimeMs;
94
+ } catch {}
95
+ if (_loaded && mtime === _fileMtime) return; // 文件未变,用缓存
96
+ _loaded = true;
97
+ _fileMtime = mtime;
98
+ try {
99
+ if (existsSync(SUBS_FILE)) {
100
+ const data = JSON.parse(readFileSync(SUBS_FILE, "utf-8"));
101
+ _subs = Array.isArray(data.subscriptions) ? data.subscriptions : [];
102
+ if (mtime) console.error(`[订阅中心] 已加载订阅池: ${_subs.length} 条 (${SUBS_FILE})`);
103
+ }
104
+ } catch (e) {
105
+ console.error(`[订阅中心] 订阅池文件加载失败(忽略,按空池启动): ${e.message}`);
106
+ _subs = [];
107
+ }
108
+ }
109
+
110
+ function saveNow() {
111
+ if (_saveTimer) {
112
+ clearTimeout(_saveTimer);
113
+ _saveTimer = null;
114
+ }
115
+ const tmp = SUBS_FILE + ".tmp";
116
+ try {
117
+ writeFileSync(tmp, JSON.stringify({ subscriptions: _subs }, null, 2), "utf-8");
118
+ renameSync(tmp, SUBS_FILE); // 原子替换,防并发读撕裂
119
+ try {
120
+ _fileMtime = statSync(SUBS_FILE).mtimeMs; // 自身写入不触发下次重读
121
+ } catch {}
122
+ } catch (e) {
123
+ try {
124
+ unlinkSync(tmp);
125
+ } catch {}
126
+ console.error(`[订阅中心] 订阅池保存失败: ${e.message}`);
127
+ }
128
+ }
129
+
130
+ function scheduleSave() {
131
+ if (_saveTimer) return;
132
+ _saveTimer = setTimeout(saveNow, SAVE_DEBOUNCE_MS);
133
+ if (_saveTimer.unref) _saveTimer.unref();
134
+ }
135
+
136
+ // ── 看门狗配置读取(仅读,算引用关系用;避免与 watchdog-server 循环依赖) ──
137
+
138
+ function loadWatchdogConfig() {
139
+ try {
140
+ if (existsSync(WATCHDOG_CONFIG_PATH))
141
+ return JSON.parse(readFileSync(WATCHDOG_CONFIG_PATH, "utf-8"));
142
+ } catch {}
143
+ return {};
144
+ }
145
+
146
+ /** URL → 被哪些节点的配置引用(按 URL 匹配各 userId 的 proxySettings) */
147
+ export function getReferences(url) {
148
+ const cfg = loadWatchdogConfig();
149
+ const ps = cfg.proxySettings || {};
150
+ const refs = [];
151
+ for (const userId of Object.keys(ps)) {
152
+ const list = Array.isArray(ps[userId] && ps[userId].subscriptions)
153
+ ? ps[userId].subscriptions
154
+ : [];
155
+ let count = 0;
156
+ const indexes = [];
157
+ list.forEach((it, i) => {
158
+ const u = typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
159
+ if (u === url) {
160
+ count++;
161
+ indexes.push(i + 1);
162
+ }
163
+ });
164
+ if (count > 0) refs.push({ userId, count, indexes });
165
+ }
166
+ return refs;
167
+ }
168
+
169
+ // ── 状态推导 ──
170
+ // 优先级: 已禁用 > 探测失败 > 未知 > 已到期 > 已耗尽 > 临期 > 余量告急 > 正常
171
+
172
+ export function deriveStatus(entry, now = Date.now()) {
173
+ if (entry.enabled === false) return { code: "disabled", label: "已禁用", color: "#6b7280" };
174
+ const probeOk = entry.probe && entry.probe.ok;
175
+ const reportFresh =
176
+ entry.lastNodeReport &&
177
+ now - entry.lastNodeReport.at < NODE_REPORT_STALE_MS;
178
+ if (entry.probe && !entry.probe.ok && !reportFresh)
179
+ return { code: "probe-failed", label: "探测失败", color: "#fbbf24" };
180
+ // 生效快照 = 探测成功值 与 节点代报 中较新者
181
+ let eff = null;
182
+ if (probeOk && entry.lastNodeReport) {
183
+ eff = entry.probe.at >= entry.lastNodeReport.at ? entry.probe : entry.lastNodeReport;
184
+ } else if (probeOk) {
185
+ eff = entry.probe;
186
+ } else if (reportFresh) {
187
+ eff = entry.lastNodeReport;
188
+ }
189
+ if (!eff) return { code: "unknown", label: "未知", color: "#6b7280" };
190
+ const used = (eff.upload || 0) + (eff.download || 0);
191
+ const total = eff.total || 0;
192
+ const expireAt = eff.expire > 0 ? eff.expire * 1000 : 0;
193
+ if (expireAt && expireAt <= now)
194
+ return { code: "expired", label: "已到期", color: "#f87171" };
195
+ if (total > 0 && used >= total)
196
+ return { code: "exhausted", label: "已耗尽", color: "#f87171" };
197
+ if (expireAt && expireAt - now < EXPIRE_WARN_MS)
198
+ return { code: "expiring", label: "临期", color: "#f87171" };
199
+ if (total > 0 && (total - used) / total < QUOTA_WARN_RATIO)
200
+ return { code: "quota-critical", label: "余量告急", color: "#f87171" };
201
+ return { code: "ok", label: "正常", color: "#4ade80" };
202
+ }
203
+
204
+ // ── 探测 ──
205
+
206
+ function parseUserinfo(raw) {
207
+ const out = {};
208
+ const re = /(\w+)=(\d+)/g;
209
+ let m;
210
+ while ((m = re.exec(raw))) out[m[1].toLowerCase()] = parseInt(m[2], 10) || 0;
211
+ return out;
212
+ }
213
+
214
+ /** 响应体轻校验(只决定是否标内容可疑,不做硬拒绝) */
215
+ function looksLikeSubscription(peek) {
216
+ if (!peek) return true;
217
+ if (/^\s*proxies:|^\s*port:|^\s*password|vmess:|ss:|trojan:|hysteria|^\[Interface\]/im.test(peek))
218
+ return true;
219
+ return /^[A-Za-z0-9+/=\r\n_-]+$/.test(peek.slice(0, 64)); // base64 订阅
220
+ }
221
+
222
+ /**
223
+ * 探测单条订阅 URL: GET(Clash UA)→ subscription-userinfo 响应头
224
+ * @returns {Promise<{ok:boolean, httpCode?:number, error?:string, upload?, download?, total?, expire?, suspiciousBody?:boolean}>}
225
+ */
226
+ async function probeUrl(url) {
227
+ let res;
228
+ try {
229
+ res = await fetch(url, {
230
+ headers: { "user-agent": UA, accept: "*/*" },
231
+ redirect: "follow", // 订阅地址常 302 到真实内容,头在最终响应上
232
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
233
+ });
234
+ } catch (e) {
235
+ return { ok: false, error: e.name === "TimeoutError" ? `超时(${PROBE_TIMEOUT_MS / 1000}s)` : e.message };
236
+ }
237
+ const httpCode = res.status;
238
+ try {
239
+ const raw =
240
+ res.headers.get("subscription-userinfo") ||
241
+ res.headers.get("Subscription-Userinfo") ||
242
+ "";
243
+ // 只窥一小段响应体做轻校验,随即取消(订阅体可能几 MB,不整读)
244
+ let peek = "";
245
+ try {
246
+ const reader = res.body.getReader();
247
+ const first = await reader.read();
248
+ peek = Buffer.from(first.value || []).toString("latin1");
249
+ reader.cancel().catch(() => {});
250
+ } catch {}
251
+ if (!raw) {
252
+ return {
253
+ ok: false,
254
+ httpCode,
255
+ error: `HTTP ${httpCode} 无 subscription-userinfo 头(UA 不匹配或非订阅地址)`,
256
+ suspiciousBody: peek ? !looksLikeSubscription(peek) : false,
257
+ };
258
+ }
259
+ const ui = parseUserinfo(raw);
260
+ // 部分机场面板把 expire 发成毫秒时间戳(mihomo 内核同款 >1e12 兼容),统一归一为秒
261
+ let expire = ui.expire || 0;
262
+ if (expire > 1e12) expire = Math.round(expire / 1000);
263
+ return {
264
+ ok: true,
265
+ httpCode,
266
+ upload: ui.upload || 0,
267
+ download: ui.download || 0,
268
+ total: ui.total || 0,
269
+ expire,
270
+ suspiciousBody: peek ? !looksLikeSubscription(peek) : false,
271
+ };
272
+ } finally {
273
+ // res.body 可能仍被 reader 锁定:Node 22 里 cancel() 对锁定流返回 rejected promise
274
+ // (非同步抛错,try/catch 拦不住),必须接 .catch 否则 unhandledRejection 崩进程
275
+ try {
276
+ Promise.resolve(res.body?.cancel?.()).catch(() => {});
277
+ } catch {}
278
+ }
279
+ }
280
+
281
+ /**
282
+ * 探测一条订阅并落快照(探测成功才记历史 + 重置检测)
283
+ * @returns {Promise<{ok:boolean, error?:string, entry?:object}>}
284
+ */
285
+ export async function probeSubscription(id, { manual = false } = {}) {
286
+ load();
287
+ const entry = _subs.find((s) => s.id === id);
288
+ if (!entry) return { ok: false, error: "订阅不存在" };
289
+ if (entry.enabled === false && !manual)
290
+ return { ok: false, error: "订阅已禁用" };
291
+ if (_probing.has(id)) return { ok: false, error: "探测进行中" };
292
+ _probing.add(id);
293
+ const now = Date.now();
294
+ try {
295
+ let r = await probeUrl(entry.url);
296
+ // 自动探测失败时重试一次(机场偶发抖动/慢响应实采:同域其它 token 秒回,个别 token 首探超时;
297
+ // 手动探测不重试——页面请求等不了 30s×2,失败态下轮 6h 周期自会再试)
298
+ if (!r.ok && !manual) {
299
+ await new Promise((res) => setTimeout(res, 5000));
300
+ r = await probeUrl(entry.url);
301
+ }
302
+ const prev = entry.probe && entry.probe.ok ? entry.probe : null;
303
+ entry.probe = {
304
+ at: now,
305
+ ok: !!r.ok,
306
+ httpCode: r.httpCode ?? null,
307
+ error: r.ok ? null : r.error || "未知错误",
308
+ upload: r.upload || 0,
309
+ download: r.download || 0,
310
+ total: r.total || 0,
311
+ expire: r.expire || 0,
312
+ suspiciousBody: !!r.suspiciousBody,
313
+ };
314
+ if (r.ok) {
315
+ const used = (r.upload || 0) + (r.download || 0);
316
+ // 流量重置检测: 累计用量只会涨,下降 = 面板侧发生了周期重置
317
+ if (prev && prev.total === r.total && prev.expire === r.expire) {
318
+ const prevUsed = (prev.upload || 0) + (prev.download || 0);
319
+ if (prevUsed > 0 && used < prevUsed) {
320
+ entry.resets = Array.isArray(entry.resets) ? entry.resets : [];
321
+ entry.resets.push({ at: now, prevUsed, used });
322
+ if (entry.resets.length > RESETS_MAX) entry.resets.shift();
323
+ console.error(
324
+ `[订阅中心] 🔄 「${entry.name}」检测到流量重置: ${Math.round(prevUsed / 1073741824)}GB → ${Math.round(used / 1073741824)}GB`,
325
+ );
326
+ }
327
+ }
328
+ entry.history = Array.isArray(entry.history) ? entry.history : [];
329
+ entry.history.push({ ts: now, used });
330
+ trimHistory(entry);
331
+ }
332
+ saveNow();
333
+ const days = r.ok && r.expire ? Math.round((r.expire * 1000 - now) / 86400000) : null;
334
+ console.error(
335
+ r.ok
336
+ ? `[订阅中心] ✓ 「${entry.name}」探测成功: HTTP ${r.httpCode}, 剩余 ${fmtGBShort((r.total || 0) - ((r.upload || 0) + (r.download || 0)))}${r.expire ? `, ${days <= 0 ? "已到期" : `剩 ${days} 天`} ` : "长期 "}用途`
337
+ : `[订阅中心] ✗ 「${entry.name}」探测失败: ${r.error}`,
338
+ );
339
+ return { ok: true, entry };
340
+ } catch (e) {
341
+ entry.probe = { at: now, ok: false, httpCode: null, error: e.message };
342
+ saveNow();
343
+ return { ok: false, error: e.message };
344
+ } finally {
345
+ _probing.delete(id);
346
+ }
347
+ }
348
+
349
+ function trimHistory(entry) {
350
+ if (!Array.isArray(entry.history)) return;
351
+ const cutoff = Date.now() - HISTORY_MAX_AGE_MS;
352
+ entry.history = entry.history.filter((h) => h.ts >= cutoff);
353
+ if (entry.history.length > HISTORY_MAX_ENTRIES)
354
+ entry.history = entry.history.slice(-HISTORY_MAX_ENTRIES);
355
+ }
356
+
357
+ function fmtGBShort(bytes) {
358
+ if (!bytes || bytes <= 0) return "0GB";
359
+ const gb = bytes / 1073741824;
360
+ return (gb >= 1024 ? (gb / 1024).toFixed(2) + "TB" : gb.toFixed(1) + "GB");
361
+ }
362
+
363
+ // ── 节点代报采集(心跳被动兜底) ──
364
+
365
+ /**
366
+ * 从心跳里提取各通道 mihomo provider 快照,按「userId+订阅名」映射回池子 URL。
367
+ * 命名规则与 src/clash/runtime.js normalizeSubscriptions 一致(sub-N 按行序兜底)。
368
+ * @param {string} userId
369
+ * @param {object|null} mainHealth 主通道健康(clashHealth)
370
+ * @param {Array} channels 多通道健康(clashHealthChannels)
371
+ */
372
+ export function ingestNodeReport(userId, mainHealth, channels) {
373
+ load();
374
+ if (!userId) return;
375
+ const nodeSubs = expandNodeSubs(
376
+ (loadWatchdogConfig().proxySettings || {})[userId]?.subscriptions || [],
377
+ );
378
+ if (!nodeSubs.length) return;
379
+ const now = Date.now();
380
+ const healths = [mainHealth, ...(Array.isArray(channels) ? channels : [])];
381
+ let changed = false;
382
+ for (const h of healths) {
383
+ const list = h && h.meta && h.meta.proxy && h.meta.proxy.subscriptions;
384
+ if (!Array.isArray(list)) continue;
385
+ for (const s of list) {
386
+ if (!s || !s.name) continue;
387
+ const matched = nodeSubs.find((n) => n.name === s.name);
388
+ if (!matched) continue;
389
+ const entry = _subs.find((e) => e.url === matched.url);
390
+ if (!entry) continue; // 不在池子里的节点配置不采集(Phase 1 池子为准)
391
+ const prev = entry.lastNodeReport;
392
+ const same =
393
+ prev &&
394
+ prev.upload === (s.upload || 0) &&
395
+ prev.download === (s.download || 0) &&
396
+ prev.total === (s.total || 0) &&
397
+ prev.expire === (s.expire || 0);
398
+ if (same) {
399
+ prev.at = now; // 只刷新新鲜度,不触发写盘
400
+ continue;
401
+ }
402
+ entry.lastNodeReport = {
403
+ at: now,
404
+ by: userId,
405
+ upload: s.upload || 0,
406
+ download: s.download || 0,
407
+ total: s.total || 0,
408
+ expire: s.expire || 0,
409
+ };
410
+ changed = true;
411
+ }
412
+ }
413
+ if (changed) scheduleSave();
414
+ }
415
+
416
+ // ── CRUD ──
417
+
418
+ function makeId() {
419
+ return "sub_" + Date.now().toString(36) + randomBytes(3).toString("hex");
420
+ }
421
+
422
+ function cleanText(v, max) {
423
+ return typeof v === "string" ? v.trim().slice(0, max || 120) : "";
424
+ }
425
+
426
+ export function addSubscription({ name, url, note } = {}) {
427
+ load();
428
+ url = cleanText(url, 2048);
429
+ if (!url || !/^https?:\/\//.test(url))
430
+ return { ok: false, error: "订阅地址须为 http(s) URL" };
431
+ if (_subs.some((s) => s.url === url))
432
+ return { ok: false, error: "该订阅地址已存在于池中" };
433
+ if (_subs.length >= SUBS_MAX)
434
+ return { ok: false, error: `订阅池已满(上限 ${SUBS_MAX} 条)` };
435
+ const now = Date.now();
436
+ const entry = {
437
+ id: makeId(),
438
+ name: cleanText(name, 64) || `订阅-${_subs.length + 1}`,
439
+ url,
440
+ note: cleanText(note, 200),
441
+ enabled: true,
442
+ addedAt: now,
443
+ updatedAt: now,
444
+ probe: null,
445
+ lastNodeReport: null,
446
+ history: [],
447
+ resets: [],
448
+ };
449
+ _subs.push(entry);
450
+ saveNow();
451
+ console.error(`[订阅中心] + 新增订阅「${entry.name}」`);
452
+ return { ok: true, entry };
453
+ }
454
+
455
+ export function updateSubscription({ id, name, url, note, enabled } = {}) {
456
+ load();
457
+ const entry = _subs.find((s) => s.id === id);
458
+ if (!entry) return { ok: false, error: "订阅不存在" };
459
+ if (url !== undefined) {
460
+ url = cleanText(url, 2048);
461
+ if (!url || !/^https?:\/\//.test(url))
462
+ return { ok: false, error: "订阅地址须为 http(s) URL" };
463
+ if (_subs.some((s) => s.id !== id && s.url === url))
464
+ return { ok: false, error: "该订阅地址已存在于池中" };
465
+ if (url !== entry.url) {
466
+ entry.url = url;
467
+ entry.probe = null; // URL 变了旧快照作废,触发重新探测
468
+ }
469
+ }
470
+ if (name !== undefined) entry.name = cleanText(name, 64) || entry.name;
471
+ if (note !== undefined) entry.note = cleanText(note, 200);
472
+ if (enabled !== undefined) entry.enabled = !!enabled;
473
+ entry.updatedAt = Date.now();
474
+ saveNow();
475
+ console.error(
476
+ `[订阅中心] ✏️ 更新订阅「${entry.name}」${enabled !== undefined ? (entry.enabled ? "(启用)" : "(禁用)") : ""}`,
477
+ );
478
+ return { ok: true, entry };
479
+ }
480
+
481
+ export function deleteSubscription(id, { force = false } = {}) {
482
+ load();
483
+ const idx = _subs.findIndex((s) => s.id === id);
484
+ if (idx === -1) return { ok: false, error: "订阅不存在" };
485
+ const entry = _subs[idx];
486
+ const refs = getReferences(entry.url);
487
+ if (refs.length && !force) {
488
+ return {
489
+ ok: false,
490
+ needForce: true,
491
+ error: `仍被 ${refs.length} 个节点引用(${refs.map((r) => r.userId).join("、")}),确认删除请传 force`,
492
+ refs,
493
+ };
494
+ }
495
+ _subs.splice(idx, 1);
496
+ saveNow();
497
+ console.error(`[订阅中心] 🗑 删除订阅「${entry.name}」${refs.length ? `(含 ${refs.length} 个节点引用)` : ""}`);
498
+ return { ok: true };
499
+ }
500
+
501
+ // ── 视图(给前端/API 的只读投影) ──
502
+
503
+ export function getSubscriptionView() {
504
+ load();
505
+ const now = Date.now();
506
+ return _subs.map((e) => {
507
+ const status = deriveStatus(e, now);
508
+ // 生效快照(与 deriveStatus 同口径)
509
+ const probeOk = e.probe && e.probe.ok;
510
+ const reportFresh = e.lastNodeReport && now - e.lastNodeReport.at < NODE_REPORT_STALE_MS;
511
+ let eff = null;
512
+ let source = "none";
513
+ if (probeOk && reportFresh) {
514
+ if (e.probe.at >= e.lastNodeReport.at) {
515
+ eff = e.probe;
516
+ source = "probe";
517
+ } else {
518
+ eff = e.lastNodeReport;
519
+ source = "node";
520
+ }
521
+ } else if (probeOk) {
522
+ eff = e.probe;
523
+ source = "probe";
524
+ } else if (reportFresh) {
525
+ eff = e.lastNodeReport;
526
+ source = "node";
527
+ }
528
+ const used = eff ? (eff.upload || 0) + (eff.download || 0) : 0;
529
+ const total = eff ? eff.total || 0 : 0;
530
+ const expireAt = eff && eff.expire > 0 ? eff.expire * 1000 : 0;
531
+ return {
532
+ id: e.id,
533
+ name: e.name,
534
+ note: e.note || "",
535
+ enabled: e.enabled !== false,
536
+ addedAt: e.addedAt || 0,
537
+ // URL 仅控制页面可见,含 token 不进任何服务端日志
538
+ url: e.url,
539
+ status,
540
+ used,
541
+ total,
542
+ remain: total > 0 ? Math.max(0, total - used) : 0,
543
+ usedRatio: total > 0 ? Math.min(1, used / total) : null,
544
+ expireAt,
545
+ resetsCount: Array.isArray(e.resets) ? e.resets.length : 0,
546
+ lastResetAt: Array.isArray(e.resets) && e.resets.length ? e.resets[e.resets.length - 1].at : 0,
547
+ source, // probe | node | none
548
+ lastProbe: e.probe
549
+ ? { at: e.probe.at, ok: !!e.probe.ok, httpCode: e.probe.httpCode, error: e.probe.error || null }
550
+ : null,
551
+ lastNodeReport: e.lastNodeReport
552
+ ? { at: e.lastNodeReport.at, by: e.lastNodeReport.by }
553
+ : null,
554
+ dataAt: eff ? eff.at : 0,
555
+ refs: getReferences(e.url),
556
+ historyRecent: Array.isArray(e.history) ? e.history.slice(-48) : [], // ≈12 天 @6h
557
+ };
558
+ });
559
+ }
560
+
561
+ // ── 探测 worker(单写者:仅心跳所在进程启动) ──
562
+
563
+ function tick() {
564
+ load();
565
+ const now = Date.now();
566
+ const due = _subs.find(
567
+ (e) =>
568
+ e.enabled !== false &&
569
+ (!e.probe || !e.probe.at || now - e.probe.at >= PROBE_INTERVAL_MS) &&
570
+ !_probing.has(e.id),
571
+ );
572
+ if (due) probeSubscription(due.id).catch(() => {});
573
+ }
574
+
575
+ /** 由心跳路径调用:首个心跳到达时启动(单例),之后调用幂等 */
576
+ export function ensureWorker() {
577
+ if (_workerTimer) return;
578
+ _workerTimer = setInterval(tick, WORKER_TICK_MS);
579
+ if (_workerTimer.unref) _workerTimer.unref();
580
+ console.error(
581
+ `[订阅中心] 探测 worker 已启动(每条 ${PROBE_INTERVAL_MS / 3600000}h,节拍 ${WORKER_TICK_MS / 1000}s)`,
582
+ );
583
+ // 启动后 5s 先探一轮到期最久的
584
+ setTimeout(tick, 5000).unref?.();
585
+ }
586
+
587
+ /** 模块加载时读盘(不启动 worker) */
588
+ export function initSubscriptionCenter() {
589
+ load();
590
+ }