@mzzsfy/dsh-usage-panel 0.4.3

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/src/notify.mjs ADDED
@@ -0,0 +1,424 @@
1
+ // 通知纯逻辑层:规则合并 / 沿触发评估 / 投影 / 认领 / 配置校验。
2
+ // 无 IO 与 npm 依赖,host 半区与单测共用;时间经参数注入。
3
+
4
+ export const DEFAULT_QUOTA_THRESHOLD_PCT = 90
5
+ export const PROJECTION_CAPACITY = 20
6
+ export const PROJECTION_TTL_MS = 60 * 1000
7
+ export const CLAIM_LOCK_TTL_MS = 30 * 1000
8
+ export const WEBHOOK_TIMEOUT_MS = 10 * 1000
9
+
10
+ export const KIND_QUOTA = 'quota'
11
+ export const KIND_BALANCE = 'balance'
12
+ export const KIND_RESET = 'reset'
13
+ // 全局通知默认值:默认关闭,余额阈值未配置即不评估。
14
+ export function defaultNotifySettings() {
15
+ return {
16
+ enabled: false,
17
+ quotaThresholdPct: DEFAULT_QUOTA_THRESHOLD_PCT,
18
+ balanceThreshold: null,
19
+ resetNotice: true,
20
+ toast: true,
21
+ webhookUrl: '',
22
+ imTargets: [],
23
+ }
24
+ }
25
+
26
+ // 账号可覆盖的字段:通道与总开关全局统一,账号仅覆盖规则本体。
27
+ const ACCOUNT_OVERRIDE_KEYS = ['quotaThresholdPct', 'balanceThreshold', 'resetNotice']
28
+
29
+ // 字段级合并:账号 notify 仅覆盖其设置的键,其余继承全局。
30
+ export function mergeAccountOverride(globalSettings, accountNotify) {
31
+ const source = accountNotify !== null && typeof accountNotify === 'object' ? accountNotify : {}
32
+ const merged = {}
33
+ for (const key of ACCOUNT_OVERRIDE_KEYS) merged[key] = globalSettings[key]
34
+ for (const key of ACCOUNT_OVERRIDE_KEYS) {
35
+ if (source[key] !== undefined) merged[key] = source[key]
36
+ }
37
+ return merged
38
+ }
39
+
40
+ // 账号沿触发状态:窗口按 label 建基线,余额默认武装;plain object 便于 JSON 持久化。
41
+ export function createNotifyState() {
42
+ return { windows: {}, balanceArmed: true }
43
+ }
44
+
45
+ // 沿触发状态读侧归一:形态异常回新状态,窗口条目仅保留三字段防脏数据扩散。
46
+ export function normalizeNotifyState(raw) {
47
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return createNotifyState()
48
+ const state = createNotifyState()
49
+ state.balanceArmed = raw.balanceArmed !== false
50
+ const windows = typeof raw.windows === 'object' && raw.windows !== null && !Array.isArray(raw.windows) ? raw.windows : {}
51
+ for (const label of Object.keys(windows)) {
52
+ const entry = windows[label]
53
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
54
+ state.windows[label] = {
55
+ resetsAt: typeof entry.resetsAt === 'string' ? entry.resetsAt : null,
56
+ // peak=null 是"无峰值"语义,不得经 Number(null) 伪装成 0
57
+ peak: entry.peak === null || entry.peak === undefined ? null : (Number.isFinite(Number(entry.peak)) ? Number(entry.peak) : null),
58
+ armed: entry.armed !== false,
59
+ }
60
+ }
61
+ return state
62
+ }
63
+
64
+ const PERCENT_BASE = 100
65
+
66
+ // 沿触发评估:刷新读数越过逻辑点(阈值穿越/窗口重置)时产出事件,返回新状态不改入参。
67
+ // 仅在 last.ok 时由 host 调用;reading 缺失按无读数处理。
68
+ export function evaluateAccount({ account, rule, state, seq, ts }) {
69
+ const events = []
70
+ const reading = account.last && account.last.reading ? account.last.reading : null
71
+ const next = createNotifyState()
72
+ next.balanceArmed = state.balanceArmed !== false
73
+ if (reading === null) return { events, state: next }
74
+ if (reading.kind === 'quota') evaluateQuota({ account, rule, state, reading, events, next })
75
+ if (reading.kind === 'balance') evaluateBalance({ account, rule, state, reading, events, next })
76
+ return { events: events.map((event, index) => buildNotifyEvent(event, seq + index, ts)), state: next }
77
+ }
78
+
79
+ // 窗口评估顺序:重置检测(重建基线并 re-arm)-> 峰值更新 -> 阈值判断。
80
+ // 基线以旧状态为底合并:上游偶发少返回某窗口时保留其基线,重现不误判新窗口。
81
+ function evaluateQuota({ account, rule, state, reading, events, next }) {
82
+ next.windows = { ...state.windows }
83
+ for (const window of reading.windows || []) {
84
+ const label = String(window.label || '')
85
+ if (label.length === 0) continue
86
+ const utilization = Number(window.utilization)
87
+ const resetsAt = typeof window.resetsAt === 'string' ? window.resetsAt : null
88
+ const prev = state.windows[label] || null
89
+ let armed = prev === null ? true : prev.armed !== false
90
+ let peak = prev === null ? null : prev.peak
91
+ // 窗口轮转:两侧 resetsAt 均有效且值不同;上一窗口峰值随 reset 事件上报
92
+ if (prev !== null && resetsAt !== null && prev.resetsAt !== null && prev.resetsAt !== resetsAt) {
93
+ if (rule.resetNotice !== false && prev.peak !== null) {
94
+ events.push({
95
+ kind: KIND_RESET,
96
+ accountId: account.id,
97
+ accountName: account.name,
98
+ label,
99
+ detail: { peak: prev.peak },
100
+ text: '[dsh] ' + account.name + ' ' + label + '窗口已重置,上一窗口峰值用量 ' + Math.round(prev.peak) + '%',
101
+ })
102
+ }
103
+ armed = true
104
+ peak = null
105
+ }
106
+ if (Number.isFinite(utilization)) {
107
+ peak = peak === null ? utilization : Math.max(peak, utilization)
108
+ if (utilization >= rule.quotaThresholdPct && armed) {
109
+ armed = false
110
+ events.push({
111
+ kind: KIND_QUOTA,
112
+ accountId: account.id,
113
+ accountName: account.name,
114
+ label,
115
+ detail: { value: utilization, threshold: rule.quotaThresholdPct },
116
+ text: '[dsh] ' + account.name + ' ' + label + '窗口用量达 ' + Math.round(utilization) + '%(阈值 ' + rule.quotaThresholdPct + '%)',
117
+ })
118
+ }
119
+ }
120
+ next.windows[label] = { resetsAt, peak, armed }
121
+ }
122
+ }
123
+
124
+ // 可用余额口径:remaining 优先,缺失(null/undefined 同视)回落 total
125
+ // (deepseek 无 remaining,total 即余额);null 不得经 Number() 伪装成 0。
126
+ function availableOf(entry) {
127
+ const remaining = entry.remaining === null || entry.remaining === undefined ? NaN : Number(entry.remaining)
128
+ if (Number.isFinite(remaining)) return remaining
129
+ const total = entry.total === null || entry.total === undefined ? NaN : Number(entry.total)
130
+ return Number.isFinite(total) ? total : null
131
+ }
132
+
133
+ // 余额评估:首个 entry 口径,下穿触发解除武装,回升到阈值上方恢复武装。
134
+ function evaluateBalance({ account, rule, state, reading, events, next }) {
135
+ const entry = (reading.entries || []).find((item) => availableOf(item) !== null)
136
+ if (entry === undefined || rule.balanceThreshold === null || rule.balanceThreshold === undefined) return
137
+ const available = availableOf(entry)
138
+ const threshold = Number(rule.balanceThreshold)
139
+ let armed = state.balanceArmed !== false
140
+ if (armed && available <= threshold) {
141
+ armed = false
142
+ const currency = String(entry.currency || '')
143
+ events.push({
144
+ kind: KIND_BALANCE,
145
+ accountId: account.id,
146
+ accountName: account.name,
147
+ label: null,
148
+ detail: { value: available, threshold, currency },
149
+ text: '[dsh] ' + account.name + ' 余额 ' + available + ' ' + currency + ',低于阈值 ' + threshold + ' ' + currency,
150
+ })
151
+ } else if (available > threshold) {
152
+ armed = true
153
+ }
154
+ next.balanceArmed = armed
155
+ }
156
+
157
+ // 事件构造唯一入口:评估产出与测试事件共用,防事件形态平行漂移。
158
+ export function buildNotifyEvent(event, seq, ts) {
159
+ return { ...event, id: 'un-' + ts.toString(36) + '-' + String(seq) + '-' + event.kind, ts }
160
+ }
161
+
162
+ // ---- IM 目标与配置校验 ----
163
+
164
+ // ID 规格与 dsh-im delivery-service 的 BOT_ID/TARGET_ID 一致,写侧拦截防落库后投递静默失败。
165
+ const IM_TARGETS_MAX = 16
166
+ const IM_BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
167
+ const IM_TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/
168
+
169
+ // imTargets 读侧归一化:非数组回空,剔除形态非法项,仅保留两字段。
170
+ export function normalizeImTargets(raw) {
171
+ if (!Array.isArray(raw)) return []
172
+ return raw
173
+ .filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item)
174
+ && typeof item.botId === 'string' && item.botId.length > 0
175
+ && typeof item.targetId === 'string' && item.targetId.length > 0)
176
+ .map(({ botId, targetId }) => ({ botId, targetId }))
177
+ }
178
+
179
+ // 目标列表操作:与 client.js LOGIC 段同形,parity 测试锁定不漂移。
180
+ // botId/targetId 字符集均不含 '/',拼接键无歧义;与 dsh-im delivery-service 共用 ID 规格。
181
+ export function imTargetKey(item) {
182
+ return item.botId + '/' + item.targetId
183
+ }
184
+
185
+ // 勾选幂等:同一 botId+targetId 只保留一份;勾选追加到尾部,取消即移除。
186
+ export function toggleImTargetList(list, botId, targetId, checked) {
187
+ const wanted = { botId, targetId }
188
+ const rest = list.filter((item) => imTargetKey(item) !== imTargetKey(wanted))
189
+ return checked ? rest.concat([wanted]) : rest
190
+ }
191
+
192
+ export function removeImTargetFromList(list, botId, targetId) {
193
+ return list.filter((item) => imTargetKey(item) !== botId + '/' + targetId)
194
+ }
195
+
196
+ // 取消注册:移除该 bot 全部目标。
197
+ export function unregisterImBotList(list, botId) {
198
+ return list.filter((item) => item.botId !== botId)
199
+ }
200
+
201
+ // 已绑 bot:按首次绑定顺序去重。
202
+ export function imBoundBotIds(list) {
203
+ const botIds = []
204
+ for (const item of list) {
205
+ if (!botIds.includes(item.botId)) botIds.push(item.botId)
206
+ }
207
+ return botIds
208
+ }
209
+
210
+ const WEBHOOK_SCHEMES = ['https:', 'http:']
211
+
212
+ function isValidWebhookUrl(value) {
213
+ if (typeof value !== 'string') return false
214
+ const trimmed = value.trim()
215
+ if (trimmed.length === 0) return true
216
+ try {
217
+ return WEBHOOK_SCHEMES.indexOf(new URL(trimmed).protocol) >= 0
218
+ } catch {
219
+ return false
220
+ }
221
+ }
222
+
223
+ // 全局通知配置写侧校验:顶层键白名单,值域内即归一通过。
224
+ export function validateNotifyPatch(patch) {
225
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
226
+ const known = ['enabled', 'quotaThresholdPct', 'balanceThreshold', 'resetNotice', 'toast', 'webhookUrl', 'imTargets']
227
+ for (const key of Object.keys(patch)) {
228
+ if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
229
+ }
230
+ const next = {}
231
+ if ('enabled' in patch) {
232
+ if (typeof patch.enabled !== 'boolean') return { ok: false, reason: 'enabled 须为布尔' }
233
+ next.enabled = patch.enabled
234
+ }
235
+ if ('quotaThresholdPct' in patch) {
236
+ const pct = patch.quotaThresholdPct
237
+ if (typeof pct !== 'number' || !Number.isFinite(pct) || pct <= 0 || pct > PERCENT_BASE) return { ok: false, reason: 'quotaThresholdPct 须为 (0,100] 内数值' }
238
+ next.quotaThresholdPct = pct
239
+ }
240
+ if ('balanceThreshold' in patch) {
241
+ const value = patch.balanceThreshold
242
+ if (value !== null && (typeof value !== 'number' || !Number.isFinite(value) || value < 0)) return { ok: false, reason: 'balanceThreshold 须为非负数值或 null' }
243
+ next.balanceThreshold = value
244
+ }
245
+ if ('resetNotice' in patch) {
246
+ if (typeof patch.resetNotice !== 'boolean') return { ok: false, reason: 'resetNotice 须为布尔' }
247
+ next.resetNotice = patch.resetNotice
248
+ }
249
+ if ('toast' in patch) {
250
+ if (typeof patch.toast !== 'boolean') return { ok: false, reason: 'toast 须为布尔' }
251
+ next.toast = patch.toast
252
+ }
253
+ if ('webhookUrl' in patch) {
254
+ if (!isValidWebhookUrl(patch.webhookUrl)) return { ok: false, reason: 'webhookUrl 须为 http(s) URL 或空串' }
255
+ next.webhookUrl = String(patch.webhookUrl).trim()
256
+ }
257
+ if ('imTargets' in patch) {
258
+ const list = patch.imTargets
259
+ if (!Array.isArray(list)) return { ok: false, reason: 'imTargets 须为数组' }
260
+ if (list.length > IM_TARGETS_MAX) return { ok: false, reason: 'imTargets 超过上限' }
261
+ const seen = new Set()
262
+ const targets = []
263
+ for (const item of list) {
264
+ const normalized = normalizeImTargets([item])[0]
265
+ if (normalized === undefined) return { ok: false, reason: 'imTargets 项须为含 botId 与 targetId 的对象' }
266
+ // 字符集规格与 dsh-im delivery-service 一致:落库前拦截,防投递静默失败
267
+ if (!IM_BOT_ID_PATTERN.test(normalized.botId)) return { ok: false, reason: 'botId 格式非法' }
268
+ if (!IM_TARGET_ID_PATTERN.test(normalized.targetId)) return { ok: false, reason: 'targetId 格式非法' }
269
+ const key = normalized.botId + '/' + normalized.targetId
270
+ if (seen.has(key)) return { ok: false, reason: 'imTargets 存在重复项' }
271
+ seen.add(key)
272
+ targets.push(normalized)
273
+ }
274
+ next.imTargets = targets
275
+ }
276
+ return { ok: true, patch: next }
277
+ }
278
+
279
+ // 账号 notify 覆盖归一:仅保留三字段中值域合法的键,值域与全局校验一致。
280
+ export function normalizeAccountNotify(raw) {
281
+ const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
282
+ const result = {}
283
+ if ('quotaThresholdPct' in source) {
284
+ const pct = source.quotaThresholdPct
285
+ if (typeof pct === 'number' && Number.isFinite(pct) && pct > 0 && pct <= PERCENT_BASE) result.quotaThresholdPct = pct
286
+ }
287
+ if ('balanceThreshold' in source) {
288
+ const value = source.balanceThreshold
289
+ if (value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0)) result.balanceThreshold = value
290
+ }
291
+ if ('resetNotice' in source && typeof source.resetNotice === 'boolean') result.resetNotice = source.resetNotice
292
+ return result
293
+ }
294
+
295
+ // ---- 投影 / 认领 / webhook(turn-notify 同构语义) ----
296
+
297
+ // 通知投影:环形容量 + 读取时过期清理;now 注入便于测试。
298
+ // version 单调递增支撑长轮询:push 与 bump 各递增一次并唤醒全部等待者,
299
+ // wait 在无更新版本时挂起至唤醒或超时,超时与 dispose 均以当前版本收尾不报错。
300
+ export function createProjection({ capacity = PROJECTION_CAPACITY, ttlMs = PROJECTION_TTL_MS, now = Date.now }) {
301
+ const ring = []
302
+ let version = 0
303
+ const waiters = new Set()
304
+ const settle = (waiter) => {
305
+ clearTimeout(waiter.timer)
306
+ waiters.delete(waiter)
307
+ waiter.resolve(version)
308
+ }
309
+ const wakeAll = () => { for (const waiter of [...waiters]) settle(waiter) }
310
+ return {
311
+ push(unit) {
312
+ const current = now()
313
+ while (ring.length > 0 && current - ring[0].ts > ttlMs) ring.shift()
314
+ ring.push(unit)
315
+ while (ring.length > capacity) ring.shift()
316
+ version += 1
317
+ wakeAll()
318
+ },
319
+ bump() {
320
+ version += 1
321
+ wakeAll()
322
+ },
323
+ version: () => version,
324
+ wait(cursor, timeoutMs) {
325
+ if (version > cursor) return Promise.resolve(version)
326
+ return new Promise((resolve) => {
327
+ const waiter = { resolve, timer: null }
328
+ waiter.timer = setTimeout(() => settle(waiter), timeoutMs)
329
+ waiters.add(waiter)
330
+ })
331
+ },
332
+ dispose: wakeAll,
333
+ list() {
334
+ const current = now()
335
+ while (ring.length > 0 && current - ring[0].ts > ttlMs) ring.shift()
336
+ return ring.slice()
337
+ },
338
+ }
339
+ }
340
+
341
+ // 认领决策(读阶段):done 标记终态只补已读;有效他锁跳过;过期锁接管;无锁认领。
342
+ // 值非法视为无锁接管;锁归属自己视为继续(重试补完成标记)。
343
+ export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOCK_TTL_MS }) {
344
+ if (done !== null && done !== undefined) return 'done'
345
+ if (stored === null || stored === undefined) return 'claim'
346
+ let lock = null
347
+ try {
348
+ lock = JSON.parse(stored)
349
+ } catch {
350
+ lock = null
351
+ }
352
+ if (lock === null || typeof lock !== 'object' || typeof lock.at !== 'number' || typeof lock.wid !== 'string') return 'takeover'
353
+ if (now - lock.at >= lockTtlMs) return 'takeover'
354
+ return lock.wid === windowId ? 'claim' : 'skip'
355
+ }
356
+
357
+ // 通知事件字段到 webhook 结构化字段的一比一映射(text 随行)。
358
+ export function buildWebhookPayload(unit) {
359
+ return {
360
+ text: unit.text,
361
+ event: unit.id,
362
+ kind: unit.kind,
363
+ account: unit.accountName,
364
+ accountId: unit.accountId,
365
+ label: unit.label,
366
+ detail: unit.detail,
367
+ ts: unit.ts,
368
+ }
369
+ }
370
+
371
+ // webhook 直发:未配置跳过;任何失败不抛出(fire-and-forget,不重试)。
372
+ // 返回真实投递结果供测试按钮呈现,真实通知路径以 void 忽略。
373
+ export async function sendWebhook({ url, payload, fetchImpl = fetch }) {
374
+ if (typeof url !== 'string' || url.trim().length === 0) return { ok: false, detail: '未配置 webhook' }
375
+ try {
376
+ const response = await fetchImpl(url.trim(), {
377
+ method: 'POST',
378
+ headers: { 'content-type': 'application/json' },
379
+ body: JSON.stringify(payload),
380
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
381
+ })
382
+ return { ok: response.ok, detail: 'HTTP ' + response.status }
383
+ } catch (error) {
384
+ return { ok: false, detail: error && error.message ? error.message : String(error) }
385
+ }
386
+ }
387
+
388
+ // settings 读数归一:字段类型异常回退默认值,读侧宽松与写侧校验宽松度一致。
389
+ export function resolvedNotifySettings(raw) {
390
+ const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
391
+ const fallback = defaultNotifySettings()
392
+ const bool = (value, fallbackValue) => (typeof value === 'boolean' ? value : fallbackValue)
393
+ const pct = Number(source.quotaThresholdPct)
394
+ const balance = source.balanceThreshold === null || source.balanceThreshold === undefined
395
+ ? null
396
+ : Number(source.balanceThreshold)
397
+ return {
398
+ enabled: bool(source.enabled, fallback.enabled),
399
+ quotaThresholdPct: Number.isFinite(pct) && pct > 0 && pct <= PERCENT_BASE ? pct : fallback.quotaThresholdPct,
400
+ balanceThreshold: Number.isFinite(balance) && balance >= 0 ? balance : null,
401
+ resetNotice: bool(source.resetNotice, fallback.resetNotice),
402
+ toast: bool(source.toast, fallback.toast),
403
+ webhookUrl: typeof source.webhookUrl === 'string' ? source.webhookUrl : '',
404
+ imTargets: normalizeImTargets(source.imTargets),
405
+ }
406
+ }
407
+
408
+ // 面板可见配置:webhookUrl 属凭据不出主机,仅回是否已配置。
409
+ export function publicNotify(resolved) {
410
+ return {
411
+ enabled: resolved.enabled,
412
+ quotaThresholdPct: resolved.quotaThresholdPct,
413
+ balanceThreshold: resolved.balanceThreshold,
414
+ resetNotice: resolved.resetNotice,
415
+ toast: resolved.toast,
416
+ imTargets: resolved.imTargets,
417
+ webhookConfigured: resolved.webhookUrl.trim().length > 0,
418
+ }
419
+ }
420
+
421
+ // botId 写侧校验:与 dsh-im delivery-service 的 BOT_ID 规格一致。
422
+ export function isValidImBotId(value) {
423
+ return typeof value === 'string' && IM_BOT_ID_PATTERN.test(value)
424
+ }