balance-dsh 2.0.1 → 2.0.3
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/lib/client.js +809 -156
- package/lib/index.js +499 -34
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -118,28 +118,210 @@ const CN_CALENDAR = {
|
|
|
118
118
|
|
|
119
119
|
// 节假日自动补录:内置表仅作**离线兜底**,运行时从社区维护的源拉取(其 papers 字段直接引用国务院公告)。
|
|
120
120
|
// 源不可用/无网络/被 CSP 拦截时静默失败,继续用内置表;拉取结果按日期覆盖同键条目。
|
|
121
|
-
|
|
121
|
+
// 节假日数据源(同一份数据、多个 CDN)。实测 cdn.jsdelivr.net 在国内经常连不上,
|
|
122
|
+
// fastly / gcore 那两个镜像反而稳定,所以按「先快后慢、逐个回退」排序,
|
|
123
|
+
// 任何一个能通就够 —— 这也让插件在某个 CDN 挂掉之后仍然能自己更新。
|
|
124
|
+
const CN_CALENDAR_SOURCES = [
|
|
125
|
+
"https://fastly.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
|
|
126
|
+
"https://gcore.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
|
|
127
|
+
"https://raw.githubusercontent.com/NateScarlet/holiday-cn/master",
|
|
128
|
+
"https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
|
|
129
|
+
];
|
|
130
|
+
const CN_CALENDAR_SOURCE = CN_CALENDAR_SOURCES[0]; // 兼容旧引用
|
|
131
|
+
const HOLIDAYS_FILENAME = "balance-holidays.json";
|
|
132
|
+
/** 缓存有效期:超过这个天数就在启动时后台重拉一次(用户什么都不用做)。 */
|
|
133
|
+
const CALENDAR_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
122
134
|
const cnCalendarYears = new Set();
|
|
135
|
+
// 已同步的节假日数据。days = 「YYYY-MM-DD」→ holiday / workday,
|
|
136
|
+
// 落盘到 <dshHome>/balance-holidays.json —— 重启后直接用缓存,断网也不退化成内置表。
|
|
137
|
+
// missing = 连上了但还没有数据的年份(典型:国务院还没发次年的放假安排)。
|
|
138
|
+
// 这些年份**不受 30 天 TTL 约束**:缓存再新鲜也每次启动补试一次,
|
|
139
|
+
// 否则在「11 月发了次年公告、但上次同步刚好锁住 30 天」这个窗口里会白等一个月。
|
|
140
|
+
const cnCalendarState = { days: {}, fetchedAt: 0, years: [], missing: [], file: "", source: "" };
|
|
141
|
+
|
|
142
|
+
/** 节假日缓存文件路径(空串 = 定位不到 home,缓存功能静默降级为纯内存)。 */
|
|
143
|
+
function holidaysFilePath(ctx) {
|
|
144
|
+
const home = dshHomeDir(ctx);
|
|
145
|
+
if (home === "") return "";
|
|
146
|
+
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
|
|
147
|
+
return home.replace(/[\\/]+$/, "") + sep + HOLIDAYS_FILENAME;
|
|
148
|
+
}
|
|
123
149
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
/** 拉取单年节假日并合并进 CN_CALENDAR / cnCalendarState;返回条数,失败返回 -1。
|
|
151
|
+
* 逐个试 CN_CALENDAR_SOURCES:某个 CDN 挂掉就换下一个,全挂才算失败。 */
|
|
152
|
+
async function fetchCalendarYear(year) {
|
|
153
|
+
for (const base of CN_CALENDAR_SOURCES) {
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(base + "/" + year + ".json", { signal: AbortSignal.timeout(6000) });
|
|
156
|
+
if (!res.ok) continue;
|
|
157
|
+
const data = await res.json();
|
|
158
|
+
if (!Array.isArray(data?.days)) continue;
|
|
159
|
+
let count = 0;
|
|
160
|
+
for (const item of data.days) {
|
|
161
|
+
if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
|
|
162
|
+
const kind = item.isOffDay ? "holiday" : "workday";
|
|
163
|
+
CN_CALENDAR[item.date] = kind;
|
|
164
|
+
cnCalendarState.days[item.date] = kind;
|
|
165
|
+
count += 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
cnCalendarState.source = base;
|
|
169
|
+
return count;
|
|
170
|
+
} catch {
|
|
171
|
+
// 这个源连不上:换下一个
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return -1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 读本地缓存(启动时调一次)。文件不存在 / 损坏一律当「没有缓存」。 */
|
|
178
|
+
async function loadCalendarCache(ctx) {
|
|
179
|
+
cnCalendarState.file = holidaysFilePath(ctx);
|
|
180
|
+
if (cnCalendarState.file === "") return false;
|
|
128
181
|
try {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
182
|
+
const { readFile } = await import("node:fs/promises");
|
|
183
|
+
const parsed = JSON.parse(await readFile(cnCalendarState.file, "utf8"));
|
|
184
|
+
const days = parsed && typeof parsed === "object" && parsed.days && typeof parsed.days === "object" ? parsed.days : null;
|
|
185
|
+
if (days === null) return false;
|
|
186
|
+
let count = 0;
|
|
187
|
+
for (const [date, kind] of Object.entries(days)) {
|
|
188
|
+
if (typeof date === "string" && (kind === "holiday" || kind === "workday")) {
|
|
189
|
+
CN_CALENDAR[date] = kind;
|
|
190
|
+
cnCalendarState.days[date] = kind;
|
|
191
|
+
count += 1;
|
|
136
192
|
}
|
|
137
193
|
}
|
|
194
|
+
cnCalendarState.fetchedAt = Number(parsed.fetchedAt) || 0;
|
|
195
|
+
if (typeof parsed.source === "string") cnCalendarState.source = parsed.source;
|
|
196
|
+
cnCalendarState.years = Array.isArray(parsed.years)
|
|
197
|
+
? parsed.years.map((y) => Number(y)).filter((y) => Number.isFinite(y)).sort()
|
|
198
|
+
: [];
|
|
199
|
+
cnCalendarState.missing = Array.isArray(parsed.missing)
|
|
200
|
+
? parsed.missing.map((y) => Number(y)).filter((y) => Number.isFinite(y)).sort()
|
|
201
|
+
: [];
|
|
202
|
+
return count > 0;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 写本地缓存:先写 .tmp 再 rename,避免写一半把文件写坏。 */
|
|
209
|
+
async function saveCalendarCache() {
|
|
210
|
+
const file = cnCalendarState.file;
|
|
211
|
+
if (file === "") return false;
|
|
212
|
+
try {
|
|
213
|
+
const { mkdir, writeFile, rename } = await import("node:fs/promises");
|
|
214
|
+
const { dirname } = await import("node:path");
|
|
215
|
+
await mkdir(dirname(file), { recursive: true });
|
|
216
|
+
const tmp = file + ".tmp";
|
|
217
|
+
const payload = {
|
|
218
|
+
fetchedAt: cnCalendarState.fetchedAt,
|
|
219
|
+
years: cnCalendarState.years,
|
|
220
|
+
missing: cnCalendarState.missing,
|
|
221
|
+
source: cnCalendarState.source || CN_CALENDAR_SOURCE,
|
|
222
|
+
days: cnCalendarState.days,
|
|
223
|
+
};
|
|
224
|
+
await writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
225
|
+
await rename(tmp, file);
|
|
226
|
+
return true;
|
|
138
227
|
} catch {
|
|
139
|
-
|
|
228
|
+
return false;
|
|
140
229
|
}
|
|
141
230
|
}
|
|
142
231
|
|
|
232
|
+
/** 当前节假日数据状态(GET /balance-api?calendar=1 直接返回它)。 */
|
|
233
|
+
function calendarStatus() {
|
|
234
|
+
return {
|
|
235
|
+
fetchedAt: cnCalendarState.fetchedAt,
|
|
236
|
+
years: cnCalendarState.years.slice(),
|
|
237
|
+
missing: cnCalendarState.missing.slice(),
|
|
238
|
+
count: Object.keys(cnCalendarState.days).length,
|
|
239
|
+
source: cnCalendarState.source || CN_CALENDAR_SOURCE,
|
|
240
|
+
days: cnCalendarState.days,
|
|
241
|
+
ttlDays: Math.round(CALENDAR_TTL_MS / 86400000),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 同步当年 ±1 年(覆盖 90 天统计窗口与跨年场景)。
|
|
246
|
+
* force=false 且缓存新鲜、且没有缺数据的年份时,完全跳过网络;
|
|
247
|
+
* 缓存新鲜但有 missing(如次年安排还没发布),只补拉那几个年份。 */
|
|
248
|
+
async function refreshCnCalendar(ctx, force) {
|
|
249
|
+
// 兜底:正常路径是 apply 里先 loadCalendarCache 把 file 填好;
|
|
250
|
+
// 但只要有人直接点「立即同步」而这个状态还没初始化,这里也得能落盘。
|
|
251
|
+
if (cnCalendarState.file === "") cnCalendarState.file = holidaysFilePath(ctx);
|
|
252
|
+
const fresh = cnCalendarState.fetchedAt > 0 && Date.now() - cnCalendarState.fetchedAt < CALENDAR_TTL_MS;
|
|
253
|
+
const stale = force || !fresh;
|
|
254
|
+
if (!stale && cnCalendarState.missing.length === 0) {
|
|
255
|
+
return { ok: true, skipped: true, ...calendarStatus() };
|
|
256
|
+
}
|
|
257
|
+
const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
|
|
258
|
+
// 只缺某几年 → 只补拉那几年,不要把已有数据的年份再拉一遍
|
|
259
|
+
const targets = stale ? [y - 1, y, y + 1] : cnCalendarState.missing.slice();
|
|
260
|
+
const done = [];
|
|
261
|
+
const failed = [];
|
|
262
|
+
const stillMissing = [];
|
|
263
|
+
let added = 0;
|
|
264
|
+
for (const year of targets) {
|
|
265
|
+
const n = await fetchCalendarYear(year);
|
|
266
|
+
if (n > 0) {
|
|
267
|
+
added += n;
|
|
268
|
+
done.push(year);
|
|
269
|
+
cnCalendarYears.add(year);
|
|
270
|
+
} else if (n === 0) {
|
|
271
|
+
// 连上了、但这一年还没数据(次年放假安排尚未公布)——不是失败,但要继续补试
|
|
272
|
+
stillMissing.push(year);
|
|
273
|
+
} else {
|
|
274
|
+
failed.push(year);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// 已拿到数据的年份从 missing 里摘掉;「连上了但没数据」的补进去(保持升序去重)。
|
|
278
|
+
// 注意「连不上」(n < 0)不算 missing —— 那是网络问题,交给 30 天 TTL 兜,
|
|
279
|
+
// 否则断网用户每次启动都会去拉三年、白白等超时。
|
|
280
|
+
//
|
|
281
|
+
// 下限 y-1:放假安排必然在当年之前公布,比 y-1 还早却还「缺」的年份不可能是
|
|
282
|
+
// 「待公布」,只可能是历史残留(例如插件停用两年后重新打开,那几年再也不会
|
|
283
|
+
// 被 targets 命中,会一直留着)。这类年份直接丢弃,否则设置页会永久显示
|
|
284
|
+
// 「2025 待公布」这种荒谬文本。上限不需要管:y+1 就是 target 的上界。
|
|
285
|
+
const floorYear = y - 1;
|
|
286
|
+
const kept = cnCalendarState.missing.filter((year) => year >= floorYear && !done.includes(year));
|
|
287
|
+
cnCalendarState.missing = [...new Set([...kept, ...stillMissing])].sort((x, z) => x - z);
|
|
288
|
+
if (done.length === 0 && failed.length > 0 && !stale) {
|
|
289
|
+
// 只补拉 missing 且全部连不上:保持原样,不动 fetchedAt(避免把数据「洗新」)
|
|
290
|
+
await saveCalendarCache();
|
|
291
|
+
return { ok: false, error: "连不上节假日数据源,继续使用本地数据。", failedYears: failed, ...calendarStatus() };
|
|
292
|
+
}
|
|
293
|
+
if (done.length === 0 && stillMissing.length > 0) {
|
|
294
|
+
// 连上了但确实还没公布(例如次年安排还没发)——不算错误,静默保持
|
|
295
|
+
await saveCalendarCache();
|
|
296
|
+
return { ok: true, pending: stillMissing, added: 0, ...calendarStatus() };
|
|
297
|
+
}
|
|
298
|
+
if (done.length === 0) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
error: "连不上节假日数据源,继续使用本地数据。",
|
|
302
|
+
failedYears: failed,
|
|
303
|
+
...calendarStatus(),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
cnCalendarState.fetchedAt = Date.now();
|
|
307
|
+
cnCalendarState.years = [...new Set([...cnCalendarState.years, ...done])].sort((x, z) => x - z);
|
|
308
|
+
await saveCalendarCache();
|
|
309
|
+
return { ok: true, added, years: done, failedYears: failed, pending: cnCalendarState.missing.slice(), ...calendarStatus() };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 拉取指定年份(保留旧入口):内部走 fetchCalendarYear,成功后落盘。 */
|
|
313
|
+
async function ensureCnCalendar(year) {
|
|
314
|
+
if (cnCalendarYears.has(year)) return;
|
|
315
|
+
cnCalendarYears.add(year); // 先占位,避免并发重复请求
|
|
316
|
+
const n = await fetchCalendarYear(year);
|
|
317
|
+
if (n < 0) return;
|
|
318
|
+
if (!cnCalendarState.years.includes(year)) {
|
|
319
|
+
cnCalendarState.years = [...cnCalendarState.years, year].sort((x, z) => x - z);
|
|
320
|
+
}
|
|
321
|
+
if (cnCalendarState.fetchedAt === 0) cnCalendarState.fetchedAt = Date.now();
|
|
322
|
+
void saveCalendarCache();
|
|
323
|
+
}
|
|
324
|
+
|
|
143
325
|
/** 北京时区日期键 YYYY-MM-DD(传入已 +8h 偏移的 Date)。 */
|
|
144
326
|
function cnDateKey(d) {
|
|
145
327
|
return d.getUTCFullYear() + "-" + String(d.getUTCMonth() + 1).padStart(2, "0") + "-" + String(d.getUTCDate()).padStart(2, "0");
|
|
@@ -345,7 +527,7 @@ function formatBalance(balance) {
|
|
|
345
527
|
// | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
|
|
346
528
|
// client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
|
|
347
529
|
// 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
|
|
348
|
-
const PLUGIN_VERSION = "2.0.
|
|
530
|
+
const PLUGIN_VERSION = "2.0.3";
|
|
349
531
|
|
|
350
532
|
// ─── 检查更新 ───────────────────────────────────────────────────────────────
|
|
351
533
|
// 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
|
|
@@ -354,10 +536,195 @@ const UPDATE_CACHE_MS = 10 * 60 * 1000;
|
|
|
354
536
|
const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/balance-dsh/latest";
|
|
355
537
|
let updateCache = { at: 0, value: null };
|
|
356
538
|
|
|
539
|
+
/** 语义化版本比较:a > b 返回正、相等返回 0、a < b 返回负。
|
|
540
|
+
* 只比三段数字(忽略预发布后缀),够用来判断「npm 上的是不是比本地新」。 */
|
|
541
|
+
function compareVersion(a, b) {
|
|
542
|
+
const parse = (v) =>
|
|
543
|
+
String(v ?? "")
|
|
544
|
+
.trim()
|
|
545
|
+
.replace(/^v/i, "")
|
|
546
|
+
.split("-")[0]
|
|
547
|
+
.split(".")
|
|
548
|
+
.map((x) => parseInt(x, 10) || 0);
|
|
549
|
+
const x = parse(a);
|
|
550
|
+
const y = parse(b);
|
|
551
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
552
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
553
|
+
if (d !== 0) return d;
|
|
554
|
+
}
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** 本插件的安装根目录(.../node_modules/balance-dsh),由 import.meta.url 反推。
|
|
559
|
+
* 定位不到返回空串(调用方报错,不动任何文件)。 */
|
|
560
|
+
async function installedPluginDir() {
|
|
561
|
+
try {
|
|
562
|
+
const { fileURLToPath } = await import("node:url");
|
|
563
|
+
const { dirname } = await import("node:path");
|
|
564
|
+
// .../node_modules/balance-dsh/lib/index.js → 上两级就是包根
|
|
565
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
566
|
+
} catch {
|
|
567
|
+
return "";
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 极简 tar.gz 解包:只取普通文件、去掉 npm 固定的 package/ 前缀,返回 [{ rel, data }]。
|
|
572
|
+
* 不处理符号链接(npm 包是禁掉 symlink 的)与 ustar 长文件名(本包路径都很短)。 */
|
|
573
|
+
async function extractPackageFiles(gz) {
|
|
574
|
+
const { gunzipSync } = await import("node:zlib");
|
|
575
|
+
const tar = gunzipSync(gz);
|
|
576
|
+
const files = [];
|
|
577
|
+
let off = 0;
|
|
578
|
+
while (off + 512 <= tar.length) {
|
|
579
|
+
const header = tar.subarray(off, off + 512);
|
|
580
|
+
// 尾部是全零块,遇到就结束
|
|
581
|
+
let allZero = true;
|
|
582
|
+
for (let i = 0; i < 512; i += 1) {
|
|
583
|
+
if (header[i] !== 0) {
|
|
584
|
+
allZero = false;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (allZero) break;
|
|
589
|
+
const name = header.toString("utf8", 0, 100).replace(/\0.*$/, "");
|
|
590
|
+
const size = parseInt(header.toString("utf8", 124, 136).replace(/\0.*$/, "").trim(), 8) || 0;
|
|
591
|
+
const typeFlag = header.toString("utf8", 156, 157);
|
|
592
|
+
// "0" / "" / NUL = 普通文件;"5" = 目录,"2" = 符号链接,都跳过
|
|
593
|
+
const isFile = typeFlag === "0" || typeFlag === "" || typeFlag === "\0";
|
|
594
|
+
if (isFile && size > 0 && name !== "") {
|
|
595
|
+
const rel = name.startsWith("package/") ? name.slice("package/".length) : name;
|
|
596
|
+
if (rel !== "" && !rel.endsWith("/") && !rel.includes("..")) {
|
|
597
|
+
files.push({ rel, data: tar.subarray(off + 512, off + 512 + size) });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
601
|
+
}
|
|
602
|
+
return files;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** tarball 地址(npm 的固定布局)。 */
|
|
606
|
+
function updateTarballUrl(version) {
|
|
607
|
+
return "https://registry.npmjs.org/balance-dsh/-/balance-dsh-" + version + ".tgz";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 就地更新本插件:下载 npm 上的最新 tarball → 解包 → 逐文件覆盖自己的安装目录。
|
|
611
|
+
*
|
|
612
|
+
* 几个刻意的取舍:
|
|
613
|
+
* - 只覆盖 tarball 里有的文件,不删任何东西。上游删掉的文件会残留,但残留无害,
|
|
614
|
+
* 比误删用户目录里的东西安全得多。
|
|
615
|
+
* - 覆盖前把旧文件备份到 <dshHome>/balance-update-backup/<from>-<时间戳>/,可手工回滚。
|
|
616
|
+
* - 逐个文件写、单个失败只记进 failed 不中断 —— 最典型的是 Windows 上 package.json
|
|
617
|
+
* 被 DSH 占着(之前同步时就踩过),但 lib/*.js 能写进去,功能不受影响。
|
|
618
|
+
* - 覆盖后 lib/index.js 的改动**必须重启 DSH** 才生效:Node 不会热替换已加载的模块。
|
|
619
|
+
* 返回值里带 restart:true,让界面明确提示。
|
|
620
|
+
*/
|
|
621
|
+
async function installLatestVersion(ctx) {
|
|
622
|
+
const found = await fetchLatestVersion();
|
|
623
|
+
if (found.error) return { ok: false, error: found.error };
|
|
624
|
+
const latest = found.latest;
|
|
625
|
+
if (compareVersion(latest, PLUGIN_VERSION) <= 0) {
|
|
626
|
+
return { ok: true, installed: false, from: PLUGIN_VERSION, to: latest, note: "已是最新版本" };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const root = await installedPluginDir();
|
|
630
|
+
if (root === "") return { ok: false, error: "定位不到插件安装目录" };
|
|
631
|
+
|
|
632
|
+
let gz;
|
|
633
|
+
try {
|
|
634
|
+
const signal =
|
|
635
|
+
typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
|
|
636
|
+
? AbortSignal.timeout(20000)
|
|
637
|
+
: undefined;
|
|
638
|
+
const res = await fetch(updateTarballUrl(latest), { signal });
|
|
639
|
+
if (!res || !res.ok) throw new Error("HTTP " + (res ? res.status : "?"));
|
|
640
|
+
gz = Buffer.from(await res.arrayBuffer());
|
|
641
|
+
} catch (error) {
|
|
642
|
+
return { ok: false, error: "下载 " + latest + " 失败:" + (error instanceof Error ? error.message : String(error)) };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
let files;
|
|
646
|
+
try {
|
|
647
|
+
files = await extractPackageFiles(gz);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
return { ok: false, error: "解包失败:" + (error instanceof Error ? error.message : String(error)) };
|
|
650
|
+
}
|
|
651
|
+
if (files.length === 0) return { ok: false, error: "下载到的包里没有文件" };
|
|
652
|
+
|
|
653
|
+
const { mkdir, writeFile, readFile, rename, unlink } = await import("node:fs/promises");
|
|
654
|
+
const { dirname, join, resolve, sep } = await import("node:path");
|
|
655
|
+
|
|
656
|
+
// 备份目录:拿不到 dshHome 就跳过备份(仍继续更新,失败项会体现在 failed 里)
|
|
657
|
+
const home = dshHomeDir(ctx);
|
|
658
|
+
const backupRoot =
|
|
659
|
+
home === "" ? "" : join(home, "balance-update-backup", PLUGIN_VERSION + "-" + Date.now());
|
|
660
|
+
|
|
661
|
+
const written = [];
|
|
662
|
+
const failed = [];
|
|
663
|
+
const rootAbs = resolve(root);
|
|
664
|
+
const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep;
|
|
665
|
+
|
|
666
|
+
for (const file of files) {
|
|
667
|
+
const dst = resolve(rootAbs, file.rel);
|
|
668
|
+
// 防路径穿越:解析后必须仍在包目录里
|
|
669
|
+
if (!dst.startsWith(rootPrefix)) {
|
|
670
|
+
failed.push(file.rel + "(路径越界,已跳过)");
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
if (backupRoot !== "") {
|
|
675
|
+
try {
|
|
676
|
+
const old = await readFile(dst);
|
|
677
|
+
const bak = join(backupRoot, file.rel);
|
|
678
|
+
await mkdir(dirname(bak), { recursive: true });
|
|
679
|
+
await writeFile(bak, old);
|
|
680
|
+
} catch {
|
|
681
|
+
// 旧文件不存在(新版本新增的文件)或不可读:没有可备份的,继续
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
685
|
+
// 先写同目录临时文件再 rename:原子替换 + 断开硬链接(profile 里有些文件是
|
|
686
|
+
// pnpm 硬链接,直接 writeFile 会写进同一个 inode,可能污染 store / 其它 profile)。
|
|
687
|
+
const staging = dst + ".new-" + Date.now() + "-" + written.length;
|
|
688
|
+
try {
|
|
689
|
+
await writeFile(staging, file.data);
|
|
690
|
+
await rename(staging, dst);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
try {
|
|
693
|
+
await unlink(staging);
|
|
694
|
+
} catch {
|
|
695
|
+
// 临时文件没建起来或已清理,忽略
|
|
696
|
+
}
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
written.push(file.rel);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
failed.push(file.rel + ":" + (error instanceof Error ? error.message : String(error)));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (written.length === 0) {
|
|
706
|
+
return { ok: false, error: "没有文件写入成功:" + failed.join(";") };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 缓存里的 latest 可能已经过时,更新完作废,下次检查重新问 registry
|
|
710
|
+
updateCache = { at: 0, value: null };
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
ok: true,
|
|
714
|
+
installed: true,
|
|
715
|
+
from: PLUGIN_VERSION,
|
|
716
|
+
to: latest,
|
|
717
|
+
files: written.length,
|
|
718
|
+
failed,
|
|
719
|
+
backup: backupRoot,
|
|
720
|
+
restart: true,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
357
724
|
/** 查 npm 上 balance-dsh 的最新版本。成功返回 { latest },失败返回 { error }(失败不缓存,下次可重试)。 */
|
|
358
|
-
async function fetchLatestVersion() {
|
|
725
|
+
async function fetchLatestVersion(force) {
|
|
359
726
|
const now = Date.now();
|
|
360
|
-
if (updateCache.value && now - updateCache.at < UPDATE_CACHE_MS) return updateCache.value;
|
|
727
|
+
if (!force && updateCache.value && now - updateCache.at < UPDATE_CACHE_MS) return updateCache.value;
|
|
361
728
|
if (typeof fetch !== "function") return { error: "当前运行环境没有 fetch" };
|
|
362
729
|
try {
|
|
363
730
|
let signal;
|
|
@@ -693,6 +1060,10 @@ const DEFAULT_SETTINGS = {
|
|
|
693
1060
|
statsSessionLimit: STATS_MAX_SESSIONS, // 统计扫描的最近会话数
|
|
694
1061
|
statsCacheSec: 300, // 统计结果缓存秒数
|
|
695
1062
|
// 计费
|
|
1063
|
+
// 高峰时段窗口。官方当前是「工作日北京 9-12、14-18」,但这条也是会变的
|
|
1064
|
+
// (改了时段就等于改了哪几个小时翻倍)。写成文本而不是数组:输入框直接对应,
|
|
1065
|
+
// 没有「数组 → 界面 → 数组」的转换损耗,用户手改也不会因为半截输入被弹回。
|
|
1066
|
+
peakWindowsText: "9-12, 14-18",
|
|
696
1067
|
peakMultiplier: 2, // 高峰时段倍率
|
|
697
1068
|
priceFlash: null, // flash 覆盖价(元/百万 token),四项全填才生效
|
|
698
1069
|
pricePro: null, // v4-pro 覆盖价
|
|
@@ -729,6 +1100,38 @@ function normalizeNewTagSeen(raw) {
|
|
|
729
1100
|
return out;
|
|
730
1101
|
}
|
|
731
1102
|
|
|
1103
|
+
/** 高峰窗口文本 → 数组。接受 "9-12, 14-18"、"9-12,14-18"、"9~12" 这几种写法。
|
|
1104
|
+
* 逐段校验(0 ≤ start < end ≤ 24,整点),**任何一段不合法就整串作废返回 null** ——
|
|
1105
|
+
* 宁可回退官方默认窗口,也不要拿半对半错的窗口去算钱。
|
|
1106
|
+
* end = 24 表示到当天结束(hour 最大 23,`hour < end` 覆盖 23:00-23:59)。 */
|
|
1107
|
+
function parsePeakWindowsText(text) {
|
|
1108
|
+
if (typeof text !== "string") return null;
|
|
1109
|
+
const out = [];
|
|
1110
|
+
for (const chunk of text.split(/[,,;;]/)) {
|
|
1111
|
+
const part = chunk.trim();
|
|
1112
|
+
if (part === "") continue;
|
|
1113
|
+
const m = part.match(/^(\d{1,2})\s*[-~到至]\s*(\d{1,2})$/);
|
|
1114
|
+
if (!m) return null;
|
|
1115
|
+
const start = Number(m[1]);
|
|
1116
|
+
const end = Number(m[2]);
|
|
1117
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
|
|
1118
|
+
if (start < 0 || end > 24 || start >= end) return null;
|
|
1119
|
+
out.push({ start, end });
|
|
1120
|
+
}
|
|
1121
|
+
return out.length > 0 ? out : null;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** 高峰窗口文本的**类型清洗**(不校验语法)。
|
|
1125
|
+
* 用户打到一半("9-")也要能存下来继续编辑,所以这里不判合法;
|
|
1126
|
+
* 真正解析发生在 withSettings(服务端算钱)与客户端 currentPeriod(显示标签),
|
|
1127
|
+
* 解析失败一律回退官方默认窗口 —— 计费永远不会因为一段烂文本而错。 */
|
|
1128
|
+
function normalizePeakWindowsText(raw, fallback) {
|
|
1129
|
+
if (typeof raw !== "string") return fallback;
|
|
1130
|
+
const text = raw.trim();
|
|
1131
|
+
if (text === "") return fallback;
|
|
1132
|
+
return text.slice(0, 80);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
732
1135
|
/** 一组覆盖价:逐个字段校验(有限非负数才收),不填的字段留空表示「用默认价」。
|
|
733
1136
|
* 四个字段都没填则整组丢弃(等价于不覆盖)。 */
|
|
734
1137
|
function normalizePriceOverride(raw) {
|
|
@@ -769,6 +1172,7 @@ function normalizeSettings(raw) {
|
|
|
769
1172
|
fabLeft: clampNumber(src.fabLeft, 0, 600, d.fabLeft),
|
|
770
1173
|
statsSessionLimit: clampNumber(src.statsSessionLimit, 10, 2000, d.statsSessionLimit),
|
|
771
1174
|
statsCacheSec: clampNumber(src.statsCacheSec, 0, 3600, d.statsCacheSec),
|
|
1175
|
+
peakWindowsText: normalizePeakWindowsText(src.peakWindowsText, d.peakWindowsText),
|
|
772
1176
|
peakMultiplier: clampNumber(src.peakMultiplier, 1, 10, d.peakMultiplier),
|
|
773
1177
|
priceFlash: normalizePriceOverride(src.priceFlash),
|
|
774
1178
|
pricePro: normalizePriceOverride(src.pricePro),
|
|
@@ -784,6 +1188,9 @@ function withSettings(config, settings) {
|
|
|
784
1188
|
return {
|
|
785
1189
|
...config,
|
|
786
1190
|
apiKey: s.apiKey !== "" ? s.apiKey : config?.apiKey,
|
|
1191
|
+
// 设置里改的高峰窗口优先;文本解析不出来就回退官方默认窗口,
|
|
1192
|
+
// 绝不能把 undefined 透给 isPeakAt(那会让 isPeakAt 自己再 fallback,语义重复)
|
|
1193
|
+
peakWindows: parsePeakWindowsText(s.peakWindowsText) ?? DEFAULT_PEAK_WINDOWS,
|
|
787
1194
|
peakMultiplier: s.peakMultiplier,
|
|
788
1195
|
priceByModel,
|
|
789
1196
|
statsSessionLimit: s.statsSessionLimit,
|
|
@@ -791,9 +1198,9 @@ function withSettings(config, settings) {
|
|
|
791
1198
|
};
|
|
792
1199
|
}
|
|
793
1200
|
|
|
794
|
-
/**
|
|
795
|
-
*
|
|
796
|
-
function
|
|
1201
|
+
/** DSH 的 home 目录:优先 dshHomePath 服务,再环境变量,最后 ~/.dsh。
|
|
1202
|
+
* 返回空串表示定位不到,此时设置读写与更新备份一律静默降级。 */
|
|
1203
|
+
function dshHomeDir(ctx) {
|
|
797
1204
|
let home = "";
|
|
798
1205
|
try {
|
|
799
1206
|
const svc = typeof ctx?.get === "function" ? ctx.get("dshHomePath") : null;
|
|
@@ -808,6 +1215,12 @@ function settingsFilePath(ctx) {
|
|
|
808
1215
|
const base = process.env.USERPROFILE || process.env.HOME || "";
|
|
809
1216
|
if (base !== "") home = `${base}${process.platform === "win32" ? "\\" : "/"}.dsh`;
|
|
810
1217
|
}
|
|
1218
|
+
return home;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** 设置文件路径。返回空串表示定位不到,此时读写一律静默降级为「用默认值、不落盘」。 */
|
|
1222
|
+
function settingsFilePath(ctx) {
|
|
1223
|
+
const home = dshHomeDir(ctx);
|
|
811
1224
|
if (home === "") return "";
|
|
812
1225
|
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
|
|
813
1226
|
return `${home.replace(/[\\/]+$/, "")}${sep}${SETTINGS_FILENAME}`;
|
|
@@ -1423,14 +1836,17 @@ export function apply(ctx, config = {}) {
|
|
|
1423
1836
|
// 时统一清理,保证重复 apply 不冲突。
|
|
1424
1837
|
const disposers = [];
|
|
1425
1838
|
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1839
|
+
// 节假日数据:先读本地缓存(这样断网、改过系统时间也照样按上次同步的结果算钱),
|
|
1840
|
+
// 再看缓存是不是过期(默认 30 天),过期才去拉。故意不 await —— 网络慢不能拖住启动,
|
|
1841
|
+
// isPeakAt 保持同步函数,缓存没到位时继续用内置表。
|
|
1842
|
+
void (async () => {
|
|
1843
|
+
try {
|
|
1844
|
+
await loadCalendarCache(ctx);
|
|
1845
|
+
await refreshCnCalendar(ctx, false);
|
|
1846
|
+
} catch {
|
|
1847
|
+
// 任何异常都只是「这次没更新」,不影响余额与计费
|
|
1848
|
+
}
|
|
1849
|
+
})();
|
|
1434
1850
|
|
|
1435
1851
|
// 1) /balance 命令:保留命令行查询入口
|
|
1436
1852
|
ctx.inject(["commands"], (commandCtx) => {
|
|
@@ -1502,12 +1918,9 @@ export function apply(ctx, config = {}) {
|
|
|
1502
1918
|
}
|
|
1503
1919
|
return;
|
|
1504
1920
|
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
}
|
|
1509
|
-
// 检查更新:GET /balance-api?update=1 → 返回 npm 上的最新版本。
|
|
1510
|
-
// 与余额无关,独立成一路,失败也不影响其它字段。
|
|
1921
|
+
// 检查更新 / 就地更新:同一个参数,GET 查询、POST 安装。
|
|
1922
|
+
// 它排在下面那道 405 之前 —— 这是除了设置写入之外唯一放开 POST 的入口,
|
|
1923
|
+
// 且只认 ?update=1(其余非 GET 仍然 405)。
|
|
1511
1924
|
let wantUpdate = false;
|
|
1512
1925
|
try {
|
|
1513
1926
|
wantUpdate = new URL(req.url, "http://localhost").searchParams.get("update") === "1";
|
|
@@ -1515,7 +1928,27 @@ export function apply(ctx, config = {}) {
|
|
|
1515
1928
|
wantUpdate = false;
|
|
1516
1929
|
}
|
|
1517
1930
|
if (wantUpdate) {
|
|
1518
|
-
|
|
1931
|
+
if (req.method === "POST") {
|
|
1932
|
+
// POST → 下载 npm 上最新 tarball 覆盖本机安装(见 installLatestVersion)。
|
|
1933
|
+
// 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
|
|
1934
|
+
try {
|
|
1935
|
+
const result = await installLatestVersion(httpCtx);
|
|
1936
|
+
writeJson(res, 200, { current: PLUGIN_VERSION, ...result });
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
writeJson(res, 200, {
|
|
1939
|
+
ok: false,
|
|
1940
|
+
current: PLUGIN_VERSION,
|
|
1941
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
if (req.method !== "GET") {
|
|
1947
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
// force:用户手动点「检查更新」时不走 10 分钟缓存,否则刚更新过会显示旧结果
|
|
1951
|
+
const found = await fetchLatestVersion(true);
|
|
1519
1952
|
if (found && found.error) {
|
|
1520
1953
|
writeJson(res, 200, { ok: false, current: PLUGIN_VERSION, error: found.error });
|
|
1521
1954
|
} else {
|
|
@@ -1523,6 +1956,38 @@ export function apply(ctx, config = {}) {
|
|
|
1523
1956
|
}
|
|
1524
1957
|
return;
|
|
1525
1958
|
}
|
|
1959
|
+
// 节假日数据:GET 读状态(含 days 明细,客户端据此与服务端同口径判断峰谷),
|
|
1960
|
+
// POST 强制重新同步。和 update 一样排在下面那道 405 之前。
|
|
1961
|
+
let wantCalendar = false;
|
|
1962
|
+
try {
|
|
1963
|
+
wantCalendar = new URL(req.url, "http://localhost").searchParams.get("calendar") === "1";
|
|
1964
|
+
} catch {
|
|
1965
|
+
wantCalendar = false;
|
|
1966
|
+
}
|
|
1967
|
+
if (wantCalendar) {
|
|
1968
|
+
if (req.method === "POST") {
|
|
1969
|
+
try {
|
|
1970
|
+
writeJson(res, 200, await refreshCnCalendar(httpCtx, true));
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
writeJson(res, 200, {
|
|
1973
|
+
ok: false,
|
|
1974
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1975
|
+
...calendarStatus(),
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (req.method !== "GET") {
|
|
1981
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
writeJson(res, 200, { ok: true, ...calendarStatus() });
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (req.method !== "GET") {
|
|
1988
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1526
1991
|
// 客户端把当前会话使用的模型通过 ?model= 传入,服务端按模型取价;
|
|
1527
1992
|
// 可选 ?session= 传入会话 id,服务端返回该会话的分时段 usage(跨时段精确计费用)
|
|
1528
1993
|
let model = "";
|