refactor(stage9): 后端业务域目录形状统一

- 每个域现在都是 src/server/routes/<domain>/ 目录:
  vehicles/routes.ts(原 routes/vehicles.ts)、vehicle-heatmap/{routes,model}.ts、
  hydrogen-heatmap/{routes,model}.ts、ele/routes.ts、feedback/routes.ts。
- 命名约定:index.ts 是聚合器(挂子路由),routes.ts 是叶子路由。mileage/energy/
  scheduling 保持 index.ts 聚合,其余统一为 routes.ts。
- 纯移动与改名,未改动任何 SQL、参数顺序、路由挂载前缀或处理器逻辑。
  import 改写由一次性 codemod 完成;vehicles/repository.test.ts 读取的源码路径同步更新。
- docs/ARCHITECTURE.md 补充"后端业务域形状"表,并如实标注 ele / feedback 尚未拆出
  repository(这两个域缺 SQL 指纹测试,建议先补测试再拆)。

lint / test(139) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:30:54 +08:00
parent 954d626afc
commit 795fb207cb
14 changed files with 48 additions and 33 deletions
@@ -0,0 +1,152 @@
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
@@ -0,0 +1,171 @@
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
));
}
+234
View File
@@ -0,0 +1,234 @@
import { Hono } from 'hono';
import pool 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';
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;