refactor(stage12): 调度域拆出 repository,跨域数据源改为显式注入

改动
- 新增 scheduling/repository.ts:通知/干预与建议列表的全部 SQL(13 条)。
- notify.ts / suggestions.ts 改为 register*(app, deps) 可注入,保留默认导出与 create*Router 工厂。
- suggestions.ts 的两处跨域依赖(里程车辆信息、OneOS 里程)改为依赖注入:
  既让本域可独立测试,也让"建议依赖里程数据"在类型上可见,而不是藏在 import 里。
- 跨域引用修正:mapRegion 原本从 '../vehicles/routes.js' 引入(为一个纯函数把整个
  车辆路由拖进依赖图),改为直接从 '../vehicles/model.js' 引入。

契约测试(新增 10 个用例)
- 逐条锁定 SQL 与参数顺序:干预登记的三步(查重 → INSERT → 回读)、409 阻断、
  批量循环、历史列表的状态过滤与 limit 上限(500/默认 200)、状态更新的 UPDATE 形状、
  400/404 分支、活跃映射与近 7 天计数、建议列表五条基础查询的内容与顺序。
- 架构守护升级:不再只看 routes.ts,而是要求该域**除 repository.ts 外的任何非测试文件
  都不得含 SQL**;匹配用"语句形状"正则(如 update <table> set)而非裸关键字,
  避免把 UpdateNotification 或日志 "update error" 误判。已验证 6/6 个 repository 被识别、
  其余文件零误判。

等价性验证
- 把 notify.ts / suggestions.ts 改造前的实现从 git 取出,与改造后跑同一批请求
  (9 个场景,含建议列表整条链路 8 次查询),对比落库 SQL、参数、HTTP 状态与响应体:
  完全一致。
- 期间修正了两处**验证工具自身**的缺陷(旧文件误引用新 notify;跨域默认实现的调用
  被记到另一侧),修正后结论可信。

