Files
ln-bi/src/server/routes/hydrogen-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

164 lines
5.9 KiB
TypeScript

import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import mysqlPool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { recentDayRange } from '../../../shared/date-range.js';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
HEATMAP_DEFAULT_WINDOW_DAYS,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
} from './model.js';
import {
buildWhere,
loadMetaProfile,
loadNearbySummary,
loadPointsSummary,
loadStationOptions,
loadStations,
type MysqlDatabase,
} from './repository.js';
export interface HydrogenHeatmapDependencies {
mysql: MysqlDatabase;
}
export function registerHydrogenHeatmapRoutes(app: Hono, deps: HydrogenHeatmapDependencies): void {
const { mysql } = deps;
// 加氢热力图受能源角色控制(fail-closed)。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
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 [profileRows, optionRows] = await Promise.all([
loadMetaProfile(mysql),
loadStationOptions(mysql),
]);
const profile = profileRows[0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS);
return c.json({
startDate: profile.start_date || fallback.start,
endDate: profile.end_date || fallback.end,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
totalKg: Number(profile.total_kg) || 0,
eligibleKg: Number(profile.eligible_kg) || 0,
vehicleCount: Number(profile.vehicle_count) || 0,
totalStationCount: Number(profile.total_station_count) || 0,
eligibleStationCount: Number(profile.eligible_station_count) || 0,
stations: optionRows.map((row) => ({
stationId: String(row.station_id),
stationName: String(row.station_name),
})),
});
});
app.get('/points', async (c) => {
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const stations = await loadStations(mysql, startDate, endDate, query, payer);
const ranked = rankHydrogenStations(stations, metric);
const { points, max } = buildHydrogenPoints(ranked, metric);
const summaryRows = await loadPointsSummary(mysql, buildWhere(startDate, endDate, query, payer));
const summary = summaryRows[0] || {};
return c.json({
startDate,
endDate,
metric,
payer,
kg: Number(summary.kg) || 0,
refuelCount: Number(summary.refuel_count) || 0,
vehicleCount: Number(summary.vehicle_count) || 0,
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
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, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
const stations = await loadStations(mysql, startDate, endDate, query, payer);
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary: { kg: number; refuelCount: number; vehicleCount: number; stationCount: number } = {
kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0,
};
if (nearbyIds.length) {
const placeholders = nearbyIds.map(() => '?').join(',');
const rows = await loadNearbySummary(
mysql,
buildWhere(startDate, endDate, query, payer),
placeholders,
nearbyIds,
);
nearbySummary = {
kg: Number(rows[0]?.kg) || 0,
refuelCount: Number(rows[0]?.refuel_count) || 0,
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
stationCount: Number(rows[0]?.station_count) || 0,
};
}
const ranked = rankHydrogenStations(nearby, metric);
return c.json({
center,
radiusKm,
kg: nearbySummary.kg,
refuelCount: nearbySummary.refuelCount,
vehicleCount: nearbySummary.vehicleCount,
stationCount: nearbySummary.stationCount,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
}
/** 生产用路由器:绑定真实连接池。 */
export function createHydrogenHeatmapRouter(): Hono {
const app = new Hono();
registerHydrogenHeatmapRoutes(app, { mysql: mysqlPool });
return app;
}
const app = createHydrogenHeatmapRouter();
export default app;