Files
ln-bi/docs/ARCHITECTURE.md
T
dsh-agent 5196db5df7 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 未引用文件。
2026-09-11 10:44:52 +08:00

166 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构与分层约定
本文是这个仓库的结构契约。**6 条硬规则由 `src/architecture.test.ts` 断言**,破坏即测试失败。
## 1. 分层与依赖方向
```
app/ 应用外壳(路由、导航注册)
modules/ 前端业务域 —— 只依赖 shared/
shared/ 叶子层:角色常量、跨端 DTO、纯工具(不得依赖 modules/ 或 server/
server/ 后端 —— 只依赖 shared/,不得依赖 modules/
```
**依赖只能向下**。前端通过 HTTP 调用后端,不通过 import。
### 6 条硬规则
| # | 规则 | 违反后果 |
| --- | --- | --- |
| 1 | `server/**` 不得 import `modules/**` | 服务端被前端类型/组件绑架,无法独立部署与测试 |
| 2 | `modules/**``components/**``auth/**``app/**` 不得 import `server/**` | 打包会拖入服务端代码与密钥 |
| 3 | `shared/**` 不得 import `modules/**` / `server/**` / `components/**` | 叶子层反向依赖会让依赖图成环 |
| 4 | 不得出现 `vendor/` 引用 | 生产代码必须位于业务域目录,不能藏在"第三方/参考"目录里 |
| 5 | `model.ts` / `model.tsx` 不得 import `react` | 纯模型必须能在 Node 里直接单测,不依赖 DOM |
| 6 | `@ts-nocheck` 只允许出现在已登记的原型快照文件 | 类型门禁对最大文件失效 |
### 关于第 6 条的豁免清单
这三个文件是**逐字节保留**的 8113 验收原型(UI / CSS 冻结,改动需重新做桌面与移动端截图验收):
```
modules/energy/hydrogen/board/EnergyBiBoardApp.tsx
modules/energy/hydrogen/station-daily/StationDailyApp.tsx
modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx
```
豁免清单是**只减不增**的:新增 `@ts-nocheck` 会被测试拦下,请改为修正类型。
## 2. 前端业务域的形状
每个域尽量保持同一形状,便于"进一个目录就知道从哪读起":
```
modules/<domain>/
index.tsx 域入口组件(导出给 app/modules.ts 注册)
api.ts 只做 HTTP,不做业务判断
types.ts DTO
model.ts 纯函数:筛选 / 排序 / 聚合 / 口径(可被 node:test 直接测)
components/ 无状态展示组件
*.css 域内样式
```
复杂域可以再分子域,例如氢能:
```
modules/energy/hydrogen/
index.tsx 导航入口
api.ts types.ts 数据访问与 DTO
model/ 纯逻辑(格式化、承担方标签、按日汇总格式)
board/ 经营看板 UI
station-daily/ 单站日报 UI
drill/ 下钻弹层 UI
common/ 域内共享工具与组件
fonts/ 看板专用字体
```
判定标准很简单:**能在 Node 里直接测的放 `model/`;需要浏览器/DOM 的放 UI 子目录。**
## 3. 后端结构
```
server/
config.ts 环境变量集中读取 + assertRuntimeConfig() 启动期校验
app.ts createApp():装配中间件、挂载路由、静态托管(无副作用,可测试)
index.ts 进程启动:config 校验 → app → bootstrap → listen
local.ts 只读预览启动(不建表、不定时任务)
bootstrap.ts 启动副作用总入口
db/ mysql / hydrogen / heatmap 连接
db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过)
middleware/ authJWT → 注入 user)、read-only
auth/ 登录换票、固定密码、权限过滤与脱敏
routes/<domain>/ 按业务域的 HTTP 路由
```
### 后端业务域的形状(`server/routes/<domain>/`
命名约定:`index.ts` 是**聚合器**(把子路由挂到前缀上),`routes.ts` 是**叶子路由**。
| 域 | 文件 | 说明 |
| --- | --- | --- |
| `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` `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` / `scheduling`
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
### 中间件顺序(在 `app.ts` 中显式体现)
```
cors → read-only → /api/auth(公开) → authMiddleware → 各业务域路由
→ /api/health → /api/* 未匹配返回 JSON 404 → 静态托管 + SPA 回退
```
两个已经踩过的坑,改动时不要退回去:
- **未匹配的 `/api/*` 必须返回 JSON 404**。若落到 SPA 回退,会返回 `200 text/html`
前端 `res.ok` 为真后在 `res.json()` 抛解析错误,把"路由不存在"伪装成数据问题。
- **模块守卫必须 fail-closed**,写法是 `if (!canAccessX(user?.roles))`
不能写成 `if (user && !canAccessX(user.roles))`——后者在 `user` 缺失时静默放行。
### 角色与数据权限
- 角色常量唯一真源:`src/shared/auth/roles.ts`(前后端共用)。
- 数据范围过滤与客户名脱敏:`src/server/auth/permissions.ts`
- `permissionLevel` 只表示**数据范围**,不等于模块权限;能源/调度/反馈各自另有白名单。
## 4. 新增一个业务模块的步骤
1. `src/modules/<domain>/`:建 `api.ts`HTTP)、`model.ts`(纯逻辑)、`index.tsx`(入口)、`components/`
2. `src/app/modules.ts`:注册导航项,必要时用 `roles.ts` 的判断控制可见性。
3. `src/server/routes/<domain>.ts`(或 `routes/<domain>/`):实现接口;需要模块级权限就加 **fail-closed** 守卫。
4. 数据权限:读取后调用 `filterByPermission` + `maskCustomerNames`,或写等价的 SQL 过滤。
5. 测试:纯逻辑进 `model.test.ts`;接口口径用**固定夹具**锁 SQL 与参数(参考 `server/routes/energy/routes.test.ts`)。
6.`npm run lint && npm test && npm run build`
## 5. 已知未尽事项
诚实记录,避免后来者以为已经做完:
- **后端仍有 2 个域没拆出 repository**`energy` / `mileage` 的 SQL 仍在各自的路由文件里
(形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`
纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kBgzip 50 kB),来自原型渲染方式(大量内联样式)。
### 已收敛的重复实现(不要退回多份)
| 能力 | 唯一实现 | 由测试守护 |
| --- | --- | --- |
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
| 运行时 DDL(建表/改表) | `src/server/db/schema/*` | 架构测试:其他 server 文件不得出现 `CREATE TABLE` / `ALTER TABLE`;每个 schema 模块必须检查 `ddlAllowed()` |
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts``modules/energy/daily-range/model.ts` | `date-range` 暂无独立测试;改动请补 |
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
| 环境变量读取与校验 | `src/server/config.ts` | `auth/password.test.ts` 覆盖密码模式;JWT 缺失拒绝启动已实测 |