@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/README.md +132 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +1424 -0
- package/src/history.mjs +117 -0
- package/src/historyStore.mjs +61 -0
- package/src/index.js +777 -0
- package/src/notify.mjs +424 -0
- package/src/parsers.mjs +327 -0
- package/src/poller.mjs +60 -0
- package/test/client-id.test.mjs +16 -0
- package/test/history.test.mjs +170 -0
- package/test/historyStore.test.mjs +107 -0
- package/test/notify-poll.test.mjs +31 -0
- package/test/notify.route.test.mjs +447 -0
- package/test/notify.test.mjs +498 -0
- package/test/parity.test.mjs +119 -0
- package/test/parsers.test.mjs +286 -0
- package/test/poller.test.mjs +88 -0
- package/test/route.test.mjs +171 -0
- package/test/switch-guard.test.mjs +32 -0
package/src/parsers.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// 用量面板纯解析层:各平台响应体 -> 归一化读数。
|
|
2
|
+
// 只做数据变换,无 IO;host 半区直接 import 本模块。
|
|
3
|
+
|
|
4
|
+
const QUOTA_PER_USD = 500000
|
|
5
|
+
const PERCENT_BASE = 100
|
|
6
|
+
|
|
7
|
+
function toStrictNumber(value) {
|
|
8
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
const text = value.trim()
|
|
11
|
+
if (text.length > 0 && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
|
|
12
|
+
return NaN
|
|
13
|
+
}
|
|
14
|
+
return NaN
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 只读自有属性,阻断 __proto__/constructor 原型链逃逸。
|
|
18
|
+
function getPath(root, path) {
|
|
19
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
20
|
+
let current = root
|
|
21
|
+
for (const segment of path.split('.')) {
|
|
22
|
+
if (current === null || current === undefined || typeof current !== 'object') return undefined
|
|
23
|
+
if (!Object.hasOwn(current, segment)) return undefined
|
|
24
|
+
current = current[segment]
|
|
25
|
+
}
|
|
26
|
+
return current
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractByRule(data, rule) {
|
|
30
|
+
if (rule === null || rule === undefined) return null
|
|
31
|
+
if (typeof rule === 'number' && Number.isFinite(rule)) return rule
|
|
32
|
+
if (typeof rule === 'string') {
|
|
33
|
+
const value = getPath(data, rule)
|
|
34
|
+
const num = toStrictNumber(value)
|
|
35
|
+
if (Number.isFinite(num)) return num
|
|
36
|
+
return typeof value === 'string' ? value : null
|
|
37
|
+
}
|
|
38
|
+
if (typeof rule === 'object' && !Array.isArray(rule)) {
|
|
39
|
+
if (rule.op === 'subtract' && Array.isArray(rule.paths)) {
|
|
40
|
+
if (rule.paths.length === 0) return null
|
|
41
|
+
const values = rule.paths.map((path) => toStrictNumber(getPath(data, path)))
|
|
42
|
+
if (!values.every(Number.isFinite)) return null
|
|
43
|
+
return values.reduce((acc, value) => acc - value)
|
|
44
|
+
}
|
|
45
|
+
if (rule.op === 'add' && Array.isArray(rule.paths)) {
|
|
46
|
+
const values = rule.paths.map((path) => toStrictNumber(getPath(data, path)))
|
|
47
|
+
if (!values.every(Number.isFinite)) return null
|
|
48
|
+
return values.reduce((acc, value) => acc + value, 0)
|
|
49
|
+
}
|
|
50
|
+
if (rule.op === 'divide' && typeof rule.path === 'string') {
|
|
51
|
+
const value = toStrictNumber(getPath(data, rule.path))
|
|
52
|
+
const by = toStrictNumber(rule.by)
|
|
53
|
+
if (!Number.isFinite(value) || !Number.isFinite(by) || by === 0) return null
|
|
54
|
+
return value / by
|
|
55
|
+
}
|
|
56
|
+
if (typeof rule.path === 'string') return extractByRule(data, rule.path)
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requireOk(condition, message) {
|
|
62
|
+
if (!condition) throw new Error(message)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// limit/remaining 三元组 -> 已用百分比口径(对齐 cc-switch)。
|
|
66
|
+
function makeTier(limitRaw, remainingRaw, resetsAt) {
|
|
67
|
+
const limit = toStrictNumber(limitRaw)
|
|
68
|
+
const remaining = toStrictNumber(remainingRaw)
|
|
69
|
+
const used = Number.isFinite(limit) && Number.isFinite(remaining) ? Math.max(limit - remaining, 0) : null
|
|
70
|
+
const utilization =
|
|
71
|
+
Number.isFinite(limit) && limit > 0 && used !== null ? (used / limit) * PERCENT_BASE : null
|
|
72
|
+
return { limit, remaining, used, utilization, resetsAt: typeof resetsAt === 'string' ? resetsAt : null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function balanceEntry(currency, total, isAvailable, info) {
|
|
76
|
+
const numOrNull = (value) => {
|
|
77
|
+
const parsed = toStrictNumber(value)
|
|
78
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
currency,
|
|
82
|
+
total,
|
|
83
|
+
granted: numOrNull(info && info.granted_balance),
|
|
84
|
+
toppedUp: numOrNull(info && info.topped_up_balance),
|
|
85
|
+
isAvailable,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseDeepSeek(body) {
|
|
90
|
+
const error = body && body.error
|
|
91
|
+
if (error) {
|
|
92
|
+
throw new Error(String((error && error.message) || (error && error.type) || '接口返回错误'))
|
|
93
|
+
}
|
|
94
|
+
const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : null
|
|
95
|
+
requireOk(infos !== null, '响应缺少 balance_infos 字段')
|
|
96
|
+
const isAvailable = body.is_available !== false
|
|
97
|
+
return {
|
|
98
|
+
kind: 'balance',
|
|
99
|
+
entries: infos.map((info) =>
|
|
100
|
+
balanceEntry(String((info && info.currency) || 'CNY'), toStrictNumber(info && info.total_balance), isAvailable, info),
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseOpenRouter(body) {
|
|
106
|
+
const data = body && body.data
|
|
107
|
+
requireOk(data !== null && typeof data === 'object', '响应缺少 data 字段')
|
|
108
|
+
const total = toStrictNumber(data.total_credits)
|
|
109
|
+
const used = toStrictNumber(data.total_usage)
|
|
110
|
+
requireOk(Number.isFinite(total) || Number.isFinite(used), '响应缺少 total_credits/total_usage 字段')
|
|
111
|
+
return {
|
|
112
|
+
kind: 'balance',
|
|
113
|
+
entries: [
|
|
114
|
+
{
|
|
115
|
+
currency: 'USD',
|
|
116
|
+
total: Number.isFinite(total) ? total : null,
|
|
117
|
+
used: Number.isFinite(used) ? used : null,
|
|
118
|
+
remaining:
|
|
119
|
+
Number.isFinite(total) && Number.isFinite(used) ? Math.max(total - used, 0) : null,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseKimi(body) {
|
|
126
|
+
const firstLimit = body && Array.isArray(body.limits) && body.limits.length ? body.limits[0] : null
|
|
127
|
+
const detail = firstLimit && firstLimit.detail ? firstLimit.detail : null
|
|
128
|
+
const usage = body && body.usage ? body.usage : null
|
|
129
|
+
requireOk(detail !== null || usage !== null, '响应缺少 usage/limits 字段')
|
|
130
|
+
const windows = []
|
|
131
|
+
if (detail) windows.push({ label: '5小时', ...makeTier(detail.limit, detail.remaining, detail.resetTime) })
|
|
132
|
+
if (usage) windows.push({ label: '7天', ...makeTier(usage.limit, usage.remaining, usage.resetTime) })
|
|
133
|
+
const level = body && body.user && body.user.membership && body.user.membership.level
|
|
134
|
+
return { kind: 'quota', windows, membership: typeof level === 'string' ? level : null }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// unit=3 -> 5小时窗,unit=6 -> 7天窗;未分类按重置时间升序回填空缺槽位。
|
|
138
|
+
// TOKENS_LIMIT(token 窗口)与 CREDIT_LIMIT(Credit 计费窗口,Pro/Lite 套餐)语义一致,一并接受。
|
|
139
|
+
function parseZhipu(body) {
|
|
140
|
+
if (body && body.success === false) {
|
|
141
|
+
throw new Error(String((body && body.msg) || '接口返回错误'))
|
|
142
|
+
}
|
|
143
|
+
const limits = body && body.data && Array.isArray(body.data.limits) ? body.data.limits : null
|
|
144
|
+
requireOk(limits !== null, '响应缺少 data.limits 字段')
|
|
145
|
+
const unclassified = []
|
|
146
|
+
let fiveHour = null
|
|
147
|
+
let weekly = null
|
|
148
|
+
let prompts = null
|
|
149
|
+
const seenTypes = []
|
|
150
|
+
for (const item of limits) {
|
|
151
|
+
if (!item || typeof item !== 'object') continue
|
|
152
|
+
if (seenTypes.indexOf(item.type) < 0) seenTypes.push(String(item.type))
|
|
153
|
+
const type = typeof item.type === 'string' ? item.type.toLowerCase() : ''
|
|
154
|
+
if (type === 'time_limit') {
|
|
155
|
+
// 工具用量窗口:currentValue/remaining 为调用次数,usageDetails 为按工具的次数明细
|
|
156
|
+
if (prompts === null) {
|
|
157
|
+
const details = Array.isArray(item.usageDetails)
|
|
158
|
+
? item.usageDetails
|
|
159
|
+
.filter((d) => d && typeof d.modelCode === 'string' && Number.isFinite(toStrictNumber(d.usage)))
|
|
160
|
+
.map((d) => ({ model: d.modelCode, usage: toStrictNumber(d.usage) }))
|
|
161
|
+
: []
|
|
162
|
+
prompts = {
|
|
163
|
+
label: '工具用量',
|
|
164
|
+
utilization: zhipuPercent(item),
|
|
165
|
+
remaining: orNull(toStrictNumber(item.remaining)),
|
|
166
|
+
limit: orNull(toStrictNumber(item.usage)),
|
|
167
|
+
resetsAt: Number.isFinite(toStrictNumber(item.nextResetTime))
|
|
168
|
+
? new Date(toStrictNumber(item.nextResetTime)).toISOString()
|
|
169
|
+
: null,
|
|
170
|
+
details,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (type !== 'tokens_limit' && type !== 'credit_limit') continue
|
|
176
|
+
const resetMs = toStrictNumber(item.nextResetTime)
|
|
177
|
+
const entry = {
|
|
178
|
+
label: '',
|
|
179
|
+
utilization: zhipuPercent(item),
|
|
180
|
+
remaining: orNull(toStrictNumber(item.remaining)),
|
|
181
|
+
limit: orNull(toStrictNumber(item.usage)),
|
|
182
|
+
resetsAt: Number.isFinite(resetMs) ? new Date(resetMs).toISOString() : null,
|
|
183
|
+
}
|
|
184
|
+
const unit = toStrictNumber(item.unit)
|
|
185
|
+
if (unit === 3 && fiveHour === null) fiveHour = entry
|
|
186
|
+
else if (unit === 6 && weekly === null) weekly = entry
|
|
187
|
+
else unclassified.push(entry)
|
|
188
|
+
}
|
|
189
|
+
unclassified.sort((a, b) => {
|
|
190
|
+
const at = a.resetsAt ? Date.parse(a.resetsAt) : Infinity
|
|
191
|
+
const bt = b.resetsAt ? Date.parse(b.resetsAt) : Infinity
|
|
192
|
+
return at - bt
|
|
193
|
+
})
|
|
194
|
+
for (const entry of unclassified) {
|
|
195
|
+
if (fiveHour === null) fiveHour = entry
|
|
196
|
+
else if (weekly === null) weekly = entry
|
|
197
|
+
}
|
|
198
|
+
requireOk(
|
|
199
|
+
fiveHour !== null || weekly !== null,
|
|
200
|
+
'响应缺少可解析的额度窗口,见到的类型: ' + (seenTypes.join(', ') || '无'),
|
|
201
|
+
)
|
|
202
|
+
const windows = []
|
|
203
|
+
if (fiveHour !== null) windows.push({ ...fiveHour, label: '5小时' })
|
|
204
|
+
if (weekly !== null) windows.push({ ...weekly, label: '7天' })
|
|
205
|
+
if (prompts !== null) windows.push(prompts)
|
|
206
|
+
const level = body && body.data && body.data.level
|
|
207
|
+
return { kind: 'quota', windows, level: typeof level === 'string' ? level : null }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 已用百分比:percentage 优先;缺失时 currentValue/usage 反推;均无效为 null。
|
|
211
|
+
function zhipuPercent(item) {
|
|
212
|
+
const percentage = toStrictNumber(item.percentage)
|
|
213
|
+
if (Number.isFinite(percentage)) return percentage
|
|
214
|
+
const current = toStrictNumber(item.currentValue)
|
|
215
|
+
const usage = toStrictNumber(item.usage)
|
|
216
|
+
if (Number.isFinite(current) && Number.isFinite(usage) && usage > 0) {
|
|
217
|
+
return (current / usage) * PERCENT_BASE
|
|
218
|
+
}
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function orNull(value) {
|
|
223
|
+
return Number.isFinite(value) ? value : null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseMiniMax(body) {
|
|
227
|
+
const baseResp = body && body.base_resp
|
|
228
|
+
const statusCode = baseResp ? toStrictNumber(baseResp.status_code) : NaN
|
|
229
|
+
if (baseResp && statusCode !== 0) {
|
|
230
|
+
throw new Error(String(baseResp.status_msg || '接口返回错误'))
|
|
231
|
+
}
|
|
232
|
+
const remains = body && Array.isArray(body.model_remains) ? body.model_remains : []
|
|
233
|
+
const item = remains.find((m) => m && m.model_name === 'general')
|
|
234
|
+
requireOk(item !== undefined, '响应缺少 general 条目')
|
|
235
|
+
const windows = []
|
|
236
|
+
const intervalRemain = toStrictNumber(item.current_interval_remaining_percent)
|
|
237
|
+
const endMs = toStrictNumber(item.end_time)
|
|
238
|
+
if (Number.isFinite(intervalRemain)) {
|
|
239
|
+
windows.push({
|
|
240
|
+
label: '5小时',
|
|
241
|
+
utilization: PERCENT_BASE - intervalRemain,
|
|
242
|
+
resetsAt: Number.isFinite(endMs) ? new Date(endMs).toISOString() : null,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
if (toStrictNumber(item.current_weekly_status) === 1) {
|
|
246
|
+
const weeklyRemain = toStrictNumber(item.current_weekly_remaining_percent)
|
|
247
|
+
const weeklyEndMs = toStrictNumber(item.weekly_end_time)
|
|
248
|
+
if (Number.isFinite(weeklyRemain)) {
|
|
249
|
+
windows.push({
|
|
250
|
+
label: '7天',
|
|
251
|
+
utilization: PERCENT_BASE - weeklyRemain,
|
|
252
|
+
resetsAt: Number.isFinite(weeklyEndMs) ? new Date(weeklyEndMs).toISOString() : null,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
requireOk(windows.length > 0, '响应缺少用量窗口字段')
|
|
257
|
+
return { kind: 'quota', windows }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseNewApi(body) {
|
|
261
|
+
if (body && typeof body.message === 'string' && body.code !== 200) {
|
|
262
|
+
throw new Error(body.message)
|
|
263
|
+
}
|
|
264
|
+
const data = body && body.data
|
|
265
|
+
requireOk(data !== null && typeof data === 'object', '响应缺少 data 字段')
|
|
266
|
+
if (data.unlimited_quota === true) {
|
|
267
|
+
throw new Error('无限额度 token 无 total_available,无法读取剩余')
|
|
268
|
+
}
|
|
269
|
+
const total = toStrictNumber(data.total_granted)
|
|
270
|
+
const used = toStrictNumber(data.total_used)
|
|
271
|
+
const remaining = toStrictNumber(data.total_available)
|
|
272
|
+
requireOk(
|
|
273
|
+
Number.isFinite(total) || Number.isFinite(remaining),
|
|
274
|
+
'响应缺少 total_granted/total_available 字段',
|
|
275
|
+
)
|
|
276
|
+
return {
|
|
277
|
+
kind: 'balance',
|
|
278
|
+
entries: [
|
|
279
|
+
{
|
|
280
|
+
currency: 'USD',
|
|
281
|
+
total: Number.isFinite(total) ? total / QUOTA_PER_USD : null,
|
|
282
|
+
used: Number.isFinite(used) ? used / QUOTA_PER_USD : null,
|
|
283
|
+
remaining: Number.isFinite(remaining) ? remaining / QUOTA_PER_USD : null,
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function extractCustom(data, extract) {
|
|
290
|
+
const remaining = extractByRule(data, extract && extract.remaining)
|
|
291
|
+
requireOk(remaining !== null && Number.isFinite(Number(remaining)), 'extract.remaining 缺失或非数值')
|
|
292
|
+
const num = (value) => {
|
|
293
|
+
// 规则解析失败(extractByRule 回 null)保持 null 语义,与真值 0 可区分
|
|
294
|
+
if (value === null || value === undefined) return null
|
|
295
|
+
const parsed = Number(value)
|
|
296
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
297
|
+
}
|
|
298
|
+
const maxBudget = extract && extract.maxBudget !== undefined ? num(extractByRule(data, extract.maxBudget)) : null
|
|
299
|
+
const spend = extract && extract.spend !== undefined ? num(extractByRule(data, extract.spend)) : null
|
|
300
|
+
const unit =
|
|
301
|
+
extract && typeof extract.unit === 'string' && extract.unit.length > 0 ? extract.unit : 'USD'
|
|
302
|
+
// 与 balance 读数联合形态对齐:kind 判别使历史采样 / 通知评估 / 渲染三链路直接生效
|
|
303
|
+
return {
|
|
304
|
+
kind: 'balance',
|
|
305
|
+
entries: [
|
|
306
|
+
{
|
|
307
|
+
currency: unit,
|
|
308
|
+
remaining: Number(remaining),
|
|
309
|
+
total: maxBudget,
|
|
310
|
+
used: spend,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export {
|
|
317
|
+
toStrictNumber,
|
|
318
|
+
getPath,
|
|
319
|
+
extractByRule,
|
|
320
|
+
parseDeepSeek,
|
|
321
|
+
parseOpenRouter,
|
|
322
|
+
parseKimi,
|
|
323
|
+
parseZhipu,
|
|
324
|
+
parseMiniMax,
|
|
325
|
+
parseNewApi,
|
|
326
|
+
extractCustom,
|
|
327
|
+
}
|
package/src/poller.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 轮询纯逻辑层:时间驱动调度 / 失败指数退避 / 档位间隔。无 IO,host 半区与单测共用。
|
|
2
|
+
// 历史教训:旧 round 分频形态(round 仅在查询时递增)构成死锁——余额类账号首查后
|
|
3
|
+
// round 恒 1 永不再查;短窗账号每 tick 必查使用户间隔设置完全失效。改为时间驱动:
|
|
4
|
+
// 上次尝试查询时刻(成功失败均记)+ 档位间隔判定到点,退避独立叠加。
|
|
5
|
+
|
|
6
|
+
export const BACKOFF_CAP_MULTIPLE = 8
|
|
7
|
+
// 短窗口档(5h 序列)查询间隔 = 序列快照粒度(history.mjs GRANULARITY_MS['10m'])
|
|
8
|
+
export const SHORT_TIER_INTERVAL_SEC = 10 * 60
|
|
9
|
+
// 长窗口档(日/月/余额序列)查询间隔 = 序列快照粒度(history.mjs GRANULARITY_MS['1h'])
|
|
10
|
+
export const LONG_TIER_INTERVAL_SEC = 60 * 60
|
|
11
|
+
|
|
12
|
+
// 失败退避状态机:基期 = 账号档位间隔,×2 封顶;成功即恢复。
|
|
13
|
+
export function createBackoff({ baseSec }) {
|
|
14
|
+
let failures = 0
|
|
15
|
+
let nextRetryAt = null
|
|
16
|
+
return {
|
|
17
|
+
get nextRetryAt() {
|
|
18
|
+
return nextRetryAt
|
|
19
|
+
},
|
|
20
|
+
onFailure(nowSec) {
|
|
21
|
+
failures += 1
|
|
22
|
+
const delaySec = baseSec * Math.pow(2, Math.min(failures - 1, Math.log2(BACKOFF_CAP_MULTIPLE)))
|
|
23
|
+
nextRetryAt = nowSec + delaySec
|
|
24
|
+
},
|
|
25
|
+
onSuccess() {
|
|
26
|
+
failures = 0
|
|
27
|
+
nextRetryAt = null
|
|
28
|
+
},
|
|
29
|
+
isBlocked(nowSec) {
|
|
30
|
+
return nextRetryAt !== null && nowSec < nextRetryAt
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
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
|
+
// 上次尝试查询时刻(秒):queriedAt 毫秒历元;缺失/非法(旧数据)回 null 视为立即到点。
|
|
49
|
+
export function lastQuerySecOf(last) {
|
|
50
|
+
const queriedAt = last !== null && typeof last === 'object' ? last.queriedAt : undefined
|
|
51
|
+
return typeof queriedAt === 'number' && Number.isFinite(queriedAt)
|
|
52
|
+
? Math.floor(queriedAt / 1000)
|
|
53
|
+
: null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 时间驱动到点判定:lastQuerySec 为 null(从未查询或旧数据缺时刻)即到点。
|
|
57
|
+
export function isDue({ lastQuerySec, nowSec, intervalSec }) {
|
|
58
|
+
if (lastQuerySec === null) return true
|
|
59
|
+
return nowSec - lastQuerySec >= intervalSec
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// client.js 注册 id 守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败。
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
10
|
+
|
|
11
|
+
test('client.js 注册 id 为完整包名', () => {
|
|
12
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
13
|
+
const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
|
|
14
|
+
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
15
|
+
assert.equal(match[1], name)
|
|
16
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// 历史快照纯逻辑 BDD:序列键映射 / 档位对齐去重 / 时间修剪 / 硬上限 / 月窗口聚合 / 读数取样。
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import {
|
|
5
|
+
SEQUENCE_TIERS,
|
|
6
|
+
GRANULARITY_MS,
|
|
7
|
+
RETENTION_POINTS,
|
|
8
|
+
HARD_POINT_CAP,
|
|
9
|
+
labelToSuffix,
|
|
10
|
+
granularityOf,
|
|
11
|
+
alignTs,
|
|
12
|
+
appendPoint,
|
|
13
|
+
pruneSequence,
|
|
14
|
+
readingToSnapshots,
|
|
15
|
+
buildMonthSequence,
|
|
16
|
+
newSequenceStore,
|
|
17
|
+
} from '../src/history.mjs'
|
|
18
|
+
|
|
19
|
+
const MIN = 60 * 1000
|
|
20
|
+
const TEN_MIN = 10 * MIN
|
|
21
|
+
const HOUR = 60 * MIN
|
|
22
|
+
|
|
23
|
+
test('场景: 窗口 label 与序列键后缀映射', () => {
|
|
24
|
+
assert.equal(labelToSuffix('5小时'), '5h')
|
|
25
|
+
assert.equal(labelToSuffix('7天'), '7d')
|
|
26
|
+
assert.equal(labelToSuffix('月'), 'month')
|
|
27
|
+
assert.equal(labelToSuffix('未知窗口'), null)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('场景: 序列档位映射', () => {
|
|
31
|
+
assert.equal(granularityOf('5h'), '10m')
|
|
32
|
+
assert.equal(granularityOf('7d'), '1h')
|
|
33
|
+
assert.equal(granularityOf('month'), '1h')
|
|
34
|
+
assert.equal(granularityOf('balance'), '1h')
|
|
35
|
+
assert.equal(Object.keys(SEQUENCE_TIERS).length, 4)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('场景: 时间就近对齐到最近档', () => {
|
|
39
|
+
assert.equal(alignTs(Date.UTC(2026, 0, 1, 10, 7), TEN_MIN), Date.UTC(2026, 0, 1, 10, 10))
|
|
40
|
+
assert.equal(alignTs(Date.UTC(2026, 0, 1, 10, 0), TEN_MIN), Date.UTC(2026, 0, 1, 10, 0))
|
|
41
|
+
assert.equal(alignTs(Date.UTC(2026, 0, 1, 14, 5), HOUR), Date.UTC(2026, 0, 1, 14, 0))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('场景: 短窗口 10 分钟档采样并记录档内最前一次', () => {
|
|
45
|
+
const store = newSequenceStore()
|
|
46
|
+
const seqKey = 'acct-1:5h'
|
|
47
|
+
appendPoint(store, seqKey, Date.UTC(2026, 0, 1, 10, 7), 80)
|
|
48
|
+
appendPoint(store, seqKey, Date.UTC(2026, 0, 1, 10, 14), 70)
|
|
49
|
+
const points = store[seqKey].points
|
|
50
|
+
assert.equal(points.length, 1)
|
|
51
|
+
assert.equal(points[0].t, Date.UTC(2026, 0, 1, 10, 10))
|
|
52
|
+
assert.equal(points[0].v, 80, '档内只保留最前一次的值')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('场景: 跨档落新点', () => {
|
|
56
|
+
const store = newSequenceStore()
|
|
57
|
+
const seqKey = 'acct-1:5h'
|
|
58
|
+
appendPoint(store, seqKey, Date.UTC(2026, 0, 1, 10, 10), 80)
|
|
59
|
+
appendPoint(store, seqKey, Date.UTC(2026, 0, 1, 10, 21), 70)
|
|
60
|
+
const points = store[seqKey].points
|
|
61
|
+
assert.equal(points.length, 2)
|
|
62
|
+
assert.equal(points[1].t, Date.UTC(2026, 0, 1, 10, 20))
|
|
63
|
+
assert.equal(points[1].v, 70)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('场景: 超期快照修剪', () => {
|
|
67
|
+
const store = newSequenceStore()
|
|
68
|
+
const seqKey = 'acct-1:5h'
|
|
69
|
+
const oldT = Date.UTC(2026, 0, 1, 10, 0)
|
|
70
|
+
const newT = oldT + RETENTION_POINTS['10m'] * TEN_MIN + TEN_MIN
|
|
71
|
+
store[seqKey] = { granularity: '10m', points: [{ t: oldT, v: 1 }, { t: newT, v: 2 }] }
|
|
72
|
+
pruneSequence(store, seqKey, newT)
|
|
73
|
+
assert.equal(store[seqKey].points.length, 1)
|
|
74
|
+
assert.equal(store[seqKey].points[0].v, 2)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('场景: 硬点数上限丢最旧', () => {
|
|
78
|
+
const store = newSequenceStore()
|
|
79
|
+
const seqKey = 'acct-1:balance'
|
|
80
|
+
const start = Date.UTC(2026, 0, 1, 0, 0)
|
|
81
|
+
const cap = HARD_POINT_CAP['1h']
|
|
82
|
+
// 直接构造超上限序列:异常高频写入场景,正常轮询由时间修剪收敛
|
|
83
|
+
store[seqKey] = {
|
|
84
|
+
granularity: '1h',
|
|
85
|
+
points: Array.from({ length: cap + 1 }, (_, i) => ({ t: start + i * 10 * 1000, v: i })),
|
|
86
|
+
}
|
|
87
|
+
appendPoint(store, seqKey, start + (cap + 1) * 10 * 1000, cap + 1, start + (cap + 1) * 10 * 1000)
|
|
88
|
+
assert.equal(store[seqKey].points.length, cap, '序列长度不超上限')
|
|
89
|
+
assert.equal(store[seqKey].points[0].v, 1, '最旧点被丢弃')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('场景: 追加快照时先时间修剪后点数上限', () => {
|
|
93
|
+
const store = newSequenceStore()
|
|
94
|
+
const seqKey = 'acct-1:5h'
|
|
95
|
+
const start = Date.UTC(2026, 0, 1, 0, 0)
|
|
96
|
+
const cap = HARD_POINT_CAP['10m']
|
|
97
|
+
const retentionMs = RETENTION_POINTS['10m'] * GRANULARITY_MS['10m']
|
|
98
|
+
// 构造留存期内的超上限序列(高频异常),验证修剪后仍受硬上限约束
|
|
99
|
+
store[seqKey] = {
|
|
100
|
+
granularity: '10m',
|
|
101
|
+
points: Array.from({ length: cap + 1 }, (_, i) => ({ t: start + i * 30 * 1000, v: i })),
|
|
102
|
+
}
|
|
103
|
+
appendPoint(store, seqKey, start + retentionMs, 999, start + retentionMs)
|
|
104
|
+
const points = store[seqKey].points
|
|
105
|
+
assert.ok(points.length <= cap)
|
|
106
|
+
assert.equal(points[points.length - 1].v, 999)
|
|
107
|
+
assert.ok(points.every((p) => p.t >= start + retentionMs - retentionMs), '全部点在留存期内')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('场景: 读数取样产出序列键与数值', () => {
|
|
111
|
+
const quota = {
|
|
112
|
+
kind: 'quota',
|
|
113
|
+
windows: [
|
|
114
|
+
{ label: '5小时', remaining: 30, utilization: 70 },
|
|
115
|
+
{ label: '7天', remaining: null, utilization: 40 },
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
const snaps = readingToSnapshots(quota)
|
|
119
|
+
assert.deepEqual(snaps, [
|
|
120
|
+
{ suffix: '5h', value: 30, tier: '10m' },
|
|
121
|
+
{ suffix: '7d', value: 40, tier: '1h' },
|
|
122
|
+
])
|
|
123
|
+
const balance = { kind: 'balance', entries: [{ currency: 'USD', remaining: 12.5, total: 20 }] }
|
|
124
|
+
assert.deepEqual(readingToSnapshots(balance), [{ suffix: 'balance', value: 12.5, tier: '1h' }])
|
|
125
|
+
const balanceTotalOnly = { kind: 'balance', entries: [{ currency: 'CNY', remaining: null, total: 88 }] }
|
|
126
|
+
assert.deepEqual(readingToSnapshots(balanceTotalOnly), [{ suffix: 'balance', value: 88, tier: '1h' }])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('场景: 月窗口按余额快照聚合当月日历月', () => {
|
|
130
|
+
const store = newSequenceStore()
|
|
131
|
+
appendPoint(store, 'acct-1:balance', Date.UTC(2026, 0, 3, 0, 0), 100)
|
|
132
|
+
appendPoint(store, 'acct-1:balance', Date.UTC(2026, 0, 5, 0, 0), 80)
|
|
133
|
+
appendPoint(store, 'acct-1:balance', Date.UTC(2025, 11, 20, 0, 0), 150)
|
|
134
|
+
const now = Date.UTC(2026, 0, 6, 0, 0)
|
|
135
|
+
buildMonthSequence(store, 'acct-1', now)
|
|
136
|
+
const month = store['acct-1:month']
|
|
137
|
+
assert.ok(month)
|
|
138
|
+
assert.equal(month.granularity, '1h')
|
|
139
|
+
assert.deepEqual(month.points, [
|
|
140
|
+
{ t: Date.UTC(2026, 0, 3, 0, 0), v: 100 },
|
|
141
|
+
{ t: Date.UTC(2026, 0, 5, 0, 0), v: 80 },
|
|
142
|
+
], '仅保留当月日历月的余额点,跨月点剔除')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('场景: 月窗口档内对齐去重与留存同规则', () => {
|
|
146
|
+
const store = newSequenceStore()
|
|
147
|
+
appendPoint(store, 'acct-1:balance', Date.UTC(2026, 0, 3, 0, 30), 100)
|
|
148
|
+
const now = Date.UTC(2026, 0, 4, 0, 0)
|
|
149
|
+
buildMonthSequence(store, 'acct-1', now)
|
|
150
|
+
assert.deepEqual(store['acct-1:month'].points, [{ t: Date.UTC(2026, 0, 3, 1, 0), v: 100 }])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('场景: 月窗口月界取本地时区月初零点,跨月点剔除', () => {
|
|
154
|
+
const store = newSequenceStore()
|
|
155
|
+
const monthStart = new Date(2026, 2, 1).getTime()
|
|
156
|
+
const beforeMonth = monthStart - HOUR
|
|
157
|
+
appendPoint(store, 'acct-1:balance', beforeMonth, 10)
|
|
158
|
+
appendPoint(store, 'acct-1:balance', monthStart, 20)
|
|
159
|
+
buildMonthSequence(store, 'acct-1', monthStart + HOUR)
|
|
160
|
+
assert.deepEqual(store['acct-1:month'].points, [{ t: monthStart, v: 20 }], '上月末尾点不进本月窗口')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('场景: 粒度毫秒与留存点数表', () => {
|
|
164
|
+
assert.equal(GRANULARITY_MS['10m'], 10 * MIN)
|
|
165
|
+
assert.equal(GRANULARITY_MS['1h'], HOUR)
|
|
166
|
+
assert.equal(RETENTION_POINTS['10m'], 7 * 24 * 6)
|
|
167
|
+
assert.equal(RETENTION_POINTS['1h'], 30 * 24)
|
|
168
|
+
assert.equal(HARD_POINT_CAP['10m'], 2 * 7 * 24 * 6)
|
|
169
|
+
assert.equal(HARD_POINT_CAP['1h'], 2 * 30 * 24)
|
|
170
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// 历史文件持久化守卫 BDD:坏文件备份与写入拒绝、恢复解除损坏标记。真实 fs + 临时目录。
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { createHistoryStore, HISTORY_BROKEN_MESSAGE } from '../src/historyStore.mjs'
|
|
8
|
+
|
|
9
|
+
test('场景: 坏 JSON 注入触发落盘保护,坏文件备份为 .bak 且 persist 拒绝写入', async () => {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'usage-panel-history-'))
|
|
11
|
+
const file = join(dir, 'history.json')
|
|
12
|
+
try {
|
|
13
|
+
await writeFile(file, '{broken json', 'utf8')
|
|
14
|
+
const store = createHistoryStore({ file })
|
|
15
|
+
await store.ensure()
|
|
16
|
+
assert.equal(store.broken, true)
|
|
17
|
+
await assert.rejects(store.persist(), (error) => error.message === HISTORY_BROKEN_MESSAGE)
|
|
18
|
+
const backup = await readFile(file + '.bak', 'utf8')
|
|
19
|
+
assert.equal(backup, '{broken json', '坏文件完整保留在备份中')
|
|
20
|
+
await assert.rejects(readFile(file, 'utf8'), (error) => error.code === 'ENOENT', '原坏文件已被移走')
|
|
21
|
+
} finally {
|
|
22
|
+
await rm(dir, { recursive: true, force: true })
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('场景: 备份后重读成功解除损坏标记,写入恢复', async () => {
|
|
27
|
+
const dir = await mkdtemp(join(tmpdir(), 'usage-panel-history-'))
|
|
28
|
+
const file = join(dir, 'history.json')
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(file, 'not json', 'utf8')
|
|
31
|
+
const store = createHistoryStore({ file })
|
|
32
|
+
await store.ensure()
|
|
33
|
+
assert.equal(store.broken, true)
|
|
34
|
+
await store.ensure()
|
|
35
|
+
assert.equal(store.broken, false, '坏文件已备份,重读 ENOENT 视为恢复')
|
|
36
|
+
store.sequences['acct-1:balance'] = { granularity: '1h', points: [] }
|
|
37
|
+
await store.persist()
|
|
38
|
+
const saved = JSON.parse(await readFile(file, 'utf8'))
|
|
39
|
+
assert.ok(saved.sequences['acct-1:balance'])
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(dir, { recursive: true, force: true })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('场景: 文件不存在视为首次使用,不标记损坏', async () => {
|
|
46
|
+
const dir = await mkdtemp(join(tmpdir(), 'usage-panel-history-'))
|
|
47
|
+
const file = join(dir, 'history.json')
|
|
48
|
+
try {
|
|
49
|
+
const store = createHistoryStore({ file })
|
|
50
|
+
await store.ensure()
|
|
51
|
+
assert.equal(store.broken, false)
|
|
52
|
+
await store.persist()
|
|
53
|
+
assert.ok(JSON.parse(await readFile(file, 'utf8')).sequences)
|
|
54
|
+
} finally {
|
|
55
|
+
await rm(dir, { recursive: true, force: true })
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('场景: 写链毒化防护——单次写入失败不永久断写', async () => {
|
|
60
|
+
const dir = await mkdtemp(join(tmpdir(), 'usage-panel-history-'))
|
|
61
|
+
const file = join(dir, 'history.json')
|
|
62
|
+
const writes = []
|
|
63
|
+
try {
|
|
64
|
+
const store = createHistoryStore({
|
|
65
|
+
file,
|
|
66
|
+
io: {
|
|
67
|
+
mkdir: async () => {},
|
|
68
|
+
readFile: async () => { const e = new Error('no'); e.code = 'ENOENT'; throw e },
|
|
69
|
+
rename: async () => {},
|
|
70
|
+
// 首次写失败(模拟瞬时 ENOSPC/占用),之后成功;写入内容记录供断言
|
|
71
|
+
writeFile: (() => {
|
|
72
|
+
let calls = 0
|
|
73
|
+
return async (_file, text) => {
|
|
74
|
+
calls += 1
|
|
75
|
+
if (calls === 1) throw new Error('ENOSPC')
|
|
76
|
+
writes.push(text)
|
|
77
|
+
}
|
|
78
|
+
})(),
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
await store.ensure()
|
|
82
|
+
store.sequences['a:balance'] = { granularity: '1h', points: [] }
|
|
83
|
+
await assert.rejects(store.persist(), /ENOSPC/, '调用方感知单次失败')
|
|
84
|
+
store.sequences['b:balance'] = { granularity: '1h', points: [] }
|
|
85
|
+
await store.persist()
|
|
86
|
+
// 后续写入不受毒化链影响,内容含两次写入的全部序列
|
|
87
|
+
assert.equal(writes.length, 1)
|
|
88
|
+
const saved = JSON.parse(writes[0])
|
|
89
|
+
assert.ok(saved.sequences['a:balance'] && saved.sequences['b:balance'])
|
|
90
|
+
} finally {
|
|
91
|
+
await rm(dir, { recursive: true, force: true })
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('场景: 落盘为紧凑 JSON(无缩进)', async () => {
|
|
96
|
+
const dir = await mkdtemp(join(tmpdir(), 'usage-panel-history-'))
|
|
97
|
+
const file = join(dir, 'history.json')
|
|
98
|
+
try {
|
|
99
|
+
const store = createHistoryStore({ file })
|
|
100
|
+
await store.ensure()
|
|
101
|
+
await store.persist()
|
|
102
|
+
const text = await readFile(file, 'utf8')
|
|
103
|
+
assert.equal(text, JSON.stringify({ sequences: {} }), '序列化无第三参缩进')
|
|
104
|
+
} finally {
|
|
105
|
+
await rm(dir, { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
})
|