项目:nestjs-admin | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
本项目实际存在三套运行环境,部署前先对号入座能省去大量困惑。本机开发环境(macOS,Intel x64)是日常主战场:PostgreSQL 与 Consul 跑在宿主机(dev agent 模式,无持久状态),四个服务以 node dist/main.js 常驻、日志写 /tmp,前端 vite dev server 直连网关。NAS 容器环境是生产主机:一台群晖(btrfs 文件系统),后端单镜像 admin-backend:latest 由 NAS 侧以 legacy builder 构建(DOCKER_BUILDKIT=0),consul/网关/两业务/health 五个容器经 compose 编排,PostgreSQL 与 MinIO 复用 NAS 上已有的 dev-stack 基础设施(dev-postgres、dev-minio 容器),端口 5800 只绑回环地址——一切入站流量都必须经过 frp 隧道与 VPS 反代,NAS 不直接暴露公网。VPS 反代层只做两件事:Let's Encrypt 证书终结与 frp 隧道的公网入口。这个三层拓扑决定了同一个环境变量在不同环境取值完全不同(最典型的是 HOST 与 CHECK:本机写 127.0.0.1,容器里必须写 compose 服务名),下文环境变量清单里的每个"说明"都应结合目标环境来读。此外,环境间同步只有代码与迁移脚本,不同步数据库与 node_modules——HANDOFF 环境迁移记录专门为此写过三个修复步骤(重装依赖、补迁移、对齐软链接),换机或重建环境时务必照做。
| 项 | 要求 | 依据 |
|---|---|---|
| Node.js | 后端日常 v22.15.0(Dockerfile 基于 node:22-bookworm-slim);跑单测必须 v24.9+(jest 30 的 require(esm),HANDOFF 陷阱 21,当前 nvm 已装 v24.21.0);前端 engines.node >=22.22.1(vue-admin/package.json) | 多处 |
| pnpm | 10.x;生产镜像钉死 corepack prepare pnpm@10.23.0(与 lockfile 生成版本一致,Dockerfile 注释) | Dockerfile |
| 操作系统/架构 | 开发机 macOS(Intel x64,darwin 25.3.0);node_modules 必须本机架构安装(跨机同步 ARM 依赖会全挂:argon2 无 darwin-x64 预编译、rolldown/swc/esbuild 缺 binding,HANDOFF 陷阱 30) | HANDOFF §2 |
| 数据库 | PostgreSQL 15(本机 Homebrew)或 16-alpine(容器),两个库 user_db、system_db,账号/口令示例 nestjs/nestjs123 | dev-start.sh、docker-compose.full.yml |
| Consul | 二进制 v2.0.3(仓库 bin/consul 为 darwin-arm64 版;开发路径约定 $HOME/work/bin/consul,可用 CONSUL_BIN 覆盖);或容器 hashicorp/consul:1.21 | bin/、ci.yml |
| 端口占用 | 网关 3040、health 3030、user 40001(多实例 40002/40003)、system 40010、Consul 8500/8501、前端 dev 5173 | dev-start.sh、vite.config |
| 可选 | MinIO(S3 兼容对象存储,缺省时上传功能降级,upload.service.ts onModuleInit 仅 warn 不阻断);NAS 线上凭据走 dev-secrets.env | dev-start.sh |
# 1) 创建两个库(本机 psql)
psql -U nestjs -d postgres -c 'CREATE DATABASE user_db'
psql -U nestjs -d postgres -c 'CREATE DATABASE system_db'
# 2) 迁移(Prisma 7 经 prisma.config.ts 读连接串;user 读 DATABASE_URL,system 读 SYSTEM_DATABASE_URL)
cd nestjs-microservice-app/user && DATABASE_URL='postgresql://nestjs:nestjs123@127.0.0.1:5432/user_db' npx prisma migrate deploy && npx prisma generate
cd nestjs-microservice-app/system && SYSTEM_DATABASE_URL='postgresql://nestjs:nestjs123@127.0.0.1:5432/system_db' npx prisma migrate deploy && npx prisma generate
# 3) 种子(漏掉会导致菜单/权限码 403 —— HANDOFF 陷阱 31 真实事故)
psql -U nestjs -d user_db -f user/prisma/seed.sql
psql -U nestjs -d system_db -f system/prisma/seed.sqlcd nestjs-microservice-app
pnpm install # 首次/换机后;argon2 需再跑 pnpm rebuild argon2(陷阱 30)
pnpm -r build # 产出各服务 dist/main.js(dev-start.sh 跑的是产物)
bash dev-start.sh # 一键起栈(详见下方分解)dev-start.sh 内部动作(脚本实文核对):
DATABASE_URL/SYSTEM_DATABASE_URL/JWT_SECRET=dev-secret-change-me/ROLE_ID=1/CONSUL_HOST=127.0.0.1/HOST=127.0.0.1/GRPC_TLS=on/SERVICE_HEALTHCHECK_ENDPOINTS 双段值);存在 dev-secrets.env 则 set -a; source 注入 MinIO/Redis 凭据,否则回退本机 MinIO 默认值并提示"上传降级"。pkill -f "node dist/main.js"、pkill -f "consul agent"(注意进程名是相对路径,用 pkill -f "gateway/dist/main.js" 匹配不到——陷阱 2;也不要 pkill -f vite,会误杀本机其他项目,停前端用 kill $(lsof -ti :5173)——陷阱 32)。[1/6] 启动两个 Consul dev agent:8500(dev1)与 8501(dev2,http/server/serf-lan/serf-wan/dns/grpc/grpc-tls 全套端口偏移,避免撞 8300——陷阱 12)。[2/6] health :3030 → [3/6] user(PORT=40001 CHECK=http://127.0.0.1:3030/api/v1/health?service=user_grpc)→ [4/6] system(SERVICE_NAME=nestjs-system SERVICE_KIND=system)。[5/6] 轮询 http://127.0.0.1:8500/v1/health/service/{nestjs-service,nestjs-system}?passing=true 直到各 ≥1 个健康实例(首个检查间隔 10s,最多等 45s)。[6/6] 启动 gateway :3040,最后 curl /api 与 health 端点输出状态。日志位置:/tmp/svc-{gateway,user,system,health}.log、/tmp/consul{1,2}.log。多实例 user 服务(根 package.json 预置脚本):pnpm dev:user1(:40002)与 pnpm dev:user2(:40003,SERVICE_ID=nestjs-user-40003、CONSUL_PORT=8501、CHECK_KEY=user2_grpc)——网关侧随机负载均衡自动生效。
cd vue-admin
pnpm install # preinstall 钩子强制 pnpm
pnpm dev # :5173;vite 把 /api、/uploads 反代到 http://localhost:3040vite.config.ts 代理自带 15s/30s 超时与错误兜底:后端不可用时返回 502 JSON {"message":"后端服务不可用,请确认网关 :3040 已启动"} 而非打崩 dev server。构建:pnpm build(8GB heap + rolldown 分包 + gzip/brotli 压缩)、pnpm typecheck(vue-tsc)。
开发期的两个高频小坑也值得提前打预防针。第一,改 .env 后必须重启 vite:环境变量是构建期注入的,热更新不会重读,HANDOFF 曾因此误判"路由模式没生效";同理 public/platform-config.json 虽然是运行时读取,但浏览器对它的缓存偶发导致改完不生效,硬刷新即可。第二,dev 期间热更新可能造成已打开标签页状态错位(URL 与内容不对应、loading 卡死),这与业务无关,硬刷新即恢复;HANDOFF 的方法论是"旧标签页不可信,一律新开标签页复验",把它当作日常习惯可以省掉大量误报排查。另外,冒烟脚本 smoke_fullchain.js 依赖 NODE_PATH=./node_modules 是因为脚本在 gateway 目录下执行却引用根仓共享依赖,若从仓库根直接 node smoke_fullchain.js 反而可能解析失败——按 HANDOFF §3 给出的原样命令执行最稳妥。
pkill -f "node dist/main.js"; pkill -f "consul agent"; kill $(lsof -ti :5173) # 停止(HANDOFF §3)
cd gateway && NODE_PATH=./node_modules node ../smoke_fullchain.js # 全链路冒烟(ALL GREEN)注意 signin 限流 10/min 是特性:冒烟多轮会 429,间隔 60s 或重启网关后再跑(陷阱 26)。
nestjs-microservice-app/.env.example + 各服务源码消费点)| 变量 | 默认值 | 消费方 | 说明 |
|---|---|---|---|
JWT_SECRET | dev-secret-change-me | 网关(JwtModule/JwtStrategy) | 生产必须强随机;缺失时 JwtStrategy 构造期即抛错快速失败 |
DATABASE_URL | postgresql://nestjs:nestjs123@127.0.0.1:5432/user_db | user 服务 Prisma(运行时+CLI) | user_db 连接串 |
SYSTEM_DATABASE_URL | 同上 system_db | system 服务(`SYSTEM_DATABASE_URL | |
PORT | 40001(user)/ 40010(system) | gRPC 服务端监听 + Consul 注册端口 | |
HOST | 127.0.0.1 | Consul 注册地址 | 容器内必须写 compose 服务名(如 user),否则其他容器不可达 |
SERVICE_NAME | nestjs-service | 注册的服务名(发现键) | system 为 nestjs-system |
SERVICE_ID | nestjs-user-${PORT} | 注册实例 ID | 多实例必须显式唯一 |
CONSUL_HOST / CONSUL_PORT | 127.0.0.1 / 8500 | 服务注册侧 | |
CONSUL1_HOST/CONSUL1_PORT、CONSUL2_HOST/CONSUL2_PORT | localhost / 8500、8501 | 网关发现侧(app.module.ts) | 容器编排中两者可指向同一 Consul 实例(生产 compose 即如此) |
CHECK | http://127.0.0.1:3030/api/v1/health?service=user_grpc | Consul HTTP 健康检查 URL | 容器内写 http://health:3030/... |
SERVICE_HEALTHCHECK_ENDPOINTS | `user_grpc,127.0.0.1:40001,user,user,user_grpc | system_grpc,127.0.0.1:40010,system,system,system_grpc` | health/网关的 ConsulController |
GRPC_TLS | on(dev-start.sh) | 服务端/网关客户端/health 探测端三处 | on 才启用 TLS;生产漏开会在 60s 后被 Consul 注销(§4.5 部署坑) |
ROLE_ID | 1 | user 服务注册/建用户默认角色 | 需与 roles 表种子一致 |
MINIO_ENDPOINT/PORT/USE_SSL/ACCESS_KEY/SECRET_KEY/BUCKET | 127.0.0.1/9000/false/''/''/nestjs-demo | 网关 UploadService | 缺凭据时上传功能降级(不崩) |
VERSION / PREFIX | 空 / /api | 网关 URI 版本控制与全局前缀 | |
SWAGGER_ENABLED | 未设(非生产默认开) | 网关 Swagger 开关 | 生产默认关闭,可显式 true 打开 |
CONSUL_TOKEN | 未设 | ConsulHttpClient X-Consul-Token | ACL 场景使用 |
CONSUL_BIN | $HOME/work/bin/consul | dev-start.sh | Consul 二进制路径 |
vue-admin/.env*)| 变量 | dev 值 | 说明 |
|---|---|---|
VITE_PORT | 5173(.env 基础文件写 8848,dev 覆盖为 5173) | dev server 端口 |
VITE_ROUTER_HISTORY | "h5"(三个模式文件均 h5) | 路由模式;改后 vite 必须重启;h5 需服务端 SPA 回退 |
VITE_PUBLIC_PATH | / | 构建基础路径 |
VITE_CDN | dev false / staging true / prod false | 构建时 CDN 替换本地依赖 |
VITE_COMPRESSION | prod "both" | gzip+brotli(不删原文件) |
VITE_HIDE_HOME | false(.env) | 隐藏首页开关(守卫据此拦 /welcome) |
运行时平台配置在 public/platform-config.json(标题"后台管理系统"、Layout=vertical、KeepAlive=true、TagsStyle=chrome、CachingAsyncRoutes=false 等),src/config/index.ts 的 getConfig 读取——发版只改静态文件即可调整外观行为,无需重编译。
dev-secrets.env(已被 .gitignore 忽略,仓库内此副本含真实开发凭据)是本机开发时注入 NAS 线上基础设施凭据 的载体:dev-start.sh 检测到该文件即 set -a; source; set +a 全量导出。当前内容两类:REDIS_*(106.15.35.71:16379——已无消费方,验证码功能于 2026-09-10 整体移除后变量保留但无人读取)与 MINIO_*(106.15.35.71:19000,NAS dev-minio 实例,桶 nestjs-demo)。
正式环境注意事项:① 该文件模式即"机密不入库"的实践,生产侧对应物是 admin-vue-deploy/env.production.template 落地为 NAS /volume1/docker/admin-vue/.env(600 权限,compose ${VAR} 插值消费);② 生产 JWT_SECRET 模板明确要求 CHANGE_ME_TO_STRONG_RANDOM_64HEX,PG_PASSWORD 与 MinIO 密钥同样经该 .env 注入;③ 演示账号 p1user 的密码已在前端+后端双侧写死保护(不可改密),生产账号 prodadmin/admin123456 在 HANDOFF §4.5 中标注"建议改密";④ 换环境部署时 MinIO 指向必须随网络拓扑调整(生产 compose 用 NAS 局域网名 dev-minio 而非公网 IP)。
Dockerfile(生产主用,monorepo 单镜像):node:22-bookworm-slim + openssl;corepack 钉 pnpm 10.23;npmmirror 与 Prisma 引擎镜像加速;先拷各包 package.json 利用层缓存,pnpm install --frozen-lockfile 后拷全量源码;按拓扑序显式构建 proto-pkg→health→user→system→gateway(template 不参与)。EXPOSE 3040 3030 40001 40010,默认 CMD 跑网关——具体跑哪个服务由 compose command 指定(如 node /app/user/dist/main.js)。NAS 构建纪律:DOCKER_BUILDKIT=0 docker build(btrfs+BuildKit 死锁雷,Dockerfile 头注释)。Dockerfile.service(参数化单服务镜像):node:24-alpine 三阶段(deps→build→runtime);--build-arg SERVICE=user|system|health|gateway 决定构建与运行目标;user/system 额外执行 prisma generate;运行时仅拷 node_modules、proto-pkg 的 dist/proto、目标服务 dist。服务拓扑与依赖门控:consul(agent -dev)+ postgres(带健康检查)→ migrate(一次性任务:user 服务跑 prisma migrate deploy,service_completed_successfully 门控)→ system-db-init(一次性 psql 建 system_db)→ migrate-system → health/user/system(GRPC_TLS=on,HOST 写服务名,CHECK 指向 health 容器)→ gateway(CONSUL1/2_HOST=consul)。数据卷 pgdata。该文件自述"在无 docker 的开发机上编写,首次使用请先核对镜像与服务发现地址"——⚠️ 待确认是否已在目标环境实测。
链路要点(全部有配置佐证):① 双层 nginx——VPS 层 proxy_set_header X-Forwarded-For $remote_addr 覆盖式写真实 IP(防伪造),前端容器层再把 $http_x_forwarded_for 原样透传(注释明确"不能覆盖成 $remote_addr"),网关 trust proxy 1 最终取到正确客户端 IP,限流按真实来源分桶;② 前端容器 nginx try_files $uri $uri/ /index.html 提供 history 路由 SPA 回退(去掉会全站深链接 404,HANDOFF 明确警告),且 index.html 强制 no-cache、/assets/ 一年 immutable;③ 镜像内自签证书 SAN 仅 localhost,容器间以服务名互连会 ERR_TLS_CERT_ALTNAME_INVALID,解法是把含服务名 SAN 的新证书卷挂覆盖 /app/proto-pkg/certs/(admin-vue-deploy/docker-compose.yml 的 user/system 两服务 volumes 注释);④ 发布流程 admin-vue-deploy/sync.sh:本机前端 pnpm build → dist 传 NAS dist-new(旧 dist 保留,NAS 侧原子切换)+ 后端源码 tar 管道同步 → NAS 侧 docker compose --no-build 重建(legacy builder 兼容);⑤ 域名 HTTPS 由 certbot 管理(vps-nginx-admin.conf 注释警告:不要预置 return 404 占位块,会压过 certbot 的 80→301 跳转)。
生产发布的完整节奏可以概括为"同步、构建、切换、验证"四拍。同步阶段跑 bash admin-vue-deploy/sync.sh,它把前端构建产物与后端源码分别送到 NAS 的暂存位置,且刻意保留旧 dist 目录——这就是回滚的物质基础。构建阶段在 NAS 侧完成(后端 DOCKER_BUILDKIT=0 docker build -t admin-backend:latest .,前端无需构建、只做目录原子切换)。切换阶段 docker compose up -d --no-build 逐个重建容器,compose 的 restart: unless-stopped 保证进程崩溃自动拉起。验证阶段按 HANDOFF §4.5 的清单走:公网 200/301、signin 200、my-menus 200、限流 10 次后 429(证明 XFF 链路正确)、浏览器登录到 welcome 全站可用。回滚则视变更范围而定:前端回滚把 NAS 上保留的旧 dist 目录切回去即可(秒级);后端回滚需要旧镜像 tag 或旧源码目录重新 build;数据库回滚最重——Prisma 迁移只进不退,实际操作是"前滚修复"(写一个新迁移抵消问题变更),这也是发布前必须在本地把 prisma migrate deploy 全量走一遍的原因。发布还有一个高频事故源要牢记:live 库曾出现"迁移已跑但 seed 未重放"的半初始化状态,任何新环境搭建或库重建后,都应把"migrate deploy + seed.sql + 冒烟 ALL GREEN"视为不可拆分的完成标准。
| 层级 | 内容 | 命令 | 现状(源码/HANDOFF 依据) |
|---|---|---|---|
| 后端类型检查 | 各包 strict tsc | pnpm exec tsc -p tsconfig.build.json --noEmit(CI 由 pnpm -r build 覆盖) | 全部通过为门禁 |
| 网关单测 | 26 个:consul 发现/竞速/自愈、auth(signin 返回体断言)、policies、serialize(*.spec.ts,jest 配置 customExportConditions:[node,require]) | cd gateway && pnpm test(需 Node 24.9+,cross-env NODE_OPTIONS=--experimental-vm-modules) | HANDOFF 第四排:26/26 全绿 |
| system 单测 | 7 个:日志真分页逻辑(skip/take、筛选构建) | cd system && pnpm test | 7/7 全绿;user 服务无测试文件(jest 0 matches 报错属正常) |
| 网关 e2e | 4 个(test/app.e2e-spec.ts,需先起 health) | pnpm run test:e2e | CI 中在 health 启动后执行 |
| 全链路冒烟 | smoke_fullchain.js:signin → my-menus(22 菜单)→ dashboard → 验证码已下线 → ALL GREEN | NODE_PATH=./node_modules node ../smoke_fullchain.js | 需全栈已启动;受 10/min 限流约束 |
| CI | GitHub Actions(.github/workflows/ci.yml):consul+postgres 服务容器 → pnpm install --frozen-lockfile → proto-pkg 构建 → prisma generate → pnpm -r build → prisma migrate deploy → 网关单测 → 起 health → e2e | push/PR 触发 | — |
| 前端门禁 | pnpm typecheck(vue-tsc --noEmit --skipLibCheck)+ pnpm build;pnpm lint(eslint --max-warnings 0 + prettier) | 每次 MUST(HANDOFF 各里程碑验收标准) | — |
| 验收基线 | 浏览器人工实测(登录/各页/深链接/暗色)+ 布局体检脚本方法论(HANDOFF 前端巡检轮沉淀) | — | — |
单测环境注意(陷阱 15/21 集中此处):jest 30 + NestJS 12 ESM-only 包必须 NODE_OPTIONS=--experimental-vm-modules(已写进 test 脚本);Node 需 ≥24.9(建议直接用 nvm 的 v24.21.0,把 ~/.nvm/versions/node/v24.21.0/bin 前置到 PATH);zsh 非交互 shell 里 node 可能是 nvm 懒加载包装函数,脚本内用绝对路径。
对这套测试体系的定位还应有个清醒的认识:它是分级门禁而非全覆盖。单测集中在最有价值也最易回归的两块——服务发现的竞速/自愈逻辑(并发与时序敏感,手工很难测)与日志分页的筛选构建(条件组合多、曾被同一类 bug 咬过两次);而业务服务的 57 个 RPC 大量依赖真实数据库行为,项目选择用"全链路冒烟 + 浏览器实测"代替逐 RPC 单测,这在教学/演示规模下是务实之举,但意味着改业务服务的读路径时不要指望单测兜底,必须本地起栈跑冒烟并人工过一遍相关页面。e2e 的 4 个用例跑在 CI 的服务容器之上,验证的是网关到数据库的纵向打通,不覆盖 Consul 竞速这类需要多实例拓扑的场景。前端则完全没有自动化测试(无 vitest/jest 配置),质量靠 typecheck 严格模式、eslint 零警告门禁与发布前的人工巡检支撑——如果要补前端测试,优先级建议是 utils/http/index.ts 的刷新队列(时序复杂)与 api/routes.ts 的菜单适配(纯函数、易测、出错影响全站导航)。
排障时建议遵循一条固定顺序,能少走弯路:先看网关日志(/tmp/svc-gateway.log,请求行带 traceId、状态码与耗时)确定请求走到了哪一层;状态码正常但业务不对,再看目标服务日志(/tmp/svc-user.log、/tmp/svc-system.log)确认 gRPC 层是否有 Prisma 错误;服务间发现类问题看 Consul 日志(/tmp/consul1.log)与 curl http://127.0.0.1:8500/v1/agent/services 的注册表实况;涉及健康状态加查 curl "http://127.0.0.1:8500/v1/health/service/nestjs-service" 看 Checks 数组的 Status。日志页本身也是一等排障工具:操作日志按 traceId 反查请求上下文,系统日志看网关 5xx 堆栈,登录日志核对认证行为。下面这张速查表覆盖了 HANDOFF 陷阱清单中与部署运行直接相关的高频项。
| 症状 | 根因与处置 |
|---|---|
登录 500,user 日志报 column users.preferences does not exist | 库缺迁移:两服务各跑 npx prisma migrate deploy(陷阱 31) |
| 接口 403 / 菜单缺失 | ① seed 未重放(跑 seed.sql);② role_menus 未授权(INSERT INTO role_menus SELECT 1, id FROM menus);③ 权限码缓存未过期(60s) |
| 网关起不来 / 任意 gRPC 报 no passing instance | Consul 未起或 CHECK 配错;health 探测端需 GRPC_TLS=on,否则实例 60s 被注销 |
容器间 gRPC 报 ERR_TLS_CERT_ALTNAME_INVALID | 自签证书 SAN 不含服务名:重签并卷挂覆盖 proto-pkg/certs |
| 限流把全站折叠成一个桶(429 频发) | 反代链路未透传 XFF 或网关 trust proxy 未设;确认两层 nginx 的 XFF 配置 |
| 冒烟第二轮起 429 | signin 10/min 特性;等 60s 或重启网关 |
| 新环境前端深链接 404 | 生产 nginx 丢 try_files ... /index.html;必须保留 SPA 回退 |
argon2 报 No native build was found (abi=127) | node_modules 架构不匹配:CI=true pnpm install + pnpm rebuild argon2(陷阱 30) |
| argon2 报 No native build was found (abi=127) | node_modules 架构不匹配:CI=true pnpm install + pnpm rebuild argon2(陷阱 30) |
最后给出一条"新环境从零到可用"的完整 checklist,把本章各节串成一次可执行的演练:装 Node 22(+nvm 备一个 24 跑单测)与 pnpm 10 → 装/定位 PostgreSQL 与 Consul 二进制 → 建两库并跑双服务 migrate deploy → 重放两份 seed.sql → pnpm install(原生包后 pnpm rebuild argon2)→ pnpm -r build → bash dev-start.sh 看到"passing: user≥1 system≥1"与 /api 探针返回 → 前端 pnpm dev 打开 5173 用 p1user 一键登录 → 跑 smoke_fullchain.js 确认 ALL GREEN → 有余力再跑双端单测与 e2e。生产化则在此之上叠加:强随机 JWT_SECRET、独立数据库实例与强口令、重签含服务名 SAN 的 gRPC 证书、VPS 证书与 frp 隧道、按 4.4 节的四拍发布流程上线。按此清单走完,读者应当能在半小时内获得一个与本仓库行为完全一致的运行实例;任何一个步骤报错,优先回到本章对应小节与 HANDOFF 陷阱清单对照,而不是凭感觉改代码——本项目的绝大多数"环境问题"都有前人踩坑记录可查。