@mzzsfy/dsh-usage-dash 0.10.4 → 0.10.5

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 CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  规则存于设置存储域,经 `GET/POST /api/usage-dash/pricing` 读写(响应含单调 revision);设置面板编辑器为常规入口。单页内价格请求合并为一个在途请求,缓存有效期内复用;保存成功会同步更新已挂载底栏/费用芯片,并重取面板日范围、热力图及当前时/分数据。迟到的低 revision 不覆盖新价格,其他编辑器未保存草稿保持独立;价格不存本机持久缓存。
29
29
 
30
- 规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/回合费用芯片的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。
30
+ 规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。**服务端单币约束**:pricing POST 整表替换时非空 currency 全表必须一致,混币提交 400 拒绝——费用聚合按数值直接相加,混币相加无意义。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/回合费用芯片的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。**计价时点口径**:时间条件按「估算时点」匹配当前规则表——面板聚合以小时桶时点,回合费用芯片以回合结束事件被观测时刻(用量首达即冻结,重渲染不漂移);规则表变更后按当前规则重算历史为既定选择,不做价格版本化存储。
31
31
 
32
32
  - 模型匹配:`model` 为两段式 `vendor/model`(首个 `/` 分段,模型段允许含 `/`),两段各自可 `*` 通配;匹配链为 全名精确 > 模型名精确(`*/model`,跨供应商同模型名同价)> 供应商精确(`vendor/*`,同供应商多模型同价)> `*/*` 全通,档位相同按数组序取首个命中,高档条件不满足逐层落低档;全链无命中不计费用并计 unpriced。提交(编辑器保存与 pricing POST)强制两段式,单段旧形态(`*` 或裸名)不再合法
33
33
  - 同模型多规则(分时段定价):组内附加计费规则从上到下首个「条件全过」者生效,全不命中落组内默认价(无条件规则,恒兜底);附加规则卡带 ↑/↓ 调序。注意同一模型的多条规则请使用相同的模型键写法——不同写法(如 `*/model` 与 `vendor/model`)分属不同档位,按档位优先级而非数组序取胜
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-dash",
3
3
  "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图、缓存命中率/生成速度/首 token 延迟曲线、模型占比与四桶明细,会话底栏官方数据后追加增强项与官方动作行回合费用芯片,多条件分时段定价规则与费用估算,en/zh 双语;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
- "version": "0.10.4",
4
+ "version": "0.10.5",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -2701,8 +2701,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
2701
2701
  useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
2702
2702
  const { rules: pricingRules } = usePricing()
2703
2703
  const matched = usageSourceBuckets(usageSource)
2704
- const price = matched && prefs.costDisplay && pricingRules !== null
2705
- ? matchPrice(pricingRules, turnModelOf(matched), new Date())
2704
+ // 计价时点 = 回合结束事件被观测的时刻(F15 决策:统一用当时价格、按回合
2705
+ // 事件时点匹配当前规则表)。官方 tokenUsage 无事件时间戳,以用量首次到
2706
+ // 达的观测时刻冻结:后续规则重渲染不再漂移,同一回合计价时点恒定
2707
+ const observedAtRef = useRef(null)
2708
+ if (matched && observedAtRef.current === null) observedAtRef.current = Date.now()
2709
+ const price = matched && prefs.costDisplay && pricingRules !== null && observedAtRef.current !== null
2710
+ ? matchPrice(pricingRules, turnModelOf(matched), new Date(observedAtRef.current))
2706
2711
  : null
2707
2712
  // 0 元(全零价/全零桶)与价未命中同不渲染,无占位符
2708
2713
  const cost = matched && price ? turnCostAmountOf(matched, price) : null
@@ -3201,6 +3206,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
3201
3206
  const heatGenerationRef = useRef(0)
3202
3207
  const panelRef = useRef(null)
3203
3208
  const [heatDays, setHeatDays] = useState(null)
