dsh-whale-widget 0.3.0 → 0.3.1

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
@@ -3,6 +3,11 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import dns from 'node:dns'
5
5
  import { fileURLToPath } from 'node:url'
6
+ import { createHash } from 'node:crypto'
7
+ import {
8
+ preciseMoney, addMoney, sumMoney, beijingDay, dayOffset,
9
+ observeBalance, balanceSummary, daySummary, accountingDays, reconcileBalance,
10
+ } from './accounting.mjs'
6
11
 
7
12
  // Node 的 fetch(undici) 可能优先 IPv6:个别域名(如 open.bigmodel.cn)有 AAAA 记录但本机 IPv6 不通,
8
13
  // 会直接报 "fetch failed"(curl 会自动回退 IPv4,所以看起来正常)。统一改为 IPv4 优先。
@@ -179,6 +184,24 @@ const API_TEMPLATES = {
179
184
  },
180
185
  probeUrl: 'https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains',
181
186
  },
187
+ // OpenCode Go(订阅):一次返回 rolling / weekly / monthly 三个窗口,值为「已用% + 重置时间」
188
+ // (usage.rolling|weekly|monthly.{percent,resetsAt})。走多窗口路径 json.windows,见 fetchModelQuota。
189
+ // 鉴权只需 Authorization: Bearer <key>(2026-09 实测:x-api-key 单独使用返回 401,无需额外请求头)。
190
+ opencode_go: {
191
+ name: 'OpenCode Go(订阅)', currency: 'USD', keyRef: 'OPENCODE_GO_API_KEY', kind: 'quota',
192
+ quota: {
193
+ url: 'https://opencode.ai/zen/go/v1/usage',
194
+ auth: 'Bearer {key}',
195
+ json: {
196
+ windows: [
197
+ { key: 'rolling', label: '5h', percent: 'usage.rolling.percent', resetAt: 'usage.rolling.resetsAt' },
198
+ { key: 'weekly', label: '周', percent: 'usage.weekly.percent', resetAt: 'usage.weekly.resetsAt' },
199
+ { key: 'monthly', label: '月', percent: 'usage.monthly.percent', resetAt: 'usage.monthly.resetsAt' },
200
+ ],
201
+ },
202
+ },
203
+ probeUrl: 'https://opencode.ai/zen/go/v1/usage',
204
+ },
182
205
  openai_compat: {
183
206
  name: 'OpenAI 兼容中转站', currency: 'USD', keyRef: 'CUSTOM_API_KEY', needsBaseUrl: true,
184
207
  // OneAPI / New API 一类网关的经典账单接口:额度(美元) + 已用(美分)
@@ -611,13 +634,10 @@ export default {
611
634
  const input = Number(usage.inputTokens) || 0
612
635
  const cache = Number(usage.cacheReadTokens) || 0
613
636
  const output = Number(usage.outputTokens) || 0
614
- const reasoning = Number(usage.reasoningTokens) || 0
615
637
  // 口径修正(issue #89 / PR #83):dsh 保证 reasoningTokens ⊆ outputTokens
616
638
  // (见 dsh-token-meter 的校验 reasoningTokens > outputTokens 即判非法),
617
639
  // 所以 reasoning 不能再单独累加 —— 否则输出侧按输出价被重复计费(实测偏高约一倍)。
618
- // 兜底:万一某 provider 上报的 reasoning 不是 output 的子集(reasoning > output),
619
- // 就把差额补回计费输出,避免另一种口径下少算
620
- const outputBilled = reasoning > output ? output + reasoning : output
640
+ const outputBilled = output // DSH outputTokens already includes reasoningTokens.
621
641
  const toks = input + cache + outputBilled
622
642
  agg.tokens += toks
623
643
  // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位),并按模型拆分
@@ -632,9 +652,9 @@ export default {
632
652
  const meta = customPriceMetaFor(model)
633
653
  if (meta && meta.cur === 'USD' && Number(meta.rate) > 0) costMsg = costMsg * Number(meta.rate)
634
654
  } catch (err) {}
635
- agg.cost += costMsg
655
+ agg.cost = addMoney(agg.cost, costMsg)
636
656
  if (model) {
637
- agg.byModel[model] = (agg.byModel[model] || 0) + costMsg
657
+ agg.byModel[model] = addMoney(agg.byModel[model] || 0, costMsg)
638
658
  agg.byModelTokens[model] = (agg.byModelTokens[model] || 0) + toks
639
659
  }
640
660
  agg.lastTs = Date.now()
@@ -726,12 +746,13 @@ export default {
726
746
  return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
727
747
  }
728
748
  const info = pickBalanceInfo(data && data.balance_infos)
729
- if (!info || info.total_balance === undefined) {
749
+ if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
730
750
  return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
731
751
  }
732
752
  return {
733
753
  ok: true,
734
754
  totalBalance: Number(info.total_balance),
755
+ accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
735
756
  currency: String(info.currency || 'CNY'),
736
757
  updatedAt: new Date().toISOString(),
737
758
  }
@@ -745,27 +766,46 @@ export default {
745
766
  }
746
767
  }
747
768
 
748
- function todayKey() {
749
- const d = new Date()
750
- const p = (n) => String(n).padStart(2, '0')
751
- return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
769
+ function todayKey() { return beijingDay() }
770
+ // Windows indexers can briefly hold a just-written file. Retry sharing
771
+ // conflicts; never treat an unreadable existing ledger as an empty ledger.
772
+ function ledgerIo(operation) {
773
+ for (let attempt = 0; ; attempt++) {
774
+ try { return operation() } catch (err) {
775
+ if (attempt >= 5 || !['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) throw err
776
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1))
777
+ }
778
+ }
752
779
  }
753
780
  function readUsageLedger() {
754
781
  for (const p of USAGE_FILE_CANDIDATES) {
755
782
  try {
756
- const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
783
+ const parsed = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
757
784
  if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
758
- } catch (err) {}
785
+ throw new Error('账本结构异常,已停止写入以保护原记录')
786
+ } catch (err) { if (err.code !== 'ENOENT') throw err }
759
787
  }
760
788
  return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
761
789
  }
762
790
  function writeUsageLedger(led) {
763
791
  const body = JSON.stringify(led)
764
792
  for (const p of USAGE_FILE_CANDIDATES) {
793
+ const temp = p + '.tmp-' + process.pid
765
794
  try {
766
- fs.writeFileSync(p, body, 'utf8')
795
+ if (fs.existsSync(p)) {
796
+ const existing = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
797
+ if (!existing.accounting || existing.accounting.version !== 1) {
798
+ try { ledgerIo(() => fs.copyFileSync(p, p + '.before-recharge-fix.bak', fs.constants.COPYFILE_EXCL)) }
799
+ catch (err) { if (err.code !== 'EEXIST') throw err }
800
+ }
801
+ }
802
+ ledgerIo(() => fs.writeFileSync(temp, body, 'utf8'))
803
+ ledgerIo(() => fs.renameSync(temp, p))
767
804
  return true
768
- } catch (err) {}
805
+ } catch (err) {
806
+ try { if (fs.existsSync(temp)) fs.unlinkSync(temp) } catch (cleanupErr) {}
807
+ if (err.code !== 'ENOENT') console.error('[whale-ledger] 账本保存失败:', err.code || err.message)
808
+ }
769
809
  }
770
810
  return false
771
811
  }
