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
+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
));
}