@mzzsfy/dsh-usage-dash 0.1.0

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/query.js ADDED
@@ -0,0 +1,193 @@
1
+ // 用量聚合纯函数:store 行形状仅作数据约定,零宿主依赖。
2
+ // 桶串本地时区推导,同粒度字典序即时间序;daily 零值槽全枚举,超槽数保最新丢最旧。
3
+
4
+ import { costOf, matchPrice } from './pricing.js'
5
+
6
+ export const MAX_SLOTS = 2000
7
+
8
+ const PAD_WIDTH = 2
9
+ const PERCENT_SCALE = 100
10
+
11
+ const GRANULARITY_DAILY = 'D'
12
+ const GRANULARITY_HOURLY = 'H'
13
+ const GRANULARITY_MINUTE = 'M'
14
+
15
+ const DAY_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
16
+ const HOUR_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/
17
+ const MINUTE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
18
+
19
+ // 桶串前缀宽:D 段定位日槽,M 行截取父 H 桶
20
+ const DAY_KEY_WIDTH = 'YYYY-MM-DD'.length
21
+ const HOUR_KEY_WIDTH = 'YYYY-MM-DDTHH'.length
22
+
23
+ const pad = (value) => String(value).padStart(PAD_WIDTH, '0')
24
+ const formatDate = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
25
+ const formatHour = (date) => `${formatDate(date)}T${pad(date.getHours())}`
26
+ const formatMinute = (date) => `${formatHour(date)}:${pad(date.getMinutes())}`
27
+
28
+ const nextDay = (date) => {
29
+ const next = new Date(date)
30
+ next.setDate(next.getDate() + 1)
31
+ return next
32
+ }
33
+ const nextHour = (date) => {
34
+ const next = new Date(date)
35
+ next.setHours(next.getHours() + 1)
36
+ return next
37
+ }
38
+ const nextMinute = (date) => {
39
+ const next = new Date(date)
40
+ next.setMinutes(next.getMinutes() + MINUTE_STEP_MINUTES)
41
+ return next
42
+ }
43
+
44
+ // 分钟桶粒度:枚举与桶键共用同一步长,from 必须对齐桶边界
45
+ const MINUTE_STEP_MINUTES = 10
46
+
47
+ // pattern 锚定桶串形态,suffix 补全为本地时区可解析日期串,format 回读校验分量合法性
48
+ const BUCKET_FORMS = {
49
+ [GRANULARITY_DAILY]: { pattern: DAY_KEY_PATTERN, suffix: 'T00:00:00', format: formatDate, step: nextDay },
50
+ [GRANULARITY_HOURLY]: { pattern: HOUR_KEY_PATTERN, suffix: ':00:00', format: formatHour, step: nextHour },
51
+ [GRANULARITY_MINUTE]: { pattern: MINUTE_KEY_PATTERN, suffix: ':00', format: formatMinute, step: nextMinute, align: MINUTE_STEP_MINUTES },
52
+ }
53
+
54
+ const parseBucketKey = (key, form) => {
55
+ if (typeof key !== 'string' || !form.pattern.test(key)) return null
56
+ const parsed = new Date(`${key}${form.suffix}`)
57
+ return Number.isNaN(parsed.getTime()) || form.format(parsed) !== key ? null : parsed
58
+ }
59
+
60
+ const enumerateBucketKeys = (form) => (from, to) => {
61
+ const start = parseBucketKey(from, form)
62
+ const end = parseBucketKey(to, form)
63
+ if (!start || !end) return []
64
+ if (form.align && start.getMinutes() % form.align !== 0) return []
65
+ const keys = []
66
+ for (let cursor = start, key = form.format(cursor); key <= to; key = form.format(cursor)) {
67
+ keys.push(key)
68
+ cursor = form.step(cursor)
69
+ }
70
+ return keys
71
+ }
72
+
73
+ export const daysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_DAILY])
74
+ export const hourKeysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_HOURLY])
75
+ export const minuteKeysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_MINUTE])
76
+
77
+ const emptySlot = (day) => ({
78
+ day,
79
+ total: 0,
80
+ byModel: {},
81
+ byProvider: {},
82
+ requests: 0,
83
+ turns: 0,
84
+ cacheHit: 0,
85
+ cacheMiss: 0,
86
+ })
87
+
88
+ const addRowToSlot = (slot, row, tokens) => {
89
+ slot.total += tokens
90
+ slot.requests += row.requests
91
+ slot.turns += row.turns
92
+ slot.cacheHit += row.cacheReadTokens
93
+ slot.cacheMiss += row.inputTokens + row.cacheWriteTokens
94
+ }
95
+
96
+ const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * PERCENT_SCALE)
97
+
98
+ const rowTokens = (row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens
99
+
100
+ export function aggregateRange(rows, g, from, to) {
101
+ const form = BUCKET_FORMS[g]
102
+ const slots = enumerateBucketKeys(form)(from, to).map((key) => emptySlot(key))
103
+ const slotByKey = new Map(slots.map((slot) => [slot.day, slot]))
104
+ const modelTotals = new Map()
105
+ const providerTotals = new Map()
106
+ const activeBuckets = new Set()
107
+ for (const row of rows) {
108
+ const slot = slotByKey.get(row.bucket)
109
+ // 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
110
+ if (!slot) continue
111
+ const tokens = rowTokens(row)
112
+ addRowToSlot(slot, row, tokens)
113
+ if (tokens === 0) continue
114
+ activeBuckets.add(row.bucket)
115
+ slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
116
+ slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
117
+ const modelTotal = modelTotals.get(row.model)
118
+ if (modelTotal) modelTotal.tokens += tokens
119
+ else modelTotals.set(row.model, { provider: row.provider, tokens })
120
+ providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
121
+ }
122
+ const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
123
+ for (const slot of slots) {
124
+ totals.tokens += slot.total
125
+ totals.requests += slot.requests
126
+ totals.turns += slot.turns
127
+ totals.cacheHit += slot.cacheHit
128
+ totals.cacheMiss += slot.cacheMiss
129
+ }
130
+ const models = [...modelTotals.entries()]
131
+ .map(([model, agg]) => ({ model, provider: agg.provider, tokens: agg.tokens, percent: percentOf(agg.tokens, totals.tokens) }))
132
+ .sort((a, b) => b.tokens - a.tokens)
133
+ const providers = [...providerTotals.entries()]
134
+ .map(([provider, tokens]) => ({ provider, tokens, percent: percentOf(tokens, totals.tokens) }))
135
+ .sort((a, b) => b.tokens - a.tokens)
136
+ const truncated = slots.length > MAX_SLOTS
137
+ const daily = truncated ? slots.slice(-MAX_SLOTS) : slots
138
+ const top = models[0]
139
+ const result = {
140
+ from,
141
+ to,
142
+ tokens: totals.tokens,
143
+ requests: totals.requests,
144
+ turns: totals.turns,
145
+ cacheHit: totals.cacheHit,
146
+ cacheMiss: totals.cacheMiss,
147
+ activeDays: activeBuckets.size,
148
+ topModel: top?.model ?? '',
149
+ topProvider: top?.provider ?? '',
150
+ daily,
151
+ models,
152
+ providers,
153
+ }
154
+ if (truncated) result.truncated = true
155
+ return result
156
+ }
157
+
158
+ // 聚合计价的槽定位:D 折叠到日槽,H/M 即本槽;计价一律取行所属 H 桶起点
159
+ const COST_SLOT_KEYS = {
160
+ [GRANULARITY_DAILY]: (bucket) => bucket.slice(0, DAY_KEY_WIDTH),
161
+ [GRANULARITY_HOURLY]: (bucket) => bucket,
162
+ [GRANULARITY_MINUTE]: (bucket) => bucket,
163
+ }
164
+
165
+ // 聚合计价:以可见槽为唯一口径,cost 行按 H 桶起点匹配价格后累加;
166
+ // unpriced = 有 token 而未命中价的去重 H 桶数,被截断丢弃的行整体不参与。
167
+ // 纯函数返回新 result,不修改入参;调用方不调用则响应无 cost/unpriced 字段
168
+ export function attachCosts(result, costRows, granularity, rules) {
169
+ const slotKeyOf = COST_SLOT_KEYS[granularity]
170
+ const slotOfDay = new Map(result.daily.map((slot) => [slot.day, slot]))
171
+ const slotCosts = new Map()
172
+ const modelCosts = new Map(result.models.map((entry) => [entry.model, 0]))
173
+ const unpricedHours = new Set()
174
+ for (const row of costRows) {
175
+ const slot = slotOfDay.get(slotKeyOf(row.bucket))
176
+ if (!slot || rowTokens(row) === 0) continue
177
+ const hourKey = row.bucket.slice(0, HOUR_KEY_WIDTH)
178
+ const date = parseBucketKey(hourKey, BUCKET_FORMS[GRANULARITY_HOURLY])
179
+ if (!date) continue
180
+ const price = matchPrice(rules, row.model, date)
181
+ if (!price) {
182
+ unpricedHours.add(hourKey)
183
+ continue
184
+ }
185
+ const cost = costOf(price, row)
186
+ slotCosts.set(slot.day, (slotCosts.get(slot.day) ?? 0) + cost)
187
+ if (modelCosts.has(row.model)) modelCosts.set(row.model, modelCosts.get(row.model) + cost)
188
+ }
189
+ const daily = result.daily.map((slot) => ({ ...slot, cost: slotCosts.get(slot.day) ?? 0 }))
190
+ const cost = daily.reduce((sum, slot) => sum + slot.cost, 0)
191
+ const models = result.models.map((entry) => ({ ...entry, cost: modelCosts.get(entry.model) }))
192
+ return { ...result, daily, models, cost, unpriced: unpricedHours.size }
193
+ }
package/src/routes.js ADDED
@@ -0,0 +1,323 @@
1
+ // 用量统计数据路由:6 个 exact 端点共用守卫/校验/信封管线,粒度由路径分派,
2
+ // pricing 端点守卫放宽(GET/POST 放行、不要求 content-type)。
3
+ // 协议契约见 docs/feat-usage-dash/host-design.md 端点章:成功 {ok:true,value},
4
+ // UsageError 回其 status 与 message(code 恒 usage_api_error),栅栏 forbidden,
5
+ // 未匹配 not_found,其余一切 500 固定文案不回显内部文本。
6
+
7
+ import { z } from 'zod'
8
+
9
+ import { aggregateRange, attachCosts } from './query.js'
10
+ import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION } from './pricing.js'
11
+ import {
12
+ GRANULARITY_DAILY,
13
+ GRANULARITY_HOURLY,
14
+ GRANULARITY_MINUTE,
15
+ MINUTE_BUCKET_SPAN_MINUTES,
16
+ clampMinuteRetentionDays,
17
+ minuteKey,
18
+ } from './store.js'
19
+
20
+ const ROUTE_PREFIX = '/api/usage-dash'
21
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
22
+ export const MAX_RANGE_SPAN_DAYS = 366
23
+ const MAX_JSON_BODY_BYTES = 64 * 1024
24
+
25
+ const HTTP_STATUS_OK = 200
26
+ const HTTP_STATUS_BAD_REQUEST = 400
27
+ const HTTP_STATUS_FORBIDDEN = 403
28
+ const HTTP_STATUS_NOT_FOUND = 404
29
+ const HTTP_STATUS_METHOD_NOT_ALLOWED = 405
30
+ const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413
31
+ const HTTP_STATUS_CONFLICT = 409
32
+ const HTTP_STATUS_UNAVAILABLE = 503
33
+ const HTTP_STATUS_INTERNAL_ERROR = 500
34
+
35
+ const ERROR_PREFIX = 'usage stats: '
36
+ const MESSAGE_BACKFILL_RUNNING = `${ERROR_PREFIX}backfill already running`
37
+ const MESSAGE_METHOD_NOT_ALLOWED = `${ERROR_PREFIX}method not allowed`
38
+ const MESSAGE_CROSS_ORIGIN = 'cross-origin request rejected'
39
+ const MESSAGE_CONTENT_TYPE = `${ERROR_PREFIX}content-type must be application/json`
40
+ const MESSAGE_RANGE_REQUIRED = `${ERROR_PREFIX}from and to are required`
41
+ const MESSAGE_INVALID_PRICING = `${ERROR_PREFIX}invalid pricing rules`
42
+ const MESSAGE_PRICING_UNAVAILABLE = `${ERROR_PREFIX}pricing unavailable`
43
+ const MESSAGE_INVALID_JSON = 'invalid json body'
44
+ const MESSAGE_INTERNAL_ERROR = 'internal error'
45
+
46
+ const CODE_USAGE_API_ERROR = 'usage_api_error'
47
+ const CODE_FORBIDDEN = 'forbidden'
48
+ const CODE_NOT_FOUND = 'not_found'
49
+
50
+ const METHOD_GET = 'GET'
51
+ const METHOD_POST = 'POST'
52
+ const CONTENT_TYPE_JSON = 'application/json'
53
+ const QUERY_SEPARATOR = /[?#]/
54
+ const EMPTY_BUCKET = ''
55
+
56
+ // D 端点按同窗口 H 行折叠费用:H 桶串字典序大于日键,读 H 行时上界补足当日末小时
57
+ const HOURS_PER_DAY = 24
58
+ const HOUR_LABEL_WIDTH = 2
59
+ const LAST_HOUR_OF_DAY = `T${String(HOURS_PER_DAY - 1).padStart(HOUR_LABEL_WIDTH, '0')}`
60
+
61
+ // 定价规则 wire 契约:kind 判别取 pricing 模块常量,单一来源;未知键由 zod 剥离
62
+ const WEEKDAY_MIN = 0
63
+ const WEEKDAY_MAX = 6
64
+
65
+ const CONDITION_FIELD_SCHEMAS = {
66
+ dailyWindow: { from: z.string(), to: z.string() },
67
+ weekdays: { days: z.array(z.number().int().min(WEEKDAY_MIN).max(WEEKDAY_MAX)) },
68
+ monthDays: { from: z.number().int(), to: z.number().int() },
69
+ dateRange: { from: z.string(), to: z.string() },
70
+ }
71
+
72
+ const conditionSchema = z.union(
73
+ CONDITION_KINDS.map((kind) => z.object({ kind: z.literal(kind), ...CONDITION_FIELD_SCHEMAS[kind] })),
74
+ )
75
+
76
+ const pricingRulesSchema = z.array(
77
+ z.object({
78
+ model: z.string(),
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),
86
+ }),
87
+ conditions: z.array(conditionSchema),
88
+ }),
89
+ )
90
+
91
+ // 桶串形态锚定 + 本地时区分量回读,回滚形(13 月/25 时/60 分)当场拒绝,
92
+ // 与 query.js 桶串推导同源
93
+ const DAY_KEY_PARTS = /^(\d{4})-(\d{2})-(\d{2})$/
94
+ const HOUR_KEY_PARTS = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})$/
95
+ const MINUTE_KEY_PARTS = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
96
+
97
+ const DATE_COMPONENTS = [
98
+ (date) => date.getFullYear(),
99
+ (date) => date.getMonth() + 1,
100
+ (date) => date.getDate(),
101
+ (date) => date.getHours(),
102
+ (date) => date.getMinutes(),
103
+ ]
104
+
105
+ class UsageError extends Error {
106
+ constructor(status, message, code = CODE_USAGE_API_ERROR) {
107
+ super(message)
108
+ this.status = status
109
+ this.code = code
110
+ }
111
+ }
112
+
113
+ const usageError = (status, message, code) => new UsageError(status, message, code)
114
+
115
+ function writeJson(res, value, status = HTTP_STATUS_OK) {
116
+ res.statusCode = status
117
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
118
+ res.end(JSON.stringify(value))
119
+ }
120
+
121
+ function writeError(ctx, res, err) {
122
+ if (err instanceof UsageError) {
123
+ writeJson(res, { ok: false, error: { code: err.code, message: err.message } }, err.status)
124
+ return
125
+ }
126
+ ctx.logger?.warn?.(`usage-dash: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`)
127
+ writeJson(res, { ok: false, error: { code: CODE_USAGE_API_ERROR, message: MESSAGE_INTERNAL_ERROR } }, HTTP_STATUS_INTERNAL_ERROR)
128
+ }
129
+
130
+ async function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
131
+ const chunks = []
132
+ let total = 0
133
+ for await (const chunk of req) {
134
+ total += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength
135
+ if (total > maxBytes) throw usageError(HTTP_STATUS_PAYLOAD_TOO_LARGE, `request body exceeds ${maxBytes} bytes`)
136
+ chunks.push(chunk)
137
+ }
138
+ if (chunks.length === 0) return undefined
139
+ const text = Buffer
140
+ .concat(chunks.map((chunk) => (typeof chunk === 'string' ? Buffer.from(chunk) : chunk)))
141
+ .toString('utf8')
142
+ try {
143
+ return JSON.parse(text)
144
+ } catch {
145
+ throw usageError(HTTP_STATUS_BAD_REQUEST, MESSAGE_INVALID_JSON)
146
+ }
147
+ }
148
+
149
+ // 同源守卫:浏览器写请求恒带 Origin,与 Host 不符即拒;无 Origin 的非浏览器客户端放行
150
+ function rejectCrossOrigin(req) {
151
+ const origin = req.headers?.origin
152
+ if (!origin) return
153
+ let sameOrigin = false
154
+ try {
155
+ sameOrigin = new URL(origin).host === req.headers?.host
156
+ } catch {
157
+ sameOrigin = false
158
+ }
159
+ if (!sameOrigin) throw usageError(HTTP_STATUS_FORBIDDEN, MESSAGE_CROSS_ORIGIN, CODE_FORBIDDEN)
160
+ }
161
+
162
+ function rejectNonJson(req) {
163
+ const contentType = String(req.headers?.['content-type'] ?? '')
164
+ if (!contentType.includes(CONTENT_TYPE_JSON)) throw usageError(HTTP_STATUS_BAD_REQUEST, MESSAGE_CONTENT_TYPE)
165
+ }
166
+
167
+ function rejectWrongMethod(req) {
168
+ if ((req.method ?? METHOD_GET) !== METHOD_POST) {
169
+ throw usageError(HTTP_STATUS_METHOD_NOT_ALLOWED, MESSAGE_METHOD_NOT_ALLOWED)
170
+ }
171
+ }
172
+
173
+ // pricing 端点放宽:GET 读 POST 写均放行且不要求 content-type,其余方法照拒
174
+ function rejectPricingMethod(req) {
175
+ const method = req.method ?? METHOD_GET
176
+ if (method !== METHOD_GET && method !== METHOD_POST) {
177
+ throw usageError(HTTP_STATUS_METHOD_NOT_ALLOWED, MESSAGE_METHOD_NOT_ALLOWED)
178
+ }
179
+ }
180
+
181
+ function parseBucketParts(value, pattern) {
182
+ if (typeof value !== 'string') return null
183
+ const match = pattern.exec(value)
184
+ if (!match) return null
185
+ const parts = match.slice(1).map(Number)
186
+ const date = new Date(parts[0], parts[1] - 1, parts[2], ...parts.slice(3))
187
+ return DATE_COMPONENTS.slice(0, parts.length).every((component, i) => component(date) === parts[i]) ? parts : null
188
+ }
189
+
190
+ function requireBucketRange(body, pattern, keyLabel) {
191
+ const from = body?.from
192
+ const to = body?.to
193
+ if (typeof from !== 'string' || typeof to !== 'string') {
194
+ throw usageError(HTTP_STATUS_BAD_REQUEST, MESSAGE_RANGE_REQUIRED)
195
+ }
196
+ const fromParts = parseBucketParts(from, pattern)
197
+ const toParts = parseBucketParts(to, pattern)
198
+ if (!fromParts || !toParts) throw usageError(HTTP_STATUS_BAD_REQUEST, `${ERROR_PREFIX}invalid ${keyLabel}`)
199
+ if (to < from) throw usageError(HTTP_STATUS_BAD_REQUEST, `${ERROR_PREFIX}to must not precede from`)
200
+ return { from, to, fromParts, toParts }
201
+ }
202
+
203
+ const utcDayOf = (parts) => Date.UTC(parts[0], parts[1] - 1, parts[2])
204
+
205
+ // 聚合 + 定价注入:pricing 激活才挂 cost/unpriced,缺省响应形状与现状一致;
206
+ // D 端点费用由同窗口 H 行折叠,其余粒度计价行即聚合行
207
+ async function aggregateWithPricing(deps, granularity, from, to) {
208
+ const rows = await deps.store.rangeRows(granularity, from, to)
209
+ let value = aggregateRange(rows, granularity, from, to)
210
+ const pricing = deps.pricing
211
+ if (pricing?.active) {
212
+ const costRows = granularity === GRANULARITY_DAILY
213
+ ? await deps.store.rangeRows(GRANULARITY_HOURLY, from, `${to}${LAST_HOUR_OF_DAY}`)
214
+ : rows
215
+ value = attachCosts(value, costRows, granularity, pricing.rules())
216
+ }
217
+ return value
218
+ }
219
+
220
+ async function respondAggregate(deps, res, granularity, from, to) {
221
+ writeJson(res, { ok: true, value: await aggregateWithPricing(deps, granularity, from, to) })
222
+ }
223
+
224
+ const rangeHandler = (deps) => async (req, res) => {
225
+ const { from, to, fromParts, toParts } = requireBucketRange(await readJsonBody(req), DAY_KEY_PARTS, 'day key')
226
+ const spanDays = (utcDayOf(toParts) - utcDayOf(fromParts)) / MS_PER_DAY + 1
227
+ if (spanDays > MAX_RANGE_SPAN_DAYS) {
228
+ throw usageError(HTTP_STATUS_BAD_REQUEST, `${ERROR_PREFIX}span exceeds ${MAX_RANGE_SPAN_DAYS} days`)
229
+ }
230
+ await respondAggregate(deps, res, GRANULARITY_DAILY, from, to)
231
+ }
232
+
233
+ const hoursHandler = (deps) => async (req, res) => {
234
+ const { from, to } = requireBucketRange(await readJsonBody(req), HOUR_KEY_PARTS, 'hour key')
235
+ await respondAggregate(deps, res, GRANULARITY_HOURLY, from, to)
236
+ }
237
+
238
+ // 分钟保留窗口(天)换算为起点桶串:起点晚于请求 from 即窗口外已清理,
239
+ // 标注实际可用范围;禁用(0)时恒无数据,covered 标注空串。
240
+ // retentionDays 经 clamp 归一(非法回落默认,超上限截断),与 store.pruneMinutes 同源
241
+ const minuteHandler = (deps) => async (req, res) => {
242
+ const { from, to, fromParts } = requireBucketRange(await readJsonBody(req), MINUTE_KEY_PARTS, 'minute key')
243
+ if (fromParts[fromParts.length - 1] % MINUTE_BUCKET_SPAN_MINUTES !== 0) {
244
+ throw usageError(HTTP_STATUS_BAD_REQUEST, `${ERROR_PREFIX}minute key must align to ${MINUTE_BUCKET_SPAN_MINUTES} minutes`)
245
+ }
246
+ const value = await aggregateWithPricing(deps, GRANULARITY_MINUTE, from, to)
247
+ const retention = clampMinuteRetentionDays(deps.retentionDays())
248
+ if (retention > 0) {
249
+ const windowStart = minuteKey(deps.now() - retention * MS_PER_DAY)
250
+ if (windowStart > from) Object.assign(value, { coveredFrom: windowStart, coveredTo: to })
251
+ } else {
252
+ Object.assign(value, { coveredFrom: EMPTY_BUCKET, coveredTo: EMPTY_BUCKET })
253
+ }
254
+ writeJson(res, { ok: true, value })
255
+ }
256
+
257
+ // pricing 端点:GET 回 {revision, rules};POST 整表替换经 zod 校验后写入并回读。
258
+ // settings 未激活时能力缺席,GET 走空值桩,POST 拒 503——能力缺席属服务暂
259
+ // 不可用而非请求状态冲突,不用 409
260
+ const INACTIVE_PRICING = { active: false, rules: () => [], revision: () => 0 }
261
+
262
+ const pricingHandler = (deps) => async (req, res) => {
263
+ const pricing = deps.pricing ?? INACTIVE_PRICING
264
+ if ((req.method ?? METHOD_GET) === METHOD_GET) {
265
+ writeJson(res, { ok: true, value: { revision: pricing.revision(), rules: pricing.rules() } })
266
+ return
267
+ }
268
+ if (!pricing.active) throw usageError(HTTP_STATUS_UNAVAILABLE, MESSAGE_PRICING_UNAVAILABLE)
269
+ const parsed = pricingRulesSchema.safeParse((await readJsonBody(req))?.rules)
270
+ if (!parsed.success) throw usageError(HTTP_STATUS_BAD_REQUEST, MESSAGE_INVALID_PRICING)
271
+ const rules = await pricing.replace(parsed.data)
272
+ writeJson(res, { ok: true, value: { revision: pricing.revision(), rules } })
273
+ }
274
+
275
+ const statusHandler = (deps) => async (req, res) => {
276
+ writeJson(res, { ok: true, value: deps.collector.status() })
277
+ }
278
+
279
+ // 409 只拒 boot 期扫描;reset 自身引发的重扫同样 running 为真,经 rebuilding 放行
280
+ const resetHandler = (deps) => async (req, res) => {
281
+ if (deps.collector.running && !deps.collector.rebuilding) {
282
+ throw usageError(HTTP_STATUS_CONFLICT, MESSAGE_BACKFILL_RUNNING)
283
+ }
284
+ await deps.collector.resetAndRescan()
285
+ writeJson(res, { ok: true, value: deps.collector.status() })
286
+ }
287
+
288
+ const STANDARD_GUARDS = [rejectWrongMethod, rejectCrossOrigin, rejectNonJson]
289
+ const PRICING_GUARDS = [rejectPricingMethod, rejectCrossOrigin]
290
+
291
+ const ENDPOINTS = [
292
+ { path: `${ROUTE_PREFIX}/range`, mount: rangeHandler },
293
+ { path: `${ROUTE_PREFIX}/hours`, mount: hoursHandler },
294
+ { path: `${ROUTE_PREFIX}/minutes`, mount: minuteHandler },
295
+ { path: `${ROUTE_PREFIX}/status`, mount: statusHandler },
296
+ { path: `${ROUTE_PREFIX}/reset`, mount: resetHandler },
297
+ { path: `${ROUTE_PREFIX}/pricing`, mount: pricingHandler, guards: PRICING_GUARDS },
298
+ ]
299
+
300
+ const routePathOf = (req) => (req.url ?? '').split(QUERY_SEPARATOR, 1)[0]
301
+
302
+ export function registerUsageRoutes(ctx, { store, collector, retentionDays, now = Date.now, pricing }) {
303
+ const deps = { store, collector, retentionDays, now, pricing }
304
+ const handlers = new Map(ENDPOINTS.map((endpoint) => [endpoint.path, {
305
+ handler: endpoint.mount(deps),
306
+ guards: endpoint.guards ?? STANDARD_GUARDS,
307
+ }]))
308
+ // exact 注册下宿主只会命中自有路径,此处仍按路径精确等值分派,未匹配即 404
309
+ const dispatch = async (req, res) => {
310
+ try {
311
+ const path = routePathOf(req)
312
+ const matched = handlers.get(path)
313
+ if (!matched) throw usageError(HTTP_STATUS_NOT_FOUND, `unknown endpoint ${path}`, CODE_NOT_FOUND)
314
+ for (const guard of matched.guards) guard(req)
315
+ await matched.handler(req, res)
316
+ } catch (err) {
317
+ writeError(ctx, res, err)
318
+ }
319
+ }
320
+ const disposers = ENDPOINTS.map(({ path }) =>
321
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path, handler: dispatch }), `usage-dash: route ${path}`))
322
+ return () => disposers.forEach((dispose) => dispose?.())
323
+ }