@mzzsfy/dsh-usage-panel 0.4.5 → 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 +4 -4
- package/package.json +1 -1
- package/src/client.js +23 -11
- package/src/index.js +15 -26
- package/src/notify.mjs +2 -2
- package/src/poller.mjs +7 -20
- package/test/notify.route.test.mjs +8 -7
- package/test/notify.test.mjs +4 -4
- 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 被空配置覆盖),恢复后解除
|
|
@@ -73,7 +73,7 @@ usage-panel:
|
|
|
73
73
|
balanceThreshold: null # 余额阈值,null 为不启用
|
|
74
74
|
resetNotice: true # 窗口重置时通知上一窗口峰值
|
|
75
75
|
toast: true # 页内 toast 通道
|
|
76
|
-
webhookUrl: '' #
|
|
76
|
+
webhookUrl: '' # webhook 目标 URL,留空禁用;面板原文回显
|
|
77
77
|
imTargets: [] # dsh-im 投递目标 [{botId, targetId}]
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -88,7 +88,7 @@ usage-panel:
|
|
|
88
88
|
### 通知接口
|
|
89
89
|
|
|
90
90
|
- `GET /api/usage-panel/notifications`:通知投影长轮询;`cursor` 缺省=首拉立即返回全量,`cursor` 等于当前版本时挂起至事件或超时,落后或超前(宿主重启版本回退)立即返回全量,客户端以响应 version 重置游标自愈;响应 `{units, version}`
|
|
91
|
-
- `GET|POST /api/usage-panel/notify-config`:全局通知规则;`webhookUrl`
|
|
91
|
+
- `GET|POST /api/usage-panel/notify-config`:全局通知规则;`webhookUrl` 原文随响应回显
|
|
92
92
|
- `POST /api/usage-panel/test-webhook` / `POST /api/usage-panel/test-im`:测试投递,返回真实结果
|
|
93
93
|
- `GET /api/usage-panel/im-targets?botId=`:列出 dsh-im 该 bot 已保存投递目标
|
|
94
94
|
|
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/client.js
CHANGED
|
@@ -992,6 +992,7 @@ function NotifyConfigCard() {
|
|
|
992
992
|
.then((res) => {
|
|
993
993
|
if (!alive) return
|
|
994
994
|
setConfig(res && res.notify ? res.notify : {})
|
|
995
|
+
setWebhookUrl(res && res.notify && typeof res.notify.webhookUrl === 'string' ? res.notify.webhookUrl : '')
|
|
995
996
|
setImAvailable(res ? res.imAvailable === true : false)
|
|
996
997
|
})
|
|
997
998
|
.catch((err) => { if (alive) setError('读取通知配置失败:' + (err && err.message ? err.message : String(err))) })
|
|
@@ -1097,6 +1098,9 @@ function NotifyConfigCard() {
|
|
|
1097
1098
|
}
|
|
1098
1099
|
const targets = Array.isArray(config.imTargets) ? config.imTargets : []
|
|
1099
1100
|
const boundBots = imBoundBotIds(targets)
|
|
1101
|
+
// 已配置态与测试/保存分流均由 webhookUrl 派生;草稿编辑中不被其他 patch 回执重置
|
|
1102
|
+
const savedWebhookUrl = typeof config.webhookUrl === 'string' ? config.webhookUrl.trim() : ''
|
|
1103
|
+
const webhookDirty = webhookUrl.trim() !== savedWebhookUrl
|
|
1100
1104
|
// 通知配置一次即久,默认折叠:summary 摘要行常显状态,展开才是完整配置
|
|
1101
1105
|
return h('details', { className: 'up-card up-fold' },
|
|
1102
1106
|
h('summary', { className: 'up-fold__summary' },
|
|
@@ -1104,7 +1108,7 @@ function NotifyConfigCard() {
|
|
|
1104
1108
|
h('span', { className: 'up-dot' + (config.enabled === true ? '' : ' up-dot--off') }),
|
|
1105
1109
|
h('span', { className: 'up-meta' }, config.enabled === true ? '已启用' : '已关闭'),
|
|
1106
1110
|
targets.length > 0 ? h('span', { className: 'up-meta' }, targets.length + ' 个 IM 目标') : null,
|
|
1107
|
-
|
|
1111
|
+
savedWebhookUrl.length > 0 ? h('span', { className: 'up-meta' }, 'webhook 已配置') : null,
|
|
1108
1112
|
),
|
|
1109
1113
|
h('div', { className: 'up-card__row' },
|
|
1110
1114
|
h(Switch, {
|
|
@@ -1156,29 +1160,37 @@ function NotifyConfigCard() {
|
|
|
1156
1160
|
onChange: (checked) => patch({ toast: checked }),
|
|
1157
1161
|
}),
|
|
1158
1162
|
h('div', { className: 'up-card__row' },
|
|
1159
|
-
h('span', { className: 'up-field__label', title: 'host 直发的 Slack 兼容 JSON
|
|
1160
|
-
|
|
1163
|
+
h('span', { className: 'up-field__label', title: 'host 直发的 Slack 兼容 JSON 通知;URL 原文回显,改动保存后自动发送测试' }, 'Webhook'),
|
|
1164
|
+
savedWebhookUrl.length > 0 ? h('span', { className: 'up-dot', title: '已配置' }) : null,
|
|
1161
1165
|
h('span', { className: 'up-field' },
|
|
1162
1166
|
h('input', {
|
|
1163
|
-
type: '
|
|
1167
|
+
type: 'text', style: { width: '260px' },
|
|
1164
1168
|
value: webhookUrl,
|
|
1165
1169
|
onChange: (e) => setWebhookUrl(e.target.value),
|
|
1166
|
-
placeholder:
|
|
1170
|
+
placeholder: 'https://hooks.example.com/…',
|
|
1167
1171
|
})),
|
|
1168
1172
|
h('button', {
|
|
1169
1173
|
className: 'up-btn',
|
|
1170
1174
|
onClick: () => {
|
|
1171
|
-
if (
|
|
1172
|
-
if (
|
|
1175
|
+
if (!webhookDirty) {
|
|
1176
|
+
if (savedWebhookUrl.length === 0) { notify('请先填写 webhook URL', 'error'); return }
|
|
1173
1177
|
testWebhook()
|
|
1174
1178
|
return
|
|
1175
1179
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1180
|
+
// 草稿与已保存值不同即保存(含清空=禁用);清空保存后通道关闭,不再自动测试误导报错
|
|
1181
|
+
const disabling = webhookUrl.trim().length === 0
|
|
1182
|
+
api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify({ webhookUrl: webhookUrl.trim() }) })
|
|
1183
|
+
.then((res) => {
|
|
1184
|
+
if (res && res.notify) {
|
|
1185
|
+
setConfig(res.notify)
|
|
1186
|
+
setWebhookUrl(typeof res.notify.webhookUrl === 'string' ? res.notify.webhookUrl : '')
|
|
1187
|
+
}
|
|
1188
|
+
notify(disabling ? 'webhook 已禁用' : '通知配置已保存', 'ok')
|
|
1189
|
+
if (!disabling) testWebhook()
|
|
1190
|
+
})
|
|
1179
1191
|
.catch(fail)
|
|
1180
1192
|
},
|
|
1181
|
-
},
|
|
1193
|
+
}, webhookDirty ? '保存并测试' : '测试'),
|
|
1182
1194
|
),
|
|
1183
1195
|
// IM 通道:目标来自 dsh-im 已保存目录,勾选即自动保存;新建与平台测试在 dsh-im 设置页完成
|
|
1184
1196
|
imAvailable ? null : h('span', { className: 'up-meta' }, 'dsh-im 未安装,IM 通道不可用'),
|
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/notify.mjs
CHANGED
|
@@ -405,7 +405,7 @@ export function resolvedNotifySettings(raw) {
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
-
// 面板可见配置:webhookUrl
|
|
408
|
+
// 面板可见配置:webhookUrl 原文随响应回显,面板所见即所存。
|
|
409
409
|
export function publicNotify(resolved) {
|
|
410
410
|
return {
|
|
411
411
|
enabled: resolved.enabled,
|
|
@@ -414,7 +414,7 @@ export function publicNotify(resolved) {
|
|
|
414
414
|
resetNotice: resolved.resetNotice,
|
|
415
415
|
toast: resolved.toast,
|
|
416
416
|
imTargets: resolved.imTargets,
|
|
417
|
-
|
|
417
|
+
webhookUrl: resolved.webhookUrl,
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
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
|
|
@@ -176,22 +176,22 @@ test('评估接线: custom 账号 extract 规则驱动 balance 读数全链路(
|
|
|
176
176
|
}
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
-
test('notify-config: GET
|
|
179
|
+
test('notify-config: GET 回显 webhookUrl 原文', async () => {
|
|
180
180
|
// Given 已配置 webhook 的 settings
|
|
181
181
|
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/private' } } })
|
|
182
182
|
apply(ctx)
|
|
183
183
|
// When 读取通知配置
|
|
184
184
|
const res = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
|
|
185
|
-
// Then
|
|
185
|
+
// Then 原文随响应回显, webhookConfigured 已移除
|
|
186
186
|
assert.equal(res.status, 200)
|
|
187
|
-
assert.equal(res.payload.notify.webhookUrl,
|
|
188
|
-
assert.equal(res.payload.notify
|
|
187
|
+
assert.equal(res.payload.notify.webhookUrl, 'https://hooks.example.com/private')
|
|
188
|
+
assert.equal('webhookConfigured' in res.payload.notify, false)
|
|
189
189
|
assert.equal(res.payload.imAvailable, false)
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
test('notify-config: 非法补丁 400, 合法补丁合并生效', async () => {
|
|
193
|
-
// Given 合法 ctx
|
|
194
|
-
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true } } })
|
|
193
|
+
// Given 合法 ctx,已配置 webhook
|
|
194
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/keep' } } })
|
|
195
195
|
apply(ctx)
|
|
196
196
|
// When 提交越界阈值
|
|
197
197
|
const bad = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { quotaThresholdPct: 0 }))
|
|
@@ -199,12 +199,13 @@ test('notify-config: 非法补丁 400, 合法补丁合并生效', async () => {
|
|
|
199
199
|
assert.equal(bad.status, 400)
|
|
200
200
|
// When 提交合法补丁(仅改阈值与余额阈值)
|
|
201
201
|
const good = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { quotaThresholdPct: 80, balanceThreshold: 20 }))
|
|
202
|
-
// Then 生效且未提供的键保持原值
|
|
202
|
+
// Then 生效且未提供的键保持原值(部分补丁不得清掉已存 webhook)
|
|
203
203
|
assert.equal(good.status, 200)
|
|
204
204
|
const view = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
|
|
205
205
|
assert.equal(view.payload.notify.quotaThresholdPct, 80)
|
|
206
206
|
assert.equal(view.payload.notify.balanceThreshold, 20)
|
|
207
207
|
assert.equal(view.payload.notify.enabled, true)
|
|
208
|
+
assert.equal(view.payload.notify.webhookUrl, 'https://hooks.example.com/keep')
|
|
208
209
|
})
|
|
209
210
|
|
|
210
211
|
test('test-webhook: 返回真实投递结果而非谎报成功', async () => {
|
package/test/notify.test.mjs
CHANGED
|
@@ -418,14 +418,14 @@ test('读侧归一: 残缺配置回退默认值', () => {
|
|
|
418
418
|
assert.equal(resolved.webhookUrl, '')
|
|
419
419
|
})
|
|
420
420
|
|
|
421
|
-
test('
|
|
421
|
+
test('面板回显: publicNotify 返回 webhookUrl 原文', () => {
|
|
422
422
|
// Given 已配置 webhook 的归一配置
|
|
423
423
|
const resolved = resolvedNotifySettings({ webhookUrl: 'https://hooks.example.com/private' })
|
|
424
424
|
// When 转面板可见形态
|
|
425
425
|
const view = publicNotify(resolved)
|
|
426
|
-
// Then
|
|
427
|
-
assert.equal(view.webhookUrl,
|
|
428
|
-
assert.equal(view
|
|
426
|
+
// Then 原文随响应回显, webhookConfigured 已移除
|
|
427
|
+
assert.equal(view.webhookUrl, 'https://hooks.example.com/private')
|
|
428
|
+
assert.equal('webhookConfigured' in view, false)
|
|
429
429
|
assert.equal(view.toast, true)
|
|
430
430
|
})
|
|
431
431
|
|
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 = []
|