diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c8e675..ec32f68 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -91,12 +91,17 @@ server/ | 域 | 文件 | 说明 | | --- | --- | --- | -| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | 唯一已完整分层到 repository 的域,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` | 路由 / 模型 / 服务已分开 | -| `energy/` | `index.ts`(聚合)+ `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` `electric.ts` `etc.ts` + `query-model.ts` `cache.ts` `constants.ts` | 路由 / 模型已分开,SQL 仍在路由内 | +| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | ✅ 完整分层;SQL 有契约测试 | +| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.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` | 同上 | -| `hydrogen-heatmap/`、`vehicle-heatmap/` | `routes.ts` + `model.ts` | 路由 / 模型已分开 | -| `ele/`、`feedback/` | `routes.ts`(+ `oss.ts`) | **尚未拆出 repository**,SQL 与处理器在同一文件 | +| `hydrogen-heatmap/`、`vehicle-heatmap/` | `routes.ts` + `model.ts` | 纯模型已抽出,**SQL 仍在 `routes.ts`** | + +已完整分层的三个域(`vehicles` / `ele` / `feedback`)由架构测试守护:`routes.ts` 不得出现 SQL, +且必须存在 `repository.ts`。其余域尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, +并建议先按 `ele/routes.test.ts` 的方式补契约测试再动。 ### 中间件顺序(在 `app.ts` 中显式体现) @@ -132,9 +137,10 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域 诚实记录,避免后来者以为已经做完: -- **后端仍有两个域没拆出 repository**:`ele/routes.ts` 与 `feedback/routes.ts` 仍把 SQL - 与处理器放在同一文件(形状见上表)。拆分时**不要改变 SQL 与参数顺序**——这两个域目前 - 没有 SQL 指纹测试,建议先补契约测试再动。 +- **后端仍有域没拆出 repository**:`mileage` / `energy` / `scheduling` / 两个热力图的 SQL 仍在 + 各自的 `routes.ts`(或平级模块)里(形状见上表)。`vehicles` / `ele` / `feedback` 已完成, + 可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,纯逻辑进 `model.ts`, + 并用 mock pool 的契约测试锁定 SQL 与参数。 - **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 diff --git a/src/architecture.test.ts b/src/architecture.test.ts index 7a05d07..e794cc8 100644 --- a/src/architecture.test.ts +++ b/src/architecture.test.ts @@ -1,5 +1,5 @@ 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 path from "node:path"; import test from "node:test"; @@ -163,3 +163,18 @@ test("运行时建表必须尊重只读模式", () => { .map(rel); 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"); +}); diff --git a/src/server/routes/ele/model.test.ts b/src/server/routes/ele/model.test.ts new file mode 100644 index 0000000..2db8f1e --- /dev/null +++ b/src/server/routes/ele/model.test.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 }); +}); diff --git a/src/server/routes/ele/model.ts b/src/server/routes/ele/model.ts new file mode 100644 index 0000000..0cc932d --- /dev/null +++ b/src/server/routes/ele/model.ts @@ -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; + 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(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 = {}; + 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(); + 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, + 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, +): { 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 }; +} diff --git a/src/server/routes/ele/repository.ts b/src/server/routes/ele/repository.ts new file mode 100644 index 0000000..ece5d02 --- /dev/null +++ b/src/server/routes/ele/repository.ts @@ -0,0 +1,120 @@ +import type { ResultSetHeader, RowDataPacket } from 'mysql2'; + +/** + * 电能充电记录表的全部 SQL 出口。 + * 语句文本与参数顺序由 routes.test.ts 的契约测试锁定。 + */ + +/** 只需要 query 能力的最小依赖,便于测试注入 mock。 */ +export interface Database { + query(sql: string, values?: unknown[]): Promise<[T, ...any[]]>; +} + +/** 按车牌查系统车辆 id(用于区分内部/外部车辆)。 */ +export async function findTruckIdsByPlates( + db: Database, + plates: string[], +): Promise> { + if (plates.length === 0) return new Map(); + const placeholders = plates.map(() => '?').join(','); + const [rows] = await db.query( + `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(); + 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 { + const [result] = await db.query( + `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 { + const [rows] = await db.query( + `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 { + const [rows] = await db.query( + `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 { + const [rows] = await db.query( + `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 { + const [rows] = await db.query( + `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 { + const [rows] = await db.query( + `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; +} diff --git a/src/server/routes/ele/routes.test.ts b/src/server/routes/ele/routes.test.ts new file mode 100644 index 0000000..ee8079d --- /dev/null +++ b/src/server/routes/ele/routes.test.ts @@ -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; + 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,路由只做校验与组装"); +}); diff --git a/src/server/routes/ele/routes.ts b/src/server/routes/ele/routes.ts index 45fc186..cd47ae9 100644 --- a/src/server/routes/ele/routes.ts +++ b/src/server/routes/ele/routes.ts @@ -1,367 +1,139 @@ import { Hono } from 'hono'; -import type { RowDataPacket, ResultSetHeader } from 'mysql2'; -import * as XLSX from 'xlsx'; import pool from '../../db/mysql.js'; import type { AuthUser } from '../../auth/types.js'; import { canAccessEnergy } from '../../auth/types.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(); - -// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端 -// 独立鉴权(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); +export interface EleDependencies { + db: Database; + /** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */ + ensureTable: () => Promise; + /** xlsx 解析,测试可注入以避免构造真实文件。 */ + parse?: (buf: ArrayBuffer) => ParsedRow[]; } -function safeNum(v: unknown): number | null { - if (v == null || v === '') return null; - const n = Number(v); - return Number.isFinite(n) ? n : null; -} +export function registerEleRoutes(app: Hono, deps: EleDependencies): void { + const { db, ensureTable } = deps; + const parse = deps.parse ?? parseSheet; -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 }; + // 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端 + // 独立鉴权(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 null; -} + return next(); + }); -interface ParsedRow { - orderNo: string; - raw: Record; - 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; - }; -} + // POST /api/ele/import — 上传 xlsx 文件 + app.post('/import', async (c) => { + await ensureTable(); + 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 = parse(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); + } -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(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 = {}; - 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)]), - }, + const { records, fileDuplicates } = dedupeByOrderNo(parsed); + + // 系统车辆匹配 + const allPlates = new Set(); + 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 findTruckIdsByPlates(db, Array.from(allPlates)); + + const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const importedAt = new Date(); + + const inserted = await insertChargeRecords(db, toInsertValues(records, plateMap, batchId, importedAt)); + const dbDuplicates = records.length - inserted; + const breakdown = countVehicleKinds(records, plateMap); + + return c.json({ + ok: true, + filename, + batchId, + parsed: parsed.length, + fileDuplicates, + inserted, + dbDuplicates, + breakdown, }); - } - return result; -} - -async function buildPlateLookup(plates: Set): Promise> { - if (plates.size === 0) return new Map(); - const arr = Array.from(plates); - const placeholders = arr.map(() => '?').join(','); - const [rows] = await pool.query( - `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(); - 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(); - 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(); - 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(sql, [values]); - const inserted = result.affectedRows; - const dbDuplicates = records.length - inserted; + // GET /api/ele/list — 分页列表(最新优先) + app.get('/list', async (c) => { + await ensureTable(); + 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 filter = buildListFilter({ + kind: c.req.query('kind'), + batchId: c.req.query('batchId'), + search: c.req.query('search'), + }); - // 统计内/外(无车牌也算外部) - 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 }, + const offset = (page - 1) * limit; + const items = await listChargeRecords(db, { ...filter, limit, offset }); + const total = await countChargeRecords(db, filter); + return c.json({ items, total, page, limit, totalPages: Math.ceil(total / limit) }); }); -}); -// ========================================================= -// 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') || ''; + // GET /api/ele/batches — 批次列表 + app.get('/batches', async (c) => { + await ensureTable(); + return c.json({ items: await listBatches(db) }); + }); - 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); - } + // GET /api/ele/aggregate — 聚合统计 + app.get('/aggregate', async (c) => { + await ensureTable(); + const [overall, daily] = await Promise.all([ + aggregateByVehicleKind(db), + aggregateDaily30d(db), + ]); + return c.json({ overall, daily }); + }); +} - const offset = (page - 1) * limit; - const [rows] = await pool.query( - `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( - `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( - `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( - `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( - `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; diff --git a/src/server/routes/feedback/repository.ts b/src/server/routes/feedback/repository.ts new file mode 100644 index 0000000..375389d --- /dev/null +++ b/src/server/routes/feedback/repository.ts @@ -0,0 +1,104 @@ +import type { ResultSetHeader, RowDataPacket } from 'mysql2'; + +/** + * 反馈表的全部 SQL 出口。 + * + * SQL 与参数顺序在此固定:路由只做校验与组装,不再拼 SQL。 + * 接口由 mock pool 的契约测试锁定(见 routes.test.ts)。 + */ + +/** 只需要 query 能力的最小依赖,便于在测试中注入 mock。 */ +export interface Database { + query(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 { + const [result] = await db.query( + `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 { + const [rows] = await db.query( + `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 { + const where: string[] = ['1=1']; + const params: (string | number)[] = []; + if (options.status) { + where.push('status = ?'); + params.push(options.status); + } + const [rows] = await db.query( + `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 { + 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; +} diff --git a/src/server/routes/feedback/routes.test.ts b/src/server/routes/feedback/routes.test.ts new file mode 100644 index 0000000..a8e45d0 --- /dev/null +++ b/src/server/routes/feedback/routes.test.ts @@ -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,路由只做校验与组装"); +}); diff --git a/src/server/routes/feedback/routes.ts b/src/server/routes/feedback/routes.ts index dd2f454..48f95ab 100644 --- a/src/server/routes/feedback/routes.ts +++ b/src/server/routes/feedback/routes.ts @@ -1,12 +1,16 @@ import { Hono } from 'hono'; -import type { ResultSetHeader, RowDataPacket } from 'mysql2'; import pool from '../../db/mysql.js'; import { ensureFeedbackTable } from '../../db/schema/feedback.js'; import type { AuthUser } from '../../auth/types.js'; import { canManageFeedback } from '../../auth/types.js'; import { uploadFeedbackImage } from './oss.js'; - -const app = new Hono(); +import { + insertFeedback, + listFeedbackForAdmin, + listFeedbackForUser, + updateFeedback, + type Database, +} from './repository.js'; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); @@ -15,137 +19,140 @@ const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']); const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']); -// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置 -const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`; +export interface FeedbackDependencies { + db: Database; + /** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */ + ensureTable: () => Promise; + /** 截图上传实现,测试可注入。 */ + uploadImage?: typeof uploadFeedbackImage; +} -app.post('/submit', async (c) => { - await ensureFeedbackTable(); - const body = await c.req.json().catch(() => ({})) as { - type?: string; module?: string | null; content?: string; - contact?: string | null; userAgent?: string; screenshots?: string[]; - }; - const type = (body.type || '').trim(); - const content = (body.content || '').trim(); - if (!VALID_TYPES.has(type)) { - return c.json({ ok: false, message: '类型不合法' }, 400); - } - if (!content || content.length > 2000) { - return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400); - } +function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined { + return c.get?.('user') as AuthUser | undefined; +} - const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; - const moduleVal = (body.module || '').slice(0, 64) || null; - const contact = (body.contact || '').slice(0, 200) || null; - const userAgent = (body.userAgent || '').slice(0, 512) || null; - const screenshots = Array.isArray(body.screenshots) - ? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6) - : []; +export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void { + const { db, ensureTable } = deps; + const uploadImage = deps.uploadImage ?? uploadFeedbackImage; - const [r] = await pool.query( - `INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`, - [type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent], - ); - return c.json({ ok: true, id: r.insertId }); -}); + // POST /api/feedback/submit — 提交反馈 + app.post('/submit', async (c) => { + await ensureTable(); + const body = await c.req.json().catch(() => ({})) as { + type?: string; module?: string | null; content?: string; + contact?: string | null; userAgent?: string; screenshots?: string[]; + }; + const type = (body.type || '').trim(); + const content = (body.content || '').trim(); + if (!VALID_TYPES.has(type)) { + return c.json({ ok: false, message: '类型不合法' }, 400); + } + if (!content || content.length > 2000) { + return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400); + } -// ========================================================= -// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file) -// ========================================================= -app.post('/upload', async (c) => { - const form = await c.req.formData(); - const file = form.get('file'); - if (!(file instanceof File)) { - return c.json({ ok: false, message: '未上传文件' }, 400); - } - const mime = file.type || 'image/png'; - if (!ALLOWED_MIME.has(mime)) { - return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400); - } - if (file.size > MAX_IMAGE_SIZE) { - return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400); - } - const buf = Buffer.from(await file.arrayBuffer()); - try { - const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime); - return c.json({ ok: true, url }); - } catch (e) { - console.error('feedback upload error:', e); - return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500); - } -}); + const user = currentUser(c); + const moduleVal = (body.module || '').slice(0, 64) || null; + const contact = (body.contact || '').slice(0, 200) || null; + const userAgent = (body.userAgent || '').slice(0, 512) || null; + const screenshots = Array.isArray(body.screenshots) + ? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6) + : []; -// GET /api/feedback/mine — 当前用户的反馈历史 -app.get('/mine', async (c) => { - await ensureFeedbackTable(); - const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; - if (!user?.userId) return c.json({ items: [] }); - const [rows] = await pool.query( - `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 }); -}); + const id = await insertFeedback(db, { + type, + module: moduleVal, + content, + contact, + screenshotsJson: JSON.stringify(screenshots), + userId: user?.userId || null, + userName: user?.userName || null, + userAgent, + }); + return c.json({ ok: true, id }); + }); -// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限) -app.get('/list', async (c) => { - await ensureFeedbackTable(); - const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; - if (!canManageFeedback(user?.roles)) { - return c.json({ ok: false, message: '无权限' }, 403); - } - const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100)); - const status = c.req.query('status') || ''; - const where: string[] = ['1=1']; - const params: (string | number)[] = []; - if (VALID_STATUS.has(status)) { - where.push('status = ?'); - params.push(status); - } - const [rows] = await pool.query( - `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 }); -}); + // POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file) + app.post('/upload', async (c) => { + const form = await c.req.formData(); + const file = form.get('file'); + if (!(file instanceof File)) { + return c.json({ ok: false, message: '未上传文件' }, 400); + } + const mime = file.type || 'image/png'; + if (!ALLOWED_MIME.has(mime)) { + return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400); + } + if (file.size > MAX_IMAGE_SIZE) { + return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400); + } + const buf = Buffer.from(await file.arrayBuffer()); + try { + const url = await uploadImage(file.name || 'screenshot.png', buf, mime); + return c.json({ ok: true, url }); + } catch (e) { + console.error('feedback upload error:', e); + return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500); + } + }); -// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限) -app.patch('/:id', async (c) => { - await ensureFeedbackTable(); - const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; - if (!canManageFeedback(user?.roles)) { - return c.json({ ok: false, message: '无权限' }, 403); - } - const id = Number(c.req.param('id')); - 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 fields: string[] = []; - const params: (string | number | null)[] = []; - if (body.status) { - if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400); - fields.push('status = ?'); - params.push(body.status); - } - if (typeof body.reply === 'string') { - const reply = body.reply.trim().slice(0, 2000); - fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`); - const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; - params.push(reply || null, user?.userName || user?.userId || null); - } - if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400); - params.push(id); - await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params); - return c.json({ ok: true }); -}); + // GET /api/feedback/mine — 当前用户的反馈历史 + app.get('/mine', async (c) => { + await ensureTable(); + const user = currentUser(c); + if (!user?.userId) return c.json({ items: [] }); + return c.json({ items: await listFeedbackForUser(db, user.userId) }); + }); + // GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限) + app.get('/list', async (c) => { + await ensureTable(); + const user = currentUser(c); + if (!canManageFeedback(user?.roles)) { + return c.json({ ok: false, message: '无权限' }, 403); + } + const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100)); + const rawStatus = c.req.query('status') || ''; + const status = VALID_STATUS.has(rawStatus) ? rawStatus : null; + return c.json({ items: await listFeedbackForAdmin(db, { status, limit }) }); + }); + + // PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限) + app.patch('/:id', async (c) => { + await ensureTable(); + const user = currentUser(c); + if (!canManageFeedback(user?.roles)) { + return c.json({ ok: false, message: '无权限' }, 403); + } + const id = Number(c.req.param('id')); + 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 }; + + let status: string | undefined; + if (body.status) { + if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400); + status = body.status; + } + const reply = typeof body.reply === 'string' + ? body.reply.trim().slice(0, 2000) + : undefined; + + const updated = await updateFeedback(db, id, { + status, + reply, + replyUser: user?.userName || user?.userId || null, + }); + if (!updated) return c.json({ ok: false, message: '没有可更新的字段' }, 400); + 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;