dsh-whale-widget 0.3.0 → 0.3.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
@@ -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 优先。
@@ -70,6 +75,11 @@ const API_FILE_CANDIDATES = [
70
75
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
71
76
  ]
72
77
  const API_BUILTIN_ID = 'deepseek'
78
+ // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
79
+ // 新增厂商模板 / 手动新增的同名模型 / Kimi 等其它厂商都不会继承这个能力。
80
+ function canAdjustBuiltinBalance(model) {
81
+ return !!(model && model.id === API_BUILTIN_ID && model.builtin === true && model.provider === 'deepseek')
82
+ }
73
83
  // 内置厂商模板:balance 描述「怎么取余额」。json 路径支持 a.b[0].c;scale 为取值后的乘数。
74
84
  // 余额接口已按官方文档 / 社区参考核对(DeepSeek、OpenRouter、Kimi、阶跃、Novita、OpenAI 兼容中转站);
75
85
  // 硅基流动的余额接口已官方下线(410),故改为「无余额接口 + /v1/models 探活」;
@@ -179,6 +189,24 @@ const API_TEMPLATES = {
179
189
  },
180
190
  probeUrl: 'https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains',
181
191
  },
192
+ // OpenCode Go(订阅):一次返回 rolling / weekly / monthly 三个窗口,值为「已用% + 重置时间」
193
+ // (usage.rolling|weekly|monthly.{percent,resetsAt})。走多窗口路径 json.windows,见 fetchModelQuota。
194
+ // 鉴权只需 Authorization: Bearer <key>(2026-09 实测:x-api-key 单独使用返回 401,无需额外请求头)。
195
+ opencode_go: {
196
+ name: 'OpenCode Go(订阅)', currency: 'USD', keyRef: 'OPENCODE_GO_API_KEY', kind: 'quota',
197
+ quota: {
198
+ url: 'https://opencode.ai/zen/go/v1/usage',
199
+ auth: 'Bearer {key}',
200
+ json: {
201
+ windows: [
202
+ { key: 'rolling', label: '5h', percent: 'usage.rolling.percent', resetAt: 'usage.rolling.resetsAt' },
203
+ { key: 'weekly', label: '周', percent: 'usage.weekly.percent', resetAt: 'usage.weekly.resetsAt' },
204
+ { key: 'monthly', label: '月', percent: 'usage.monthly.percent', resetAt: 'usage.monthly.resetsAt' },
205
+ ],
206
+ },
207
+ },
208
+ probeUrl: 'https://opencode.ai/zen/go/v1/usage',
209
+ },
182
210
  openai_compat: {
183
211
  name: 'OpenAI 兼容中转站', currency: 'USD', keyRef: 'CUSTOM_API_KEY', needsBaseUrl: true,
184
212
  // OneAPI / New API 一类网关的经典账单接口:额度(美元) + 已用(美分)
@@ -611,13 +639,10 @@ export default {
611
639
  const input = Number(usage.inputTokens) || 0
612
640
  const cache = Number(usage.cacheReadTokens) || 0
613
641
  const output = Number(usage.outputTokens) || 0
614
- const reasoning = Number(usage.reasoningTokens) || 0
615
642
  // 口径修正(issue #89 / PR #83):dsh 保证 reasoningTokens ⊆ outputTokens
616
643
  // (见 dsh-token-meter 的校验 reasoningTokens > outputTokens 即判非法),
617
644
  // 所以 reasoning 不能再单独累加 —— 否则输出侧按输出价被重复计费(实测偏高约一倍)。
618
- // 兜底:万一某 provider 上报的 reasoning 不是 output 的子集(reasoning > output),
619
- // 就把差额补回计费输出,避免另一种口径下少算
620
- const outputBilled = reasoning > output ? output + reasoning : output
645
+ const outputBilled = output // DSH outputTokens already includes reasoningTokens.
621
646
  const toks = input + cache + outputBilled
622
647
  agg.tokens += toks
623
648
  // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位),并按模型拆分
@@ -632,9 +657,9 @@ export default {
632
657
  const meta = customPriceMetaFor(model)
633
658
  if (meta && meta.cur === 'USD' && Number(meta.rate) > 0) costMsg = costMsg * Number(meta.rate)
634
659
  } catch (err) {}
635
- agg.cost += costMsg
660
+ agg.cost = addMoney(agg.cost, costMsg)
636
661
  if (model) {
637
- agg.byModel[model] = (agg.byModel[model] || 0) + costMsg
662
+ agg.byModel[model] = addMoney(agg.byModel[model] || 0, costMsg)
638
663
  agg.byModelTokens[model] = (agg.byModelTokens[model] || 0) + toks
639
664
  }
640
665
  agg.lastTs = Date.now()
@@ -726,12 +751,13 @@ export default {
726
751
  return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
727
752
  }
728
753
  const info = pickBalanceInfo(data && data.balance_infos)
729
- if (!info || info.total_balance === undefined) {
754
+ if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
730
755
  return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
731
756
  }
732
757
  return {
733
758
  ok: true,
734
759
  totalBalance: Number(info.total_balance),
760
+ accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
735
761
  currency: String(info.currency || 'CNY'),
736
762
  updatedAt: new Date().toISOString(),
737
763
  }
@@ -745,27 +771,46 @@ export default {
745
771
  }
746
772
  }
747
773
 
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())
774
+ function todayKey() { return beijingDay() }
775
+ // Windows indexers can briefly hold a just-written file. Retry sharing
776
+ // conflicts; never treat an unreadable existing ledger as an empty ledger.
777
+ function ledgerIo(operation) {
778
+ for (let attempt = 0; ; attempt++) {
779
+ try { return operation() } catch (err) {
780
+ if (attempt >= 5 || !['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) throw err
781
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1))
782
+ }
783
+ }
752
784
  }
