Files
ln-bi/docs/ARCHITECTURE.md
T
dsh-agent c199f031c5 refactor(stage10): ele / feedback 拆出 repository + model,并补契约测试
背景
- 这两个域把 SQL 与处理器放在同一文件,是全仓仅有的两个零接口测试的后端域。
  直接拆会改动 SQL 与参数顺序,而硬约束要求不得变更统计口径 —— 因此按
  "可注入化 -> 补契约测试 -> 再提取" 的顺序做。

改动
- ele:拆为 routes.ts(校验与组装)/ repository.ts(全部 SQL)/ model.ts(xlsx 解析、
  取值清洗、筛选片段、插入值组装等纯逻辑),并改为 registerEleRoutes(app, deps) 可注入。
- feedback:拆为 routes.ts / repository.ts,同样可注入;建表与截图上传也作为依赖注入。
- 两域各补测试:ele 7 个接口契约 + 6 个模型用例,feedback 9 个接口契约。
  mock pool 逐条断言 SQL 文本与参数顺序(含分页、状态白名单、批量插入的 30 列顺序)。
- 架构测试新增一条:已完整分层的三个域(vehicles / ele / feedback)必须有 repository.ts
  且 routes.ts 不得出现 SQL。

等价性验证(关键)
- 把改造前的实现从 git 取出,与改造后的实现跑同一批请求(含真实 xlsx 解析路径),
  对比每一步落库 SQL、参数、HTTP 状态与响应体:
    feedback:6 个场景,SQL + 参数 + 状态完全一致(差异仅 DDL 已移交 db/schema 层)
    ele     :5 个场景,SQL + 参数 + 状态 + 响应体完全一致(仅随机 batchId/时间戳做掩码)
- 期间的修正:曾把 /mine 与 /list 的列集合统一,二者实际不同(管理列表多 user_id/user_name),
  已按原样保留;测试同时锁定了这一差异。

lint / test(161) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:36:05 +08:00

9.0 KiB
Raw Blame History

架构与分层约定

本文是这个仓库的结构契约。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 + algorithm.ts notification-model.ts 同上
hydrogen-heatmap/vehicle-heatmap/ routes.ts + model.ts 纯模型已抽出,SQL 仍在 routes.ts

已完整分层的三个域(vehicles / ele / feedback)由架构测试守护:routes.ts 不得出现 SQL 且必须存在 repository.ts。其余域尚未拆出 repository——拆分时不要改变 SQL 与参数顺序 并建议先按 ele/routes.test.ts 的方式补契约测试再动。

中间件顺序(在 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.tsHTTP)、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. 已知未尽事项

诚实记录,避免后来者以为已经做完:

  • 后端仍有域没拆出 repositorymileage / energy / scheduling / 两个热力图的 SQL 仍在 各自的 routes.ts(或平级模块)里(形状见上表)。vehicles / ele / feedback 已完成, 可作为模板:路由只做校验与组装,SQL 进 repository.ts,纯逻辑进 model.ts 并用 mock pool 的契约测试锁定 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.tsmodules/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 缺失拒绝启动已实测