@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/index.js ADDED
@@ -0,0 +1,777 @@
1
+ // 用量面板 Host 半区:多平台余额查询 + 定期轮询 + 历史快照落盘。webServer 路由供浏览器半区调用,
2
+ // fetch 直连平台 API,配置持久化在 ~/.dsh/dsh-usage-panel/accounts.json,快照在 history.json。
3
+
4
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
5
+ import { homedir } from 'node:os'
6
+ import { dirname, join } from 'node:path'
7
+ import z from '@deepseek-ai/schemastery'
8
+ import {
9
+ parseDeepSeek,
10
+ parseOpenRouter,
11
+ parseKimi,
12
+ parseZhipu,
13
+ parseMiniMax,
14
+ parseNewApi,
15
+ extractCustom,
16
+ } from './parsers.mjs'
17
+ import { readingToSnapshots, appendPoint, buildMonthSequence, newSequenceStore } from './history.mjs'
18
+ import { createHistoryStore } from './historyStore.mjs'
19
+ import { createBackoff, isShortWindowTier, tierIntervalSec, lastQuerySecOf, isDue } from './poller.mjs'
20
+ import {
21
+ DEFAULT_QUOTA_THRESHOLD_PCT,
22
+ WEBHOOK_TIMEOUT_MS,
23
+ buildNotifyEvent,
24
+ buildWebhookPayload,
25
+ createProjection,
26
+ createNotifyState,
27
+ evaluateAccount,
28
+ mergeAccountOverride,
29
+ normalizeAccountNotify,
30
+ normalizeImTargets,
31
+ normalizeNotifyState,
32
+ publicNotify,
33
+ resolvedNotifySettings,
34
+ sendWebhook,
35
+ validateNotifyPatch,
36
+ isValidImBotId,
37
+ } from './notify.mjs'
38
+
39
+ const FETCH_TIMEOUT_MS = 20 * 1000
40
+ const BODY_MAX_BYTES = 256 * 1024
41
+ const MAX_ACCOUNTS = 20
42
+ // 通知长轮询服务端挂起上限,与 turn-notify 同值;客户端超时须大于此值
43
+ const LONG_POLL_WAIT_MS = 25 * 1000
44
+ // 数据目录支持 env 注入(测试隔离);缺省落 ~/.dsh/dsh-usage-panel
45
+ const DATA_DIR = process.env.DSH_USAGE_PANEL_DATA_DIR || join(homedir(), '.dsh', 'dsh-usage-panel')
46
+ const DATA_FILE = join(DATA_DIR, 'accounts.json')
47
+ const HISTORY_FILE = join(DATA_DIR, 'history.json')
48
+ const BAK_SUFFIX = '.bak'
49
+ const ACCOUNTS_BROKEN_MESSAGE = '账号配置文件已损坏已备份,已暂停写入以防数据丢失'
50
+ const TICK_SEC = 30
51
+ const SHORT_SUFFIX = '5h'
52
+
53
+ const NAMESPACE = 'usage-panel'
54
+
55
+ const SETTINGS_SCHEMA = z.object({
56
+ notify: z.object({
57
+ enabled: z.boolean().default(false).description('通知总开关,默认关闭'),
58
+ quotaThresholdPct: z.number().default(DEFAULT_QUOTA_THRESHOLD_PCT).description('用量窗口阈值百分比'),
59
+ balanceThreshold: z.union([z.number(), z.const(null)]).default(null).description('余额阈值,null 为不启用'),
60
+ resetNotice: z.boolean().default(true).description('额度窗口重置时通知上一窗口峰值'),
61
+ toast: z.boolean().default(true).description('浏览器页内 toast 通道'),
62
+ webhookUrl: z.string().role('secret').default('').description('webhook 目标 URL(Slack-compatible {text}),留空禁用'),
63
+ imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 投递目标列表'),
64
+ }).default({}),
65
+ })
66
+
67
+ const TYPE_DEEPSEEK = 'deepseek'
68
+ const TYPE_OPENROUTER = 'openrouter'
69
+ const TYPE_KIMI = 'kimi'
70
+ const TYPE_ZHIPU = 'zhipu'
71
+ const TYPE_MINIMAX = 'minimax'
72
+ const TYPE_NEWAPI = 'newapi'
73
+ const TYPE_CUSTOM = 'custom'
74
+
75
+ const ACCOUNT_TYPES = [TYPE_DEEPSEEK, TYPE_OPENROUTER, TYPE_KIMI, TYPE_ZHIPU, TYPE_MINIMAX, TYPE_NEWAPI, TYPE_CUSTOM]
76
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
77
+
78
+ // 各预设平台的默认基址与余额接口路径。
79
+ const TYPE_META = {
80
+ [TYPE_DEEPSEEK]: { defaultBase: 'https://api.deepseek.com', path: '/user/balance', rawAuth: false },
81
+ [TYPE_OPENROUTER]: { defaultBase: 'https://openrouter.ai', path: '/api/v1/credits', rawAuth: false },
82
+ [TYPE_KIMI]: { defaultBase: 'https://api.kimi.com/coding', path: '/v1/usages', rawAuth: false, userAgent: 'KimiCLI/1.5' },
83
+ [TYPE_ZHIPU]: { defaultBase: 'https://open.bigmodel.cn', path: '/api/monitor/usage/quota/limit', rawAuth: true },
84
+ [TYPE_MINIMAX]: { defaultBase: 'https://api.minimaxi.com', path: '/v1/api/openplatform/coding_plan/remains', rawAuth: false },
85
+ [TYPE_NEWAPI]: { defaultBase: '', path: '/api/usage/token', rawAuth: false },
86
+ [TYPE_CUSTOM]: { defaultBase: '', path: '', rawAuth: false },
87
+ }
88
+
89
+ const PARSERS = {
90
+ [TYPE_DEEPSEEK]: parseDeepSeek,
91
+ [TYPE_OPENROUTER]: parseOpenRouter,
92
+ [TYPE_KIMI]: parseKimi,
93
+ [TYPE_ZHIPU]: parseZhipu,
94
+ [TYPE_MINIMAX]: parseMiniMax,
95
+ [TYPE_NEWAPI]: parseNewApi,
96
+ }
97
+
98
+ // 解析分发:custom 需要账号级 extract 规则,单参表无法表达,特化传参
99
+ function parseReading(account, body) {
100
+ return account.type === TYPE_CUSTOM
101
+ ? extractCustom(body, account.custom && account.custom.extract)
102
+ : PARSERS[account.type](body)
103
+ }
104
+
105
+ // ---- HTTP 工具 ----
106
+ // 访问控制交给 DSH web 鉴权层(非本机 Host 的请求必须携带凭据);
107
+ // 读路由响应不含明文 Key,读数不含敏感凭据;写路由统一经 guardedRoute
108
+ // (同源 + JSON 守卫),阻断跨站简单请求 drive-by 改写账号/配置或借测试通道外发(turn-notify 同构)。
109
+
110
+ function readBody(req) {
111
+ return new Promise((resolve, reject) => {
112
+ let size = 0
113
+ const chunks = []
114
+ req.on('data', (chunk) => {
115
+ size += chunk.length
116
+ if (size > BODY_MAX_BYTES) {
117
+ reject(new Error('请求体超过上限'))
118
+ req.destroy()
119
+ return
120
+ }
121
+ chunks.push(chunk)
122
+ })
123
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
124
+ req.on('error', reject)
125
+ })
126
+ }
127
+
128
+ function sendJson(res, status, payload) {
129
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
130
+ res.end(JSON.stringify(payload))
131
+ }
132
+
133
+ // 同源守卫:浏览器写请求恒带 Origin,与 Host 不符即拒;无 Origin 的非浏览器客户端放行。
134
+ function rejectCrossOrigin(req, res) {
135
+ const origin = req.headers ? req.headers.origin : undefined
136
+ if (!origin) return false
137
+ let sameOrigin = false
138
+ try {
139
+ sameOrigin = new URL(origin).host === req.headers.host
140
+ } catch {
141
+ sameOrigin = false
142
+ }
143
+ if (sameOrigin) return false
144
+ sendJson(res, 403, { error: '跨源请求被拒绝' })
145
+ return true
146
+ }
147
+
148
+ function rejectNonJson(req, res) {
149
+ const contentType = req.headers ? String(req.headers['content-type'] || '') : ''
150
+ if (contentType.indexOf('application/json') >= 0) return false
151
+ sendJson(res, 400, { error: 'content-type 须为 application/json' })
152
+ return true
153
+ }
154
+
155
+ // 路由样板:双方法变体放行 GET(读无 CSRF 面),其余 405;POST-only 变体供无读面的
156
+ // 写路由(测试通道/查询)使用——GET 放行会让跨站 <img src> 无守卫驱动外发;
157
+ // POST 加跨源/JSON 守卫;业务异常归一,handler 只留业务体。全部路由统一经此,防逐路由遗漏。
158
+ const guardedRoute = (handler) => async (req, res) => {
159
+ try {
160
+ if (req.method !== 'GET' && req.method !== 'POST') {
161
+ sendJson(res, 405, { error: 'method not allowed' })
162
+ return
163
+ }
164
+ if (req.method === 'POST') {
165
+ if (rejectCrossOrigin(req, res)) return
166
+ if (rejectNonJson(req, res)) return
167
+ }
168
+ await handler(req, res)
169
+ } catch (error) {
170
+ sendJson(res, 400, { error: error && error.message ? error.message : String(error) })
171
+ }
172
+ }
173
+ guardedRoute.post = (handler) => async (req, res) => {
174
+ if (req.method !== 'POST') {
175
+ sendJson(res, 405, { error: 'method not allowed' })
176
+ return
177
+ }
178
+ return guardedRoute(handler)(req, res)
179
+ }
180
+
181
+ // ---- 平台请求 ----
182
+
183
+ function isUsableUrl(value) {
184
+ return typeof value === 'string' && /^https?:\/\/\S+$/.test(value)
185
+ }
186
+
187
+ function stripUnsafeHeaderChars(value) {
188
+ return String(value).replace(/[\r\n]+/g, ' ')
189
+ }
190
+
191
+ function buildRequest(account) {
192
+ const meta = TYPE_META[account.type]
193
+ if (account.type === TYPE_CUSTOM) {
194
+ const custom = account.custom || {}
195
+ if (!isUsableUrl(custom.url)) throw new Error('自定义端点 URL 无效(需 http/https 且无空白)')
196
+ const method = HTTP_METHODS.indexOf(String(custom.method || 'GET').toUpperCase()) >= 0
197
+ ? String(custom.method).toUpperCase()
198
+ : 'GET'
199
+ const headers = {}
200
+ const rawHeaders = custom.headers && typeof custom.headers === 'object' ? custom.headers : {}
201
+ for (const name of Object.keys(rawHeaders)) {
202
+ if (typeof rawHeaders[name] !== 'string') continue
203
+ headers[stripUnsafeHeaderChars(name)] = stripUnsafeHeaderChars(rawHeaders[name])
204
+ }
205
+ return {
206
+ url: custom.url,
207
+ method,
208
+ headers,
209
+ body: method !== 'GET' && method !== 'HEAD' && typeof custom.body === 'string' && custom.body.length > 0 ? custom.body : undefined,
210
+ }
211
+ }
212
+ const base = (account.baseUrl || '').trim() || meta.defaultBase
213
+ if (base.length === 0) throw new Error('该平台需要填写 API 基础地址')
214
+ if (!isUsableUrl(base)) throw new Error('API 基础地址无效')
215
+ const key = (account.apiKey || '').trim()
216
+ if (key.length === 0) throw new Error('未配置 API Key')
217
+ const headers = { Authorization: (meta.rawAuth ? '' : 'Bearer ') + key, Accept: 'application/json' }
218
+ if (meta.userAgent) headers['User-Agent'] = meta.userAgent
219
+ return { url: base.replace(/\/+$/, '') + meta.path, method: 'GET', headers, body: undefined }
220
+ }
221
+
222
+ async function performRequest(request) {
223
+ const response = await fetch(request.url, {
224
+ method: request.method,
225
+ headers: request.headers,
226
+ body: request.body,
227
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
228
+ })
229
+ const text = await response.text()
230
+ if (!response.ok) {
231
+ throw new Error('HTTP ' + response.status + (text ? ': ' + text.slice(0, 200) : ''))
232
+ }
233
+ return text
234
+ }
235
+
236
+ // ---- 配置存取 ----
237
+
238
+ function defaultConfig() {
239
+ return { version: 1, accounts: [] }
240
+ }
241
+
242
+ function requireOk(condition, message) {
243
+ if (!condition) throw new Error(message)
244
+ }
245
+
246
+ function normalizeAccounts(input) {
247
+ requireOk(Array.isArray(input), 'accounts 必须是数组')
248
+ requireOk(input.length <= MAX_ACCOUNTS, '账号数量超过上限 ' + MAX_ACCOUNTS)
249
+ return input.map((raw, index) => {
250
+ requireOk(raw !== null && typeof raw === 'object', '第 ' + (index + 1) + ' 个账号格式非法')
251
+ const type = String(raw.type || '')
252
+ requireOk(ACCOUNT_TYPES.indexOf(type) >= 0, '未知平台类型: ' + type)
253
+ const customSource = raw.custom && typeof raw.custom === 'object' ? raw.custom : {}
254
+ const headers = {}
255
+ if (customSource.headers && typeof customSource.headers === 'object') {
256
+ for (const name of Object.keys(customSource.headers)) {
257
+ if (typeof customSource.headers[name] === 'string') headers[name] = customSource.headers[name]
258
+ }
259
+ }
260
+ const lastSource = raw.last && typeof raw.last === 'object' ? raw.last : null
261
+ const last = lastSource && typeof lastSource.ok === 'boolean'
262
+ ? {
263
+ ok: lastSource.ok,
264
+ // 无 kind 判别字段的旧形态读数(v1 custom)单点清洗为 null,渲染回落空态,首轮刷新自然归一
265
+ reading: lastSource.reading !== null && lastSource.reading !== undefined && typeof lastSource.reading === 'object' &&
266
+ (lastSource.reading.kind === 'quota' || lastSource.reading.kind === 'balance')
267
+ ? lastSource.reading
268
+ : null,
269
+ error: typeof lastSource.error === 'string' ? lastSource.error : null,
270
+ queriedAt: Number.isFinite(Number(lastSource.queriedAt)) ? Number(lastSource.queriedAt) : null,
271
+ }
272
+ : null
273
+ const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : 'acct-' + String(index)
274
+ // 序列键以「id:后缀」拼接,id 含冒号会破坏键解析与孤儿清理的账号段切分
275
+ requireOk(id.indexOf(':') < 0, '账号 id 不得包含冒号: ' + id)
276
+ return {
277
+ id,
278
+ name: typeof raw.name === 'string' && raw.name.trim().length > 0 ? raw.name.trim() : type,
279
+ type,
280
+ baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : '',
281
+ apiKey: typeof raw.apiKey === 'string' ? raw.apiKey.trim() : '',
282
+ custom: {
283
+ url: typeof customSource.url === 'string' ? customSource.url.trim() : '',
284
+ method: typeof customSource.method === 'string' ? customSource.method.toUpperCase() : 'GET',
285
+ headers,
286
+ body: typeof customSource.body === 'string' ? customSource.body : '',
287
+ extract: customSource.extract && typeof customSource.extract === 'object' ? customSource.extract : {},
288
+ },
289
+ last,
290
+ notify: normalizeAccountNotify(raw.notify),
291
+ notifyState: normalizeNotifyState(raw.notifyState),
292
+ }
293
+ })
294
+ }
295
+
296
+ // 响应剥离明文 Key:客户端只需知道是否已配置,Key 永不出主机。
297
+ function redactAccount(account) {
298
+ return { ...account, apiKey: '', hasKey: account.apiKey.length > 0 }
299
+ }
300
+
301
+ async function queryAccount(account) {
302
+ const queriedAt = Date.now()
303
+ let last
304
+ try {
305
+ const text = await performRequest(buildRequest(account))
306
+ let body
307
+ try {
308
+ body = JSON.parse(text)
309
+ } catch {
310
+ throw new Error('响应不是合法 JSON')
311
+ }
312
+ const topError = body && body.error
313
+ if (topError && typeof topError.message === 'string') throw new Error(topError.message)
314
+ const reading = parseReading(account, body)
315
+ last = { ok: true, reading, error: null, queriedAt }
316
+ } catch (error) {
317
+ last = { ok: false, reading: null, error: error && error.message ? error.message : String(error), queriedAt }
318
+ }
319
+ account.last = last
320
+ return { ok: last.ok, account }
321
+ }
322
+
323
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
324
+ // timer 为软依赖:不进 inject 声明,服务缺失时仅停用自动轮询,面板手动查询不受影响
325
+ export const inject = ['webServer']
326
+
327
+ export function apply(ctx) {
328
+ let config = null
329
+ let loadPromise = null
330
+ let writeChain = Promise.resolve()
331
+ let accountsBroken = false
332
+ let historyStore = createHistoryStore({ file: HISTORY_FILE })
333
+ let history = newSequenceStore()
334
+ // 账号级档位退避状态:基期随读数档位翻转重建;调度到点由 last.queriedAt + 档位间隔另行判定
335
+ const pollState = new Map()
336
+ // 通知投影(client 轮询展示)与事件序号
337
+ const projection = createProjection({})
338
+ let notifySeq = 0
339
+
340
+ function readNotifySettings() {
341
+ const settings = ctx.get('settings')
342
+ const value = settings ? settings.get(NAMESPACE) : undefined
343
+ return resolvedNotifySettings(value && typeof value === 'object' ? value.notify : undefined)
344
+ }
345
+
346
+ function ensureHistory() {
347
+ return historyStore.ensure().then((sequences) => {
348
+ history = sequences
349
+ return history
350
+ })
351
+ }
352
+
353
+ function persistHistory() {
354
+ return historyStore.persist()
355
+ }
356
+
357
+ // 查询成功后落快照:按读数取样追加各序列,并重建当月月窗口序列。
358
+ function recordSnapshots(account, reading, queriedAt) {
359
+ const snaps = readingToSnapshots(reading)
360
+ for (const snap of snaps) {
361
+ appendPoint(history, account.id + ':' + snap.suffix, queriedAt, snap.value, queriedAt)
362
+ }
363
+ buildMonthSequence(history, account.id, queriedAt)
364
+ return persistHistory()
365
+ }
366
+
367
+ // 账号级退避状态:基期 = 账号档位间隔(读数档位翻转时重建),只管退避不管调度
368
+ function pollEntry(account) {
369
+ const baseSec = tierIntervalSec(hasShortWindow(account))
370
+ const existing = pollState.get(account.id)
371
+ if (existing && existing.baseSec === baseSec) return existing
372
+ const state = { baseSec, backoff: createBackoff({ baseSec }) }
373
+ pollState.set(account.id, state)
374
+ return state
375
+ }
376
+
377
+ function hasShortWindow(account) {
378
+ return isShortWindowTier(
379
+ account.last,
380
+ account.last !== null && account.last.reading !== null &&
381
+ readingToSnapshots(account.last.reading).some((snap) => snap.suffix === SHORT_SUFFIX),
382
+ )
383
+ }
384
+
385
+ function runQuery(account) {
386
+ const queriedAt = Date.now()
387
+ return queryAccount(account).then((result) => {
388
+ const state = pollEntry(account)
389
+ if (result.ok) {
390
+ state.backoff.onSuccess()
391
+ // 先评估后落盘:阈值穿越事件不因落盘失败丢失;落盘失败仅弃本轮快照
392
+ evaluateAndDispatch(account, queriedAt)
393
+ return recordSnapshots(account, account.last.reading, queriedAt)
394
+ .catch(() => {})
395
+ .then(() => result)
396
+ }
397
+ state.backoff.onFailure(Math.floor(queriedAt / 1000))
398
+ return result
399
+ })
400
+ }
401
+
402
+ // 刷新评估:越逻辑点事件入投影并经 webhook / dsh-im 投递;沿触发状态随账号落盘,
403
+ // 重启不重发。评估在通知关闭时短路,状态零成本。
404
+ function evaluateAndDispatch(account, queriedAt) {
405
+ const globalNotify = readNotifySettings()
406
+ if (!globalNotify.enabled) return
407
+ const rule = mergeAccountOverride(globalNotify, account.notify)
408
+ const outcome = evaluateAccount({
409
+ account,
410
+ rule,
411
+ state: account.notifyState || createNotifyState(),
412
+ seq: notifySeq,
413
+ ts: queriedAt,
414
+ })
415
+ notifySeq += outcome.events.length
416
+ account.notifyState = outcome.state
417
+ for (const event of outcome.events) {
418
+ // toast 开关在投影侧消费:关闭即浏览器半区轮询不到,出站通道不受影响
419
+ if (globalNotify.toast) projection.push(event)
420
+ void sendWebhook({ url: globalNotify.webhookUrl, payload: buildWebhookPayload(event) })
421
+ deliverIm(event, globalNotify)
422
+ }
423
+ }
424
+
425
+ // IM 投递:多目标逐发 fire-and-forget 不重试,失败即弃,与 webhook 同语义;
426
+ // dshIm 经运行期可选读取,未装 dsh-im 的 profile 本插件照常工作。
427
+ function deliverIm(event, globalNotify) {
428
+ const dshIm = ctx.get('dshIm')
429
+ if (dshIm === undefined) return
430
+ for (const { botId, targetId } of normalizeImTargets(globalNotify.imTargets)) {
431
+ void Promise.resolve().then(() => dshIm.send(botId, targetId, event.text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })).catch(() => {})
432
+ }
433
+ }
434
+
435
+ function ensureConfig() {
436
+ if (config !== null && !accountsBroken) return Promise.resolve(config)
437
+ if (accountsBroken) {
438
+ // 损坏解除路径(historyStore 同构):坏文件已被备份移走,重读 ENOENT 即解除;
439
+ // 用户手工修复后放回合法文件,重读解析成功同样解除
440
+ loadPromise = null
441
+ accountsBroken = false
442
+ }
443
+ if (!loadPromise) {
444
+ // 损坏守卫(historyStore 同构):解析失败先把坏文件备份 .bak 并标记 broken 拒写,
445
+ // 防空配置被后续持久化覆盖,账号 Key 全部丢失;修复文件后重读成功即解除
446
+ loadPromise = readFile(DATA_FILE, 'utf8')
447
+ .then((text) => {
448
+ const parsed = JSON.parse(text)
449
+ requireOk(parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.accounts), '配置形态无效')
450
+ config = parsed
451
+ accountsBroken = false
452
+ return config
453
+ })
454
+ .catch((error) => {
455
+ const missing = error && error.code === 'ENOENT'
456
+ config = defaultConfig()
457
+ if (missing) {
458
+ accountsBroken = false
459
+ return config
460
+ }
461
+ accountsBroken = true
462
+ return rename(DATA_FILE, DATA_FILE + BAK_SUFFIX).catch(() => {}).then(() => config)
463
+ })
464
+ }
465
+ return loadPromise
466
+ }
467
+
468
+ function persistConfig() {
469
+ if (accountsBroken) return Promise.reject(new Error(ACCOUNTS_BROKEN_MESSAGE))
470
+ // 写链毒化防护:链上失败不传播到后续写入(调用方 await 本次结果感知单次失败)
471
+ const result = writeChain.then(async () => {
472
+ await mkdir(dirname(DATA_FILE), { recursive: true })
473
+ await writeFile(DATA_FILE, JSON.stringify(config), 'utf8')
474
+ })
475
+ writeChain = result.catch(() => {})
476
+ return result
477
+ }
478
+
479
+ ctx.effect(
480
+ () =>
481
+ ctx.webServer.register({
482
+ kind: 'exact',
483
+ path: '/api/usage-panel/accounts',
484
+ handler: guardedRoute(async (req, res) => {
485
+ if (req.method === 'GET') {
486
+ const current = await ensureConfig()
487
+ sendJson(res, 200, { accounts: current.accounts.map(redactAccount) })
488
+ return
489
+ }
490
+ const body = JSON.parse(await readBody(req))
491
+ await ensureConfig()
492
+ await ensureHistory()
493
+ const previous = Array.isArray(config.accounts) ? config.accounts : []
494
+ const saved = normalizeAccounts(body && body.accounts).map((account) => {
495
+ const old = previous.find((item) => item.id === account.id)
496
+ if (old === undefined) return account
497
+ // 客户端拿不到旧 Key,空 Key 视为「保持不变」;last 与 notifyState 不经表单,
498
+ // 同 id 旧值回填,防编辑账号丢失读数与沿触发防抖基线
499
+ const merged = account.apiKey.length > 0 ? account : { ...account, apiKey: old.apiKey }
500
+ return { ...merged, last: old.last, notifyState: old.notifyState }
501
+ })
502
+ config = { version: 1, accounts: saved }
503
+ // 已删除账号的轮询状态同步清理,不留悬挂退避
504
+ for (const id of [...pollState.keys()]) {
505
+ if (!saved.some((account) => account.id === id)) pollState.delete(id)
506
+ }
507
+ // 已删除账号的历史序列同步清理(id 无冒号前置校验保证账号段切分可靠),
508
+ // 防 history.json 无主数据无限累积
509
+ for (const key of Object.keys(history)) {
510
+ const accountId = key.slice(0, key.indexOf(':'))
511
+ if (!saved.some((account) => account.id === accountId)) delete history[key]
512
+ }
513
+ await persistConfig().catch((error) => {
514
+ // 写失败(如 broken 拒写)回滚内存态,防 tick/GET 与磁盘不一致
515
+ config = { version: 1, accounts: previous }
516
+ throw error
517
+ })
518
+ await persistHistory().catch(() => {})
519
+ sendJson(res, 200, { ok: true, accounts: saved.map(redactAccount) })
520
+ }),
521
+ }),
522
+ 'usage-panel accounts route',
523
+ )
524
+
525
+ ctx.effect(
526
+ () =>
527
+ ctx.webServer.register({
528
+ kind: 'exact',
529
+ path: '/api/usage-panel/query',
530
+ handler: guardedRoute.post(async (req, res) => {
531
+ const body = JSON.parse(await readBody(req))
532
+ const id = body && typeof body.id === 'string' ? body.id : ''
533
+ const auto = body && body.auto === true
534
+ const current = await ensureConfig()
535
+ const account = current.accounts.find((item) => item.id === id)
536
+ requireOk(account !== undefined, '账号不存在: ' + id)
537
+ // 面板打开触发的自动查询受退避约束,避免绕过退避轰炸上游;手动刷新不受限
538
+ if (auto && pollEntry(account).backoff.isBlocked(Math.floor(Date.now() / 1000))) {
539
+ sendJson(res, 200, { ok: false, skipped: true, account: redactAccount(account) })
540
+ return
541
+ }
542
+ await ensureHistory()
543
+ const result = await runQuery(account)
544
+ // last/notifyState 落盘失败不否定已成功的查询
545
+ await persistConfig().catch(() => {})
546
+ sendJson(res, 200, { ok: result.ok, account: redactAccount(result.account) })
547
+ }),
548
+ }),
549
+ 'usage-panel query route',
550
+ )
551
+
552
+ ctx.effect(
553
+ () =>
554
+ ctx.webServer.register({
555
+ kind: 'exact',
556
+ path: '/api/usage-panel/history',
557
+ handler: async (req, res) => {
558
+ if (req.method !== 'GET') {
559
+ sendJson(res, 405, { error: 'method not allowed' })
560
+ return
561
+ }
562
+ await ensureHistory()
563
+ sendJson(res, 200, { sequences: history })
564
+ },
565
+ }),
566
+ 'usage-panel history route',
567
+ )
568
+
569
+ ctx.effect(
570
+ () =>
571
+ ctx.webServer.register({
572
+ kind: 'exact',
573
+ path: '/api/usage-panel/settings',
574
+ handler: async (req, res) => {
575
+ if (req.method !== 'GET') {
576
+ sendJson(res, 405, { error: 'method not allowed' })
577
+ return
578
+ }
579
+ sendJson(res, 200, { pollArmed })
580
+ },
581
+ }),
582
+ 'usage-panel settings route',
583
+ )
584
+
585
+ // 测试事件构造:与评估产出共用 buildNotifyEvent,防事件形态平行漂移
586
+ const buildTestEvent = () => {
587
+ notifySeq += 1
588
+ return buildNotifyEvent({
589
+ kind: 'quota',
590
+ accountId: 'test',
591
+ accountName: '测试账号',
592
+ label: '5小时',
593
+ detail: { value: 92, threshold: 90 },
594
+ text: '[dsh] 测试事件:账号余额通知通道',
595
+ }, notifySeq, Date.now())
596
+ }
597
+
598
+ // dsh-im 投递错误码到 HTTP 状态的映射,未收录错误按网关失败处理
599
+ const IM_ERROR_STATUS = { 'bad-request': 400, 'unknown-bot': 404, 'bot-not-connected': 503 }
600
+
601
+ ctx.effect(() => {
602
+ const disposeRoute = ctx.webServer.register({
603
+ kind: 'exact',
604
+ path: '/api/usage-panel/notifications',
605
+ handler: async (req, res) => {
606
+ if (req.method !== 'GET') {
607
+ sendJson(res, 405, { error: 'method not allowed' })
608
+ return
609
+ }
610
+ // 长轮询:cursor 缺省=首拉立即返回;仅版本恰等时挂起至事件 push / 超时;
611
+ // cursor 超前(宿主重启版本回退)立即返回全量,客户端以响应 version 重置游标自愈
612
+ const url = new URL(req.url, 'http://localhost')
613
+ const rawCursor = Number(url.searchParams.get('cursor'))
614
+ const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
615
+ if (url.searchParams.has('cursor') && projection.version() === cursor) {
616
+ await projection.wait(cursor, LONG_POLL_WAIT_MS)
617
+ }
618
+ sendJson(res, 200, { units: projection.list(), version: projection.version() })
619
+ },
620
+ })
621
+ return () => {
622
+ disposeRoute()
623
+ // 唤醒全部挂起长轮询,以当前版本收尾,防卸载后连接悬挂
624
+ projection.dispose()
625
+ }
626
+ }, 'usage-panel notifications route')
627
+
628
+ ctx.effect(
629
+ () =>
630
+ ctx.webServer.register({
631
+ kind: 'exact',
632
+ path: '/api/usage-panel/notify-config',
633
+ handler: guardedRoute(async (req, res) => {
634
+ if (req.method === 'GET') {
635
+ sendJson(res, 200, { notify: publicNotify(readNotifySettings()), imAvailable: ctx.get('dshIm') !== undefined })
636
+ return
637
+ }
638
+ const body = JSON.parse(await readBody(req))
639
+ const check = validateNotifyPatch(body)
640
+ requireOk(check.ok, check.reason)
641
+ // 读出当前值做浅合并后整体写回,不依赖 settings.update 的嵌套合并语义
642
+ const merged = { ...readNotifySettings(), ...check.patch }
643
+ const settings = ctx.get('settings')
644
+ requireOk(settings !== undefined, 'settings 服务不可用')
645
+ await settings.update(NAMESPACE, { notify: merged })
646
+ sendJson(res, 200, { ok: true, notify: publicNotify(readNotifySettings()) })
647
+ }),
648
+ }),
649
+ 'usage-panel notify-config route',
650
+ )
651
+
652
+ ctx.effect(
653
+ () =>
654
+ ctx.webServer.register({
655
+ kind: 'exact',
656
+ path: '/api/usage-panel/test-webhook',
657
+ handler: guardedRoute.post(async (req, res) => {
658
+ const result = await sendWebhook({ url: readNotifySettings().webhookUrl, payload: buildWebhookPayload(buildTestEvent()) })
659
+ sendJson(res, 200, result)
660
+ }),
661
+ }),
662
+ 'usage-panel test-webhook route',
663
+ )
664
+
665
+ ctx.effect(
666
+ () =>
667
+ ctx.webServer.register({
668
+ kind: 'exact',
669
+ path: '/api/usage-panel/im-targets',
670
+ handler: async (req, res) => {
671
+ if (req.method !== 'GET') {
672
+ sendJson(res, 405, { error: 'method not allowed' })
673
+ return
674
+ }
675
+ const dshIm = ctx.get('dshIm')
676
+ if (dshIm === undefined) {
677
+ sendJson(res, 503, { error: 'dsh-im 未安装' })
678
+ return
679
+ }
680
+ const botId = new URL(req.url, 'http://localhost').searchParams.get('botId') || ''
681
+ if (!isValidImBotId(botId)) {
682
+ sendJson(res, 400, { error: 'botId 缺失或非法' })
683
+ return
684
+ }
685
+ try {
686
+ const targets = await dshIm.listTargets(botId)
687
+ // route 为平台原生路由 ID,选择目标无需知道,不出主机
688
+ sendJson(res, 200, { targets: targets.map(({ targetId, name, kind }) => ({ targetId, name, kind })) })
689
+ } catch (error) {
690
+ const code = error && error.code ? error.code : 'delivery-failed'
691
+ sendJson(res, IM_ERROR_STATUS[code] ?? 502, { error: code })
692
+ }
693
+ },
694
+ }),
695
+ 'usage-panel im-targets route',
696
+ )
697
+
698
+ ctx.effect(
699
+ () =>
700
+ ctx.webServer.register({
701
+ kind: 'exact',
702
+ path: '/api/usage-panel/test-im',
703
+ handler: guardedRoute.post(async (req, res) => {
704
+ const dshIm = ctx.get('dshIm')
705
+ if (dshIm === undefined) {
706
+ sendJson(res, 200, { ok: false, detail: 'dsh-im 未安装' })
707
+ return
708
+ }
709
+ const targets = normalizeImTargets(readNotifySettings().imTargets)
710
+ if (targets.length === 0) {
711
+ sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
712
+ return
713
+ }
714
+ // 逐目标结算,真实结果随响应返回,与 test-webhook 的不谎报原则一致
715
+ const results = await Promise.all(targets.map(async ({ botId, targetId }) => {
716
+ try {
717
+ await dshIm.send(botId, targetId, buildTestEvent().text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })
718
+ return { botId, targetId, ok: true, detail: 'sent' }
719
+ } catch (error) {
720
+ return { botId, targetId, ok: false, detail: error && error.code ? error.code : 'delivery-failed' }
721
+ }
722
+ }))
723
+ sendJson(res, 200, { ok: results.every((item) => item.ok), results })
724
+ }),
725
+ }),
726
+ 'usage-panel test-im route',
727
+ )
728
+
729
+ // 定期轮询:固定短 tick,时间驱动调度——上次尝试查询时刻(account.last.queriedAt,
730
+ // 成功失败均记、随配置持久化)距今超过账号档位间隔即到点;退避独立叠加,二者皆过才查。
731
+ // 旧 round 分频形态已废:round 仅查询时递增使余额类账号死锁停摆,短窗账号每 tick 必查
732
+ // 使间隔设置失效。
733
+ // timer 软依赖经嵌套 inject 等待:服务激活才武装轮询,缺失则自动轮询停用,
734
+ // 面板手动查询不受影响,也不因等待服务而阻塞插件装载。
735
+ // pollArmed(武装,外露)与 pollInFlight(单轮在途互斥)分离:在途是瞬态,
736
+ // 误作可用性暴露会让健康环境常驻误报降级。dispose 显式挂回插件 fiber,
737
+ // timer 服务重启导致嵌套 fiber 重跑时不产生双 interval
738
+ let pollInFlight = false
739
+ let pollArmed = false
740
+ ctx.inject(['timer'], (timerCtx) => {
741
+ if (typeof timerCtx.interval !== 'function') return
742
+ const dispose = timerCtx.interval(() => {
743
+ if (pollInFlight) return
744
+ const current = config
745
+ if (!current) return
746
+ const nowSec = Math.floor(Date.now() / 1000)
747
+ const due = current.accounts.filter((account) => {
748
+ if (pollEntry(account).backoff.isBlocked(nowSec)) return false
749
+ return isDue({
750
+ lastQuerySec: lastQuerySecOf(account.last),
751
+ nowSec,
752
+ intervalSec: tierIntervalSec(hasShortWindow(account)),
753
+ })
754
+ })
755
+ if (due.length === 0) return
756
+ pollInFlight = true
757
+ ensureHistory()
758
+ .then(async () => {
759
+ for (const account of due) {
760
+ // 单账号失败不中止本轮其余账号(broken 等持久态下尤为关键)
761
+ await runQuery(account).catch(() => {})
762
+ }
763
+ await persistConfig().catch(() => {})
764
+ })
765
+ .catch(() => {})
766
+ .then(() => {
767
+ pollInFlight = false
768
+ })
769
+ }, TICK_SEC * 1000)
770
+ ctx.effect(() => dispose, 'usage-panel poll interval')
771
+ pollArmed = true
772
+ })
773
+
774
+ ctx.inject(['settings'], (settingsCtx) => {
775
+ settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
776
+ })
777
+ }