753
785
  function readUsageLedger() {
754
786
  for (const p of USAGE_FILE_CANDIDATES) {
755
787
  try {
756
- const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
788
+ const parsed = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
757
789
  if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
758
- } catch (err) {}
790
+ throw new Error('账本结构异常,已停止写入以保护原记录')
791
+ } catch (err) { if (err.code !== 'ENOENT') throw err }
759
792
  }
760
793
  return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
761
794
  }
762
795
  function writeUsageLedger(led) {
763
796
  const body = JSON.stringify(led)
764
797
  for (const p of USAGE_FILE_CANDIDATES) {
798
+ const temp = p + '.tmp-' + process.pid
765
799
  try {
766
- fs.writeFileSync(p, body, 'utf8')
800
+ if (fs.existsSync(p)) {
801
+ const existing = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
802
+ if (!existing.accounting || existing.accounting.version !== 1) {
803
+ try { ledgerIo(() => fs.copyFileSync(p, p + '.before-recharge-fix.bak', fs.constants.COPYFILE_EXCL)) }
804
+ catch (err) { if (err.code !== 'EEXIST') throw err }
805
+ }
806
+ }
807
+ ledgerIo(() => fs.writeFileSync(temp, body, 'utf8'))
808
+ ledgerIo(() => fs.renameSync(temp, p))
767
809
  return true
768
- } catch (err) {}
810
+ } catch (err) {
811
+ try { if (fs.existsSync(temp)) fs.unlinkSync(temp) } catch (cleanupErr) {}
812
+ if (err.code !== 'ENOENT') console.error('[whale-ledger] 账本保存失败:', err.code || err.message)
813
+ }
769
814
  }
770
815
  return false
771
816
  }
@@ -828,26 +873,18 @@ export default {
828
873
  return true
829
874
  } catch (err) { return false }
830
875
  }
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 }
876
+ function dayKeyOfDate(d) { return beijingDay(d.getTime()) }
877
+ function dayKeyFromTs(ts) { return beijingDay(Number(ts)) }
878
+ function dayAdd(baseDayStr, delta) { return dayOffset(baseDayStr, delta) }
842
879
  // 每轮结算后追加一条用量事件(模型明细/7天/全部记录的来源;上限 8000 条)
