From ce502657711e37d0ed65b14195936b3b7229d97b Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 13:54:51 +0800 Subject: [PATCH] refactor: publish mileage metric catalog --- src/server/index.ts | 2 + src/server/routes/analytics/index.test.ts | 19 ++++ src/server/routes/analytics/index.ts | 29 +++++++ src/shared/analytics/catalog.test.ts | 27 ++++++ src/shared/analytics/catalog.ts | 100 ++++++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 src/server/routes/analytics/index.test.ts create mode 100644 src/server/routes/analytics/index.ts create mode 100644 src/shared/analytics/catalog.test.ts create mode 100644 src/shared/analytics/catalog.ts diff --git a/src/server/index.ts b/src/server/index.ts index 5df95d5..21d05ff 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,6 +11,7 @@ import eleRouter from './routes/ele/index.js'; import feedbackRouter from './routes/feedback/index.js'; import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js'; +import analyticsRouter from './routes/analytics/index.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import authRouter from './auth/login.js'; import { authMiddleware } from './auth/middleware.js'; @@ -35,6 +36,7 @@ app.route('/api/ele', eleRouter); app.route('/api/feedback', feedbackRouter); app.route('/api/vehicle-heatmap', vehicleHeatmapRouter); app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter); +app.route('/api/analytics', analyticsRouter); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); diff --git a/src/server/routes/analytics/index.test.ts b/src/server/routes/analytics/index.test.ts new file mode 100644 index 0000000..45a3022 --- /dev/null +++ b/src/server/routes/analytics/index.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import app from './index.js'; + +test('returns the published mileage metric contract', async () => { + const response = await app.request('/metrics?domain=mileage'); + const payload = await response.json(); + + assert.equal(response.status, 200); + assert.equal(payload.catalogVersion, 1); + assert.deepEqual(payload.domains, ['mileage']); + assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate')); +}); + +test('rejects unpublished metric domains', async () => { + const response = await app.request('/metrics?domain=energy'); + + assert.equal(response.status, 400); +}); diff --git a/src/server/routes/analytics/index.ts b/src/server/routes/analytics/index.ts new file mode 100644 index 0000000..de55008 --- /dev/null +++ b/src/server/routes/analytics/index.ts @@ -0,0 +1,29 @@ +import { Hono } from 'hono'; +import { + METRIC_CATALOG_VERSION, + isMetricDomain, + listMetricDefinitions, + type MetricDomain, +} from '../../../shared/analytics/catalog.js'; + +const app = new Hono(); + +app.get('/metrics', (c) => { + const requestedDomain = c.req.query('domain'); + let domain: MetricDomain | undefined; + if (requestedDomain) { + if (!isMetricDomain(requestedDomain)) { + return c.json({ error: 'Unsupported metric domain', domain: requestedDomain }, 400); + } + domain = requestedDomain; + } + + const metrics = listMetricDefinitions(domain); + return c.json({ + catalogVersion: METRIC_CATALOG_VERSION, + domains: Array.from(new Set(metrics.map(metric => metric.domain))), + metrics, + }); +}); + +export default app; diff --git a/src/shared/analytics/catalog.test.ts b/src/shared/analytics/catalog.test.ts new file mode 100644 index 0000000..63365ac --- /dev/null +++ b/src/shared/analytics/catalog.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + METRIC_CATALOG, + isMetricDomain, + listMetricDefinitions, +} from './catalog.js'; + +test('metric ids are unique and namespaced by domain', () => { + const ids = METRIC_CATALOG.map(metric => metric.id); + + assert.equal(new Set(ids).size, ids.length); + assert.ok(METRIC_CATALOG.every(metric => metric.id.startsWith(`${metric.domain}.`))); +}); + +test('lists mileage metrics without exposing the catalog array', () => { + const metrics = listMetricDefinitions('mileage'); + + assert.ok(metrics.length >= 5); + assert.notEqual(metrics, METRIC_CATALOG); + assert.ok(metrics.every(metric => metric.domain === 'mileage')); +}); + +test('validates only published metric domains', () => { + assert.equal(isMetricDomain('mileage'), true); + assert.equal(isMetricDomain('unknown'), false); +}); diff --git a/src/shared/analytics/catalog.ts b/src/shared/analytics/catalog.ts new file mode 100644 index 0000000..59813eb --- /dev/null +++ b/src/shared/analytics/catalog.ts @@ -0,0 +1,100 @@ +export type MetricDomain = 'mileage'; +export type MetricUnit = 'km' | 'percent' | 'vehicle' | 'timestamp'; +export type MetricAggregation = 'sum' | 'weighted-ratio' | 'distinct-count' | 'latest'; +export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness'; + +export interface MetricDefinition { + id: string; + domain: MetricDomain; + label: string; + description: string; + unit: MetricUnit; + aggregation: MetricAggregation; + timeSemantics: MetricTimeSemantics; + formula: string; + sources: readonly string[]; + dimensions: readonly string[]; + drillEntity: 'vehicle' | 'assessment-target'; +} + +export const METRIC_CATALOG_VERSION = 1; + +const MILEAGE_METRICS: readonly MetricDefinition[] = [ + { + id: 'mileage.daily_total', + domain: 'mileage', + label: '当日总里程', + description: '所选自然日内有效车辆日里程之和。', + unit: 'km', + aggregation: 'sum', + timeSemantics: 'flow', + formula: 'SUM(vehicle_daily_mileage_km)', + sources: ['OneOS mileage API'], + dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model', 'plate'], + drillEntity: 'vehicle', + }, + { + id: 'mileage.period_total', + domain: 'mileage', + label: '区间总里程', + description: '所选自然日区间内有效车辆日里程之和。', + unit: 'km', + aggregation: 'sum', + timeSemantics: 'flow', + formula: 'SUM(vehicle_daily_mileage_km) OVER selected_date_range', + sources: ['OneOS mileage range API'], + dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model', 'plate'], + drillEntity: 'vehicle', + }, + { + id: 'mileage.vehicle_count', + domain: 'mileage', + label: '监控车辆数', + description: '当前权限和筛选范围内的去重车辆数量。', + unit: 'vehicle', + aggregation: 'distinct-count', + timeSemantics: 'snapshot', + formula: 'COUNT(DISTINCT vehicle_id)', + sources: ['Asset database'], + dimensions: ['department', 'region', 'customer', 'vehicle-model'], + drillEntity: 'vehicle', + }, + { + id: 'mileage.assessment_completion_rate', + domain: 'mileage', + label: '考核完成率', + description: '按目标里程加权汇总,不平均车辆或考核组的完成率。', + unit: 'percent', + aggregation: 'weighted-ratio', + timeSemantics: 'snapshot', + formula: 'SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100', + sources: ['Mileage assessment database'], + dimensions: ['assessment-year', 'assessment-target', 'department', 'customer', 'plate'], + drillEntity: 'assessment-target', + }, + { + id: 'mileage.data_freshness', + domain: 'mileage', + label: '数据统计时间', + description: '当前选源协议最后一条有效里程数据的业务时间。', + unit: 'timestamp', + aggregation: 'latest', + timeSemantics: 'freshness', + formula: 'COALESCE(data_time, updated_at, calculated_at)', + sources: ['OneOS mileage API'], + dimensions: ['source-protocol', 'plate'], + drillEntity: 'vehicle', + }, +]; + +export const METRIC_CATALOG: readonly MetricDefinition[] = MILEAGE_METRICS; + +export function isMetricDomain(value: string): value is MetricDomain { + return METRIC_CATALOG.some(metric => metric.domain === value); +} + +export function listMetricDefinitions(domain?: MetricDomain): MetricDefinition[] { + return METRIC_CATALOG + .filter(metric => !domain || metric.domain === domain) + .map(metric => ({ ...metric })); +}