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,126 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
haversineKm,
hydrogenMetricValue,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
type HydrogenStationRecord,
} 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 station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStationRecord {
return {
station_id: '1',
station_name: '测试加氢站',
address: '测试路 1 号',
longitude: '120',
latitude: '30',
kg: '10.5',
refuel_count: '3',
vehicle_count: '2',
first_refuel: '2026-07-01 08:00:00',
last_refuel: '2026-07-02 18:00:00',
...overrides,
};
}
test('加氢热力图查询默认使用近 30 天滚动窗口、去除搜索空白并限制枚举值', () => {
assert.deepEqual(parseHydrogenHeatmapQuery({}, NOW), {
startDate: FALLBACK.start,
endDate: FALLBACK.end,
query: '',
payer: 'all',
metric: 'kg',
});
assert.deepEqual(parseHydrogenHeatmapQuery({
startDate: '2026-06-01',
endDate: 'bad',
query: ' 嘉兴 ',
payer: 'customer',
metric: 'vehicles',
}, NOW), {
startDate: '2026-06-01',
endDate: FALLBACK.end,
query: '嘉兴',
payer: 'customer',
metric: 'vehicles',
});
assert.equal(parseHydrogenHeatmapQuery({ payer: 'bad' }).payer, 'all');
assert.equal(parseHydrogenHeatmapQuery({ metric: 'bad' }).metric, 'kg');
});
test('加氢附近查询继续沿用经纬度 Number 语义和 5 至 200 公里半径限制', () => {
assert.deepEqual(parseNearbyQuery({ lng: '', lat: '0', radiusKm: '0' }), {
center: { lng: 0, lat: 0 },
radiusKm: 50,
});
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '-10' }).radiusKm, 5);
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '201' }).radiusKm, 200);
assert.equal(parseNearbyQuery({ lng: '120', lat: 'bad' }).center, null);
});
test('加氢指标取值和站点序列化继续保持字段、数值转换及零值回退', () => {
const row = station();
assert.equal(hydrogenMetricValue(row, 'kg'), 10.5);
assert.equal(hydrogenMetricValue(row, 'refuels'), 3);
assert.equal(hydrogenMetricValue(row, 'vehicles'), 2);
assert.equal(hydrogenMetricValue(station({ kg: 'bad' }), 'kg'), 0);
assert.deepEqual(serializeHydrogenStation(row, 'refuels'), {
stationId: '1',
stationName: '测试加氢站',
address: '测试路 1 号',
lng: 120,
lat: 30,
kg: 10.5,
refuelCount: 3,
vehicleCount: 2,
firstRefuel: '2026-07-01 08:00:00',
lastRefuel: '2026-07-02 18:00:00',
metricValue: 3,
});
});
test('站点排名和热力点继续按所选指标降序并保留同值输入顺序', () => {
const stations = [
station({ station_id: 'A', kg: 10, longitude: 120, latitude: 30 }),
station({ station_id: 'B', kg: 20, longitude: 121, latitude: 31 }),
station({ station_id: 'C', kg: 20, longitude: 122, latitude: 32 }),
];
const ranked = rankHydrogenStations(stations, 'kg');
assert.deepEqual(ranked.map((row) => row.station_id), ['B', 'C', 'A']);
assert.deepEqual(buildHydrogenPoints(ranked, 'kg'), {
points: [
{ lng: 121, lat: 31, count: 20 },
{ lng: 122, lat: 32, count: 20 },
{ lng: 120, lat: 30, count: 10 },
],
max: 20,
});
assert.deepEqual(buildHydrogenPoints([], 'kg'), { points: [], max: 1 });
});
test('加氢附近筛选继续使用 Haversine 距离并包含边界内站点', () => {
const stations = [
station({ station_id: 'near', longitude: 120.01 }),
station({ station_id: 'far', longitude: 121 }),
];
assert.equal(haversineKm(120, 30, 120, 30), 0);
assert.ok(Math.abs(haversineKm(0, 0, 0, 1) - 111.195) < 0.01);
assert.deepEqual(
filterHydrogenStationsByRadius(stations, { lng: 120, lat: 30 }, 5)
.map((row) => row.station_id),
['near'],
);
});
-149
View File
@@ -1,149 +0,0 @@
import { recentDayRange } from '../../../shared/date-range.js';
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
export type HydrogenStationRecord = {
station_id: string;
station_name: string;
address: string;
longitude: string | number;
latitude: string | number;
kg: string | number;
refuel_count: string | number;
vehicle_count: string | number;
first_refuel: string;
last_refuel: string;
};
type HydrogenHeatmapQueryInput = {
startDate?: string;
endDate?: string;
query?: string;
payer?: string;
metric?: string;
};
type NearbyQueryInput = {
lng?: string;
lat?: string;
radiusKm?: string;
};
/** 未显式传日期时的兜底窗口:近 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));
}
function parseMetric(value: string | undefined): HydrogenHeatmapMetric {
if (value === 'refuels' || value === 'vehicles') return value;
return 'kg';
}
function parsePayer(value: string | undefined): HydrogenPayer {
if (value === 'lingniu' || value === 'customer') return value;
return 'all';
}
export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput, 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 || '').trim(),
payer: parsePayer(input.payer),
metric: parseMetric(input.metric),
};
}
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 hydrogenMetricValue(
row: HydrogenStationRecord,
metric: HydrogenHeatmapMetric,
): number {
if (metric === 'refuels') return Number(row.refuel_count) || 0;
if (metric === 'vehicles') return Number(row.vehicle_count) || 0;
return Number(row.kg) || 0;
}
export function serializeHydrogenStation(
row: HydrogenStationRecord,
metric: HydrogenHeatmapMetric,
) {
return {
stationId: row.station_id,
stationName: row.station_name,
address: row.address,
lng: Number(row.longitude),
lat: Number(row.latitude),
kg: Number(row.kg) || 0,
refuelCount: Number(row.refuel_count) || 0,
vehicleCount: Number(row.vehicle_count) || 0,
firstRefuel: row.first_refuel,
lastRefuel: row.last_refuel,
metricValue: hydrogenMetricValue(row, metric),
};
}
export function rankHydrogenStations(
stations: HydrogenStationRecord[],
metric: HydrogenHeatmapMetric,
): HydrogenStationRecord[] {
return [...stations].sort((left, right) => (
hydrogenMetricValue(right, metric) - hydrogenMetricValue(left, metric)
));
}
export function buildHydrogenPoints(
stations: HydrogenStationRecord[],
metric: HydrogenHeatmapMetric,
) {
const points = stations.map((row) => ({
lng: Number(row.longitude),
lat: Number(row.latitude),
count: hydrogenMetricValue(row, metric),
}));
return {
points,
max: Math.max(1, ...points.map((point) => point.count)),
};
}
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 filterHydrogenStationsByRadius(
stations: HydrogenStationRecord[],
center: { lng: number; lat: number },
radiusKm: number,
): HydrogenStationRecord[] {
return stations.filter((row) => haversineKm(
center.lng,
center.lat,
Number(row.longitude),
Number(row.latitude),
) <= radiusKm);
}
@@ -1,157 +0,0 @@
import type { RowDataPacket } from 'mysql2';
import type { HydrogenPayer, HydrogenStationRecord } from './model.js';
/**
* 加氢热力图的数据访问。
*
* 全部 SQL 集中在此;`buildWhere` 的片段顺序与参数顺序由 routes.test.ts 锁定。
* 注意 `VALID_COORDINATE` 被多处 SQL 复用(meta 的统计口径依赖它),不要各自复制。
*/
/** MySQL 连接:只声明用到的能力。 */
export interface MysqlDatabase {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
export type StationRow = RowDataPacket & HydrogenStationRecord;
/** 有效坐标与省份排除条件(多处 SQL 共用,必须保持一致)。 */
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 '%西藏%'
`;
/** 组装 WHERE 片段;片段顺序与参数顺序由契约测试锁定。 */
export 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 async function loadStations(
mysql: MysqlDatabase,
startDate: string,
endDate: string,
query: string,
payer: HydrogenPayer,
): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await mysql.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 async function loadMetaProfile(mysql: MysqlDatabase): Promise<RowDataPacket[]> {
const [rows] = await mysql.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'
`);
return rows;
}
/** 有有效坐标的站点选项。 */
export async function loadStationOptions(mysql: MysqlDatabase): Promise<RowDataPacket[]> {
const [rows] = await mysql.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
`);
return rows;
}
/** /points 的汇总(含站点数与天数)。 */
export async function loadPointsSummary(
mysql: MysqlDatabase,
where: { sql: string; params: Array<string | number> },
): Promise<RowDataPacket[]> {
const [rows] = await mysql.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);
return rows;
}
/** /nearby 的汇总:在 WHERE 之上再按站点 id 收窄。 */
export async function loadNearbySummary(
mysql: MysqlDatabase,
where: { sql: string; params: Array<string | number> },
placeholders: string,
nearbyIds: string[],
): Promise<RowDataPacket[]> {
const [rows] = await mysql.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]);
return rows;
}
@@ -1,191 +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 type { AuthUser } from "../../auth/types.js";
import { registerHydrogenHeatmapRoutes, type HydrogenHeatmapDependencies } from "./routes.js";
import { buildWhere, type MysqlDatabase } from "./repository.js";
interface Call {
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMysql(handler?: (sql: string) => unknown[]): { db: MysqlDatabase; calls: Call[] } {
const calls: Call[] = [];
const respond = (sql: string) => {
const rows = handler ? handler(sql) : [];
return [rows, []] as never;
};
return {
calls,
db: {
async query(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return respond(String(sql));
},
async execute(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return respond(String(sql));
},
},
};
}
const ENERGY: AuthUser = {
userId: "u", userName: "n", loginName: "l", depCode: "", depName: "",
permissionLevel: "full", roles: ["BI-LEADER-ENERGY"],
};
function makeApp(user: AuthUser | undefined, deps: HydrogenHeatmapDependencies): Hono<{ Variables: { user: AuthUser } }> {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
if (user) c.set("user", user);
await next();
});
registerHydrogenHeatmapRoutes(app as never, deps);
return app;
}
test("buildWhere:片段顺序与参数顺序锁定(搜索与承担方)", () => {
const plain = buildWhere("2026-07-01", "2026-07-02", "", "all");
assert.equal(plain.params.length, 2);
assert.equal(plain.sql.includes("b.del_flag = '0'"), true);
assert.equal(plain.sql.includes("DATE(b.refuel_time) BETWEEN ? AND ?"), true);
assert.equal(plain.sql.includes("NOT LIKE '%西藏%'"), true, "有效坐标条件必须包含西藏排除");
const filtered = buildWhere("2026-07-01", "2026-07-02", "站A", "lingniu");
assert.deepEqual(filtered.params, ["2026-07-01", "2026-07-02", "%站A%"]);
assert.equal(filtered.sql.includes("COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0"), true);
const customer = buildWhere("2026-07-01", "2026-07-02", "", "customer");
assert.equal(customer.sql.includes("(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)"), true);
assert.deepEqual(customer.params, ["2026-07-01", "2026-07-02"]);
});
test("角色守卫:无能源角色返回 403 且不查库", async () => {
const { db, calls } = createMysql();
const app = makeApp({ ...ENERGY, roles: [] }, { mysql: db });
for (const url of ["/config", "/meta", "/points?startDate=2026-07-01&endDate=2026-07-02", "/nearby?lng=1&lat=1"]) {
assert.equal((await app.request(url)).status, 403, url);
}
assert.equal(calls.length, 0);
});
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(ENERGY, { mysql: createMysql().db });
assert.equal((await app.request("/config")).status, 503);
} finally {
if (prevKey !== undefined) process.env.AMAP_WEB_KEY = prevKey;
if (prevCode !== undefined) process.env.AMAP_SECURITY_JS_CODE = prevCode;
}
});
test("/meta:数据水位、覆盖口径与站点选项映射不变", async () => {
const { db, calls } = createMysql((sql) => {
if (/AS start_date/.test(sql)) {
return [{
start_date: "2026-01-01", end_date: "2026-07-13",
total_refuel_count: "6", total_kg: "150.5", total_station_count: "2",
vehicle_count: "5", eligible_refuel_count: "5", eligible_kg: "140",
eligible_station_count: "2",
}];
}
if (/GROUP BY s\.id/.test(sql)) return [{ station_id: "11", station_name: "站A" }];
return [];
});
const app = makeApp(ENERGY, { mysql: db });
const res = await app.request("/meta");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
startDate: "2026-01-01",
endDate: "2026-07-13",
totalRefuelCount: 6,
eligibleRefuelCount: 5,
excludedRefuelCount: 1,
gpsCoverageRate: 5 / 6,
totalKg: 150.5,
eligibleKg: 140,
vehicleCount: 5,
totalStationCount: 2,
eligibleStationCount: 2,
stations: [{ stationId: "11", stationName: "站A" }],
});
assert.equal(calls.length, 2);
});
test("/meta:无数据时回落到近 30 天滚动窗口", async () => {
const { db } = createMysql(() => [{}]);
const app = makeApp(ENERGY, { mysql: db });
const body = await (await app.request("/meta")).json() as Record<string, unknown>;
const today = new Date();
const expectedEnd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
assert.equal(body.endDate, expectedEnd, "不再回落到写死的历史区间");
assert.equal(body.totalRefuelCount, 0);
assert.equal(body.gpsCoverageRate, 0);
});
test("/points:日期倒置 400;正常路径两次查询与口径不变", async () => {
const { db, calls } = createMysql((sql) => {
if (/GROUP BY b\.station_id/.test(sql)) {
return [{
station_id: "11", station_name: "站A", address: "a", longitude: "113.1", latitude: "23.2",
kg: "100.5", refuel_count: "4", vehicle_count: "3",
first_refuel: "2026-07-01 08:00:00", last_refuel: "2026-07-02 09:00:00",
}];
}
return [{ kg: "100.5", refuel_count: "4", vehicle_count: "3", station_count: "1", day_count: "2" }];
});
const app = makeApp(ENERGY, { mysql: db });
assert.equal((await app.request("/points?startDate=2026-07-10&endDate=2026-07-01")).status, 400);
assert.equal(calls.length, 0);
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&metric=kg");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.kg, 100.5);
assert.equal(body.refuelCount, 4);
assert.equal(body.vehicleCount, 3);
assert.equal(body.stationCount, 1);
assert.equal(body.dayCount, 2);
assert.equal(body.payer, "all");
assert.equal(Array.isArray(body.topStations), true);
assert.equal(calls.length, 2);
assert.equal(calls[0].sql.includes("FROM hydrogen_fuel_ledger b LEFT JOIN hydrogen_station s"), true);
assert.equal(calls[1].sql.includes("COUNT(DISTINCT DATE(b.refuel_time)) AS day_count"), true);
assert.deepEqual(calls[1].params, ["2026-07-01", "2026-07-02"]);
});
test("/nearby:半径内无站点时不发第二条汇总查询", async () => {
const { db, calls } = createMysql(() => []);
const app = makeApp(ENERGY, { mysql: 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.kg, 0);
assert.deepEqual(body.topStations, []);
assert.equal(calls.length, 1, "只应有一次站点查询");
});
test("/nearby:非法经纬度 400", async () => {
const app = makeApp(ENERGY, { mysql: createMysql().db });
assert.equal((await app.request("/nearby?lng=x&lat=23")).status, 400);
});
test("分层:路由文件不再直接书写 SQL(本域 SQL 为大小写混排)", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(SELECT|FROM|WHERE|JOIN|GROUP BY|ORDER BY)\b/g) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts");
});
@@ -1,163 +0,0 @@
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;