393 lines
14 KiB
TypeScript
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();
|
|
}
|