跳转到主要内容

Nest 使用笔记

第十六章:GraphQL 分析看板——code-first、多数据源聚合

打开 learnhub 的 AnalyticsResolver,在真实代码里讲透 GraphQL 的 code-first schema、Apollo 驱动配置、Resolver/ObjectType 装饰器,以及如何用一个 GraphQL 查询聚合 MySQL 计数与 Mongo DAU。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • GraphQL
  • 数据聚合

前面十五章里 learnhub 的 REST 接口已经搭起来了——帖子、评论、权限、上传都齐。这一章加一个新口味:用 GraphQL 给运营做一个分析看板,DAU(日活)+ 站点总览一次取回。和「写个 hello resolver 跑通查询」不同,这一章直接打开 learnhub 已经在用的 AnalyticsResolver,在真实代码里讲透四件事:code-first schema 怎么生成、Resolver/ObjectType 怎么写、多数据源在一个查询里怎么聚合、同一个 JWT Guard 怎么同时盖住 HTTP 和 GraphQL

顺带说一句:这一章原本计划讲 Nginx 反向代理,后来课程结构调整了——Nginx 网关配置挪到第二十三章 wrap-up 用 Docker Compose 编排整套服务时一起讲。所以这个文件的 URL 还是 /docs/nest/nginx/,但内容讲的是 GraphQL,找 Nginx 那部分的同学直接跳第二十三章。

先搞懂:GraphQL 是什么 / 为什么需要 / 企业级怎么用

GraphQL 是什么:Facebook 2012 内部用、2015 开源的查询语言 + 运行时。客户端自己写查询语句说要哪些字段,服务端按需返回,全套服务对外只有一个 endpoint。一份 schema 描述「服务能回答什么」,客户端照着 schema 写 query,服务端解析执行。

为什么需要它:REST 的痛点很真实——over-fetch(详情接口返回了一大堆列表里用不到的字段)、under-fetch(首页要拼帖子+作者+评论,得调 3 次再前端组装)、版本地狱(v1/v2/v3 一改就破老前端)。GraphQL 一次性解决:客户端按需取字段、一次查询拿多源数据、schema 即合约。但前提是你要懂它的复杂度——N+1、查询深度限制、缓存策略都会悄悄咬人。

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):

  • REST 和 GraphQL 共存而非二选一——learnhub 的帖子/认证/权限继续走 REST(简单增删改查够用),只给运营看板这种「需要灵活聚合多源」的场景上 GraphQL;
  • code-first:写 TS resolver 类 + 装饰器,schema 自动生成,TS 类型是唯一真相源——本章第三步落地;
  • 鉴权复用 HTTP 那套——同一个 JwtAuthGuard 同时盖住 REST 和 GraphQL,不重写——本章第七步落地;
  • 警惕 N+1:列表查询返回的关联字段,每条都触发一次解析 → 上 dataloader 批量。

这一章你会做出什么

  • 打开 learnhub 的 src/modules/analytics/,逐文件讲清楚它为什么这么写。
  • 在真实代码里吃透:code-first schema 生成autoSchemaFile)、Resolver + ObjectType 装饰器多数据源聚合(一个 summary 查询里同时查 MySQL 计数 + Mongo DAU)、GraphQL 鉴权GqlExecutionContext)。
  • 在浏览器 Apollo Sandbox 里写出查询语句,看到 JSON 结果。

前置:learnhub 跑着(MySQL 必起,Mongo 起了才能看 DAU),种子数据里有 admin / admin123456,仓库代码已拉到本地能打开看。

第一步:先想清楚——GraphQL vs REST,什么时候用哪个

新手接触 GraphQL 经常被带着走「REST 过时了」——这是错觉。两种范式各有适用面,learnhub 是混着用的。

REST 在 learnhub 里的职责:帖子 CRUD(/api/v1/posts)、登录(/api/v1/auth/login)、文件上传、权限管理——这些是资源导向的简单增删改查,每个接口返回结构稳定、字段固定,REST 的「URL 是资源、HTTP 动词是动作」模型非常贴。

