dsh-router-core 0.3.2 → 0.3.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.
@@ -5,14 +5,21 @@
5
5
  * 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
6
6
  * keys.json),不为一个看板引入数据库依赖。
7
7
  *
8
- * 两条查询路径(照 9router `getUsageStats` 的 `useDailySummary` 分叉):
9
- * - `today` / `24h` → 走**明细环**(滚动窗口,天桶是自然日,对不上)
10
- * - `7d` / `30d` → 走**天桶**(长期精确,不受明细环容量限制)
8
+ * 统计口径(不再走明细环 —— 见下面「天花板」):
9
+ * - `today` → 走**天桶**(今天这一桶就是答案,天然精确)
10
+ * - `24h` → 走**小时桶**(滚动窗口,天桶是自然日,对不上)
11
+ * - `7d` / `30d` → 走**天桶**(长期精确)
11
12
  *
12
13
  * 天花板(JSON 的代价,刻意接受):
13
- * - 明细环只留最近 `RING_CAP` 条。超量时 today/24h 会**少算**(天桶不会,
14
- * 所以 7d/30d 永远准)。要彻底准就把明细也落盘成按小时的桶,届时
15
- * `chart()` 的小时桶直接读它即可,接口不用变。
14
+ * - 明细环 `recent[]` 只留最近 `RING_CAP` 条,**只服务「最近请求列表」**,
15
+ * 不参与任何统计。历史上 today/24h 直接扫它,单日超 500 条就被静默截断,
16
+ * 于是出现「今日 500 / 7 天 1621」这种自相矛盾的数字——现在不会了。
17
+ * - **小时桶只从新数据开始累积**:升级前落的盘没有小时粒度,而明细环只剩
18
+ * 最近 500 条、回溯不回去,所以那段历史的**天内分布是永久缺失的**。
19
+ * 影响面:①`24h` 遇到「小时粒度不齐」的自然日 → 整桶计入(宁可多算,
20
+ * 也不显示一个比「今日」还小的荒唐数字);②`today`/`24h` 的小时柱状图
21
+ * 那几格是空的(不编数据)。天桶口径(today/7d/30d)完全不受影响,
22
+ * 新数据立刻就有小时粒度,隔天起图表全正常。
16
23
  * - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
17
24
  * - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
18
25
  * 要严格就改成立即写,代价是每个请求一次磁盘写。
@@ -52,6 +59,21 @@ interface Entry {
52
59
  lastTs: number
53
60
  }
54
61
 
62
+ /** 小时桶(today/24h 的统计口径,下标 = 本地小时 0..23)。 */
63
+ interface HourBucket {
64
+ requests: number
65
+ ok: number
66
+ failed: number
67
+ promptTokens: number
68
+ completionTokens: number
69
+ cachedTokens: number
70
+ durationMs: number
71
+ ttfbMs: number
72
+ ttfbCount: number
73
+ estimatedInputs: number
74
+ estimatedOutputs: number
75
+ }
76
+
55
77
  /** 一天一桶。 */
