J11 · 收尾与上线
最后一天:让 TaskFlow 能交给别人运维。基于 DTO 生成的 OpenAPI 文档、给编排平台用的健康探针、安全响应头和限流、精简的 Docker 镜像、 GitLab 流水线。Spring Boot 默认就提供其中一部分(Actuator、Spring Security);在 Nest 里,每块积木都要显式添加。这一天以回顾项目、 LEARNINGS.md 和 README 收尾。
目标
- 用
@nestjs/swagger及其 CLI 插件在/docs上发布 OpenAPI 文档,并知道什么时候需要写@ApiProperty、@ApiTags、@ApiBearerAuth。 - 提供 Terminus 健康检查(数据库、内存、磁盘),分成 liveness 和 readiness。
- 启用
helmet、CORS 和限流(@nestjs/throttler)。 - 构建一个多阶段、非 root、使用
npm ci --omit=dev的 Docker 镜像。 - 编写 GitLab CI 流水线:安装、lint、测试、构建镜像。
- 做最终回顾:重读
LEARNINGS.md,完善 README。
Spring → NestJS
| Spring | NestJS | 要点 |
|---|---|---|
| springdoc-openapi | @nestjs/swagger + CLI 插件 | 插件在编译时读取类型,而不是在运行时 |
@Schema、@Parameter | @ApiProperty、@ApiPropertyOptional、@ApiQuery | 有了插件,大多数 DTO 都用不着 |
@Tag | @ApiTags('projects') | 在界面中给路由分组 |
@SecurityRequirement | @ApiBearerAuth() + .addBearerAuth() | Swagger UI 中的 “Authorize” 按钮 |
Actuator /actuator/health/liveness、/readiness | @nestjs/terminus:/health/live、/health/ready | 控制器自己写,指标自己选 |
| Spring Security 的默认响应头 | helmet() | Express 中间件,需要启用 |
CorsConfigurationSource、@CrossOrigin | app.enableCors({ origin }) | origin: false 时关闭 |
| Bucket4j、API 网关 | @nestjs/throttler | 计数器在内存中,按实例统计 |
| JRE 镜像 + jar(Jib、buildpacks) | 基于 node:24-alpine 的多阶段 Dockerfile | 没有单一的 jar:dist/ + 生产环境的 node_modules |
GitLab CI 中的 mvn verify | npm ci、npm run lint、npm run build、npm run test:cov、npm run test:e2e | 每个 job 都从一个空容器开始 |
原理
用 @nestjs/swagger 生成 Swagger 与 OpenAPI
DocumentBuilder 描述 API(标题、版本、认证方案),SwaggerModule.createDocument() 遍历控制器及其元数据,SwaggerModule.setup('docs', ...) 在 /docs 上提供界面,在 /docs-json 上提供 JSON,在 /docs-yaml 上提供 YAML。
要解决的问题:运行时 name: string 已经不存在了。没有辅助手段的话,每个 DTO 属性都得加一个 @ApiProperty(),重复写类型和约束。在 nest-cli.json 中声明的 CLI 插件会在 nest build / nest start 期间运行:它读取 TypeScript 类型、可选属性的 ?,以及(开启 classValidatorShim 时)class-validator 的装饰器,然后把 OpenAPI 元数据加到编译后的代码里。在 TaskFlow 上验证过:CreateProjectDto 生成的 name 是必填的,带有 maxLength: 100。开启 introspectComments 后,/** ... */ 注释会变成描述。
默认情况下,插件只处理 *.dto.ts 和 *.entity.ts 文件:放在其他文件里的 DTO 会被悄悄忽略。所以 TaskFlow 的分页 DTO 叫 pagination.dto.ts(/docs 上的 Swagger)。显式装饰器在这些情况下仍然有用:示例值、描述、插件不处理的文件,或者要隐藏的字段 (@ApiHideProperty(),见下文):
// TaskFlow 之外的示例
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateProjectDto {
@ApiProperty({ example: 'Website', description: '项目的显示名称' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
}
// 在插件不读取的文件中(query.ts,没有 .dto.ts 后缀):一切都要手写
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
}@ApiTags('projects') 把一个控制器的路由归为一组;@ApiBearerAuth() 配合 DocumentBuilder 中的 .addBearerAuth(),表明这些路由需要 JWT, 并添加 “Authorize” 按钮。装了 Swagger 之后,PartialType 等函数改从 @nestjs/swagger 导入(mapped types)。
类比失效的地方:springdoc 在运行时通过反射读取 Bean Validation 注解。这里的信息只在编译时存在:如果代码不是由 nest build / nest start 编译的 (普通的 tsc、ts-node、ts-jest),插件不会运行,文档就是空的。
用 @nestjs/terminus 做健康检查
TerminusModule 提供 HealthCheckService 和一系列指标。health.check([...]) 执行这些指标并返回 { status, info, error, details };如果有一个失败, 它会抛出带着同一个对象的 ServiceUnavailableException(503)。TaskFlow 用到的指标:
| 指标 | 检查内容 | 何时失败 |
|---|---|---|
TypeOrmHealthIndicator.pingCheck('database', { timeout }) | 对数据库发一条测试查询 | 超时未响应 |
MemoryHealthIndicator.checkHeap('memory_heap', 字节数) | 已用的 V8 堆 | 超过阈值(这里是 512 MiB) |
DiskHealthIndicator.checkStorage('disk', { path, thresholdPercent }) | 磁盘占用 | 这里是超过 90% |
两个探针,回答两个问题:
- liveness:进程还能响应吗?失败时,编排平台会重启容器。它不能依赖任何外部资源:如果它检查数据库,PostgreSQL 一出故障就会重启所有实例, 却什么也修不好。
- readiness:这个实例能接收流量吗?失败时,它会被从负载均衡中摘除,直到数据库恢复。
这正是 Spring Boot 的划分(/actuator/health/liveness 和 /readiness)。类比失效的地方:Actuator 根据 classpath 自动配置;这里要自己写控制器、 自己选指标。控制器用 version: VERSION_NEUTRAL 退出版本控制(是 /health,不是 /v1/health,见 API 版本), 标注了 @Public()(J8)和 @SkipThrottle()。@HealthCheck() 装饰器会添加响应头 Cache-Control: no-cache, no-store, must-revalidate,并在 Swagger 中为响应生成文档。
安全:helmet、CORS、限流
helmet 是一个 Express 中间件,用来设置安全响应头:Content-Security-Policy、Strict-Transport-Security、X-Content-Type-Options: nosniff、 X-Frame-Options: SAMEORIGIN 等,并去掉 X-Powered-By。相当于 Spring Security 默认添加的那些响应头。已在浏览器中验证:在 helmet() 的默认配置下, Swagger UI 能正常显示。
CORS 只和浏览器有关:浏览器会拦截来自其他源、且没有 Access-Control-Allow-Origin 的响应。curl 和服务器之间的调用不受影响。 app.enableCors({ origin }) 接受一个源列表;origin: false 不发送任何 CORS 响应头,相当于对其他源关闭 API。
限流:ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }])(ttl 以毫秒计)定义默认限制,由 ThrottlerGuard 执行,它以 APP_GUARD 的形式 声明在 JWT guard 之前(全局 guard)。@Throttle() 为某条路由修改这个限制,@SkipThrottle() 去掉它。超过限制时, guard 抛出 ThrottlerException:429,带 Retry-After 响应头,在 TaskFlow 中响应体是全局过滤器生成的 problem+json,带 "code": "TOO_MANY_REQUESTS"。Spring 没有内置的对应物(用 Bucket4j、Resilience4j 或 API 网关)。
客户端按 IP(req.ip)识别,计数器保存在内存中,按实例统计:有三个副本时,一个客户端能发出三倍于限制的请求。多个命名限制可以共存:
// TaskFlow 之外的示例:两个时间窗口,再对一条路由单独覆盖
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1_000, limit: 3 },
{ name: 'long', ttl: 60_000, limit: 100 },
]);
@Throttle({ short: { limit: 1, ttl: 1_000 } })
@Post('exports')
createExport() { /* ... */ }多阶段 Dockerfile
在 Java 这边,最终镜像包含一个 JRE 和一个 jar。Node 应用没有单一的产物:需要编译后的代码(dist/)和生产依赖(node_modules)。多阶段构建把这两种 需求分开:
build阶段:安装全部依赖(TypeScript、Nest CLI、Swagger 插件),然后执行nest build;runtime阶段:npm ci --omit=dev只安装dependencies(及其自身的依赖),再从上一阶段复制dist/。Jest、oxlint 和 Nest CLI 不在最终镜像里。 TypeScript 却在:@nestjs/swagger12 依赖它,TypeORM 1.1 依赖ts-node。TaskFlow 生产环境的node_modules大约 169 MB。
npm ci 严格按照 package-lock.json 安装,如果 package.json 和 lockfile 不一致就会失败:它是 CI 和镜像构建用的命令,npm install 则是开发机上用的。 先复制 package.json 和 lockfile、再复制代码,只要依赖不变,Docker 就能复用依赖那一层。
三项运维设置:
USER node:官方镜像提供了一个非 root 的node用户。应用里的漏洞不会让攻击者获得容器内的 root 权限。CMD ["node", "dist/main.js"](exec 形式):node是主进程,容器停止时直接收到SIGTERM。配合enableShutdownHooks(),Nest 会正常关闭连接池 (生命周期)。HEALTHCHECK:Docker(以及 Compose)会请求/health/live。Kubernetes 忽略这条指令,使用自己的探针。
.dockerignore 把 node_modules、dist、覆盖率报告、测试、.git 和 .env* 文件排除在构建上下文之外:本地的密钥不会进入镜像。
GitLab CI:lint、测试、构建镜像
GitLab 流水线是一系列阶段(stages);同一阶段的 job 并行运行,每个都在一个全新的容器里。所以要在 before_script 中执行 npm ci(通过 default 对所有 job 生效),并以 package-lock.json 为键缓存 .npm 目录。job 可以启动 services(这里是给端到端测试用的 PostgreSQL),也可以用 rules 限制运行条件(这里只在默认分支上构建镜像)。
| 学习计划中的阶段 | .gitlab-ci.yml 中的 job | 命令 |
|---|---|---|
| install | 每个 job 的 before_script | npm ci --cache .npm --prefer-offline |
| lint | lint-and-build | npm run lint(oxlint)、npm run typecheck,然后 npm run build(nest build),每个分支都执行 |
| test | unit-tests、e2e-tests | npm run test:cov、npm run test:e2e |
| build | docker-image,默认分支 | docker build,然后 docker push 到 GitLab 镜像仓库 |
TaskFlow 案例
/docs 上的 Swagger
插件在 CLI 配置中声明:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "classValidatorShim": true, "introspectComments": true }
}
]
}
}文档由 setupSwagger() 挂载,只有 main.ts 会调用它:
export function setupSwagger(app: INestApplication): void {
const config = new DocumentBuilder()
.setTitle('TaskFlow API')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config));
}// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
setupApp(app);
setupSwagger(app);
await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();- 给
SwaggerModule.setup()传一个函数,会把文档的构建推迟到第一次请求/docs或/docs-json时,之后缓存在内存中。 /docs在所有环境中都提供,包括生产环境。要隐藏它,就根据NODE_ENV有条件地调用setupSwagger()。- 控制器上标注了
@ApiTags()和@ApiBearerAuth():
@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
@Roles(Role.Admin) // every route of this controller: ADMIN only, otherwise 403
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
findAll() {
return this.users.findAll();
}
}插件根据 class-validator 装饰器为 CreateProjectDto 生成的 schema:
{
"type": "object",
"properties": {
"name": { "type": "string", "maxLength": 100 },
"description": { "type": "string", "maxLength": 2000 }
},
"required": ["name"]
}阅读 /docs-json 时要检查两点,TaskFlow 都已处理好:
GET /v1/projects的文档包含search、archived、page和limit。后两个来自src/common/dto/pagination.dto.ts中的PaginationQueryDto: 正是.dto.ts后缀让插件去读这个文件。如果命名为pagination.ts,它会被悄悄忽略,page和limit会从文档中消失,而校验照样生效。Userschema(通过Project的关联关系引用到)不包含passwordHash。这个字段带两个装饰器,各管一件事:
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 254, unique: true })
email: string;
// Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
// @ApiHideProperty keeps it out of the OpenAPI schema as well.
@Exclude()
@ApiHideProperty()
@Column({ name: 'password_hash', type: 'varchar' })
passwordHash: string;
@Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
role: Role;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}@Exclude() 在运行时生效:ClassSerializerInterceptor 把这个字段从返回的 JSON 中去掉(J8)。它不影响文档,文档是插件 根据类型构建的:没有 @ApiHideProperty() 的话,passwordHash 会在 /docs 中被描述,尽管没有任何响应包含它。
用 Terminus 实现 /health:liveness 与 readiness
// /actuator/health/liveness and /readiness. VERSION_NEUTRAL keeps them at
// /health/... instead of /v1/health/... for load balancers and Kubernetes.
@ApiTags('health')
@Public()
@SkipThrottle()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly memory: MemoryHealthIndicator,
private readonly disk: DiskHealthIndicator,
) {}
/** Liveness: the process answers. No dependency, or a DB outage restarts every pod. */
@Get('live')
@HealthCheck()
live() {
return this.health.check([]);
}
/** Readiness: ready to receive traffic. */
@Get(['', 'ready'])
@HealthCheck()
ready() {
return this.health.check([
() => this.db.pingCheck('database', { timeout: 1_500 }),
() => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
() => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
}@Get(['', 'ready']) 在 /health 和 /health/ready 上提供 readiness。一切正常时的响应:
{
"status": "ok",
"info": {
"database": { "status": "up" },
"memory_heap": { "status": "up" },
"disk": { "status": "up" }
},
"error": {},
"details": {
"database": { "status": "up" },
"memory_heap": { "status": "up" },
"disk": { "status": "up" }
}
}/health/live 不检查任何外部资源:{"status":"ok","info":{},"error":{},"details":{}}。
某个指标失败时,Terminus 抛出 ServiceUnavailableException,并记录 “Health Check has failed!” 及详情。在 TaskFlow 中,全局过滤器像处理其他异常一样 转换它:响应是 503 application/problem+json,各指标的详情只出现在日志中。
{
"status": 503,
"code": "SERVICE_UNAVAILABLE",
"message": "Service Unavailable Exception",
"path": "/health",
"timestamp": "2026-11-24T10:41:07.215Z",
"correlationId": "c4e1a7d2-5b3f-4e8a-9d61-0f2b7a9c3e55"
}对编排平台来说,只有 HTTP 状态码重要。如果需要让人在响应里看到是哪个指标挂了,就让这个控制器直接返回 Terminus 的响应体。端到端测试验证了 /health (数据库 up)和 /health/live。
Dockerfile 与 Compose 中的 app 服务
# syntax=docker/dockerfile:1
# Stage 1: install everything and compile TypeScript to dist/.
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build
# Stage 2: production dependencies and the compiled code only.
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# The official image ships a non-root "node" user.
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -qO- http://127.0.0.1:3000/health/live || exit 1
CMD ["node", "dist/main.js"]node_modules
dist
coverage
.env*
!.env.example
.git
test
*.logCompose 中的 app 服务在 app profile 下构建这个镜像:
# docker compose up -d -> PostgreSQL + Adminer for `npm run start:dev`
# docker compose --profile queue up -d -> + Redis for the BullMQ queue (J9)
# docker compose --profile app up -d -> + the API itself, built from the Dockerfile (J11)
services:
db:
image: postgres:16-alpine
container_name: taskflow-db
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskflow -d taskflow"]
interval: 5s
timeout: 3s
retries: 10
# Throwaway database for e2e tests: data lives in memory (tmpfs).
db-test:
image: postgres:16-alpine
container_name: taskflow-db-test
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- db
redis:
image: redis:7-alpine
profiles: [queue]
ports:
- "6379:6379"
app:
build: .
profiles: [app]
environment:
NODE_ENV: production
PORT: 3000
DB_HOST: db
DB_PORT: 5432
DB_USER: taskflow
DB_PASSWORD: taskflow
DB_NAME: taskflow
# Empty unless exported: the app then refuses to start (validateEnv), while
# the other services still work without it.
JWT_SECRET: ${JWT_SECRET:-}
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:JWT_SECRET: ${JWT_SECRET:-}:值来自 shell(或所在目录的.env文件)。缺失时为空:应用拒绝启动(validateEnv要求至少 8 个字符),而 Compose 的其他服务照常运行。DB_HOST: db:Compose 网络中的服务名,不是localhost。- 带
condition: service_healthy的depends_on会等到pg_isready有响应。 NODE_ENV: production:没有演示模块,JSON 日志,级别为info。
镜像不会在启动时执行迁移:这是一个单独的步骤,TaskFlow 的 README 中有说明。migration:run 脚本依赖 ts-node 和 src/database/data-source.ts,镜像里 都没有;npm run migration:run:prod 在编译后的 DataSource 上运行 TypeORM CLI(typeorm migration:run -d dist/database/data-source.js),它会加载 dist/ 中编译后的迁移。在启动 API 之前,用同一个镜像起一个一次性容器来执行它:
export JWT_SECRET=$(openssl rand -hex 32) # 否则 app 容器会拒绝启动
docker compose --profile app build
docker compose --profile app run --rm app npm run migration:run:prod
docker compose --profile app up -d
curl http://localhost:3000/health/liverun --rm 会启动 db(通过 depends_on),用这条命令代替 CMD 执行,然后删除容器。导出的 JWT_SECRET 只给 app 容器用:docker compose up -d (数据库和 Adminer)不需要它。没有这个密钥,app 容器会因为 validateEnv 的错误立即退出 (“property JWT_SECRET has failed the following constraints: minLength, isString”,J7)。
在 TaskFlow 上验证过:docker build 成功;在空数据库上,migration:run:prod 创建了所有表,然后容器启动;它以 node 用户运行;连接 PostgreSQL 时 /health 和 /health/live 都返回 ok;生产环境下 /demo/ping 返回 404(演示模块没有加载);日志是 JSON 行。
.gitlab-ci.yml 流水线
stages: [verify, build]
default:
image: node:24-alpine
cache:
key:
files: [package-lock.json]
paths: [.npm/]
before_script:
- npm ci --cache .npm --prefer-offline
lint-and-build:
stage: verify
script:
- npm run lint
- npm run typecheck
- npm run build # nest build, with the Swagger plugin, on every branch
unit-tests:
stage: verify
script:
- npm run test:cov
coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/'
e2e-tests:
stage: verify
services:
- name: postgres:16-alpine
alias: db
variables:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
NODE_ENV: test
DB_HOST: db
DB_PORT: "5432"
DB_USER: taskflow
DB_PASSWORD: taskflow
DB_NAME: taskflow_test
JWT_SECRET: ci-only-secret-value
script:
- npm run test:e2e
docker-image:
stage: build
image: docker:27
services: [docker:27-dind]
before_script: []
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push --all-tags "$CI_REGISTRY_IMAGE"lint-and-build:oxlint(包括no-floating-promises)、tsc --noEmit,然后npm run build,也就是带 Swagger 插件的nest build,每个分支都执行。 构建错误在 merge request 阶段就会暴露,不用等到构建镜像。unit-tests:低于覆盖率阈值时npm run test:cov失败(覆盖率);coverage表达式从 Jest 文本报告的All files行中提取第一个百分比,显示在 GitLab 中。e2e-tests:一个postgres:16-alpine服务,可通过名字db访问。job 的变量同时配置服务(POSTGRES_*)和应用(DB_*、JWT_SECRET、NODE_ENV=test)。CI 中没有.env.test文件:一切都来自变量。docker-image:Docker-in-Docker(docker:27-dind),用预定义变量CI_REGISTRY_*登录 GitLab 镜像仓库,打两个 tag(commit 和latest)。 runner 必须允许 Docker-in-Docker(特权模式)。
未验证
验证期间,这条流水线没有在 GitLab runner 上运行过。它执行的命令(npm run lint、npm run typecheck、npm run build、npm run test:cov、 npm run test:e2e)在本地都能通过,镜像也能用 docker build 构建。
最终回顾:LEARNINGS.md 与 README
从头到尾重读 LEARNINGS.md。模板为每一天预留了“与 Spring 的区别”“遇到的陷阱”“待深入”三项(学习日志)。补全缺失的日子, 然后在文件开头总结出让你付出代价最大的五个与 Spring 的区别。把它们和常见陷阱列表对照一下。
完善 README,让新人不用找你也能上手。TaskFlow 的 README 已经涵盖启动、脚本、Redis 选项、Docker 操作顺序(密钥、构建、migration:run:prod,然后 up) 和项目结构。还缺:
- 一张环境变量表(
.env.example中的变量,包括PROJECTS_STORE、REDIS_URL、CORS_ORIGIN); - 端到端测试的前提条件(
db-test、.env.test的内容); - 运维用的 URL:
/docs、/health、/health/live、/health/ready; - 每个 CI job 的作用。
每一天的内容在 TaskFlow 中的位置:
| 天 | 文件 | 章节 |
|---|---|---|
| J1 | src/main.ts、docker-compose.yml、src/demo/event-loop.controller.ts | J1 |
| J2 | src/projects/dto/project.dto.ts、src/projects/projects.controller.ts | J2 |
| J3 | src/projects/projects.store.ts、src/projects/projects.module.ts、src/common/common.module.ts | J3 |
| J4 | src/projects/entities/、src/projects/typeorm-projects.store.ts、src/database/ | J4 |
| J5 | src/tasks/entities/task.entity.ts、src/database/migrations/ | J5 |
| J6 | src/common/filters/、src/common/interceptors/、src/common/middleware/ | J6 |
| J7 | src/config/、src/app.module.ts | J7 |
| J8 | src/auth/、src/users/ | J8 |
| J9 | src/tasks/events/、src/tasks/tasks.scheduler.ts、src/notifications/ | J9 |
| J10 | src/**/*.spec.ts、test/app.e2e-spec.ts、jest.config.ts | J10 |
| J11 | src/health/、src/app.setup.ts、Dockerfile、.gitlab-ci.yml | J11 |
打 tag 前的最后检查:npm run lint、npm run typecheck、npm test、npm run test:e2e、npm run build,然后 git tag jour-11。
场景速查
- 要给 API 写文档,又不想给每个字段加注解 → 在
nest-cli.json中启用@nestjs/swagger的 CLI 插件(Swagger)。 - 某个字段出现在
/docs里,但从不返回 →@ApiHideProperty()(/docs 上的 Swagger)。 - Kubernetes 要重启卡住的 pod,但不能因为数据库故障而重启 → liveness 不依赖任何东西,readiness 检查数据库(健康检查)。
- 其他源上的前端要调用 API →
CORS_ORIGIN=https://app.example.com(安全)。 - 登录要能抵御暴力破解 → 在路由上加
@Throttle({ default: { limit: 5, ttl: 60_000 } });在反向代理后面,配置trust proxy以便按客户端计数 (安全、陷阱)。 - 想要一个不以 root 运行的轻量镜像 → 多阶段构建、
npm ci --omit=dev、USER node(Dockerfile)。 - 容器启动时数据库 schema 必须是最新的 → 在
up之前执行docker compose --profile app run --rm app npm run migration:run:prod(Dockerfile 与 Compose)。 - 构建或测试失败时要阻止 merge request → 流水线的
verify阶段(CI)。
陷阱
liveness 检查数据库
PostgreSQL 一出故障,所有实例的 liveness 都会失败,编排平台会反复重启它们,却什么也修不好。数据库检查属于 readiness。
代理后面的限流
throttler 用 req.ip 识别客户端。在负载均衡器后面,这是代理的 IP:所有客户端共用同一个计数器。让 Express 信任代理:
// TaskFlow 之外的示例,在 main.ts 中
// import type { NestExpressApplication } from '@nestjs/platform-express';
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });
app.set('trust proxy', 1); // 应用前面有一层代理- Swagger 插件与文件名。 只处理
*.dto.ts和*.entity.ts,其他文件被悄悄跳过:所以 TaskFlow 里叫pagination.dto.ts。文件名不同的话,就手动加装饰器 (@ApiPropertyOptional())。 @Exclude()不会在 Swagger 中隐藏字段。 它作用于返回的 JSON;对 OpenAPI schema,要加@ApiHideProperty(),就像passwordHash那样。- 503 的响应体被全局过滤器改写。 Terminus 的详情只在日志里(/health)。
CMD ["npm", "start"]。npm夹在 Docker 和 Node 之间,让SIGTERM的传递变复杂;在 TaskFlow 中,npm start还会执行nest start,而镜像里没有它。 直接启动node。- 容器首次启动时忘了执行迁移。 镜像不会在启动时执行迁移:在
up之前,用一次性容器执行npm run migration:run:prod(Dockerfile 与 Compose)。 - 在镜像里执行
npm run migration:run。 这个脚本依赖 ts-node 和src/,镜像里都没有;改用读取dist/的migration:run:prod。 .env文件被复制进镜像。.dockerignore排除了.env*;在加COPY . .之前先确认这一点。- throttler 计数器按实例统计。 要实现全局限制,需要共享存储(
ThrottlerModule的storage选项)。 - 自定义 CSP。
helmet()的默认配置不影响 Swagger UI;如果收紧了contentSecurityPolicy,要重新打开/docs检查。
练习
- [ ] 安装
@nestjs/swagger,在nest-cli.json中声明插件,在/docs上提供 Swagger,并在/docs-json中确认name为必填且带maxLength: 100。 - [ ] 在控制器上加
@ApiTags()和@ApiBearerAuth(),用 “Authorize” 完成认证,然后从 Swagger UI 调用GET /v1/projects。 - [ ] 在
/docs-json中确认GET /v1/projects的文档包含page和limit,并且passwordHash没有出现。临时去掉@ApiHideProperty(),观察这个字段 重新出现在 schema 中。 - [ ] 基于 Terminus 的
/health(readiness:数据库、内存、磁盘)和/health/live(liveness)。停掉数据库(docker compose stop db):/health返回 503,/health/live返回 200。 - [ ] 启用
helmet、通过CORS_ORIGIN配置 CORS、启用 throttler;验证一分钟内第 6 次登录返回 429。 - [ ] 编写多阶段
Dockerfile和 Compose 中的app服务;用一次性容器执行npm run migration:run:prod应用迁移,运行docker compose --profile app up -d,确认进程以node用户运行。不设JWT_SECRET再试一次,阅读报错。 - [ ] 编写
.gitlab-ci.yml(install、lint、test、build),在 merge request 上观察流水线。 - [ ] 最终回顾:重读
LEARNINGS.md,完善 README。 - [ ]
git tag jour-11。