3209
+ // 热力图重取信号(F23):手动刷新/回扫完成即 bump,独立请求随之重取
3210
+ const [heatFetchTick, setHeatFetchTick] = useState(0)
3211
+ const bumpHeatFetch = () => setHeatFetchTick((tick) => tick + 1)
3204
3212
  // 费用展示货币来源:价格规则异步首帧未回时空串即不带符号
3205
3213
  const { rules: pricingRules, tick: pricingTick } = usePricing()
3206
3214
 
@@ -3226,7 +3234,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
3226
3234
  }))
3227
3235
  })
3228
3236
  return () => { heatGenerationRef.current++ }
3229
- }, [pricingTick])
3237
+ }, [pricingTick, heatFetchTick])
3230
3238
 
3231
3239
  const load = useCallback(async () => {
3232
3240
  const request = range === 'custom'
@@ -3305,6 +3313,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
3305
3313
 
3306
3314
  const refresh = () => {
3307
3315
  setError('')
3316
+ // 手动刷新同触热力图重取(F23):重建后热力图口径随日桶更新,
3317
+ // 独立请求此前只随挂载/价格变更重取,重建产物不进热力图
3318
+ bumpHeatFetch()
3308
3319
  if (view === 'day') load()
3309
3320
  else refreshPoints()
3310
3321
  }
@@ -3335,9 +3346,18 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
3335
3346
  baseDone = value.done
3336
3347
  scheduleRefresh()
3337
3348
  }
3349
+ wasRunning = true
3338
3350
  schedule(STATUS_POLL_FAST_MS)
3351
+ } else if (wasRunning) {
3352
+ // running→idle 转换补最终刷新(F23):reset 同步等回扫,响应时 running 已 false,
3353
+ // done 增长分支永不触发——回扫产物的最终态靠此转换收尾(含热力图重取)
3354
+ wasRunning = false
3355
+ scheduleRefresh()
3356
+ bumpHeatFetch()
3357
+ schedule(STATUS_POLL_SLOW_MS)
3339
3358
  }
3340
3359
  }
3360
+ let wasRunning = false
3341
3361
  tick()
3342
3362
  statusMachineRef.current = { restart: () => { baseDone = 0; schedule(STATUS_POLL_FAST_MS) } }