843
880
  function appendUsageEvent(ev) {
844
881
  const led = readUsageLedger()
845
882
  led.events = Array.isArray(led.events) ? led.events : []
846
883
  led.events.push({
847
884
  ts: Number(ev.ts) || Date.now(),
848
- day: dayKeyFromTs(ev.ts),
885
+ day: dayKeyFromTs(Number(ev.ts) || Date.now()),
849
886
  model: String(ev.model || '未知'),
850
- cost: round2(ev.cost),
887
+ cost: preciseMoney(Number(ev.cost) || 0),
851
888
  tokens: Math.round(Number(ev.tokens) || 0),
852
889
  })
853
890
  // 保留策略:events 90 天 / 最多 2 万条(超期或超量归档到 .dshw-usage-archive.json)
@@ -858,88 +895,38 @@ export default {
858
895
  function usageRecordsPayload() {
859
896
  const led = readUsageLedger()
860
897
  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 = {}
898
+ const today = todayKey()
899
+ function modelsFor(day) {
900
+ const map = new Map()
892
901
  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
- }
902
+ if (e.day !== day) continue
903
+ const name = String(e.model || '未知')
904
+ map.set(name, addMoney(map.get(name) || 0, Number(e.cost) || 0))
913
905
  }
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) })
906
+ return Array.from(map, ([model, cost]) => ({ model, cost, source: 'events', currency: 'CNY' }))
907
+ .sort((a, b) => b.cost - a.cost)
908
+ }
909
+ function forDay(date) {
910
+ const summary = daySummary(led, date)
911
+ const models = modelsFor(date)
912
+ return {
913
+ ...summary, date, total: summary.amount, models,
914
+ modelTotal: sumMoney(models.map(m => m.cost)), modelCurrency: 'CNY',
919
915
  }
920
- arr.sort((a, b) => b.cost - a.cost)
921
- return arr
922
916
  }
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)
917
+ const todayData = forDay(today)
918
+ const days7 = Array.from({ length: 7 }, (_, i) => forDay(dayAdd(today, -i)))
919
+ const total7ByCurrency = {}
920
+ for (const d of days7) total7ByCurrency[d.currency] = addMoney(total7ByCurrency[d.currency] || 0, d.total)
921
+ const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
922
+ for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
937
923
  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 },
924
+ ok: true, version: '0.3.2', today: todayData, days7,
925
+ total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
926
+ all: {
927
+ days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
928
+ events: events.slice().sort((a, b) => b.ts - a.ts).slice(0, 500),
929
+ },
943
930
  settings: readUsageSettings(),
944
931
  }
945
932
  }
@@ -1472,10 +1459,41 @@ export default {
1472
1459
  if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
1473
1460
  if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
1474
1461
  if (j.level) level = String(pickJsonPath(data, j.level) || '')
1475
- if (usedPct === null && remainPct === null && !resetAt) {
1462
+ // v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
1463
+ // rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
1464
+ // json.windows: [{ key, label, percent, resetAt }] 描述;这里归一成 windows 数组,
1465
+ // 并把第一个窗口回填成主窗口 usedPct / resetAt,保持原有单窗口链路的兼容。
1466
+ let windows = null
1467
+ if (Array.isArray(j.windows)) {
1468
+ const list = []
1469
+ for (const w of j.windows) {
1470
+ if (!w) continue
1471
+ let wp = w.percent ? num(pickJsonPath(data, w.percent)) : null
1472
+ if (wp !== null) wp = Math.max(0, Math.min(100, wp))
1473
+ let wr = null
1474
+ if (w.resetAt) {
1475
+ const rv = pickJsonPath(data, w.resetAt)
1476
+ if (rv !== undefined && rv !== null && rv !== '') wr = rv
1477
+ }
1478
+ if (wp === null && wr === null) continue
1479
+ list.push({ key: String(w.key || ''), label: String(w.label || ''), usedPct: wp, resetAt: wr })
1480
+ }
1481
+ if (list.length) windows = list
1482
+ }
1483
+ if (windows) {
1484
+ const w0 = windows[0]
1485
+ if (usedPct === null && w0.usedPct !== null) usedPct = w0.usedPct
1486
+ if (!resetAt && w0.resetAt !== null) resetAt = w0.resetAt
1487
+ if (weeklyUsedPct === null) {
1488
+ for (const w of windows) {
1489
+ if ((w.key === 'weekly' || w.label === '周') && w.usedPct !== null) { weeklyUsedPct = w.usedPct; break }
1490
+ }
1491
+ }
1492
+ }
1493
+ if (usedPct === null && remainPct === null && !resetAt && !windows) {
1476
1494
  return { ok: false, code: 'PARSE', error: '额度接口返回无法解析(字段路径不匹配)' }
1477
1495
  }
1478
- return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct }
1496
+ return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct, windows }
1479
1497
  } catch (err) {
1480
1498
  return { ok: false, code: 'HTTP', error: '额度接口请求失败: ' + String((err && err.message) || err).slice(0, 140) }
1481
1499
  }
@@ -1551,7 +1569,7 @@ export default {
1551
1569
  if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
1552
1570
  if (typeof remaining === 'number' && isFinite(remaining)) {
1553
1571
  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))
1572
+ if (typeof cur.lastBalance === 'number' && remaining < cur.lastBalance) cur.delta = addMoney(cur.delta, cur.lastBalance - remaining)
1555
1573
  cur.lastBalance = remaining
