dsh-desktop-statusbar 2.1.1 → 2.1.2
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/README.en.md +130 -108
- package/README.md +128 -108
- package/docs/pricing.en.png +0 -0
- package/docs/pricing.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/balance-api.js +11 -46
- package/lib/client.js +340 -49
- package/lib/index.js +572 -250
- package/lib/release-age.js +390 -0
- package/lib/self-update.js +368 -0
- package/package.json +6 -1
- package/tools/plugin-dir.cjs +112 -0
- package/tools/shot.cjs +365 -0
- package/tools/sync-from-plugin.cjs +49 -0
- package/tools/sync-to-plugin.cjs +71 -0
- package/tools/test.cjs +99 -0
package/lib/index.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { z } from 'zod'
|
|
41
41
|
import { adapterFor, adaptersFor, pluginRoutesIn } from './balance-api.js'
|
|
42
42
|
import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
|
|
43
|
+
import { isUpdating, lastUpdateResult, resolveSelfDir, selfUpdateOnce } from './self-update.js'
|
|
43
44
|
import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
44
45
|
import { basename, dirname, join } from 'node:path'
|
|
45
46
|
import { homedir } from 'node:os'
|
|
@@ -404,9 +405,47 @@ const MAX_CALLS = 4000
|
|
|
404
405
|
const appendCalls = (calls, more) =>
|
|
405
406
|
calls.length + more.length <= MAX_CALLS ? calls.concat(more) : calls.slice(calls.length + more.length - MAX_CALLS).concat(more)
|
|
406
407
|
|
|
408
|
+
/**
|
|
409
|
+
* 被 MAX_CALLS 截掉的调用,按「模型 + 峰谷档」累计的 token。
|
|
410
|
+
*
|
|
411
|
+
* 为什么需要它:客户端算「总计费用」是**逐条调用**计价(每条按自己发生的时刻定峰谷),
|
|
412
|
+
* 而 calls 只保留最近 MAX_CALLS 条;超出部分原先直接丢弃,费用就少算了 ——
|
|
413
|
+
* 实测 5000 次调用少 20%、2 万次少 80%,而「总计用量」用的是官方全量数据,
|
|
414
|
+
* 于是同一个界面上用量是全量、费用是残缺的,且没有任何提示。
|
|
415
|
+
*
|
|
416
|
+
* 这里把丢掉的那些按档累计,客户端用档位价补算即可 —— 档位在 host 侧按北京时间判定,
|
|
417
|
+
* 与逐条计价同一套规则,所以两段相加与「一条不丢地算」结果一致。
|
|
418
|
+
*/
|
|
419
|
+
const overflowTier = z.object({
|
|
420
|
+
peak: usageBucket,
|
|
421
|
+
offPeak: usageBucket,
|
|
422
|
+
}).strict()
|
|
423
|
+
|
|
424
|
+
/** 往 overflow 里加一笔;返回新的 overflow(不改原对象)。 */
|
|
425
|
+
function addOverflow(overflow, model, parts, peak) {
|
|
426
|
+
const zero = () => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0 })
|
|
427
|
+
const prev = overflow[model] ?? { peak: zero(), offPeak: zero() }
|
|
428
|
+
const tierName = peak === true ? 'peak' : 'offPeak'
|
|
429
|
+
const tier = prev[tierName]
|
|
430
|
+
return {
|
|
431
|
+
...overflow,
|
|
432
|
+
[model]: {
|
|
433
|
+
...prev,
|
|
434
|
+
[tierName]: {
|
|
435
|
+
input: tier.input + parts.input,
|
|
436
|
+
cacheRead: tier.cacheRead + parts.cacheRead,
|
|
437
|
+
cacheWrite: tier.cacheWrite + parts.cacheWrite,
|
|
438
|
+
output: tier.output + parts.output,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
407
444
|
const sessionUsageSchema = z.object({
|
|
408
445
|
models: z.record(z.string(), usageBucket),
|
|
409
446
|
calls: z.array(usageCall),
|
|
447
|
+
/* 被 MAX_CALLS 截掉的那部分(见 addOverflow 的说明)。老缓存没有这个键,所以可选。 */
|
|
448
|
+
overflow: z.record(z.string(), overflowTier).optional(),
|
|
410
449
|
turn: z.number().nullable(),
|
|
411
450
|
current: lastUsage.nullable(),
|
|
412
451
|
last: lastUsage.nullable(),
|
|
@@ -419,7 +458,7 @@ const sessionUsageSchema = z.object({
|
|
|
419
458
|
const sessionUsageProjection = {
|
|
420
459
|
key: 'desktopStatusbarUsage',
|
|
421
460
|
stateSchema: sessionUsageSchema,
|
|
422
|
-
init: () => ({ models: {}, calls: [], current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
|
|
461
|
+
init: () => ({ models: {}, calls: [], overflow: {}, current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
|
|
423
462
|
apply: (state, event) => {
|
|
424
463
|
const calls = Array.isArray(state.calls) ? state.calls : []
|
|
425
464
|
const turnOf = (event) => {
|
|
@@ -484,10 +523,22 @@ const sessionUsageProjection = {
|
|
|
484
523
|
output: (prev === null ? 0 : prev.output) + one.output,
|
|
485
524
|
}
|
|
486
525
|
const current = { ...summary, calls: callsForTurn.concat([one]) }
|
|
526
|
+
/* 追加后若超过 MAX_CALLS,最前面那几条会被丢掉。丢掉的是**真实用过的 token**,
|
|
527
|
+
必须把它们按档累计进 overflow,否则客户端逐条计价时会少算(见 addOverflow 说明)。
|
|
528
|
+
用合并前后的长度差算出丢了几条 —— 它们一定是最老的,也就是 calls 队首那几条。 */
|
|
529
|
+
const merged = appendCalls(calls, [one])
|
|
530
|
+
const droppedCount = calls.length + 1 - merged.length
|
|
531
|
+
let overflow = typeof base.overflow === 'object' && base.overflow !== null ? base.overflow : {}
|
|
532
|
+
for (let i = 0; i < droppedCount; i += 1) {
|
|
533
|
+
const gone = calls[i]
|
|
534
|
+
if (gone === undefined) break
|
|
535
|
+
overflow = addOverflow(overflow, gone.model, gone, isPeakTime(gone.at))
|
|
536
|
+
}
|
|
487
537
|
return {
|
|
488
538
|
...base,
|
|
489
539
|
turn: turn === null ? stateTurn : turn,
|
|
490
|
-
calls:
|
|
540
|
+
calls: merged,
|
|
541
|
+
overflow,
|
|
491
542
|
current,
|
|
492
543
|
// 轮次变化时把上一轮结算进 last
|
|
493
544
|
last: prev === null && base.current !== null ? base.current : base.last,
|
|
@@ -503,7 +554,9 @@ const sessionUsageProjection = {
|
|
|
503
554
|
// 界面上的「本轮」因此与「总计」对不上,提升版本强制从事件日志重放。
|
|
504
555
|
// 4 → 5:旧缓存里缺「会话起标题」那几笔 —— attempt 事件没有 message,取不到型号时
|
|
505
556
|
// 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
|
|
506
|
-
|
|
557
|
+
// 5 → 6:新增 overflow(被 MAX_CALLS 截掉那段的按档累计)。旧缓存里这个字段不存在,
|
|
558
|
+
// 长会话的费用会一直少算截掉的部分,必须重放才能补出来。
|
|
559
|
+
stateVersion: 6,
|
|
507
560
|
}
|
|
508
561
|
|
|
509
562
|
/* -------------------------------------------------------- sessionTimeRange */
|
|
@@ -806,26 +859,82 @@ function sendJson(res, status, body) {
|
|
|
806
859
|
res.end(text)
|
|
807
860
|
}
|
|
808
861
|
|
|
809
|
-
|
|
862
|
+
/* 写请求的来源校验:只拦「带跨站 Origin 的 POST」。
|
|
863
|
+
插件把 API 挂在 127.0.0.1 的 webServer 上,浏览器里任意网页都能对本机发简单请求
|
|
864
|
+
(`Content-Type: text/plain` 的 POST 不触发 CORS 预检,页面读不到响应也照样**把请求送达**)。
|
|
865
|
+
实测:伪造的价格库被完整接受,settleUnpriced 随后按攻击者单价结算进 `days[].cost`,
|
|
866
|
+
而 cost 一旦记下就不再改动 —— 污染不可逆;同一面还能触发 /api/self-update 真跑 pnpm 安装。
|
|
867
|
+
按用户确认的最小方案只做这一条判定,**不**引入 CORS 预检、**不**影响 GET:
|
|
868
|
+
· 没有 Origin(命令行 / curl / 宿主内部)→ 放行;
|
|
869
|
+
· Origin 的 host 与请求的 host 相同 → 同源,放行;
|
|
870
|
+
· 带跨站 Origin(含 opaque 的 `null`)的 POST → 由调用方回 403。
|
|
871
|
+
取不到 Host 头时判不了,保守放行(本机 webServer 一定带 Host,这一支只防意外形状)。 */
|
|
872
|
+
function isCrossSiteWrite(req) {
|
|
873
|
+
if (req === null || req === undefined || req.method !== 'POST') return false
|
|
874
|
+
const headers = req.headers
|
|
875
|
+
if (headers === null || headers === undefined || typeof headers !== 'object') return false
|
|
876
|
+
const raw = typeof headers.origin === 'string' ? headers.origin.trim() : ''
|
|
877
|
+
if (raw.length === 0) return false
|
|
878
|
+
const target = typeof headers.host === 'string' ? headers.host.trim().toLowerCase() : ''
|
|
879
|
+
if (target.length === 0) return false
|
|
880
|
+
/* opaque 来源(沙箱 iframe、file:// 页面)与谁都不是同源 */
|
|
881
|
+
if (raw.toLowerCase() === 'null') return true
|
|
882
|
+
const schemeAt = raw.indexOf('://')
|
|
883
|
+
const rest = schemeAt === -1 ? raw : raw.slice(schemeAt + 3)
|
|
884
|
+
const authority = rest.split('/')[0].split('?')[0].toLowerCase()
|
|
885
|
+
const at = authority.indexOf('@')
|
|
886
|
+
const hostOnly = at === -1 ? authority : authority.slice(at + 1)
|
|
887
|
+
if (hostOnly.length === 0) return true
|
|
888
|
+
return hostOnly !== target
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/* 请求体上限。原先统一 4KB,但**价格库会随模型数增长**:实测一条模型约 186 字节,
|
|
892
|
+
24 个模型就超过 4096 → 请求被直接断开,客户端 fetch 抛错又被 `.catch(() => {})` 吞掉,
|
|
893
|
+
于是「设置页改的单价只对底栏生效、活跃总览恒用旧价」而且毫无提示(实测缺陷)。
|
|
894
|
+
这里的接口只服务本机、只收本插件自己的小 JSON,放宽到 256KB 足够且没有实际风险。 */
|
|
895
|
+
const JSON_BODY_LIMIT = 256 * 1024
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* 读一小段 JSON 请求体。
|
|
899
|
+
*
|
|
900
|
+
* 收的是**原始 Buffer**,最后一次性解码:以前写 `raw += chunk`,每个 chunk 各自跑一次
|
|
901
|
+
* `toString('utf8')`,一个汉字的 3 个字节被切在两个 chunk 里时两半都变成 U+FFFD ——
|
|
902
|
+
* 实测中文模型名被污染成 `���定义模型`,接口照样回 `ok:true`,之后查价永远匹配不上。
|
|
903
|
+
* 上限也按**字节数**判(`raw.length` 是字符数,一个汉字只算 1,拿它当字节数会让
|
|
904
|
+
* 256KB 的上限实际放到 768KB)。
|
|
905
|
+
*
|
|
906
|
+
* @returns {Promise<object|null>} 解析失败或超限返回 null
|
|
907
|
+
*/
|
|
810
908
|
function readJsonBody(req) {
|
|
811
909
|
return new Promise((resolve) => {
|
|
812
|
-
|
|
910
|
+
const chunks = []
|
|
911
|
+
let size = 0
|
|
813
912
|
let settled = false
|
|
913
|
+
let overLimit = false
|
|
814
914
|
const finish = (value) => {
|
|
815
915
|
if (settled) return
|
|
816
916
|
settled = true
|
|
817
917
|
resolve(value)
|
|
818
918
|
}
|
|
819
919
|
req.on('data', (chunk) => {
|
|
820
|
-
|
|
821
|
-
|
|
920
|
+
if (overLimit === true) return
|
|
921
|
+
/* 非 Buffer 的 chunk(测试里的字符串)也按同一语义折成字节数 */
|
|
922
|
+
const buf = Buffer.isBuffer(chunk) === true ? chunk : Buffer.from(String(chunk), 'utf8')
|
|
923
|
+
size += buf.length
|
|
924
|
+
if (size > JSON_BODY_LIMIT) {
|
|
925
|
+
overLimit = true
|
|
926
|
+
/* 先记下超限,再断开。**不要**在 destroy 之后写响应(socket 已销毁,写不进去)。 */
|
|
927
|
+
chunks.length = 0
|
|
822
928
|
req.destroy()
|
|
823
929
|
finish(null)
|
|
930
|
+
return
|
|
824
931
|
}
|
|
932
|
+
chunks.push(buf)
|
|
825
933
|
})
|
|
826
934
|
req.on('end', () => {
|
|
935
|
+
if (overLimit === true) return
|
|
827
936
|
try {
|
|
828
|
-
const parsed = JSON.parse(
|
|
937
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
829
938
|
finish(parsed !== null && typeof parsed === 'object' ? parsed : null)
|
|
830
939
|
} catch (error) {
|
|
831
940
|
finish(null)
|
|
@@ -1363,41 +1472,55 @@ async function queryBalance(ctx, force, provider, model) {
|
|
|
1363
1472
|
*
|
|
1364
1473
|
* 为什么必须落盘:会话一旦被删除,日志就没了,而周总计必须把已经发生的消耗留住。
|
|
1365
1474
|
* 台账按「事件」去重(同一 turn/step 只算一次、assistant/message 优先),于是重复扫描不会
|
|
1366
|
-
*
|
|
1475
|
+
* 重复计数,删掉会话也不会让已记下的数字缩水;窗口是**滚动的「近 7 天」**
|
|
1476
|
+
* (见 recentStartMs:今天往前推 6 天,不再对齐自然周)。
|
|
1367
1477
|
* 全程只读 token 数字,不读正文,台账里也只有数字、没有对话内容。
|
|
1368
1478
|
*/
|
|
1369
1479
|
|
|
1370
1480
|
/**
|
|
1371
|
-
*
|
|
1481
|
+
* 柱状窗口的起点:**本机时区**今天 00:00 往前推 6 天,也就是「近 7 天」。
|
|
1372
1482
|
* 这里用的是滚动窗口,不再对齐自然周 —— 用户看到的是最近七天,而不是本周一到现在。
|
|
1483
|
+
*
|
|
1484
|
+
* 日期一律按**装机者所在时区**算(见 localDateKey 的说明),不再固定 UTC+8。
|
|
1373
1485
|
*/
|
|
1374
1486
|
function recentStartMs(now) {
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1487
|
+
const d = new Date(now)
|
|
1488
|
+
/* 按**日历字段**回退 6 天,不要写 `本地今天 00:00 - 6*24h`:
|
|
1489
|
+
有夏令时的时区里,这 6 天中有一天只有 23 小时(春季拨快),
|
|
1490
|
+
减固定毫秒会落到 7 天前的 23:00,被 localDateKey 记成前一天 ——
|
|
1491
|
+
于是窗口变成「7 天前…昨天」,「今天」那根柱子消失、当天用量落在窗口外。
|
|
1492
|
+
实测纽约/伦敦/悉尼/圣地亚哥各错 18 天/3 年(UTC+8 的机器上永远看不到)。 */
|
|
1493
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - 6).getTime()
|
|
1379
1494
|
}
|
|
1380
1495
|
|
|
1381
|
-
/** 本月 1 号 00:00
|
|
1496
|
+
/** 本月 1 号 00:00(本机时区)的毫秒时间戳。 */
|
|
1382
1497
|
function monthStartMs(now) {
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), 1) - BEIJING
|
|
1498
|
+
const d = new Date(now)
|
|
1499
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
|
|
1386
1500
|
}
|
|
1387
1501
|
|
|
1388
1502
|
const padTwo = (value) => (value < 10 ? '0' + String(value) : String(value))
|
|
1389
1503
|
|
|
1390
|
-
/** 当年 1 月 1 日 00:00
|
|
1504
|
+
/** 当年 1 月 1 日 00:00(本机时区)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
|
|
1391
1505
|
function yearStartMs(now) {
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
return Date.UTC(shifted.getUTCFullYear(), 0, 1) - BEIJING
|
|
1506
|
+
const d = new Date(now)
|
|
1507
|
+
return new Date(d.getFullYear(), 0, 1).getTime()
|
|
1395
1508
|
}
|
|
1396
1509
|
|
|
1397
|
-
/**
|
|
1510
|
+
/**
|
|
1511
|
+
* 日期键 'YYYY-MM-DD' —— 按**装机者所在时区**取,与客户端同一口径。
|
|
1512
|
+
*
|
|
1513
|
+
* 曾经固定用北京时间(UTC+8):那是 DeepSeek 峰谷计价与账单的口径,但**日期归属**
|
|
1514
|
+
* 不该跟着它走 —— 使用者看的是自己日历上的「今天/昨天」,UTC+8 装机时两地一致,
|
|
1515
|
+
* 非 UTC+8 的机器上活跃总览会整体错一天(今天那格高亮成昨天、未来/过去的灰阶也偏)。
|
|
1516
|
+
*
|
|
1517
|
+
* 注意区分两件事,别把这里改回去:
|
|
1518
|
+
* · **日期归属**(这条函数)= 装机者时区,纯粹是给人看的日历。
|
|
1519
|
+
* · **峰谷计价**(isPeakTime)= 北京时间,那是 DeepSeek 的官方计费规则,与装机位置无关。
|
|
1520
|
+
*/
|
|
1398
1521
|
function localDateKey(ms) {
|
|
1399
|
-
const date = new Date(ms
|
|
1400
|
-
return date.
|
|
1522
|
+
const date = new Date(ms)
|
|
1523
|
+
return date.getFullYear() + '-' + padTwo(date.getMonth() + 1) + '-' + padTwo(date.getDate())
|
|
1401
1524
|
}
|
|
1402
1525
|
|
|
1403
1526
|
/* 官方参考价(CNY / 每百万 tokens),与客户端价格表同源、与官方定价页一致。
|
|
@@ -1607,7 +1730,7 @@ let extraHolidays = null
|
|
|
1607
1730
|
/** 落盘过的法定节假日(带缓存);文件不存在或读不动就当没有这回事。 */
|
|
1608
1731
|
function extraHolidayDays() {
|
|
1609
1732
|
if (extraHolidays !== null) return extraHolidays
|
|
1610
|
-
const parsed =
|
|
1733
|
+
const parsed = readJsonObjectFile(holidaysPath())
|
|
1611
1734
|
extraHolidays = parsed !== null && Array.isArray(parsed.days)
|
|
1612
1735
|
? parsed.days.filter((one) => typeof one === 'string')
|
|
1613
1736
|
: []
|
|
@@ -1711,6 +1834,10 @@ function sanitizePriceBook(raw) {
|
|
|
1711
1834
|
output: keep(value.output),
|
|
1712
1835
|
}
|
|
1713
1836
|
: null)
|
|
1837
|
+
/* 普通对象字面量,但写入一律走 defineBookEntry:键名是 `__proto__` 时
|
|
1838
|
+
`out[name] = {...}` 是**设置原型**而不是建自有属性 —— 那条目会从 Object.keys
|
|
1839
|
+
里消失(静默丢条目)、返回对象的原型还被换掉(把不可信输入挂成原型,是原型污染)。
|
|
1840
|
+
用 defineProperty 建可枚举的自有属性,行为与其它键名一致。 */
|
|
1714
1841
|
const out = {}
|
|
1715
1842
|
const names = Object.keys(raw)
|
|
1716
1843
|
for (let i = 0; i < names.length; i += 1) {
|
|
@@ -1724,7 +1851,7 @@ function sanitizePriceBook(raw) {
|
|
|
1724
1851
|
/* 保留「是否峰谷计价」:没勾的条目是**全天一个价**,计价时两档都要取 peak,
|
|
1725
1852
|
否则空闲时段会挑到那个没填的档、把费用算成 0(实测缺陷)。缺省按两档。 */
|
|
1726
1853
|
const code = entry.currency === 'USD' ? 'USD' : (entry.currency === 'CNY' ? 'CNY' : null)
|
|
1727
|
-
out
|
|
1854
|
+
defineBookEntry(out, names[i], {
|
|
1728
1855
|
peak: peak !== null ? peak : offPeak,
|
|
1729
1856
|
offPeak: offPeak !== null ? offPeak : peak,
|
|
1730
1857
|
tiered: entry.tiered !== false,
|
|
@@ -1734,7 +1861,7 @@ function sanitizePriceBook(raw) {
|
|
|
1734
1861
|
host 不替它决定(否则往返一次多出一个字段,深比较基准会被悄悄改掉)。
|
|
1735
1862
|
注意:必须保留,不保留就等于清洗时把币种丢了。 */
|
|
1736
1863
|
...(code === null ? {} : { currency: code }),
|
|
1737
|
-
}
|
|
1864
|
+
})
|
|
1738
1865
|
}
|
|
1739
1866
|
return Object.keys(out).length === 0 ? null : out
|
|
1740
1867
|
}
|
|
@@ -1746,16 +1873,46 @@ function sanitizePriceBook(raw) {
|
|
|
1746
1873
|
*
|
|
1747
1874
|
* 它以前的作用是"不一致就把台账整本丢丢掉重扫",那会连已经算好的费用一起改掉,
|
|
1748
1875
|
* 会话日志一旦被删,历史数字就再也算不回来。现在它只读不写、只用来触发补算。
|
|
1876
|
+
*
|
|
1877
|
+
* 格式(v4 起):`fp4:` + JSON 数组。以前是 `名字:四项:四项|名字:…` 的拼接,两个坑:
|
|
1878
|
+
* · 键名里带 `:`(Ollama 的 `qwen2.5:14b`)或 `|`(`a|b`)会把分隔符吃掉,
|
|
1879
|
+
* `parseFingerprint` 要么整条丢、要么还原出**错的键名** → 回扫把"当年已按自建价
|
|
1880
|
+
* 算过钱"的事件误判成"当时缺价",再补一遍 → 同一笔用量计两次、当天 cost 翻倍;
|
|
1881
|
+
* · 只序列化四项数字,`tiered`(峰谷计价开关)与 `currency` 改了却算不出变化 →
|
|
1882
|
+
* 用户把「峰谷计价」改成「全天同价」或换币种时 `changed=false`,欠账一直挂着不结算。
|
|
1883
|
+
* JSON 天然带转义,键名不再可能撞上分隔符;`tiered` / `currency` 一起进指纹。
|
|
1884
|
+
*
|
|
1885
|
+
* 币种按**缺省即 CNY** 归一:host 的 `sanitizePriceBook` 不补默认币种、客户端会落成显式
|
|
1886
|
+
* `CNY`,两侧必须算出同一串(见 tests/verify-price-parity.js 的差分断言)。
|
|
1887
|
+
* 两档都空的条目(没定价)不进指纹 —— 它们不参与计价,也不参与"当时有没有价"的判定。
|
|
1749
1888
|
*/
|
|
1889
|
+
const FINGERPRINT_MARK = 'fp4:'
|
|
1890
|
+
|
|
1750
1891
|
function fingerprintOf(book) {
|
|
1751
|
-
const
|
|
1752
|
-
const
|
|
1892
|
+
const source = book === null || book === undefined ? {} : book
|
|
1893
|
+
const names = Object.keys(source).sort()
|
|
1894
|
+
const rows = []
|
|
1895
|
+
const tierOf = (t) => (t === null || t === undefined
|
|
1896
|
+
? null
|
|
1897
|
+
: [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)])
|
|
1753
1898
|
for (let i = 0; i < names.length; i += 1) {
|
|
1754
|
-
const one =
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1899
|
+
const one = source[names[i]]
|
|
1900
|
+
const rawPeak = tierOf(one === null || one === undefined ? null : one.peak)
|
|
1901
|
+
const rawOffPeak = tierOf(one === null || one === undefined ? null : one.offPeak)
|
|
1902
|
+
if (rawPeak === null && rawOffPeak === null) continue
|
|
1903
|
+
/* 两档互相补全,与 `sanitizePriceBook` 同一语义:只给一档的条目(缺省那档为 undefined)
|
|
1904
|
+
在库里、在 parse 回来的库里都表现为"两档同价"。不补全的话 `parseFingerprint`
|
|
1905
|
+
会把缺档还原成有值的那档,`fingerprintOf(parse(fp)) !== fp` —— 往返不成立,
|
|
1906
|
+
而 verify-price-parity 正是靠"指纹往返回到原串"守住格式的。 */
|
|
1907
|
+
rows.push([
|
|
1908
|
+
names[i],
|
|
1909
|
+
rawPeak !== null ? rawPeak : rawOffPeak,
|
|
1910
|
+
rawOffPeak !== null ? rawOffPeak : rawPeak,
|
|
1911
|
+
one !== null && one !== undefined && one.tiered === false ? 0 : 1,
|
|
1912
|
+
one !== null && one !== undefined && one.currency === 'USD' ? 'USD' : 'CNY',
|
|
1913
|
+
])
|
|
1914
|
+
}
|
|
1915
|
+
return FINGERPRINT_MARK + JSON.stringify(rows)
|
|
1759
1916
|
}
|
|
1760
1917
|
|
|
1761
1918
|
function priceFingerprint() {
|
|
@@ -1764,10 +1921,16 @@ function priceFingerprint() {
|
|
|
1764
1921
|
|
|
1765
1922
|
/**
|
|
1766
1923
|
* 指纹 → 价格库(fingerprintOf 的逆运算),迁移回扫用它还原"当时的价格库"。
|
|
1767
|
-
*
|
|
1924
|
+
* 单条形状对不上(不是数组、数字不合法)就跳过那一条;整体为空的返回 null。
|
|
1925
|
+
*
|
|
1926
|
+
* 两种格式都认:
|
|
1927
|
+
* · `fp4:` 前缀 → 新格式(JSON,可往返);
|
|
1928
|
+
* · 别的(老台账里落盘的旧格式)→ 按老的 `:` / `|` 拼接解析,只为让升级前的
|
|
1929
|
+
* 台账还能还原出当年的价目表(解析不出来就返回 null,调用方回退内置表,与旧行为一致)。
|
|
1768
1930
|
*/
|
|
1769
1931
|
function parseFingerprint(text) {
|
|
1770
1932
|
if (typeof text !== 'string' || text.length === 0) return null
|
|
1933
|
+
if (text.startsWith(FINGERPRINT_MARK) === true) return parseFingerprintRows(text.slice(FINGERPRINT_MARK.length))
|
|
1771
1934
|
const out = {}
|
|
1772
1935
|
const segments = text.split('|')
|
|
1773
1936
|
const tier = (raw) => {
|
|
@@ -1790,11 +1953,56 @@ function parseFingerprint(text) {
|
|
|
1790
1953
|
const peak = tier(fields[1])
|
|
1791
1954
|
const offPeak = tier(fields[2])
|
|
1792
1955
|
if (peak === null && offPeak === null) continue
|
|
1793
|
-
out
|
|
1956
|
+
defineBookEntry(out, fields[0], { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak })
|
|
1957
|
+
}
|
|
1958
|
+
return Object.keys(out).length === 0 ? null : out
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/** 把 `fp4:` 后面的 JSON 数组还原成价格库。 */
|
|
1962
|
+
function parseFingerprintRows(text) {
|
|
1963
|
+
let rows = null
|
|
1964
|
+
try {
|
|
1965
|
+
rows = JSON.parse(text)
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
return null
|
|
1968
|
+
}
|
|
1969
|
+
if (Array.isArray(rows) !== true) return null
|
|
1970
|
+
const out = {}
|
|
1971
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1972
|
+
const row = rows[i]
|
|
1973
|
+
if (Array.isArray(row) !== true || row.length < 5) continue
|
|
1974
|
+
const name = row[0]
|
|
1975
|
+
if (typeof name !== 'string' || name.length === 0) continue
|
|
1976
|
+
const tier = (raw) => {
|
|
1977
|
+
if (Array.isArray(raw) !== true || raw.length !== 4) return null
|
|
1978
|
+
const one = {
|
|
1979
|
+
input: Number(raw[0]),
|
|
1980
|
+
cacheRead: Number(raw[1]),
|
|
1981
|
+
cacheWrite: Number(raw[2]),
|
|
1982
|
+
output: Number(raw[3]),
|
|
1983
|
+
}
|
|
1984
|
+
if (Number.isFinite(one.input) !== true || Number.isFinite(one.cacheRead) !== true
|
|
1985
|
+
|| Number.isFinite(one.cacheWrite) !== true || Number.isFinite(one.output) !== true) return null
|
|
1986
|
+
return one
|
|
1987
|
+
}
|
|
1988
|
+
const peak = tier(row[1])
|
|
1989
|
+
const offPeak = tier(row[2])
|
|
1990
|
+
if (peak === null && offPeak === null) continue
|
|
1991
|
+
defineBookEntry(out, name, {
|
|
1992
|
+
peak: peak !== null ? peak : offPeak,
|
|
1993
|
+
offPeak: offPeak !== null ? offPeak : peak,
|
|
1994
|
+
tiered: row[3] !== 0,
|
|
1995
|
+
currency: row[4] === 'USD' ? 'USD' : 'CNY',
|
|
1996
|
+
})
|
|
1794
1997
|
}
|
|
1795
1998
|
return Object.keys(out).length === 0 ? null : out
|
|
1796
1999
|
}
|
|
1797
2000
|
|
|
2001
|
+
/** 往价格库里写一条:`__proto__` 这类键名必须建成自有属性(见 sanitizePriceBook 处的说明)。 */
|
|
2002
|
+
function defineBookEntry(target, name, value) {
|
|
2003
|
+
Object.defineProperty(target, name, { value: value, enumerable: true, writable: true, configurable: true })
|
|
2004
|
+
}
|
|
2005
|
+
|
|
1798
2006
|
/** 一笔用量按单价折成人民币;模型没有价格表就返回 0(不计入花费)。 */
|
|
1799
2007
|
function tokenCost(model, at, parts) {
|
|
1800
2008
|
const entry = priceEntryFor(model)
|
|
@@ -2650,9 +2858,13 @@ function emptyLedger() {
|
|
|
2650
2858
|
}
|
|
2651
2859
|
|
|
2652
2860
|
/**
|
|
2653
|
-
*
|
|
2861
|
+
* 读一个 JSON 对象文件;不存在、读不出或不是合法 JSON 对象时返回 null。
|
|
2862
|
+
*
|
|
2863
|
+
* 名字**刻意不叫** readLedgerFile:它不只读台账,`holidays.json` 也走这里
|
|
2864
|
+
* (两者都是「一个 JSON 对象,坏了就当没有」的语义)。用台账专属的名字会让人
|
|
2865
|
+
* 以为改动只该影响台账。
|
|
2654
2866
|
*/
|
|
2655
|
-
function
|
|
2867
|
+
function readJsonObjectFile(file) {
|
|
2656
2868
|
try {
|
|
2657
2869
|
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
2658
2870
|
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
@@ -2704,9 +2916,9 @@ function logLedgerNote(message) {
|
|
|
2704
2916
|
}
|
|
2705
2917
|
|
|
2706
2918
|
function readLedger() {
|
|
2707
|
-
const parsed =
|
|
2919
|
+
const parsed = readJsonObjectFile(ledgerPath())
|
|
2708
2920
|
if (parsed !== null) return migrateLedger(parsed)
|
|
2709
|
-
const backup =
|
|
2921
|
+
const backup = readJsonObjectFile(ledgerPath() + '.bak')
|
|
2710
2922
|
if (backup !== null) {
|
|
2711
2923
|
logLedgerNote('usage-ledger: 主文件读不出来,已回退到自动备份')
|
|
2712
2924
|
const recovered = migrateLedger(backup)
|
|
@@ -2730,7 +2942,7 @@ function writeLedger(ledger) {
|
|
|
2730
2942
|
let kept = false
|
|
2731
2943
|
/* 只有"上一份是能解析的好数据"才留成备份:主文件损坏时我们刚回退过 .bak,
|
|
2732
2944
|
若这时把损坏内容复制成新 .bak,备份就只能救命一次(实测缺陷)。 */
|
|
2733
|
-
if (
|
|
2945
|
+
if (readJsonObjectFile(target) !== null) {
|
|
2734
2946
|
try {
|
|
2735
2947
|
copyFileSync(target, target + '.bak')
|
|
2736
2948
|
kept = true
|
|
@@ -2759,7 +2971,14 @@ function writeLedger(ledger) {
|
|
|
2759
2971
|
function addUnpriced(day, model, parts, peakTime) {
|
|
2760
2972
|
const name = typeof model === 'string' && model.length > 0 ? model : '(未知模型)'
|
|
2761
2973
|
const bucket = day.unpriced ?? {}
|
|
2762
|
-
|
|
2974
|
+
/* 必须按**自有属性**判断,不能用 `bucket[name] ?? 默认值`:模型名是 JS 原型属性名
|
|
2975
|
+
(`constructor` / `toString` / `valueOf` / `__proto__` …)时,`bucket[name]` 会取到
|
|
2976
|
+
原型上的函数或原型对象,`??` 不触发 —— 于是 `one.peak` 是 undefined,下面
|
|
2977
|
+
`tier.input += …` 抛 TypeError。抛错发生在 collectIntoLedger / backfillUnpriced 里,
|
|
2978
|
+
queueLedgerJob 随之 reject,`/api/daily-usage` 恒返回 ledger-failed,活跃总览整块打不开。
|
|
2979
|
+
凡是"用户/上游给的字符串当对象键"的地方都要走这条判据。 */
|
|
2980
|
+
const hasOwn = Object.prototype.hasOwnProperty.call(bucket, name)
|
|
2981
|
+
const one = hasOwn === true ? bucket[name] : {
|
|
2763
2982
|
peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
2764
2983
|
offPeak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
2765
2984
|
}
|
|
@@ -2768,7 +2987,8 @@ function addUnpriced(day, model, parts, peakTime) {
|
|
|
2768
2987
|
tier.cacheRead += parts.cacheRead
|
|
2769
2988
|
tier.cacheWrite += parts.cacheWrite
|
|
2770
2989
|
tier.output += parts.output
|
|
2771
|
-
bucket[name] = one
|
|
2990
|
+
/* 同理:`bucket[name] = one` 在 name 为 `__proto__` 时是设置原型而不是建条目 */
|
|
2991
|
+
defineBookEntry(bucket, name, one)
|
|
2772
2992
|
day.unpriced = bucket
|
|
2773
2993
|
}
|
|
2774
2994
|
|
|
@@ -2989,13 +3209,29 @@ function pruneLedger(ledger) {
|
|
|
2989
3209
|
|
|
2990
3210
|
/** 把台账里的日桶铺成「近 7 天」固定 7 格(最早一天在最左、今天是最后一格),并算合计;另附当月与全年。 */
|
|
2991
3211
|
function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
2992
|
-
const DAY = 24 * 3600 * 1000
|
|
2993
3212
|
const days = []
|
|
2994
3213
|
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, calls: 0, cost: 0 }
|
|
3214
|
+
/* 逐日按**日历**推进,不用 rangeStart + i*24h:后者在夏令时切换日会重复/跳天
|
|
3215
|
+
(同 monthUsagePayload 的说明)。rangeStart 本身已是本地 00:00。 */
|
|
3216
|
+
const startDate = new Date(rangeStart)
|
|
2995
3217
|
for (let i = 0; i < 7; i += 1) {
|
|
2996
|
-
|
|
2997
|
-
const key = localDateKey(
|
|
2998
|
-
const
|
|
3218
|
+
const at = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i).getTime()
|
|
3219
|
+
const key = localDateKey(at)
|
|
3220
|
+
const raw = ledger.days[key]
|
|
3221
|
+
/* 日桶逐个字段兜底:台账被外部改坏(不是对象、或字段缺失/非数字)时,
|
|
3222
|
+
直接相加会得到 NaN,整个 totals 都变成 NaN、JSON 里还丢键。
|
|
3223
|
+
monthUsagePayload / yearUsagePayload 一直有兜底,只有这里没有。 */
|
|
3224
|
+
const bucket = raw !== null && raw !== undefined && typeof raw === 'object'
|
|
3225
|
+
? {
|
|
3226
|
+
input: Number(raw.input) || 0,
|
|
3227
|
+
cacheRead: Number(raw.cacheRead) || 0,
|
|
3228
|
+
cacheWrite: Number(raw.cacheWrite) || 0,
|
|
3229
|
+
output: Number(raw.output) || 0,
|
|
3230
|
+
calls: Number(raw.calls) || 0,
|
|
3231
|
+
cost: Number(raw.cost) || 0,
|
|
3232
|
+
peak: raw.peak,
|
|
3233
|
+
}
|
|
3234
|
+
: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: undefined }
|
|
2999
3235
|
const total = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
|
|
3000
3236
|
const cost = typeof bucket.cost === 'number' && Number.isFinite(bucket.cost) ? bucket.cost : 0
|
|
3001
3237
|
/* 高峰那一份:老台账没有 peak 字段时按 0 处理 */
|
|
@@ -3009,8 +3245,6 @@ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
|
3009
3245
|
peak.total = peak.input + peak.cacheRead + peak.cacheWrite + peak.output
|
|
3010
3246
|
days.push({
|
|
3011
3247
|
date: key,
|
|
3012
|
-
/* 窗口内第几天(0 = 最早那天,6 = 今天);不再是星期几 */
|
|
3013
|
-
offset: i,
|
|
3014
3248
|
input: bucket.input,
|
|
3015
3249
|
cacheRead: bucket.cacheRead,
|
|
3016
3250
|
cacheWrite: bucket.cacheWrite,
|
|
@@ -3048,22 +3282,31 @@ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
|
3048
3282
|
* 「月内最高的一天」(要标紫的那天)只在当月天里评,避免被上月的量抢走。
|
|
3049
3283
|
*/
|
|
3050
3284
|
function monthUsagePayload(ledger, monthStart) {
|
|
3051
|
-
|
|
3052
|
-
const
|
|
3053
|
-
const
|
|
3054
|
-
const
|
|
3055
|
-
const
|
|
3056
|
-
const
|
|
3057
|
-
const
|
|
3058
|
-
const
|
|
3059
|
-
const monthEnd = monthStart + (daysInMonth - 1) * DAY
|
|
3060
|
-
const lastWeekday = (new Date(monthEnd + BEIJING).getUTCDay() + 6) % 7
|
|
3285
|
+
/* 一律按本机时区读年月日,与 localDateKey 同口径(别在这里加 8 小时偏移) */
|
|
3286
|
+
const first = new Date(monthStart)
|
|
3287
|
+
const year = first.getFullYear()
|
|
3288
|
+
const month = first.getMonth() + 1
|
|
3289
|
+
const daysInMonth = new Date(year, month, 0).getDate()
|
|
3290
|
+
const firstWeekday = (first.getDay() + 6) % 7 /* 周一=0 */
|
|
3291
|
+
const monthEnd = new Date(year, month - 1, daysInMonth).getTime()
|
|
3292
|
+
const lastWeekday = (new Date(year, month - 1, daysInMonth).getDay() + 6) % 7
|
|
3061
3293
|
const gridDays = daysInMonth + firstWeekday + (6 - lastWeekday)
|
|
3294
|
+
/* 网格起点:当月 1 号往前退 firstWeekday 天。
|
|
3295
|
+
用**日历逐日构造**而不是 `起点 + i * 24h` —— 后者在有夏令时的时区会错:
|
|
3296
|
+
切换日本地时间整体挪 1 小时,+24h 可能落回同一天(出现两格同一天、另一天缺失),
|
|
3297
|
+
实测纽约 / 伦敦 / 悉尼 / 圣地亚哥都会中(UTC+8 的机器上永远看不到)。
|
|
3298
|
+
逐日构造让每格都稳稳落在本地 00:00。 */
|
|
3299
|
+
const gridStartDate = new Date(year, month - 1, 1 - firstWeekday)
|
|
3300
|
+
const dayAt = (i) => new Date(
|
|
3301
|
+
gridStartDate.getFullYear(),
|
|
3302
|
+
gridStartDate.getMonth(),
|
|
3303
|
+
gridStartDate.getDate() + i
|
|
3304
|
+
).getTime()
|
|
3062
3305
|
const days = []
|
|
3063
3306
|
let max = 0
|
|
3064
3307
|
let maxDate = null
|
|
3065
3308
|
for (let i = 0; i < gridDays; i += 1) {
|
|
3066
|
-
const at =
|
|
3309
|
+
const at = dayAt(i)
|
|
3067
3310
|
const key = localDateKey(at)
|
|
3068
3311
|
const inMonth = at >= monthStart && at <= monthEnd
|
|
3069
3312
|
const bucket = ledger.days[key]
|
|
@@ -3100,13 +3343,13 @@ function monthUsagePayload(ledger, monthStart) {
|
|
|
3100
3343
|
* 客户端拿它画「活跃总览」标题下面那 12 个方块(配色规则与右侧月历一致)。
|
|
3101
3344
|
*/
|
|
3102
3345
|
function yearUsagePayload(ledger, now) {
|
|
3103
|
-
|
|
3104
|
-
const year = new Date(now
|
|
3346
|
+
/* 一律按本机时区读年月日,与 localDateKey 同口径 */
|
|
3347
|
+
const year = new Date(now).getFullYear()
|
|
3105
3348
|
const months = []
|
|
3106
3349
|
let max = 0
|
|
3107
3350
|
let maxMonth = null
|
|
3108
3351
|
for (let m = 1; m <= 12; m += 1) {
|
|
3109
|
-
const daysInMonth = new Date(
|
|
3352
|
+
const daysInMonth = new Date(year, m, 0).getDate()
|
|
3110
3353
|
let input = 0
|
|
3111
3354
|
let cacheRead = 0
|
|
3112
3355
|
let cacheWrite = 0
|
|
@@ -3114,7 +3357,7 @@ function yearUsagePayload(ledger, now) {
|
|
|
3114
3357
|
let calls = 0
|
|
3115
3358
|
let cost = 0
|
|
3116
3359
|
for (let d = 1; d <= daysInMonth; d += 1) {
|
|
3117
|
-
const bucket = ledger.days[localDateKey(Date
|
|
3360
|
+
const bucket = ledger.days[localDateKey(new Date(year, m - 1, d).getTime())]
|
|
3118
3361
|
if (bucket === undefined) continue
|
|
3119
3362
|
input += bucket.input || 0
|
|
3120
3363
|
cacheRead += bucket.cacheRead || 0
|
|
@@ -3397,203 +3640,282 @@ export function apply(ctx) {
|
|
|
3397
3640
|
ctx.effect(() => ctx.webServer.register({
|
|
3398
3641
|
kind: 'prefix',
|
|
3399
3642
|
path: '/dsh-desktop-statusbar/api',
|
|
3643
|
+
/* 整体兜底:handler 里任何一处抛错(如 ctx.get('credentials') 在 try 之外被
|
|
3644
|
+
queryBalance 调到、读盘/解压意外抛出)原本会让这个 Promise reject ——
|
|
3645
|
+
webServer 不替我们收尾,于是既不写响应也不关连接,客户端 fetch **永久 pending**
|
|
3646
|
+
(底栏余额永远停在「查询中」)。这里统一写 500 并结束响应;已经写过响应头
|
|
3647
|
+
(headersSent)时不再二次写,只结束连接,避免 ERR_HTTP_HEADERS_SENT。 */
|
|
3400
3648
|
handler: async (req, res) => {
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
|
|
3406
|
-
if (req.method === 'POST') {
|
|
3407
|
-
const body = await readJsonBody(req)
|
|
3408
|
-
const book = body === null ? null : sanitizePriceBook(body.models)
|
|
3409
|
-
if (book === null) {
|
|
3410
|
-
sendJson(res, 400, { ok: false, reason: 'bad-models' })
|
|
3411
|
-
return
|
|
3412
|
-
}
|
|
3413
|
-
/* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
|
|
3414
|
-
/* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
|
|
3415
|
-
否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
|
|
3416
|
-
let settled = { entries: 0, amount: 0 }
|
|
3417
|
-
let changed = false
|
|
3418
|
-
const swapped = await queueLedgerJob(async () => {
|
|
3419
|
-
const before = activePriceFingerprint
|
|
3420
|
-
clientPrices = book
|
|
3421
|
-
const nowFingerprint = priceFingerprint()
|
|
3422
|
-
activePriceFingerprint = nowFingerprint
|
|
3423
|
-
const nowChanged = before !== nowFingerprint
|
|
3424
|
-
/* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
|
|
3425
|
-
已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
|
|
3426
|
-
if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
|
|
3427
|
-
const ledger = readLedger()
|
|
3428
|
-
const done = settleUnpriced(ledger)
|
|
3429
|
-
/* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
|
|
3430
|
-
(免得每次都重迁一遍)。没有台账时不在这里创建。
|
|
3431
|
-
**绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
|
|
3432
|
-
是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
|
|
3433
|
-
const onDisk = readLedgerFile(ledgerPath())
|
|
3434
|
-
const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
|
|
3435
|
-
if (done.entries > 0 || stale) {
|
|
3436
|
-
ledger.updatedAt = Date.now()
|
|
3437
|
-
writeLedger(ledger)
|
|
3438
|
-
}
|
|
3439
|
-
return { changed: true, settled: done }
|
|
3440
|
-
})
|
|
3441
|
-
changed = swapped.changed
|
|
3442
|
-
settled = swapped.settled
|
|
3443
|
-
if (changed) {
|
|
3444
|
-
/* 面板的数字要重新取一次(payload 缓存在上面) */
|
|
3445
|
-
weekUsageCache = { at: 0, value: null }
|
|
3446
|
-
weekUsageScan = null
|
|
3447
|
-
}
|
|
3448
|
-
logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
|
|
3449
|
-
+ (changed
|
|
3450
|
-
? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
|
|
3451
|
-
: '(与当前一致)'))
|
|
3452
|
-
sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
|
|
3453
|
-
return
|
|
3454
|
-
}
|
|
3455
|
-
/* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
|
|
3456
|
-
sendJson(res, 200, {
|
|
3457
|
-
models: {
|
|
3458
|
-
'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
|
|
3459
|
-
peak: TOKEN_PRICES.peak['deepseek-flash'],
|
|
3460
|
-
offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
|
|
3461
|
-
},
|
|
3462
|
-
'deepseek-v4-pro': {
|
|
3463
|
-
peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
|
|
3464
|
-
offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
|
|
3465
|
-
},
|
|
3466
|
-
},
|
|
3467
|
-
note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
|
|
3468
|
-
})
|
|
3469
|
-
return
|
|
3470
|
-
}
|
|
3471
|
-
if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
|
|
3472
|
-
const raw = url.searchParams.get('year')
|
|
3473
|
-
const year = raw === null ? NaN : Number(raw)
|
|
3474
|
-
if (Number.isInteger(year) !== true || year < 2020 || year > 2100) {
|
|
3475
|
-
sendJson(res, 400, { ok: false, reason: 'bad-year' })
|
|
3476
|
-
return
|
|
3477
|
-
}
|
|
3649
|
+
try {
|
|
3650
|
+
await handleApi(req, res, ctx)
|
|
3651
|
+
} catch (error) {
|
|
3652
|
+
logHost(ctx, 'api: 未捕获异常 ' + probeDetail(error))
|
|
3478
3653
|
try {
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
|
|
3484
|
-
+ ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
|
|
3485
|
-
}
|
|
3486
|
-
sendJson(res, 200, result)
|
|
3487
|
-
} catch (error) {
|
|
3488
|
-
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
|
|
3489
|
-
logHost(ctxRef, 'holidays: 抓取失败 ' + detail)
|
|
3490
|
-
sendJson(res, 200, { ok: false, reason: 'fetch-failed' })
|
|
3654
|
+
if (res.headersSent === true) res.end()
|
|
3655
|
+
else sendJson(res, 500, { ok: false, reason: 'internal-error' })
|
|
3656
|
+
} catch (nested) {
|
|
3657
|
+
/* 连接已经断了,没什么可做的 */
|
|
3491
3658
|
}
|
|
3492
|
-
return
|
|
3493
3659
|
}
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3660
|
+
},
|
|
3661
|
+
}), 'dsh-desktop-statusbar: balance api')
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
async function handleApi(req, res, ctx) {
|
|
3665
|
+
/* 带跨站 Origin 的写请求一律拒绝:浏览器里任意网页都能对本机发简单请求,
|
|
3666
|
+
必须在**改任何状态之前**挡住(判定见 isCrossSiteWrite 的说明)。 */
|
|
3667
|
+
if (isCrossSiteWrite(req) === true) {
|
|
3668
|
+
logHost(ctx, 'api: 拒绝跨站写请求 origin=' + String(req.headers.origin))
|
|
3669
|
+
sendJson(res, 403, { ok: false, reason: 'cross-origin' })
|
|
3670
|
+
return
|
|
3671
|
+
}
|
|
3672
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
3673
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
|
|
3674
|
+
/* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
|
|
3675
|
+
活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
|
|
3676
|
+
加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
|
|
3677
|
+
if (req.method === 'POST') {
|
|
3678
|
+
const body = await readJsonBody(req)
|
|
3679
|
+
const book = body === null ? null : sanitizePriceBook(body.models)
|
|
3680
|
+
if (book === null) {
|
|
3681
|
+
sendJson(res, 400, { ok: false, reason: 'bad-models' })
|
|
3515
3682
|
return
|
|
3516
3683
|
}
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3684
|
+
/* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
|
|
3685
|
+
/* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
|
|
3686
|
+
否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
|
|
3687
|
+
let settled = { entries: 0, amount: 0 }
|
|
3688
|
+
let changed = false
|
|
3689
|
+
const swapped = await queueLedgerJob(async () => {
|
|
3690
|
+
const before = activePriceFingerprint
|
|
3691
|
+
clientPrices = book
|
|
3692
|
+
const nowFingerprint = priceFingerprint()
|
|
3693
|
+
activePriceFingerprint = nowFingerprint
|
|
3694
|
+
const nowChanged = before !== nowFingerprint
|
|
3695
|
+
/* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
|
|
3696
|
+
已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
|
|
3697
|
+
if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
|
|
3698
|
+
const ledger = readLedger()
|
|
3699
|
+
const done = settleUnpriced(ledger)
|
|
3700
|
+
/* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
|
|
3701
|
+
(免得每次都重迁一遍)。没有台账时不在这里创建。
|
|
3702
|
+
**绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
|
|
3703
|
+
是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
|
|
3704
|
+
const onDisk = readJsonObjectFile(ledgerPath())
|
|
3705
|
+
const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
|
|
3706
|
+
if (done.entries > 0 || stale) {
|
|
3707
|
+
ledger.updatedAt = Date.now()
|
|
3708
|
+
writeLedger(ledger)
|
|
3532
3709
|
}
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3710
|
+
return { changed: true, settled: done }
|
|
3711
|
+
})
|
|
3712
|
+
changed = swapped.changed
|
|
3713
|
+
settled = swapped.settled
|
|
3714
|
+
if (changed) {
|
|
3715
|
+
/* 面板的数字要重新取一次(payload 缓存在上面) */
|
|
3716
|
+
weekUsageCache = { at: 0, value: null }
|
|
3717
|
+
weekUsageScan = null
|
|
3540
3718
|
}
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3719
|
+
logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
|
|
3720
|
+
+ (changed
|
|
3721
|
+
? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
|
|
3722
|
+
: '(与当前一致)'))
|
|
3723
|
+
sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
|
|
3724
|
+
return
|
|
3725
|
+
}
|
|
3726
|
+
/* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
|
|
3727
|
+
sendJson(res, 200, {
|
|
3728
|
+
models: {
|
|
3729
|
+
'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
|
|
3730
|
+
peak: TOKEN_PRICES.peak['deepseek-flash'],
|
|
3731
|
+
offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
|
|
3732
|
+
},
|
|
3733
|
+
'deepseek-v4-pro': {
|
|
3734
|
+
peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
|
|
3735
|
+
offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
|
|
3736
|
+
},
|
|
3737
|
+
},
|
|
3738
|
+
note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
|
|
3739
|
+
})
|
|
3740
|
+
return
|
|
3741
|
+
}
|
|
3742
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
|
|
3743
|
+
const raw = url.searchParams.get('year')
|
|
3744
|
+
const year = raw === null ? NaN : Number(raw)
|
|
3745
|
+
if (Number.isInteger(year) !== true || year < 2020 || year > 2100) {
|
|
3746
|
+
sendJson(res, 400, { ok: false, reason: 'bad-year' })
|
|
3747
|
+
return
|
|
3748
|
+
}
|
|
3749
|
+
try {
|
|
3750
|
+
const result = await fetchHolidayDays(year)
|
|
3751
|
+
if (result.ok === true) {
|
|
3752
|
+
/* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
|
|
3753
|
+
const merged = saveHolidayDays(result.days)
|
|
3754
|
+
logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
|
|
3755
|
+
+ ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
|
|
3573
3756
|
}
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3757
|
+
sendJson(res, 200, result)
|
|
3758
|
+
} catch (error) {
|
|
3759
|
+
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
|
|
3760
|
+
logHost(ctxRef, 'holidays: 抓取失败 ' + detail)
|
|
3761
|
+
sendJson(res, 200, { ok: false, reason: 'fetch-failed' })
|
|
3762
|
+
}
|
|
3763
|
+
return
|
|
3764
|
+
}
|
|
3765
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/version') {
|
|
3766
|
+
/* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
|
|
3767
|
+
if (latestVersion.checkedAt === 0 && versionCheck !== null) {
|
|
3768
|
+
try {
|
|
3769
|
+
latestVersion = await versionCheck
|
|
3770
|
+
} catch (error) {
|
|
3771
|
+
/* 保持 latest 为 null,下面按「查不到」处理 */
|
|
3584
3772
|
}
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3773
|
+
}
|
|
3774
|
+
const raw = url.searchParams.get('current')
|
|
3775
|
+
const current = raw === null ? '' : raw.trim()
|
|
3776
|
+
sendJson(res, 200, {
|
|
3777
|
+
ok: latestVersion.latest !== null,
|
|
3778
|
+
current,
|
|
3779
|
+
latest: latestVersion.latest,
|
|
3780
|
+
/* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
|
|
3781
|
+
hasUpdate: latestVersion.latest !== null && current.length > 0
|
|
3782
|
+
&& compareVersions(latestVersion.latest, current) > 0,
|
|
3783
|
+
checkedAt: latestVersion.checkedAt,
|
|
3784
|
+
error: latestVersion.error,
|
|
3785
|
+
})
|
|
3786
|
+
return
|
|
3787
|
+
}
|
|
3788
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/self-update') {
|
|
3789
|
+
/* 一键更新:由本插件自己完成,不依赖「插件管理器」这个别的插件,也不走
|
|
3790
|
+
pnpm 的 24 小时发布年龄策略(做法见 self-update.js 顶部说明)。
|
|
3791
|
+
GET = 问当前状态(界面挂在更新中途刷新时能接上);POST = 发起一次更新。 */
|
|
3792
|
+
if (req.method !== 'POST') {
|
|
3793
|
+
const last = lastUpdateResult()
|
|
3794
|
+
sendJson(res, 200, {
|
|
3795
|
+
ok: true,
|
|
3796
|
+
busy: isUpdating(),
|
|
3797
|
+
last: last === null ? null : {
|
|
3798
|
+
ok: last.ok === true,
|
|
3799
|
+
reason: last.reason === undefined ? null : last.reason,
|
|
3800
|
+
from: last.from === undefined ? null : last.from,
|
|
3801
|
+
to: last.to === undefined ? null : last.to,
|
|
3802
|
+
},
|
|
3803
|
+
})
|
|
3804
|
+
return
|
|
3805
|
+
}
|
|
3806
|
+
const body = await readJsonBody(req)
|
|
3807
|
+
const raw = body === null || typeof body.version !== 'string' ? '' : body.version.trim()
|
|
3808
|
+
/* 目标版本必须是明确的三段式版本号:不接受 latest/tag/范围,避免装到别的东西。
|
|
3809
|
+
空则不传 —— host 会自己问 registry 的最新版。 */
|
|
3810
|
+
if (raw.length > 0 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(raw) !== true) {
|
|
3811
|
+
sendJson(res, 400, { ok: false, reason: 'bad-version' })
|
|
3812
|
+
return
|
|
3813
|
+
}
|
|
3814
|
+
if (isUpdating() === true) {
|
|
3815
|
+
sendJson(res, 409, { ok: false, reason: 'busy' })
|
|
3816
|
+
return
|
|
3817
|
+
}
|
|
3818
|
+
let target = raw
|
|
3819
|
+
if (target.length === 0) {
|
|
3820
|
+
/* 客户端没带版本就自己查一次最新的 */
|
|
3821
|
+
const checked = await checkLatestVersion()
|
|
3822
|
+
latestVersion = checked
|
|
3823
|
+
if (checked.latest === null) {
|
|
3824
|
+
sendJson(res, 200, { ok: false, reason: checked.error === null ? 'no-version' : checked.error })
|
|
3588
3825
|
return
|
|
3589
3826
|
}
|
|
3590
|
-
|
|
3591
|
-
|
|
3827
|
+
target = checked.latest
|
|
3828
|
+
}
|
|
3829
|
+
const selfDir = resolveSelfDir()
|
|
3830
|
+
logHost(ctxRef, 'update: 收到更新请求 → ' + target + '(当前目录 ' + selfDir + ')')
|
|
3831
|
+
const value = await selfUpdateOnce({
|
|
3832
|
+
selfDir,
|
|
3833
|
+
version: target,
|
|
3834
|
+
logger: (message) => logHost(ctxRef, message),
|
|
3835
|
+
})
|
|
3836
|
+
logHost(ctxRef, 'update: 结果 ' + JSON.stringify(value))
|
|
3837
|
+
sendJson(res, 200, value)
|
|
3838
|
+
return
|
|
3839
|
+
}
|
|
3840
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
|
|
3841
|
+
if (req.method === 'POST') {
|
|
3842
|
+
const body = await readJsonBody(req)
|
|
3843
|
+
const model = body !== null && typeof body.model === 'string' ? body.model.trim() : ''
|
|
3844
|
+
if (model.length === 0) {
|
|
3845
|
+
sendJson(res, 400, { ok: false, reason: 'model-required' })
|
|
3592
3846
|
return
|
|
3593
3847
|
}
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3848
|
+
activeModel = {
|
|
3849
|
+
provider: typeof body.provider === 'string' && body.provider.length > 0 ? body.provider : null,
|
|
3850
|
+
model: model,
|
|
3851
|
+
at: Date.now(),
|
|
3852
|
+
}
|
|
3853
|
+
sendJson(res, 200, { ok: true })
|
|
3854
|
+
return
|
|
3855
|
+
}
|
|
3856
|
+
sendJson(res, 200, {
|
|
3857
|
+
ok: true,
|
|
3858
|
+
provider: activeModel.provider,
|
|
3859
|
+
model: activeModel.model,
|
|
3860
|
+
at: activeModel.at,
|
|
3861
|
+
})
|
|
3862
|
+
return
|
|
3863
|
+
}
|
|
3864
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
|
|
3865
|
+
/* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
|
|
3866
|
+
找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
|
|
3867
|
+
本轮里队友花了多少;不传就是整会话(「总计」用)。
|
|
3868
|
+
参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
|
|
3869
|
+
全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
|
|
3870
|
+
const raw = url.searchParams.get('sessionId')
|
|
3871
|
+
const sessionId = raw === null ? '' : raw.trim()
|
|
3872
|
+
if (sessionId.length === 0 || sessionId.length > 200) {
|
|
3873
|
+
sendJson(res, 400, { ok: false, reason: 'bad-session' })
|
|
3874
|
+
return
|
|
3875
|
+
}
|
|
3876
|
+
/* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
|
|
3877
|
+
本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
|
|
3878
|
+
数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
|
|
3879
|
+
"当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
|
|
3880
|
+
就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
|
|
3881
|
+
const rawSince = url.searchParams.get('since')
|
|
3882
|
+
const sinceText = rawSince === null ? '' : rawSince.trim()
|
|
3883
|
+
const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
|
|
3884
|
+
const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
|
|
3885
|
+
&& parsedSince <= Date.now() + 5 * 60 * 1000
|
|
3886
|
+
/* 这个接口会排队(collectSubagentUsage 内部走 queueLedgerJob)——
|
|
3887
|
+
别按「只读、不排队」去理解它的时序。此处原先的注释与实现矛盾,已改正。 */
|
|
3888
|
+
let value
|
|
3889
|
+
try {
|
|
3890
|
+
value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
|
|
3891
|
+
} catch (error) {
|
|
3892
|
+
logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
|
|
3893
|
+
value = { ok: false, reason: 'scan-failed' }
|
|
3894
|
+
}
|
|
3895
|
+
sendJson(res, 200, value)
|
|
3896
|
+
return
|
|
3897
|
+
}
|
|
3898
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
|
|
3899
|
+
let value
|
|
3900
|
+
try {
|
|
3901
|
+
value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
|
|
3902
|
+
} catch (error) {
|
|
3903
|
+
logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
|
|
3904
|
+
value = { ok: false, reason: 'ledger-failed' }
|
|
3905
|
+
}
|
|
3906
|
+
sendJson(res, 200, value)
|
|
3907
|
+
return
|
|
3908
|
+
}
|
|
3909
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
|
|
3910
|
+
if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
|
|
3911
|
+
sendJson(res, 200, { ok: true })
|
|
3912
|
+
return
|
|
3913
|
+
}
|
|
3914
|
+
if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
|
|
3915
|
+
sendJson(res, 404, { ok: false, reason: 'not-found' })
|
|
3916
|
+
return
|
|
3917
|
+
}
|
|
3918
|
+
const force = url.searchParams.get('force') === '1'
|
|
3919
|
+
const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
|
|
3920
|
+
sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
|
|
3599
3921
|
}
|