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/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: appendCalls(calls, [one]),
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
- stateVersion: 5,
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
- /** 读一小段 JSON 请求体(只用于本插件自己的上报接口,超过 4KB 直接断开)。 */
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
- let raw = ''
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
- raw += chunk
821
- if (raw.length > 4096) {
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(raw)
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
- * 重复计数,删掉会话也不会让已记下的数字缩水;窗口是自然周(周一 00:00 起)。
1475
+ * 重复计数,删掉会话也不会让已记下的数字缩水;窗口是**滚动的「近 7 天」**
1476
+ * (见 recentStartMs:今天往前推 6 天,不再对齐自然周)。
1367
1477
  * 全程只读 token 数字,不读正文,台账里也只有数字、没有对话内容。
1368
1478
  */
1369
1479
 
1370
1480
  /**
1371
- * 柱状窗口的起点:今天 00:00(北京时间)往前推 6 天,也就是「近 7 天」。
1481
+ * 柱状窗口的起点:**本机时区**今天 00:00 往前推 6 天,也就是「近 7 天」。
1372
1482
  * 这里用的是滚动窗口,不再对齐自然周 —— 用户看到的是最近七天,而不是本周一到现在。
1483
+ *
1484
+ * 日期一律按**装机者所在时区**算(见 localDateKey 的说明),不再固定 UTC+8。
1373
1485
  */
1374
1486
  function recentStartMs(now) {
1375
- const BEIJING = 8 * 3600 * 1000
1376
- const shifted = new Date(now + BEIJING)
1377
- const todayStart = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - BEIJING
1378
- return todayStart - 6 * 24 * 3600 * 1000
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 BEIJING = 8 * 3600 * 1000
1384
- const shifted = new Date(now + BEIJING)
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(北京时间)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
1504
+ /** 当年 1 月 1 日 00:00(本机时区)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
1391
1505
  function yearStartMs(now) {
1392
- const BEIJING = 8 * 3600 * 1000
1393
- const shifted = new Date(now + BEIJING)
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
- /** 北京时间(UTC+8)的日期键 'YYYY-MM-DD' —— DeepSeek 的峰谷与账单都按北京时间。 */
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 + 8 * 3600 * 1000)
1400
- return date.getUTCFullYear() + '-' + padTwo(date.getUTCMonth() + 1) + '-' + padTwo(date.getUTCDate())
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 = readLedgerFile(holidaysPath())
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[names[i]] = {
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 names = Object.keys(book === null || book === undefined ? {} : book).sort()
1752
- const parts = []
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 = book[names[i]]
1755
- const pick = (t) => (t === null || t === undefined ? 'x' : [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)].join(','))
1756
- parts.push(names[i] + ':' + pick(one.peak) + ':' + pick(one.offPeak))
1757
- }
1758
- return parts.join('|')
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
- * 单条形状对不上(段数不是 3、数字不合法)就跳过那一条;整体为空的返回 null。
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[fields[0]] = { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak }
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
- * 读一个台账文件;文件不存在、读不出或不是合法 JSON 对象时返回 null。
2861
+ * 读一个 JSON 对象文件;不存在、读不出或不是合法 JSON 对象时返回 null。
2862
+ *
2863
+ * 名字**刻意不叫** readLedgerFile:它不只读台账,`holidays.json` 也走这里
2864
+ * (两者都是「一个 JSON 对象,坏了就当没有」的语义)。用台账专属的名字会让人
2865
+ * 以为改动只该影响台账。
2654
2866
  */
