Monorepo 架构地图
这一页回答「这个仓长什么样、东西该放哪」。 逐主题的细节见各专题文档(见下方索引); 每条架构决策的理由在 ADR 里,本页只描述现状。
概述
Walnut Admin 是一个全栈 TypeScript monorepo,采用 Turborepo + pnpm workspaces 管理 16 个包(3 个 app + 3 个 platform-any 包 + 4 个 platform-web 包 + 6 个 tooling 包;包数以 pnpm-workspace.yaml 的 packages 字段为准)。项目从三个独立仓库合并而来,通过 pnpm catalog 统一依赖版本、Turborepo 编排任务、pnpm 原生 release management(versioning.fixed 单一组)管理版本号。
技术栈速览
| 层级 | 技术 |
|---|---|
| 包管理 | pnpm 12+(workspace + catalog,catalogMode: strict) |
| 任务编排 | Turborepo 2.9(任务拓扑 + 缓存 + 架构边界) |
| 类型系统 | TypeScript 6.0(前端 ESM + 后端 CJS,双轨 toolchain) |
| 代码检查 | ESLint 10.3 flat config + @antfu/eslint-config + @walnut/eslint-config |
| 格式化 | ESLint stylistic(无 Prettier,lint:fix / lint-staged 即格式化入口) |
| 版本管理 | pnpm 原生 release management(versioning.fixed 单一组:全部 workspace 包永远同版本,组成员数由 versioning-config.test.ts 机械拦) |
| 变更日志 | git-cliff 逐包渲染 CHANGELOG.md(PR 链接 + 贡献者);pnpm release 另生成根 changelog-latest.md |
| 死代码检测 | Knip 6.29(当前是红的且有意维持,见 Knip) |
| Git Hooks | lefthook(lefthook.yml)+ lint-staged |
| 前端框架 | Vue 3 + Vite 8 + Naive UI + UnoCSS |
| 后端框架 | NestJS 12 + SWC + PostgreSQL(TypeORM)+ Redis |
| 文档引擎 | VitePress 1.6 |
| Node 要求 | >= 24.15.0 |
「我要加 X,该放哪」
这是本页最该被查的一张表。判据写在最后一列 —— 它比位置更重要,因为它解释了为什么。 拿不准时先看最后一列:多数错误都来自「把只服务一个 app 的东西放进了共享包」。
| 我要加… | 放哪 | 具体位置 | 判据 / 陷阱 |
|---|---|---|---|
| 跨端常量(响应码、枚举、路由) | @walnut/contract | packages/platform-any/contract/src/ | 前后端直接消费、不加包装层(ADR 0004);有快照测试守护,改了会 diff |
| 跨端类型(前后端都要用) | @walnut/types | packages/platform-any/types/src/<x>.d.ts,并在 src/index.d.ts 补具名 re-export | 纯类型、零运行时;子路径 @walnut/types/<x> 与根 @walnut/types 都可用 |
| 纯函数工具(不碰 DOM、不碰 Vue) | @walnut/utils | packages/platform-any/utils-core/src/ | 目标是后端也能以 CJS 消费 ⇒ 引 Vue/DOM 就出界(ADR 0006);必须补测试 |
| 浏览器工具 / Vue composable | @walnut/client | packages/platform-web/client/src/ | 仅浏览器;Vue / pinia 是 peerDependency |
| HTTP 适配器 / 拦截器 | @walnut/http | packages/platform-web/http/src/ | 包名是 @walnut/http(旧名 axios) |
| 可复用 UI 组件 | @walnut/ui | packages/platform-web/ui/src/<Name>/{index.ts,index.vue} | 只有「零 app 依赖 + 已被 ≥2 处复用」才迁(判据见架构待办的「@walnut/ui 剩余组件」),否则先留在 apps/admin/src/components |
| 只属于 admin 的组件 / store / 页面 | apps/admin | src/components/**、src/store/modules/**、src/views/** | 组件一律 index.ts(导出)+ index.vue(实现);API 函数以 API 结尾 |
| 后端接口 | apps/server | apps/api/src/modules/<domain>/ | 一模块一套 module / controller / service / basic.repository,规则见 apps/server/AGENTS.md |
| 后端内部共享能力 | apps/server/libs/<x> | 按分层新建 lib,或加进既有层 | 它们不是 workspace 包,走 tsconfig paths,namespace @walnut-server/*(ADR 0007);分层与依赖方向见 ADR 0022 |
| 一条仓库门禁 / 脚本 | @walnut/scripts | src/ci/<name>.ts + bin/<name>.ts | 新增门禁有五步接线清单,少一步就等于没接上 —— 见下 |
| 共享配置预设(ESLint / tsconfig / Vitest / commitlint) | 对应的 packages/tooling/<x> | 已在册的 6 个包之一 | 新增第 7 个 tooling 包要同步单一 fixed 组、versioning-config.test.ts、以及一堆文档里的包清单 |
| 一篇架构决策记录 | 文档站 content/adr/ | NNNN-<slug>.md | 形态由 pnpm lint:adr 机械强制,形状约定见 ADR 索引 |
| 一篇架构 / 工程文档 | 文档站 content/monorepo/ | 新页 + 在 .vitepress/config/zh.ts 的 sidebar 里登记 | 不登记 = 页面不可达(见 apps/docs/AGENTS.md 约定 3) |
| 一条产品 / 功能待办 | 仓库根 TODO.md | 按「重要紧急」四档 | 与下面的架构待办刻意分开:产品取舍没有机械判据 |
| 一条架构 / 工程债 | 架构待办 | P2 / P3 / 搁置 / 未裁决 | 每条都要能写出一条机械判据,否则它属于根 TODO.md |
新增一条门禁的接线清单(五步,缺一不可)
以 @walnut/scripts 的某个 bin 为例 —— 本仓已有多条门禁,历史上漏接过步骤:
src/ci/<name>.ts写纯逻辑(collectFindings()之类)+bin/<name>.ts三行:tsimport { main } from '../src/ci/<name>.ts' import { runCli } from '../src/lib/cli.ts' await runCli(main)packages/tooling/scripts/package.json的bin里登记;- 根
package.json加一条lint:<name>脚本; - 接进推送前门禁表:
packages/tooling/scripts/src/ci/prepush.ts的PREPUSH_GATES里加一项 ({ id, label, argv, why }——why要写清「凭什么它在推送前必须跑」)。执行器并行跑、 每段报耗时;prepush.test.ts会机械校验每一项的argv[0]都落到真实存在的根脚本上 (打错一个名字 = 一整段门禁静默消失); - 接进
ci.yml的 quality job;若它也属于发版前必须过的,再加进packages/tooling/release/src/release/steps.ts的电池(steps.test.ts会校验整表)。
门禁的输出与退出码口径(2026-09-23 统一,别各写各的)
退出码不由门禁决定,由 lib/cli.ts 的 runCli 一处映射 —— 门禁只负责「抛什么」:
| 门禁里怎么做 | 退出码 | 含义 |
|---|---|---|
正常 return | 0 | 通过 |
throw new ViolationError('…') | 1 | 查出违规(改代码就能过) |
throw new PreconditionError('…') | 2 | 前置条件未满足(先修环境再重跑) |
别的异常(TypeError 等) | 1 | 脚本自己出问题 —— 不该让人去修一个不存在的前置条件 |
输出走 lib/log.ts,不直接用 console.*(它有 TTY/管道两条正确路径;console.* 在 POSIX 管道下 配合 process.exit 会丢输出)。四种出口,够用且不重叠:
| 出口 | 去哪 | 什么时候用 |
|---|---|---|
line('ok' | 'warning', text) | stdout | 结论行(带标记) |
lineErr('violation' | 'warning', text) | stderr | 违规结论 / 降级提示 |
out(text) | stdout | 上下文字("这次扫了多少东西"),不带标记 |
err(text) | stderr | 明细与「修法:…」,不带标记 |
结语由 runCli 统一打(✖ <ViolationError 的消息> / ✖ 前置条件未满足:<消息>): 门禁自己打明细,最后一行让 runCli 说 —— 于是所有门禁的收尾长得一样。 标记词表在 log.ts 的 MARK(✅ / ✖ / ⚠ / ⏭️)。
⚠️
prepush的对齐表格是唯一例外:那里用✓/✗而不是✅/✖, 因为表格靠padStart对齐、而 emoji 是双宽字符,混进去整列会歪。
⚠️ 发版 CLI(
@walnut/release)也保留自己的顶层 catch:它的映射多一条规则 (子进程退出码 2 也算前置条件未满足 → 2),但词表与出口是同一套(log.ts+errors.ts)。 别为了"形式统一"把它硬塞进runCli—— 那会丢掉那条规则。
另外两条约定:门禁由
pnpm install --force才会重新链接 bin(pnpm install有时不重链); 以及宁可漏报不可误报 —— 一个开始误报的门禁会被无视,等于没做(各门禁模块顶部都写了这条取舍)。
仓库全景
双层级 monorepo
项目存在两层包管理结构:
walnut-admin/ ← Turborepo + pnpm workspace(外层)
├── apps/
│ ├── admin/ ← @walnut/admin(Vue3 SPA)
│ ├── server/ ← @walnut/server(NestJS API)
│ │ ├── apps/api/ ← NestJS 默认部署单元(HTTP + cron + Bull)
│ │ ├── apps/worker/ ← 可选第二进程(cron + Bull,默认不启动)
│ │ ├── apps/cli/ ← 工具入口(migration / openapi 导出)
│ │ └── libs/ ← NestJS CLI monorepo(内层,内部 lib 分六层)
│ └── docs/ ← @walnut/docs(VitePress 文档站)
├── packages/ ← 共享库(按平台分组,ADR 0017)
│ ├── platform-any/ ← 运行时无关
│ │ ├── contract/ ← @walnut/contract(类型 + 常量)
│ │ ├── types/ ← @walnut/types(类型声明)
│ │ └── utils-core/ ← @walnut/utils(纯函数工具;目录名 ≠ 包名)
│ ├── platform-web/ ← 浏览器 / Vue(纯源码,不构建)
│ │ ├── api-types/ ← @walnut/api-types(由 openapi.json 生成)
│ │ ├── client/ ← @walnut/client
│ │ ├── http/ ← @walnut/http(原名 axios)
│ │ └── ui/ ← @walnut/ui
│ └── tooling/ ← 工具链 6 包(ADR 0019)
│ ├── tsconfig/ ← @walnut/tsconfig(纯 JSON 预设 base / ts / vue)
│ ├── eslint-config/ ← @walnut/eslint-config(base / vue / nest)
│ ├── commitlint-config/ ← @walnut/commitlint-config
│ ├── vitest-config/ ← @walnut/vitest-config(共享 Vitest 预设)
│ ├── scripts/ ← @walnut/scripts(仓库级脚本:lib / ci / env + bin)
│ └── release/ ← @walnut/release(发版编排,bin walnut-release)
├── turbo.json ← 任务定义 + 缓存 + 架构边界
├── pnpm-workspace.yaml ← workspace 声明 + catalog + versioning
├── tsconfig.json ← 仓库根 TS 配置(extends @walnut/tsconfig/base.json)
├── eslint.config.ts ← 根 ESLint 入口
├── knip.config.ts ← 死代码检测配置
└── TODO.md ← 产品 / 功能待办(与架构待办分开的两本账)外层(Turborepo 层面):apps/* + packages/platform-any/* + packages/platform-web/* + packages/tooling/* 的 workspace 包,通过 pnpm workspace 协议(workspace:*)相互引用。
内层(Server 内部):apps/server/libs/* 下的 NestJS 内部库(按「基建 → 技术 → identity 内核 → 领域域 → 装配(guards)→ app-domain」六层;完整清单以目录为准),通过 TypeScript paths 映射解析,不走 pnpm workspace。命名空间为 @walnut-server/*,与外层 @walnut/* 物理分离。apps/api 是默认部署单元(组合根),apps/worker / apps/cli 是同一 workspace 内的可选组合入口,不是新服务 —— 见 ADR 0022。
关键设计决策
- 异构 Toolchain:前端 ESM + Vite +
moduleResolution: "bundler";后端 CJS + NestJS CLI + SWC +moduleResolution: "node"。Server 不继承任何@walnut/tsconfig预设(ADR 0012)。 - 两个命名空间:
@walnut/*(外层,pnpm workspace 包)和@walnut-server/*(内层,NestJS internal libs)。物理分离,无命名冲突。 - catalog 统一版本:依赖版本只写在
pnpm-workspace.yaml的catalog:段,catalogMode: strict阻止包内直接写版本号。 hoist: false:严格依赖隔离 —— 每个包只能解析自己package.json中声明的依赖。仅有少数精确包名的publicHoistPattern例外被提升到根node_modules/。- 供应链防护:
minimumReleaseAge(发布冷却期)、trustPolicy: no-downgrade(拒绝可信度下降的版本)、blockExoticSubdeps(传递依赖禁止异源)三项由 pnpm 在安装时校验 lockfile。 - 依赖构建脚本白名单只放行两条:
@sentry/cli与lefthook(它的 postinstall 就是lefthook install,不放行则 git 钩子静默消失,见 ADR 0018);其余原生模块通过optionalDependencies分发预编译产物,不需要构建脚本。
共享包体系
依赖图
@walnut/contract ← 基础层(类型 + 常量)
↑
@walnut/utils ← 纯函数工具(依赖 contract + types)
↑
@walnut/client @walnut/http ← 浏览器/Vue 层(依赖 utils + contract)
↑ ↑
@walnut/ui ← naive-ui 组件层(peer: naive-ui + vue)
↑
@walnut/admin ← 消费所有共享包各包职责
| 包 | 职责 | 关键依赖 |
|---|---|---|
@walnut/contract | 共享类型、DTO、枚举、API 契约 | 零运行时依赖 |
@walnut/utils | 纯函数(regex、queue、crypto) | contract + types |
@walnut/types | 环境类型声明(universal / storage / deep-ref / object-key) | 零依赖 |
@walnut/client | 浏览器工具 + Vue composables + store 工厂 | Vue 3(peer) |
@walnut/http | HTTP 客户端框架(instance + adapters) | axios + client |
@walnut/ui | naive-ui 组件 | naive-ui + vue(peer) |
@walnut/eslint-config | ESLint 共享预设(base / vue / nest) | ESLint(peer) |
@walnut/commitlint-config | commitlint 规则(scope-enum 等) | commitlint |
@walnut/tsconfig | 纯 JSON tsconfig 预设,无依赖、无源码 | 无 |
@walnut/vitest-config | 共享 Vitest 预设(发现规则 / 环境 / 覆盖率) | vitest(peer) |
@walnut/scripts | 仓库级脚本通用层:lib/ 纯逻辑、ci/ 门禁、env/ 加解密,经 bin 被根 scripts 调用 | @dotenvx/dotenvx、typescript |
@walnut/release | 发版编排(src/release/),bin walnut-release | @walnut/scripts、git-cliff、yaml |
每个包自己的「契约 / 配置 / 扩展点 / 已知限制」写在它自己的
README.md里,不在这里重复 —— 那些内容离代码越近越不容易漂。逐包入口:contract·types·utils·client·http·ui·tsconfig·eslint-config·commitlint-config·vitest-config·scripts·release; 后端内部 lib 的逐库说明:9 个基建 lib 各有apps/server/libs/<x>/README.md;其余领域 / 技术 lib 的说明在libs/<x>/src的文件头注释与 ADR 0022。
消费方式
- 前端(Vite):通过
workspace:*symlink 直接消费源码(JIT 模式,ADR 0005) - 后端(NestJS):通过
workspace:*symlink 消费 CJS 构建产物(exports的require条件,ADR 0002) - 不发布 npm:当前为内部 monorepo。平台包不含
private字段(表示代码公开可见,不代表要发布到 npm);各 app 与@walnut/scripts/@walnut/release/@walnut/tsconfig为private: true。
后端内部库体系
Server 内部通过 NestJS CLI + SWC 管理内部库,命名空间 @walnut-server/*。内部 lib 按依赖方向分六层(下层不知道上层,完整清单以 apps/server/libs/ 目录为准,分层表见 ADR 0022):
apps/server/libs/
├── # 基建(9)
│ config/ const/ context/ db/ decorators/ exceptions/ pipes/ types/ utils/
├── # 技术(19)
│ als/ blpath/ cookie/ crypto/ delay/ lock/ queue/ sse/
│ cache/ throttle/ mailer/ sms/ rsa/ ip/
│ repository/ list/ crud/ scope-resolver/ token/
├── # identity 内核(1)
│ identity/
├── # 领域域(3)
│ system/ auth/ security/
├── # 装配(1)
│ guards/
└── # app-domain(1)
app-domain/这些 lib 通过 apps/server/tsconfig.json 的 paths 映射解析,由 NestJS CLI + SWC 统一编译。它们不参与 pnpm workspace,不通过 package.json exports 消费。为什么这样:ADR 0007;分层、组合根与端口注入反转:ADR 0022。
依赖方向由三条机械守卫守住:apps/server/eslint.config.ts 的 libs-boundary(lib 内禁止 @/* 与逃逸进 apps/);lib-registration.drift.spec.ts(磁盘 ↔ paths ↔ nest-cli ↔ swcrc ↔ app tsconfig 五处注册面全等);dto-entity.drift.spec.ts 与 eslint-data-scope-entities.drift.spec.ts(DTO/实体与数据范围清单对账)。
现状 / 待办 / 历史 的分工
三件事各有一个家,别互相抄(「一个事实一个家」):
| 想知道 | 去哪 |
|---|---|
| 现在是什么样 | 本页 + monorepo/ 下的各专题页(package-scripts / turbo-cache-boundary / ci-cd / release / doc-conventions …) |
| 还剩什么没做 | 架构待办事项 —— 每条都写清「为什么现在不能做」与解冻条件,条目不带编号(用名字) |
| 做过什么 | 归档的执行记录与核实记录(2026-09-24 从待办页搬出来冻结,待办页因此从 116 KB 降到 ~27 KB) |
判据:当前状态写成可核对的断言(或一条能重跑的判据),待办写成「解冻条件」, 历史只在归档里叙事。三处都不要写会腐烂的计数(包数 / bin 数 / 门禁段数)—— 那类数字没有门禁拦得住,2026-09-24 实测抓到 4 处已经不准的。