Files
ln-bi/src/server/routes/ele/index.ts
T
dsh-agent 9a9c9d08e1 refactor(stage4): 集中配置与启动校验,权限守卫 fail-closed,凭据移出仓库
配置分层
- 新增 src/server/config.ts:环境变量集中读取 + 启动期校验。
  删除公开兜底密钥 ln-bi-default-secret:SSO 模式下 JWT_SECRET 缺失或
  短于 32 字符时 assertRuntimeConfig() 直接拒绝启动(已实测)。
- index.ts 不再自己读端口/环境;auth/config.ts 与 auth/login.ts 改为引用集中配置。

权限守卫
- energy / scheduling / hydrogen-heatmap 的守卫由 “user 存在才校验” 改为
  “无角色即拒绝”:user 缺失时按无权限处理,不再静默放行(fail-closed)。
- /api/ele/* 此前完全无鉴权,任何已登录用户都能写入电费表;现按能源域
  (BI-LEADER-ENERGY) 守卫。

接口契约
- 未匹配的 /api/* 由 200 text/html(SPA) 改为 404 application/json;
  未认证时仍是 401,避免向未授权调用方暴露路由是否存在。
- 新增全局 onError 返回 JSON 500。

凭据治理(此前均为 git 跟踪文件中的明文)
- Dockerfile 删除烧进镜像的 JWT_SECRET,改为必须运行时注入。
- docker-compose.yml 删除生产库口令/JWT 密钥/失效的 MILEAGE_DB_*,
  改为强制注入写法;补齐 OSS_* 与 NODE_ENV/DEV_BYPASS_AUTH/BI_AUTH_*。
- woodpecker.yml 删除 Harbor base64 凭据改用 secret,pull_request 不再推镜像。
- 删除 scripts-tmp/(含生产库 root 口令)与已跟踪的 .DS_Store;.gitignore 补全。
- 文档中残留的里程库口令改为占位符。

lint / test(128) / build 全绿。
2026-09-11 10:19:07 +08:00

368 lines
14 KiB
TypeScript

import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { ensureChargeRecordTable } from './migration.js';
const app = new Hono();
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
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();
});
// 与 xlsx 列名对齐
const COL = {
orderNo: '订单编号',
stationNo: '电站编号',
stationName: '电站名称',
terminalName: '终端名称',
region: '所属大区',
city: '所属城市',
district: '市区名称',
operatingCompany:'运营公司',
stationType: '电站类型',
orderStatus: '订单状态',
chargeForm: '充电形式',
startTime: '充电开始时间',
endTime: '充电结束时间',
duration: '充电时长(分钟)',
kwh: '充电电量(度)',
eFee: '充电电费(元)',
serviceFee: '充电服务费(元)',
fee: '充电费用(元)',
plate: '车牌号',
judgedPlate: '判定车牌号',
vin: '车架号',
customerName: '真实姓名',
customerPhone: '手机号',
enterpriseName: '企业名称',
} as const;
function safeStr(v: unknown, max = 250): string | null {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
return s.slice(0, max);
}
function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function safeDt(v: unknown): string | null {
const s = safeStr(v);
if (!s) return null;
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
// 简单校验
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
}
function normalizePlate(p: unknown): string | null {
const s = safeStr(p, 32);
if (!s) return null;
// 去掉所有空白字符
const trimmed = s.replace(/\s+/g, '').toUpperCase();
return trimmed || null;
}
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
// 寻找含"订单编号"和"车牌号"的那一行
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!Array.isArray(row)) continue;
const cells = row.map(c => (c == null ? '' : String(c)));
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
return { headerIdx: i, header: cells };
}
}
return null;
}
interface ParsedRow {
orderNo: string;
raw: Record<string, unknown>;
values: {
stationNo: string | null; stationName: string | null; terminalName: string | null;
region: string | null; city: string | null; district: string | null;
operatingCompany: string | null; stationType: string | null;
orderStatus: string | null; chargeForm: string | null;
startTime: string | null; endTime: string | null;
duration: number | null; kwh: number | null;
eFee: number | null; serviceFee: number | null; fee: number | null;
plate: string | null; judgedPlate: string | null; vin: string | null;
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
};
}
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
if (!ws) return [];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
const found = findHeaderRow(rows as unknown[][]);
if (!found) return [];
const { headerIdx, header } = found;
const idx = (label: string) => header.indexOf(label);
const result: ParsedRow[] = [];
for (let r = headerIdx + 1; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
const orderNo = safeStr(row[idx(COL.orderNo)]);
if (!orderNo) continue;
const raw: Record<string, unknown> = {};
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
result.push({
orderNo,
raw,
values: {
stationNo: safeStr(row[idx(COL.stationNo)]),
stationName: safeStr(row[idx(COL.stationName)]),
terminalName: safeStr(row[idx(COL.terminalName)]),
region: safeStr(row[idx(COL.region)]),
city: safeStr(row[idx(COL.city)]),
district: safeStr(row[idx(COL.district)]),
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
stationType: safeStr(row[idx(COL.stationType)]),
orderStatus: safeStr(row[idx(COL.orderStatus)]),
chargeForm: safeStr(row[idx(COL.chargeForm)]),
startTime: safeDt(row[idx(COL.startTime)]),
endTime: safeDt(row[idx(COL.endTime)]),
duration: safeNum(row[idx(COL.duration)]),
kwh: safeNum(row[idx(COL.kwh)]),
eFee: safeNum(row[idx(COL.eFee)]),
serviceFee: safeNum(row[idx(COL.serviceFee)]),
fee: safeNum(row[idx(COL.fee)]),
plate: normalizePlate(row[idx(COL.plate)]),
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
vin: safeStr(row[idx(COL.vin)]),
customerName: safeStr(row[idx(COL.customerName)]),
customerPhone: safeStr(row[idx(COL.customerPhone)]),
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
},
});
}
return result;
}
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
if (plates.size === 0) return new Map();
const arr = Array.from(plates);
const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
arr,
);
const map = new Map<string, string>();
for (const r of rows) {
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
}
return map;
}
// =========================================================
// POST /api/ele/import — 上传 xlsx 文件
// =========================================================
app.post('/import', async (c) => {
await ensureChargeRecordTable();
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const filename = file.name || 'unnamed.xlsx';
const buf = await file.arrayBuffer();
let parsed: ParsedRow[];
try {
parsed = parseSheet(buf);
} catch (e) {
console.error('parseSheet error:', e);
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
}
if (parsed.length === 0) {
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
}
// 文件内去重
const dedupMap = new Map<string, ParsedRow>();
for (const p of parsed) dedupMap.set(p.orderNo, p);
const records = Array.from(dedupMap.values());
const fileDuplicates = parsed.length - records.length;
// 系统车辆匹配
const allPlates = new Set<string>();
for (const r of records) {
if (r.values.plate) allPlates.add(r.values.plate);
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
}
const plateMap = await buildPlateLookup(allPlates);
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const importedAt = new Date();
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
const sql = `INSERT IGNORE INTO bi_ele_charge_record
(order_no, station_no, station_name, terminal_name, region, city, district,
operating_company, station_type, order_status, charge_form,
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
matched_truck_id, matched_plate, vehicle_kind, raw_json,
batch_id, imported_at)
VALUES ?`;
const values = records.map(r => {
const plate = r.values.plate || r.values.judgedPlate;
const matchedId = plate ? plateMap.get(plate) || null : null;
// 命中系统车辆=internal;其余(含车牌为空)一律 external
const kind = matchedId ? 'internal' : 'external';
return [
r.orderNo,
r.values.stationNo, r.values.stationName, r.values.terminalName,
r.values.region, r.values.city, r.values.district,
r.values.operatingCompany, r.values.stationType,
r.values.orderStatus, r.values.chargeForm,
r.values.startTime, r.values.endTime, r.values.duration,
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
r.values.plate, r.values.judgedPlate, r.values.vin,
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
matchedId, matchedId ? plate : null, kind,
JSON.stringify(r.raw),
batchId, importedAt,
];
});
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
const inserted = result.affectedRows;
const dbDuplicates = records.length - inserted;
// 统计内/外(无车牌也算外部)
let internal = 0, external = 0;
for (const r of records) {
const plate = r.values.plate || r.values.judgedPlate;
if (plate && plateMap.has(plate)) internal++;
else external++;
}
return c.json({
ok: true,
filename,
batchId,
parsed: parsed.length,
fileDuplicates,
inserted,
dbDuplicates,
breakdown: { internal, external },
});
});
// =========================================================
// GET /api/ele/list — 分页列表(最新优先)
// =========================================================
app.get('/list', async (c) => {
await ensureChargeRecordTable();
const page = Math.max(1, Number(c.req.query('page')) || 1);
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
const kind = c.req.query('kind') || '';
const batchId = c.req.query('batchId') || '';
const search = c.req.query('search') || '';
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (kind === 'internal' || kind === 'external') {
where.push('vehicle_kind = ?');
params.push(kind);
}
if (batchId) {
where.push('batch_id = ?');
params.push(batchId);
}
if (search) {
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
const q = `%${search}%`;
params.push(q, q, q);
}
const offset = (page - 1) * limit;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, order_no, station_name, terminal_name, region, city,
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
plate, judged_plate, customer_name, vehicle_kind,
batch_id, imported_at
FROM bi_ele_charge_record
WHERE ${where.join(' AND ')}
ORDER BY start_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
);
const [countRows] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
params,
);
const total = Number(countRows[0]?.total || 0);
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
});
// =========================================================
// GET /api/ele/batches — 批次列表
// =========================================================
app.get('/batches', async (c) => {
await ensureChargeRecordTable();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT batch_id,
MIN(imported_at) AS imported_at,
COUNT(*) AS records,
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY batch_id
ORDER BY imported_at DESC
LIMIT 50`,
);
return c.json({ items: rows });
});
// =========================================================
// GET /api/ele/aggregate — 聚合统计
// =========================================================
app.get('/aggregate', async (c) => {
await ensureChargeRecordTable();
// 全量分类汇总
const [overallRows] = await pool.query<RowDataPacket[]>(
`SELECT vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY vehicle_kind`,
);
// 近 30 日按日
const [dailyRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
ORDER BY date DESC`,
);
return c.json({ overall: overallRows, daily: dailyRows });
});
export default app;