refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
+20 -118
View File
@@ -1,9 +1,19 @@
import dotenv from 'dotenv';
import {
DEFAULT_ONEOS_PROTOCOL_PRIORITY,
normalizeOneOsProtocol,
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();
@@ -15,19 +25,6 @@ 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;
@@ -78,80 +75,6 @@ function shanghaiDate(): string {
}).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[],
@@ -193,7 +116,7 @@ async function requestDate(
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
return normalizeRows(payload.data, date);
return normalizeOneOsRows(payload.data, date);
}
} catch (error) {
lastError = error;
@@ -280,7 +203,7 @@ async function requestRange(
snapshotId = payload.snapshotId || snapshotId;
allRows.push(...payload.data);
const nextCursor = payload.nextCursor || null;
if (!nextCursor) return normalizeRangeRows(allRows, startDate, endDate);
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;
@@ -302,7 +225,7 @@ export async function fetchOneOsDailyMileage(
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}`);
assertMileageDate(date);
const policyKey = protocolPriority.join('>');
const cacheKey = `${date}:${policyKey}`;
@@ -338,38 +261,17 @@ export async function fetchOneOsMileageDates(
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]);
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 = [
startDate,
endDate,
selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*',
protocolPriority.join('>'),
].join(':');
const key = oneOsRangeRequestKey(startDate, endDate, selectedPlates, protocolPriority);
let pending = rangeInflight.get(key);
if (!pending) {
pending = requestRange(