revert: restore v1.1.14 (37a9f303)
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
shishengliang
2026-09-16 09:29:30 +08:00
parent c49a02e3d9
commit 6ea1b3d5dd
210 changed files with 4448 additions and 36179 deletions
@@ -1,152 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildVehicleGrid,
buildVehicleRanking,
daysInclusive,
filterVehicleRecordsByRadius,
haversineKm,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './model.js';
import { recentDayRange } from '../../../shared/date-range.js';
// 默认区间是"近 30 天"滚动窗口,注入固定时钟让断言可复现。
const NOW = new Date('2026-07-13T12:00:00');
const FALLBACK = recentDayRange(30, NOW);
function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRecord {
return {
date: '2026-07-01',
vin: 'VIN-A',
plate: '粤A00001',
time: '2026-07-01 08:00:00',
longitude: 120,
latitude: 30,
source: 'test',
sourceRecordId: '1',
...overrides,
};
}
test('车辆热力图查询默认使用近 30 天滚动窗口,并保留原有字符串处理规则', () => {
assert.deepEqual(parseVehicleHeatmapQuery({}, NOW), {
startDate: FALLBACK.start,
endDate: FALLBACK.end,
query: '',
batchModel: '',
metric: 'locations',
});
assert.deepEqual(parseVehicleHeatmapQuery({
startDate: '2026-06-01',
endDate: 'invalid',
query: ' 粤A ',
batchModel: ' 190台冷链 ',
metric: 'vehicles',
}, NOW), {
startDate: '2026-06-01',
endDate: FALLBACK.end,
query: ' 粤A ',
batchModel: '190台冷链',
metric: 'vehicles',
});
});
test('附近查询继续沿用经纬度 Number 语义和 5 至 200 公里半径限制', () => {
assert.deepEqual(parseNearbyQuery({ lng: '', lat: '0', radiusKm: '0' }), {
center: { lng: 0, lat: 0 },
radiusKm: 50,
});
assert.deepEqual(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '-1' }), {
center: { lng: 120, lat: 30 },
radiusKm: 5,
});
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '500' }).radiusKm, 200);
assert.equal(parseNearbyQuery({ lng: 'bad', lat: '30' }).center, null);
});
test('网格精度继续按含首尾天数和筛选条件决定', () => {
assert.equal(daysInclusive('2026-07-01', '2026-07-14'), 14);
assert.equal(daysInclusive('2026-07-14', '2026-07-01'), 0);
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-14', '', ''), 3);
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-15', '', ''), 2);
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-15', ' ', ''), 3);
});
test('车辆网格继续区分位置次数和去重车辆数并保留插入顺序', () => {
const records = [
record({ vin: 'VIN-A', longitude: 120.1231, latitude: 30.9878 }),
record({ vin: 'VIN-A', longitude: 120.1232, latitude: 30.9879 }),
record({ vin: 'VIN-B', longitude: 120.1233, latitude: 30.9877 }),
record({ vin: 'VIN-C', longitude: 121, latitude: 31 }),
];
assert.deepEqual(buildVehicleGrid(records, 'locations', 3), {
points: [
{ lng: 120.123, lat: 30.988, count: 3 },
{ lng: 121, lat: 31, count: 1 },
],
max: 3,
});
assert.deepEqual(buildVehicleGrid(records, 'vehicles', 3), {
points: [
{ lng: 120.123, lat: 30.988, count: 2 },
{ lng: 121, lat: 31, count: 1 },
],
max: 2,
});
assert.deepEqual(buildVehicleGrid([], 'locations', 2), { points: [], max: 1 });
});
test('车辆排名继续按记录数、车牌排序并保留首末出现时间', () => {
const records = [
record({ vin: 'VIN-B', plate: '', time: '2026-07-02 10:00:00' }),
record({ vin: 'VIN-A', plate: '粤A00001', time: '2026-07-02 09:00:00' }),
record({ vin: 'VIN-B', plate: '粤B00001', time: '2026-07-01 08:00:00' }),
record({ vin: 'VIN-A', plate: '粤A00001', time: '2026-07-03 11:00:00' }),
];
assert.deepEqual(buildVehicleRanking(records), [
{
vin: 'VIN-A',
plateNumber: '粤A00001',
locationCount: 2,
firstSeen: '2026-07-02 09:00:00',
lastSeen: '2026-07-03 11:00:00',
},
{
vin: 'VIN-B',
plateNumber: '粤B00001',
locationCount: 2,
firstSeen: '2026-07-01 08:00:00',
lastSeen: '2026-07-02 10:00:00',
},
]);
assert.equal(buildVehicleRanking(records, 1).length, 1);
});
test('车辆统计和附近筛选继续使用 VIN、自然日去重及 Haversine 距离', () => {
const records = [
record({ vin: 'VIN-A', date: '2026-07-01' }),
record({ vin: 'VIN-A', date: '2026-07-02', longitude: 120.01 }),
record({ vin: 'VIN-B', date: '2026-07-02', longitude: 121 }),
];
assert.deepEqual(summarizeVehicleRecords(records), {
locationCount: 3,
vehicleCount: 2,
dayCount: 2,
});
assert.equal(haversineKm(120, 30, 120, 30), 0);
assert.ok(Math.abs(haversineKm(0, 0, 0, 1) - 111.195) < 0.01);
assert.deepEqual(
filterVehicleRecordsByRadius(records, { lng: 120, lat: 30 }, 5).map(({ vin, date }) => ({ vin, date })),
[
{ vin: 'VIN-A', date: '2026-07-01' },
{ vin: 'VIN-A', date: '2026-07-02' },
],
);
});
-171
View File
@@ -1,171 +0,0 @@
import { recentDayRange } from '../../../shared/date-range.js';
export type VehicleHeatmapMetric = 'locations' | 'vehicles';
export type VehicleHeatmapRecord = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
sourceRecordId: string;
};
export type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
type VehicleHeatmapQueryInput = {
startDate?: string;
endDate?: string;
query?: string;
batchModel?: string;
metric?: string;
};
type NearbyQueryInput = {
lng?: string;
lat?: string;
radiusKm?: string;
};
/**
* 未显式传日期时的兜底窗口。
* 此前写死为 2026-01-01 ~ 2026-07-13,会让默认查询永久指向一段历史数据;
* 现改为"近 N 天"滚动窗口。
*/
export const HEATMAP_DEFAULT_WINDOW_DAYS = 30;
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput, now: Date = new Date()) {
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now);
return {
startDate: isDate(input.startDate) ? input.startDate : fallback.start,
endDate: isDate(input.endDate) ? input.endDate : fallback.end,
query: input.query || '',
batchModel: (input.batchModel || '').trim(),
metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const,
};
}
export function parseNearbyQuery(input: NearbyQueryInput) {
const longitude = Number(input.lng);
const latitude = Number(input.lat);
return {
center: Number.isFinite(longitude) && Number.isFinite(latitude)
? { lng: longitude, lat: latitude }
: null,
radiusKm: Math.min(200, Math.max(5, Number(input.radiusKm) || 50)),
};
}
export function daysInclusive(startDate: string, endDate: string): number {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
}
export function vehicleGridPrecision(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): number {
return daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
}
export function buildVehicleGrid(
records: VehicleHeatmapRecord[],
metric: VehicleHeatmapMetric,
precision: number,
) {
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
for (const record of records) {
const lng = Number(record.longitude.toFixed(precision));
const lat = Number(record.latitude.toFixed(precision));
const key = `${lng},${lat}`;
let point = grid.get(key);
if (!point) {
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
grid.set(key, point);
}
if (point.vins) {
point.vins.add(record.vin);
point.count = point.vins.size;
} else {
point.count += 1;
}
}
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
let max = 1;
for (const point of points) max = Math.max(max, point.count);
return { points, max };
}
export function buildVehicleRanking(records: VehicleHeatmapRecord[], limit = 10): VehicleRank[] {
const vehicles = new Map<string, VehicleRank>();
for (const record of records) {
const current = vehicles.get(record.vin);
if (!current) {
vehicles.set(record.vin, {
vin: record.vin,
plateNumber: record.plate,
locationCount: 1,
firstSeen: record.time,
lastSeen: record.time,
});
continue;
}
current.locationCount += 1;
if (record.time < current.firstSeen) current.firstSeen = record.time;
if (record.time > current.lastSeen) current.lastSeen = record.time;
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
}
return [...vehicles.values()]
.sort((left, right) => right.locationCount - left.locationCount
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
.slice(0, limit);
}
export function summarizeVehicleRecords(records: VehicleHeatmapRecord[]) {
return {
locationCount: records.length,
vehicleCount: new Set(records.map((record) => record.vin)).size,
dayCount: new Set(records.map((record) => record.date)).size,
};
}
export function haversineKm(
leftLng: number,
leftLat: number,
rightLng: number,
rightLat: number,
): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function filterVehicleRecordsByRadius(
records: VehicleHeatmapRecord[],
center: { lng: number; lat: number },
radiusKm: number,
): VehicleHeatmapRecord[] {
return records.filter((record) => (
haversineKm(center.lng, center.lat, record.longitude, record.latitude) <= radiusKm
));
}
@@ -1,142 +0,0 @@
import type { VehicleHeatmapRecord } from './model.js';
/**
* 车辆热力图的数据访问。
*
* 该域同时使用两个库:主业务库(MySQL,考核批次 → 车牌映射)与车辆位置库
* (PostgreSQL 只读,定位点)。两者的 query 返回形状不同,因此分别声明依赖,
* 并由 routes.test.ts 的契约测试锁定 SQL 与参数。
*/
/** MySQL 连接:只声明用到的能力。 */
export interface MysqlDatabase {
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
/** PostgreSQL 连接:pg 的返回形状是 { rows }。 */
export interface PgDatabase {
query<T = any>(sql: string, values?: any[]): Promise<{ rows: T[] }>;
}
export 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;
};
export 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;
};
/** 考核批次 → 车牌集合。 */
export async function loadBatchModelPlates(mysql: MysqlDatabase): Promise<Map<string, Set<string>>> {
const [rows] = await mysql.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;
}
/** 与既有实现一致:把首行直接当作 MetaRow(缺行时由调用方行为保持不变)。 */
export async function loadMeta(pg: PgDatabase): Promise<MetaRow> {
const metaResult = await pg.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
`);
return metaResult.rows[0] as MetaRow;
}
export async function loadVehicleOptions(pg: PgDatabase): Promise<VehicleOptionRow[]> {
const vehiclesResult = await pg.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
`);
return vehiclesResult.rows;
}
/**
* 逐车每日首个有效定位点。
* `$4` 沿用原实现的语义——是否选择了考核批次(而不是"车牌集合是否为空"),
* 调用方需在批次无车牌时提前返回,不要靠这里兜底。
*/
export async function loadRecords(
pg: PgDatabase,
args: { startDate: string; endDate: string; query: string; batchModelSelected: boolean; batchPlates: string[] },
): Promise<VehicleHeatmapRecord[]> {
const result = await pg.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
`, [args.startDate, args.endDate, args.query.trim(), args.batchModelSelected, args.batchPlates]);
return result.rows.map((row) => ({
date: row.date,
vin: row.vin,
plate: row.plate,
time: row.time,
longitude: Number(row.longitude),
latitude: Number(row.latitude),
source: row.source,
sourceRecordId: row.source_record_id,
}));
}
@@ -1,218 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import { registerVehicleHeatmapRoutes, type VehicleHeatmapDependencies } from "./routes.js";
import type { MysqlDatabase, PgDatabase } from "./repository.js";
interface Call {
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMysql(results: unknown[] = []): { db: MysqlDatabase; calls: Call[] } {
const calls: Call[] = [];
const queue = [...results];
return {
calls,
db: {
async execute(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return [queue.shift() ?? [], []] as never;
},
},
};
}
function createPg(results: { rows: unknown[] }[] = []): { db: PgDatabase; calls: Call[] } {
const calls: Call[] = [];
const queue = [...results];
return {
calls,
db: {
async query(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return (queue.shift() ?? { rows: [] }) as never;
},
},
};
}
function makeApp(deps: VehicleHeatmapDependencies): Hono {
const app = new Hono();
registerVehicleHeatmapRoutes(app, deps);
return app;
}
const META_ROW = {
start_date: "2026-01-01",
end_date: "2026-07-13",
location_count: "10",
vehicle_count: "3",
day_count: "5",
total_location_count: "12",
excluded_location_count: "2",
outside_mainland_count: "1",
tibet_count: "1",
};
const RECORD_ROW = {
date: "2026-07-01",
vin: "VIN1",
plate: "粤A1",
time: "2026-07-01 08:00:00",
longitude: "113.1",
latitude: "23.2",
source: "gps",
source_record_id: "r1",
};
const EMPTY_BATCH_PLATES = [[], []];
test("/config:缺少高德配置时返回 503", async () => {
const prevKey = process.env.AMAP_WEB_KEY;
const prevCode = process.env.AMAP_SECURITY_JS_CODE;
delete process.env.AMAP_WEB_KEY;
delete process.env.AMAP_SECURITY_JS_CODE;
try {
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
const res = await app.request("/config");
assert.equal(res.status, 503);
} finally {
if (prevKey !== undefined) process.env.AMAP_WEB_KEY = prevKey;
if (prevCode !== undefined) process.env.AMAP_SECURITY_JS_CODE = prevCode;
}
});
test("/meta:两条定位库查询与批次映射的 SQL 原样保留", async () => {
const mysql = createMysql([[]]);
const pg = createPg([{ rows: [META_ROW] }, { rows: [{ vin: "VIN2", plate_number: "粤B2" }] }]);
// /meta issues: loadMeta, loadVehicleOptions (parallel) + batch plates
const app = makeApp({ mysql: mysql.db, pg: pg.db, cachedBatchPlates: () => new Map([["B1", new Set(["粤A1"])]]) });
const res = await app.request("/meta");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.deepEqual(body, {
startDate: "2026-01-01",
endDate: "2026-07-13",
locationCount: 10,
vehicleCount: 3,
dayCount: 5,
totalLocationCount: 12,
excludedLocationCount: 2,
outsideMainlandCount: 1,
tibetCount: 1,
batchModels: ["B1"],
vehicles: [{ vin: "VIN2", plateNumber: "粤B2" }],
});
assert.equal(pg.calls.length, 2);
assert.equal(
pg.calls[0].sql,
"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",
);
assert.equal(pg.calls[1].sql.includes("select distinct on (vin) vin, plate_number"), true);
assert.equal(mysql.calls.length, 0, "命中缓存的批次映射不应查主库");
});
test("/meta:缓存缺失时查主库取批次车牌,失败按空映射降级", async () => {
const mysql = createMysql([[{ target_name: "B1", plate_number: "粤A1" }, { target_name: "B1", plate_number: "粤A2" }]]);
const pg = createPg([{ rows: [META_ROW] }, { rows: [] }]);
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/meta");
assert.equal(res.status, 200);
assert.equal(mysql.calls.length, 1);
assert.equal(
mysql.calls[0].sql,
"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",
);
});
test("/points:日期倒置返回 400 且不查定位库", async () => {
const mysql = createMysql();
const pg = createPg();
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/points?startDate=2026-07-10&endDate=2026-07-01");
assert.equal(res.status, 400);
assert.equal(pg.calls.length, 0);
});
test("/points:定位点查询的 $4/$5 语义与参数顺序保持不变", async () => {
const mysql = createMysql();
const pg = createPg([{ rows: [RECORD_ROW] }]);
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&query=%20VIN1%20&metric=locations");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.locationCount, 1);
assert.equal(body.vehicleCount, 1);
assert.equal(body.metric, "locations");
assert.equal(Array.isArray(body.points), true);
assert.deepEqual(body.topVehicles, [{ vin: "VIN1", plateNumber: "粤A1", locationCount: 1, firstSeen: RECORD_ROW.time, lastSeen: RECORD_ROW.time }]);
assert.equal(pg.calls.length, 1);
assert.equal(pg.calls[0].sql.includes("from analytics.vehicle_daily_first_location where stat_date between $1::date and $2::date"), true);
assert.equal(pg.calls[0].sql.includes("and ($4::boolean = false or plate_number = any($5::text[]))"), true);
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "VIN1", false, []]);
});
test("/points:选择批次但批次无车牌时直接返回空,不查定位库", async () => {
const mysql = createMysql([[]]); // batch model exists but has no plates
const pg = createPg();
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=unknown");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.locationCount, 0);
assert.deepEqual(body.points, []);
assert.equal(pg.calls.length, 0, "无车牌时不应查定位库");
});
test("/points:选定批次时 $4=true 且 $5 为车牌数组", async () => {
const mysql = createMysql([[]]);
const pg = createPg([{ rows: [] }]);
const app = makeApp({
mysql: mysql.db,
pg: pg.db,
cachedBatchPlates: () => new Map([["B1", new Set(["粤A1", "粤A2"])]]),
});
await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=B1");
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "", true, ["粤A1", "粤A2"]]);
});
test("/nearby:非法经纬度返回 400", async () => {
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
const res = await app.request("/nearby?lng=abc&lat=23");
assert.equal(res.status, 400);
});
test("/nearby:半径过滤与排名口径不变", async () => {
const pg = createPg([{ rows: [
{ ...RECORD_ROW, longitude: "113.1", latitude: "23.2" },
{ ...RECORD_ROW, vin: "VIN2", source_record_id: "r2", longitude: "120.0", latitude: "30.0" },
] }]);
const app = makeApp({ mysql: createMysql().db, pg: pg.db });
const res = await app.request("/nearby?lng=113.1&lat=23.2&radiusKm=5&startDate=2026-07-01&endDate=2026-07-02");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.locationCount, 1, "只保留半径内的点");
assert.equal(body.vehicleCount, 1);
assert.equal(body.radiusKm, 5);
});
test("分层:路由文件不再直接书写 SQL", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(select|insert into|insert ignore|update |delete from|create table|alter table)\b/gi) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts(注意本域 SQL 为小写)");
});
-169
View File
@@ -1,169 +0,0 @@
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;