# 架构与分层约定 本文是这个仓库的结构契约。**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// 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/ auth(JWT → 注入 user)、read-only auth/ 登录换票、固定密码、权限过滤与脱敏 routes// 按业务域的 HTTP 路由 ``` ### 后端业务域的形状(`server/routes//`) 命名约定:`index.ts` 是**聚合器**(把子路由挂到前缀上),`routes.ts` 是**叶子路由**。 | 域 | 文件 | 说明 | | --- | --- | --- | | `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 仍在路由内 | | `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 与处理器在同一文件 | ### 中间件顺序(在 `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//`:建 `api.ts`(HTTP)、`model.ts`(纯逻辑)、`index.tsx`(入口)、`components/`。 2. `src/app/modules.ts`:注册导航项,必要时用 `roles.ts` 的判断控制可见性。 3. `src/server/routes/.ts`(或 `routes//`):实现接口;需要模块级权限就加 **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. 已知未尽事项 诚实记录,避免后来者以为已经做完: - **后端仍有两个域没拆出 repository**:`ele/routes.ts` 与 `feedback/routes.ts` 仍把 SQL 与处理器放在同一文件(形状见上表)。拆分时**不要改变 SQL 与参数顺序**——这两个域目前 没有 SQL 指纹测试,建议先补契约测试再动。 - **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 - **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个 传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。 - **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 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 缺失拒绝启动已实测 |