@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/README.md +49 -0
- package/cordis.patch.yml +9 -0
- package/package.json +46 -0
- package/src/client.js +2416 -0
- package/src/collector.js +327 -0
- package/src/index.js +104 -0
- package/src/pricing.js +119 -0
- package/src/query.js +193 -0
- package/src/routes.js +323 -0
- package/src/store.js +333 -0
- package/test/client.test.mjs +614 -0
- package/test/collector.test.mjs +603 -0
- package/test/pricing-parity.test.mjs +231 -0
- package/test/pricing.test.mjs +285 -0
- package/test/query.test.mjs +427 -0
- package/test/routes.test.mjs +566 -0
- package/test/stats-line.test.mjs +407 -0
- package/test/store.test.mjs +397 -0
- package/test/switch-guard.test.mjs +32 -0
- package/test/turn-tail.test.mjs +120 -0
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
|
+
}
|