@@ -828,26 +868,18 @@ export default {
828
868
  return true
829
869
  } catch (err) { return false }
830
870
  }
831
- function dayKeyOfDate(d) {
832
- const p = (n) => String(n).padStart(2, '0')
833
- return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
834
- }
835
- function dayKeyFromTs(ts) { return dayKeyOfDate(new Date(Number(ts))) }
836
- function dayAdd(baseDayStr, delta) {
837
- const d = new Date(baseDayStr + 'T00:00:00')
838
- d.setDate(d.getDate() + delta)
839
- return dayKeyOfDate(d)
840
- }
841
- function round2(x) { return Math.round((Number(x) || 0) * 100) / 100 }
871
+ function dayKeyOfDate(d) { return beijingDay(d.getTime()) }
872
+ function dayKeyFromTs(ts) { return beijingDay(Number(ts)) }
873
+ function dayAdd(baseDayStr, delta) { return dayOffset(baseDayStr, delta) }
842
874
  // 每轮结算后追加一条用量事件(模型明细/7天/全部记录的来源;上限 8000 条)
843
875
  function appendUsageEvent(ev) {
844
876
  const led = readUsageLedger()
845
877
  led.events = Array.isArray(led.events) ? led.events : []
846
878
  led.events.push({
847
879
  ts: Number(ev.ts) || Date.now(),
848
- day: dayKeyFromTs(ev.ts),
880
+ day: dayKeyFromTs(Number(ev.ts) || Date.now()),
849
881
  model: String(ev.model || '未知'),
850
- cost: round2(ev.cost),
882
+ cost: preciseMoney(Number(ev.cost) || 0),
851
883
  tokens: Math.round(Number(ev.tokens) || 0),
852
884
  })
853
885
  // 保留策略:events 90 天 / 最多 2 万条(超期或超量归档到 .dshw-usage-archive.json)
@@ -858,88 +890,38 @@ export default {
858
890
  function usageRecordsPayload() {
859
891
  const led = readUsageLedger()
860
892
  const events = Array.isArray(led.events) ? led.events : []
861
- const history = led.history && typeof led.history === 'object' ? led.history : {}
862
- const today = dayKeyFromTs(Date.now())
863
- function sumEv(d) { let s = 0; for (const e of events) if (e.day === d) s += Number(e.cost) || 0; return s }
864
- // 当日真实消费 = 零点起始余额 − 当前余额(官方按真实扣款计,事件漏记时用它兜底)
865
- function realDelta() {
866
- if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number') {
867
- const d = led.dayStart - led.lastBalance
868
- if (d > 0) return d
869
- }
870
- return 0
871
- }
872
- // 单日合计:事件合计优先,其次历史归档值;今天以真实余额差为准——
873
- // 官方账单按真实扣款计,事件计价可能出现系统性偏高(如重复/口径差异),
874
- // 而余额差(零点余额−当前余额)与官网几乎一致,故今日不再与事件取大。
875
- function totalDay(d) {
876
- if (d === today) {
877
- const dd = realDelta()
878
- if (dd > 0) return round2(dd)
879
- // 余额差不可用(充值/退款/无基准)时退回事件/历史
880
- let v = sumEv(d)
881
- const h = history[d]
882
- if (typeof h === 'number' && h > v) v = h
883
- return round2(v)
884
- }
885
- let v = sumEv(d)
886
- const h = history[d]
887
- if (typeof h === 'number' && h > v) v = h
888
- return round2(v)
889
- }
890
- function modelsFor(d) {
891
- const map = {}
893
+ const today = todayKey()
894
+ function modelsFor(day) {
895
+ const map = new Map()
892
896
  for (const e of events) {
893
- if (e.day !== d) continue
894
- const k = e.model || '未知'
895
- map[k] = (map[k] || 0) + (Number(e.cost) || 0)
896
- }
897
- const names = Object.keys(map).sort((a, b) => map[b] - map[a])
898
- const tot = totalDay(d)
899
- const evSum = names.reduce((s, k) => s + map[k], 0)
900
- const arr = names.map((k) => ({ model: k, cost: map[k] }))
901
- // 今天:模型明细需与合计一致——
902
- // 事件合计 > 余额差(事件偏高)时按比例压缩到合计;
903
- // 事件合计 < 合计(事件漏记/余额差兜底)时补一行「未入明细」。
904
- if (d === today && evSum > 0.0001 && evSum > tot + 0.004) {
905
- const k = tot / evSum
906
- for (const it of arr) it.cost = round2(it.cost * k)
907
- const now = arr.reduce((s, it) => s + it.cost, 0)
908
- const diff = round2(tot - now)
909
- if (Math.abs(diff) > 0.004) {
910
- if (diff > 0) arr[0].cost = round2(arr[0].cost + diff)
911
- else arr[0].cost = round2(Math.max(0, arr[0].cost + diff))
912
- }
897
+ if (e.day !== day) continue
898
+ const name = String(e.model || '未知')
899
+ map.set(name, addMoney(map.get(name) || 0, Number(e.cost) || 0))
913
900
  }
914
- arr.forEach((it) => { it.cost = round2(it.cost) })
915
- // 今天若有“未入明细”的真实消费(事件漏记部分),补一行使合计对得上
916
- if (d === today) {
917
- const gap = tot - evSum
918
- if (gap > 0.004) arr.push({ model: '(未入明细)', cost: round2(gap) })
901
+ return Array.from(map, ([model, cost]) => ({ model, cost, source: 'events', currency: 'CNY' }))
902
+ .sort((a, b) => b.cost - a.cost)
903
+ }
904
+ function forDay(date) {
905
+ const summary = daySummary(led, date)
906
+ const models = modelsFor(date)
907
+ return {
908
+ ...summary, date, total: summary.amount, models,
909
+ modelTotal: sumMoney(models.map(m => m.cost)), modelCurrency: 'CNY',
919
910
  }
920
- arr.sort((a, b) => b.cost - a.cost)
921
- return arr
922
911
  }
923
- const days7 = []
924
- let total7 = 0
925
- // 今天在最上,往后退 6 天
926
- for (let i = 0; i <= 6; i++) {
927
- const d = dayAdd(today, -i)
928
- const t = totalDay(d)
929
- total7 += t
930
- days7.push({ date: d, total: round2(t), models: modelsFor(d) })
931
- }
932
- const daySet = {}
933
- Object.keys(history).forEach((d) => { if (/^\d{4}-\d{2}-\d{2}$/.test(d)) daySet[d] = 1 })
934
- events.forEach((e) => { if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) daySet[e.day] = 1 })
935
- const allDays = Object.keys(daySet).sort().reverse().map((d) => ({ date: d, total: totalDay(d), models: modelsFor(d) }))
936
- const latestEvents = events.slice().sort((a, b) => b.ts - a.ts).slice(0, 500)
912
+ const todayData = forDay(today)
913
+ const days7 = Array.from({ length: 7 }, (_, i) => forDay(dayAdd(today, -i)))
914
+ const total7ByCurrency = {}
915
+ for (const d of days7) total7ByCurrency[d.currency] = addMoney(total7ByCurrency[d.currency] || 0, d.total)
916
+ const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
917
+ for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
937
918
  return {
938
- ok: true,
939
- today: { total: totalDay(today), models: modelsFor(today) },
940
- days7: days7,
941
- total7: round2(total7),
942
- all: { days: allDays, events: latestEvents },
919
+ ok: true, version: '0.3.1', today: todayData, days7,
920
+ total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
921
+ all: {
922
+ days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
923
+ events: events.slice().sort((a, b) => b.ts - a.ts).slice(0, 500),
924
+ },
943
925
  settings: readUsageSettings(),
944
926
  }
945
927
  }
@@ -1472,10 +1454,41 @@ export default {
1472
1454
  if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
1473
1455
  if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
1474
1456
  if (j.level) level = String(pickJsonPath(data, j.level) || '')
1475
- if (usedPct === null && remainPct === null && !resetAt) {
1457
+ // v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
1458
+ // rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
1459
+ // json.windows: [{ key, label, percent, resetAt }] 描述;这里归一成 windows 数组,
1460
+ // 并把第一个窗口回填成主窗口 usedPct / resetAt,保持原有单窗口链路的兼容。
1461
+ let windows = null
1462
+ if (Array.isArray(j.windows)) {
1463
+ const list = []
1464
+ for (const w of j.windows) {
1465
+ if (!w) continue
1466
+ let wp = w.percent ? num(pickJsonPath(data, w.percent)) : null
1467
+ if (wp !== null) wp = Math.max(0, Math.min(100, wp))
1468
+ let wr = null
1469
+ if (w.resetAt) {
1470
+ const rv = pickJsonPath(data, w.resetAt)
1471
+ if (rv !== undefined && rv !== null && rv !== '') wr = rv
1472
+ }
1473
+ if (wp === null && wr === null) continue
1474
+ list.push({ key: String(w.key || ''), label: String(w.label || ''), usedPct: wp, resetAt: wr })
1475
+ }
1476
+ if (list.length) windows = list
1477
+ }
1478
+ if (windows) {
1479
+ const w0 = windows[0]
1480
+ if (usedPct === null && w0.usedPct !== null) usedPct = w0.usedPct
1481
+ if (!resetAt && w0.resetAt !== null) resetAt = w0.resetAt
1482
+ if (weeklyUsedPct === null) {
1483
+ for (const w of windows) {
1484
+ if ((w.key === 'weekly' || w.label === '周') && w.usedPct !== null) { weeklyUsedPct = w.usedPct; break }
1485
+ }
1486
+ }
1487
+ }
1488
+ if (usedPct === null && remainPct === null && !resetAt && !windows) {
1476
1489
  return { ok: false, code: 'PARSE', error: '额度接口返回无法解析(字段路径不匹配)' }
1477
1490
  }
1478
- return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct }
1491
+ return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct, windows }
1479
1492
  } catch (err) {
1480
1493
  return { ok: false, code: 'HTTP', error: '额度接口请求失败: ' + String((err && err.message) || err).slice(0, 140) }
1481
1494
  }
@@ -1551,7 +1564,7 @@ export default {
1551
1564
  if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
1552
1565
  if (typeof remaining === 'number' && isFinite(remaining)) {
1553
1566
  if (cur.dayStart === null || cur.dayStart === undefined) cur.dayStart = remaining
1554
- if (typeof cur.lastBalance === 'number' && remaining < cur.lastBalance - 0.000001) cur.delta = round2(cur.delta + (cur.lastBalance - remaining))
1567
+ if (typeof cur.lastBalance === 'number' && remaining < cur.lastBalance) cur.delta = addMoney(cur.delta, cur.lastBalance - remaining)
1555
1568
  cur.lastBalance = remaining
1556
1569
  }
1557
1570
  reg.usage[id] = cur
@@ -1568,8 +1581,8 @@ export default {
1568
1581
  const reg = readApiRegistry()
1569
1582
  const cur = reg.usage && reg.usage[id]
1570
1583
  if (!cur || cur.day !== todayKey()) return { amount: 0, source: 'none', currency: mcur }
1571
- if (typeof cur.delta === 'number' && cur.delta > 0.004) return { amount: round2(cur.delta), source: 'balance', currency: mcur }
1572
- return { amount: round2(Number(cur.eventCost) || 0), source: 'events', currency: 'CNY' }
1584
+ if (typeof cur.lastBalance === 'number' && isFinite(cur.lastBalance)) return { amount: preciseMoney(cur.delta || 0), source: 'balance', currency: mcur }
1585
+ return { amount: preciseMoney(Number(cur.eventCost) || 0), source: 'events', currency: 'CNY' }
1573
1586
  } catch (err) { return { amount: 0, source: 'none', currency: mcur } }
1574
1587
  }
1575
1588
  function apiUsageRaw(id) {
@@ -1625,7 +1638,7 @@ export default {
1625
1638
  const day = todayKey()
1626
1639
  let cur = reg.usage[hit.id]
1627
1640
  if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
1628
- cur.eventCost = round2((Number(cur.eventCost) || 0) + (Number(cost) || 0))
1641
+ cur.eventCost = addMoney(Number(cur.eventCost) || 0, Number(cost) || 0)
1629
1642
  cur.eventTokens = (Number(cur.eventTokens) || 0) + (Number(tokens) || 0)
1630
1643
  // 累计量:额度(资源包/订阅)按它算已用;跨天不清零
1631
1644
  const tk = Number(tokens) || 0
@@ -1838,9 +1851,13 @@ export default {
1838
1851
  const p = await getBalance()
1839
1852
  if (p && p.ok) {
1840
1853
  entry.balance = Number(p.totalBalance)
1841
- entry.todayUsage = ledgerTodayTotal(readUsageLedger())
1842
- entry.todayUsageCurrency = 'CNY' // 内置 DeepSeek 走账本,一律人民币
1843
- entry.usageSource = 'ledger'
1854
+ const summary = daySummary(readUsageLedger(), todayKey())
1855
+ entry.currency = p.currency
1856
+ entry.todayUsage = summary.amount
1857
+ entry.todayUsageCurrency = summary.currency
1858
+ entry.usageSource = summary.source
1859
+ entry.usageLabel = summary.label
1860
+ entry.accounting = balanceSummary(readUsageLedger(), todayKey())
1844
1861
  } else if (p) entry.error = p.error || p.code || '余额获取失败'
1845
1862
  } catch (err) { entry.error = String((err && err.message) || err) }
1846
1863
  } else {
@@ -1923,107 +1940,55 @@ export default {
1923
1940
  }
1924
1941
  }
1925
1942
 
1926
- // 记账:跨天时以当天第一次观测到的余额为“当日零点起始余额 dayStart”;
1927
- // 当天内每次观测都滚动刷新 lastBalance,不再因“今天已有事件”而提前返回——
1928
- // 事件明细可能因会话中断/重启漏记,真实余额差(dayStart−lastBalance)始终可算。
1929
- // 归档以真实余额差为准(与官网一致,事件计价偏高时不被带高);余额差不可用时才用事件。
1930
- function recordLedgerUsage(currentBalance) {
1931
- const t = todayKey()
1932
- let led = readUsageLedger()
1933
- const events = Array.isArray(led.events) ? led.events : []
1934
- function sumEv(d) { let s = 0; for (const e of events) if (e.day === d) s += Number(e.cost) || 0; return s }
1935
- if (led.date !== t) {
1936
- // 跨天:归档旧日真实合计(优先当日余额差;不可用/为0时退事件与历史),并重置当天基准
1937
- const oldDay = led.date
1938
- if (oldDay) {
1939
- led.history = led.history || {}
1940
- let oldTotal = 0
1941
- if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number' && led.lastBalance < led.dayStart) {
1942
- oldTotal = Math.max(oldTotal, led.dayStart - led.lastBalance)
1943
- }
1944
- oldTotal = Math.max(oldTotal, sumEv(oldDay))
1945
- if (typeof led.todayUsage === 'number') oldTotal = Math.max(oldTotal, led.todayUsage)
1946
- led.history[oldDay] = round2(oldTotal)
1947
- }
1948
- led.date = t
1949
- led.dayStart = currentBalance
1950
- led.todayUsage = 0
1951
- led.lastBalance = currentBalance
1952
- } else {
1953
- // 兼容旧账本:当天尚无 dayStart 时,把已冻结的 lastBalance 视为当天起始余额
1954
- if (typeof led.dayStart !== 'number' && typeof led.lastBalance === 'number') {
1955
- led.dayStart = led.lastBalance
1956
- }
1957
- led.lastBalance = currentBalance
1958
- }
1959
- // 归档/裁剪统一由 pruneLedgerUsage 负责(history 保留 365 天,events 90 天/2 万条)
1943
+ // Each currency/key has its own timed observation window. An increase is
1944
+ // recorded separately and never subtracts previously observed consumption.
1945
+ function recordLedgerUsage(currentBalance, currency, scope, at) {
1946
+ const led = readUsageLedger()
1947
+ observeBalance(led, { balance: currentBalance, currency, scope, at })
1960
1948
  pruneLedgerUsage(led)
1961
- writeUsageLedger(led)
1949
+ if (!writeUsageLedger(led)) throw new Error('账本保存失败,请检查 DSH 数据目录写入权限')
1962
1950
  return led
1963
1951
  }
1964
1952
  function normalizeUsageMode() {
1965
1953
  return 'ledger' // 小鲸鱼记账为唯一记账方式
1966
1954
  }
1967
- // 今日已用:以“当日真实余额差(零点起始余额−当前余额)”为准——
1968
- // 官方账单按真实扣款计,事件计价可能出现系统性偏高(14.49 vs 余额差 13.91 vs 官网 13.88),
1969
- // 余额差与官网几乎一致;事件合计仅在余额差不可用(充值/退款/无基准)时作兜底。
1970
- function ledgerTodayTotal(led) {
1971
- const events = Array.isArray(led.events) ? led.events : []
1972
- const t = dayKeyFromTs(Date.now())
1973
- let s = 0
1974
- for (const e of events) if (e.day === t) s += Number(e.cost) || 0
1975
- if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number') {
1976
- const d = led.dayStart - led.lastBalance
1977
- if (d > 0.004) return round2(d)
1978
- }
1979
- return round2(s)
1980
- }
1955
+ function ledgerTodayTotal(led) { return daySummary(led, todayKey()).amount }
1981
1956
 
1982
- async function getBalancePayload() {
1983
- const payload = await fetchBalance()
1984
- if (!payload.ok) {
1985
- // 余额接口失败(含限流 429)时仍附上今日已用:事件优先,其次账本余额差
1986
- const led = readUsageLedger()
1987
- return { ...payload, todayUsage: ledgerTodayTotal(led), usageMode: 'ledger' }
1988
- }
1989
- recordLedgerUsage(Number(payload.totalBalance))
1990
- // 必须在写入后重读账本:否则 todayUsage 会按“上一轮 lastBalance”计算,
1991
- // 造成泡泡与用量记录(后者直接读文件)数值不一致(如 4.99 vs 5.05)
1957
+ function publicBalance(payload) {
1958
+ const { accountTag, ...visible } = payload
1992
1959
  const led = readUsageLedger()
1993
- const full = { ...payload }
1994
- full.isPeak = isPeakTime(Math.floor(Date.now() / 1000))
1995
- full.todayUsage = ledgerTodayTotal(led)
1996
- full.usageMode = 'ledger'
1997
- return full
1960
+ const summary = daySummary(led, todayKey())
1961
+ return {
1962
+ ...visible, version: '0.3.1', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
1963
+ todayUsage: summary.amount, todayUsageCurrency: summary.currency,
1964
+ usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
1965
+ accounting: balanceSummary(led, todayKey()),
1966
+ }
1998
1967
  }
1999
1968
 
2000
- function getBalance() {
1969
+ function getBalance(force = false) {
2001
1970
  const now = Date.now()
2002
- if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
2003
- return Promise.resolve(balanceCache.payload)
1971
+ if (!force && balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
1972
+ return Promise.resolve(publicBalance(balanceCache.payload))
2004
1973
  }
2005
1974
  if (balanceInFlight) return balanceInFlight
2006
- balanceInFlight = getBalancePayload()
1975
+ balanceInFlight = fetchBalance()
2007
1976
  .then((payload) => {
2008
1977
  if (payload.ok) {
2009
- balanceCache = { at: now, payload }
2010
- return payload
1978
+ recordLedgerUsage(payload.totalBalance, payload.currency, payload.accountTag, Date.parse(payload.updatedAt))
1979
+ balanceCache = { at: Date.now(), payload }
1980
+ return publicBalance(payload)
2011
1981
  }
2012
1982
  if (payload.transient && balanceCache) {
2013
- // transient network/API blip: keep serving the last known balance
2014
- return { ...balanceCache.payload, stale: true, error: payload.error }
1983
+ return { ...publicBalance(balanceCache.payload), stale: true, error: payload.error }
2015
1984
  }
2016
- if (!payload.transient) console.error('[whale-balance]', payload.code, payload.error)
2017
- return payload
1985
+ return publicBalance(payload)
2018
1986
  })
2019
1987
  .catch((err) => ({
2020
- ok: false,
2021
- code: 'ERROR',
1988
+ ok: false, code: 'ERROR',
2022
1989
  error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
2023
1990
  }))
2024
- .finally(() => {
2025
- balanceInFlight = null
2026
- })
1991
+ .finally(() => { balanceInFlight = null })
2027
1992
  return balanceInFlight
2028
1993
  }
2029
1994
 
@@ -2435,7 +2400,8 @@ export default {
2435
2400
  path: '/dsh-whale/balance.json',
2436
2401
  handler: async (req, res) => {
2437
2402
  try {
2438
- const payload = await getBalance()
2403
+ const refresh = new URL(req.url || '/', 'http://localhost').searchParams.get('refresh') === '1'
2404
+ const payload = await getBalance(refresh)
2439
2405
  res.writeHead(200, JSON_HEADERS)
2440
2406
  res.end(JSON.stringify(payload))
2441
2407
  } catch (err) {
@@ -2472,14 +2438,33 @@ export default {
2472
2438
  res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
2473
2439
  return
2474
2440
  }
2441
+ // issue #97:缺字段一律「沿用现有值」,绝不落默认 —— 否则任何不全的 PUT
2442
+ // (旧客户端 / 手写 curl / 加载竞态)都会把用户的设置洗成默认值。
2443
+ // 特别注意 scrollGapPx:读取默认是 17,而写入缺省曾是 0,会静默把避让宽度改掉。
2444
+ const old = readSizeConfig() || {}
2445
+ const pickB = (v, cur, dflt) => (typeof v === 'boolean' ? v : (typeof cur === 'boolean' ? cur : dflt))
2446
+ const pickN = (v, cur, dflt) => (typeof v === 'number' ? v : (typeof cur === 'number' ? cur : dflt))
2447
+ const pickS = (v, cur, dflt) => (typeof v === 'string' && v ? v : (typeof cur === 'string' && cur ? cur : dflt))
2475
2448
  // 用量模式变化时让余额缓存失效,下次请求立即按新模式计算
2476
2449
  if (typeof parsed.usageMode === 'string') {
2477
- const old = readSizeConfig()
2478
- if (!old || normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
2450
+ if (normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
2479
2451
  balanceCache = null
2480
2452
  }
2481
2453
  }
2482
- const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs, parsed.scrollGapOn, parsed.scrollGapPx, parsed.menuBtnHide)
2454
+ const result = writeSizeConfig(
2455
+ scale,
2456
+ pickB(parsed.sound, old.sound, true),
2457
+ pickN(parsed.vol, old.vol, 0.9),
2458
+ pickS(parsed.soundSet, old.soundSet, 'duck'),
2459
+ typeof parsed.usageMode === 'string' ? parsed.usageMode : old.usageMode,
2460
+ typeof parsed.peakMode === 'string' ? parsed.peakMode : old.peakMode,
2461
+ pickB(parsed.bubbleOn, old.bubbleOn, true),
2462
+ pickB(parsed.turnCostOn, old.turnCostOn, true),
2463
+ pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
2464
+ pickB(parsed.scrollGapOn, old.scrollGapOn, false),
2465
+ pickN(parsed.scrollGapPx, old.scrollGapPx, 17),
2466
+ pickB(parsed.menuBtnHide, old.menuBtnHide, false)
2467
+ )
2483
2468
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
2484
2469
  res.end(JSON.stringify(result))
2485
2470
  } catch (err) {
@@ -2498,16 +2483,8 @@ export default {
2498
2483
  path: '/dsh-whale/usage-records.json',
2499
2484
  handler: async (req, res) => {
2500
2485
  try {
2501
- // 与余额路由同源:请求到来时若持有较新的余额缓存(≤70s,覆盖两次 60s 轮询间隙),
2502
- // 先用它刷新账本 lastBalance 再算汇总,保证“今日合计”与泡泡“今日已用”读同一份数据;
2503
- // 缓存太旧(挂件长时间未开)则跳过,避免把过期余额写回账本。
2504
- try {
2505
- if (balanceCache && balanceCache.payload && balanceCache.payload.ok &&
2506
- isFinite(Number(balanceCache.payload.totalBalance)) &&
2507
- Date.now() - balanceCache.at <= 70000) {
2508
- recordLedgerUsage(Number(balanceCache.payload.totalBalance))
2509
- }
2510
- } catch (err) {}
2486
+ // Reports read the committed ledger. Replaying cached balance samples
2487
+ // here could roll back a correction or create a false midnight baseline.
2511
2488
  res.writeHead(200, JSON_HEADERS)
2512
2489
  res.end(JSON.stringify(usageRecordsPayload()))
2513
2490
  } catch (err) {
@@ -2516,6 +2493,50 @@ export default {
2516
2493
  }
2517
2494
  },
2518
2495
  }))
2496
+ disposers.push(registerRoute({
2497
+ kind: 'exact',
2498
+ path: '/dsh-whale/balance-adjustments.json',
2499
+ handler: async (req, res) => {
2500
+ try {
2501
+ if (req.method === 'PUT') {
2502
+ const input = JSON.parse(await readBodyMax(req, 8192))
2503
+ if (!input || typeof input !== 'object') throw new Error('校正内容无效')
2504
+ if (input.day === todayKey()) {
2505
+ const fresh = await getBalance(true)
2506
+ if (!fresh.ok || fresh.stale) {
2507
+ res.writeHead(503, JSON_HEADERS)
2508
+ res.end(JSON.stringify({ ok: false, error: '暂时无法刷新余额,请稍后再保存校正' }))
2509
+ return
2510
+ }
2511
+ }
2512
+ const led = readUsageLedger()
2513
+ const summary = reconcileBalance(led, input)
2514
+ if (!writeUsageLedger(led)) throw new Error('校正保存失败,请检查 DSH 数据目录写入权限')
2515
+ balanceCache = null
2516
+ res.writeHead(200, JSON_HEADERS)
2517
+ res.end(JSON.stringify({ ok: true, summary }))
2518
+ return
2519
+ }
2520
+ if (req.method !== 'GET' && req.method !== undefined) {
2521
+ res.writeHead(405, { ...JSON_HEADERS, Allow: 'GET, PUT' })
2522
+ res.end(JSON.stringify({ ok: false, error: '不支持的请求方法' }))
2523
+ return
2524
+ }
2525
+ const fresh = await getBalance(true)
2526
+ const led = readUsageLedger()
2527
+ const days = accountingDays(led).sort().reverse().map(day => balanceSummary(led, day))
2528
+ res.writeHead(200, JSON_HEADERS)
2529
+ res.end(JSON.stringify({
2530
+ ok: true, days, today: todayKey(), fresh: !!fresh.ok && !fresh.stale,
2531
+ error: !fresh.ok || fresh.stale ? (fresh.error || '余额暂未刷新') : null,
2532
+ }))
2533
+ } catch (err) {
2534
+ res.writeHead(err.status || 400, JSON_HEADERS)
2535
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 240) }))
2536
+ }
2537
+ },
2538
+ }))
2539
+
2519
2540
  // 用量设置(任务结束音/余额预警/今日预算)
2520
2541
  disposers.push(registerRoute({
2521
2542
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",
@@ -15,7 +15,8 @@
15
15
  "lib",
16
16
  "assets",
17
17
  "cordis.patch.yml",
18
- "README.md"
18
+ "README.md",
19
+ "PROVENANCE.md"
19
20
  ],
20
21
  "repository": {
21
22
  "type": "git",