235 lines
7.3 KiB
TypeScript
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;
|