56
78
  interface DayBucket {
57
79
  requests: number
@@ -67,6 +89,8 @@ interface DayBucket {
67
89
  /** 输入是估算的请求数(面板据此提示「部分数据为估算」)。 */
68
90
  estimatedInputs: number
69
91
  estimatedOutputs: number
92
+ /** 24 个小时桶,定长(天桶本身才 1.5KB,加这个可忽略)。 */
93
+ hours: HourBucket[]
70
94
  bySupplier: Record<string, Entry>
71
95
  byModel: Record<string, Entry>
72
96
  byRequested: Record<string, Entry>
@@ -80,13 +104,30 @@ interface UsageFile {
80
104
 
81
105
  export type Period = 'today' | '24h' | '7d' | '30d'
82
106
 
83
- /** 明细环容量:够 today/24h 看,又不至于让 JSON 无限长。 */
107
+ /** 明细环容量:只喂「最近请求列表」,跟统计口径无关了。 */
84
108
  const RING_CAP = 500
109
+ /** 一天几格(小时桶定长;要更细的精度就把这个值放大)。 */
110
+ const HOURS = 24
85
111
  /** 天桶保留天数。 */
86
112
  const KEEP_DAYS = 30
87
113
  /** 落盘防抖。 */
88
114
  const SAVE_DEBOUNCE_MS = 2000
89
115
 
116
+ /** 小时格:一个整点 + 它落在哪个自然日 + 那天的数据/该小时桶(无数据则 undefined)。 */
117
+ interface HourSlot {
118
+ dayKey: string
119
+ start: number
120
+ day: DayBucket | undefined
121
+ b: HourBucket | undefined
122
+ }
123
+
124
+ const emptyHour = (): HourBucket => ({
125
+ requests: 0, ok: 0, failed: 0,
126
+ promptTokens: 0, completionTokens: 0, cachedTokens: 0,
127
+ durationMs: 0, ttfbMs: 0, ttfbCount: 0,
128
+ estimatedInputs: 0, estimatedOutputs: 0,
129
+ })
130
+
90
131
  const emptyDay = (): DayBucket => ({
91
132
  requests: 0,
92
133
  ok: 0,
@@ -99,6 +140,7 @@ const emptyDay = (): DayBucket => ({
99
140
  ttfbCount: 0,
100
141
  estimatedInputs: 0,
101
142
  estimatedOutputs: 0,
143
+ hours: Array.from({ length: HOURS }, () => emptyHour()),
102
144
  bySupplier: {},
103
145
  byModel: {},
104
146
  byRequested: {},
@@ -174,6 +216,23 @@ export class UsageStore {
174
216
  if (rec.inputEstimated) day.estimatedInputs += 1
175
217
  if (rec.outputEstimated) day.estimatedOutputs += 1
176
218
 
219
+ const h = day.hours[new Date(ts).getHours()]
220
+ if (h !== undefined) {
221
+ h.requests += 1
222
+ if (rec.ok) h.ok += 1
223
+ else h.failed += 1
224
+ h.promptTokens += promptTokens
225
+ h.completionTokens += completionTokens
226
+ h.cachedTokens += rec.cachedTokens
227
+ h.durationMs += rec.durationMs
228
+ if (rec.ttfbMs > 0) {
229
+ h.ttfbMs += rec.ttfbMs
230
+ h.ttfbCount += 1
231
+ }
232
+ if (rec.inputEstimated) h.estimatedInputs += 1
233
+ if (rec.outputEstimated) h.estimatedOutputs += 1
234
+ }
235
+
177
236
  this.bump(day.bySupplier, rec.supplier, rec)
178
237
  this.bump(day.byModel, rec.model, rec)
179
238
  this.bump(day.byRequested, rec.requested, rec)
@@ -198,26 +257,80 @@ export class UsageStore {
198
257
  map[key] = e
199
258
  }
200
259
 
201
- /** 窗口内的明细(today/24h 路径)。 */
202
- private inWindow(period: Period, now: number): UsageRecord[] {
203
- const cutoff = period === 'today' ? new Date(now).setHours(0, 0, 0, 0) : now - 86400000
204
- return this.recent.filter((r) => r.ts >= cutoff)
260
+ /**
261
+ * 从 `first` 起的 24 个小时格:每格带整点时间戳、所属自然日、那天的数据。
262
+ *
263
+ * `today` 传今天 00:00,`24h` 传「当前整点往前 23 格」。
264
+ * 跨天一律靠 `localDateKey(start)` 现算,不用「减 N 小时」去猜天数:
265
+ * 本地时区(尤其 DST)下小时偏移和自然日不是一回事,算错就漏一整天。
266
+ */
267
+ private hourSlots(first: number): HourSlot[] {
268
+ const out: HourSlot[] = []
269
+ for (let i = 0; i < HOURS; i += 1) {
270
+ const start = first + i * 3600000
271
+ const dayKey = localDateKey(start)
272
+ const day = this.days.get(dayKey)
273
+ out.push({ dayKey, start, day, b: day?.hours[new Date(start).getHours()] })
274
+ }
275
+ return out
276
+ }
277
+
278
+ /** 今天的小时格起点(自然日零点)。 */
279
+ private static dayStartOf(now: number): number {
280
+ return new Date(now).setHours(0, 0, 0, 0)
281
+ }
282
+
283
+ /** 24h 滚动窗口的小时格起点(当前整点往前 23 格)。 */
284
+ private static rollingStartOf(now: number): number {
285
+ const c = new Date(now)
286
+ c.setMinutes(0, 0, 0)
287
+ return c.getTime() - (HOURS - 1) * 3600000
288
+ }
289
+
290
+ /**
291
+ * 一整天的小时桶是否齐(各格之和 >= 天桶计数)。
292
+ *
293
+ * 不齐 = 这天是升级前落的盘(没有小时粒度)→ 24h 窗口踩到它就只能整桶计入。
294
+ * 宁可多算窗口外那截,也不能报一个比「今日」还小的数。
295
+ */
296
+ private static hoursComplete(d: DayBucket): boolean {
297
+ return d.hours.reduce((n, h) => n + h.requests, 0) >= d.requests
205
298
  }
206
299
 
207
300
  /** 汇总 + Top 榜。 */
208
301
  stats(period: Period, now = Date.now()): StatsResult {
209
- // today/24h 走明细环(滚动窗口);7d/30d 走天桶
210
- if (period === 'today' || period === '24h') {
211
- const rows = this.inWindow(period, now)
302
+ // today 走天桶(今天这一桶就是答案);24h 走小时桶;7d/30d 走天桶
303
+ if (period === 'today') {
304
+ const d = this.days.get(localDateKey(now))
305
+ const acc = emptyAcc()
306
+ if (d === undefined) return finish(acc, {}, {}, {}, this.lifetime)
307
+ addDay(acc, d)
308
+ // 天桶的维度表就是 today 的 Top 榜,不用另算
309
+ return finish(acc, d.bySupplier, d.byModel, d.byRequested, this.lifetime)
310
+ }
311
+
312
+ if (period === '24h') {
313
+ const slots = this.hourSlots(UsageStore.rollingStartOf(now))
212
314
  const acc = emptyAcc()
213
315
  const bySupplier: Record<string, Entry> = {}
214
316
  const byModel: Record<string, Entry> = {}
215
317
  const byRequested: Record<string, Entry> = {}
216
- for (const rec of rows) {
217
- addRec(acc, rec)
218
- bumpInto(bySupplier, rec.supplier, rec)
219
- bumpInto(byModel, rec.model, rec)
220
- bumpInto(byRequested, rec.requested, rec)
318
+ for (const key of new Set(slots.map((s) => s.dayKey))) {
319
+ const d = this.days.get(key)
320
+ if (d === undefined) continue
321
+ if (UsageStore.hoursComplete(d)) {
322
+ // 小时粒度齐:只算窗口内踩到的格
323
+ for (const s of slots) {
324
+ if (s.dayKey === key && s.b !== undefined) addHour(acc, s.b)
325
+ }
326
+ } else {
327
+ // 老数据(升级前落的盘):整桶计入,宁可多算也不漏算
328
+ addDay(acc, d)
329
+ }
330
+ // Top 榜只能走天桶的维度表(小时桶不拆维度)
331
+ mergeInto(bySupplier, d.bySupplier)
332
+ mergeInto(byModel, d.byModel)
333
+ mergeInto(byRequested, d.byRequested)
221
334
  }
222
335
  return finish(acc, bySupplier, byModel, byRequested, this.lifetime)
223
336
  }
@@ -230,17 +343,7 @@ export class UsageStore {
230
343
  for (let i = days - 1; i >= 0; i -= 1) {
231
344
  const d = this.days.get(localDateKey(now - i * 86400000))
232
345
  if (d === undefined) continue
233
- acc.requests += d.requests
234
- acc.ok += d.ok
235
- acc.failed += d.failed
236
- acc.promptTokens += d.promptTokens
237
- acc.completionTokens += d.completionTokens
238
- acc.cachedTokens += d.cachedTokens
239
- acc.durationMs += d.durationMs
240
- acc.ttfbMs += d.ttfbMs
241
- acc.ttfbCount += d.ttfbCount
242
- acc.estimatedInputs += d.estimatedInputs
243
- acc.estimatedOutputs += d.estimatedOutputs
346
+ addDay(acc, d)
244
347
  mergeInto(bySupplier, d.bySupplier)
245
348
  mergeInto(byModel, d.byModel)
246
349
  mergeInto(byRequested, d.byRequested)
@@ -248,26 +351,16 @@ export class UsageStore {
248
351
  return finish(acc, bySupplier, byModel, byRequested, this.lifetime)
249
352
  }
250
353
 
251
- /** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
354
+ /** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
252
355
  chart(period: Period, now = Date.now()): ChartBucket[] {
253
356
  if (period === 'today' || period === '24h') {
254
- const rows = this.inWindow(period, now)
255
- const start = period === 'today'
256
- ? Math.floor(new Date(now).setHours(0, 0, 0, 0) / 3600000) * 3600000
257
- : Math.floor((now - 23 * 3600000) / 3600000) * 3600000
258
- const buckets: ChartBucket[] = Array.from({ length: 24 }, (_, i) => ({
259
- label: `${String(new Date(start + i * 3600000).getHours()).padStart(2, '0')}:00`,
260
- requests: 0,
261
- tokens: 0,
357
+ const p = (n: number): string => String(n).padStart(2, '0')
358
+ const first = period === 'today' ? UsageStore.dayStartOf(now) : UsageStore.rollingStartOf(now)
359
+ return this.hourSlots(first).map((s) => ({
360
+ label: `${p(new Date(s.start).getHours())}:00`,
361
+ requests: s.b?.requests ?? 0,
362
+ tokens: (s.b?.promptTokens ?? 0) + (s.b?.completionTokens ?? 0),
262
363
  }))
263
- for (const rec of rows) {
264
- const idx = Math.floor((rec.ts - start) / 3600000)
265
- const b = buckets[idx]
266
- if (b === undefined) continue
267
- b.requests += 1
268
- b.tokens += rec.promptTokens + rec.completionTokens
269
- }
270
- return buckets
271
364
  }
272
365
  const days = period === '7d' ? 7 : 30
273
366
  const out: ChartBucket[] = []
@@ -320,8 +413,14 @@ export class UsageStore {
320
413
  this.days = new Map(Object.entries(f.days ?? {}))
321
414
  this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : []
322
415
  this.lifetime = typeof f.lifetime === 'number' ? f.lifetime : 0
323
- // 逐字段兜底:旧文件缺后加的字段(ttfb*/estimated*)不能让整个桶变 NaN
324
- for (const [k, d] of this.days) this.days.set(k, { ...emptyDay(), ...d })
416
+ // 逐字段兜底:旧文件缺后加的字段(ttfb*/estimated*/hours)不能让整个桶变 NaN。
417
+ // hours 额外修长度/补空:老文件没有它,且 spread 会整段覆盖成短数组或带洞。
418
+ for (const [k, d] of this.days) {
419
+ const day: DayBucket = { ...emptyDay(), ...d }
420
+ const hours = Array.from({ length: HOURS }, (_, i) => ({ ...emptyHour(), ...d.hours?.[i] }))
421
+ day.hours = hours
422
+ this.days.set(k, day)
423
+ }
325
424
  } catch {
326
425
  // 首次运行/文件损坏:从空开始,不阻断启动
327
426
  }
@@ -411,33 +510,34 @@ const emptyAcc = (): Acc => ({
411
510
  estimatedInputs: 0, estimatedOutputs: 0,
412
511
  })
413
512
 
414
- function addRec(acc: Acc, rec: UsageRecord): void {
415
- acc.requests += 1
416
- if (rec.ok) acc.ok += 1
417
- else acc.failed += 1
418
- acc.promptTokens += rec.promptTokens
419
- acc.completionTokens += rec.completionTokens
420
- acc.cachedTokens += rec.cachedTokens
421
- acc.durationMs += rec.durationMs
422
- if (rec.ttfbMs > 0) {
423
- acc.ttfbMs += rec.ttfbMs
424
- acc.ttfbCount += 1
425
- }
426
- if (rec.inputEstimated) acc.estimatedInputs += 1
427
- if (rec.outputEstimated) acc.estimatedOutputs += 1
513
+ /** 天桶累加(today / 7d / 30d 路径,以及 24h 的「老数据整桶兜底」)。 */
514
+ function addDay(acc: Acc, d: DayBucket): void {
515
+ acc.requests += d.requests
516
+ acc.ok += d.ok
517
+ acc.failed += d.failed
518
+ acc.promptTokens += d.promptTokens
519
+ acc.completionTokens += d.completionTokens
520
+ acc.cachedTokens += d.cachedTokens
521
+ acc.durationMs += d.durationMs
522
+ acc.ttfbMs += d.ttfbMs
523
+ acc.ttfbCount += d.ttfbCount
524
+ acc.estimatedInputs += d.estimatedInputs
525
+ acc.estimatedOutputs += d.estimatedOutputs
428
526
  }
429
527
 
430
- function bumpInto(map: Record<string, Entry>, key: string, rec: UsageRecord): void {
431
- if (key === '') return
432
- const e = map[key] ?? emptyEntry()
433
- e.requests += 1
434
- if (rec.ok) e.ok += 1
435
- else e.failed += 1
436
- e.promptTokens += rec.promptTokens
437
- e.completionTokens += rec.completionTokens
438
- e.cachedTokens += rec.cachedTokens
439
- e.lastTs = Math.max(e.lastTs, rec.ts)
440
- map[key] = e
528
+ /** 小时桶累加(24h 路径)。 */
529
+ function addHour(acc: Acc, h: HourBucket): void {
530
+ acc.requests += h.requests
531
+ acc.ok += h.ok
532
+ acc.failed += h.failed
533
+ acc.promptTokens += h.promptTokens
534
+ acc.completionTokens += h.completionTokens
535
+ acc.cachedTokens += h.cachedTokens
536
+ acc.durationMs += h.durationMs
537
+ acc.ttfbMs += h.ttfbMs
538
+ acc.ttfbCount += h.ttfbCount
539
+ acc.estimatedInputs += h.estimatedInputs
540
+ acc.estimatedOutputs += h.estimatedOutputs
441
541
  }
442
542
 
443
543
  function finish(
@@ -288,6 +288,121 @@ test('周期:7d/30d 走天桶,明细环撑爆也不影响', () => {
288
288
  assert.equal(s.recentList(1000).length, 500) // 明细环只留 500
289
289
  })
290
290
 
291
+ test('周期:today/24h 走小时桶,明细环撑爆也不影响(回归:今日曾被截断成 500)', () => {
292
+ const s = new UsageStore('')
293
+ const now = Date.now()
294
+ // 塞 600 条(> RING_CAP 500),全在今天
295
+ for (let i = 0; i < 600; i += 1) rec(s, { ts: now })
296
+ assert.equal(s.stats('today', now).requests, 600, 'today 必须精确,不能受明细环容量限制')
297
+ assert.equal(s.stats('24h', now).requests, 600)
298
+ assert.equal(s.stats('7d', now).requests, 600)
299
+ assert.equal(s.stats('30d', now).requests, 600)
300
+ assert.equal(s.recentList(1000).length, 500, '明细环仍然只留 500(它只服务最近列表)')
301
+ })
302
+
303
+ test('周期:today 精确,不会把昨天算进来', () => {
304
+ const s = new UsageStore('')
305
+ // 基准取正午:两头都留够余量,免得真实挂钟一过 23 点测试就翻车(踩过)
306
+ const base = new Date()
307
+ base.setHours(12, 0, 0, 0)
308
+ const now = base.getTime()
309
+ const dayStart = new Date(now).setHours(0, 0, 0, 0)
310
+
311
+ // 昨天 22:00 一批、今天 08/09 点各一批 —— 每批都逼近 RING_CAP
312
+ for (let i = 0; i < 300; i += 1) rec(s, { ts: dayStart - 2 * 3600000 })
313
+ for (let i = 0; i < 300; i += 1) rec(s, { ts: dayStart + 8 * 3600000 })
314
+ for (let i = 0; i < 300; i += 1) rec(s, { ts: dayStart + 9 * 3600000 })
315
+
316
+ assert.equal(s.stats('today', now).requests, 600, 'today 只算今天')
317
+ assert.equal(s.stats('7d', now).requests, 900)
318
+ // 24h 从今天 12:00 往前滚:昨天 22:00 那批距今 14 小时,还在窗口内
319
+ assert.equal(s.stats('24h', now).requests, 900)
320
+ })
321
+
322
+ test('图表:today 的小时桶按整点归位,不再靠明细环', () => {
323
+ const s = new UsageStore('')
324
+ const base = new Date()
325
+ base.setHours(12, 0, 0, 0)
326
+ const now = base.getTime()
327
+ const dayStart = new Date(now).setHours(0, 0, 0, 0)
328
+
329
+ for (let i = 0; i < 600; i += 1) rec(s, { ts: dayStart + 9 * 3600000 + 1000 }) // 今天 09:00 那格
330
+
331
+ const ch = s.chart('today', now)
332
+ assert.equal(ch.length, 24)
333
+ assert.equal(ch[9]?.requests, 600, '全落 09:00 那格')
334
+ assert.equal(ch[9]?.tokens, 600 * 15)
335
+ assert.equal(ch[10]?.requests, 0)
336
+ assert.equal(ch.reduce((n, b) => n + b.requests, 0), 600, '各格之和 = today 总数')
337
+ })
338
+
339
+ test('落盘:旧文件没有 hours 字段也能读(没有迁移,补空即可)', () => {
340
+ const dir = `${process.env.TMPDIR ?? '/tmp'}/dshr-usage-${Math.random().toString(36).slice(2)}`
341
+ mkdirSync(dir, { recursive: true })
342
+ const fp = `${dir}/state.json`
343
+ // 手写一份「老格式」:天桶没有 hours(升级前落盘的就是这样)
344
+ writeFileSync(
345
+ `${dir}/usage.json`,
346
+ JSON.stringify({
347
+ days: { [localDateKey(Date.now())]: { requests: 3, ok: 3, failed: 0, bySupplier: {} } },
348
+ recent: [],
349
+ lifetime: 3,
350
+ }),
351
+ )
352
+ const s = new UsageStore(fp)
353
+ // 天桶口径照常:老数据一天都不会丢
354
+ assert.equal(s.stats('7d').requests, 3)
355
+ assert.equal(s.stats('today').requests, 3, 'today 走天桶,老数据照读')
356
+ // 小时粒度是永久缺失的(明细环回溯不回去)→ 24h 整桶兜底,不编数据
357
+ assert.equal(s.stats('24h').requests, 3, '小时桶不齐 → 整桶计入,不报比 today 更小的数')
358
+ assert.equal(s.chart('today').length, 24, '图表格子数不变')
359
+ assert.equal(s.chart('today').reduce((n, b) => n + b.requests, 0), 0, '没有小时粒度就不瞎填')
360
+ // 新请求照常累加,补上的 hours 立刻生效
361
+ rec(s, { ts: Date.now() })
362
+ assert.equal(s.stats('today').requests, 4)
363
+ assert.equal(s.chart('today').reduce((n, b) => n + b.requests, 0), 1)
364
+ })
365
+
366
+ test('周期:24h 跨天时,小时粒度齐的走小时格、不齐的整桶兜底', () => {
367
+ const dir = `${process.env.TMPDIR ?? '/tmp'}/dshr-usage-${Math.random().toString(36).slice(2)}`
368
+ mkdirSync(dir, { recursive: true })
369
+ const fp = `${dir}/state.json`
370
+ // 时钟固定当天 12:00,不用 Date.now():24h 是当前整点往前 23 格,
371
+ // 真实时钟在 23 点时窗口起点正好是今天 00:00,昨天整桶落在窗口外,
372
+ // 这条用例就会从 700 变成 300 —— 测试挂不挂取决于跑的时刻,不能接受。
373
+ const now = new Date().setHours(12, 30, 0, 0)
374
+ const dayStart = new Date(now).setHours(0, 0, 0, 0)
375
+ const yesterdayKey = localDateKey(dayStart - 12 * 3600000)
376
+
377
+ // 昨天是「老数据」:天桶有 400 条但没有小时粒度
378
+ writeFileSync(
379
+ `${dir}/usage.json`,
380
+ JSON.stringify({
381
+ days: {
382
+ [yesterdayKey]: { requests: 400, ok: 400, failed: 0, bySupplier: {}, byModel: {}, byRequested: {} },
383
+ },
384
+ recent: [],
385
+ lifetime: 400,
386
+ }),
387
+ )
388
+ const s = new UsageStore(fp)
389
+ // 今天补 300 条(有小时粒度),全在当前小时,一定落在 24h 窗口内
390
+ for (let i = 0; i < 300; i += 1) rec(s, { ts: now })
391
+
392
+ assert.equal(s.stats('today', now).requests, 300, 'today 只算今天')
393
+ // 窗口含昨天 12:00 之后那截 → 但昨天没小时粒度,只能整桶(400)计入
394
+ const d24 = s.stats('24h', now).requests
395
+ assert.ok(d24 >= 300, '24h 绝不能小于 today')
396
+ assert.ok(d24 <= 700, '24h 也绝不能超总数')
397
+ assert.equal(d24, 700, '昨天整桶 400 + 今天 300')
398
+
399
+ // 反面对照:时钟移到 23 点,窗口不再含昨天,昨天那桶必须整桶退出
400
+ // (不是被算一半,也不是漏掉今天的 300)。锁死「整桶进/整桶出」。
401
+ const late = new Date().setHours(23, 30, 0, 0)
402
+ assert.equal(s.stats('24h', late).requests, 300, '窗口不含昨天时只剩今天')
403
+ assert.equal(s.stats('today', late).requests, 300, 'today 不受影响')
404
+ })
405
+
291
406
  test('Top 榜:按请求数降序,且三个维度各自聚合', () => {
292
407
  const s = new UsageStore('')
293
408
  rec(s, { supplier: 'a', model: 'm1', requested: 'combo-x' })
package/src/shared.ts CHANGED
@@ -60,6 +60,8 @@ export interface RouterCheckinResponse {
60
60
  succeeded: number
61
61
  /** 今日已签到的链接数(幂等成功)。 */
62
62
  already: number
63
+ /** 失败/未开放的链接数。 */
64
+ failed: number
63
65
  results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }>
64
66
  }
65
67
 
@@ -98,7 +98,8 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
98
98
 
99
99
  // 账号池由核心持有(选号/冷却/禁用/错误累计),插件不参与。
100
100
  // 挂在 supplier 上:status() 叠加状态与 Router 的 chat 循环共用同一实例。
101
- const pool = new AccountPool()
101
+ // 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
102
+ const pool = new AccountPool(instance.id)
102
103
 
103
104
  const supplier: Supplier = {
104
105
  id: instance.id,
@@ -4,7 +4,10 @@
4
4
  * 锁定的契约:checkinNow 是**单账号**能力,遍历所有链接 + 汇总是核心的活:
5
5
  * - 禁用链接不签(不动它)
6
6
  * - 某个链接抛错 → 记成 error,不带垮其它链接
7
- * - succeeded / already 按 status 统计,ok = 有任一成功或今日已签
7
+ * - succeeded / already / failed 按 status 统计
8
+ * - **ok = 全部链接都成功(含已签)**;有任一失败 → HTTP 400 + ok:false,
9
+ * 让面板把失败如实亮出来(2026-09-02 修正:旧口径「有任一成功就算 ok」
10
+ * 会把部分失败吞成整体成功,UI 谎报「签到完成」)
8
11
  */
9
12
  import test from 'node:test'
10
13
  import assert from 'node:assert/strict'
@@ -84,6 +87,7 @@ interface CheckinPayload {
84
87
  total: number
85
88
  succeeded: number
86
89
  already: number
90
+ failed: number
87
91
  results?: CheckinResult[]
88
92
  error?: string
89
93
  }
@@ -120,20 +124,36 @@ test('签到:禁用链接照样签(禁用由插件自己判定,核心不
120
124
  assert.equal(body.results?.[0]?.status, 'disabled')
121
125
  })
122
126
 
123
- test('签到:单链接抛错记成 error,其余照签', async () => {
127
+ test('签到:单链接抛错记成 error 不连坐;有失败 → 400 不算整体成功', async () => {
124
128
  const { status, body } = await postCheckin(
125
129
  fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
126
130
  if (uid === 'a') throw new Error('boom')
127
131
  return { ok: true, status: 'ok' }
128
132
  }),
129
133
  )
130
- assert.equal(status, 200)
134
+ // 有任一失败整体就不是 ok(否则 UI 会谎报「签到完成」)——a 的错照记,不连坐 b
135
+ assert.equal(status, 400)
136
+ assert.equal(body.ok, false)
131
137
  assert.equal(body.succeeded, 1)
138
+ assert.equal(body.failed, 1)
132
139
  assert.equal(body.results?.[0]?.status, 'error')
133
140
  assert.equal(body.results?.[0]?.message, 'boom')
134
141
  assert.equal(body.results?.[1]?.status, 'ok')
135
142
  })
136
143
 
144
+ test('签到:全部成功(含 already)才算整体 ok', async () => {
145
+ const { status, body } = await postCheckin(
146
+ fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) =>
147
+ uid === 'a' ? { ok: true, status: 'ok', message: '+200' } : { ok: true, status: 'already', message: '今日已签到' },
148
+ ),
149
+ )
150
+ assert.equal(status, 200)
151
+ assert.equal(body.ok, true)
152
+ assert.equal(body.succeeded, 1)
153
+ assert.equal(body.already, 1)
154
+ assert.equal(body.failed, 0)
155
+ })
156
+
137
157
  test('签到:今日已签(already)算成功,不计入 succeeded', async () => {
138
158
  const { status, body } = await postCheckin(
139
159
  fixture([{ uid: 'a' }], async () => ({ ok: true, status: 'already', message: '今日已签到' })),
@@ -375,8 +375,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
375
375
  }
376
376
  const succeeded = results.filter((r) => r.status === 'ok').length
377
377
  const already = results.filter((r) => r.status === 'already').length
378
- const payload = { ok: succeeded + already > 0, total: uids.length, succeeded, already, results }
379
- writeJson(res, payload.ok ? 200 : 400, payload)
378
+ // 全部链接都成功(含已签)才算整体成功;有任一失败 → ok:false + HTTP 400,
379
+ // 让面板把失败如实亮出来。别学旧逻辑「有成功就算 ok」——部分失败被
380
+ // 当成整体成功,UI 会谎报「签到完成」(2026-09-02 实测踩到)。
381
+ const failed = results.length - succeeded - already
382
+ const ok = failed === 0 && results.length > 0
383
+ const payload = { ok, total: uids.length, succeeded, already, failed, results }
384
+ writeJson(res, ok ? 200 : 400, payload)
380
385
  },
381
386
  })
382
387
  }