1556
1574
  }
1557
1575
  reg.usage[id] = cur
@@ -1568,8 +1586,8 @@ export default {
1568
1586
  const reg = readApiRegistry()
1569
1587
  const cur = reg.usage && reg.usage[id]
1570
1588
  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' }
1589
+ if (typeof cur.lastBalance === 'number' && isFinite(cur.lastBalance)) return { amount: preciseMoney(cur.delta || 0), source: 'balance', currency: mcur }
1590
+ return { amount: preciseMoney(Number(cur.eventCost) || 0), source: 'events', currency: 'CNY' }
1573
1591
  } catch (err) { return { amount: 0, source: 'none', currency: mcur } }
1574
1592
  }
1575
1593
  function apiUsageRaw(id) {
@@ -1625,7 +1643,7 @@ export default {
1625
1643
  const day = todayKey()
1626
1644
  let cur = reg.usage[hit.id]
1627
1645
  if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
1628
- cur.eventCost = round2((Number(cur.eventCost) || 0) + (Number(cost) || 0))
1646
+ cur.eventCost = addMoney(Number(cur.eventCost) || 0, Number(cost) || 0)
1629
1647
  cur.eventTokens = (Number(cur.eventTokens) || 0) + (Number(tokens) || 0)
1630
1648
  // 累计量:额度(资源包/订阅)按它算已用;跨天不清零
1631
1649
  const tk = Number(tokens) || 0
@@ -1811,6 +1829,7 @@ export default {
1811
1829
  let entry = {
1812
1830
  id: m.id, name: m.name, provider: m.provider, currency: m.currency,
1813
1831
  keyRef: m.keyRef, builtin: !!m.builtin, baseUrl: m.baseUrl || '',
1832
+ canAdjustBalance: canAdjustBuiltinBalance(m),
1814
1833
  matchIds: m.matchIds || [], settings: settings.models && settings.models[m.id] ? settings.models[m.id] : null,
1815
1834
  price: m.price || null,
1816
1835
  // 手动额度(订阅/资源包):总量、单位、已用、重置周期;前端据此显示额度模块
@@ -1838,9 +1857,13 @@ export default {
1838
1857
  const p = await getBalance()
1839
1858
  if (p && p.ok) {
1840
1859
  entry.balance = Number(p.totalBalance)
1841
- entry.todayUsage = ledgerTodayTotal(readUsageLedger())
1842
- entry.todayUsageCurrency = 'CNY' // 内置 DeepSeek 走账本,一律人民币
1843
- entry.usageSource = 'ledger'
1860
+ const summary = daySummary(readUsageLedger(), todayKey())
1861
+ entry.currency = p.currency
1862
+ entry.todayUsage = summary.amount
1863
+ entry.todayUsageCurrency = summary.currency
1864
+ entry.usageSource = summary.source
1865
+ entry.usageLabel = summary.label
1866
+ entry.accounting = balanceSummary(readUsageLedger(), todayKey())
1844
1867
  } else if (p) entry.error = p.error || p.code || '余额获取失败'
1845
1868
  } catch (err) { entry.error = String((err && err.message) || err) }
1846
1869
  } else {
@@ -1923,107 +1946,55 @@ export default {
1923
1946
  }
1924
1947
  }
1925
1948
 
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 万条)
1949
+ // Each currency/key has its own timed observation window. An increase is
1950
+ // recorded separately and never subtracts previously observed consumption.
1951
+ function recordLedgerUsage(currentBalance, currency, scope, at) {
1952
+ const led = readUsageLedger()
1953
+ observeBalance(led, { balance: currentBalance, currency, scope, at })
1960
1954
  pruneLedgerUsage(led)
1961
- writeUsageLedger(led)
1955
+ if (!writeUsageLedger(led)) throw new Error('账本保存失败,请检查 DSH 数据目录写入权限')
1962
1956
  return led
1963
1957
  }
1964
1958
  function normalizeUsageMode() {
1965
1959
  return 'ledger' // 小鲸鱼记账为唯一记账方式
1966
1960
  }
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
- }
1961
+ function ledgerTodayTotal(led) { return daySummary(led, todayKey()).amount }
1981
1962
 
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)
1963
+ function publicBalance(payload) {
1964
+ const { accountTag, ...visible } = payload
1992
1965
  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
1966
+ const summary = daySummary(led, todayKey())
1967
+ return {
1968
+ ...visible, version: '0.3.2', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
1969
+ todayUsage: summary.amount, todayUsageCurrency: summary.currency,
1970
+ usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
1971
+ accounting: balanceSummary(led, todayKey()),
1972
+ }
1998
1973
  }
