diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec32f68..674a7cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 仍在各路由文件内** | | `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` | 同上 | -| `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, -且必须存在 `repository.ts`。其余域尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, -并建议先按 `ele/routes.test.ts` 的方式补契约测试再动。 +已完整分层的五个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap`) +由架构测试守护:`routes.ts` 不得出现 SQL,且必须存在 `repository.ts`。 +其余域(`energy` / `mileage` / `scheduling`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, +请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。 ### 中间件顺序(在 `app.ts` 中显式体现) @@ -137,10 +139,10 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域 诚实记录,避免后来者以为已经做完: -- **后端仍有域没拆出 repository**:`mileage` / `energy` / `scheduling` / 两个热力图的 SQL 仍在 - 各自的 `routes.ts`(或平级模块)里(形状见上表)。`vehicles` / `ele` / `feedback` 已完成, - 可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,纯逻辑进 `model.ts`, - 并用 mock pool 的契约测试锁定 SQL 与参数。 +- **后端仍有 3 个域没拆出 repository**:`energy` / `mileage` / `scheduling` 的 SQL 仍在各自的 + 路由文件里(形状见上表)。已完成的有 5 个域,可作为模板:路由只做校验与组装, + SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。 + 注意 `vehicle-heatmap` 的 SQL 是小写、`hydrogen-heatmap` 是大小写混排,正则检查要忽略大小写。 - **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 diff --git a/src/architecture.test.ts b/src/architecture.test.ts index e794cc8..269a0dd 100644 --- a/src/architecture.test.ts +++ b/src/architecture.test.ts @@ -165,9 +165,15 @@ test("运行时建表必须尊重只读模式", () => { }); test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => { - // 这三个域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里 + // 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里 // (见 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[] = []; for (const dir of layered) { if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`); diff --git a/src/server/routes/hydrogen-heatmap/repository.ts b/src/server/routes/hydrogen-heatmap/repository.ts new file mode 100644 index 0000000..50c89fa --- /dev/null +++ b/src/server/routes/hydrogen-heatmap/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(sql: string, values?: any[]): Promise<[T, ...any[]]>; + execute(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 = [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 { + const where = buildWhere(startDate, endDate, query, payer); + const [rows] = await mysql.execute(` + 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 { + const [rows] = await mysql.query(` + 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 { + const [rows] = await mysql.query(` + 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 }, +): Promise { + const [rows] = await mysql.execute(` + 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 }, + placeholders: string, + nearbyIds: string[], +): Promise { + const [rows] = await mysql.execute(` + 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; +} diff --git a/src/server/routes/hydrogen-heatmap/routes.test.ts b/src/server/routes/hydrogen-heatmap/routes.test.ts new file mode 100644 index 0000000..e2586e4 --- /dev/null +++ b/src/server/routes/hydrogen-heatmap/routes.test.ts @@ -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; + 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; + 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; + 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"); +}); diff --git a/src/server/routes/hydrogen-heatmap/routes.ts b/src/server/routes/hydrogen-heatmap/routes.ts index 3d24528..48a0c85 100644 --- a/src/server/routes/hydrogen-heatmap/routes.ts +++ b/src/server/routes/hydrogen-heatmap/routes.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; -import pool from '../../db/mysql.js'; +import mysqlPool from '../../db/mysql.js'; import type { AuthUser } from '../../auth/types.js'; import { canAccessEnergy } from '../../auth/types.js'; import { recentDayRange } from '../../../shared/date-range.js'; @@ -12,232 +12,152 @@ import { parseNearbyQuery, rankHydrogenStations, serializeHydrogenStation, - type HydrogenPayer, - type HydrogenStationRecord, } from './model.js'; +import { + buildWhere, + loadMetaProfile, + loadNearbySummary, + loadPointsSummary, + loadStationOptions, + loadStations, + type MysqlDatabase, +} from './repository.js'; -type StationRow = RowDataPacket & HydrogenStationRecord; - -const VALID_COORDINATE = ` - o.longitude BETWEEN 73.5 AND 135.1 - AND o.latitude BETWEEN 18 AND 53.6 - AND COALESCE(o.province, '') NOT LIKE '%西藏%' -`; - -function buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) { - const clauses = [ - `b.del_flag = '0'`, - `DATE(b.refuel_time) BETWEEN ? AND ?`, - VALID_COORDINATE, - ]; - const params: Array = [startDate, endDate]; - - if (query) { - clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`); - params.push(`%${query}%`); - } - if (payer === 'lingniu') { - clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`); - } else if (payer === 'customer') { - clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`); - } - return { sql: clauses.join(' AND '), params }; +export interface HydrogenHeatmapDependencies { + mysql: MysqlDatabase; } -async function loadStations( - startDate: string, - endDate: string, - query: string, - payer: HydrogenPayer, -): Promise { - const where = buildWhere(startDate, endDate, query, payer); - const [rows] = await pool.execute(` - SELECT - CAST(b.station_id AS CHAR) AS station_id, - COALESCE( - NULLIF(MAX(s.station_short_name), ''), - NULLIF(MAX(s.station_name), ''), - NULLIF(MAX(b.station_name), ''), - NULLIF(MAX(o.fixed_station_name), ''), - CONCAT('未知站点 #', b.station_id) - ) AS station_name, - COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address, - MAX(o.longitude) AS longitude, - MAX(o.latitude) AS latitude, - ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg, - COUNT(*) AS refuel_count, - COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, - DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel, - DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel - FROM hydrogen_fuel_ledger b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' - INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id - WHERE ${where.sql} - GROUP BY b.station_id - `, where.params); - return rows; -} +export function registerHydrogenHeatmapRoutes(app: Hono, deps: HydrogenHeatmapDependencies): void { + const { mysql } = deps; -const router = new Hono(); - -router.use('*', async (c, next) => { - const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; - if (!canAccessEnergy(user?.roles)) { - return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403); - } - return next(); -}); - -router.get('/config', (c) => { - const key = process.env.AMAP_WEB_KEY; - const securityCode = process.env.AMAP_SECURITY_JS_CODE; - if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503); - return c.json({ key, securityCode }); -}); - -router.get('/meta', async (c) => { - const [profileRows, optionRows] = await Promise.all([ - pool.query(` - 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(` - SELECT - CAST(s.id AS CHAR) AS station_id, - COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name - FROM hydrogen_station s - INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id - INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0' - WHERE s.del_flag = '0' AND ${VALID_COORDINATE} - GROUP BY s.id, s.station_short_name, s.station_name - ORDER BY station_name - `), - ]); - const profile = profileRows[0][0] || {}; - const totalRefuelCount = Number(profile.total_refuel_count) || 0; - const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0; - const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS); - return c.json({ - startDate: profile.start_date || fallback.start, - endDate: profile.end_date || fallback.end, - totalRefuelCount, - eligibleRefuelCount, - excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount), - gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0, - totalKg: Number(profile.total_kg) || 0, - eligibleKg: Number(profile.eligible_kg) || 0, - vehicleCount: Number(profile.vehicle_count) || 0, - totalStationCount: Number(profile.total_station_count) || 0, - eligibleStationCount: Number(profile.eligible_station_count) || 0, - stations: optionRows[0].map((row) => ({ - stationId: String(row.station_id), - stationName: String(row.station_name), - })), + // 加氢热力图受能源角色控制(fail-closed)。 + app.use('*', async (c, next) => { + const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; + if (!canAccessEnergy(user?.roles)) { + return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403); + } + return next(); }); -}); -router.get('/points', async (c) => { - const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({ - startDate: c.req.query('startDate'), - endDate: c.req.query('endDate'), - query: c.req.query('query'), - payer: c.req.query('payer'), - metric: c.req.query('metric'), + app.get('/config', (c) => { + const key = process.env.AMAP_WEB_KEY; + const securityCode = process.env.AMAP_SECURITY_JS_CODE; + if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503); + return c.json({ key, securityCode }); }); - if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400); - const stations = await loadStations(startDate, endDate, query, payer); - const ranked = rankHydrogenStations(stations, metric); - const { points, max } = buildHydrogenPoints(ranked, metric); - const where = buildWhere(startDate, endDate, query, payer); - const [summaryRows] = await pool.execute(` - SELECT - ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg, - COUNT(*) AS refuel_count, - COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, - COUNT(DISTINCT b.station_id) AS station_count, - COUNT(DISTINCT DATE(b.refuel_time)) AS day_count - FROM hydrogen_fuel_ledger b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' - INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id - WHERE ${where.sql} - `, where.params); - const summary = summaryRows[0] || {}; - return c.json({ - startDate, - endDate, - metric, - payer, - kg: Number(summary.kg) || 0, - refuelCount: Number(summary.refuel_count) || 0, - vehicleCount: Number(summary.vehicle_count) || 0, - stationCount: Number(summary.station_count) || 0, - dayCount: Number(summary.day_count) || 0, - points, - max, - topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)), - }); -}); -router.get('/nearby', async (c) => { - const { center, radiusKm } = parseNearbyQuery({ - lng: c.req.query('lng'), - lat: c.req.query('lat'), - radiusKm: c.req.query('radiusKm'), + app.get('/meta', async (c) => { + const [profileRows, optionRows] = await Promise.all([ + loadMetaProfile(mysql), + loadStationOptions(mysql), + ]); + const profile = profileRows[0] || {}; + const totalRefuelCount = Number(profile.total_refuel_count) || 0; + const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0; + const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS); + return c.json({ + startDate: profile.start_date || fallback.start, + endDate: profile.end_date || fallback.end, + totalRefuelCount, + eligibleRefuelCount, + excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount), + gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0, + totalKg: Number(profile.total_kg) || 0, + eligibleKg: Number(profile.eligible_kg) || 0, + vehicleCount: Number(profile.vehicle_count) || 0, + totalStationCount: Number(profile.total_station_count) || 0, + eligibleStationCount: Number(profile.eligible_station_count) || 0, + stations: optionRows.map((row) => ({ + stationId: String(row.station_id), + stationName: String(row.station_name), + })), + }); }); - if (!center) return c.json({ error: '经纬度参数无效' }, 400); - const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({ - startDate: c.req.query('startDate'), - endDate: c.req.query('endDate'), - query: c.req.query('query'), - payer: c.req.query('payer'), - metric: c.req.query('metric'), + + app.get('/points', async (c) => { + const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({ + startDate: c.req.query('startDate'), + endDate: c.req.query('endDate'), + query: c.req.query('query'), + payer: c.req.query('payer'), + metric: c.req.query('metric'), + }); + if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400); + const stations = await loadStations(mysql, startDate, endDate, query, payer); + const ranked = rankHydrogenStations(stations, metric); + const { points, max } = buildHydrogenPoints(ranked, metric); + const summaryRows = await loadPointsSummary(mysql, buildWhere(startDate, endDate, query, payer)); + const summary = summaryRows[0] || {}; + return c.json({ + startDate, + endDate, + metric, + payer, + kg: Number(summary.kg) || 0, + refuelCount: Number(summary.refuel_count) || 0, + vehicleCount: Number(summary.vehicle_count) || 0, + stationCount: Number(summary.station_count) || 0, + dayCount: Number(summary.day_count) || 0, + points, + max, + topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)), + }); }); - const stations = await loadStations(startDate, endDate, query, payer); - const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm); - const nearbyIds = nearby.map((row) => row.station_id); - let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 }; - if (nearbyIds.length) { - const placeholders = nearbyIds.map(() => '?').join(','); - const where = buildWhere(startDate, endDate, query, payer); - const [rows] = await pool.execute(` - SELECT - ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg, - COUNT(*) AS refuel_count, - COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count, - COUNT(DISTINCT b.station_id) AS station_count - FROM hydrogen_fuel_ledger b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' - INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id - WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders}) - `, [...where.params, ...nearbyIds]); - nearbySummary = { - kg: Number(rows[0]?.kg) || 0, - refuelCount: Number(rows[0]?.refuel_count) || 0, - vehicleCount: Number(rows[0]?.vehicle_count) || 0, - stationCount: Number(rows[0]?.station_count) || 0, + + app.get('/nearby', async (c) => { + const { center, radiusKm } = parseNearbyQuery({ + lng: c.req.query('lng'), + lat: c.req.query('lat'), + radiusKm: c.req.query('radiusKm'), + }); + if (!center) return c.json({ error: '经纬度参数无效' }, 400); + const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({ + startDate: c.req.query('startDate'), + endDate: c.req.query('endDate'), + query: c.req.query('query'), + payer: c.req.query('payer'), + metric: c.req.query('metric'), + }); + const stations = await loadStations(mysql, startDate, endDate, query, payer); + const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm); + const nearbyIds = nearby.map((row) => row.station_id); + let nearbySummary: { kg: number; refuelCount: number; vehicleCount: number; stationCount: number } = { + kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0, }; - } - const ranked = rankHydrogenStations(nearby, metric); - return c.json({ - center, - radiusKm, - kg: nearbySummary.kg, - refuelCount: nearbySummary.refuelCount, - vehicleCount: nearbySummary.vehicleCount, - stationCount: nearbySummary.stationCount, - topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)), + if (nearbyIds.length) { + const placeholders = nearbyIds.map(() => '?').join(','); + const rows = await loadNearbySummary( + mysql, + buildWhere(startDate, endDate, query, payer), + placeholders, + nearbyIds, + ); + nearbySummary = { + kg: Number(rows[0]?.kg) || 0, + refuelCount: Number(rows[0]?.refuel_count) || 0, + vehicleCount: Number(rows[0]?.vehicle_count) || 0, + stationCount: Number(rows[0]?.station_count) || 0, + }; + } + const ranked = rankHydrogenStations(nearby, metric); + return c.json({ + center, + radiusKm, + kg: nearbySummary.kg, + refuelCount: nearbySummary.refuelCount, + vehicleCount: nearbySummary.vehicleCount, + stationCount: nearbySummary.stationCount, + topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)), + }); }); -}); +} -export default router; +/** 生产用路由器:绑定真实连接池。 */ +export function createHydrogenHeatmapRouter(): Hono { + const app = new Hono(); + registerHydrogenHeatmapRoutes(app, { mysql: mysqlPool }); + return app; +} + +const app = createHydrogenHeatmapRouter(); +export default app; diff --git a/src/server/routes/vehicle-heatmap/repository.ts b/src/server/routes/vehicle-heatmap/repository.ts new file mode 100644 index 0000000..6c8b7ff --- /dev/null +++ b/src/server/routes/vehicle-heatmap/repository.ts @@ -0,0 +1,142 @@ +import type { VehicleHeatmapRecord } from './model.js'; + +/** + * 车辆热力图的数据访问。 + * + * 该域同时使用两个库:主业务库(MySQL,考核批次 → 车牌映射)与车辆位置库 + * (PostgreSQL 只读,定位点)。两者的 query 返回形状不同,因此分别声明依赖, + * 并由 routes.test.ts 的契约测试锁定 SQL 与参数。 + */ + +/** MySQL 连接:只声明用到的能力。 */ +export interface MysqlDatabase { + execute(sql: string, values?: any[]): Promise<[T, ...any[]]>; +} + +/** PostgreSQL 连接:pg 的返回形状是 { rows }。 */ +export interface PgDatabase { + query(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>> { + 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>(); + for (const row of rows) { + const plates = result.get(row.target_name) || new Set(); + plates.add(row.plate_number); + result.set(row.target_name, plates); + } + return result; +} + +/** 与既有实现一致:把首行直接当作 MetaRow(缺行时由调用方行为保持不变)。 */ +export async function loadMeta(pg: PgDatabase): Promise { + const metaResult = await pg.query(` + 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 { + const vehiclesResult = await pg.query(` + 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 { + const result = await pg.query(` + 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, + })); +} diff --git a/src/server/routes/vehicle-heatmap/routes.test.ts b/src/server/routes/vehicle-heatmap/routes.test.ts new file mode 100644 index 0000000..5de717f --- /dev/null +++ b/src/server/routes/vehicle-heatmap/routes.test.ts @@ -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; + 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; + 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; + 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; + 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 为小写)"); +}); diff --git a/src/server/routes/vehicle-heatmap/routes.ts b/src/server/routes/vehicle-heatmap/routes.ts index 959d02e..29ef448 100644 --- a/src/server/routes/vehicle-heatmap/routes.ts +++ b/src/server/routes/vehicle-heatmap/routes.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import pool from '../../db/mysql.js'; +import mysqlPool from '../../db/mysql.js'; import heatmapPool from '../../db/heatmap.js'; import { getCache } from '../mileage/cache.js'; import { @@ -12,223 +12,158 @@ import { vehicleGridPrecision, type VehicleHeatmapRecord, } from './model.js'; +import { + loadBatchModelPlates, + loadMeta, + loadRecords, + loadVehicleOptions, + type MysqlDatabase, + type PgDatabase, +} from './repository.js'; -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 interface VehicleHeatmapDependencies { + mysql: MysqlDatabase; + pg: PgDatabase; + /** 里程缓存里的考核批次车牌映射(可命中热缓存,避免每次查主库)。 */ + cachedBatchPlates?: () => Map> | undefined; +} -type VehicleOptionRow = { - vin: string; - plate_number: string; -}; +export function registerVehicleHeatmapRoutes(app: Hono, deps: VehicleHeatmapDependencies): void { + const { mysql, pg } = deps; -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; -}; - -async function loadBatchModelPlates(): Promise>> { - 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>(); - for (const row of rows) { - const plates = result.get(row.target_name) || new Set(); - plates.add(row.plate_number); - result.set(row.target_name, plates); + /** + * 批次 → 车牌映射。优先用里程缓存;缓存缺失时查主库。 + * 查询失败按"无批次数据"降级,不影响其他筛选(与既有行为一致)。 + */ + async function resolveBatchModelPlates(): Promise>> { + const cached = deps.cachedBatchPlates?.() ?? getCache()?.targetPlatesMap; + if (cached?.size) return cached; + try { + return await loadBatchModelPlates(mysql); + } catch (error) { + console.error('[vehicle-heatmap] batch model lookup failed', error); + return new Map(); } - 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 { + 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, - endDate: string, - query: string, - batchModel: string, -): Promise { - let batchPlates: string[] = []; - if (batchModel) { - const modelPlates = await loadBatchModelPlates(); - batchPlates = [...(modelPlates.get(batchModel) || [])]; - if (batchPlates.length === 0) return []; - } - - const result = await heatmapPool.query(` - 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, - })); +/** 生产用路由器:绑定真实连接池。 */ +export function createVehicleHeatmapRouter(): Hono { + const app = new Hono(); + registerVehicleHeatmapRoutes(app, { mysql: mysqlPool, pg: heatmapPool }); + return app; } -const router = new Hono(); - -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(` - 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(` - 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; +const app = createVehicleHeatmapRouter(); +export default app;