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
+53 -9
View File
@@ -4,6 +4,11 @@ import { dirname, join } from 'node:path';
import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
import {
sourceCategoryFromProtocol,
type MileageSourceCategory,
type OneOsProtocol,
} from './source-policy.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -64,6 +69,7 @@ interface MileageRow {
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
@@ -75,6 +81,7 @@ interface DailyMileageRow {
date: string;
daily_km: string | number | null;
source: string | null;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
@@ -87,6 +94,7 @@ function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
daily_km: String(row.dailyMileageKm ?? 0),
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
@@ -190,6 +198,8 @@ function mergeVehicles(
// Never backfill it from another mileage source outside the assessment page.
totalKm: gpsTotal,
source,
sourceProtocol: m?.source_protocol || null,
sourceCategory: sourceCategoryFromProtocol(m?.source_protocol || null),
dataTime: m?.data_time || null,
calculatedAt: m?.calculated_at || null,
updatedAt: m?.updated_at || null,
@@ -248,10 +258,13 @@ export async function refreshMonitoringCache(): Promise<void> {
}
}
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
export async function queryDateMileage(
dateStr: string,
protocolPriority?: OneOsProtocol[],
): Promise<CachedVehicle[]> {
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(dateStr),
fetchOneOsDailyMileage(previousDate(dateStr)),
fetchOneOsDailyMileage(dateStr, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -282,11 +295,16 @@ function datesBetween(start: string, end: string): string[] {
return result;
}
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
export async function queryRangeMileage(
startDate: string,
endDate: string,
protocolPriority?: OneOsProtocol[],
): Promise<RangeMileageResult> {
if (startDate === endDate) {
const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
...vehicle,
dailyMileage: { [startDate]: vehicle.dailyKm },
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
}));
return {
vehicles,
@@ -301,9 +319,9 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
const days = datesBetween(startDate, endDate);
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days),
fetchOneOsDailyMileage(endDate),
fetchOneOsDailyMileage(previousDate(startDate)),
fetchOneOsMileageDates(days, undefined, protocolPriority),
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(startDate), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -316,6 +334,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
date,
daily_km: row.dailyMileageKm,
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
@@ -324,12 +343,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
}
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
const perVehicleSum = new Map<string, {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
@@ -357,6 +379,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
@@ -365,6 +396,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
@@ -379,6 +411,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
...aggregate,
vin: endDateRow.vin || aggregate.vin,
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
data_time: endDateRow.dataTime || aggregate.data_time,
updated_at: endDateRow.updatedAt || aggregate.updated_at,
});
@@ -393,9 +426,20 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
buildPlateTargetNamesMap(targetRows),
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
return { ...vehicle, dailyMileage: completedDailyMileage };
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {
+11 -2
View File
@@ -3,6 +3,11 @@ import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
import {
DEFAULT_MILEAGE_SOURCE_PRIORITY,
parseMileageSourcePriority,
protocolsForSourcePriority,
} from './source-policy.js';
const app = new Hono();
@@ -14,6 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY],
};
function applyFilters(vehicles: CachedVehicle[], params: {
@@ -104,6 +110,8 @@ app.get('/', async (c) => {
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const filterParams = {
search: c.req.query('search') || '',
@@ -128,7 +136,7 @@ app.get('/', async (c) => {
if (range) {
try {
const result = await queryRangeMileage(range.start, range.end);
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
@@ -139,7 +147,7 @@ app.get('/', async (c) => {
}
} else if (date) {
try {
allVehicles = await queryDateMileage(date);
allVehicles = await queryDateMileage(date, protocolPriority);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
@@ -200,6 +208,7 @@ app.get('/', async (c) => {
page,
totalPages: Math.ceil(total / limit),
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
sourcePriority,
});
});
+73 -11
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;
@@ -0,0 +1,38 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type OneOsProtocol = 'GB32960' | 'MQTT' | 'JT808';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS';
export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument', 'gps'];
export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT', 'JT808'];
export function parseMileageSourcePriority(value?: string): MileageSourceGroup[] {
if (!value) return [...DEFAULT_MILEAGE_SOURCE_PRIORITY];
const groups = value.split(',')
.map(item => item.trim().toLowerCase())
.filter((item): item is MileageSourceGroup => item === 'instrument' || item === 'gps');
const unique = Array.from(new Set(groups));
return unique.length > 0 ? unique : [...DEFAULT_MILEAGE_SOURCE_PRIORITY];
}
export function protocolsForSourcePriority(groups: MileageSourceGroup[]): OneOsProtocol[] {
return groups.flatMap(group => group === 'instrument'
? ['GB32960', 'MQTT'] as OneOsProtocol[]
: ['JT808'] as OneOsProtocol[]);
}
export function normalizeOneOsProtocol(value: unknown): OneOsProtocol | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
if (normalized === 'GB32960' || normalized === '32960') return 'GB32960';
if (normalized === 'MQTT') return 'MQTT';
if (normalized === 'JT808' || normalized === '808') return 'JT808';
return null;
}
export function sourceCategoryFromProtocol(
protocol: OneOsProtocol | null,
): MileageSourceCategory | null {
if (protocol === 'GB32960' || protocol === 'MQTT') return 'INSTRUMENT';
if (protocol === 'JT808') return 'GPS';
return null;
}
+4
View File
@@ -6,6 +6,9 @@ export interface CachedVehicle {
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;
dailySourceProtocols?: Record<string, 'GB32960' | 'MQTT' | 'JT808' | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
@@ -72,6 +75,7 @@ export interface MonitoringResponse {
page: number;
totalPages: number;
updatedAt: string;
sourcePriority: ('instrument' | 'gps')[];
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
+24 -3
View File
@@ -1,5 +1,10 @@
import { Hono } from 'hono';
import { fetchOneOsMileageDates } from './oneos-api.js';
import {
parseMileageSourcePriority,
protocolsForSourcePriority,
sourceCategoryFromProtocol,
} from './source-policy.js';
const app = new Hono();
@@ -23,6 +28,8 @@ const MAX_DAYS = 366;
app.get('/:plate/recent', async (c) => {
const plate = c.req.param('plate');
if (!plate) return c.json({ plate: '', days: [] }, 400);
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -58,10 +65,16 @@ app.get('/:plate/recent', async (c) => {
dates.push(fmt(dateCursor));
dateCursor.setDate(dateCursor.getDate() + 1);
}
const rowsByDate = await fetchOneOsMileageDates(dates, [plate]);
const rowsByDate = await fetchOneOsMileageDates(dates, [plate], protocolPriority);
// 补全:从 start 到 end 每天一条
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
const result: {
date: string;
dailyKm: number;
isDataSynced: boolean;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | null;
}[] = [];
const cursor = new Date(start);
while (cursor <= end) {
const key = fmt(cursor);
@@ -70,11 +83,19 @@ app.get('/:plate/recent', async (c) => {
date: key,
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
isDataSynced: hit?.status === 'NORMAL',
sourceProtocol: hit?.sourceProtocol || null,
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
});
cursor.setDate(cursor.getDate() + 1);
}
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
return c.json({
plate,
start: fmt(start),
end: fmt(end),
sourcePriority,
days: result,
});
} catch (e: unknown) {
console.error('vehicle recent error:', e);
return c.json({ plate, days: [] }, 500);