refactor(stage9): 后端业务域目录形状统一

- 每个域现在都是 src/server/routes/<domain>/ 目录:
  vehicles/routes.ts(原 routes/vehicles.ts)、vehicle-heatmap/{routes,model}.ts、
  hydrogen-heatmap/{routes,model}.ts、ele/routes.ts、feedback/routes.ts。
- 命名约定:index.ts 是聚合器(挂子路由),routes.ts 是叶子路由。mileage/energy/
  scheduling 保持 index.ts 聚合,其余统一为 routes.ts。
- 纯移动与改名,未改动任何 SQL、参数顺序、路由挂载前缀或处理器逻辑。
  import 改写由一次性 codemod 完成;vehicles/repository.test.ts 读取的源码路径同步更新。
- docs/ARCHITECTURE.md 补充"后端业务域形状"表,并如实标注 ele / feedback 尚未拆出
  repository(这两个域缺 SQL 指纹测试,建议先补测试再拆)。

lint / test(139) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:30:54 +08:00
parent 954d626afc
commit 795fb207cb
14 changed files with 48 additions and 33 deletions
@@ -0,0 +1,243 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { recentDayRange } from '../../../shared/date-range.js';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
HEATMAP_DEFAULT_WINDOW_DAYS,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
type HydrogenPayer,
type HydrogenStationRecord,
} from './model.js';
type StationRow = RowDataPacket & HydrogenStationRecord;
const VALID_COORDINATE = `
o.longitude BETWEEN 73.5 AND 135.1
AND o.latitude BETWEEN 18 AND 53.6
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
`;
function buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) {
const clauses = [
`b.del_flag = '0'`,
`DATE(b.refuel_time) BETWEEN ? AND ?`,
VALID_COORDINATE,
];
const params: Array<string | number> = [startDate, endDate];
if (query) {
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
params.push(`%${query}%`);
}
if (payer === 'lingniu') {
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
} else if (payer === 'customer') {
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
}
return { sql: clauses.join(' AND '), params };
}
async function loadStations(
startDate: string,
endDate: string,
query: string,
payer: HydrogenPayer,
): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<StationRow[]>(`
SELECT
CAST(b.station_id AS CHAR) AS station_id,
COALESCE(
NULLIF(MAX(s.station_short_name), ''),
NULLIF(MAX(s.station_name), ''),
NULLIF(MAX(b.station_name), ''),
NULLIF(MAX(o.fixed_station_name), ''),
CONCAT('未知站点 #', b.station_id)
) AS station_name,
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
MAX(o.longitude) AS longitude,
MAX(o.latitude) AS latitude,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
GROUP BY b.station_id
`, where.params);
return rows;
}
const router = new Hono();
router.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [profileRows, optionRows] = await Promise.all([
pool.query<RowDataPacket[]>(`
SELECT
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
COUNT(*) AS total_refuel_count,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
COUNT(DISTINCT b.station_id) AS total_station_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE b.del_flag = '0'
`),
pool.query<RowDataPacket[]>(`
SELECT
CAST(s.id AS CHAR) AS station_id,
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
FROM hydrogen_station s
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
GROUP BY s.id, s.station_short_name, s.station_name
ORDER BY station_name
`),
]);
const profile = profileRows[0][0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS);
return c.json({
startDate: profile.start_date || fallback.start,
endDate: profile.end_date || fallback.end,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
totalKg: Number(profile.total_kg) || 0,
eligibleKg: Number(profile.eligible_kg) || 0,
vehicleCount: Number(profile.vehicle_count) || 0,
totalStationCount: Number(profile.total_station_count) || 0,
eligibleStationCount: Number(profile.eligible_station_count) || 0,
stations: optionRows[0].map((row) => ({
stationId: String(row.station_id),
stationName: String(row.station_name),
})),
});
});
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'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const stations = await loadStations(startDate, endDate, query, payer);
const ranked = rankHydrogenStations(stations, metric);
const { points, max } = buildHydrogenPoints(ranked, metric);
const where = buildWhere(startDate, endDate, query, payer);
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count,
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
`, where.params);
const summary = summaryRows[0] || {};
return c.json({
startDate,
endDate,
metric,
payer,
kg: Number(summary.kg) || 0,
refuelCount: Number(summary.refuel_count) || 0,
vehicleCount: Number(summary.vehicle_count) || 0,
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
router.get('/nearby', async (c) => {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
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(startDate, endDate, query, payer);
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
if (nearbyIds.length) {
const placeholders = nearbyIds.map(() => '?').join(',');
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
`, [...where.params, ...nearbyIds]);
nearbySummary = {
kg: Number(rows[0]?.kg) || 0,
refuelCount: Number(rows[0]?.refuel_count) || 0,
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
stationCount: Number(rows[0]?.station_count) || 0,
};
}
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;