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>> { 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>(); for (const row of rows) { const plates = result.get(row.target_name) || new Set(); 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 { let batchPlates: string[] = []; if (batchModel) { const modelPlates = await loadBatchModelPlates(); batchPlates = [...(modelPlates.get(batchModel) || [])]; if (batchPlates.length === 0) return []; } const result = await heatmapPool.query(` 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(` 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(` 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;