我用 NestJS + Vue3 写了一套完整的研发管理系统(58 表 / 229 API / 130 项断言,全开源

0 阅读14分钟

源码级复刻禅道:我用 NestJS + Vue3 写了一套完整的研发管理系统(58 表 / 229 API / 130 项断言,全开源)

dashboard.png

写在前面

先回答最常见的三个问题:

为什么是复刻禅道,而不是自己设计一个项目管理系统? 因为自己设计大概率是"想当然的产品设计",而禅道(ZenTao)是经过二十年生产环境验证的研发管理方法论:项目集 → 产品 → 项目 → 执行的四层框架、需求/任务/Bug/用例各自的完整状态机、以及"需求拆任务、任务关联版本、版本挂测试单、失败转 Bug、修复后发布"这条完整的研发闭环。这些规则本身就是最有价值的部分,值得逐行读一遍源码弄懂它。

复刻到什么程度? 不是"界面像",是源码级校准:每一条状态流转规则都能追溯到禅道 PHP 源码的具体文件和行号(下文有真实例子)。本地克隆禅道开源版 22.7 全量源码作为唯一业务事实源,任何业务规则疑义以源码为最高仲裁。

这个项目适合谁?

  • 想找一个业务复杂度足够真实的 NestJS + Vue3 全栈参考项目的人(网上全栈项目大多是 Todo 级 demo,而这里有 58 张表、229 个 API、221 个权限点)
  • 想了解研发管理系统内部实现的人(需求池怎么管、Bug 怎么闭环、权限怎么控)
  • 以及单纯想看看"把一个 20 年老系统拆开重写"是什么体验的人

项目已开源:**github.com/yhemeo613/z… License),觉得有帮助的话点个 Star ⭐


项目全景:一组数字

维度数字
数据库MySQL 8.4,58 张表(TypeORM migration 管线管理)
后端NestJS 11,28 个业务模块,Swagger 审计 229 个端点
权限221 个权限点(与禅道 grouppriv 表同构)、7 个预置角色
前端Vue 3.5 + Element Plus,34 个页面全部落地
实时协同Socket.IO 看板(泳道 × 列 × 拖拽 × presence)
自动化验证130 项 HTTP 断言 + 62 项单元测试 + 7 例 Playwright E2E + 权限漂移门禁 + Swagger 全量审计
部署docker-compose 四容器(MySQL / Redis / server / web),CI 双 job

先看几张实际界面(禅道风格 1:1 复刻):

需求列表(支持状态/模块/关键词筛选、行内指派):

stories.png

实时看板(泳道 = 需求/Bug/任务,拖拽卡片即触发服务端任务动作):

board.png

统计中心(ECharts:项目概览 / 执行负载 / 产品质量 / 路线图):

reports.png

角色权限(221 个权限点树状配置,与禅道 grouppriv 同构):

roles.png 更多截图(需求详情、任务、Bug、文档中心、登录页)见仓库 README。


整体架构

zentao-nest/
├── apps/
│   ├── server/     # NestJS 11 后端(28 模块,58 表迁移管线)
│   │   ├── src/common/     # 三守卫/拦截器/过滤器/Redis/Swagger
│   │   ├── src/modules/    # system/program/product/.../board 28 个模块
│   │   └── test/           # unit(Vitest) + http(130 断言脚本)
│   └── web/        # Vue 3.5 前端(34 页面 + Playwright E2E)
├── packages/
│   └── shared/     # ★ 双端同源契约包(零运行时依赖)
├── docs/           # 10 份设计文档(互引闭环)
└── docker-compose.yml

核心设计 1:shared 契约包,让前后端说同一种话

前后端最大的扯皮来源是"契约不一致":后端改了枚举,前端还在用旧值;权限点加了,前端指令没跟上。我的解法是 packages/shared——一个零运行时依赖的 TypeScript 包,前后端共同 import:

  • enums/:需求/任务/质量三大域的枚举 + 状态流转权威表 + 隐式迁移纯函数
  • permissions.ts:221 个权限点常量(种子数据、后端守卫、前端 v-permission 三处共用这一个源)
  • socket-contract.ts:Socket 事件名与 payload/ack 类型
  • kanban-map.ts:任务状态 ↔ 看板列的映射
  • error-codes.ts:错误码注册表 + HTTP 状态码段位推导

关键约束:这个包不许引任何 npm 依赖,只允许纯类型 + 纯函数。这保证了它可以在前后端任何一侧无成本使用,也让"权限点漂移检测"成为可能(后文详述)。

核心设计 2:一条请求的完整链路

请求 → TraceId 中间件 → JwtAuthGuard(验签 + denylist)
     → PermissionsGuard(权限点 ← Redis 缓存 ← DB sys_permission)
     → DataScopeGuard(可见集 ← ACL 白名单 ∪ 团队成员)
     → Controller → Service(状态机 + 副作用)→ Repository
     → 统一响应 { code, message, data, traceId, timestamp }

