Files
ln-bi/src/server/routes/vehicle-heatmap.ts
T
shishengliang 6ea1b3d5dd
ci/woodpecker/push/woodpecker Pipeline failed
revert: restore v1.1.14 (37a9f303)
2026-09-16 09:29:30 +08:00

235 lines
7.3 KiB
TypeScript

import { Hono } from 'hono';
import pool from '../db.js';
import heatmapPool from '../heatmap-db.js';
import { getCache } from './mileage/cache.js';
import {
buildVehicleGrid,
buildVehicleRanking,
filterVehicleRecordsByRadius,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './vehicle-heatmap-model.js';
type MetaRow = {
start_date: string;
end_date: string;
location_count: string;
vehicle_count: string;
day_count: string;
total_location_count: string;
excluded_location_count: string;
outside_mainland_count: string;
tibet_count: string;
};
type VehicleOptionRow = {
vin: string;
plate_number: string;
};
type RecordRow = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
source_record_id: string;
};
type BatchModelRow = {
target_name: string;
plate_number: string;
};
async function loadBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = getCache()?.targetPlatesMap;
if (cached?.size) return cached;
try {
const [rows] = await pool.execute(`
select t.target_name, v.plate_number
from lingniu_prod.tab_mileage_assessment_target t
join lingniu_prod.tab_mileage_assessment_vehicle v
on v.target_id = t.id and v.is_deleted = 0
where t.is_deleted = 0
`) as [BatchModelRow[], unknown];
const result = new Map<string, Set<string>>();
for (const row of rows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
} catch (error) {
console.error('[vehicle-heatmap] batch model lookup failed', error);
return new Map();
}
}
async function loadRecords(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): Promise<VehicleHeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await loadBatchModelPlates();
batchPlates = [...(modelPlates.get(batchModel) || [])];
if (batchPlates.length === 0) return [];
}
const result = await heatmapPool.query<RecordRow>(`
select
to_char(stat_date, 'YYYY-MM-DD') as date,
vin,
plate_number as plate,
to_char(first_event_time at time zone 'Asia/Shanghai',
'YYYY-MM-DD HH24:MI:SS') as time,
longitude,
latitude,
source_name as source,
coalesce(source_record_id::text, '') as source_record_id
from analytics.vehicle_daily_first_location
where stat_date between $1::date and $2::date
and is_heatmap_eligible
and ($4::boolean = false or plate_number = any($5::text[]))
and (
$3 = ''
or upper(vin) like '%' || upper($3) || '%'
or upper(plate_number) like '%' || upper($3) || '%'
)
order by stat_date, vin
`, [startDate, endDate, query.trim(), Boolean(batchModel), batchPlates]);
return result.rows.map((row) => ({
...row,
longitude: Number(row.longitude),
latitude: Number(row.latitude),
sourceRecordId: row.source_record_id,
}));
}
const router = new Hono();
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) {
return c.json({ error: '高德地图配置缺失' }, 503);
}
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [metaResult, vehiclesResult, batchModelPlates] = await Promise.all([
heatmapPool.query<MetaRow>(`
select
to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date,
to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date,
count(*) filter (where is_heatmap_eligible)::text as location_count,
count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count,
count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count,
count(*)::text as total_location_count,
count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count,
count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count,
count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count
from analytics.vehicle_daily_first_location
`),
heatmapPool.query<VehicleOptionRow>(`
select distinct on (vin) vin, plate_number
from analytics.vehicle_daily_first_location
where is_heatmap_eligible
order by vin, stat_date desc
`),
loadBatchModelPlates(),
]);
const meta = metaResult.rows[0];
const vehicles = vehiclesResult.rows
.map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber }))
.sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'));
return c.json({
startDate: meta.start_date,
endDate: meta.end_date,
locationCount: Number(meta.location_count),
vehicleCount: Number(meta.vehicle_count),
dayCount: Number(meta.day_count),
totalLocationCount: Number(meta.total_location_count),
excludedLocationCount: Number(meta.excluded_location_count),
outsideMainlandCount: Number(meta.outside_mainland_count),
tibetCount: Number(meta.tibet_count),
batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')),
vehicles,
});
});
router.get('/points', async (c) => {
const {
startDate,
endDate,
query,
batchModel,
metric,
} = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const records = await loadRecords(startDate, endDate, query, batchModel);
const precision = vehicleGridPrecision(startDate, endDate, query, batchModel);
const { points, max } = buildVehicleGrid(records, metric, precision);
const summary = summarizeVehicleRecords(records);
return c.json({
startDate,
endDate,
metric,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
dayCount: summary.dayCount,
points,
max,
topVehicles: buildVehicleRanking(records),
});
});
router.get('/nearby', async (c) => {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) {
return c.json({ error: '经纬度参数无效' }, 400);
}
const { startDate, endDate, query, batchModel } = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
});
const records = await loadRecords(startDate, endDate, query, batchModel);
const nearby = filterVehicleRecordsByRadius(records, center, radiusKm);
const summary = summarizeVehicleRecords(nearby);
return c.json({
center,
radiusKm,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
topVehicles: buildVehicleRanking(nearby),
});
});
export default router;