Files
ln-bi/src/server/routes/vehicle-heatmap/routes.ts
T
dsh-agent 523f7b312d refactor(stage11): 两个热力图拆出 repository,并补契约测试
vehicle-heatmap
- 拆为 routes.ts / repository.ts(model.ts 原已存在),改为 registerVehicleHeatmapRoutes(app, deps)。
- 该域同时访问两个库,因此依赖分成 MysqlDatabase(execute,考核批次→车牌)与
  PgDatabase(query 返回 { rows },定位点),两者返回形状不同,不做统一抽象。
- 保留 $4 的原语义:是否"选择了考核批次"(而非"车牌集合是否为空");
  批次无车牌时由调用方提前返回,与原实现一致。
- 保留 loadBatchModelPlates 的降级行为(查询失败→空映射)与"命中缓存不查主库"。

hydrogen-heatmap
- 拆为 routes.ts / repository.ts,改为 registerHydrogenHeatmapRoutes(app, deps)。
- VALID_COORDINATE(含西藏排除)与 buildWhere 移入 repository 并导出:
  它被 meta 的统计口径复用,散落两处极易漂移。
- 5 条 SQL 由脚本从原文件按顺序抽取后原样落位,避免手工转写长 SQL 出错。

契约测试(新增 19 个用例)
- 逐条断言 SQL 文本(规范化空格)与参数顺序:分页/白名单/批次车牌数组/
  WHERE 片段顺序/IN 占位符拼接/半径内无站点时不发第二条查询。
- buildWhere 直接单测片段与参数顺序。
- 架构守护的"已完整分层"清单扩到 5 个域。

等价性验证(关键)
- 把两个文件改造前的实现从 git 取出,与改造后跑同一批请求,对比落库 SQL、参数、
  HTTP 状态与响应体:
    vehicle-heatmap  :8 个场景完全一致(含批次路径、"未知批次"提前返回、错误分支)
    hydrogen-heatmap :8 个场景完全一致(含筛选组合、无权限 403、错误分支)

期间的修正:曾为 /meta 的空结果新增 503 分支,属于原实现没有的行为变更,已回退为原样。

lint / test(181) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:40:13 +08:00

170 lines
5.4 KiB
TypeScript

import { Hono } from 'hono';
import mysqlPool from '../../db/mysql.js';
import heatmapPool from '../../db/heatmap.js';
import { getCache } from '../mileage/cache.js';
import {
buildVehicleGrid,
buildVehicleRanking,
filterVehicleRecordsByRadius,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './model.js';
import {
loadBatchModelPlates,
loadMeta,
loadRecords,
loadVehicleOptions,
type MysqlDatabase,
type PgDatabase,
} from './repository.js';
export interface VehicleHeatmapDependencies {
mysql: MysqlDatabase;
pg: PgDatabase;
/** 里程缓存里的考核批次车牌映射(可命中热缓存,避免每次查主库)。 */
cachedBatchPlates?: () => Map<string, Set<string>> | undefined;
}
export function registerVehicleHeatmapRoutes(app: Hono, deps: VehicleHeatmapDependencies): void {
const { mysql, pg } = deps;
/**
* 批次 → 车牌映射。优先用里程缓存;缓存缺失时查主库。
* 查询失败按"无批次数据"降级,不影响其他筛选(与既有行为一致)。
*/
async function resolveBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = deps.cachedBatchPlates?.() ?? getCache()?.targetPlatesMap;
if (cached?.size) return cached;
try {
return await loadBatchModelPlates(mysql);
} catch (error) {
console.error('[vehicle-heatmap] batch model lookup failed', error);
return new Map();
}
}
async function collectRecords(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): Promise<VehicleHeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await resolveBatchModelPlates();
batchPlates = [...(modelPlates.get(batchModel) || [])];
if (batchPlates.length === 0) return [];
}
return loadRecords(pg, {
startDate,
endDate,
query,
batchModelSelected: Boolean(batchModel),
batchPlates,
});
}
app.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 });
});
app.get('/meta', async (c) => {
const [meta, vehicleRows, batchModelPlates] = await Promise.all([
loadMeta(pg),
loadVehicleOptions(pg),
resolveBatchModelPlates(),
]);
const vehicles = vehicleRows
.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,
});
});
app.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 collectRecords(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),
});
});
app.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 collectRecords(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 function createVehicleHeatmapRouter(): Hono {
const app = new Hono();
registerVehicleHeatmapRoutes(app, { mysql: mysqlPool, pg: heatmapPool });
return app;
}
const app = createVehicleHeatmapRouter();
export default app;