Files
ln-bi/docs/ARCHITECTURE.md
T
dsh-agent e4c8195ede refactor(stage13): 能源域先行搬迁电能与 ETC 的 SQL(该域完成一半)
改动
- 新增 energy/repository.ts:电能总览 KPI、本月逐日、最近有数据月份、昨日电量、
  按日区间查询、ETC 汇总,共 6 个查询函数(SQL 与参数逐字保留)。
- electric.ts / etc.ts 改为调用 repository;两者本就是 register*(app, deps) 形态,
  因此这次是纯粹的 SQL 位置迁移,没有改接口契约。
- 车辆归属筛选片段 electricKindClause 也移入 repository:
  它本质是 SQL,留在路由里会让"路由不含 SQL"这条规则形同虚设。

验证
- energy/routes.test.ts 的 13 个既有用例直接作为安全网(其中多处对 SQL 与参数做断言),
  迁移后全部通过,说明查询文本、参数顺序与口径未变。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。

未完成(已在文档中写明)
- hydrogen-station-board.ts 仍有 10 条查询 + 4 个本地 SQL 片段助手;
- hydrogen-bi-v2.ts 仍有 7 条带动态插值的查询。
因此 energy 暂不加入架构守护的"已完整分层"清单,避免给出已完成的不实信号。
2026-09-11 10:46:58 +08:00

166 lines
9.7 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`(聚合)+ `electric.ts` `etc.ts` `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` + `repository.ts`(电能/ETC 已迁入)+ `query-model.ts` `cache.ts` `constants.ts` | 🔶 **部分分层**`electric` / `etc` 的 SQL 已在 repository,两个 hydrogen 文件仍在路由内,因此该域尚未进守护清单 |
| `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 缺失拒绝启动已实测 |