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:
+10
-8
@@ -97,11 +97,13 @@ server/
|
|||||||
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** |
|
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** |
|
||||||
| `energy/` | `index.ts`(聚合)+ `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` `electric.ts` `etc.ts` + `query-model.ts` `cache.ts` `constants.ts` | 路由 / 模型已分开,**SQL 仍在路由内** |
|
| `energy/` | `index.ts`(聚合)+ `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` `electric.ts` `etc.ts` + `query-model.ts` `cache.ts` `constants.ts` | 路由 / 模型已分开,**SQL 仍在路由内** |
|
||||||
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` + `algorithm.ts` `notification-model.ts` | 同上 |
|
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` + `algorithm.ts` `notification-model.ts` | 同上 |
|
||||||
| `hydrogen-heatmap/`、`vehicle-heatmap/` | `routes.ts` + `model.ts` | 纯模型已抽出,**SQL 仍在 `routes.ts`** |
|
| `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 |
|
||||||
|
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
|
||||||
|
|
||||||
已完整分层的三个域(`vehicles` / `ele` / `feedback`)由架构测试守护:`routes.ts` 不得出现 SQL,
|
已完整分层的五个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap`)
|
||||||
且必须存在 `repository.ts`。其余域尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
由架构测试守护:`routes.ts` 不得出现 SQL,且必须存在 `repository.ts`。
|
||||||
并建议先按 `ele/routes.test.ts` 的方式补契约测试再动。
|
其余域(`energy` / `mileage` / `scheduling`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
||||||
|
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
|
||||||
|
|
||||||
|
|
||||||
### 中间件顺序(在 `app.ts` 中显式体现)
|
### 中间件顺序(在 `app.ts` 中显式体现)
|
||||||
@@ -137,10 +139,10 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
|||||||
|
|
||||||
诚实记录,避免后来者以为已经做完:
|
诚实记录,避免后来者以为已经做完:
|
||||||
|
|
||||||
- **后端仍有域没拆出 repository**:`mileage` / `energy` / `scheduling` / 两个热力图的 SQL 仍在
|
- **后端仍有 3 个域没拆出 repository**:`energy` / `mileage` / `scheduling` 的 SQL 仍在各自的
|
||||||
各自的 `routes.ts`(或平级模块)里(形状见上表)。`vehicles` / `ele` / `feedback` 已完成,
|
路由文件里(形状见上表)。已完成的有 5 个域,可作为模板:路由只做校验与组装,
|
||||||
可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,纯逻辑进 `model.ts`,
|
SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
|
||||||
并用 mock pool 的契约测试锁定 SQL 与参数。
|
注意 `vehicle-heatmap` 的 SQL 是小写、`hydrogen-heatmap` 是大小写混排,正则检查要忽略大小写。
|
||||||
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
||||||
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
||||||
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
||||||
|
|||||||
@@ -165,9 +165,15 @@ test("运行时建表必须尊重只读模式", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => {
|
test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => {
|
||||||
// 这三个域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
|
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
|
||||||
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
|
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
|
||||||
const layered = ["server/routes/vehicles", "server/routes/ele", "server/routes/feedback"];
|
const layered = [
|
||||||
|
"server/routes/vehicles",
|
||||||
|
"server/routes/ele",
|
||||||
|
"server/routes/feedback",
|
||||||
|
"server/routes/vehicle-heatmap",
|
||||||
|
"server/routes/hydrogen-heatmap",
|
||||||
|
];
|
||||||
const offenders: string[] = [];
|
const offenders: string[] = [];
|
||||||
for (const dir of layered) {
|
for (const dir of layered) {
|
||||||
if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`);
|
if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket } from 'mysql2';
|
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 type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
import { recentDayRange } from '../../../shared/date-range.js';
|
import { recentDayRange } from '../../../shared/date-range.js';
|
||||||
@@ -12,232 +12,152 @@ import {
|
|||||||
parseNearbyQuery,
|
parseNearbyQuery,
|
||||||
rankHydrogenStations,
|
rankHydrogenStations,
|
||||||
serializeHydrogenStation,
|
serializeHydrogenStation,
|
||||||
type HydrogenPayer,
|
|
||||||
type HydrogenStationRecord,
|
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
import {
|
||||||
|
buildWhere,
|
||||||
|
loadMetaProfile,
|
||||||
|
loadNearbySummary,
|
||||||
|
loadPointsSummary,
|
||||||
|
loadStationOptions,
|
||||||
|
loadStations,
|
||||||
|
type MysqlDatabase,
|
||||||
|
} from './repository.js';
|
||||||
|
|
||||||
type StationRow = RowDataPacket & HydrogenStationRecord;
|
export interface HydrogenHeatmapDependencies {
|
||||||
|
mysql: MysqlDatabase;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStations(
|
export function registerHydrogenHeatmapRoutes(app: Hono, deps: HydrogenHeatmapDependencies): void {
|
||||||
startDate: string,
|
const { mysql } = deps;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = new Hono();
|
// 加氢热力图受能源角色控制(fail-closed)。
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
router.use('*', async (c, next) => {
|
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
|
||||||
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
|
if (!canAccessEnergy(user?.roles)) {
|
||||||
if (!canAccessEnergy(user?.roles)) {
|
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||||||
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
}
|
||||||
}
|
return next();
|
||||||
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),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/points', async (c) => {
|
app.get('/config', (c) => {
|
||||||
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
|
const key = process.env.AMAP_WEB_KEY;
|
||||||
startDate: c.req.query('startDate'),
|
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
|
||||||
endDate: c.req.query('endDate'),
|
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
|
||||||
query: c.req.query('query'),
|
return c.json({ key, securityCode });
|
||||||
payer: c.req.query('payer'),
|
|
||||||
metric: c.req.query('metric'),
|
|
||||||
});
|
});
|
||||||
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) => {
|
app.get('/meta', async (c) => {
|
||||||
const { center, radiusKm } = parseNearbyQuery({
|
const [profileRows, optionRows] = await Promise.all([
|
||||||
lng: c.req.query('lng'),
|
loadMetaProfile(mysql),
|
||||||
lat: c.req.query('lat'),
|
loadStationOptions(mysql),
|
||||||
radiusKm: c.req.query('radiusKm'),
|
]);
|
||||||
|
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({
|
app.get('/points', async (c) => {
|
||||||
startDate: c.req.query('startDate'),
|
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
|
||||||
endDate: c.req.query('endDate'),
|
startDate: c.req.query('startDate'),
|
||||||
query: c.req.query('query'),
|
endDate: c.req.query('endDate'),
|
||||||
payer: c.req.query('payer'),
|
query: c.req.query('query'),
|
||||||
metric: c.req.query('metric'),
|
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);
|
app.get('/nearby', async (c) => {
|
||||||
const nearbyIds = nearby.map((row) => row.station_id);
|
const { center, radiusKm } = parseNearbyQuery({
|
||||||
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
|
lng: c.req.query('lng'),
|
||||||
if (nearbyIds.length) {
|
lat: c.req.query('lat'),
|
||||||
const placeholders = nearbyIds.map(() => '?').join(',');
|
radiusKm: c.req.query('radiusKm'),
|
||||||
const where = buildWhere(startDate, endDate, query, payer);
|
});
|
||||||
const [rows] = await pool.execute<RowDataPacket[]>(`
|
if (!center) return c.json({ error: '经纬度参数无效' }, 400);
|
||||||
SELECT
|
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
|
||||||
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
startDate: c.req.query('startDate'),
|
||||||
COUNT(*) AS refuel_count,
|
endDate: c.req.query('endDate'),
|
||||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
query: c.req.query('query'),
|
||||||
COUNT(DISTINCT b.station_id) AS station_count
|
payer: c.req.query('payer'),
|
||||||
FROM hydrogen_fuel_ledger b
|
metric: c.req.query('metric'),
|
||||||
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
|
const stations = await loadStations(mysql, startDate, endDate, query, payer);
|
||||||
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
|
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
|
||||||
`, [...where.params, ...nearbyIds]);
|
const nearbyIds = nearby.map((row) => row.station_id);
|
||||||
nearbySummary = {
|
let nearbySummary: { kg: number; refuelCount: number; vehicleCount: number; stationCount: number } = {
|
||||||
kg: Number(rows[0]?.kg) || 0,
|
kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0,
|
||||||
refuelCount: Number(rows[0]?.refuel_count) || 0,
|
|
||||||
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
|
|
||||||
stationCount: Number(rows[0]?.station_count) || 0,
|
|
||||||
};
|
};
|
||||||
}
|
if (nearbyIds.length) {
|
||||||
const ranked = rankHydrogenStations(nearby, metric);
|
const placeholders = nearbyIds.map(() => '?').join(',');
|
||||||
return c.json({
|
const rows = await loadNearbySummary(
|
||||||
center,
|
mysql,
|
||||||
radiusKm,
|
buildWhere(startDate, endDate, query, payer),
|
||||||
kg: nearbySummary.kg,
|
placeholders,
|
||||||
refuelCount: nearbySummary.refuelCount,
|
nearbyIds,
|
||||||
vehicleCount: nearbySummary.vehicleCount,
|
);
|
||||||
stationCount: nearbySummary.stationCount,
|
nearbySummary = {
|
||||||
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { VehicleHeatmapRecord } from './model.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 车辆热力图的数据访问。
|
||||||
|
*
|
||||||
|
* 该域同时使用两个库:主业务库(MySQL,考核批次 → 车牌映射)与车辆位置库
|
||||||
|
* (PostgreSQL 只读,定位点)。两者的 query 返回形状不同,因此分别声明依赖,
|
||||||
|
* 并由 routes.test.ts 的契约测试锁定 SQL 与参数。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** MySQL 连接:只声明用到的能力。 */
|
||||||
|
export interface MysqlDatabase {
|
||||||
|
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PostgreSQL 连接:pg 的返回形状是 { rows }。 */
|
||||||
|
export interface PgDatabase {
|
||||||
|
query<T = any>(sql: string, values?: any[]): Promise<{ rows: T[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 考核批次 → 车牌集合。 */
|
||||||
|
export async function loadBatchModelPlates(mysql: MysqlDatabase): Promise<Map<string, Set<string>>> {
|
||||||
|
const [rows] = await mysql.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与既有实现一致:把首行直接当作 MetaRow(缺行时由调用方行为保持不变)。 */
|
||||||
|
export async function loadMeta(pg: PgDatabase): Promise<MetaRow> {
|
||||||
|
const metaResult = await pg.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
|
||||||
|
`);
|
||||||
|
return metaResult.rows[0] as MetaRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadVehicleOptions(pg: PgDatabase): Promise<VehicleOptionRow[]> {
|
||||||
|
const vehiclesResult = await pg.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
|
||||||
|
`);
|
||||||
|
return vehiclesResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐车每日首个有效定位点。
|
||||||
|
* `$4` 沿用原实现的语义——是否选择了考核批次(而不是"车牌集合是否为空"),
|
||||||
|
* 调用方需在批次无车牌时提前返回,不要靠这里兜底。
|
||||||
|
*/
|
||||||
|
export async function loadRecords(
|
||||||
|
pg: PgDatabase,
|
||||||
|
args: { startDate: string; endDate: string; query: string; batchModelSelected: boolean; batchPlates: string[] },
|
||||||
|
): Promise<VehicleHeatmapRecord[]> {
|
||||||
|
const result = await pg.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
|
||||||
|
`, [args.startDate, args.endDate, args.query.trim(), args.batchModelSelected, args.batchPlates]);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
date: row.date,
|
||||||
|
vin: row.vin,
|
||||||
|
plate: row.plate,
|
||||||
|
time: row.time,
|
||||||
|
longitude: Number(row.longitude),
|
||||||
|
latitude: Number(row.latitude),
|
||||||
|
source: row.source,
|
||||||
|
sourceRecordId: row.source_record_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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 { registerVehicleHeatmapRoutes, type VehicleHeatmapDependencies } from "./routes.js";
|
||||||
|
import type { MysqlDatabase, PgDatabase } from "./repository.js";
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
sql: string;
|
||||||
|
params: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
function createMysql(results: unknown[] = []): { db: MysqlDatabase; calls: Call[] } {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const queue = [...results];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
db: {
|
||||||
|
async execute(sql: string, params?: any[]) {
|
||||||
|
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||||
|
return [queue.shift() ?? [], []] as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPg(results: { rows: unknown[] }[] = []): { db: PgDatabase; calls: Call[] } {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const queue = [...results];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
db: {
|
||||||
|
async query(sql: string, params?: any[]) {
|
||||||
|
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||||
|
return (queue.shift() ?? { rows: [] }) as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(deps: VehicleHeatmapDependencies): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
registerVehicleHeatmapRoutes(app, deps);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const META_ROW = {
|
||||||
|
start_date: "2026-01-01",
|
||||||
|
end_date: "2026-07-13",
|
||||||
|
location_count: "10",
|
||||||
|
vehicle_count: "3",
|
||||||
|
day_count: "5",
|
||||||
|
total_location_count: "12",
|
||||||
|
excluded_location_count: "2",
|
||||||
|
outside_mainland_count: "1",
|
||||||
|
tibet_count: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECORD_ROW = {
|
||||||
|
date: "2026-07-01",
|
||||||
|
vin: "VIN1",
|
||||||
|
plate: "粤A1",
|
||||||
|
time: "2026-07-01 08:00:00",
|
||||||
|
longitude: "113.1",
|
||||||
|
latitude: "23.2",
|
||||||
|
source: "gps",
|
||||||
|
source_record_id: "r1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_BATCH_PLATES = [[], []];
|
||||||
|
|
||||||
|
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({ mysql: createMysql().db, pg: createPg().db });
|
||||||
|
const res = await app.request("/config");
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
} finally {
|
||||||
|
if (prevKey !== undefined) process.env.AMAP_WEB_KEY = prevKey;
|
||||||
|
if (prevCode !== undefined) process.env.AMAP_SECURITY_JS_CODE = prevCode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/meta:两条定位库查询与批次映射的 SQL 原样保留", async () => {
|
||||||
|
const mysql = createMysql([[]]);
|
||||||
|
const pg = createPg([{ rows: [META_ROW] }, { rows: [{ vin: "VIN2", plate_number: "粤B2" }] }]);
|
||||||
|
// /meta issues: loadMeta, loadVehicleOptions (parallel) + batch plates
|
||||||
|
const app = makeApp({ mysql: mysql.db, pg: pg.db, cachedBatchPlates: () => new Map([["B1", new Set(["粤A1"])]]) });
|
||||||
|
|
||||||
|
const res = await app.request("/meta");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json() as Record<string, unknown>;
|
||||||
|
assert.deepEqual(body, {
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-07-13",
|
||||||
|
locationCount: 10,
|
||||||
|
vehicleCount: 3,
|
||||||
|
dayCount: 5,
|
||||||
|
totalLocationCount: 12,
|
||||||
|
excludedLocationCount: 2,
|
||||||
|
outsideMainlandCount: 1,
|
||||||
|
tibetCount: 1,
|
||||||
|
batchModels: ["B1"],
|
||||||
|
vehicles: [{ vin: "VIN2", plateNumber: "粤B2" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(pg.calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
pg.calls[0].sql,
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
assert.equal(pg.calls[1].sql.includes("select distinct on (vin) vin, plate_number"), true);
|
||||||
|
assert.equal(mysql.calls.length, 0, "命中缓存的批次映射不应查主库");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/meta:缓存缺失时查主库取批次车牌,失败按空映射降级", async () => {
|
||||||
|
const mysql = createMysql([[{ target_name: "B1", plate_number: "粤A1" }, { target_name: "B1", plate_number: "粤A2" }]]);
|
||||||
|
const pg = createPg([{ rows: [META_ROW] }, { rows: [] }]);
|
||||||
|
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||||
|
|
||||||
|
const res = await app.request("/meta");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(mysql.calls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
mysql.calls[0].sql,
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/points:日期倒置返回 400 且不查定位库", async () => {
|
||||||
|
const mysql = createMysql();
|
||||||
|
const pg = createPg();
|
||||||
|
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||||
|
|
||||||
|
const res = await app.request("/points?startDate=2026-07-10&endDate=2026-07-01");
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(pg.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/points:定位点查询的 $4/$5 语义与参数顺序保持不变", async () => {
|
||||||
|
const mysql = createMysql();
|
||||||
|
const pg = createPg([{ rows: [RECORD_ROW] }]);
|
||||||
|
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||||
|
|
||||||
|
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&query=%20VIN1%20&metric=locations");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json() as Record<string, unknown>;
|
||||||
|
assert.equal(body.locationCount, 1);
|
||||||
|
assert.equal(body.vehicleCount, 1);
|
||||||
|
assert.equal(body.metric, "locations");
|
||||||
|
assert.equal(Array.isArray(body.points), true);
|
||||||
|
assert.deepEqual(body.topVehicles, [{ vin: "VIN1", plateNumber: "粤A1", locationCount: 1, firstSeen: RECORD_ROW.time, lastSeen: RECORD_ROW.time }]);
|
||||||
|
|
||||||
|
assert.equal(pg.calls.length, 1);
|
||||||
|
assert.equal(pg.calls[0].sql.includes("from analytics.vehicle_daily_first_location where stat_date between $1::date and $2::date"), true);
|
||||||
|
assert.equal(pg.calls[0].sql.includes("and ($4::boolean = false or plate_number = any($5::text[]))"), true);
|
||||||
|
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "VIN1", false, []]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/points:选择批次但批次无车牌时直接返回空,不查定位库", async () => {
|
||||||
|
const mysql = createMysql([[]]); // batch model exists but has no plates
|
||||||
|
const pg = createPg();
|
||||||
|
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||||
|
|
||||||
|
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=unknown");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json() as Record<string, unknown>;
|
||||||
|
assert.equal(body.locationCount, 0);
|
||||||
|
assert.deepEqual(body.points, []);
|
||||||
|
assert.equal(pg.calls.length, 0, "无车牌时不应查定位库");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/points:选定批次时 $4=true 且 $5 为车牌数组", async () => {
|
||||||
|
const mysql = createMysql([[]]);
|
||||||
|
const pg = createPg([{ rows: [] }]);
|
||||||
|
const app = makeApp({
|
||||||
|
mysql: mysql.db,
|
||||||
|
pg: pg.db,
|
||||||
|
cachedBatchPlates: () => new Map([["B1", new Set(["粤A1", "粤A2"])]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=B1");
|
||||||
|
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "", true, ["粤A1", "粤A2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/nearby:非法经纬度返回 400", async () => {
|
||||||
|
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
|
||||||
|
const res = await app.request("/nearby?lng=abc&lat=23");
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/nearby:半径过滤与排名口径不变", async () => {
|
||||||
|
const pg = createPg([{ rows: [
|
||||||
|
{ ...RECORD_ROW, longitude: "113.1", latitude: "23.2" },
|
||||||
|
{ ...RECORD_ROW, vin: "VIN2", source_record_id: "r2", longitude: "120.0", latitude: "30.0" },
|
||||||
|
] }]);
|
||||||
|
const app = makeApp({ mysql: createMysql().db, pg: pg.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.locationCount, 1, "只保留半径内的点");
|
||||||
|
assert.equal(body.vehicleCount, 1);
|
||||||
|
assert.equal(body.radiusKm, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("分层:路由文件不再直接书写 SQL", () => {
|
||||||
|
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
|
||||||
|
const offenders = source.match(/\b(select|insert into|insert ignore|update |delete from|create table|alter table)\b/gi) ?? [];
|
||||||
|
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts(注意本域 SQL 为小写)");
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import pool from '../../db/mysql.js';
|
import mysqlPool from '../../db/mysql.js';
|
||||||
import heatmapPool from '../../db/heatmap.js';
|
import heatmapPool from '../../db/heatmap.js';
|
||||||
import { getCache } from '../mileage/cache.js';
|
import { getCache } from '../mileage/cache.js';
|
||||||
import {
|
import {
|
||||||
@@ -12,223 +12,158 @@ import {
|
|||||||
vehicleGridPrecision,
|
vehicleGridPrecision,
|
||||||
type VehicleHeatmapRecord,
|
type VehicleHeatmapRecord,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
import {
|
||||||
|
loadBatchModelPlates,
|
||||||
|
loadMeta,
|
||||||
|
loadRecords,
|
||||||
|
loadVehicleOptions,
|
||||||
|
type MysqlDatabase,
|
||||||
|
type PgDatabase,
|
||||||
|
} from './repository.js';
|
||||||
|
|
||||||
type MetaRow = {
|
export interface VehicleHeatmapDependencies {
|
||||||
start_date: string;
|
mysql: MysqlDatabase;
|
||||||
end_date: string;
|
pg: PgDatabase;
|
||||||
location_count: string;
|
/** 里程缓存里的考核批次车牌映射(可命中热缓存,避免每次查主库)。 */
|
||||||
vehicle_count: string;
|
cachedBatchPlates?: () => Map<string, Set<string>> | undefined;
|
||||||
day_count: string;
|
}
|
||||||
total_location_count: string;
|
|
||||||
excluded_location_count: string;
|
|
||||||
outside_mainland_count: string;
|
|
||||||
tibet_count: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VehicleOptionRow = {
|
export function registerVehicleHeatmapRoutes(app: Hono, deps: VehicleHeatmapDependencies): void {
|
||||||
vin: string;
|
const { mysql, pg } = deps;
|
||||||
plate_number: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RecordRow = {
|
/**
|
||||||
date: string;
|
* 批次 → 车牌映射。优先用里程缓存;缓存缺失时查主库。
|
||||||
vin: string;
|
* 查询失败按"无批次数据"降级,不影响其他筛选(与既有行为一致)。
|
||||||
plate: string;
|
*/
|
||||||
time: string;
|
async function resolveBatchModelPlates(): Promise<Map<string, Set<string>>> {
|
||||||
longitude: number;
|
const cached = deps.cachedBatchPlates?.() ?? getCache()?.targetPlatesMap;
|
||||||
latitude: number;
|
if (cached?.size) return cached;
|
||||||
source: string;
|
try {
|
||||||
source_record_id: string;
|
return await loadBatchModelPlates(mysql);
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error('[vehicle-heatmap] batch model lookup failed', error);
|
||||||
type BatchModelRow = {
|
return new Map();
|
||||||
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 collectRecords(
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
query: string,
|
||||||
|
batchModel: string,
|
||||||
|
): Promise<VehicleHeatmapRecord[]> {
|
||||||
|
let batchPlates: string[] = [];
|
||||||
|
if (batchModel) {
|
||||||
|
const modelPlates = await resolveBatchModelPlates();
|
||||||
|
batchPlates = [...(modelPlates.get(batchModel) || [])];
|
||||||
|
if (batchPlates.length === 0) return [];
|
||||||
|
}
|
||||||
|
return loadRecords(pg, {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
query,
|
||||||
|
batchModelSelected: Boolean(batchModel),
|
||||||
|
batchPlates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [meta, vehicleRows, batchModelPlates] = await Promise.all([
|
||||||
|
loadMeta(pg),
|
||||||
|
loadVehicleOptions(pg),
|
||||||
|
resolveBatchModelPlates(),
|
||||||
|
]);
|
||||||
|
const vehicles = vehicleRows
|
||||||
|
.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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.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 collectRecords(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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 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 collectRecords(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),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecords(
|
/** 生产用路由器:绑定真实连接池。 */
|
||||||
startDate: string,
|
export function createVehicleHeatmapRouter(): Hono {
|
||||||
endDate: string,
|
const app = new Hono();
|
||||||
query: string,
|
registerVehicleHeatmapRoutes(app, { mysql: mysqlPool, pg: heatmapPool });
|
||||||
batchModel: string,
|
return app;
|
||||||
): 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();
|
const app = createVehicleHeatmapRouter();
|
||||||
|
export default app;
|
||||||
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;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user