GraphQL 在 learnhub 里的职责:只有运营分析看板一个场景。为什么是它?因为看板有几个特征:

  • 多源:DAU 来自 Mongo(行为日志聚合)、站点总览来自 MySQL(用户/帖子/评论计数),一个画面要同时显示。
  • 频繁演进:今天看 DAU,明天加留存,后天加分渠道口径——REST 写死 JSON 字段每次都要改后端,GraphQL 让客户端自己挑字段。
  • 多端复用:Web 看板、内部 BI 工具、报表导出,都从同一个 graph 取数据,各取所需。

思考:分析看板本来可以做成 GET /api/v1/analytics/summary 返回固定 JSON——为什么这里值得上 GraphQL?因为看板是个频繁变视图的场景:产品每周加新指标、改口径,REST 要么做成一个大而全的接口每次都 over-fetch、要么每加一个指标发一版后端,GraphQL 让客户端自己拼字段、后端只负责把「能回答的问题」用 schema 暴露。这才是 GraphQL 真正的甜区——前端需求不确定、聚合多源、视图演进频繁。反过来,简单的 posts/:id 详情接口上 GraphQL 就是给自己找麻烦。

注意:GraphQL 不是 REST 的替代品,是补充。整个项目全切 GraphQL 会让你为「取一个帖子」这种简单需求也写一堆 query string + 解析逻辑、加 N+1 监控、维护 schema 演进——复杂度收益不对等。learnhub 只在分析看板上用它,是经过取舍的:简单 REST、灵活 GraphQL,各司其职。

第二步:code-first vs schema-first——为什么选 code-first

GraphQL 的 schema(.graphql 文件)描述了类型、查询、字段——是一份契约。写 schema 有两条路:

  • schema-first:先手写 schema.graphqltype Query { summary: Summary! } 这种),再用代码生成器生成 TS 类型,最后写 resolver 实现它。schema 是真相源。
  • code-first:直接写 TS resolver 类 + 装饰器(@ObjectType() @Field()),Nest 启动时自动从这些装饰器生成 schema。TS 代码是真相源。

learnhub 选 code-first(autoSchemaFile: true),原因:

  • TS 类型已经是真相源了——entity、DTO、service 返回值都靠 TS 类型驱动。再手写一份 .graphql schema 等于维护两份类型定义,改个字段两处改、还容易对不齐。
  • 装饰器和实体写在一起,IDE 跳转、重构、类型检查都顺,写起来和写 controller 几乎一样。
  • schema 启动时自动生成到内存(autoSchemaFile: true),不用把生成的 .graphql 文件 commit 进 git,没有「生成物 vs 手写」的冲突。

代价:schema 长什么样不能直接看文件(要么把 autoSchemaFile 设成路径落盘看、要么去 Apollo Sandbox 浏览),新人入门要多认几个装饰器。learnhub 觉得这个代价值得——下面会看到,code-first 下写 GraphQL 和写 REST controller 几乎是同一件事。

第三步:Apollo 驱动的接入——forRootAsync

Nest 的 @nestjs/graphql 是个抽象层,下面要接一个具体的 GraphQL 服务器实现。learnhub 用 Apollo@nestjs/apollo + ApolloDriver),是 Nest 生态最成熟的选择。接进 app.module.ts

// learnhub/src/app.module.ts
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';

GraphQLModule.forRootAsync<ApolloDriverConfig>({
  driver: ApolloDriver,
  useFactory: () => ({
    autoSchemaFile: true,        // code-first:从装饰器自动生成 schema(存内存)
    path: 'graphql',
    useGlobalPrefix: true,       // 配合 main.ts 的 setGlobalPrefix('api') → /api/graphql
    sortSchema: true,            // 生成的 schema 字段按字母排序,diff 干净
    // GET /api/graphql 默认进 Apollo Sandbox(浏览器里的在线测试 UI)
  }),
}),