1999
1974
 
2000
- function getBalance() {
1975
+ function getBalance(force = false) {
2001
1976
  const now = Date.now()
2002
- if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
2003
- return Promise.resolve(balanceCache.payload)
1977
+ if (!force && balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
1978
+ return Promise.resolve(publicBalance(balanceCache.payload))
2004
1979
  }
2005
1980
  if (balanceInFlight) return balanceInFlight
2006
- balanceInFlight = getBalancePayload()
1981
+ balanceInFlight = fetchBalance()
2007
1982
  .then((payload) => {
2008
1983
  if (payload.ok) {
2009
- balanceCache = { at: now, payload }
2010
- return payload
1984
+ recordLedgerUsage(payload.totalBalance, payload.currency, payload.accountTag, Date.parse(payload.updatedAt))
1985
+ balanceCache = { at: Date.now(), payload }
1986
+ return publicBalance(payload)
2011
1987
  }
2012
1988
  if (payload.transient && balanceCache) {
2013
- // transient network/API blip: keep serving the last known balance
2014
- return { ...balanceCache.payload, stale: true, error: payload.error }
1989
+ return { ...publicBalance(balanceCache.payload), stale: true, error: payload.error }
2015
1990
  }
2016
- if (!payload.transient) console.error('[whale-balance]', payload.code, payload.error)
2017
- return payload
1991
+ return publicBalance(payload)
2018
1992
  })
2019
1993
  .catch((err) => ({
2020
- ok: false,
2021
- code: 'ERROR',
1994
+ ok: false, code: 'ERROR',
2022
1995
  error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
2023
1996
  }))
2024
- .finally(() => {
2025
- balanceInFlight = null
2026
- })
1997
+ .finally(() => { balanceInFlight = null })
2027
1998
  return balanceInFlight
2028
1999
  }
2029
2000
 
@@ -2435,7 +2406,8 @@ export default {
2435
2406
  path: '/dsh-whale/balance.json',
2436
2407
  handler: async (req, res) => {
2437
2408
  try {
2438
- const payload = await getBalance()
2409
+ const refresh = new URL(req.url || '/', 'http://localhost').searchParams.get('refresh') === '1'
2410
+ const payload = await getBalance(refresh)
2439
2411
  res.writeHead(200, JSON_HEADERS)
2440
2412
  res.end(JSON.stringify(payload))
2441
2413
  } catch (err) {
@@ -2472,14 +2444,33 @@ export default {
2472
2444
  res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
2473
2445
  return
2474
2446
  }
2447
+ // issue #97:缺字段一律「沿用现有值」,绝不落默认 —— 否则任何不全的 PUT
2448
+ // (旧客户端 / 手写 curl / 加载竞态)都会把用户的设置洗成默认值。
2449
+ // 特别注意 scrollGapPx:读取默认是 17,而写入缺省曾是 0,会静默把避让宽度改掉。
2450
+ const old = readSizeConfig() || {}
2451
+ const pickB = (v, cur, dflt) => (typeof v === 'boolean' ? v : (typeof cur === 'boolean' ? cur : dflt))
2452
+ const pickN = (v, cur, dflt) => (typeof v === 'number' ? v : (typeof cur === 'number' ? cur : dflt))
2453
+ const pickS = (v, cur, dflt) => (typeof v === 'string' && v ? v : (typeof cur === 'string' && cur ? cur : dflt))
2475
2454
  // 用量模式变化时让余额缓存失效,下次请求立即按新模式计算
2476
2455
  if (typeof parsed.usageMode === 'string') {
2477
- const old = readSizeConfig()
2478
- if (!old || normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
2456
+ if (normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
2479
2457
  balanceCache = null
2480
2458
  }
2481
2459
  }
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)
2460
+ const result = writeSizeConfig(
2461
+ scale,
2462
+ pickB(parsed.sound, old.sound, true),
2463
+ pickN(parsed.vol, old.vol, 0.9),
2464
+ pickS(parsed.soundSet, old.soundSet, 'duck'),
2465
+ typeof parsed.usageMode === 'string' ? parsed.usageMode : old.usageMode,
2466
+ typeof parsed.peakMode === 'string' ? parsed.peakMode : old.peakMode,
2467
+ pickB(parsed.bubbleOn, old.bubbleOn, true),
2468
+ pickB(parsed.turnCostOn, old.turnCostOn, true),
2469
+ pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
2470
+ pickB(parsed.scrollGapOn, old.scrollGapOn, false),
2471
+ pickN(parsed.scrollGapPx, old.scrollGapPx, 17),
2472
+ pickB(parsed.menuBtnHide, old.menuBtnHide, false)
2473
+ )
2483
2474
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
2484
2475
  res.end(JSON.stringify(result))
2485
2476
  } catch (err) {
@@ -2498,16 +2489,8 @@ export default {
2498
2489
  path: '/dsh-whale/usage-records.json',
2499
2490
  handler: async (req, res) => {
2500
2491
  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) {}
2492
+ // Reports read the committed ledger. Replaying cached balance samples
2493
+ // here could roll back a correction or create a false midnight baseline.
2511
2494
  res.writeHead(200, JSON_HEADERS)
2512
2495
  res.end(JSON.stringify(usageRecordsPayload()))
2513
2496
  } catch (err) {
@@ -2516,6 +2499,64 @@ export default {
2516
2499
  }
2517
2500
  },
2518
2501
  }))
2502
+ disposers.push(registerRoute({
2503
+ kind: 'exact',
2504
+ path: '/dsh-whale/balance-adjustments.json',
2505
+ handler: async (req, res) => {
2506
+ try {
2507
+ // 先核对身份再碰余额或账本:必须是唯一且明确的 DeepSeek(内置)。
2508
+ // 新增厂商模板不会自动继承这个能力,也拒绝其它模型读写校正。
2509
+ const targetIds = new URL(req.url || '/', 'http://localhost').searchParams.getAll('modelId')
2510
+ const targetModelId = targetIds.length === 1 ? targetIds[0] : null
2511
+ if (targetModelId !== API_BUILTIN_ID || !canAdjustBuiltinBalance(apiModelById(targetModelId))) {
2512
+ const error = new Error('余额校正仅支持 DeepSeek(内置),请从该模型的设置菜单进入')
2513
+ error.status = 403
2514
+ throw error
2515
+ }
2516
+ if (req.method === 'PUT') {
2517
+ const input = JSON.parse(await readBodyMax(req, 8192))
2518
+ if (!input || typeof input !== 'object') throw new Error('校正内容无效')
2519
+ if (input.modelId !== targetModelId) {
2520
+ const error = new Error('校正请求的模型不匹配,仅支持 DeepSeek(内置)')
2521
+ error.status = 403
2522
+ throw error
2523
+ }
2524
+ if (input.day === todayKey()) {
2525
+ const fresh = await getBalance(true)
2526
+ if (!fresh.ok || fresh.stale) {
2527
+ res.writeHead(503, JSON_HEADERS)
2528
+ res.end(JSON.stringify({ ok: false, error: '暂时无法刷新余额,请稍后再保存校正' }))
2529
+ return
2530
+ }
2531
+ }
2532
+ const led = readUsageLedger()
2533
+ const summary = reconcileBalance(led, input)
2534
+ if (!writeUsageLedger(led)) throw new Error('校正保存失败,请检查 DSH 数据目录写入权限')
2535
+ balanceCache = null
2536
+ res.writeHead(200, JSON_HEADERS)
2537
+ res.end(JSON.stringify({ ok: true, summary }))
2538
+ return
2539
+ }
2540
+ if (req.method !== 'GET' && req.method !== undefined) {
2541
+ res.writeHead(405, { ...JSON_HEADERS, Allow: 'GET, PUT' })
2542
+ res.end(JSON.stringify({ ok: false, error: '不支持的请求方法' }))
2543
+ return
2544
+ }
2545
+ const fresh = await getBalance(true)
2546
+ const led = readUsageLedger()
2547
+ const days = accountingDays(led).sort().reverse().map(day => balanceSummary(led, day))
2548
+ res.writeHead(200, JSON_HEADERS)
2549
+ res.end(JSON.stringify({
2550
+ ok: true, days, today: todayKey(), fresh: !!fresh.ok && !fresh.stale,
2551
+ error: !fresh.ok || fresh.stale ? (fresh.error || '余额暂未刷新') : null,
2552
+ }))
2553
+ } catch (err) {
2554
+ res.writeHead(err.status || 400, JSON_HEADERS)
2555
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 240) }))
2556
+ }
2557
+ },
2558
+ }))
2559
+
2519
2560
  // 用量设置(任务结束音/余额预警/今日预算)
2520
2561
  disposers.push(registerRoute({
2521
2562
  kind: 'exact',