feat(mileage): add OneOS source priority controls

This commit is contained in:
kkfluous
2026-07-23 14:11:52 +08:00
parent becacecc67
commit d8773ff0a0
12 changed files with 448 additions and 44 deletions

View File

@@ -1,4 +1,9 @@
import dotenv from 'dotenv';
import {
DEFAULT_ONEOS_PROTOCOL_PRIORITY,
normalizeOneOsProtocol,
type OneOsProtocol,
} from './source-policy.js';
dotenv.config();
@@ -20,6 +25,7 @@ export interface OneOsDailyMileage {
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
sourceProtocol: OneOsProtocol | null;
}
interface OneOsMileageResponse {
@@ -42,6 +48,17 @@ interface CacheEntry {
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(/\/+$/, '');
@@ -94,6 +111,9 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage
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 = {
@@ -106,6 +126,7 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage
dataTime,
calculatedAt,
updatedAt,
sourceProtocol,
};
const existing = best.get(plateNumber);
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
@@ -131,12 +152,18 @@ function normalizeRangeRows(value: unknown, startDate: string, endDate: string):
return result;
}
async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
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: {
@@ -145,10 +172,18 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
},
// Intentionally omit plateNumbers: the API then returns every vehicle
// authorized for this application on the requested natural day.
body: JSON.stringify({ date }),
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(
@@ -157,6 +192,7 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
return normalizeRows(payload.data, date);
}
} catch (error) {
@@ -175,6 +211,7 @@ async function requestRange(
startDate: string,
endDate: string,
plateNumbers: string[],
protocolPriority: OneOsProtocol[],
): Promise<Map<string, OneOsDailyMileage[]>> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
const allRows: unknown[] = [];
@@ -192,6 +229,8 @@ async function requestRange(
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}`, {
@@ -204,6 +243,14 @@ async function requestRange(
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(
@@ -212,6 +259,7 @@ async function requestRange(
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
break;
}
} catch (error) {
@@ -252,26 +300,29 @@ function trimCache(): void {
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 hit = cache.get(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(date);
let pending = inflight.get(cacheKey);
if (!pending) {
pending = requestDate(date).then(result => {
pending = requestDate(date, protocolPriority).then(result => {
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
cache.delete(date);
cache.delete(cacheKey);
if (ttl > 0) {
cache.set(date, { rows: result, expiresAt: Date.now() + ttl });
cache.set(cacheKey, { rows: result, expiresAt: Date.now() + ttl });
trimCache();
}
return result;
}).finally(() => inflight.delete(date));
inflight.set(date, pending);
}).finally(() => inflight.delete(cacheKey));
inflight.set(cacheKey, pending);
}
rows = await pending;
}
@@ -284,6 +335,7 @@ export async function fetchOneOsDailyMileage(
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();
@@ -312,10 +364,20 @@ export async function fetchOneOsMileageDates(
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') : '*'}`;
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).finally(() => rangeInflight.delete(key));
pending = requestRange(
startDate,
endDate,
selectedPlates,
protocolPriority,
).finally(() => rangeInflight.delete(key));
rangeInflight.set(key, pending);
}
const rowsByDate = await pending;