三个守卫各司其职:JWT 只管"你是谁",PermissionsGuard 管"你能不能做这个动作",DataScopeGuard 管"你能看到哪些数据"。功能权限和数据权限分离,这是禅道权限模型里最容易被人忽略、也最值得学的一部分。


硬骨头 ①:状态机的源码级校准

这是整个项目里最花时间、也最有收获的部分。

需求(Story)五态与"评审按版本裁剪"

需求状态机:draft → reviewing → active → changing → closed。看起来平平无奇,但禅道有两个反直觉的规则,是读源码才弄明白的:

  1. 评审结果是按版本裁剪的。需求每次变更(title/spec/verify 任一变化)会 version + 1 并重置评审人。此时老的评审记录怎么办?禅道的规则是:v1 版本(首次评审)没有"撤销评审"动作,v>1 版本没有"评审拒绝"动作。第一版被拒直接打回草稿;变更版被拒则保留原 active 版本继续用。这类规则不读源码根本设计不出来。

  2. 多评审人 allpass 裁决:每个版本独立记录各评审人的结论,全员评完的瞬间自动裁决——不需要任何人点"最终确认"。

  3. 免评审开关:sys_config.needReview 可以全局关闭评审环节(draft 直接提交成 active),同时保留"强制评审名单"——某些需求类型强制走评审。

任务(Task)六态与"工时驱动的隐式迁移"

任务六态:wait / doing / done / pause / cancel / closed。操作 → 前置状态的权威表直接抄自禅道 model.php:2533-2596:

// packages/shared/src/enums/task.enum.ts
export const TASK_TRANSITIONS: Record<string, readonly string[]> = {
  start: ['wait'],                          // 只能从"未开始"开始
  restart: ['pause'],                       // 只有"已暂停"能重启
  pause: ['doing'],                         // 只有"进行中"能暂停
  finish: ['wait', 'doing', 'pause'],       // 非终态均可完成
  cancel: ['wait', 'doing', 'pause'],
  close: ['done', 'cancel'],                // 只有完成/取消能关闭
  activate: ['done', 'closed', 'cancel'],   // 返工:终态唤醒(必填剩余工时)
};

非法流转统一返回错误码 3006,前端动作条按这张表渲染——能点什么按钮由状态决定,而不是由前端硬编码决定。

更妙的是禅道的一个设计:记工时本身会引发状态迁移。给一个 wait 的任务记工时?它自动变 doing(Started)。剩余工时填 0?自动变 done(Finished)。给一个 done 的任务又记了工时且 left≠0?自动唤醒成 doing(Activated):

// 记工时隐式状态迁移(禅道 tao.php:47-102 的 TypeScript 化)
export function deriveStatusOnEffort(
  current: string, left: number, consumed: number,
): { status: string; action: 'Finished' | 'Started' | 'Activated' | 'RecordWorkhour' } | null {
  if (left === 0 && !['done', 'closed', 'cancel'].includes(current)) {
    return { status: 'done', action: 'Finished' };
  }
  if (current === 'wait' && (consumed > 0 || left > 0)) {
    return { status: 'doing', action: 'Started' };
  }
  if (['done', 'pause', 'cancel', 'closed'].includes(current) && left > 0) {
    return { status: 'doing', action: 'Activated' };
  }
  return null;
}

配套的不变量:consumed = Σeffort 只增不减、left = 最后一条工时流水。所有工时聚合规则(含父子任务 Σ、状态推导优先级)都写成了 shared 包里的纯函数,62 项单测里有一大片就是表驱动地测这些纯函数。

stage 引擎:15 个值全程自动推进

需求有个 stage 字段表示它走到研发流程哪一步了,共 15 个值。这个字段全程不接受人工设置,完全由其他实体的动作驱动:

创建计划 ──────→ planned
项目导入范围 ──→ projected
开发任务开始 ──→ developing(design/devel/test 三桶分别推进)
开发任务完毕 ──→ developed
测试任务开始 ──→ testing
测试完毕 ─────→ tested
发布 Release ─→ released
需求关闭 ─────→ closed

E2E 全链路脚本(18 步)逐点断言了每一次 stage 跃迁——这条"需求的一生"测试跑通的那一刻,是整个项目最有成就感的时刻。


硬骨头 ②:RBAC + 数据权限

JWT 里不放权限

一个常见做法是把角色/权限塞进 JWT payload,签发时一次搞定。但这样做有个致命伤:改了权限要等 token 过期才生效。

我的选择是 JWT payload 最小化——只有 {sub, account, jti, typ},不含任何角色/权限字段。每次鉴权实时查 DB,Redis 缓存 10 分钟,授权变更时主动失效缓存。这样改权限即时生效,代价只是每 10 分钟一次 DB 查询压力。

