import { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; import mysqlPool from '../../db/mysql.js'; import type { AuthUser } from '../../auth/types.js'; import { canAccessEnergy } from '../../auth/types.js'; import { recentDayRange } from '../../../shared/date-range.js'; import { buildHydrogenPoints, filterHydrogenStationsByRadius, HEATMAP_DEFAULT_WINDOW_DAYS, parseHydrogenHeatmapQuery, parseNearbyQuery, rankHydrogenStations, serializeHydrogenStation, } from './model.js'; import { buildWhere, loadMetaProfile, loadNearbySummary, loadPointsSummary, loadStationOptions, loadStations, type MysqlDatabase, } from './repository.js'; export interface HydrogenHeatmapDependencies { mysql: MysqlDatabase; } export function registerHydrogenHeatmapRoutes(app: Hono, deps: HydrogenHeatmapDependencies): void { const { mysql } = deps; // 加氢热力图受能源角色控制(fail-closed)。 app.use('*', async (c, next) => { const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; if (!canAccessEnergy(user?.roles)) { return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403); } return next(); }); app.get('/config', (c) => { const key = process.env.AMAP_WEB_KEY; const securityCode = process.env.AMAP_SECURITY_JS_CODE; if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503); return c.json({ key, securityCode }); }); app.get('/meta', async (c) => { const [profileRows, optionRows] = await Promise.all([ loadMetaProfile(mysql), loadStationOptions(mysql), ]); const profile = profileRows[0] || {}; const totalRefuelCount = Number(profile.total_refuel_count) || 0; const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0; const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS); return c.json({ startDate: profile.start_date || fallback.start, endDate: profile.end_date || fallback.end, totalRefuelCount, eligibleRefuelCount, excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount), gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0, totalKg: Number(profile.total_kg) || 0, eligibleKg: Number(profile.eligible_kg) || 0, vehicleCount: Number(profile.vehicle_count) || 0, totalStationCount: Number(profile.total_station_count) || 0, eligibleStationCount: Number(profile.eligible_station_count) || 0, stations: optionRows.map((row) => ({ stationId: String(row.station_id), stationName: String(row.station_name), })), }); }); app.get('/points', async (c) => { const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({ startDate: c.req.query('startDate'), endDate: c.req.query('endDate'), query: c.req.query('query'), payer: c.req.query('payer'), metric: c.req.query('metric'), }); if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400); const stations = await loadStations(mysql, startDate, endDate, query, payer); const ranked = rankHydrogenStations(stations, metric); const { points, max } = buildHydrogenPoints(ranked, metric); const summaryRows = await loadPointsSummary(mysql, buildWhere(startDate, endDate, query, payer)); const summary = summaryRows[0] || {}; return c.json({ startDate, endDate, metric, payer, kg: Number(summary.kg) || 0, refuelCount: Number(summary.refuel_count) || 0, vehicleCount: Number(summary.vehicle_count) || 0, stationCount: Number(summary.station_count) || 0, dayCount: Number(summary.day_count) || 0, points, max, topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)), }); }); app.get('/nearby', async (c) => { const { center, radiusKm } = parseNearbyQuery({ lng: c.req.query('lng'), lat: c.req.query('lat'), radiusKm: c.req.query('radiusKm'), }); if (!center) return c.json({ error: '经纬度参数无效' }, 400); const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({ startDate: c.req.query('startDate'), endDate: c.req.query('endDate'), query: c.req.query('query'), payer: c.req.query('payer'), metric: c.req.query('metric'), }); const stations = await loadStations(mysql, startDate, endDate, query, payer); const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm); const nearbyIds = nearby.map((row) => row.station_id); let nearbySummary: { kg: number; refuelCount: number; vehicleCount: number; stationCount: number } = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0, }; if (nearbyIds.length) { const placeholders = nearbyIds.map(() => '?').join(','); const rows = await loadNearbySummary( mysql, buildWhere(startDate, endDate, query, payer), placeholders, nearbyIds, ); nearbySummary = { kg: Number(rows[0]?.kg) || 0, refuelCount: Number(rows[0]?.refuel_count) || 0, vehicleCount: Number(rows[0]?.vehicle_count) || 0, stationCount: Number(rows[0]?.station_count) || 0, }; } const ranked = rankHydrogenStations(nearby, metric); return c.json({ center, radiusKm, kg: nearbySummary.kg, refuelCount: nearbySummary.refuelCount, vehicleCount: nearbySummary.vehicleCount, stationCount: nearbySummary.stationCount, topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)), }); }); } /** 生产用路由器:绑定真实连接池。 */ export function createHydrogenHeatmapRouter(): Hono { const app = new Hono(); registerHydrogenHeatmapRoutes(app, { mysql: mysqlPool }); return app; } const app = createHydrogenHeatmapRouter(); export default app;