diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 674a7cc..a928eb3 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -96,13 +96,13 @@ server/
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** |
| `energy/` | `index.ts`(聚合)+ `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` `electric.ts` `etc.ts` + `query-model.ts` `cache.ts` `constants.ts` | 路由 / 模型已分开,**SQL 仍在路由内** |
-| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` + `algorithm.ts` `notification-model.ts` | 同上 |
+| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 |
| `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(定位点)两个库 |
-已完整分层的五个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap`)
-由架构测试守护:`routes.ts` 不得出现 SQL,且必须存在 `repository.ts`。
-其余域(`energy` / `mileage` / `scheduling`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
+已完整分层的六个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling`)
+由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
+其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
@@ -139,10 +139,11 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
诚实记录,避免后来者以为已经做完:
-- **后端仍有 3 个域没拆出 repository**:`energy` / `mileage` / `scheduling` 的 SQL 仍在各自的
- 路由文件里(形状见上表)。已完成的有 5 个域,可作为模板:路由只做校验与组装,
- SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
- 注意 `vehicle-heatmap` 的 SQL 是小写、`hydrogen-heatmap` 是大小写混排,正则检查要忽略大小写。
+- **后端仍有 2 个域没拆出 repository**:`energy` / `mileage` 的 SQL 仍在各自的路由文件里
+ (形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
+ 纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
+ 守卫用的是"语句形状"正则(如 `update
set`)而不是裸关键字,避免把
+ `UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
diff --git a/src/architecture.test.ts b/src/architecture.test.ts
index 269a0dd..99433ee 100644
--- a/src/architecture.test.ts
+++ b/src/architecture.test.ts
@@ -48,6 +48,11 @@ function targetPath(file: string, spec: string): string | null {
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[] {
return allFiles.filter((f) => rel(f).startsWith(prefix));
}
@@ -164,7 +169,7 @@ test("运行时建表必须尊重只读模式", () => {
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
});
-test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", () => {
+test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => {
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
const layered = [
@@ -173,14 +178,25 @@ test("已完整分层的域:routes.ts 不含 SQL,且存在 repository.ts", (
"server/routes/feedback",
"server/routes/vehicle-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[] = [];
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`);
+ const abs = path.join(srcDir, dir);
+ if (!existsSync(path.join(abs, "repository.ts"))) {
+ offenders.push(`${dir}/repository.ts 缺失`);
+ 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");
});
diff --git a/src/server/routes/scheduling/notify.ts b/src/server/routes/scheduling/notify.ts
index 6092c4c..79b2455 100644
--- a/src/server/routes/scheduling/notify.ts
+++ b/src/server/routes/scheduling/notify.ts
@@ -1,5 +1,5 @@
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 {
NotifyRequest,
@@ -18,233 +18,196 @@ import {
type ActiveNotificationRow,
type NotificationDbRow,
} 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;
+}
-/**
- * Count non-cancelled interventions created within the last 7 days.
- */
-export async function fetchRecentInterventionCount(): Promise {
- 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;
+/** 近 7 天未取消的干预条数(供建议列表使用)。 */
+export async function fetchRecentInterventionCount(db: Database): Promise {
+ return countRecentInterventions(db);
}
/**
- * Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
- * Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
+ * 当前可见 (suggestion, candidate) 组合的通知状态映射。
+ * Key: `${suggestionId}::${candidatePlate}` → 最近一条未取消通知。
*/
-export async function fetchActiveNotificationMap(): Promise<
- Map
-> {
- const [rows] = (await pool.execute(
- `SELECT id, suggestion_id, candidate_plate, status, created_at
- FROM tab_scheduling_notifications
- WHERE status != 'cancelled'
- ORDER BY created_at DESC`,
- )) as [any[], unknown];
-
- return buildActiveNotificationMap(rows as ActiveNotificationRow[]);
+export async function fetchActiveNotificationMap(
+ db: Database,
+): Promise