@mzzsfy/dsh-usage-panel 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/index.js +15 -26
- package/src/poller.mjs +7 -20
- package/test/poller.test.mjs +10 -24
package/README.md
CHANGED
|
@@ -40,8 +40,8 @@ dsh plugin --profile web add @mzzsfy/dsh-usage-panel
|
|
|
40
40
|
|
|
41
41
|
## 定期查询与趋势(v2)
|
|
42
42
|
|
|
43
|
-
-
|
|
44
|
-
- 失败退避:单账号失败按指数退避(基期 =
|
|
43
|
+
- 定期查询:时间驱动调度——全账号统一每 10 分钟查询一次(余额阈值等通知最长延迟约 10 分钟);上次尝试查询时刻随配置持久化,宿主重启后调度无缝衔接。定时轮询为软依赖:宿主 timer 服务不可用时仅停用自动轮询,面板显示降级提示,手动查询与全部配置能力不受影响
|
|
44
|
+
- 失败退避:单账号失败按指数退避(基期 = 统一查询间隔,×2 封顶 8 倍),成功即恢复。退避与到点判定独立叠加,二者皆过才发起查询。面板打开触发的自动查询同样受退避约束,手动刷新不受限
|
|
45
45
|
- 历史快照:按序列分档落盘 `history.json`(5 小时滚动 → 10 分钟粒度留 7 天;7 天 / 月 / 余额 → 小时粒度留 30 天),档内去重,超期修剪,硬点数上限兜底;删除账号时其历史序列同步清理
|
|
46
46
|
- 趋势视图:悬浮账号卡片弹出 sparkline(自绘 SVG),短 / 长窗口独立成图,绝对值 / 差值双视角;「详情」对话框可切时间范围(长窗口 / 月 / 余额:近 7 天 / 30 天 / 全部;5 小时短窗口仅近 7 天 / 全部),含区间摘要与明细表
|
|
47
47
|
- 历史文件解析失败时自动备份为 `history.json.bak` 并暂停写入(防空数据覆盖);`history.json` 被移除或恢复为可解析内容后自动恢复写入,`.bak` 是损坏前的最后数据,删除前请确认不再需要。`accounts.json` 同构守卫:损坏时备份为 `accounts.json.bak` 并拒绝一切写入(防账号 Key 被空配置覆盖),恢复后解除
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-panel",
|
|
3
3
|
"description": "多平台 LLM 账号余额与额度面板:支持 DeepSeek/OpenRouter/Kimi/智谱/MiniMax/NewApi 及自定义端点,自动定期查询,趋势图与历史快照,用量/余额阈值与窗口重置多通道通知",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from './parsers.mjs'
|
|
17
17
|
import { readingToSnapshots, appendPoint, buildMonthSequence, newSequenceStore } from './history.mjs'
|
|
18
18
|
import { createHistoryStore } from './historyStore.mjs'
|
|
19
|
-
import { createBackoff,
|
|
19
|
+
import { createBackoff, lastQuerySecOf, isDue, runLimited, POLL_INTERVAL_SEC } from './poller.mjs'
|
|
20
20
|
import {
|
|
21
21
|
DEFAULT_QUOTA_THRESHOLD_PCT,
|
|
22
22
|
WEBHOOK_TIMEOUT_MS,
|
|
@@ -51,7 +51,6 @@ const HISTORY_FILE = join(DATA_DIR, 'history.json')
|
|
|
51
51
|
const BAK_SUFFIX = '.bak'
|
|
52
52
|
const ACCOUNTS_BROKEN_MESSAGE = '账号配置文件已损坏已备份,已暂停写入以防数据丢失'
|
|
53
53
|
const TICK_SEC = 30
|
|
54
|
-
const SHORT_SUFFIX = '5h'
|
|
55
54
|
|
|
56
55
|
const NAMESPACE = 'usage-panel'
|
|
57
56
|
|
|
@@ -334,9 +333,8 @@ export function apply(ctx) {
|
|
|
334
333
|
let accountsBroken = false
|
|
335
334
|
let historyStore = createHistoryStore({ file: HISTORY_FILE })
|
|
336
335
|
let history = newSequenceStore()
|
|
337
|
-
//
|
|
338
|
-
const pollState = new Map()
|
|
339
|
-
// 通知投影(client 轮询展示)与事件序号
|
|
336
|
+
// 账号级退避状态:每账号失败计数独立,只管退避不管调度;调度到点由 last.queriedAt + 统一间隔另行判定
|
|
337
|
+
const pollState = new Map() // 通知投影(client 轮询展示)与事件序号
|
|
340
338
|
const projection = createProjection({})
|
|
341
339
|
let notifySeq = 0
|
|
342
340
|
|
|
@@ -367,37 +365,28 @@ export function apply(ctx) {
|
|
|
367
365
|
return persistHistory()
|
|
368
366
|
}
|
|
369
367
|
|
|
370
|
-
// 账号级退避状态:基期 =
|
|
368
|
+
// 账号级退避状态:基期 = 统一查询间隔,每账号失败计数独立,只管退避不管调度
|
|
371
369
|
function pollEntry(account) {
|
|
372
|
-
const baseSec = tierIntervalSec(hasShortWindow(account))
|
|
373
370
|
const existing = pollState.get(account.id)
|
|
374
|
-
if (existing
|
|
375
|
-
const
|
|
376
|
-
pollState.set(account.id,
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function hasShortWindow(account) {
|
|
381
|
-
return isShortWindowTier(
|
|
382
|
-
account.last,
|
|
383
|
-
account.last !== null && account.last.reading !== null &&
|
|
384
|
-
readingToSnapshots(account.last.reading).some((snap) => snap.suffix === SHORT_SUFFIX),
|
|
385
|
-
)
|
|
371
|
+
if (existing) return existing
|
|
372
|
+
const backoff = createBackoff({ baseSec: POLL_INTERVAL_SEC })
|
|
373
|
+
pollState.set(account.id, backoff)
|
|
374
|
+
return backoff
|
|
386
375
|
}
|
|
387
376
|
|
|
388
377
|
function runQuery(account) {
|
|
389
378
|
const queriedAt = Date.now()
|
|
390
379
|
return queryAccount(account).then((result) => {
|
|
391
|
-
const
|
|
380
|
+
const backoff = pollEntry(account)
|
|
392
381
|
if (result.ok) {
|
|
393
|
-
|
|
382
|
+
backoff.onSuccess()
|
|
394
383
|
// 先评估后落盘:阈值穿越事件不因落盘失败丢失;落盘失败仅弃本轮快照
|
|
395
384
|
evaluateAndDispatch(account, queriedAt)
|
|
396
385
|
return recordSnapshots(account, account.last.reading, queriedAt)
|
|
397
386
|
.catch(() => {})
|
|
398
387
|
.then(() => result)
|
|
399
388
|
}
|
|
400
|
-
|
|
389
|
+
backoff.onFailure(Math.floor(queriedAt / 1000))
|
|
401
390
|
return result
|
|
402
391
|
})
|
|
403
392
|
}
|
|
@@ -538,7 +527,7 @@ export function apply(ctx) {
|
|
|
538
527
|
const account = current.accounts.find((item) => item.id === id)
|
|
539
528
|
requireOk(account !== undefined, '账号不存在: ' + id)
|
|
540
529
|
// 面板打开触发的自动查询受退避约束,避免绕过退避轰炸上游;手动刷新不受限
|
|
541
|
-
if (auto && pollEntry(account).
|
|
530
|
+
if (auto && pollEntry(account).isBlocked(Math.floor(Date.now() / 1000))) {
|
|
542
531
|
sendJson(res, 200, { ok: false, skipped: true, account: redactAccount(account) })
|
|
543
532
|
return
|
|
544
533
|
}
|
|
@@ -730,7 +719,7 @@ export function apply(ctx) {
|
|
|
730
719
|
)
|
|
731
720
|
|
|
732
721
|
// 定期轮询:固定短 tick,时间驱动调度——上次尝试查询时刻(account.last.queriedAt,
|
|
733
|
-
// 成功失败均记、随配置持久化)
|
|
722
|
+
// 成功失败均记、随配置持久化)距今超过统一查询间隔即到点;退避独立叠加,二者皆过才查。
|
|
734
723
|
// 旧 round 分频形态已废:round 仅查询时递增使余额类账号死锁停摆,短窗账号每 tick 必查
|
|
735
724
|
// 使间隔设置失效。
|
|
736
725
|
// timer 软依赖经嵌套 inject 等待:服务激活才武装轮询,缺失则自动轮询停用,
|
|
@@ -748,11 +737,11 @@ export function apply(ctx) {
|
|
|
748
737
|
if (!current) return
|
|
749
738
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
750
739
|
const due = current.accounts.filter((account) => {
|
|
751
|
-
if (pollEntry(account).
|
|
740
|
+
if (pollEntry(account).isBlocked(nowSec)) return false
|
|
752
741
|
return isDue({
|
|
753
742
|
lastQuerySec: lastQuerySecOf(account.last),
|
|
754
743
|
nowSec,
|
|
755
|
-
intervalSec:
|
|
744
|
+
intervalSec: POLL_INTERVAL_SEC,
|
|
756
745
|
})
|
|
757
746
|
})
|
|
758
747
|
if (due.length === 0) return
|
package/src/poller.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
// 轮询纯逻辑层:时间驱动调度 / 失败指数退避 /
|
|
1
|
+
// 轮询纯逻辑层:时间驱动调度 / 失败指数退避 / 统一查询间隔。无 IO,host 半区与单测共用。
|
|
2
2
|
// 历史教训:旧 round 分频形态(round 仅在查询时递增)构成死锁——余额类账号首查后
|
|
3
3
|
// round 恒 1 永不再查;短窗账号每 tick 必查使用户间隔设置完全失效。改为时间驱动:
|
|
4
|
-
// 上次尝试查询时刻(成功失败均记)+
|
|
4
|
+
// 上次尝试查询时刻(成功失败均记)+ 统一间隔判定到点,退避独立叠加。
|
|
5
|
+
// 旧档位间隔(短窗 10 分钟/余额 1 小时)已统一为单一间隔:通知及时性要求余额类
|
|
6
|
+
// 账号同节奏,两档常量相等后档位机制退化为恒同值死路径,整体移除。
|
|
5
7
|
|
|
6
8
|
export const BACKOFF_CAP_MULTIPLE = 8
|
|
7
|
-
//
|
|
8
|
-
export const
|
|
9
|
-
// 长窗口档(日/月/余额序列)查询间隔 = 序列快照粒度(history.mjs GRANULARITY_MS['1h'])
|
|
10
|
-
export const LONG_TIER_INTERVAL_SEC = 60 * 60
|
|
9
|
+
// 全账号统一查询间隔
|
|
10
|
+
export const POLL_INTERVAL_SEC = 10 * 60
|
|
11
11
|
|
|
12
|
-
// 失败退避状态机:基期 =
|
|
12
|
+
// 失败退避状态机:基期 = 统一查询间隔,×2 封顶;成功即恢复。
|
|
13
13
|
export function createBackoff({ baseSec }) {
|
|
14
14
|
let failures = 0
|
|
15
15
|
let nextRetryAt = null
|
|
@@ -32,19 +32,6 @@ export function createBackoff({ baseSec }) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// 短窗口档账号判定:从未查询成功(含最近一次失败)或最近成功读数含短窗序列。
|
|
36
|
-
// 失败归短档:保持 10 分钟调度节奏与 600s 退避基期,避免一次瞬时失败把含 5h
|
|
37
|
-
// 窗口的账号塌缩到 1 小时档,造成短窗序列长时间空洞;轰炸由退避指数压制。
|
|
38
|
-
export function isShortWindowTier(last, readingHasShort) {
|
|
39
|
-
if (last === null) return true
|
|
40
|
-
return last.ok !== true || readingHasShort
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// 账号档位间隔:含短窗口序列走短档,否则长档。
|
|
44
|
-
export function tierIntervalSec(hasShortWindow) {
|
|
45
|
-
return hasShortWindow ? SHORT_TIER_INTERVAL_SEC : LONG_TIER_INTERVAL_SEC
|
|
46
|
-
}
|
|
47
|
-
|
|
48
35
|
// 上次尝试查询时刻(秒):queriedAt 毫秒历元;缺失/非法(旧数据)回 null 视为立即到点。
|
|
49
36
|
export function lastQuerySecOf(last) {
|
|
50
37
|
const queriedAt = last !== null && typeof last === 'object' ? last.queriedAt : undefined
|
package/test/poller.test.mjs
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
// 轮询纯逻辑 BDD:时间驱动调度 / 失败退避 /
|
|
1
|
+
// 轮询纯逻辑 BDD:时间驱动调度 / 失败退避 / 统一查询间隔。无外部依赖,定时器由调用方注入。
|
|
2
2
|
// 历史:round 分频形态(shouldQueryThisRound/longWindowDivisor)已被时间驱动取代——
|
|
3
3
|
// round 仅查询时递增使余额类账号死锁停摆,短窗账号每 tick 必查使用户间隔失效。
|
|
4
|
+
// 档位间隔(短档 10 分钟/长档 1 小时)已被统一间隔取代——通知及时性要求余额类账号
|
|
5
|
+
// 与短窗账号同节奏,两档常量相等后档位机制退化为恒同值死路径,整体移除。
|
|
4
6
|
import { test } from 'node:test'
|
|
5
7
|
import assert from 'node:assert/strict'
|
|
6
8
|
import {
|
|
7
9
|
BACKOFF_CAP_MULTIPLE,
|
|
8
|
-
|
|
9
|
-
LONG_TIER_INTERVAL_SEC,
|
|
10
|
+
POLL_INTERVAL_SEC,
|
|
10
11
|
createBackoff,
|
|
11
|
-
tierIntervalSec,
|
|
12
12
|
lastQuerySecOf,
|
|
13
13
|
isDue,
|
|
14
|
-
isShortWindowTier,
|
|
15
14
|
runLimited,
|
|
16
15
|
} from '../src/poller.mjs'
|
|
17
16
|
|
|
@@ -45,11 +44,8 @@ test('场景: 退避期间被跳过,到期放行', () => {
|
|
|
45
44
|
assert.equal(backoff.isBlocked(600), false)
|
|
46
45
|
})
|
|
47
46
|
|
|
48
|
-
test('场景:
|
|
49
|
-
assert.equal(
|
|
50
|
-
assert.equal(LONG_TIER_INTERVAL_SEC, 60 * 60)
|
|
51
|
-
assert.equal(tierIntervalSec(true), SHORT_TIER_INTERVAL_SEC)
|
|
52
|
-
assert.equal(tierIntervalSec(false), LONG_TIER_INTERVAL_SEC)
|
|
47
|
+
test('场景: 统一查询间隔 10 分钟,余额类账号与短窗账号同节奏', () => {
|
|
48
|
+
assert.equal(POLL_INTERVAL_SEC, 10 * 60)
|
|
53
49
|
})
|
|
54
50
|
|
|
55
51
|
test('场景: 上次尝试查询时刻换算,缺失或非法回 null 视为立即到点', () => {
|
|
@@ -65,29 +61,19 @@ test('场景: 时间驱动到点判定', () => {
|
|
|
65
61
|
assert.equal(isDue({ lastQuerySec: null, nowSec: 1000, intervalSec: 600 }), true, '从未查询立即到点')
|
|
66
62
|
assert.equal(isDue({ lastQuerySec: 500, nowSec: 1099, intervalSec: 600 }), false, '未满间隔')
|
|
67
63
|
assert.equal(isDue({ lastQuerySec: 500, nowSec: 1100, intervalSec: 600 }), true, '恰满间隔到点')
|
|
68
|
-
assert.equal(isDue({ lastQuerySec: 0, nowSec:
|
|
64
|
+
assert.equal(isDue({ lastQuerySec: 0, nowSec: POLL_INTERVAL_SEC, intervalSec: POLL_INTERVAL_SEC }), true)
|
|
69
65
|
})
|
|
70
66
|
|
|
71
67
|
test('场景: 退避与到点独立叠加,双过才查(时序推演锁定)', () => {
|
|
72
|
-
//
|
|
73
|
-
const backoff = createBackoff({ baseSec:
|
|
68
|
+
// 失败:退避基期 = 统一间隔;首次失败后一个周期内到点亦被退避压住
|
|
69
|
+
const backoff = createBackoff({ baseSec: POLL_INTERVAL_SEC })
|
|
74
70
|
backoff.onFailure(1000)
|
|
75
71
|
const dueAt = (lastQuerySec, nowSec) =>
|
|
76
|
-
!backoff.isBlocked(nowSec) && isDue({ lastQuerySec, nowSec, intervalSec:
|
|
72
|
+
!backoff.isBlocked(nowSec) && isDue({ lastQuerySec, nowSec, intervalSec: POLL_INTERVAL_SEC })
|
|
77
73
|
assert.equal(dueAt(400, 1500), false, '退避未过(到 1600 才解),尽管 1000 已到点')
|
|
78
74
|
assert.equal(dueAt(400, 1600), true, '退避与到点双过')
|
|
79
75
|
})
|
|
80
76
|
|
|
81
|
-
test('场景: 短窗口档判定,从未成功(含最近失败)按短档,失败不塌缩到长档', () => {
|
|
82
|
-
const readingHasShort = true
|
|
83
|
-
assert.equal(isShortWindowTier(null, readingHasShort), true, 'last 为空视为短窗口档')
|
|
84
|
-
assert.equal(isShortWindowTier(null, false), true, 'last 为空即使无短窗口读数也首轮即查')
|
|
85
|
-
assert.equal(isShortWindowTier({ ok: true, reading: {} }, readingHasShort), true)
|
|
86
|
-
assert.equal(isShortWindowTier({ ok: true, reading: {} }, false), false, '成功且仅长窗口按长档')
|
|
87
|
-
assert.equal(isShortWindowTier({ ok: false, reading: null }, readingHasShort), true, '失败不塌缩:含短窗账号保持短档节奏')
|
|
88
|
-
assert.equal(isShortWindowTier({ ok: false, reading: null }, false), true, '失败的长窗账号也归短档:10 分钟节奏 + 退避压制重试')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
77
|
test('场景: 受限并发执行——全项被处理,慢任务不阻塞其他 worker', async () => {
|
|
92
78
|
// Given 6 项任务,并发 2:每个任务挂起直到放行;并发内慢任务(后放行)只占 1 个 worker 位
|
|
93
79
|
const processed = []
|