import { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; import pool from '../db.js'; import type { AuthUser } from '../auth/types.js'; import { canAccessEnergy } from '../auth/types.js'; type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles'; type Payer = 'all' | 'lingniu' | 'customer'; type StationRow = RowDataPacket & { station_id: string; station_name: string; address: string; longitude: string | number; latitude: string | number; kg: string | number; refuel_count: string | number; vehicle_count: string | number; first_refuel: string; last_refuel: string; }; const DEFAULT_START = '2026-01-01'; const DEFAULT_END = '2026-07-13'; 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 isDate(value: string | undefined): value is string { return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); } function parseMetric(value: string | undefined): HydrogenHeatmapMetric { if (value === 'refuels' || value === 'vehicles') return value; return 'kg'; } function parsePayer(value: string | undefined): Payer { if (value === 'lingniu' || value === 'customer') return value; return 'all'; } function buildWhere(startDate: string, endDate: string, query: string, payer: Payer) { 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 }; } async function loadStations(startDate: string, endDate: string, query: string, payer: Payer): 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; } function metricValue(row: StationRow, metric: HydrogenHeatmapMetric): number { if (metric === 'refuels') return Number(row.refuel_count) || 0; if (metric === 'vehicles') return Number(row.vehicle_count) || 0; return Number(row.kg) || 0; } function serializeStation(row: StationRow, metric: HydrogenHeatmapMetric) { return { stationId: row.station_id, stationName: row.station_name, address: row.address, lng: Number(row.longitude), lat: Number(row.latitude), kg: Number(row.kg) || 0, refuelCount: Number(row.refuel_count) || 0, vehicleCount: Number(row.vehicle_count) || 0, firstRefuel: row.first_refuel, lastRefuel: row.last_refuel, metricValue: metricValue(row, metric), }; } function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number { const radians = (degrees: number) => degrees * Math.PI / 180; const earthRadius = 6371; const dLat = radians(rightLat - leftLat); const dLng = radians(rightLng - leftLng); const a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2; return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } const router = new Hono(); router.use('*', async (c, next) => { const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; if (user && !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; return c.json({ startDate: profile.start_date || DEFAULT_START, endDate: profile.end_date || DEFAULT_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 = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START; const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END; if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400); const query = (c.req.query('query') || '').trim(); const payer = parsePayer(c.req.query('payer')); const metric = parseMetric(c.req.query('metric')); const stations = await loadStations(startDate, endDate, query, payer); const ranked = [...stations].sort((left, right) => metricValue(right, metric) - metricValue(left, metric)); const points = ranked.map((row) => ({ lng: Number(row.longitude), lat: Number(row.latitude), count: metricValue(row, 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: Math.max(1, ...points.map((point) => point.count)), topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)), }); }); router.get('/nearby', async (c) => { const longitude = Number(c.req.query('lng')); const latitude = Number(c.req.query('lat')); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return c.json({ error: '经纬度参数无效' }, 400); const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START; const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END; const query = (c.req.query('query') || '').trim(); const payer = parsePayer(c.req.query('payer')); const metric = parseMetric(c.req.query('metric')); const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50)); const stations = await loadStations(startDate, endDate, query, payer); const nearby = stations.filter((row) => haversineKm( longitude, latitude, Number(row.longitude), Number(row.latitude), ) <= 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, }; } const ranked = [...nearby].sort((left, right) => metricValue(right, metric) - metricValue(left, metric)); return c.json({ center: { lng: longitude, lat: latitude }, radiusKm, kg: nearbySummary.kg, refuelCount: nearbySummary.refuelCount, vehicleCount: nearbySummary.vehicleCount, stationCount: nearbySummary.stationCount, topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)), }); }); export default router;