import dotenv from 'dotenv'; import { DEFAULT_ONEOS_PROTOCOL_PRIORITY, normalizeOneOsProtocol, type OneOsProtocol, } from './source-policy.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; export interface OneOsDailyMileage { vin: string; plateNumber: string; date: string; dailyMileageKm: number | null; totalMileageKm: number | null; status: 'NORMAL' | 'NO_DATA'; dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; sourceProtocol: OneOsProtocol | null; } 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()); } function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage[] { if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field'); const best = new Map(); for (const item of value) { if (!item || typeof item !== 'object') continue; const row = item as Record; const vin = typeof row.vin === 'string' ? row.vin.trim() : ''; const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : ''; const date = typeof row.date === 'string' ? row.date : requestedDate; const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA'; const rawKm = row.dailyMileageKm; const numericKm = rawKm === null || rawKm === undefined ? null : Number(rawKm); const dailyMileageKm = status === 'NORMAL' && numericKm !== null && Number.isFinite(numericKm) ? Math.max(0, numericKm) : null; const rawTotalKm = row.totalMileageKm; const numericTotalKm = rawTotalKm === null || rawTotalKm === undefined ? null : Number(rawTotalKm); const totalMileageKm = status === 'NORMAL' && numericTotalKm !== null && Number.isFinite(numericTotalKm) ? Math.max(0, numericTotalKm) : null; const stringField = (...names: string[]): string | null => { for (const name of names) { const field = row[name]; if (typeof field === 'string' && field.trim()) return field.trim(); } return null; }; // dataTime is the preferred contract. Aliases keep the BI forward-compatible // while the Open API rolls out the formal field name. const dataTime = stringField('dataTime', 'statisticTime', 'recordTime'); const calculatedAt = stringField('calculatedAt', 'calculationTime'); const updatedAt = stringField('updatedAt'); const sourceProtocol = normalizeOneOsProtocol( stringField('sourceProtocol', 'protocol', 'dataSource'), ); if (!plateNumber || date !== requestedDate) continue; const normalized: OneOsDailyMileage = { vin, plateNumber, date, dailyMileageKm, totalMileageKm, status, dataTime, calculatedAt, updatedAt, sourceProtocol, }; const existing = best.get(plateNumber); if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) { best.set(plateNumber, normalized); } } return Array.from(best.values()); } function normalizeRangeRows(value: unknown, startDate: string, endDate: string): Map { if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field'); const rawByDate = new Map(); for (const item of value) { if (!item || typeof item !== 'object') continue; const date = (item as Record).date; if (typeof date !== 'string' || date < startDate || date > endDate) continue; const rows = rawByDate.get(date) || []; rows.push(item); rawByDate.set(date, rows); } const result = new Map(); for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date)); return result; } 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 normalizeRows(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 normalizeRangeRows(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 { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${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 uniqueDates = Array.from(new Set(dates)).sort(); const selectedPlates = Array.from(new Set( (plateNumbers || []).map(plate => plate.trim()).filter(Boolean), )).sort(); if (uniqueDates.length === 0) return result; for (const date of uniqueDates) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`); result.set(date, []); } const groups: string[][] = []; for (const date of uniqueDates) { const current = groups[groups.length - 1]; if (!current) { groups.push([date]); continue; } const previous = new Date(`${current[current.length - 1]}T00:00:00Z`); previous.setUTCDate(previous.getUTCDate() + 1); if (previous.toISOString().slice(0, 10) === date && current.length < 366) current.push(date); else groups.push([date]); } await Promise.all(groups.map(async group => { const startDate = group[0]; const endDate = group[group.length - 1]; const key = [ startDate, endDate, selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*', protocolPriority.join('>'), ].join(':'); 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(); }