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 未引用文件。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db/mysql.js';
|
||||
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';
|
||||
@@ -12,232 +12,152 @@ import {
|
||||
parseNearbyQuery,
|
||||
rankHydrogenStations,
|
||||
serializeHydrogenStation,
|
||||
type HydrogenPayer,
|
||||
type HydrogenStationRecord,
|
||||
} from './model.js';
|
||||
import {
|
||||
buildWhere,
|
||||
loadMetaProfile,
|
||||
loadNearbySummary,
|
||||
loadPointsSummary,
|
||||
loadStationOptions,
|
||||
loadStations,
|
||||
type MysqlDatabase,
|
||||
} from './repository.js';
|
||||
|
||||
type StationRow = RowDataPacket & HydrogenStationRecord;
|
||||
|
||||
const VALID_COORDINATE = `
|
||||
o.longitude BETWEEN 73.5 AND 135.1
|
||||
AND o.latitude BETWEEN 18 AND 53.6
|
||||
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
|
||||
`;
|
||||
|
||||
function buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) {
|
||||
const clauses = [
|
||||
`b.del_flag = '0'`,
|
||||
`DATE(b.refuel_time) BETWEEN ? AND ?`,
|
||||
VALID_COORDINATE,
|
||||
];
|
||||
const params: Array<string | number> = [startDate, endDate];
|
||||
|
||||
if (query) {
|
||||
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
|
||||
params.push(`%${query}%`);
|
||||
}
|
||||
if (payer === 'lingniu') {
|
||||
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
|
||||
} else if (payer === 'customer') {
|
||||
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
|
||||
}
|
||||
return { sql: clauses.join(' AND '), params };
|
||||
export interface HydrogenHeatmapDependencies {
|
||||
mysql: MysqlDatabase;
|
||||
}
|
||||
|
||||
async function loadStations(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
query: string,
|
||||
payer: HydrogenPayer,
|
||||
): Promise<StationRow[]> {
|
||||
const where = buildWhere(startDate, endDate, query, payer);
|
||||
const [rows] = await pool.execute<StationRow[]>(`
|
||||
SELECT
|
||||
CAST(b.station_id AS CHAR) AS station_id,
|
||||
COALESCE(
|
||||
NULLIF(MAX(s.station_short_name), ''),
|
||||
NULLIF(MAX(s.station_name), ''),
|
||||
NULLIF(MAX(b.station_name), ''),
|
||||
NULLIF(MAX(o.fixed_station_name), ''),
|
||||
CONCAT('未知站点 #', b.station_id)
|
||||
) AS station_name,
|
||||
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
|
||||
MAX(o.longitude) AS longitude,
|
||||
MAX(o.latitude) AS latitude,
|
||||
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
||||
COUNT(*) AS refuel_count,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
||||
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
|
||||
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
|
||||
FROM hydrogen_fuel_ledger b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
||||
WHERE ${where.sql}
|
||||
GROUP BY b.station_id
|
||||
`, where.params);
|
||||
return rows;
|
||||
}
|
||||
export function registerHydrogenHeatmapRoutes(app: Hono, deps: HydrogenHeatmapDependencies): void {
|
||||
const { mysql } = deps;
|
||||
|
||||
const router = new Hono();
|
||||
|
||||
router.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();
|
||||
});
|
||||
|
||||
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 [profileRows, optionRows] = await Promise.all([
|
||||
pool.query<RowDataPacket[]>(`
|
||||
SELECT
|
||||
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
|
||||
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
|
||||
COUNT(*) AS total_refuel_count,
|
||||
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
|
||||
COUNT(DISTINCT b.station_id) AS total_station_count,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
||||
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
|
||||
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
|
||||
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
|
||||
FROM hydrogen_fuel_ledger b
|
||||
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
||||
WHERE b.del_flag = '0'
|
||||
`),
|
||||
pool.query<RowDataPacket[]>(`
|
||||
SELECT
|
||||
CAST(s.id AS CHAR) AS station_id,
|
||||
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
|
||||
FROM hydrogen_station s
|
||||
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
|
||||
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
|
||||
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
|
||||
GROUP BY s.id, s.station_short_name, s.station_name
|
||||
ORDER BY station_name
|
||||
`),
|
||||
]);
|
||||
const profile = profileRows[0][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[0].map((row) => ({
|
||||
stationId: String(row.station_id),
|
||||
stationName: String(row.station_name),
|
||||
})),
|
||||
// 加氢热力图受能源角色控制(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();
|
||||
});
|
||||
});
|
||||
|
||||
router.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'),
|
||||
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 });
|
||||
});
|
||||
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
|
||||
const stations = await loadStations(startDate, endDate, query, payer);
|
||||
const ranked = rankHydrogenStations(stations, metric);
|
||||
const { points, max } = buildHydrogenPoints(ranked, metric);
|
||||
const where = buildWhere(startDate, endDate, query, payer);
|
||||
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
|
||||
SELECT
|
||||
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
||||
COUNT(*) AS refuel_count,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
||||
COUNT(DISTINCT b.station_id) AS station_count,
|
||||
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
|
||||
FROM hydrogen_fuel_ledger b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
||||
WHERE ${where.sql}
|
||||
`, where.params);
|
||||
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)),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/nearby', async (c) => {
|
||||
const { center, radiusKm } = parseNearbyQuery({
|
||||
lng: c.req.query('lng'),
|
||||
lat: c.req.query('lat'),
|
||||
radiusKm: c.req.query('radiusKm'),
|
||||
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),
|
||||
})),
|
||||
});
|
||||
});
|
||||
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'),
|
||||
|
||||
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)),
|
||||
});
|
||||
});
|
||||
const stations = await loadStations(startDate, endDate, query, payer);
|
||||
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
|
||||
const nearbyIds = nearby.map((row) => row.station_id);
|
||||
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
|
||||
if (nearbyIds.length) {
|
||||
const placeholders = nearbyIds.map(() => '?').join(',');
|
||||
const where = buildWhere(startDate, endDate, query, payer);
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(`
|
||||
SELECT
|
||||
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
||||
COUNT(*) AS refuel_count,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
||||
COUNT(DISTINCT b.station_id) AS station_count
|
||||
FROM hydrogen_fuel_ledger b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
||||
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
|
||||
`, [...where.params, ...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,
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
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)),
|
||||
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 default router;
|
||||
/** 生产用路由器:绑定真实连接池。 */
|
||||
export function createHydrogenHeatmapRouter(): Hono {
|
||||
const app = new Hono();
|
||||
registerHydrogenHeatmapRoutes(app, { mysql: mysqlPool });
|
||||
return app;
|
||||
}
|
||||
|
||||
const app = createHydrogenHeatmapRouter();
|
||||
export default app;
|
||||
|
||||
Reference in New Issue
Block a user