import dotenv from 'dotenv'; import { DEFAULT_ONEOS_PROTOCOL_PRIORITY, type OneOsProtocol, } from './source-policy.js'; import { assertMileageDate, groupContiguousMileageDates, normalizeOneOsRangeRows, normalizeOneOsRows, normalizeSelectedPlates, oneOsRangeRequestKey, type OneOsDailyMileage, } from './oneos-model.js'; export type { OneOsDailyMileage } from './oneos-model.js'; dotenv.config(); const ENDPOINT = '/api/v1/vehicles/mileage/query'; const RANGE_ENDPOINT = '/api/v1/vehicles/mileage/range/query'; const DEFAULT_TIMEOUT_MS = 20_000; const CURRENT_DAY_TTL_MS = 0; const HISTORICAL_TTL_MS = 6 * 60 * 60 * 1000; const MAX_CACHE_DATES = 400; const RANGE_PAGE_SIZE = 5000; interface OneOsMileageResponse { code?: string; message?: string; data?: unknown; traceId?: string; } interface OneOsMileageRangeResponse extends OneOsMileageResponse { snapshotId?: string; nextCursor?: string | null; } interface CacheEntry { expiresAt: number; rows: OneOsDailyMileage[]; } const cache = new Map(); const inflight = new Map>(); const rangeInflight = new Map>>(); let protocolPrioritySupported: boolean | null = null; let protocolFallbackWarned = false; function markProtocolPriorityUnsupported(): void { protocolPrioritySupported = false; if (protocolFallbackWarned) return; protocolFallbackWarned = true; console.warn( '[mileage] OneOS does not support protocolPriority/sourceProtocol yet; using the legacy request contract', ); } function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } { const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, ''); const apiKey = process.env.ONEOS_MILEAGE_API_KEY || ''; const timeoutMs = Number(process.env.ONEOS_MILEAGE_API_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS; if (!baseUrl) throw new Error('ONEOS_MILEAGE_API_BASE_URL is not configured'); if (!apiKey) throw new Error('ONEOS_MILEAGE_API_KEY is not configured'); return { baseUrl, apiKey, timeoutMs }; } function shanghaiDate(): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); } async function requestDate( date: string, protocolPriority: OneOsProtocol[], ): Promise { const { baseUrl, apiKey, timeoutMs } = apiConfig(); let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { const includeProtocolPriority = protocolPrioritySupported !== false; const body: Record = { date }; if (includeProtocolPriority) body.protocolPriority = protocolPriority; const response = await fetch(`${baseUrl}${ENDPOINT}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, // Intentionally omit plateNumbers: the API then returns every vehicle // authorized for this application on the requested natural day. body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const payload = await response.json().catch(() => null) as OneOsMileageResponse | null; if ( includeProtocolPriority && response.status === 400 && payload?.code === 'INVALID_REQUEST' ) { markProtocolPriorityUnsupported(); continue; } if (!response.ok || payload?.code !== 'SUCCESS') { const trace = payload?.traceId ? `, traceId=${payload.traceId}` : ''; const error = new Error( `OneOS mileage API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`, ); if (response.status < 500 || attempt === 2) throw error; lastError = error; } else { if (includeProtocolPriority) protocolPrioritySupported = true; return normalizeOneOsRows(payload.data, date); } } catch (error) { lastError = error; const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message); if (nonRetryable || attempt === 2) throw error; } await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)); } throw lastError instanceof Error ? lastError : new Error('OneOS mileage API request failed'); } async function requestRange( startDate: string, endDate: string, plateNumbers: string[], protocolPriority: OneOsProtocol[], ): Promise> { const { baseUrl, apiKey, timeoutMs } = apiConfig(); const allRows: unknown[] = []; const seenCursors = new Set(); let cursor: string | null = null; let snapshotId: string | null = null; for (let page = 0; page < 10_000; page += 1) { let payload: OneOsMileageRangeResponse | null = null; let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { const body: Record = { startDate, endDate, pageSize: RANGE_PAGE_SIZE, }; const includeProtocolPriority = protocolPrioritySupported !== false; if (includeProtocolPriority) body.protocolPriority = protocolPriority; if (plateNumbers.length > 0) body.plateNumbers = plateNumbers; if (cursor) body.cursor = cursor; const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null; if ( includeProtocolPriority && response.status === 400 && payload?.code === 'INVALID_REQUEST' ) { markProtocolPriorityUnsupported(); continue; } if (!response.ok || payload?.code !== 'SUCCESS') { const trace = payload?.traceId ? `, traceId=${payload.traceId}` : ''; const error = new Error( `OneOS mileage range API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`, ); if (response.status < 500 || attempt === 2) throw error; lastError = error; } else { if (includeProtocolPriority) protocolPrioritySupported = true; break; } } catch (error) { lastError = error; const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message); if (nonRetryable || attempt === 2) throw error; } await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)); } if (!payload || payload.code !== 'SUCCESS') { throw lastError instanceof Error ? lastError : new Error('OneOS mileage range API request failed'); } if (!Array.isArray(payload.data)) throw new Error('OneOS mileage range API returned a non-array data field'); if (snapshotId && payload.snapshotId !== snapshotId) { throw new Error('OneOS mileage range API snapshot changed during pagination'); } snapshotId = payload.snapshotId || snapshotId; allRows.push(...payload.data); const nextCursor = payload.nextCursor || null; if (!nextCursor) return normalizeOneOsRangeRows(allRows, startDate, endDate); if (seenCursors.has(nextCursor)) throw new Error('OneOS mileage range API returned a repeated cursor'); seenCursors.add(nextCursor); cursor = nextCursor; } throw new Error('OneOS mileage range API exceeded the pagination safety limit'); } function trimCache(): void { while (cache.size > MAX_CACHE_DATES) { const oldestKey = cache.keys().next().value as string | undefined; if (!oldestKey) break; cache.delete(oldestKey); } } export async function fetchOneOsDailyMileage( date: string, plateNumbers?: string[], protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, ): Promise { assertMileageDate(date); const policyKey = protocolPriority.join('>'); const cacheKey = `${date}:${policyKey}`; const hit = cache.get(cacheKey); let rows: OneOsDailyMileage[]; if (hit && hit.expiresAt > Date.now()) { rows = hit.rows; } else { let pending = inflight.get(cacheKey); if (!pending) { pending = requestDate(date, protocolPriority).then(result => { const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS; cache.delete(cacheKey); if (ttl > 0) { cache.set(cacheKey, { rows: result, expiresAt: Date.now() + ttl }); trimCache(); } return result; }).finally(() => inflight.delete(cacheKey)); inflight.set(cacheKey, pending); } rows = await pending; } if (!plateNumbers?.length) return rows; const selected = new Set(plateNumbers.map(plate => plate.trim()).filter(Boolean)); return rows.filter(row => selected.has(row.plateNumber)); } export async function fetchOneOsMileageDates( dates: string[], plateNumbers?: string[], protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, ): Promise> { const result = new Map(); const selectedPlates = normalizeSelectedPlates(plateNumbers); if (dates.length === 0) return result; const groups = groupContiguousMileageDates(dates); for (const group of groups) { for (const date of group) result.set(date, []); } await Promise.all(groups.map(async group => { const startDate = group[0]; const endDate = group[group.length - 1]; const key = oneOsRangeRequestKey(startDate, endDate, selectedPlates, protocolPriority); let pending = rangeInflight.get(key); if (!pending) { pending = requestRange( startDate, endDate, selectedPlates, protocolPriority, ).finally(() => rangeInflight.delete(key)); rangeInflight.set(key, pending); } const rowsByDate = await pending; for (const date of group) result.set(date, rowsByDate.get(date) || []); })); return result; } export function clearOneOsMileageCache(): void { cache.clear(); rangeInflight.clear(); }