几个要点:

  • driver: ApolloDriver 显式指定用 Apollo——Nest 也支持 Mercurius(Fastify 下更轻)等其他驱动,写法会有差异,认准一个。
  • autoSchemaFile: true 是 code-first 的开关:值为 true 表示 schema 只存内存、不落盘;值也可以是一个文件路径(join(process.cwd(), 'schema.graphql')),那样会把生成的 schema 写进文件、方便 review。learnhub 选内存。
  • path: 'graphql' + useGlobalPrefix: true:因为 main.ts 里设了全局前缀 api,组合起来 GraphQL 端点是 /api/graphql这两个必须配套——只设 path 不开 useGlobalPrefix,端点会是 /graphql,前缀不一致会让前端代理和鉴权逻辑乱掉。
  • sortSchema: true 让生成的 schema 字段稳定排序,团队协作时 commit diff 才不会因为字段顺序随机变而脏掉。
  • forRootAsync 而不是 forRoot:和 TypeORM 一样,留异步工厂口子方便未来从 ConfigService 注入配置(虽然这里 useFactory 没注入依赖,但和其他模块风格统一)。

注意:Apollo Sandbox 是 Apollo Server v3+ 默认的浏览器测试 UI——访问 GET /api/graphql(注意是 GET,POST 才是真正发查询)会出来一个网页,左边写 query、右边看结果、还能看自动生成的 schema。它是 GraphQL 版的 Swagger——第十四章 Swagger 已经讲过这类「在线试接口」的思路,这里同理:浏览器里直接试,不用 curl。

第四步:定义 ObjectType——返回给客户端的对象类型

GraphQL 的一切都靠 schema 里的类型。code-first 下,类型是 TS 类 + 装饰器。learnhub 看板要返回两种数据:DAU 的点(日期 + 活跃数)、站点总览(用户/帖子/评论/浏览量计数)。各定义一个 @ObjectType

// learnhub/src/modules/analytics/dto/analytics.dto.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class DauPoint {
  @Field(() => String, { description: '日期 YYYY-MM-DD' })
  date: string;

  @Field(() => Int, { description: '当日活跃用户数(distinct userId)' })
  activeUsers: number;
}

@ObjectType({ description: '站点总览统计' })
export class Summary {
  @Field(() => Int) userCount: number;
  @Field(() => Int) postCount: number;
  @Field(() => Int) commentCount: number;
  @Field(() => Int, { description: '全部帖子浏览量之和' }) totalViews: number;
}

@ObjectType() 把类标记成「GraphQL 的对象类型」,@Field() 把每个属性标记成「这个类型的一个字段」。@Field(() => Int) 显式声明标量类型——TS 的 number 在 GraphQL 里歧义(可能是 Int 也可能是 Float),所以必须显式指明。description 写进 schema 文档,Apollo Sandbox 里能看到,作用等同 Swagger 的 @ApiProperty 描述。

注意 DauPoint 在 resolver 里返回的是 [DauPoint](列表),因为 DAU 要返回近 N 天的曲线,每天一个点;Summary 是单对象。

第五步:写 Resolver——Query 怎么映射到查询

ObjectType 定义了「形状」,Resolver 定义了「怎么取」。GraphQL 的查询分三类:Query(读)、Mutation(写)、Subscription(订阅、WebSocket 推)。learnhub 看板只读,所以只写 @Query

// learnhub/src/modules/analytics/analytics.resolver.ts
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
import { AnalyticsService } from './analytics.service';
import { DauPoint, Summary } from './dto/analytics.dto';

@Resolver()
export class AnalyticsResolver {
  constructor(private readonly analytics: AnalyticsService) {}

  @Query(() => [DauPoint], { description: '近 N 天日活' })
  async dau(@Args('days', { type: () => Int, defaultValue: 7 }) days: number): Promise<DauPoint[]> {
    return this.analytics.dau(days);
  }

  @Query(() => Summary, { description: '站点总览:用户/帖子/评论/浏览量' })
  async summary(): Promise<Summary> {
    return this.analytics.summary();
  }
}

几个要点:

  • @Resolver() 标记这是个 resolver 类,Nest 启动时扫到、注册进 GraphQL schema 生成。空括号表示「不绑定到某个具体 ObjectType 的字段解析」(那种用法叫字段 resolver,给某个对象类型的字段写自定义解析逻辑,learnhub 这里用不上)。
  • @Query(() => [DauPoint]) 声明一个 Query:返回类型是 DauPoint 的列表(方括号是 GraphQL 的列表类型)。和 @Field 一样,返回类型必须显式传函数——Nest 靠反射拿不到运行时泛型信息(TS 编译后类型擦除),必须告诉它这个 query 返回什么。
  • @Args('days', { type: () => Int, defaultValue: 7 }) 声明一个查询参数:客户端传 dau(days: 30),没传就用默认 7 天。type: () => Int 同样要显式。
  • resolver 自己不写业务逻辑——只负责把 GraphQL 查询翻译成对 service 的调用,业务全在 AnalyticsService。这和 REST 那边 controller/service 分层完全一致:Resolver 就是 GraphQL 版的 Controller。