权限点为什么是 221 个,DB 单源?

禅道的权限模型是 (module, action) 二元组,存在 grouppriv 表里。我把禅道全部权限点整理成 221 个常量放在 shared 包,但运行时以 DB sys_permission 表为单源——种子启动时从 shared 常量同步进 DB,守卫只认 DB(经 Redis 缓存)。

这里有个专门的门禁脚本 permission-drift-check.mjs:对照 shared 常量 ↔ DB 表,两边不一致直接让 CI 挂掉。没有这个门禁,改了枚举忘跑种子的事故只是时间问题。

数据权限三层模型

功能权限之外还有"你能看到哪些数据":

层机制
公开对象 acl = open,全组织可见
白名单对象 acl = private + sys_acl 白名单(自定义可见名单)
团队项目/执行团队成员天然可见其所属对象

实现上是可见集预计算:把每个用户可见的对象 ID 集合物化到 Redis,列表查询统一 WHERE id IN (可见集),单对象越权访问统一返回 2004。数据权限和功能权限在任何接口上都同时生效。


硬骨头 ③:实时看板,服务端是唯一事实源

看板最容易做成"前端拖来拖去,后端只存个位置"。禅道的看板语义不是这样:列即状态,拖拽即动作。我把这套语义做成了 Socket.IO 协议:

sequenceDiagram
    participant A as 用户A(浏览器)
    participant G as /ws 网关(JWT握手)
    participant S as 服务端(唯一事实源)
    participant B as 用户B(浏览器)
    A->>G: board:join {executionId}
    G-->>A: ack 全量看板(泳道/列/单元格)
    A->>G: card:move {taskId, toColumn, eventId}
    G->>S: 按列映射任务动作(如 developed→finish)
    S-->>G: 任务新状态 + cell.cards 落位
    G-->>A: ack {ok, task}
    G-->>B: card:moved / card:status:changed 广播

三个关键决策:

  1. 拖到"研发完毕"列 = 服务端执行 finish 动作,走完整任务状态机(工时校验、隐式迁移、父子聚合一个不少)。列映射表在 shared 包,前后端同源。动作失败则拖拽回滚。
  2. eventId 幂等去重:前端重发/断线重连不会造成重复动作。
  3. WIP 超限只警告不拦截:超限广播 board:wip:exceeded,但流程不被卡死——这是禅道的原版取舍,看板方法本就该柔性。

前端用 sortablejs 做乐观更新:先挪卡片再发事件,ack 失败回滚;断线自动重连并重放 join,全量刷新看板。


测试体系:为什么是 130 个"土"脚本断言,而不是 supertest

后端测试我用了两个形态,各有分工:

62 项 Vitest 单测:状态机表驱动测试、枚举契约、权限点完整性、错误码推导——全部围绕 shared 纯函数和 Nest Service 纯逻辑,毫秒级跑完。

130 项 HTTP 断言脚本(自研 .mjs):真正起服务、连真 MySQL/Redis、从登录开始把业务流走一遍:

// apps/server/test/http/p3-assert.mjs(节选)
const assert = (id, desc, pass, detail) => {
  results.push({ id, desc, pass, detail: String(detail).slice(0, 140) });
};

async function api(method, path, body, token) {
  const res = await fetch(BASE + path, { /* ... */ });
  return { status: res.status, ...(await res.json().catch(() => ({}))) };
}

// 非法流转必须被拒:对 done 任务调 start → 期望 4xx + 3006
const bad = await api('POST', `/tasks/${doneTaskId}/start`, {}, admin);
assert('T3', 'done 不可 start(3006)', bad.code === 3006, bad.message);

不用 supertest 的理由:这些脚本的定位不是"测试",是可独立复现的验收证据——node p3-assert.mjs 一条命令,退出码就是失败数,本地、CI、将来任何环境都能跑。验收阶段我需要的是"26/26 通过"这种台账式结果,而不是测试框架的报告文件。

六大脚本各管一段:四层框架 26 例 / 核心实体流转 31 例 / 全链路闭环 18 例 / 文档 OA 审计通知 26 例 / 看板配置统计附件 29 例 / Swagger 结构审计 4 项。加上权限漂移门禁和 7 例 Playwright E2E(登录链、越权访问、冒烟),构成全部质量门禁。


Docker 与 CI

cp .env.example .env    # 改 JWT_SECRET / SEED_ADMIN_PASSWORD
docker compose up -d --build
# 访问 http://localhost 即完整系统

四容器编排有个容易忽略的门禁:迁移必须先于应用启动。server 容器入口先跑 TypeORM migration 再起 Nest(生产 synchronize=false),否则四容器同时启动会出现"应用起来表还没建"的竞态。

