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/PROVENANCE.md +35 -0
- package/README.md +29 -11
- package/assets/DSH2.png +0 -0
- package/assets/whale-widget.js +717 -125
- package/lib/accounting.mjs +187 -0
- package/lib/index.js +228 -207
- package/package.json +3 -2
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
|
-
|
|
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
|
|
655
|
+
agg.cost = addMoney(agg.cost, costMsg)
|
|
636
656
|
if (model) {
|
|
637
|
-
agg.byModel[model] = (agg.byModel[model] || 0
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
833
|
-
|
|
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:
|
|
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
|
|
862
|
-
|
|
863
|
-
|
|
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 !==
|
|
894
|
-
const
|
|
895
|
-
map
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
for (
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1572
|
-
return { amount:
|
|
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 =
|
|
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
|
-
|
|
1842
|
-
entry.
|
|
1843
|
-
entry.
|
|
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
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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
|
-
|
|
1983
|
-
const
|
|
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
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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 =
|
|
1975
|
+
balanceInFlight = fetchBalance()
|
|
2007
1976
|
.then((payload) => {
|
|
2008
1977
|
if (payload.ok) {
|
|
2009
|
-
|
|
2010
|
-
|
|
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
|
-
|
|
2014
|
-
return { ...balanceCache.payload, stale: true, error: payload.error }
|
|
1983
|
+
return { ...publicBalance(balanceCache.payload), stale: true, error: payload.error }
|
|
2015
1984
|
}
|
|
2016
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
2502
|
-
//
|
|
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.
|
|
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",
|