对应到客户端的查询语句长这样:

query {
  summary {
    userCount
    postCount
    totalViews   # 客户端可以只挑要的字段,没要 commentCount 就不会返回
  }
  dau(days: 14) {
    date
    activeUsers
  }
}

这条查询里,客户端一次请求同时拿了 summary 和 dau——REST 要么调两次接口、要么后端专门写一个聚合接口。这是 GraphQL 的核心收益之一:一个查询拿多源数据,客户端决定要哪些字段

第六步:多数据源聚合——一个查询背后查 MySQL + Mongo

这是这一章最有价值的一节。summarydau 看似两个简单查询,背后却跨了 MySQL 和 Mongo 两个数据源。看 AnalyticsService

// learnhub/src/modules/analytics/analytics.service.ts
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BehaviorLogService } from '../behavior-log/behavior-log.service';

@Injectable()
export class AnalyticsService {
  constructor(
    private readonly behaviorLog: BehaviorLogService,
    @InjectRepository(User) private readonly userRepo: Repository<User>,
    @InjectRepository(Post) private readonly postRepo: Repository<Post>,
    @InjectRepository(Comment) private readonly commentRepo: Repository<Comment>,
  ) {}

  async dau(days: number): Promise<DauPoint[]> {
    return this.behaviorLog.dau(days);
  }

  async summary(): Promise<Summary> {
    const [userCount, postCount, commentCount, viewRow] = await Promise.all([
      this.userRepo.count(),
      this.postRepo.count(),
      this.commentRepo.count(),
      this.postRepo.createQueryBuilder('p').select('COALESCE(SUM(p.view_count),0)', 's').getRawOne(),
    ]);
    return {
      userCount,
      postCount,
      commentCount,
      totalViews: Number(viewRow?.s ?? 0),
    };
  }
}

两个看点:

看点一:多数据源在一个 service 里组合。 AnalyticsService 同时注入了 TypeORM 的 Repository<User/Post/Comment>(走 MySQL)和 BehaviorLogService(内部走 Mongo)。summary 的四个字段都来自 MySQL:用户/帖子/评论计数各一个 repo.count(),浏览量求和走 QueryBuilder 的 SUMdau 则完全委托给 BehaviorLogService——它内部查的是 Mongo 的行为日志集合(第十八章细讲)。客户端看到的 Summary 对象字段是统一的、来源对它透明——这就是多源持久化(polyglot persistence)在一个 API 后面统一暴露的收益,GraphQL 在这里扮演 facade(外观)的角色。

看点二:Promise.all 并发执行。 四个 MySQL 查询彼此独立,用 Promise.all 并发而不是 await 一个接一个——总耗时约等于最慢的那个,而不是四个之和。看板类接口这种「拼装多个独立计数」的场景,并发差别很明显:串行 4 条各 20ms 是 80ms,并发就是 ~20ms。

至于 DAU 那条,看 BehaviorLogService.dau

// learnhub/src/modules/behavior-log/behavior-log.service.ts
async dau(days = 7): Promise<{ date: string; activeUsers: number }[]> {
  const since = new Date(Date.now() - days * 24 * 3600 * 1000);
  const rows = await this.model.aggregate<{
    _id: string;
    activeUsers: number;
  }>([
    { $match: { createdAt: { $gte: since }, userId: { $ne: null } } },
    {
      $group: {
        _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
        users: { $addToSet: '$userId' },
      },
    },
    { $project: { _id: 1, activeUsers: { $size: '$users' } } },
    { $sort: { _id: 1 } },
  ]);
  return rows.map((r) => ({ date: r._id, activeUsers: r.activeUsers }));
}