CI 是双 job:

  • quality:install → 类型门禁(server ts-check + web vue-tsc build)→ 62 单测 → 三包构建
  • integration:GitHub Actions service 容器起真 MySQL 8.4 + Redis 7 → 迁移 → 起服务 → 权限漂移门禁 → 六脚本全量回归

踩坑实录(都是真实事故)

1. shared 包 CJS/ESM 双格式导致前端构建失败。 一开始 shared 同时产出两种格式想"兼容一切",结果 Vite/Rollup 在某些引用路径下解析到 CJS 产物直接报错。最终统一 ESM,server/web 全链 ESM,问题消失。教训:Monorepo 内部包别贪格式兼容,统一模块体系。

2. TS 严格 ESM 下相对导入必须带 .js 后缀。 import { x } from './foo' 在 moduleResolution: NodeNext 下运行时找不到文件(编译期却不报错),直到服务启动才炸。全量补 .js 后缀 + CI 类型门禁兜底。

3. DTO 字段"神秘消失"。 Nest 全局开了 whitelist: true 后,某个 DTO 忘了挂 @IsString() 之类校验器,前端传的字段被 whitelist 静默剥离,落库永远是 undefined。这类 bug 没报错、没日志,最后靠 HTTP 断言抓到。教训:DTO 校验器必须全覆盖,断言脚本是最好的探测器。

4. Date <= string 恒为 false。 一处过期比较拿 Date 对象和字符串比,TypeScript 不报错(结构上能比),逻辑永远走 false 分支,某个 4951 校验整体失效。教训:边界入参在入口处统一转型,别信类型系统的宽容。

5. 刷新页面后权限点为空。 前端把会话持久化到 localStorage,但恢复会话时不补拉 /auth/me——权限点数组是空的,页面全被 v-permission 拦成 403。这个 bug 是逐页走查(用浏览器自动化逐页 L4 检查)抓到的,单页手工测试根本发现不了。修复:路由守卫发现权限点未加载就先补拉。

6. 401 刷新风暴。 token 过期时页面并发 5 个请求,各自触发刷新 token,互相覆盖还循环重放。解法:raw axios 单飞(single-flight)刷新 + __retried 标记防循环,刷新完成前后续请求挂起排队。

7. Docker 构建三连坑。 没有 .dockerignore,宿主的 node_modules 和 dist 被 COPY 进镜像(Linux 容器里躺着 Windows 的依赖二进制);corepack 漂移到 pnpm 12 后 --frozen-lockfile 行为变化静默跳过安装;compose 漏传 JWT_SECRET 导致四容器环境不一致。全部修复并写进了缺陷存档。

8. 禅道 parentId 的语义双关。 复刻执行树时发现禅道的 parentId 既表"父执行"又表"父阶段",影子迭代(multiple=0 项目自动创建的默认执行)的 close 校验被误判,报了不存在的 4603。靠 grade > 1 过滤才还原正确语义——老系统的隐式约定,只有读源码才看得见。


快速开始

# 前置:Node ≥ 22、pnpm 10、Docker
git clone https://github.com/yhemeo613/zentao-nest.git
cd zentao-nest

pnpm install
cp .env.example .env                                # MySQL/Redis 凭据 + JWT_SECRET
cp apps/server/.env.example apps/server/.env
docker compose up -d mysql redis                    # 基础设施

cd apps/server
pnpm exec tsx node_modules/typeorm/cli.js migration:run -d src/database/data-source.ts
cd ../..
pnpm dev                                            # server :3000 + web :5173

打开 http://localhost:5173 ,种子账号 admin / Admin@123456 登录即可。Swagger 全量 229 端点在 http://localhost:3000/api/docs 。


写在最后

这个项目对我最大的改变是三件事:

  1. 读源码 > 读文档。禅道的文档不少,但"评审结果按版本裁剪""记工时隐式迁移"这类规则只有源码写得清楚。把一个成熟系统的边界情况逐个还原的过程,比读十篇设计模式文章收获都大。
  2. 文档先行不是形式主义。10 份设计文档、13 条 ADR(只增不改)、每条业务规则都能指到 docs 章节——AI 协作时代这更重要,因为文档就是给 AI 的上下文契约。
  3. "可复现的验收证据"应该是一等公民。130 项断言 + 权限漂移门禁 + Swagger 审计,让"我做完了"变成"我证明了做完了"。

路线图:地盘页 100% 复刻收尾、性能 P95 实测、Prometheus 监控、Excel 导入导出、多人任务……详见仓库 docs/09。

项目地址(求 Star ⭐): github.com/yhemeo613/z…

如果对某块实现感兴趣(比如状态机、数据权限、看板协议),欢迎评论区留言,可以展开写续篇。也欢迎 Issue / PR。