Skip to content

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 Hookslefthook(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/contractpackages/platform-any/contract/src/前后端直接消费、不加包装层(ADR 0004);有快照测试守护,改了会 diff
跨端类型(前后端都要用)@walnut/typespackages/platform-any/types/src/<x>.d.ts,并在 src/index.d.ts 补具名 re-export纯类型、零运行时;子路径 @walnut/types/<x> 与根 @walnut/types 都可用
纯函数工具(不碰 DOM、不碰 Vue)@walnut/utilspackages/platform-any/utils-core/src/目标是后端也能以 CJS 消费 ⇒ 引 Vue/DOM 就出界(ADR 0006);必须补测试
浏览器工具 / Vue composable@walnut/clientpackages/platform-web/client/src/仅浏览器;Vue / pinia 是 peerDependency
HTTP 适配器 / 拦截器@walnut/httppackages/platform-web/http/src/包名是 @walnut/http(旧名 axios)
可复用 UI 组件@walnut/uipackages/platform-web/ui/src/<Name>/{index.ts,index.vue}只有「零 app 依赖 + 已被 ≥2 处复用」才迁(判据见架构待办的「@walnut/ui 剩余组件」),否则先留在 apps/admin/src/components
只属于 admin 的组件 / store / 页面apps/adminsrc/components/**、src/store/modules/**、src/views/**组件一律 index.ts(导出)+ index.vue(实现);API 函数以 API 结尾
后端接口apps/serverapps/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/scriptssrc/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 为例 —— 本仓已有多条门禁,历史上漏接过步骤:

  1. src/ci/<name>.ts 写纯逻辑(collectFindings() 之类)+ bin/<name>.ts 三行:
    ts
    import { main } from '../src/ci/<name>.ts'
    import { runCli } from '../src/lib/cli.ts'
    
    await runCli(main)
  2. packages/tooling/scripts/package.json 的 bin 里登记;
  3. 根 package.json 加一条 lint:<name> 脚本;
  4. 接进推送前门禁表:packages/tooling/scripts/src/ci/prepush.ts 的 PREPUSH_GATES 里加一项 ({ id, label, argv, why } —— why 要写清「凭什么它在推送前必须跑」)。执行器并行跑、 每段报耗时;prepush.test.ts 会机械校验每一项的 argv[0] 都落到真实存在的根脚本上 (打错一个名字 = 一整段门禁静默消失);
  5. 接进 ci.yml 的 quality job;若它也属于发版前必须过的,再加进 packages/tooling/release/src/release/steps.ts 的电池(steps.test.ts 会校验整表)。

门禁的输出与退出码口径(2026-09-23 统一,别各写各的) ​

退出码不由门禁决定,由 lib/cli.ts 的 runCli 一处映射 —— 门禁只负责「抛什么」:

门禁里怎么做退出码含义
正常 return0通过
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。

关键设计决策 ​

  1. 异构 Toolchain:前端 ESM + Vite + moduleResolution: "bundler";后端 CJS + NestJS CLI + SWC + moduleResolution: "node"。Server 不继承任何 @walnut/tsconfig 预设(ADR 0012)。
  2. 两个命名空间:@walnut/*(外层,pnpm workspace 包)和 @walnut-server/*(内层,NestJS internal libs)。物理分离,无命名冲突。
  3. catalog 统一版本:依赖版本只写在 pnpm-workspace.yaml 的 catalog: 段,catalogMode: strict 阻止包内直接写版本号。
  4. hoist: false:严格依赖隔离 —— 每个包只能解析自己 package.json 中声明的依赖。仅有少数精确包名的 publicHoistPattern 例外被提升到根 node_modules/。
  5. 供应链防护:minimumReleaseAge(发布冷却期)、trustPolicy: no-downgrade(拒绝可信度下降的版本)、blockExoticSubdeps(传递依赖禁止异源)三项由 pnpm 在安装时校验 lockfile。
  6. 依赖构建脚本白名单只放行两条:@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/httpHTTP 客户端框架(instance + adapters)axios + client
@walnut/uinaive-ui 组件naive-ui + vue(peer)
@walnut/eslint-configESLint 共享预设(base / vue / nest)ESLint(peer)
@walnut/commitlint-configcommitlint 规则(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 处已经不准的。

相关 ADR ​

基于 MIT 许可发布