lint / test(191) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:44:52 +08:00
parent 523f7b312d
commit 5196db5df7
6 changed files with 656 additions and 257 deletions
+9 -8
View File
@@ -96,13 +96,13 @@ server/
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`+ `routes.test.ts` | ✅ 完整分层;同上 | | `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 仍在各路由文件内** | | `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 仍在路由内** | | `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` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 |
| `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`+ `routes.test.ts` | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 | | `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`+ `routes.test.ts` | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 |
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 | | `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
已完整分层的个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` 已完整分层的个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling`
由架构测试守护:`routes.ts` 不得出现 SQL,且必须存在 `repository.ts` 由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**
其余域(`energy` / `mileage` / `scheduling`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**, 其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。 请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
@@ -139,10 +139,11 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
诚实记录,避免后来者以为已经做完: 诚实记录,避免后来者以为已经做完:
- **后端仍有 3 个域没拆出 repository**`energy` / `mileage` / `scheduling` 的 SQL 仍在各自的 - **后端仍有 2 个域没拆出 repository**`energy` / `mileage` 的 SQL 仍在各自的路由文件里
路由文件里(形状见上表)。已完成的有 5 个域,可作为模板:路由只做校验与组装, (形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`
SQL 进 `repository.ts`纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。 纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
注意 `vehicle-heatmap` 的 SQL 是小写、`hydrogen-heatmap` 是大小写混排,正则检查要忽略大小写。 守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, - **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
+22 -6
View File
@@ -48,6 +48,11 @@ function targetPath(file: string, spec: string): string | null {
return path.relative(srcDir, path.resolve(path.dirname(file), spec)); return path.relative(srcDir, path.resolve(path.dirname(file), spec));
} }
/** 去掉注释后再做 SQL 关键字匹配:注释里的 "update status" 之类不应触发守卫。 */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
}
function filesUnder(prefix: string): string[] { function filesUnder(prefix: string): string[] {
return allFiles.filter((f) => rel(f).startsWith(prefix)); return allFiles.filter((f) => rel(f).startsWith(prefix));
} }
@@ -164,7 +169,7 @@ test("运行时建表必须尊重只读模式", () => {
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL"); assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
}); });
test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => { test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => {
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里 // 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。 // (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
const layered = [ const layered = [
@@ -173,14 +178,25 @@ test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", (
"server/routes/feedback", "server/routes/feedback",
"server/routes/vehicle-heatmap", "server/routes/vehicle-heatmap",
"server/routes/hydrogen-heatmap", "server/routes/hydrogen-heatmap",
"server/routes/scheduling",
]; ];
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
// 不应误判,而小写 SQL 也依然能被抓到。
const SQL = /\bselect\b[\s\S]{0,400}?\bfrom\b|\binsert\s+(into|ignore)\b|\bupdate\s+[\w.`]+\s+set\b|\bdelete\s+from\b|\bcreate\s+table\b|\balter\s+table\b/i;
const offenders: string[] = []; const offenders: string[] = [];
for (const dir of layered) { for (const dir of layered) {
if (!existsSync(path.join(srcDir, dir, "repository.ts"))) offenders.push(`${dir}/repository.ts 缺失`); const abs = path.join(srcDir, dir);
const routes = path.join(srcDir, dir, "routes.ts"); if (!existsSync(path.join(abs, "repository.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}/repository.ts 缺失`);
offenders.push(`${dir}/routes.ts 仍含 SQL`); continue;
}
for (const file of readdirSync(abs)) {
if (!file.endsWith(".ts")) continue;
if (file === "repository.ts" || file.endsWith(".test.ts")) continue;
if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) {
offenders.push(`${dir}/${file} 仍含 SQL`);
}
} }
} }
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts"); assert.deepEqual(offenders, [], "SQL 必须留在各域的 repository.ts");
}); });
+166 -203
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import pool from '../../db/mysql.js'; import mysqlPool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import type { import type {
NotifyRequest, NotifyRequest,
@@ -18,233 +18,196 @@ import {
type ActiveNotificationRow, type ActiveNotificationRow,
type NotificationDbRow, type NotificationDbRow,
} from './notification-model.js'; } from './notification-model.js';
import {
countRecentInterventions,
findBlockingNotification,
findNotificationById,
insertNotification,
listActiveNotifications,
listNotifications,
updateNotification,
type Database,
} from './repository.js';
const app = new Hono(); export interface NotifyDependencies {
db: Database;
}
/** /** 近 7 天未取消的干预条数(供建议列表使用)。 */
* Count non-cancelled interventions created within the last 7 days. export async function fetchRecentInterventionCount(db: Database): Promise<number> {
*/ return countRecentInterventions(db);
export async function fetchRecentInterventionCount(): Promise<number> {
const [rows] = (await pool.execute(
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
WHERE status != 'cancelled'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
)) as [any[], unknown];
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
} }
/** /**
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs. * 当前可见 (suggestion, candidate) 组合的通知状态映射。
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification. * Key: `${suggestionId}::${candidatePlate}` → 最近一条未取消通知。
*/ */
export async function fetchActiveNotificationMap(): Promise< export async function fetchActiveNotificationMap(
Map<string, { id: number; status: NotificationStatus }> db: Database,
> { ): Promise<Map<string, { id: number; status: NotificationStatus }>> {
const [rows] = (await pool.execute( const rows = await listActiveNotifications(db);
`SELECT id, suggestion_id, candidate_plate, status, created_at return buildActiveNotificationMap(rows as unknown as ActiveNotificationRow[]);
FROM tab_scheduling_notifications
WHERE status != 'cancelled'
ORDER BY created_at DESC`,
)) as [any[], unknown];
return buildActiveNotificationMap(rows as ActiveNotificationRow[]);
} }
async function insertNotification( export function registerNotifyRoutes(app: Hono, deps: NotifyDependencies): void {
req: NotifyRequest, const { db } = deps;
operator: { id: string | null; name: string | null },
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
// active intervention at a time. Any non-cancelled record for the same
// suggestion_id blocks further interventions until it is cancelled.
const [existing] = (await pool.execute(
`SELECT id, candidate_plate FROM tab_scheduling_notifications
WHERE suggestion_id = ? AND status != 'cancelled'
LIMIT 1`,
[req.suggestionId],
)) as [any[], unknown];
if (existing.length > 0) { /**
return { skipped: true, existingPlate: existing[0].candidate_plate as string }; * 登记一条干预。
} * 业务规则:同一 suggestion 同一时刻最多一条生效干预,已有未取消记录则跳过。
*/
const [result] = (await pool.execute( async function insertOne(
`INSERT INTO tab_scheduling_notifications req: NotifyRequest,
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) operator: { id: string | null; name: string | null },
VALUES (?, ?, ?, ?, ?, 'sent')`, ): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name], const existing = await findBlockingNotification(db, req.suggestionId);
)) as [any, unknown]; if (existing.length > 0) {
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
const insertedId = Number(result.insertId);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[insertedId],
)) as [any[], unknown];
return rowToNotificationRecord(rows[0] as NotificationDbRow);
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// POST /api/scheduling/notify — single notify
app.post('/', async (c) => {
try {
const body = await c.req.json<NotifyRequest>();
const { suggestionId, currentPlate, candidatePlate } = body;
if (!hasRequiredNotifyFields(body)) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
} }
const user = (c as any).get('user') as AuthUser | undefined; const insertedId = await insertNotification(db, {
const operator = { suggestionId: req.suggestionId,
id: user?.userId ?? null, currentPlate: req.currentPlate,
name: user?.userName ?? null, candidatePlate: req.candidatePlate,
}; operatorId: operator.id,
operatorName: operator.name,
});
const row = await findNotificationById(db, insertedId);
return rowToNotificationRecord(row as NotificationDbRow);
}
const result = await insertNotification(body, operator); // POST /api/scheduling/notify — single notify
if ('skipped' in result) { app.post('/', async (c) => {
return c.json( try {
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` }, const body = await c.req.json<NotifyRequest>();
409, const { suggestionId, currentPlate, candidatePlate } = body;
if (!hasRequiredNotifyFields(body)) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = { id: user?.userId ?? null, name: user?.userName ?? null };
const result = await insertOne(body, operator);
if ('skipped' in result) {
return c.json(
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
409,
);
}
console.log(
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
); );
return c.json({
success: true,
message: `干预已登记:${currentPlate}${candidatePlate}`,
record: result,
});
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '登记干预失败' }, 500);
} }
});
console.log( // POST /api/scheduling/notify/batch — bulk notify
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`, app.post('/batch', async (c) => {
); try {
const body = await c.req.json<NotifyBatchRequest>();
return c.json({ if (!Array.isArray(body.items) || body.items.length === 0) {
success: true, return c.json({ success: false, message: '缺少 items' }, 400);
message: `干预已登记:${currentPlate}${candidatePlate}`,
record: result,
});
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '登记干预失败' }, 500);
}
});
// POST /api/scheduling/notify/batch — bulk notify
app.post('/batch', async (c) => {
try {
const body = await c.req.json<NotifyBatchRequest>();
if (!Array.isArray(body.items) || body.items.length === 0) {
return c.json({ success: false, message: '缺少 items' }, 400);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
for (const item of body.items) {
if (!hasRequiredNotifyFields(item)) {
result.failed++;
continue;
} }
try {
const r = await insertNotification(item, operator); const user = (c as any).get('user') as AuthUser | undefined;
if ('skipped' in r) result.skipped++; const operator = { id: user?.userId ?? null, name: user?.userName ?? null };
else {
result.success++; const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
result.records.push(r); for (const item of body.items) {
if (!hasRequiredNotifyFields(item)) {
result.failed++;
continue;
}
try {
const r = await insertOne(item, operator);
if ('skipped' in r) result.skipped++;
else {
result.success++;
result.records.push(r);
}
} catch {
result.failed++;
} }
} catch {
result.failed++;
} }
console.log(
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
);
return c.json({
success: true,
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
result,
});
} catch (e: unknown) {
console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '批量干预失败' }, 500);
} }
});
console.log( // GET /api/scheduling/notify — list all notifications (history)
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`, app.get('/', async (c) => {
); try {
const rawStatus = c.req.query('status');
return c.json({ const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
success: true, const rows = await listNotifications(db, { status: rawStatus || null, limit });
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`, return c.json({ records: rows.map(rowToNotificationRecord) });
result, } catch (e: unknown) {
}); console.error('scheduling notifications list error:', e);
} catch (e: unknown) { return c.json({ records: [] }, 500);
console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '批量干预失败' }, 500);
}
});
// GET /api/scheduling/notify — list all notifications (history)
app.get('/', async (c) => {
try {
const status = c.req.query('status');
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
const where: string[] = [];
const params: (string | number)[] = [];
if (status) {
where.push('status = ?');
params.push(status);
} }
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; });
params.push(limit);
const [rows] = (await pool.query( // PATCH /api/scheduling/notify/:id — update status (execute / cancel)
`SELECT * FROM tab_scheduling_notifications app.patch('/:id', async (c) => {
${whereSql} try {
ORDER BY created_at DESC const id = Number(c.req.param('id'));
LIMIT ?`, if (!Number.isFinite(id) || id <= 0) {
params, return c.json({ success: false, message: 'id 无效' }, 400);
)) as [any[], unknown]; }
return c.json({ records: (rows as NotificationDbRow[]).map(rowToNotificationRecord) }); const body = await c.req.json<UpdateNotificationRequest>();
} catch (e: unknown) { if (!body.status) {
console.error('scheduling notifications list error:', e); return c.json({ success: false, message: '缺少 status' }, 400);
return c.json({ records: [] }, 500); }
}
});
// PATCH /api/scheduling/notify/:id — update status (execute / cancel) if (!isNotificationStatus(body.status)) {
app.patch('/:id', async (c) => { return c.json({ success: false, message: 'status 不合法' }, 400);
try { }
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) { const { fields, params } = buildNotificationUpdate(body, id);
return c.json({ success: false, message: 'id 无效' }, 400); await updateNotification(db, fields, params);
const row = await findNotificationById(db, id);
if (!row) {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({ success: true, record: rowToNotificationRecord(row) });
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
} }
});
}
const body = await c.req.json<UpdateNotificationRequest>(); /** 生产用路由器:绑定真实连接池。 */
if (!body.status) { export function createNotifyRouter(): Hono {
return c.json({ success: false, message: '缺少 status' }, 400); const app = new Hono();
} registerNotifyRoutes(app, { db: mysqlPool });
return app;
if (!isNotificationStatus(body.status)) { }
return c.json({ success: false, message: 'status 不合法' }, 400);
}
const { fields, params } = buildNotificationUpdate(body, id);
await pool.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
params,
);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[id],
)) as [any[], unknown];
if (rows.length === 0) {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({
success: true,
record: rowToNotificationRecord(rows[0] as NotificationDbRow),
});
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
}
});
const app = createNotifyRouter();
export default app; export default app;
+176
View File
@@ -0,0 +1,176 @@
import type { RowDataPacket } from 'mysql2';
import type { NotificationStatus } from './types.js';
import type { NotificationDbRow } from './notification-model.js';
/**
* 智能调度的数据访问。
*
* 通知/干预记录的全部 SQL 集中在此;字段映射与更新字段拼装仍在
* notification-model.ts(纯函数,已有单测)。SQL 文本与参数顺序由
* notify-routes.test.ts 的契约测试锁定。
*/
/** MySQL 连接:只声明用到的能力。 */
export interface Database {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
/** 近 7 天未取消的干预条数。 */
export async function countRecentInterventions(db: Database): Promise<number> {
const [rows] = (await db.execute(
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
WHERE status != 'cancelled'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
)) as [any[], unknown];
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
}
/** 全部未取消的通知(按创建时间倒序)。 */
export async function listActiveNotifications(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(
`SELECT id, suggestion_id, candidate_plate, status, created_at
FROM tab_scheduling_notifications
WHERE status != 'cancelled'
ORDER BY created_at DESC`,
)) as [RowDataPacket[], unknown];
return rows;
}
/**
* 同一 suggestion 是否已有生效干预(每个当前车辆同一时刻只允许一条)。
*/
export async function findBlockingNotification(
db: Database,
suggestionId: string,
): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(
`SELECT id, candidate_plate FROM tab_scheduling_notifications
WHERE suggestion_id = ? AND status != 'cancelled'
LIMIT 1`,
[suggestionId],
)) as [RowDataPacket[], unknown];
return rows;
}
export interface NewNotification {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
operatorId: string | null;
operatorName: string | null;
}
export async function insertNotification(db: Database, row: NewNotification): Promise<number> {
const [result] = (await db.execute(
`INSERT INTO tab_scheduling_notifications
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
VALUES (?, ?, ?, ?, ?, 'sent')`,
[row.suggestionId, row.currentPlate, row.candidatePlate, row.operatorId, row.operatorName],
)) as [any, unknown];
return Number(result.insertId);
}
export async function findNotificationById(db: Database, id: number): Promise<NotificationDbRow | undefined> {
const [rows] = (await db.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[id],
)) as [NotificationDbRow[], unknown];
return rows[0];
}
/** 历史列表:可选状态过滤 + 条数上限。 */
export async function listNotifications(
db: Database,
options: { status: string | null; limit: number },
): Promise<NotificationDbRow[]> {
const where: string[] = [];
const params: (string | number)[] = [];
if (options.status) {
where.push('status = ?');
params.push(options.status);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(options.limit);
const [rows] = (await db.query(
`SELECT * FROM tab_scheduling_notifications
${whereSql}
ORDER BY created_at DESC
LIMIT ?`,
params,
)) as [NotificationDbRow[], unknown];
return rows;
}
/** 按 buildNotificationUpdate 产出的片段更新记录。 */
export async function updateNotification(
db: Database,
fields: string[],
params: (string | number | null)[],
): Promise<void> {
await db.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
params,
);
}
/** 供 suggestions 视图使用的活跃状态映射所需的状态类型。 */
export type { NotificationStatus };
/** 考核目标(用于年度目标里程)。 */
export async function loadAssessmentTargets(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
)) as [RowDataPacket[], unknown];
return rows;
}
/** 考核车辆明细。 */
export async function loadAssessmentVehicles(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0`)) as [RowDataPacket[], unknown];
return rows;
}
/** 在运营车辆的类型(vehicle_info × vehicle_model)。 */
export async function loadVehicleTypes(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`)) as [RowDataPacket[], unknown];
return rows;
}
/** 实时车辆省份/城市。 */
export async function loadRealtimeLocations(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL`)) as [RowDataPacket[], unknown];
return rows;
}
/** 库存车辆(operation_status 3/4 且非停用)。 */
export async function loadInventoryVehicles(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'`)) as [RowDataPacket[], unknown];
return rows;
}
+239
View File
@@ -0,0 +1,239 @@
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 { registerNotifyRoutes, fetchActiveNotificationMap, fetchRecentInterventionCount } from "./notify.js";
import { registerSuggestionsRoutes } from "./suggestions.js";
import type { Database } from "./repository.js";
interface Call {
via: "query" | "execute";
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 mk = (via: "query" | "execute") => async (sql: string, params?: any[]) => {
calls.push({ via, sql: normalize(sql), params: (params ?? []) as unknown[] });
return [queue.shift() ?? [], []] as never;
};
return { db: { query: mk("query"), execute: mk("execute") }, calls };
}
const USER: AuthUser = {
userId: "u-1", userName: "张三", loginName: "zhangsan", depCode: "", depName: "",
permissionLevel: "full", roles: ["BI-SCHEDULE-OPT"],
};
function appWith(register: (app: any, deps: any) => void, db: Database, extra: Record<string, unknown> = {}) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
c.set("user", USER);
await next();
});
register(app, { db, ...extra });
return app;
}
/** 建议列表依赖的跨域数据源:测试中替成空实现,避免触达真实库/外部 API。 */
const STUB_DEPS = {
loadVehicleInfoMap: async () => new Map(),
loadOneOsMileageDates: async () => new Map(),
};
const NOTIFICATION_ROW = {
id: 5,
suggestion_id: "sug-1",
current_plate: "粤A1",
candidate_plate: "粤B2",
operator_id: "u-1",
operator_name: "张三",
status: "sent",
created_at: "2026-07-01 10:00:00",
updated_at: "2026-07-01 10:00:00",
executed_at: null,
notes: null,
before_mileage: null,
after_mileage: null,
};
test("干预登记:先查是否已有生效记录,再 INSERT 并回读", async () => {
const { db, calls } = createMockDb([[], { insertId: 5 }, [NOTIFICATION_ROW]]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }),
});
assert.equal(res.status, 200);
const payload = await res.json() as { success: boolean; record: Record<string, unknown> };
assert.equal(payload.success, true);
assert.equal(payload.record.candidatePlate, "粤B2");
assert.equal(calls.length, 3);
assert.equal(
calls[0].sql,
"SELECT id, candidate_plate FROM tab_scheduling_notifications WHERE suggestion_id = ? AND status != 'cancelled' LIMIT 1",
);
assert.deepEqual(calls[0].params, ["sug-1"]);
assert.equal(calls[0].via, "execute");
assert.equal(
calls[1].sql,
"INSERT INTO tab_scheduling_notifications (suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) VALUES (?, ?, ?, ?, ?, 'sent')",
);
assert.deepEqual(calls[1].params, ["sug-1", "粤A1", "粤B2", "u-1", "张三"]);
assert.equal(calls[2].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?");
assert.deepEqual(calls[2].params, [5]);
});
test("干预登记:已有生效记录返回 409 且不写库", async () => {
const { db, calls } = createMockDb([[{ id: 9, candidate_plate: "粤C3" }]]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }),
});
assert.equal(res.status, 409);
assert.equal(calls.length, 1, "被阻断后不应再 INSERT");
const payload = await res.json() as { message: string };
assert.equal(payload.message.includes("粤C3"), true);
});
test("干预登记:缺少必要参数返回 400 且不查库", async () => {
const { db, calls } = createMockDb();
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestionId: "sug-1" }),
});
assert.equal(res.status, 400);
assert.equal(calls.length, 0);
});
test("历史列表:状态过滤与条数上限,SQL 与参数顺序锁定", async () => {
const { db, calls } = createMockDb([[]]);
const app = appWith(registerNotifyRoutes, db);
await app.request("/?status=sent&limit=9999");
assert.equal(calls.length, 1);
assert.equal(calls[0].via, "query", "列表沿用 query(非 execute");
assert.equal(
calls[0].sql,
"SELECT * FROM tab_scheduling_notifications WHERE status = ? ORDER BY created_at DESC LIMIT ?",
);
assert.deepEqual(calls[0].params, ["sent", 500], "limit 上限 500");
calls.length = 0;
await app.request("/");
assert.equal(calls[0].sql, "SELECT * FROM tab_scheduling_notifications ORDER BY created_at DESC LIMIT ?");
assert.deepEqual(calls[0].params, [200], "无过滤时默认 200");
});
test("状态更新:UPDATE 字段顺序与回读 SQL 保持", async () => {
const { db, calls } = createMockDb([{}, [NOTIFICATION_ROW]]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/5", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "executed" }),
});
assert.equal(res.status, 200);
assert.equal(calls[0].via, "execute");
assert.equal(calls[0].sql.startsWith("UPDATE tab_scheduling_notifications SET "), true);
assert.equal(calls[0].sql.endsWith("WHERE id = ?"), true);
assert.equal(calls[0].params[calls[0].params.length - 1], 5);
assert.equal(calls[1].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?");
});
test("状态更新:非法 id / 缺 status / 非法 status 一律 400 且不查库", async () => {
const { db, calls } = createMockDb();
const app = appWith(registerNotifyRoutes, db);
const cases: Array<[string, unknown]> = [
["/0", { status: "executed" }],
["/5", {}],
["/5", { status: "nope" }],
];
for (const [url, body] of cases) {
const res = await app.request(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
assert.equal(res.status, 400, `${url} ${JSON.stringify(body)}`);
}
assert.equal(calls.length, 0);
});
test("状态更新:记录不存在返回 404", async () => {
const { db } = createMockDb([{}, []]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/5", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled" }),
});
assert.equal(res.status, 404);
});
test("活跃映射与近 7 天计数:SQL 与映射键保持", async () => {
const { db, calls } = createMockDb([
[{ id: 1, suggestion_id: "sug-1", candidate_plate: "粤B2", status: "sent", created_at: "2026-07-01 10:00:00" }],
[{ cnt: "4" }],
]);
const map = await fetchActiveNotificationMap(db);
assert.equal(map.get("sug-1::粤B2")?.id, 1);
assert.equal(
calls[0].sql,
"SELECT id, suggestion_id, candidate_plate, status, created_at FROM tab_scheduling_notifications WHERE status != 'cancelled' ORDER BY created_at DESC",
);
assert.equal(await fetchRecentInterventionCount(db), 4);
assert.equal(
calls[1].sql,
"SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications WHERE status != 'cancelled' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
);
});
test("建议列表:五条基础查询的 SQL 与顺序保持(不查外部 API 的路径)", async () => {
const { db, calls } = createMockDb([[], [], [], [], []]);
const app = appWith(registerSuggestionsRoutes, db, STUB_DEPS);
// targetId 让响应可预测;OneOS 段在无车牌时不会触发
const res = await app.request("/?targetId=1");
assert.equal(res.status, 200);
const sqls = calls.map(c => c.sql);
assert.equal(sqls[0], "SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id");
assert.equal(sqls[1].startsWith("SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,"), true);
assert.equal(sqls[1].includes("FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0"), true);
assert.equal(sqls[2].includes("FROM vehicle_info vi LEFT JOIN vehicle_status vs"), true);
assert.equal(sqls[2].includes("COALESCE(vs.operation_status, '') <> '5'"), true);
assert.equal(sqls[3], "SELECT plate_number, province, city FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL");
assert.equal(sqls[4].includes("COALESCE(vs.operation_status, '') IN ('3','4')"), true, "库存车辆条件保持");
});
test("分层:调度域的路由文件不再直接书写 SQL", () => {
const dir = path.dirname(fileURLToPath(import.meta.url));
for (const file of ["notify.ts", "suggestions.ts"]) {
const source = readFileSync(path.join(dir, file), "utf8");
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
assert.deepEqual(offenders, [], `${file} 的 SQL 必须留在 repository.ts`);
}
});
+44 -40
View File
@@ -1,11 +1,19 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import pool from '../../db/mysql.js'; import mysqlPool from '../../db/mysql.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js'; import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js'; import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
import { mapRegion } from '../vehicles/routes.js'; import { mapRegion } from '../vehicles/model.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js'; import { classifyVehicle, generateSuggestions } from './algorithm.js';
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js'; import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
import {
loadAssessmentTargets,
loadAssessmentVehicles,
loadInventoryVehicles,
loadRealtimeLocations,
loadVehicleTypes,
type Database,
} from './repository.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js'; import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
@@ -64,17 +72,28 @@ function recentCompletedDates(count: number): string[] {
// Route // Route
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const app = new Hono(); export interface SuggestionsDependencies {
db: Database;
/**
* 跨域数据源(里程域的车辆信息与 OneOS 里程)。
* 显式作为依赖注入:既让本域可独立测试,也让"建议依赖里程数据"这件事在类型上可见。
*/
loadVehicleInfoMap?: typeof fetchVehicleInfoMap;
loadOneOsMileageDates?: typeof fetchOneOsMileageDates;
}
app.get('/', async (c) => { export function registerSuggestionsRoutes(app: Hono, deps: SuggestionsDependencies): void {
const { db } = deps;
const loadVehicleInfoMap = deps.loadVehicleInfoMap ?? fetchVehicleInfoMap;
const loadOneOsMileageDates = deps.loadOneOsMileageDates ?? fetchOneOsMileageDates;
app.get('/', async (c) => {
try { try {
const targetIdParam = c.req.query('targetId'); const targetIdParam = c.req.query('targetId');
const filterTargetId = targetIdParam ? Number(targetIdParam) : null; const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
// ---- Query 1: Assessment targets ---- // ---- Query 1: Assessment targets ----
const [targets] = await pool.execute( const targets = await loadAssessmentTargets(db);
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>(); const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
for (const t of targets) { for (const t of targets) {
@@ -85,26 +104,13 @@ app.get('/', async (c) => {
} }
// ---- Query 2: Assessment vehicles ---- // ---- Query 2: Assessment vehicles ----
const [assessmentRows] = await pool.execute(` const assessmentRows = await loadAssessmentVehicles(db);
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ---- // ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap(); const vehicleInfoMap = await loadVehicleInfoMap();
// ---- Query 4: Vehicle types from vehicle_info ---- // ---- Query 4: Vehicle types from vehicle_info ----
const [truckTypeRows] = await pool.execute(` const truckTypeRows = await loadVehicleTypes(db);
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>(); const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckTypeRows) { for (const row of truckTypeRows) {
@@ -115,11 +121,7 @@ app.get('/', async (c) => {
} }
// ---- Query 5: Real-time location ---- // ---- Query 5: Real-time location ----
const [locationRows] = await pool.execute(` const locationRows = await loadRealtimeLocations(db);
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`) as [any[], unknown];
const locationMap = new Map<string, { province: string; city: string }>(); const locationMap = new Map<string, { province: string; city: string }>();
for (const row of locationRows) { for (const row of locationRows) {
@@ -139,7 +141,7 @@ app.get('/', async (c) => {
const dates = recentCompletedDates(30); const dates = recentCompletedDates(30);
const sevenDayDates = new Set(dates.slice(-7)); const sevenDayDates = new Set(dates.slice(-7));
const allowedPlates = new Set(allPlates); const allowedPlates = new Set(allPlates);
const rowsByDate = await fetchOneOsMileageDates(dates); const rowsByDate = await loadOneOsMileageDates(dates);
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>(); const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
for (const [date, rows] of rowsByDate) { for (const [date, rows] of rowsByDate) {
for (const row of rows) { for (const row of rows) {
@@ -182,15 +184,7 @@ app.get('/', async (c) => {
} }
// ---- Query 7: Inventory vehicles (rent_status = 0) ---- // ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(` const inventoryTruckRows = await loadInventoryVehicles(db);
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'
`) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ---- // ---- Build assessment vehicle lookup for inventory cross-reference ----
const assessmentByPlate = new Map<string, any>(); const assessmentByPlate = new Map<string, any>();
@@ -311,7 +305,7 @@ app.get('/', async (c) => {
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles); const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Attach notification status to candidates ---- // ---- Attach notification status to candidates ----
const notificationMap = await fetchActiveNotificationMap(); const notificationMap = await fetchActiveNotificationMap(db);
for (const s of suggestions) { for (const s of suggestions) {
for (const c of s.candidates) { for (const c of s.candidates) {
const key = `${s.id}::${c.plateNumber}`; const key = `${s.id}::${c.plateNumber}`;
@@ -365,7 +359,7 @@ app.get('/', async (c) => {
// Recalculate summary based on permission-filtered results // Recalculate summary based on permission-filtered results
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length; const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length; const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
const recentInterventionCount = await fetchRecentInterventionCount(); const recentInterventionCount = await fetchRecentInterventionCount(db);
const filteredSummary: SchedulingSummary = { const filteredSummary: SchedulingSummary = {
qualifiedCount: filteredQualified, qualifiedCount: filteredQualified,
hopelessCount: filteredHopeless, hopelessCount: filteredHopeless,
@@ -396,4 +390,14 @@ app.get('/', async (c) => {
} }
}); });
}
/** 生产用路由器:绑定真实连接池。 */
export function createSuggestionsRouter(): Hono {
const app = new Hono();
registerSuggestionsRoutes(app, { db: mysqlPool });
return app;
}
const app = createSuggestionsRouter();
export default app; export default app;