refactor(stage10): ele / feedback 拆出 repository + model,并补契约测试
背景
- 这两个域把 SQL 与处理器放在同一文件,是全仓仅有的两个零接口测试的后端域。
直接拆会改动 SQL 与参数顺序,而硬约束要求不得变更统计口径 —— 因此按
"可注入化 -> 补契约测试 -> 再提取" 的顺序做。
改动
- ele:拆为 routes.ts(校验与组装)/ repository.ts(全部 SQL)/ model.ts(xlsx 解析、
取值清洗、筛选片段、插入值组装等纯逻辑),并改为 registerEleRoutes(app, deps) 可注入。
- feedback:拆为 routes.ts / repository.ts,同样可注入;建表与截图上传也作为依赖注入。
- 两域各补测试:ele 7 个接口契约 + 6 个模型用例,feedback 9 个接口契约。
mock pool 逐条断言 SQL 文本与参数顺序(含分页、状态白名单、批量插入的 30 列顺序)。
- 架构测试新增一条:已完整分层的三个域(vehicles / ele / feedback)必须有 repository.ts
且 routes.ts 不得出现 SQL。
等价性验证(关键)
- 把改造前的实现从 git 取出,与改造后的实现跑同一批请求(含真实 xlsx 解析路径),
对比每一步落库 SQL、参数、HTTP 状态与响应体:
feedback:6 个场景,SQL + 参数 + 状态完全一致(差异仅 DDL 已移交 db/schema 层)
ele :5 个场景,SQL + 参数 + 状态 + 响应体完全一致(仅随机 batchId/时间戳做掩码)
- 期间的修正:曾把 /mine 与 /list 的列集合统一,二者实际不同(管理列表多 user_id/user_name),
已按原样保留;测试同时锁定了这一差异。
lint / test(161) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
+14
-8
@@ -91,12 +91,17 @@ server/
|
|||||||
|
|
||||||
| 域 | 文件 | 说明 |
|
| 域 | 文件 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | 唯一已完整分层到 repository 的域,SQL 有指纹测试 |
|
| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | ✅ 完整分层;SQL 有契约测试 |
|
||||||
| `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` | 路由 / 模型 / 服务已分开 |
|
| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.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 仍在路由内 |
|
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
|
||||||
|
| `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` | 同上 |
|
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` + `algorithm.ts` `notification-model.ts` | 同上 |
|
||||||
| `hydrogen-heatmap/`、`vehicle-heatmap/` | `routes.ts` + `model.ts` | 路由 / 模型已分开 |
|
| `hydrogen-heatmap/`、`vehicle-heatmap/` | `routes.ts` + `model.ts` | 纯模型已抽出,**SQL 仍在 `routes.ts`** |
|
||||||
| `ele/`、`feedback/` | `routes.ts`(+ `oss.ts`) | **尚未拆出 repository**,SQL 与处理器在同一文件 |
|
|
||||||
|
已完整分层的三个域(`vehicles` / `ele` / `feedback`)由架构测试守护:`routes.ts` 不得出现 SQL,
|
||||||
|
且必须存在 `repository.ts`。其余域尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
||||||
|
并建议先按 `ele/routes.test.ts` 的方式补契约测试再动。
|
||||||
|
|
||||||
|
|
||||||
### 中间件顺序(在 `app.ts` 中显式体现)
|
### 中间件顺序(在 `app.ts` 中显式体现)
|
||||||
@@ -132,9 +137,10 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
|||||||
|
|
||||||
诚实记录,避免后来者以为已经做完:
|
诚实记录,避免后来者以为已经做完:
|
||||||
|
|
||||||
- **后端仍有两个域没拆出 repository**:`ele/routes.ts` 与 `feedback/routes.ts` 仍把 SQL
|
- **后端仍有域没拆出 repository**:`mileage` / `energy` / `scheduling` / 两个热力图的 SQL 仍在
|
||||||
与处理器放在同一文件(形状见上表)。拆分时**不要改变 SQL 与参数顺序**——这两个域目前
|
各自的 `routes.ts`(或平级模块)里(形状见上表)。`vehicles` / `ele` / `feedback` 已完成,
|
||||||
没有 SQL 指纹测试,建议先补契约测试再动。
|
可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,纯逻辑进 `model.ts`,
|
||||||
|
并用 mock pool 的契约测试锁定 SQL 与参数。
|
||||||
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
||||||
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
||||||
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync, readdirSync } from "node:fs";
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -163,3 +163,18 @@ test("运行时建表必须尊重只读模式", () => {
|
|||||||
.map(rel);
|
.map(rel);
|
||||||
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
|
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => {
|
||||||
|
// 这三个域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
|
||||||
|
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
|
||||||
|
const layered = ["server/routes/vehicles", "server/routes/ele", "server/routes/feedback"];
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const dir of layered) {
|
||||||
|
if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`);
|
||||||
|
const routes = path.join(srcDir, dir, "routes.ts");
|
||||||
|
if (existsSync(routes) && /\b(select|insert into|insert ignore|update |delete from|create table|alter table)\b/i.test(readFileSync(routes, "utf8"))) {
|
||||||
|
offenders.push(`${dir}/routes.ts 仍含 SQL`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts");
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
buildListFilter,
|
||||||
|
countVehicleKinds,
|
||||||
|
dedupeByOrderNo,
|
||||||
|
findHeaderRow,
|
||||||
|
normalizePlate,
|
||||||
|
safeDt,
|
||||||
|
safeNum,
|
||||||
|
safeStr,
|
||||||
|
toInsertValues,
|
||||||
|
type ParsedRow,
|
||||||
|
} from "./model.js";
|
||||||
|
|
||||||
|
function row(orderNo: string, plate: string | null): ParsedRow {
|
||||||
|
return {
|
||||||
|
orderNo,
|
||||||
|
raw: { 订单编号: orderNo },
|
||||||
|
values: {
|
||||||
|
stationNo: null, stationName: null, terminalName: null,
|
||||||
|
region: null, city: null, district: null,
|
||||||
|
operatingCompany: null, stationType: null,
|
||||||
|
orderStatus: null, chargeForm: null,
|
||||||
|
startTime: null, endTime: null,
|
||||||
|
duration: null, kwh: null,
|
||||||
|
eFee: null, serviceFee: null, fee: null,
|
||||||
|
plate, judgedPlate: null, vin: null,
|
||||||
|
customerName: null, customerPhone: null, enterpriseName: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("取值清洗:字符串截断、数字与日期的非法值一律为空", () => {
|
||||||
|
assert.equal(safeStr(" x "), "x");
|
||||||
|
assert.equal(safeStr("abcdef", 3), "abc");
|
||||||
|
assert.equal(safeStr(" "), null);
|
||||||
|
assert.equal(safeNum("12.5"), 12.5);
|
||||||
|
assert.equal(safeNum("abc"), null);
|
||||||
|
assert.equal(safeNum(""), null);
|
||||||
|
// 日期:仅接受 YYYY-MM-DD[ HH:MM[:SS]],10/16 位自动补全
|
||||||
|
assert.equal(safeDt("2026-04-29"), "2026-04-29 00:00:00");
|
||||||
|
assert.equal(safeDt("2026-04-29 16:24"), "2026-04-29 16:24:00");
|
||||||
|
assert.equal(safeDt("2026-04-29 16:24:05"), "2026-04-29 16:24:05");
|
||||||
|
assert.equal(safeDt("29/04/2026"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("车牌归一化:去空白并大写,空值返回 null", () => {
|
||||||
|
assert.equal(normalizePlate(" 粤a 12345 "), "粤A12345");
|
||||||
|
assert.equal(normalizePlate(""), null);
|
||||||
|
assert.equal(normalizePlate(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("表头定位:必须同时包含订单编号与车牌号", () => {
|
||||||
|
const rows = [
|
||||||
|
["报表说明", null],
|
||||||
|
["订单编号", "车牌号", "充电电量(度)"],
|
||||||
|
["A1", "粤A1", "10"],
|
||||||
|
];
|
||||||
|
assert.deepEqual(findHeaderRow(rows), {
|
||||||
|
headerIdx: 1,
|
||||||
|
header: ["订单编号", "车牌号", "充电电量(度)"],
|
||||||
|
});
|
||||||
|
assert.equal(findHeaderRow([["订单编号"]]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("文件内按订单号去重保留最后一条", () => {
|
||||||
|
const parsed = [row("A", "粤A"), row("B", "粤B"), row("A", "粤C")];
|
||||||
|
const { records, fileDuplicates } = dedupeByOrderNo(parsed);
|
||||||
|
assert.equal(fileDuplicates, 1);
|
||||||
|
assert.equal(records.length, 2);
|
||||||
|
assert.equal(records.find(r => r.orderNo === "A")?.values.plate, "粤C");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("列表筛选:片段与参数顺序固定,非法 kind 被丢弃", () => {
|
||||||
|
assert.deepEqual(buildListFilter({}), { where: ["1=1"], params: [] });
|
||||||
|
assert.deepEqual(buildListFilter({ kind: "bogus" }), { where: ["1=1"], params: [] });
|
||||||
|
assert.deepEqual(
|
||||||
|
buildListFilter({ kind: "internal", batchId: "b1", search: "粤A" }),
|
||||||
|
{
|
||||||
|
where: ["1=1", "vehicle_kind = ?", "batch_id = ?", "(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)"],
|
||||||
|
params: ["internal", "b1", "%粤A%", "%粤A%", "%粤A%"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("插入值:命中系统车辆记 internal,其余记 external", () => {
|
||||||
|
const records = [row("A", "粤A1"), row("B", "粤B2"), row("C", null)];
|
||||||
|
const plateMap = new Map([["粤A1", "9"]]);
|
||||||
|
const values = toInsertValues(records, plateMap, "batch-1", new Date("2026-04-29T00:00:00Z"));
|
||||||
|
|
||||||
|
assert.equal(values.length, 3);
|
||||||
|
assert.equal(values[0].length, 30, "每行 30 列,顺序由契约测试锁定");
|
||||||
|
assert.equal(values[0][0], "A");
|
||||||
|
assert.equal(values[0][24], "9"); // matched_truck_id
|
||||||
|
assert.equal(values[0][25], "粤A1"); // matched_plate
|
||||||
|
assert.equal(values[0][26], "internal"); // vehicle_kind
|
||||||
|
assert.equal(values[0][28], "batch-1"); // batch_id
|
||||||
|
|
||||||
|
assert.equal(values[1][24], null);
|
||||||
|
assert.equal(values[1][26], "external");
|
||||||
|
assert.equal(values[2][26], "external", "无车牌也算外部");
|
||||||
|
assert.equal(values[2][25], null, "无车牌时不写 matched_plate");
|
||||||
|
|
||||||
|
assert.deepEqual(countVehicleKinds(records, plateMap), { internal: 1, external: 2 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电能导入的纯逻辑:xlsx 解析、取值清洗、筛选条件与插入值组装。
|
||||||
|
* 不访问数据库,因此可被 node:test 直接覆盖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 与 xlsx 列名对齐。 */
|
||||||
|
export 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;
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeNum(v: unknown): number | null {
|
||||||
|
if (v == null || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件内按订单号去重(保留最后一条,与既有行为一致)。 */
|
||||||
|
export function dedupeByOrderNo(parsed: ParsedRow[]): { records: ParsedRow[]; fileDuplicates: number } {
|
||||||
|
const dedupMap = new Map<string, ParsedRow>();
|
||||||
|
for (const p of parsed) dedupMap.set(p.orderNo, p);
|
||||||
|
const records = Array.from(dedupMap.values());
|
||||||
|
return { records, fileDuplicates: parsed.length - records.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表接口的 WHERE 片段与参数(顺序固定,由契约测试锁定)。 */
|
||||||
|
export function buildListFilter(query: { kind?: string; batchId?: string; search?: string }): {
|
||||||
|
where: string[];
|
||||||
|
params: (string | number)[];
|
||||||
|
} {
|
||||||
|
const where: string[] = ['1=1'];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
const kind = query.kind || '';
|
||||||
|
if (kind === 'internal' || kind === 'external') {
|
||||||
|
where.push('vehicle_kind = ?');
|
||||||
|
params.push(kind);
|
||||||
|
}
|
||||||
|
if (query.batchId) {
|
||||||
|
where.push('batch_id = ?');
|
||||||
|
params.push(query.batchId);
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
|
||||||
|
const q = `%${query.search}%`;
|
||||||
|
params.push(q, q, q);
|
||||||
|
}
|
||||||
|
return { where, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装批量插入的值。
|
||||||
|
* 命中系统车辆记 internal;其余(含车牌为空)一律 external。
|
||||||
|
*/
|
||||||
|
export function toInsertValues(
|
||||||
|
records: ParsedRow[],
|
||||||
|
plateMap: Map<string, string>,
|
||||||
|
batchId: string,
|
||||||
|
importedAt: Date,
|
||||||
|
): unknown[][] {
|
||||||
|
return records.map(r => {
|
||||||
|
const plate = r.values.plate || r.values.judgedPlate;
|
||||||
|
const matchedId = plate ? plateMap.get(plate) || null : null;
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内/外部车辆计数:无车牌也算外部。 */
|
||||||
|
export function countVehicleKinds(
|
||||||
|
records: ParsedRow[],
|
||||||
|
plateMap: Map<string, string>,
|
||||||
|
): { internal: number; external: number } {
|
||||||
|
let internal = 0;
|
||||||
|
let external = 0;
|
||||||
|
for (const r of records) {
|
||||||
|
const plate = r.values.plate || r.values.judgedPlate;
|
||||||
|
if (plate && plateMap.has(plate)) internal++;
|
||||||
|
else external++;
|
||||||
|
}
|
||||||
|
return { internal, external };
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电能充电记录表的全部 SQL 出口。
|
||||||
|
* 语句文本与参数顺序由 routes.test.ts 的契约测试锁定。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 只需要 query 能力的最小依赖,便于测试注入 mock。 */
|
||||||
|
export interface Database {
|
||||||
|
query<T = any>(sql: string, values?: unknown[]): Promise<[T, ...any[]]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按车牌查系统车辆 id(用于区分内部/外部车辆)。 */
|
||||||
|
export async function findTruckIdsByPlates(
|
||||||
|
db: Database,
|
||||||
|
plates: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
if (plates.length === 0) return new Map();
|
||||||
|
const placeholders = plates.map(() => '?').join(',');
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(
|
||||||
|
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
||||||
|
FROM vehicle_info
|
||||||
|
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
|
||||||
|
plates,
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量 INSERT IGNORE,靠 order_no 的 UNIQUE 约束做库内去重。 */
|
||||||
|
export async function insertChargeRecords(db: Database, values: unknown[][]): Promise<number> {
|
||||||
|
const [result] = await db.query<ResultSetHeader>(
|
||||||
|
`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 ?`,
|
||||||
|
[values],
|
||||||
|
);
|
||||||
|
return result.affectedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChargeRecords(
|
||||||
|
db: Database,
|
||||||
|
filter: { where: string[]; params: (string | number)[]; limit: number; offset: number },
|
||||||
|
): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.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 ${filter.where.join(' AND ')}
|
||||||
|
ORDER BY start_time DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...filter.params, filter.limit, filter.offset],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countChargeRecords(
|
||||||
|
db: Database,
|
||||||
|
filter: { where: string[]; params: (string | number)[] },
|
||||||
|
): Promise<number> {
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(
|
||||||
|
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${filter.where.join(' AND ')}`,
|
||||||
|
filter.params,
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.total || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBatches(db: Database): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.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 rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggregateByVehicleKind(db: Database): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.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`,
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggregateDaily30d(db: Database): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.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 rows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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 { registerEleRoutes, type EleDependencies } from "./routes.js";
|
||||||
|
import type { Database } from "./repository.js";
|
||||||
|
import type { ParsedRow } from "./model.js";
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
sql: string;
|
||||||
|
params: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const queue = [...resultSets];
|
||||||
|
const db: Database = {
|
||||||
|
async query(sql: string, params?: unknown[]) {
|
||||||
|
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||||
|
return [queue.shift() ?? [], []] as never;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { db, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENERGY_USER: AuthUser = {
|
||||||
|
userId: "u-e",
|
||||||
|
userName: "能源",
|
||||||
|
loginName: "energy",
|
||||||
|
depCode: "",
|
||||||
|
depName: "",
|
||||||
|
permissionLevel: "full",
|
||||||
|
roles: ["BI-LEADER-ENERGY"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAIN_USER: AuthUser = { ...ENERGY_USER, roles: [] };
|
||||||
|
|
||||||
|
function makeApp(user: AuthUser | undefined, deps: EleDependencies): Hono<{ Variables: { user: AuthUser } }> {
|
||||||
|
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
if (user) c.set("user", user);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
registerEleRoutes(app as never, deps);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedRow(orderNo: string, plate: string | null, kwh: number | null): ParsedRow {
|
||||||
|
return {
|
||||||
|
orderNo,
|
||||||
|
raw: { 订单编号: orderNo },
|
||||||
|
values: {
|
||||||
|
stationNo: "S1", stationName: "站", terminalName: null,
|
||||||
|
region: null, city: null, district: null,
|
||||||
|
operatingCompany: null, stationType: null,
|
||||||
|
orderStatus: null, chargeForm: null,
|
||||||
|
startTime: "2026-04-29 10:00:00", endTime: null,
|
||||||
|
duration: null, kwh,
|
||||||
|
eFee: null, serviceFee: null, fee: 12.5,
|
||||||
|
plate, judgedPlate: null, vin: null,
|
||||||
|
customerName: null, customerPhone: null, enterpriseName: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("电能导入受能源角色保护(fail-closed)", async () => {
|
||||||
|
const { db, calls } = createMockDb();
|
||||||
|
const app = makeApp(PLAIN_USER, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
for (const [method, url] of [["GET", "/list"], ["GET", "/batches"], ["GET", "/aggregate"]] as const) {
|
||||||
|
const res = await app.request(url, { method });
|
||||||
|
assert.equal(res.status, 403, `${method} ${url} 应被拒绝`);
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 0, "无权限时不应触达数据库");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("列表:WHERE 片段、分页参数与总数查询保持一致", async () => {
|
||||||
|
const { db, calls } = createMockDb([[{ id: 1 }], [{ total: 3 }]]);
|
||||||
|
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const res = await app.request("/list?kind=internal&batchId=b1&search=粤A&page=2&limit=10");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), { items: [{ id: 1 }], total: 3, page: 2, limit: 10, totalPages: 1 });
|
||||||
|
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"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 1=1 AND vehicle_kind = ? AND batch_id = ? AND (order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?) ORDER BY start_time DESC, id DESC LIMIT ? OFFSET ?",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, ["internal", "b1", "%粤A%", "%粤A%", "%粤A%", 10, 10]);
|
||||||
|
assert.equal(
|
||||||
|
calls[1].sql,
|
||||||
|
"SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE 1=1 AND vehicle_kind = ? AND batch_id = ? AND (order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[1].params, ["internal", "b1", "%粤A%", "%粤A%", "%粤A%"], "计数不带分页参数");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("列表:limit 上限 200,page 最小 1", async () => {
|
||||||
|
const { db, calls } = createMockDb([[], [{ total: 0 }]]);
|
||||||
|
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
await app.request("/list?limit=9999&page=0");
|
||||||
|
assert.deepEqual(calls[0].params, [200, 0], "limit 截断到 200,page 回退 1 => offset 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("批次与聚合:SQL 原样保留", async () => {
|
||||||
|
const { db, calls } = createMockDb([[], [], []]);
|
||||||
|
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
await app.request("/batches");
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
await app.request("/aggregate");
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calls[1].sql,
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("导入:车牌匹配 + 批量插入语句与统计口径不变", async () => {
|
||||||
|
const { db, calls } = createMockDb([
|
||||||
|
[{ plate_number: "粤A1", truck_id: 9 }], // findTruckIdsByPlates
|
||||||
|
{ affectedRows: 1 }, // insert
|
||||||
|
]);
|
||||||
|
const rows = [
|
||||||
|
parsedRow("A", "粤B2", 10),
|
||||||
|
parsedRow("B", "粤B2", 20),
|
||||||
|
parsedRow("A", "粤A1", 30), // 文件内重复订单号:按既有口径保留最后一条
|
||||||
|
];
|
||||||
|
const app = makeApp(ENERGY_USER, {
|
||||||
|
db,
|
||||||
|
ensureTable: async () => {},
|
||||||
|
parse: () => rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", new File([new Uint8Array([1])], "charges.xlsx"));
|
||||||
|
const res = await app.request("/import", { method: "POST", body: form });
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const payload = await res.json() as Record<string, unknown>;
|
||||||
|
assert.equal(payload.ok, true);
|
||||||
|
assert.equal(payload.filename, "charges.xlsx");
|
||||||
|
assert.equal(payload.parsed, 3);
|
||||||
|
assert.equal(payload.fileDuplicates, 1);
|
||||||
|
assert.equal(payload.inserted, 1);
|
||||||
|
assert.equal(payload.dbDuplicates, 1, "去重后 2 条,插入 1 条 => 库内重复 1 条");
|
||||||
|
assert.deepEqual(payload.breakdown, { internal: 1, external: 1 });
|
||||||
|
assert.equal(typeof payload.batchId, "string");
|
||||||
|
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"SELECT plate_number, CAST(id AS CHAR) AS truck_id FROM vehicle_info WHERE del_flag = '0' AND plate_number IN (?,?)",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, ["粤A1", "粤B2"]);
|
||||||
|
assert.equal(
|
||||||
|
calls[1].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 inserted = calls[1].params[0] as unknown[][];
|
||||||
|
assert.equal(inserted.length, 2);
|
||||||
|
assert.equal(inserted[0].length, 30);
|
||||||
|
assert.equal(inserted[0][24], "9", "truck id 以字符串写入,与原实现一致");
|
||||||
|
assert.equal(inserted[0][26], "internal");
|
||||||
|
assert.equal(inserted[1][26], "external");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("导入:解析失败与空结果返回 400,且不写库", async () => {
|
||||||
|
const { db, calls } = createMockDb();
|
||||||
|
const app = makeApp(ENERGY_USER, {
|
||||||
|
db,
|
||||||
|
ensureTable: async () => {},
|
||||||
|
parse: () => { throw new Error('bad file'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", new File([new Uint8Array([1])], "bad.xlsx"));
|
||||||
|
const res = await app.request("/import", { method: "POST", body: form });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
|
||||||
|
const emptyApp = makeApp(ENERGY_USER, { db, ensureTable: async () => {}, parse: () => [] });
|
||||||
|
const form2 = new FormData();
|
||||||
|
form2.set("file", new File([new Uint8Array([1])], "empty.xlsx"));
|
||||||
|
const res2 = await emptyApp.request("/import", { method: "POST", body: form2 });
|
||||||
|
assert.equal(res2.status, 400);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("分层:路由文件不再直接书写 SQL", () => {
|
||||||
|
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
|
||||||
|
const offenders = source.match(/\b(SELECT|INSERT IGNORE|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
|
||||||
|
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
|
||||||
|
});
|
||||||
+62
-290
@@ -1,12 +1,38 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
|
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import pool from '../../db/mysql.js';
|
import pool from '../../db/mysql.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
import { ensureChargeRecordTable } from '../../db/schema/ele.js';
|
import { ensureChargeRecordTable } from '../../db/schema/ele.js';
|
||||||
|
import {
|
||||||
|
buildListFilter,
|
||||||
|
countVehicleKinds,
|
||||||
|
dedupeByOrderNo,
|
||||||
|
parseSheet,
|
||||||
|
toInsertValues,
|
||||||
|
type ParsedRow,
|
||||||
|
} from './model.js';
|
||||||
|
import {
|
||||||
|
aggregateByVehicleKind,
|
||||||
|
aggregateDaily30d,
|
||||||
|
countChargeRecords,
|
||||||
|
findTruckIdsByPlates,
|
||||||
|
insertChargeRecords,
|
||||||
|
listBatches,
|
||||||
|
listChargeRecords,
|
||||||
|
type Database,
|
||||||
|
} from './repository.js';
|
||||||
|
|
||||||
const app = new Hono();
|
export interface EleDependencies {
|
||||||
|
db: Database;
|
||||||
|
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
|
||||||
|
ensureTable: () => Promise<void>;
|
||||||
|
/** xlsx 解析,测试可注入以避免构造真实文件。 */
|
||||||
|
parse?: (buf: ArrayBuffer) => ParsedRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
|
||||||
|
const { db, ensureTable } = deps;
|
||||||
|
const parse = deps.parse ?? parseSheet;
|
||||||
|
|
||||||
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
|
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
|
||||||
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
|
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
|
||||||
@@ -18,165 +44,9 @@ app.use('*', async (c, next) => {
|
|||||||
return next();
|
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 文件
|
// POST /api/ele/import — 上传 xlsx 文件
|
||||||
// =========================================================
|
|
||||||
app.post('/import', async (c) => {
|
app.post('/import', async (c) => {
|
||||||
await ensureChargeRecordTable();
|
await ensureTable();
|
||||||
const form = await c.req.formData();
|
const form = await c.req.formData();
|
||||||
const file = form.get('file');
|
const file = form.get('file');
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
@@ -186,7 +56,7 @@ app.post('/import', async (c) => {
|
|||||||
const buf = await file.arrayBuffer();
|
const buf = await file.arrayBuffer();
|
||||||
let parsed: ParsedRow[];
|
let parsed: ParsedRow[];
|
||||||
try {
|
try {
|
||||||
parsed = parseSheet(buf);
|
parsed = parse(buf);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('parseSheet error:', e);
|
console.error('parseSheet error:', e);
|
||||||
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
|
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
|
||||||
@@ -195,11 +65,7 @@ app.post('/import', async (c) => {
|
|||||||
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
|
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件内去重
|
const { records, fileDuplicates } = dedupeByOrderNo(parsed);
|
||||||
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>();
|
const allPlates = new Set<string>();
|
||||||
@@ -207,53 +73,14 @@ app.post('/import', async (c) => {
|
|||||||
if (r.values.plate) allPlates.add(r.values.plate);
|
if (r.values.plate) allPlates.add(r.values.plate);
|
||||||
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
|
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
|
||||||
}
|
}
|
||||||
const plateMap = await buildPlateLookup(allPlates);
|
const plateMap = await findTruckIdsByPlates(db, Array.from(allPlates));
|
||||||
|
|
||||||
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const importedAt = new Date();
|
const importedAt = new Date();
|
||||||
|
|
||||||
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
|
const inserted = await insertChargeRecords(db, toInsertValues(records, plateMap, batchId, importedAt));
|
||||||
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;
|
const dbDuplicates = records.length - inserted;
|
||||||
|
const breakdown = countVehicleKinds(records, plateMap);
|
||||||
// 统计内/外(无车牌也算外部)
|
|
||||||
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({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -263,105 +90,50 @@ app.post('/import', async (c) => {
|
|||||||
fileDuplicates,
|
fileDuplicates,
|
||||||
inserted,
|
inserted,
|
||||||
dbDuplicates,
|
dbDuplicates,
|
||||||
breakdown: { internal, external },
|
breakdown,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// GET /api/ele/list — 分页列表(最新优先)
|
// GET /api/ele/list — 分页列表(最新优先)
|
||||||
// =========================================================
|
|
||||||
app.get('/list', async (c) => {
|
app.get('/list', async (c) => {
|
||||||
await ensureChargeRecordTable();
|
await ensureTable();
|
||||||
const page = Math.max(1, Number(c.req.query('page')) || 1);
|
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 limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||||
const kind = c.req.query('kind') || '';
|
const filter = buildListFilter({
|
||||||
const batchId = c.req.query('batchId') || '';
|
kind: c.req.query('kind'),
|
||||||
const search = c.req.query('search') || '';
|
batchId: c.req.query('batchId'),
|
||||||
|
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 offset = (page - 1) * limit;
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
const items = await listChargeRecords(db, { ...filter, limit, offset });
|
||||||
`SELECT id, order_no, station_name, terminal_name, region, city,
|
const total = await countChargeRecords(db, filter);
|
||||||
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
|
return c.json({ items, total, page, limit, totalPages: Math.ceil(total / limit) });
|
||||||
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 — 批次列表
|
// GET /api/ele/batches — 批次列表
|
||||||
// =========================================================
|
|
||||||
app.get('/batches', async (c) => {
|
app.get('/batches', async (c) => {
|
||||||
await ensureChargeRecordTable();
|
await ensureTable();
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
return c.json({ items: await listBatches(db) });
|
||||||
`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 — 聚合统计
|
// GET /api/ele/aggregate — 聚合统计
|
||||||
// =========================================================
|
|
||||||
app.get('/aggregate', async (c) => {
|
app.get('/aggregate', async (c) => {
|
||||||
await ensureChargeRecordTable();
|
await ensureTable();
|
||||||
// 全量分类汇总
|
const [overall, daily] = await Promise.all([
|
||||||
const [overallRows] = await pool.query<RowDataPacket[]>(
|
aggregateByVehicleKind(db),
|
||||||
`SELECT vehicle_kind,
|
aggregateDaily30d(db),
|
||||||
COUNT(*) AS records,
|
]);
|
||||||
ROUND(SUM(kwh), 2) AS total_kwh,
|
return c.json({ overall, daily });
|
||||||
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 function createEleRouter(): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
registerEleRoutes(app, { db: pool, ensureTable: ensureChargeRecordTable });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createEleRouter();
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反馈表的全部 SQL 出口。
|
||||||
|
*
|
||||||
|
* SQL 与参数顺序在此固定:路由只做校验与组装,不再拼 SQL。
|
||||||
|
* 接口由 mock pool 的契约测试锁定(见 routes.test.ts)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 只需要 query 能力的最小依赖,便于在测试中注入 mock。 */
|
||||||
|
export interface Database {
|
||||||
|
query<T = any>(sql: string, values?: unknown[]): Promise<[T, ...any[]]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置。
|
||||||
|
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||||||
|
|
||||||
|
// 两个列表接口的列集合刻意不同:管理列表额外返回 user_id / user_name。
|
||||||
|
const USER_LIST_COLUMNS = `id, type, module, content, contact, screenshots, status,
|
||||||
|
reply_content, reply_user, reply_at, created_at`;
|
||||||
|
const ADMIN_LIST_COLUMNS = `id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||||||
|
reply_content, reply_user, reply_at, created_at`;
|
||||||
|
|
||||||
|
export interface NewFeedback {
|
||||||
|
type: string;
|
||||||
|
module: string | null;
|
||||||
|
content: string;
|
||||||
|
contact: string | null;
|
||||||
|
screenshotsJson: string;
|
||||||
|
userId: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertFeedback(db: Database, row: NewFeedback): Promise<number> {
|
||||||
|
const [result] = await db.query<ResultSetHeader>(
|
||||||
|
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
||||||
|
[row.type, row.module, row.content, row.contact, row.screenshotsJson, row.userId, row.userName, row.userAgent],
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户的反馈历史(最多 100 条)。 */
|
||||||
|
export async function listFeedbackForUser(db: Database, userId: string): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(
|
||||||
|
`SELECT ${USER_LIST_COLUMNS}
|
||||||
|
FROM bi_user_feedback
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理列表:可选状态过滤 + 条数上限。 */
|
||||||
|
export async function listFeedbackForAdmin(
|
||||||
|
db: Database,
|
||||||
|
options: { status: string | null; limit: number },
|
||||||
|
): Promise<RowDataPacket[]> {
|
||||||
|
const where: string[] = ['1=1'];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
if (options.status) {
|
||||||
|
where.push('status = ?');
|
||||||
|
params.push(options.status);
|
||||||
|
}
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(
|
||||||
|
`SELECT ${ADMIN_LIST_COLUMNS}
|
||||||
|
FROM bi_user_feedback
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[...params, options.limit],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackUpdate {
|
||||||
|
status?: string;
|
||||||
|
/** 传 undefined 表示不更新回复;传 null/'' 表示清空。 */
|
||||||
|
reply?: string | null;
|
||||||
|
replyUser?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理更新。返回 false 表示没有任何可更新字段。 */
|
||||||
|
export async function updateFeedback(db: Database, id: number, update: FeedbackUpdate): Promise<boolean> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: (string | number | null)[] = [];
|
||||||
|
if (update.status !== undefined) {
|
||||||
|
fields.push('status = ?');
|
||||||
|
params.push(update.status);
|
||||||
|
}
|
||||||
|
if (update.reply !== undefined) {
|
||||||
|
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
||||||
|
params.push(update.reply || null, update.replyUser ?? null);
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return false;
|
||||||
|
await db.query(
|
||||||
|
`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`,
|
||||||
|
[...params, id],
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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 { registerFeedbackRoutes, type FeedbackDependencies } from "./routes.js";
|
||||||
|
import type { Database } from "./repository.js";
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
sql: string;
|
||||||
|
params: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化空格,便于对 SQL 文本做稳定断言(不改变语义)。 */
|
||||||
|
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const db: Database = {
|
||||||
|
async query(sql: string, params?: unknown[]) {
|
||||||
|
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||||
|
return [resultSets.shift() ?? [], []] as never;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { db, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN: AuthUser = {
|
||||||
|
userId: "u-admin",
|
||||||
|
userName: "管理员",
|
||||||
|
loginName: "admin",
|
||||||
|
depCode: "",
|
||||||
|
depName: "",
|
||||||
|
permissionLevel: "full",
|
||||||
|
roles: ["BI-ADMIN-FEEDBACK"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NORMAL: AuthUser = {
|
||||||
|
userId: "u-1",
|
||||||
|
userName: "普通用户",
|
||||||
|
loginName: "user1",
|
||||||
|
depCode: "",
|
||||||
|
depName: "",
|
||||||
|
permissionLevel: "personal",
|
||||||
|
roles: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(user: AuthUser | undefined, deps: FeedbackDependencies): Hono<{ Variables: { user: AuthUser } }> {
|
||||||
|
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
if (user) c.set("user", user);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
registerFeedbackRoutes(app as never, deps);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("提交反馈:INSERT 语句与参数顺序保持不变", async () => {
|
||||||
|
const { db, calls } = createMockDb([{ insertId: 42 }]);
|
||||||
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const res = await app.request("/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "bug",
|
||||||
|
module: "mileage",
|
||||||
|
content: " 车牌筛选失效 ",
|
||||||
|
contact: "13800000000",
|
||||||
|
screenshots: ["https://oss.example.com/a.png", "javascript:alert(1)", "not-a-url"],
|
||||||
|
userAgent: "jest",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), { ok: true, id: 42 });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR))",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, [
|
||||||
|
"bug",
|
||||||
|
"mileage",
|
||||||
|
"车牌筛选失效",
|
||||||
|
"13800000000",
|
||||||
|
JSON.stringify(["https://oss.example.com/a.png"]),
|
||||||
|
"u-1",
|
||||||
|
"普通用户",
|
||||||
|
"jest",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("提交反馈:类型与内容长度校验拦截", async () => {
|
||||||
|
const { db, calls } = createMockDb();
|
||||||
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const badType = await app.request("/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "nope", content: "x" }),
|
||||||
|
});
|
||||||
|
assert.equal(badType.status, 400);
|
||||||
|
|
||||||
|
const empty = await app.request("/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "bug", content: " " }),
|
||||||
|
});
|
||||||
|
assert.equal(empty.status, 400);
|
||||||
|
assert.equal(calls.length, 0, "校验失败不应触达数据库");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("我的反馈:列集合不含 user_id/user_name,按当前用户过滤", async () => {
|
||||||
|
const { db, calls } = createMockDb([[]]);
|
||||||
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const res = await app.request("/mine");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"SELECT id, type, module, content, contact, screenshots, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, ["u-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("我的反馈:无登录用户时直接返回空列表且不查库", async () => {
|
||||||
|
const { db, calls } = createMockDb();
|
||||||
|
const app = makeApp(undefined, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const res = await app.request("/mine");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), { items: [] });
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("管理列表:无管理角色返回 403 且不查库", async () => {
|
||||||
|
const { db, calls } = createMockDb();
|
||||||
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const res = await app.request("/list");
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("管理列表:列集合含 user_id/user_name,状态白名单与条数上限生效", async () => {
|
||||||
|
const { db, calls } = createMockDb([[]]);
|
||||||
|
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
await app.request("/list?status=done&limit=9999");
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 AND status = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, ["done", 500], "limit 上限为 500");
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
await app.request("/list?status=not-a-status&limit=abc");
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 ORDER BY created_at DESC LIMIT ?",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, [100], "非法状态被丢弃,limit 回退默认值");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("管理更新:状态与回复的字段顺序与参数顺序保持不变", async () => {
|
||||||
|
const { db, calls } = createMockDb([{ affectedRows: 1 }]);
|
||||||
|
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const res = await app.request("/7", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: "done", reply: " 已修复 " }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].sql,
|
||||||
|
"UPDATE bi_user_feedback SET status = ?, reply_content = ?, reply_user = ?, reply_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR) WHERE id = ?",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].params, ["done", "已修复", "管理员", 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("管理更新:无可用字段或非法 id 返回 400", async () => {
|
||||||
|
const { db, calls } = createMockDb();
|
||||||
|
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
||||||
|
|
||||||
|
const none = await app.request("/7", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.equal(none.status, 400);
|
||||||
|
|
||||||
|
const badId = await app.request("/0", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: "done" }),
|
||||||
|
});
|
||||||
|
assert.equal(badId.status, 400);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("分层:路由文件不再直接书写 SQL", () => {
|
||||||
|
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
|
||||||
|
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
|
||||||
|
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
|
||||||
|
});
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
|
||||||
import pool from '../../db/mysql.js';
|
import pool from '../../db/mysql.js';
|
||||||
import { ensureFeedbackTable } from '../../db/schema/feedback.js';
|
import { ensureFeedbackTable } from '../../db/schema/feedback.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canManageFeedback } from '../../auth/types.js';
|
import { canManageFeedback } from '../../auth/types.js';
|
||||||
import { uploadFeedbackImage } from './oss.js';
|
import { uploadFeedbackImage } from './oss.js';
|
||||||
|
import {
|
||||||
const app = new Hono();
|
insertFeedback,
|
||||||
|
listFeedbackForAdmin,
|
||||||
|
listFeedbackForUser,
|
||||||
|
updateFeedback,
|
||||||
|
type Database,
|
||||||
|
} from './repository.js';
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||||
@@ -15,11 +19,25 @@ const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
|||||||
|
|
||||||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||||
|
|
||||||
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
|
export interface FeedbackDependencies {
|
||||||
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
db: Database;
|
||||||
|
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
|
||||||
|
ensureTable: () => Promise<void>;
|
||||||
|
/** 截图上传实现,测试可注入。 */
|
||||||
|
uploadImage?: typeof uploadFeedbackImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined {
|
||||||
|
return c.get?.('user') as AuthUser | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void {
|
||||||
|
const { db, ensureTable } = deps;
|
||||||
|
const uploadImage = deps.uploadImage ?? uploadFeedbackImage;
|
||||||
|
|
||||||
|
// POST /api/feedback/submit — 提交反馈
|
||||||
app.post('/submit', async (c) => {
|
app.post('/submit', async (c) => {
|
||||||
await ensureFeedbackTable();
|
await ensureTable();
|
||||||
const body = await c.req.json().catch(() => ({})) as {
|
const body = await c.req.json().catch(() => ({})) as {
|
||||||
type?: string; module?: string | null; content?: string;
|
type?: string; module?: string | null; content?: string;
|
||||||
contact?: string | null; userAgent?: string; screenshots?: string[];
|
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||||||
@@ -33,7 +51,7 @@ app.post('/submit', async (c) => {
|
|||||||
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = currentUser(c);
|
||||||
const moduleVal = (body.module || '').slice(0, 64) || null;
|
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||||||
const contact = (body.contact || '').slice(0, 200) || null;
|
const contact = (body.contact || '').slice(0, 200) || null;
|
||||||
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||||||
@@ -41,17 +59,20 @@ app.post('/submit', async (c) => {
|
|||||||
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const [r] = await pool.query<ResultSetHeader>(
|
const id = await insertFeedback(db, {
|
||||||
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
type,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
module: moduleVal,
|
||||||
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
content,
|
||||||
);
|
contact,
|
||||||
return c.json({ ok: true, id: r.insertId });
|
screenshotsJson: JSON.stringify(screenshots),
|
||||||
|
userId: user?.userId || null,
|
||||||
|
userName: user?.userName || null,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return c.json({ ok: true, id });
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||||||
// =========================================================
|
|
||||||
app.post('/upload', async (c) => {
|
app.post('/upload', async (c) => {
|
||||||
const form = await c.req.formData();
|
const form = await c.req.formData();
|
||||||
const file = form.get('file');
|
const file = form.get('file');
|
||||||
@@ -67,7 +88,7 @@ app.post('/upload', async (c) => {
|
|||||||
}
|
}
|
||||||
const buf = Buffer.from(await file.arrayBuffer());
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
try {
|
try {
|
||||||
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
|
const url = await uploadImage(file.name || 'screenshot.png', buf, mime);
|
||||||
return c.json({ ok: true, url });
|
return c.json({ ok: true, url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('feedback upload error:', e);
|
console.error('feedback upload error:', e);
|
||||||
@@ -77,75 +98,61 @@ app.post('/upload', async (c) => {
|
|||||||
|
|
||||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||||
app.get('/mine', async (c) => {
|
app.get('/mine', async (c) => {
|
||||||
await ensureFeedbackTable();
|
await ensureTable();
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = currentUser(c);
|
||||||
if (!user?.userId) return c.json({ items: [] });
|
if (!user?.userId) return c.json({ items: [] });
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
return c.json({ items: await listFeedbackForUser(db, user.userId) });
|
||||||
`SELECT id, type, module, content, contact, screenshots, status,
|
|
||||||
reply_content, reply_user, reply_at, created_at
|
|
||||||
FROM bi_user_feedback
|
|
||||||
WHERE user_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 100`,
|
|
||||||
[user.userId],
|
|
||||||
);
|
|
||||||
return c.json({ items: rows });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||||
app.get('/list', async (c) => {
|
app.get('/list', async (c) => {
|
||||||
await ensureFeedbackTable();
|
await ensureTable();
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = currentUser(c);
|
||||||
if (!canManageFeedback(user?.roles)) {
|
if (!canManageFeedback(user?.roles)) {
|
||||||
return c.json({ ok: false, message: '无权限' }, 403);
|
return c.json({ ok: false, message: '无权限' }, 403);
|
||||||
}
|
}
|
||||||
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||||||
const status = c.req.query('status') || '';
|
const rawStatus = c.req.query('status') || '';
|
||||||
const where: string[] = ['1=1'];
|
const status = VALID_STATUS.has(rawStatus) ? rawStatus : null;
|
||||||
const params: (string | number)[] = [];
|
return c.json({ items: await listFeedbackForAdmin(db, { status, limit }) });
|
||||||
if (VALID_STATUS.has(status)) {
|
|
||||||
where.push('status = ?');
|
|
||||||
params.push(status);
|
|
||||||
}
|
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
|
||||||
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
|
||||||
reply_content, reply_user, reply_at, created_at
|
|
||||||
FROM bi_user_feedback
|
|
||||||
WHERE ${where.join(' AND ')}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?`,
|
|
||||||
[...params, limit],
|
|
||||||
);
|
|
||||||
return c.json({ items: rows });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||||
app.patch('/:id', async (c) => {
|
app.patch('/:id', async (c) => {
|
||||||
await ensureFeedbackTable();
|
await ensureTable();
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = currentUser(c);
|
||||||
if (!canManageFeedback(user?.roles)) {
|
if (!canManageFeedback(user?.roles)) {
|
||||||
return c.json({ ok: false, message: '无权限' }, 403);
|
return c.json({ ok: false, message: '无权限' }, 403);
|
||||||
}
|
}
|
||||||
const id = Number(c.req.param('id'));
|
const id = Number(c.req.param('id'));
|
||||||
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||||||
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||||||
const fields: string[] = [];
|
|
||||||
const params: (string | number | null)[] = [];
|
let status: string | undefined;
|
||||||
if (body.status) {
|
if (body.status) {
|
||||||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||||||
fields.push('status = ?');
|
status = body.status;
|
||||||
params.push(body.status);
|
|
||||||
}
|
}
|
||||||
if (typeof body.reply === 'string') {
|
const reply = typeof body.reply === 'string'
|
||||||
const reply = body.reply.trim().slice(0, 2000);
|
? body.reply.trim().slice(0, 2000)
|
||||||
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
: undefined;
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
|
||||||
params.push(reply || null, user?.userName || user?.userId || null);
|
const updated = await updateFeedback(db, id, {
|
||||||
}
|
status,
|
||||||
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
reply,
|
||||||
params.push(id);
|
replyUser: user?.userName || user?.userId || null,
|
||||||
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
});
|
||||||
|
if (!updated) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生产用路由器:绑定真实连接池与建表函数。 */
|
||||||
|
export function createFeedbackRouter(): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
registerFeedbackRoutes(app, { db: pool, ensureTable: ensureFeedbackTable });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createFeedbackRouter();
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
Reference in New Issue
Block a user