diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 573282c..d5be8d2 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -180,6 +180,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。 - 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。 - 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。 +- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。 - 已完成电能数据截至时间和实际趋势月展示。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 已完成氢能结构化故障状态与重试体验。 diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index a2b5d73..b51ab13 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -18,6 +18,10 @@ import { storeManualMonitoringSnapshot, } from './manual-snapshot.js'; import { sumMileageKm } from './precision.js'; +import { + rangeMileageSnapshotCache, + type RangeSnapshotStatus, +} from './range-snapshot.js'; const app = new Hono(); @@ -148,7 +152,7 @@ app.get('/', async (c) => { let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; let dateRange: { start: string; end: string } | undefined; let dataUpdatedAt: string | undefined; - let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass'; + let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' | RangeSnapshotStatus = 'bypass'; if (range) { const cache = getCache(); @@ -193,7 +197,14 @@ app.get('/', async (c) => { } else { cacheStatus = cacheEligible ? 'miss' : 'bypass'; try { - const result = await queryRangeMileage(range.start, range.end, protocolPriority); + const loaded = await rangeMileageSnapshotCache.load({ + startDate: range.start, + endDate: range.end, + protocolPriority, + force, + }, () => queryRangeMileage(range.start, range.end, protocolPriority)); + const { result } = loaded; + cacheStatus = loaded.status; allVehicles = result.vehicles; rangeDailyTotals = result.dailyTotals; dateRange = { start: result.start, end: result.end }; diff --git a/src/server/routes/mileage/range-snapshot.test.ts b/src/server/routes/mileage/range-snapshot.test.ts new file mode 100644 index 0000000..e9d378e --- /dev/null +++ b/src/server/routes/mileage/range-snapshot.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { RangeMileageSnapshotCache } from './range-snapshot.js'; +import type { RangeMileageResult } from './cache.js'; + +function result(start = '2026-08-01', end = '2026-08-07'): RangeMileageResult { + return { vehicles: [], dailyTotals: [], start, end }; +} + +const input = { + startDate: '2026-08-01', + endDate: '2026-08-07', + protocolPriority: ['GB32960'] as const, +}; + +test('reuses a completed range snapshot until its TTL expires', async () => { + let now = 1_000; + let calls = 0; + const cache = new RangeMileageSnapshotCache({ ttlMs: 60_000, now: () => now }); + const loader = async () => { + calls += 1; + return result(); + }; + + assert.equal((await cache.load(input, loader)).status, 'range-miss'); + assert.equal((await cache.load(input, loader)).status, 'range-hit'); + now = 61_000; + assert.equal((await cache.load(input, loader)).status, 'range-miss'); + assert.equal(calls, 2); +}); + +test('deduplicates concurrent requests for the same range', async () => { + const cache = new RangeMileageSnapshotCache(); + let calls = 0; + let resolve!: (value: RangeMileageResult) => void; + const pending = new Promise(done => { resolve = done; }); + const loader = async () => { + calls += 1; + return pending; + }; + + const first = cache.load(input, loader); + const second = cache.load(input, loader); + resolve(result()); + + assert.equal((await first).status, 'range-miss'); + assert.equal((await second).status, 'range-inflight'); + assert.equal(calls, 1); +}); + +test('keeps date ranges and source protocols in separate cache keys', async () => { + const cache = new RangeMileageSnapshotCache(); + let calls = 0; + const loader = async () => { + calls += 1; + return result(); + }; + + await cache.load(input, loader); + await cache.load({ ...input, endDate: '2026-08-06' }, loader); + await cache.load({ ...input, protocolPriority: ['MQTT'] }, loader); + assert.equal(calls, 3); +}); + +test('force refresh bypasses a completed snapshot', async () => { + const cache = new RangeMileageSnapshotCache(); + let calls = 0; + const loader = async () => { + calls += 1; + return result(); + }; + + await cache.load(input, loader); + const refreshed = await cache.load({ ...input, force: true }, loader); + assert.equal(refreshed.status, 'range-refresh'); + assert.equal(calls, 2); +}); + +test('does not cache failed range requests', async () => { + const cache = new RangeMileageSnapshotCache(); + let calls = 0; + const loader = async () => { + calls += 1; + if (calls === 1) throw new Error('upstream unavailable'); + return result(); + }; + + await assert.rejects(cache.load(input, loader), /upstream unavailable/); + const recovered = await cache.load(input, loader); + assert.equal(recovered.status, 'range-miss'); + assert.equal(calls, 2); +}); diff --git a/src/server/routes/mileage/range-snapshot.ts b/src/server/routes/mileage/range-snapshot.ts new file mode 100644 index 0000000..229c9ce --- /dev/null +++ b/src/server/routes/mileage/range-snapshot.ts @@ -0,0 +1,97 @@ +import type { RangeMileageResult } from './cache.js'; +import type { OneOsProtocol } from './source-policy.js'; + +export type RangeSnapshotStatus = 'range-hit' | 'range-miss' | 'range-refresh' | 'range-inflight'; + +export interface RangeSnapshotLoadResult { + result: RangeMileageResult; + status: RangeSnapshotStatus; +} + +interface RangeSnapshotEntry { + result: RangeMileageResult; + expiresAt: number; +} + +interface RangeSnapshotCacheOptions { + ttlMs?: number; + maxEntries?: number; + now?: () => number; +} + +interface RangeSnapshotInput { + startDate: string; + endDate: string; + protocolPriority: readonly OneOsProtocol[]; + force?: boolean; +} + +const DEFAULT_TTL_MS = 60 * 1000; +const DEFAULT_MAX_ENTRIES = 8; + +function snapshotKey(input: RangeSnapshotInput): string { + return [input.startDate, input.endDate, input.protocolPriority.join('>')].join(':'); +} + +export class RangeMileageSnapshotCache { + private readonly entries = new Map(); + private readonly inflight = new Map>(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + + constructor(options: RangeSnapshotCacheOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.now = options.now ?? Date.now; + } + + async load( + input: RangeSnapshotInput, + loader: () => Promise, + ): Promise { + const key = snapshotKey(input); + if (!input.force) { + const entry = this.entries.get(key); + if (entry && entry.expiresAt > this.now()) { + this.entries.delete(key); + this.entries.set(key, entry); + return { result: entry.result, status: 'range-hit' }; + } + } + + const existing = this.inflight.get(key); + if (existing) { + return { result: await existing, status: 'range-inflight' }; + } + + const pending = loader() + .then(result => { + this.entries.delete(key); + this.entries.set(key, { + result, + expiresAt: this.now() + this.ttlMs, + }); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as string | undefined; + if (!oldest) break; + this.entries.delete(oldest); + } + return result; + }) + .finally(() => this.inflight.delete(key)); + this.inflight.set(key, pending); + + return { + result: await pending, + status: input.force ? 'range-refresh' : 'range-miss', + }; + } + + clear(): void { + this.entries.clear(); + this.inflight.clear(); + } +} + +export const rangeMileageSnapshotCache = new RangeMileageSnapshotCache();