perf: reuse mileage monitoring snapshots

This commit is contained in:
kkfluous
2026-08-07 15:48:07 +08:00
parent 60e81cb4ba
commit de2a0896c4
8 changed files with 195 additions and 11 deletions
+1
View File
@@ -162,6 +162,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成电能数据截至时间和实际趋势月展示。 - 已完成电能数据截至时间和实际趋势月展示。
- 已完成氢能结构化故障状态与重试体验。 - 已完成氢能结构化故障状态与重试体验。
- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。
- 待恢复氢能数据库后进行总览、站点、客户三层对账。 - 待恢复氢能数据库后进行总览、站点、客户三层对账。
- 待 ETC 同步后进行通行记录、账单和收款三层对账。 - 待 ETC 同步后进行通行记录、账单和收款三层对账。
+3 -2
View File
@@ -565,7 +565,7 @@ export default function MonitoringView() {
}, [filterTargetNames]); }, [filterTargetNames]);
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true) => { const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
if (showPageLoading) setPageLoading(true); if (showPageLoading) setPageLoading(true);
setPageError(null); setPageError(null);
return fetchMonitoring({ return fetchMonitoring({
@@ -589,6 +589,7 @@ export default function MonitoringView() {
endDate: rangeEnd || undefined, endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined, brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority, sourcePriority,
force,
}).then(d => { }).then(d => {
setVehicles(d.vehicles); setVehicles(d.vehicles);
setStats(d.stats); setStats(d.stats);
@@ -613,7 +614,7 @@ export default function MonitoringView() {
if (manualRefreshing) return; if (manualRefreshing) return;
setManualRefreshing(true); setManualRefreshing(true);
try { try {
await loadFirstPage(false); await loadFirstPage(false, true);
} finally { } finally {
setManualRefreshing(false); setManualRefreshing(false);
} }
+2
View File
@@ -44,6 +44,7 @@ export async function fetchMonitoring(params?: {
endDate?: string; endDate?: string;
brands?: string[]; brands?: string[];
sourcePriority?: MileageSourceGroup[]; sourcePriority?: MileageSourceGroup[];
force?: boolean;
}): Promise<MonitoringData> { }): Promise<MonitoringData> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy); if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -76,6 +77,7 @@ export async function fetchMonitoring(params?: {
if (params?.startDate) query.set('startDate', params.startDate); if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate); if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(',')); if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
if (params?.force) query.set('force', '1');
const qs = query.toString(); const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
} }
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearManualMonitoringSnapshot,
getManualMonitoringSnapshot,
storeManualMonitoringSnapshot,
} from './manual-snapshot.js';
const result = {
vehicles: [],
dailyTotals: [{ date: '2026-08-07', totalKm: 12 }],
start: '2026-08-07',
end: '2026-08-07',
};
test('reuses a manual snapshot only for its date range and TTL', () => {
clearManualMonitoringSnapshot();
storeManualMonitoringSnapshot(result, 1_000);
assert.ok(getManualMonitoringSnapshot('2026-08-07', '2026-08-07', 60_999));
assert.equal(getManualMonitoringSnapshot('2026-08-06', '2026-08-06', 60_999), null);
assert.equal(getManualMonitoringSnapshot('2026-08-07', '2026-08-07', 61_000), null);
});
@@ -0,0 +1,40 @@
import type { RangeMileageResult } from './cache.js';
const MANUAL_SNAPSHOT_TTL_MS = 60 * 1000;
interface ManualSnapshot {
result: RangeMileageResult;
updatedAt: string;
expiresAt: number;
}
let snapshot: ManualSnapshot | null = null;
export function storeManualMonitoringSnapshot(
result: RangeMileageResult,
now = Date.now(),
): ManualSnapshot {
snapshot = {
result,
updatedAt: new Date(now).toISOString(),
expiresAt: now + MANUAL_SNAPSHOT_TTL_MS,
};
return snapshot;
}
export function getManualMonitoringSnapshot(
start: string,
end: string,
now = Date.now(),
): ManualSnapshot | null {
if (!snapshot || snapshot.expiresAt <= now) {
snapshot = null;
return null;
}
if (snapshot.result.start !== start || snapshot.result.end !== end) return null;
return snapshot;
}
export function clearManualMonitoringSnapshot(): void {
snapshot = null;
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isMonitoringCacheEligible,
isMonitoringSnapshotScope,
shanghaiDate,
} from './monitoring-cache-policy.js';
test('uses the monitoring snapshot only for the current instrument day', () => {
const base = {
startDate: '2026-08-07',
endDate: '2026-08-07',
today: '2026-08-07',
sourcePriority: ['instrument'] as const,
force: false,
};
assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: [...base.sourcePriority] }), true);
assert.equal(isMonitoringCacheEligible({ ...base, startDate: '2026-08-06', sourcePriority: ['instrument'] }), false);
assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: ['gps'] }), false);
assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: ['instrument'], force: true }), false);
assert.equal(isMonitoringSnapshotScope({ ...base, sourcePriority: ['instrument'] }), true);
});
test('formats the current date in Asia/Shanghai', () => {
assert.equal(shanghaiDate(new Date('2026-08-06T16:30:00.000Z')), '2026-08-07');
});
@@ -0,0 +1,32 @@
import type { MileageSourceGroup } from './source-policy.js';
export function isMonitoringCacheEligible(input: {
startDate: string;
endDate: string;
today: string;
sourcePriority: MileageSourceGroup[];
force: boolean;
}): boolean {
return !input.force && isMonitoringSnapshotScope(input);
}
export function isMonitoringSnapshotScope(input: {
startDate: string;
endDate: string;
today: string;
sourcePriority: MileageSourceGroup[];
}): boolean {
return input.startDate === input.today
&& input.endDate === input.today
&& input.sourcePriority.length === 1
&& input.sourcePriority[0] === 'instrument';
}
export function shanghaiDate(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
+69 -9
View File
@@ -8,6 +8,15 @@ import {
parseMileageSourcePriority, parseMileageSourcePriority,
protocolsForSourcePriority, protocolsForSourcePriority,
} from './source-policy.js'; } from './source-policy.js';
import {
isMonitoringCacheEligible,
isMonitoringSnapshotScope,
shanghaiDate,
} from './monitoring-cache-policy.js';
import {
getManualMonitoringSnapshot,
storeManualMonitoringSnapshot,
} from './manual-snapshot.js';
const app = new Hono(); const app = new Hono();
@@ -115,6 +124,7 @@ app.get('/', async (c) => {
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || ''); const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority')); const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority); const protocolPriority = protocolsForSourcePriority(sourcePriority);
const force = c.req.query('force') === '1';
const filterParams = { const filterParams = {
search: c.req.query('search') || '', search: c.req.query('search') || '',
@@ -136,17 +146,66 @@ app.get('/', async (c) => {
let filters: MonitoringFilters; let filters: MonitoringFilters;
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined; let dateRange: { start: string; end: string } | undefined;
let dataUpdatedAt: string | undefined;
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass';
if (range) { if (range) {
try { const cache = getCache();
const result = await queryRangeMileage(range.start, range.end, protocolPriority); const today = shanghaiDate();
allVehicles = result.vehicles; const snapshotScope = isMonitoringSnapshotScope({
rangeDailyTotals = result.dailyTotals; startDate: range.start,
dateRange = { start: result.start, end: result.end }; endDate: range.end,
today,
sourcePriority,
});
const cacheEligible = isMonitoringCacheEligible({
startDate: range.start,
endDate: range.end,
today,
sourcePriority,
force,
});
const manualSnapshot = cacheEligible
? getManualMonitoringSnapshot(range.start, range.end)
: null;
if (manualSnapshot) {
cacheStatus = 'manual-hit';
allVehicles = manualSnapshot.result.vehicles;
rangeDailyTotals = manualSnapshot.result.dailyTotals;
dateRange = { start: manualSnapshot.result.start, end: manualSnapshot.result.end };
dataUpdatedAt = manualSnapshot.updatedAt;
filters = buildDateFilters(allVehicles); filters = buildDateFilters(allVehicles);
} catch (e: unknown) { } else if (cacheEligible && cache) {
console.error('monitoring range query error:', e); cacheStatus = 'hit';
return c.json(EMPTY_RESPONSE, 500); allVehicles = cache.vehicles.map(vehicle => ({
...vehicle,
dailyMileage: { [range.start]: vehicle.dailyKm },
dailySourceProtocols: { [range.start]: vehicle.sourceProtocol },
}));
rangeDailyTotals = [{
date: range.start,
totalKm: Math.round(allVehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
}];
dateRange = { ...range };
dataUpdatedAt = cache.updatedAt;
filters = cache.filters;
} else {
cacheStatus = cacheEligible ? 'miss' : 'bypass';
try {
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
filters = buildDateFilters(allVehicles);
if (force && snapshotScope) {
const stored = storeManualMonitoringSnapshot(result);
cacheStatus = 'refresh';
dataUpdatedAt = stored.updatedAt;
}
} catch (e: unknown) {
console.error('monitoring range query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} }
} else if (date) { } else if (date) {
try { try {
@@ -218,6 +277,7 @@ app.get('/', async (c) => {
const paged = sorted.slice(offset, offset + limit); const paged = sorted.slice(offset, offset + limit);
const total = filtered.length; const total = filtered.length;
c.header('X-Mileage-Cache', cacheStatus);
return c.json({ return c.json({
vehicles: maskCustomerNames(paged), vehicles: maskCustomerNames(paged),
stats, stats,
@@ -227,7 +287,7 @@ app.get('/', async (c) => {
total, total,
page, page,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), updatedAt: dataUpdatedAt || dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
sourcePriority, sourcePriority,
}); });
}); });