3343
3363
  return () => {
package/src/routes.js CHANGED
@@ -73,20 +73,25 @@ const conditionSchema = z.union(
73
73
  CONDITION_KINDS.map((kind) => z.object({ kind: z.literal(kind), ...CONDITION_FIELD_SCHEMAS[kind] })),
74
74
  )
75
75
 
76
- const pricingRulesSchema = z.array(
77
- z.object({
78
- // 模型键强制两段式 vendor/model(段可通配),单段旧形态提交即拒
79
- model: z.string().refine(isTwoSegmentModel),
80
- unit: z.literal(UNIT_PER_MILLION).optional(),
81
- currency: z.enum(CURRENCIES).nullable(),
82
- price: z.object({
83
- input: z.number().min(0),
84
- output: z.number().min(0),
85
- cacheRead: z.number().min(0),
86
- cacheWrite: z.number().min(0),
87
- }),
88
- conditions: z.array(conditionSchema),
76
+ const pricingRuleShape = z.object({
77
+ // 模型键强制两段式 vendor/model(段可通配),单段旧形态提交即拒
78
+ model: z.string().refine(isTwoSegmentModel),
79
+ unit: z.literal(UNIT_PER_MILLION).optional(),
80
+ currency: z.enum(CURRENCIES).nullable(),
81
+ price: z.object({
82
+ input: z.number().min(0),
83
+ output: z.number().min(0),
84
+ cacheRead: z.number().min(0),
85
+ cacheWrite: z.number().min(0),
89
86
  }),
87
+ conditions: z.array(conditionSchema),
88
+ })
89
+
90
+ const pricingRulesSchema = z.array(pricingRuleShape).refine(
91
+ // 表级单币约束(F15):定价聚合按数值直接相加,混币相加无意义;
92
+ // 非空币种全表必须同一种(null 视为未指定,不参与币种一致对账)
93
+ (rules) => new Set(rules.map((rule) => rule.currency).filter((c) => c !== null)).size <= 1,
94
+ { message: '定价表必须单币种:非空 currency 全表一致' },
90
95
  )
91
96
 
92
97
  // 桶串形态锚定 + 本地时区分量回读,回滚形(13 月/25 时/60 分)当场拒绝,
package/src/store.js CHANGED
@@ -37,6 +37,7 @@ const TABLE_BUCKETS = 'buckets'
37
37
  const MISSING_RECORD_PATTERN = /no record .* to update/
38
38
  const MINUTE_KEY_PREFIX = `${GRANULARITY_MINUTE}|`
39
39
  const HOUR_KEY_PREFIX = `${GRANULARITY_HOURLY}|`
40
+ const DAILY_KEY_PREFIX = `${GRANULARITY_DAILY}|`
40
41
 
41
42
  // 保留值归一:非法回落默认,超出上限截到上限;0(禁用)合法保留
42
43
  export function clampMinuteRetentionDays(days) {
@@ -70,6 +71,16 @@ export const GRANULARITIES = [
70
71
  [GRANULARITY_MINUTE, minuteKey],
71
72
  ]
72
73
 
74
+ // 档保留资格(F14):样本桶 ≥ 该档 cutoff 即可写入。日档无上限恒写;
75
+ // 小时档固定留存;分钟档走可配置天数(0 = 分钟档关闭,一律不写)
76
+ function withinRetention(g, bucket, nowMs, minuteRetentionDays) {
77
+ if (g === GRANULARITY_DAILY) return true
78
+ if (g === GRANULARITY_HOURLY) return bucket >= hourKey(nowMs - HOUR_RETENTION_DAYS * DAY_MS)
79
+ const retention = clampMinuteRetentionDays(minuteRetentionDays)
80
+ if (retention === 0) return false
81
+ return bucket >= minuteKey(nowMs - retention * DAY_MS)
82
+ }
83
+
73
84
  // vendor 段推导与定价匹配同源:首个 / 前段,无 / 或首位斜杠归 default
74
85
  export function providerOf(modelRef) {
75
86
  return splitRequestSegments(modelRef)[0]
@@ -259,7 +270,9 @@ export class UsageStore {
259
270
  return this.readCursor() ?? {}
260
271
  }
261
272
 
262
- // 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush 周期
273
+ // 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush 周期。
274
+ // 入队按档保留资格过滤(F14):超出该档留存期的样本不写入——
275
+ // 写后即被 prune 删除是纯 IO 浪费;回扫历史导入经同一通道自动受控
263
276
  async record(sample) {
264
277
  await this.ready
265
278
  this.requireTable()
@@ -268,6 +281,7 @@ export class UsageStore {
268
281
  const provider = sample.turn ? PROVIDER_UNSET : providerOf(model)
269
282
  const delta = deltaOf(sample, nowMs)
270
283
  for (const [g, bucketOf] of GRANULARITIES) {
284
+ if (!withinRetention(g, bucketOf(sample.time), nowMs, this.retentionDays())) continue
271
285
  const bucket = bucketOf(sample.time)
272
286
  const entry = { ...delta, bucket, provider, model }
273
287
  const existing = this.pending.get(rowKey(g, bucket, provider, model))
@@ -310,6 +324,9 @@ export class UsageStore {
310
324
  if (outcome.status !== 'rejected') return []
311
325
  const key = [...batch.keys()][index]
312
326
  const [entryKey, entry] = [key, batch.get(key)]
327
+ // 重排前复核保留资格(F14):失败重试期间样本可能已滑出留存期,不再重排
328
+ const g = key.startsWith(DAILY_KEY_PREFIX) ? GRANULARITY_DAILY : key.startsWith(HOUR_KEY_PREFIX) ? GRANULARITY_HOURLY : GRANULARITY_MINUTE
329
+ if (!withinRetention(g, entry.bucket, this.now(), this.retentionDays())) return []
313
330
  const existing = this.pending.get(entryKey)
314
331
  this.pending.set(entryKey, existing ? addDelta(existing, entry) : entry)
315
332
  return [outcome.reason]
@@ -453,6 +453,8 @@ test('pricing POST 非法规则拒绝 400 且不写入', async () => {
453
453
  { rules: [{ ...PRICING_RULE, conditions: [{ kind: 'unknown' }] }] },
454
454
  { rules: [{ ...PRICING_RULE, conditions: [{ kind: 'weekdays', days: [7] }] }] },
455
455
  { rules: [{ ...PRICING_RULE, currency: '€' }] },
456
+ // F15 表级单币:非空币种混用整表拒绝;null 与单币并存合法
457
+ { rules: [{ ...PRICING_RULE, currency: '¥' }, { ...PRICING_RULE, model: 'openai/gpt-x', currency: '$' }] },
456
458
  ]
457
459
  for (const payload of invalidPayloads) {
458
460
  const res = await invokeJson(routes, PATH_PRICING, { payload })
@@ -81,9 +81,13 @@ function fakeDomain({ failUpdate = () => null } = {}) {
81
81
  }
82
82
  }
83
83
 
84
+ // F14 契约:record 按档保留资格过滤。夹具时钟固定在样本日次日,样本全部
85
+ // 落在留存期内;超期丢弃语义由专项用例验证
86
+ const FIXED_NOW = local(2026, 8, 3, 0, 0)
84
87
  const facilityOf = (domain, backupDomain) => ({
85
88
  open: async (definition) => (definition?.name === 'usage_stats_backup' ? (backupDomain ?? domain) : domain),
86
89
  })
90
+ const makeStore = (domain, options = {}) => new UsageStore(facilityOf(domain), { now: () => FIXED_NOW, ...options })
87
91
 
88
92
  const tokenSample = (time, extra = {}) => ({
89
93
  time,
@@ -134,7 +138,7 @@ test('跨日跨时跨分边界:毫秒推进即换桶', () => {
134
138
 
135
139
  test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
136
140
  const domain = fakeDomain()
137
- const store = new UsageStore(facilityOf(domain))
141
+ const store = makeStore(domain)
138
142
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
139
143
  await store.flushNow()
140
144
  const keys = ['D|2026-08-02', 'H|2026-08-02T14', 'M|2026-08-02T14:30'].map(
@@ -156,7 +160,7 @@ test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
156
160
 
157
161
  test('turn 样本落合成行:provider default 与 model (turns)', async () => {
158
162
  const domain = fakeDomain()
159
- const store = new UsageStore(facilityOf(domain))
163
+ const store = makeStore(domain)
160
164
  await store.record({ time: local(2026, 8, 2, 14, 37), turn: true })
161
165
  await store.flushNow()
162
166
  const seen = domain.rows.get('D|2026-08-02|default|(turns)')
@@ -167,7 +171,7 @@ test('turn 样本落合成行:provider default 与 model (turns)', async () => {
167
171
 
168
172
  test('request 样本只计请求数,无归因落 (unknown)', async () => {
169
173
  const domain = fakeDomain()
170
- const store = new UsageStore(facilityOf(domain))
174
+ const store = makeStore(domain)
171
175
  await store.record({ time: local(2026, 8, 2, 14, 37), request: true, model: 'deepseek-chat' })
172
176
  await store.record({ time: local(2026, 8, 2, 14, 38), request: true })
173
177
  await store.flushNow()
@@ -182,7 +186,7 @@ test('request 样本只计请求数,无归因落 (unknown)', async () => {
182
186
 
183
187
  test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', async () => {
184
188
  const domain = fakeDomain()
185
- const store = new UsageStore(facilityOf(domain))
189
+ const store = makeStore(domain)
186
190
  const at = local(2026, 8, 2, 14, 37)
187
191
  await store.record(tokenSample(at, { durationMs: 6500 }))
188
192
  await store.record(tokenSample(at + 1000, { durationMs: 2500 }))
@@ -204,7 +208,7 @@ test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', asy
204
208
 
205
209
  test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
206
210
  const domain = fakeDomain()
207
- const store = new UsageStore(facilityOf(domain))
211
+ const store = makeStore(domain)
208
212
  const at = local(2026, 8, 2, 14, 37)
209
213
  const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
210
214
  await store.record(tokenSample(at))
@@ -221,7 +225,7 @@ test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
221
225
 
222
226
  test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 token', async () => {
223
227
  const domain = fakeDomain()
224
- const store = new UsageStore(facilityOf(domain))
228
+ const store = makeStore(domain)
225
229
  const at = local(2026, 8, 2, 14, 37)
226
230
  // chunk 先发 token 样本:只有 token 桶
227
231
  await store.record(tokenSample(at, { durationMs: undefined }))
@@ -245,7 +249,7 @@ test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 t
245
249
 
246
250
  test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () => {
247
251
  const domain = fakeDomain()
248
- const store = new UsageStore(facilityOf(domain))
252
+ const store = makeStore(domain)
249
253
  const at = local(2026, 8, 2, 14, 37)
250
254
  await store.record(tokenSample(at))
251
255
  await store.flushNow()
@@ -258,7 +262,7 @@ test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () =>
258
262
 
259
263
  test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
260
264
  const domain = fakeDomain()
261
- const store = new UsageStore(facilityOf(domain))
265
+ const store = makeStore(domain)
262
266
  const at = local(2026, 8, 2, 14, 37)
263
267
  const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
264
268
  await store.record(tokenSample(at))
@@ -279,7 +283,7 @@ test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
279
283
 
280
284
  test('missing-record 首写竞态:put 种子后重试写入真值', async () => {
281
285
  const domain = fakeDomain()
282
- const store = new UsageStore(facilityOf(domain))
286
+ const store = makeStore(domain)
283
287
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
284
288
  await store.flushNow()
285
289
  assert.equal(domain.stats.puts, 3)
@@ -296,7 +300,7 @@ test('flush 中单粒度失败:错误经 onFlushError 上抛,失败行留 pendin
296
300
  return new Error('simulated hour failure')
297
301
  },
298
302
  })
299
- const store = new UsageStore(facilityOf(domain))
303
+ const store = makeStore(domain)
300
304
  const errors = []
301
305
  store.onFlushError = (err) => errors.push(err)
302
306
  await store.readyPromise()
@@ -317,7 +321,7 @@ test('非 missing 错误不触发种子重试', async () => {
317
321
  const domain = fakeDomain({
318
322
  failUpdate: (candidate) => (candidate === key ? new Error('backend write failed') : null),
319
323
  })
320
- const store = new UsageStore(facilityOf(domain))
324
+ const store = makeStore(domain)
321
325
  const errors = []
322
326
  store.onFlushError = (err) => errors.push(err)
323
327
  await store.readyPromise()
@@ -331,7 +335,7 @@ test('非 missing 错误不触发种子重试', async () => {
331
335
 
332
336
  test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
333
337
  const domain = fakeDomain()
334
- const store = new UsageStore(facilityOf(domain))
338
+ const store = makeStore(domain)
335
339
  const table = domain.table()
336
340
  await table.put('D|2026-07-31|a|m', rowOf('2026-07-31', 'a', 'm'))
337
341
  await table.put('D|2026-08-01|a|m', rowOf('2026-08-01', 'a', 'm'))
@@ -348,7 +352,7 @@ test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
348
352
 
349
353
  test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async () => {
350
354
  const domain = fakeDomain()
351
- const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
355
+ const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
352
356
  const table = domain.table()
353
357
  await table.put('M|2026-08-08T11:00|a|m', rowOf('2026-08-08T11:00', 'a', 'm'))
354
358
  await table.put('M|2026-08-08T12:00|a|m', rowOf('2026-08-08T12:00', 'a', 'm'))
@@ -365,7 +369,7 @@ test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async (
365
369
 
366
370
  test('保留窗口为 0 时分钟桶全部清理', async () => {
367
371
  const domain = fakeDomain()
368
- const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
372
+ const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
369
373
  const table = domain.table()
370
374
  await table.put('M|2026-08-10T11:00|a|m', rowOf('2026-08-10T11:00', 'a', 'm'))
371
375
  await table.put('D|2026-08-10|a|m', rowOf('2026-08-10', 'a', 'm'))
@@ -376,7 +380,7 @@ test('保留窗口为 0 时分钟桶全部清理', async () => {
376
380
 
377
381
  test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
378
382
  const domain = fakeDomain()
379
- const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
383
+ const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
380
384
  const table = domain.table()
381
385
  // clamp 后窗口起点 = 08-03T12:00,7 天前的桶一律清理,即使注入 30 天
382
386
  await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
@@ -388,7 +392,7 @@ test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
388
392
 
389
393
  test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async () => {
390
394
  const domain = fakeDomain()
391
- const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
395
+ const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
392
396
  const table = domain.table()
393
397
  await table.put('H|2026-07-25T00:00|a|m', rowOf('2026-07-25T00:00', 'a', 'm'))
394
398
  await table.put('H|2026-07-26T12:00|a|m', rowOf('2026-07-26T12:00', 'a', 'm'))
@@ -404,7 +408,7 @@ test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async
404
408
  test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触发', async () => {
405
409
  let clock = local(2026, 8, 10, 12, 0)
406
410
  const domain = fakeDomain()
407
- const store = new UsageStore(facilityOf(domain), { now: () => clock, retentionDays: () => 0 })
411
+ const store = makeStore(domain, { now: () => clock, retentionDays: () => 0 })
408
412
  const table = domain.table()
409
413
  await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
410
414
  await store.record(tokenSample(local(2026, 8, 10, 12, 0)))
@@ -422,7 +426,7 @@ test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触
422
426
 
423
427
  test('并发 markSeenSessions 不丢 id', async () => {
424
428
  const domain = fakeDomain()
425
- const store = new UsageStore(facilityOf(domain))
429
+ const store = makeStore(domain)
426
430
  await Promise.all([
427
431
  store.markSeenSessions(['a']),
428
432
  store.markSeenSessions(['b']),
@@ -433,7 +437,7 @@ test('并发 markSeenSessions 不丢 id', async () => {
433
437
 
434
438
  test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重置', async () => {
435
439
  const domain = fakeDomain()
436
- const store = new UsageStore(facilityOf(domain))
440
+ const store = makeStore(domain)
437
441
  const pending = store.markSeenSessions(['a'])
438
442
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
439
443
  await store.reset(new Map([['live-a', 7]]))
@@ -444,7 +448,7 @@ test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重
444
448
 
445
449
  test('reset 不带边界时游标清空', async () => {
446
450
  const domain = fakeDomain()
447
- const store = new UsageStore(facilityOf(domain))
451
+ const store = makeStore(domain)
448
452
  await store.markLiveSequences([['s', 5]])
449
453
  await store.reset()
450
454
  assert.deepEqual(domain.global.get(), { backfilledSessions: [], liveFirstSeq: {} })
@@ -452,7 +456,7 @@ test('reset 不带边界时游标清空', async () => {
452
456
 
453
457
  test('markLiveSequences 保留最小 seq 且无变化不写 global', async () => {
454
458
  const domain = fakeDomain()
455
- const store = new UsageStore(facilityOf(domain))
459
+ const store = makeStore(domain)
456
460
  await store.markLiveSequences([['s', 5]])
457
461
  assert.deepEqual(await store.liveSequences(), new Map([['s', 5]]))
458
462
  await store.markLiveSequences([['s', 3], ['t', 9]])
@@ -474,7 +478,7 @@ test('游标写单次失败不断链', async () => {
474
478
  }
475
479
  return originalSet(value)
476
480
  }
477
- const store = new UsageStore(facilityOf(domain))
481
+ const store = makeStore(domain)
478
482
  await assert.rejects(store.markSeenSessions(['a']))
479
483
  await store.markSeenSessions(['b'])
480
484
  assert.deepEqual([...(await store.seenSessions())].sort(), ['b'])
@@ -483,7 +487,7 @@ test('游标写单次失败不断链', async () => {
483
487
  test('启动时游标为空且已有行则清空待重建', async () => {
484
488
  const domain = fakeDomain()
485
489
  await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
486
- const store = new UsageStore(facilityOf(domain))
490
+ const store = makeStore(domain)
487
491
  await store.readyPromise()
488
492
  assert.equal(domain.rows.size, 0)
489
493
  })
@@ -492,7 +496,7 @@ test('启动时游标非空则既有行保留', async () => {
492
496
  const domain = fakeDomain()
493
497
  await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
494
498
  await domain.global.set({ backfilledSessions: ['old'], liveFirstSeq: {} })
495
- const store = new UsageStore(facilityOf(domain))
499
+ const store = makeStore(domain)
496
500
  await store.readyPromise()
497
501
  assert.equal(domain.rows.size, 1)
498
502
  })
@@ -500,7 +504,7 @@ test('启动时游标非空则既有行保留', async () => {
500
504
  test('reset 前自动快照,restore 整体回退到快照态', async () => {
501
505
  const domain = fakeDomain()
502
506
  const backup = fakeDomain()
503
- const store = new UsageStore(facilityOf(domain, backup))
507
+ const store = new UsageStore(facilityOf(domain, backup), { now: () => FIXED_NOW })
504
508
  await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
505
509
  await store.markSeenSessions(['s1', 's2'])
506
510
  assert.equal(store.backupInfo().available, false)
@@ -521,7 +525,7 @@ test('reset 前自动快照,restore 整体回退到快照态', async () => {
521
525
  test('restore 前先快照当前态:回退动作自身可再回退', async () => {
522
526
  const domain = fakeDomain()
523
527
  const backup = fakeDomain()
524
- const store = new UsageStore(facilityOf(domain, backup))
528
+ const store = new UsageStore(facilityOf(domain, backup), { now: () => FIXED_NOW })
525
529
  await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
526
530
  await store.reset()
527
531
  await store.record(tokenSample(local(2026, 8, 3, 14, 0), { inputTokens: 99 }))
@@ -544,7 +548,7 @@ test('备份域不可用:重建照常,backupInfo 标记不可用,restore 报错'
544
548
  return domain
545
549
  },
546
550
  }
547
- const store = new UsageStore(facility)
551
+ const store = new UsageStore(facility, { now: () => FIXED_NOW })
548
552
  await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
549
553
  await store.reset()
550
554
  assert.equal(domain.rows.size, 0)
@@ -631,7 +635,7 @@ for (const withCode of [true, false]) {
631
635
  test('UO1 Given old owner with seen cursor and pending tokens, When new generation opens, Then old owner flushes every bucket once', async () => {
632
636
  __resetSharedStoreForTests()
633
637
  const facility = facilityWithReserved()
634
- const old = new UsageStore(facility)
638
+ const old = new UsageStore(facility, { now: () => FIXED_NOW })
635
639
  await old.readyPromise()
636
640
  await old.markSeenSessions(['s1'])
637
641
  await old.record(tokenSample(local(2026, 8, 2, 14, 37)))
@@ -681,7 +685,7 @@ test('get 缝在场但孤儿已不存在:维持降级,不逃逸', async () => {
681
685
 
682
686
  test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次 update', async () => {
683
687
  const domain = fakeDomain()
684
- const store = new UsageStore(facilityOf(domain))
688
+ const store = makeStore(domain)
685
689
  await store.readyPromise()
686
690
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
687
691
  assert.equal(domain.rows.size, 0)
@@ -693,7 +697,7 @@ test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次
693
697
 
694
698
  test('同桶多样本合并:pending 累加,一次 update 落全部计数', async () => {
695
699
  const domain = fakeDomain()
696
- const store = new UsageStore(facilityOf(domain))
700
+ const store = makeStore(domain)
697
701
  await store.readyPromise()
698
702
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
699
703
  await store.record(tokenSample(local(2026, 8, 2, 14, 38)))
@@ -706,7 +710,7 @@ test('同桶多样本合并:pending 累加,一次 update 落全部计数', async
706
710
 
707
711
  test('flush 周期自动落盘', async () => {
708
712
  const domain = fakeDomain()
709
- const store = new UsageStore(facilityOf(domain), { flushIntervalMs: 5 })
713
+ const store = makeStore(domain, { flushIntervalMs: 5 })
710
714
  await store.readyPromise()
711
715
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
712
716
  await new Promise((resolve) => setTimeout(resolve, 30))
@@ -715,7 +719,7 @@ test('flush 周期自动落盘', async () => {
715
719
 
716
720
  test('rangeRows 前自动 flush,读己之写', async () => {
717
721
  const domain = fakeDomain()
718
- const store = new UsageStore(facilityOf(domain))
722
+ const store = makeStore(domain)
719
723
  await store.readyPromise()
720
724
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
721
725
  const rows = await store.rangeRows('D', '2026-08-02', '2026-08-02')
@@ -725,7 +729,7 @@ test('rangeRows 前自动 flush,读己之写', async () => {
725
729
 
726
730
  test('reset 前 pending 先落盘再清表', async () => {
727
731
  const domain = fakeDomain()
728
- const store = new UsageStore(facilityOf(domain))
732
+ const store = makeStore(domain)
729
733
  await store.readyPromise()
730
734
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
731
735
  await store.reset(new Map())
@@ -738,3 +742,21 @@ test('降级态 record 仍按调用失败', async () => {
738
742
  await store.readyPromise()
739
743
  await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
740
744
  })
745
+
746
+ // ── F14: 入队保留资格过滤 ──────────────────────────────────────────────────
747
+ test('F14: 超出档留存期的样本不入 pending 不落盘,回扫同通道受控', async () => {
748
+ const domain = fakeDomain()
749
+ // 时钟"今天" = 样本日次日 + 8 天:8/2 样本对分钟档(7 天)超期、小时档(15 天)与日档在期
750
+ const now = local(2026, 8, 10, 0, 0)
751
+ const store = new UsageStore(facilityOf(domain), { now: () => now })
752
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
753
+ await store.flushNow()
754
+ const prefixes = [...domain.rows.keys()].map((key) => key.split('|').slice(0, 2).join('|'))
755
+ assert.ok(!prefixes.some((p) => p.startsWith('M|')), `分钟档超期不得落行: ${prefixes.join(',')}`)
756
+ assert.ok(prefixes.some((p) => p.startsWith('D|')), '日档无留存上限恒写入')
757
+ assert.ok(prefixes.some((p) => p.startsWith('H|')), '小时档 15 天内在期')
758
+ // 分钟档保留 0(关闭):分钟样本一律不入队
759
+ const offStore = new UsageStore(facilityOf(fakeDomain()), { now: () => local(2026, 8, 2, 14, 38), retentionDays: () => 0 })
760
+ await offStore.record(tokenSample(local(2026, 8, 2, 14, 37)))
761
+ assert.equal(offStore.pending.size, 2, '关闭分钟档后仅日/小时两行入队')
762
+ })