这是 Mongo 的聚合管道$match 过滤时间范围 → $group 按天分组并用 $addToSet 收集每天出现过的 userId → $project$size 算集合大小(去重后的活跃数)→ $sort 按日期升序。这套管道的细节(schema、索引、为什么用 Mongo 存行为日志)是第十八章的内容,这里只要知道一个事实:MySQL 的「按天去重计数」在大表上很贵,Mongo 的聚合管道专为这类文档聚合设计。GraphQL 在前面把两个引擎的能力拼成一个统一 API。

思考:为什么 DAU 不直接在 MySQL 算?因为行为日志是「写入极频繁、字段可能变、按时间范围扫」的典型文档场景——每次请求都 append 一条,MySQL 行锁 + 索引开销大,去重聚合在大表上跑 COUNT(DISTINCT) 会很慢。Mongo 无 schema、写优化、原生聚合管道,正好对路。这是「为不同数据形态选不同存储」的实践——learnhub 把交易型数据(用户、帖子、评论)给 MySQL,把日志型数据(行为流)给 Mongo。GraphQL 在前面把它们统一暴露给客户端,客户端完全感知不到底下是两个引擎。

模块装配把这一切串起来:

// learnhub/src/modules/analytics/analytics.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([User, Post, Comment]), BehaviorLogModule],
  providers: [AnalyticsService, AnalyticsResolver],
})
export class AnalyticsModule {}

TypeOrmModule.forFeature([...]) 注册 MySQL 三个实体的 Repository,BehaviorLogModule 是 Mongo 那边的服务(exports: [BehaviorLogService, ...],第十八章细讲)。providers 把 Resolver 和 Service 都注册——Resolver 也算 provider,Nest 才会把它扫进 GraphQL schema 生成。

第七步:鉴权——同一个 JwtAuthGuard 盖住 GraphQL

GraphQL 请求也走 HTTP(POST /api/graphql,body 里是 query string),所以理论上 HTTP 的中间件、守卫、拦截器都能作用到 GraphQL 上——但有一个坑:GraphQL 的 ExecutionContext 和 HTTP 的不一样,直接 context.switchToHttp().getRequest() 在 GraphQL 请求里拿不到东西。learnhub 的 JwtAuthGuard 做了个分支处理:

// learnhub/src/core/guards/jwt-auth.guard.ts
import { GqlExecutionContext } from '@nestjs/graphql';

private getRequest(context: ExecutionContext): Request & { user?: RequestUser } {
  if (context.getType() === 'http') {
    return context.switchToHttp().getRequest();
  }
  const gqlCtx = GqlExecutionContext.create(context);
  return gqlCtx.getContext().req;
}

GqlExecutionContext.create(context).getContext().req 把 GraphQL 的执行上下文转回底层的 Express Request——Apollo 驱动默认把 express 的 req 挂在 GraphQL context 上,所以拿得到。这样 canActivate 里取 token、验签、挂 req.user 的逻辑一行不用改,REST 和 GraphQL 共用一套鉴权(第十一章细讲 JwtAuthGuard 本体,这里只看它怎么兼容 GraphQL)。

客户端怎么带 token?GraphQL 请求的 HTTP header 里加 Authorization: Bearer xxx,和 REST 一模一样。Apollo Sandbox 顶部有 Headers 输入框,加一条 {"Authorization": "Bearer eyJ..."} 就能带着鉴权发查询。

注意:第十一章(JWT)讲的 @Public() 白名单在 GraphQL 上同样生效——因为 JwtAuthGuardAPP_GUARD 全局注册的,无论请求走 REST 还是 GraphQL 都会被拦一道。如果某个 query 想公开(比如首页的公开统计),在 resolver 方法上加 @Public() 即可。learnhub 的 analytics 查询默认需要登录,因为运营看板不对外开放。

第八步:跑起来,在 Apollo Sandbox 里写查询

cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql redis mongo   # 起 MySQL/Redis/Mongo
npm install
npm run migration:run                                                 # 建表 + 灌种子
npm run start:dev                                                     # 起服务

服务起来后,浏览器打开 http://localhost:3000/api/graphql(GET),进 Apollo Sandbox。先登录取 token(用第十一章的 /api/v1/auth/login,账号 admin / admin123456),然后在 Sandbox 顶部 Headers 加:

{ "Authorization": "Bearer 把token粘这里" }

写一条查询:

query {
  summary {
    userCount
    postCount
    commentCount
    totalViews
  }
  dau(days: 7) {
    date
    activeUsers
  }
}

点运行,拿到(数据取决于你库里的内容):

{
  "data": {
    "summary": { "userCount": 1, "postCount": 5, "commentCount": 3, "totalViews": 142 },
    "dau": [
      { "date": "2026-07-13", "activeUsers": 1 },
      { "date": "2026-07-14", "activeUsers": 1 }
    ]
  }
}

试着只挑要的字段——把 summary 里的 commentCount 删掉再跑,返回的 JSON 里就没那个字段;把 dau(days: 7) 改成 dau(days: 30),曲线立刻变成 30 天。这就是 GraphQL「客户端决定字段、客户端决定参数」最直观的体验:schema 没变、resolver 没变,客户端改 query 就能改返回结构。

这一章的成果

  1. 接好 @nestjs/graphql + Apollo 驱动,autoSchemaFile: true 走 code-first,schema 从 TS 装饰器自动生成,端点 /api/graphql 走全局前缀。
  2. 写了 AnalyticsResolver + DauPoint / Summary 两个 @ObjectType,理解了 @Query / @Args / @Field 装饰器和 schema 的对应关系。
  3. AnalyticsService 里把 MySQL 计数(TypeORM Repository)和 Mongo DAU(BehaviorLogService 聚合管道)聚合成一个 GraphQL 查询,吃透了「多源持久化在一个 API 后面统一暴露」。
  4. 复用同一个 JwtAuthGuard 盖住 REST 和 GraphQL——GqlExecutionContext 把 GraphQL 上下文转回 express req,鉴权逻辑零改动。
  5. 在 Apollo Sandbox 里写了真实查询,体会到「客户端挑字段、一个查询拿多源」的收益,也清楚了 GraphQL 不是 REST 的替代品而是补充——learnhub 只在看板这种聚合多源、视图演进的场景上用它。

常见问题

  • 启动报 “Cannot find GraphQL module”:装依赖。npm i @nestjs/graphql @nestjs/apollo graphql @apollo/server,再在 app.module.tsdriver: ApolloDriver。learnhub 的 package.json 已经封好。
  • Apollo Sandbox 打不开:要 GET /api/graphql 才是 Sandbox UI,POST /api/graphql 才是真发查询。生产环境建议关掉 Sandbox——通过 introspection: false 关掉 schema 自省,防止外人扫接口。
  • N+1:列表型 Query(返回 [Post] 然后每条都带 author 字段)每条都会触发一次 author 解析 → 100 条帖子 = 100 次查 user。解法是 dataloader——按批 + 缓存。learnhub 的 analytics 没踩这个坑(返回的是聚合后的数字,不是关联列表),但写复杂 resolver 时要警惕。
  • code-first 生成的 schema 改了不生效:schema 在每次启动时重新生成,不要手改任何 schema.graphql 文件(learnhub 用 autoSchemaFile: true 压根没落盘)。改字段去改 TS 装饰器,重启服务。
  • 鉴权拿不到 token:GraphQL 请求的 token 走 HTTP header(Authorization: Bearer xxx),不是 query 参数。Apollo Sandbox 在顶部 Headers 配。如果守卫抛 401,先看 JwtAuthGuard.getRequest 是不是走了 GraphQL 分支(context.getType() 在 GraphQL 请求里返回的不是 'http',得用 GqlExecutionContext)。
  • GraphQL 适合所有接口吗:不适合。简单资源 CRUD(帖子增删改查)上 GraphQL 是自找麻烦——query string 解析、schema 学习成本、N+1 监控都白加。learnhub 的原则:简单 REST、灵活 GraphQL。
  • 找 Nginx 反代内容去哪了:这章原来是 Nginx,已经按课程结构调整换成了 GraphQL。Nginx 网关 + Docker Compose 的完整反代配置在第二十三章 wrap-up。

下一章是实战串烧——把前面学的 JWT 登录 + TypeORM CRUD + 参数校验串成一个完整小项目:注册登录后能增删改查自己的文章,把前十六章的零件装成一辆能跑的车。