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

295 lines
10 KiB
TypeScript

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<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());
}
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 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<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 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<OneOsDailyMileage[]> {
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<Map<string, OneOsDailyMileage[]>> {
const result = new Map<string, OneOsDailyMileage[]>();
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();
}