2655
- function readLedgerFile(file) {
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 = readLedgerFile(ledgerPath())
2919
+ const parsed = readJsonObjectFile(ledgerPath())
2708
2920
  if (parsed !== null) return migrateLedger(parsed)
2709
- const backup = readLedgerFile(ledgerPath() + '.bak')
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 (readLedgerFile(target) !== null) {
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
- const one = bucket[name] ?? {
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(rangeStart + i * DAY)
2998
- const bucket = ledger.days[key] ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }
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
- const BEIJING = 8 * 3600 * 1000
3052
- const DAY = 24 * 3600 * 1000
3053
- const first = new Date(monthStart + BEIJING)
3054
- const year = first.getUTCFullYear()
3055
- const month = first.getUTCMonth() + 1
3056
- const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
3057
- const firstWeekday = (first.getUTCDay() + 6) % 7 /* 周一=0 */
3058
- const gridStart = monthStart - firstWeekday * DAY
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 = gridStart + i * DAY
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
- const BEIJING = 8 * 3600 * 1000
3104
- const year = new Date(now + BEIJING).getUTCFullYear()
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(Date.UTC(year, m, 0)).getUTCDate()
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.UTC(year, m - 1, d) - BEIJING)]
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
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
3402
- if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
3403
- /* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
3404
- 活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
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
- const result = await fetchHolidayDays(year)
3480
- if (result.ok === true) {
3481
- /* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
3482
- const merged = saveHolidayDays(result.days)
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
- if (url.pathname === '/dsh-desktop-statusbar/api/version') {
3495
- /* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
3496
- if (latestVersion.checkedAt === 0 && versionCheck !== null) {
3497
- try {
3498
- latestVersion = await versionCheck
3499
- } catch (error) {
3500
- /* 保持 latest 为 null,下面按「查不到」处理 */
3501
- }
3502
- }
3503
- const raw = url.searchParams.get('current')
3504
- const current = raw === null ? '' : raw.trim()
3505
- sendJson(res, 200, {
3506
- ok: latestVersion.latest !== null,
3507
- current,
3508
- latest: latestVersion.latest,
3509
- /* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
3510
- hasUpdate: latestVersion.latest !== null && current.length > 0
3511
- && compareVersions(latestVersion.latest, current) > 0,
3512
- checkedAt: latestVersion.checkedAt,
3513
- error: latestVersion.error,
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
- if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
3518
- if (req.method === 'POST') {
3519
- const body = await readJsonBody(req)
3520
- const model = body !== null && typeof body.model === 'string' ? body.model.trim() : ''
3521
- if (model.length === 0) {
3522
- sendJson(res, 400, { ok: false, reason: 'model-required' })
3523
- return
3524
- }
3525
- activeModel = {
3526
- provider: typeof body.provider === 'string' && body.provider.length > 0 ? body.provider : null,
3527
- model: model,
3528
- at: Date.now(),
3529
- }
3530
- sendJson(res, 200, { ok: true })
3531
- return
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
- sendJson(res, 200, {
3534
- ok: true,
3535
- provider: activeModel.provider,
3536
- model: activeModel.model,
3537
- at: activeModel.at,
3538
- })
3539
- return
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
- if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
3542
- /* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
3543
- 找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
3544
- 本轮里队友花了多少;不传就是整会话(「总计」用)。
3545
- 参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
3546
- 全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
3547
- const raw = url.searchParams.get('sessionId')
3548
- const sessionId = raw === null ? '' : raw.trim()
3549
- if (sessionId.length === 0 || sessionId.length > 200) {
3550
- sendJson(res, 400, { ok: false, reason: 'bad-session' })
3551
- return
3552
- }
3553
- /* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
3554
- 本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
3555
- 数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
3556
- "当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
3557
- 就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
3558
- const rawSince = url.searchParams.get('since')
3559
- const sinceText = rawSince === null ? '' : rawSince.trim()
3560
- const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
3561
- const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
3562
- && parsedSince <= Date.now() + 5 * 60 * 1000
3563
- /* 这个接口只读日志、不排队:它不碰台账,没有与扫描抢写盘的问题 */
3564
- let value
3565
- try {
3566
- value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
3567
- } catch (error) {
3568
- logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
3569
- value = { ok: false, reason: 'scan-failed' }
3570
- }
3571
- sendJson(res, 200, value)
3572
- return
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
- if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
3575
- let value
3576
- try {
3577
- value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
3578
- } catch (error) {
3579
- logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
3580
- value = { ok: false, reason: 'ledger-failed' }
3581
- }
3582
- sendJson(res, 200, value)
3583
- return
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
- if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
3586
- if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
3587
- sendJson(res, 200, { ok: true })
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
- if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
3591
- sendJson(res, 404, { ok: false, reason: 'not-found' })
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
- const force = url.searchParams.get('force') === '1'
3595
- const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
3596
- sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
3597
- },
3598
- }), 'dsh-desktop-statusbar: balance api')
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
  }