Files
ln-bi/src/server/routes/mileage/oneos-api.ts

393 lines
14 KiB
TypeScript

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<string, CacheEntry>();
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
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<string, OneOsDailyMileage>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
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<string, OneOsDailyMileage[]> {
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
const rawByDate = new Map<string, unknown[]>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const date = (item as Record<string, unknown>).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<string, OneOsDailyMileage[]>();
for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date));
return result;
}
async function requestDate(
date: string,
protocolPriority: OneOsProtocol[],
): Promise<OneOsDailyMileage[]> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const includeProtocolPriority = protocolPrioritySupported !== false;
const body: Record<string, unknown> = { 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<Map<string, OneOsDailyMileage[]>> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
const allRows: unknown[] = [];
const seenCursors = new Set<string>();
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<string, unknown> = {
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<OneOsDailyMileage[]> {
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<Map<string, OneOsDailyMileage[]>> {
const result = new Map<string, OneOsDailyMileage[]>();
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();
}