React Mobile 项目从 0 到 1(后端篇):NestJS + Prisma + PostgreSQL + JWT 全流程

43 阅读15分钟

React Mobile 项目从 0 到 1(后端篇):NestJS + Prisma + PostgreSQL + JWT 全流程

在前一篇前端内容中,我们基于 shadcn/ui + Vite 快速构建了高品质的移动端界面,结合 Zustand 进行状态管理,并通过 Mock 数据实现了页面的初步可视化。然而,当后端接口尚未稳定时,整个应用本质上仍处于“空中楼阁”状态:接口响应异常、数据一致性缺失、权限控制不当,都可能导致用户体验急剧下降,甚至直接引发卸载和负面评价。

本文将系统性地聚焦后端基础设施的完整搭建,采用当前移动端中小型项目中最实用且高效的技术栈:NestJS + Prisma + PostgreSQL + JWT,从 0 到 1 完成全链路构建。内容覆盖以下核心模块:

  • NestJS 项目初始化与全局配置(main.ts 入口、路由前缀、CORS、静态资源、ValidationPipe 等一站式设置)
  • Prisma ORM 的 schema 设计、关系映射、迁移流程与类型安全查询实践
  • 关联查询优化(include/select/_count + Promise.all 并行 + 防 N+1)
  • DTO 输入校验与全局管道(class-validator + class-transformer)
  • JWT 无状态认证体系(登录签发、守卫保护、策略验证、前端 Axios 拦截器配合)

起点:NestJS 项目骨架 & 全局配置

先别急着写业务,项目骨架搭不对,后期维护就是地狱。

NestJS 不是让你 npm init 随便玩的野路子,它内置 CLI 直接给你生成一套企业级的目录结构:
controllers → services → modules → providers → entities
分层清晰得像 Angular 的后端翻版,又带点 Spring Boot 的味道,但比 Spring 轻量、启动快得多。

CLI一键初始化

# 直接用 Nest CLI 起项目,指定 pnpm(强烈推荐)
nest new backend --package-manager pnpm

cd backend

# 后面这些包几乎是标配,提前装上省得反复折腾
pnpm add @nestjs/platform-express class-validator class-transformer
pnpm add -D @types/node

执行完 nest new,你会得到:

  • src/app.module.ts —— 根模块,像心脏
  • src/app.controller.ts + src/app.service.ts —— 入门 demo
  • src/main.ts —— 启动入口
  • 测试文件、配置文件一应俱全

为什么非要用 Nest CLI 而不是手写? 因为它本质上是一个工厂模式(Factory Pattern) + 依赖注入(DI)容器的完整实现:

  • CLI 帮你批量生成模块、controller、service、guard、pipe 等,文件名、导出、@Module 装饰器自动对齐
  • 后续 nest g resource posts 一条命令就能生成 CRUD 骨架(controller + service + dto + entity),省掉 80% 重复体力活
  • 可以让nestjs像express 一样拥有一些服务 ,只需要 import { NestExpressApplication } from '@nestjs/platform-express';

main.ts —— 全局配置的“大脑中枢”

main.ts 是 NestJS 的入口文件,这里配置全局中间件、路由前缀、静态资源等。想想 Express:你得手动 app.use() 一堆东西;Nest 直接在 create 时注入。

完整代码:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    cors: true,  // 跨域全开,开发阶段防前端报错
  });

  app.setGlobalPrefix('api');  // 接口统一 /api/posts,避免裸路径冲突

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,            // 只保留 DTO 定义的字段,多余的自动丢
      forbidNonWhitelisted: true, // 多传字段直接 400 Bad Request
      transform: true,            // 自动类型转换,query string "1" → number 1
    }),
  );

  // 静态文件服务:uploads 目录暴露为 /uploads/xxx.jpg
  app.useStaticAssets(join(process.cwd(), 'uploads'), {
    prefix: '/uploads/',
  });

  await app.listen(process.env.PORT ?? 3000);  // 环境变量优先,fallback 3000
}
bootstrap();

逐行拆解

  • NestExpressApplication:Nest 默认用 Express 底层,但能切换 Fastify(更快)。为什么使用 Express?生态成熟,中间件多;后期压力大再切 Fastify。
  • cors: true:移动端 H5 或 App 跨域必备。坑点:上线时别全开,指定 origins: ['你的前端域名'],防 CSRF。
  • setGlobalPrefix('api'):所有 controller 路由自动加 /api 前缀。好处:区分 API 和静态资源,避免冲突;版本化时加 /api/v1 超方便。
  • useGlobalPipes(ValidationPipe):全局启用 DTO 校验(后面详解)。为什么全局?每个接口重复写校验逻辑太复杂,集中管理
    • whitelist: true + forbidNonWhitelisted: true:前端传多余字段(如 hacker 注入)直接挡掉,防脏数据入库。
    • transform: true:query/body 自动转类型。例:page="1" → 1,无需 service 里手动 Number()。注意:没 Type(() => Number) 装饰,transform 失效。

Prisma:数据库的“现代翻译官”

2026 年还在手写 SQL join 的,已经落后了。

Prisma 是 ORM(Object Relational Mapping)的王牌:把表映射成类,查询像操作 JS 对象。为什么不 TypeORM 或 Sequelize?Prisma 类型推导更强,迁移自动,SQL 零暴露,适合全栈选手。

初始化 & schema 设计

pnpm add prisma -D
pnpm add @prisma/client
npx prisma init

tip: 推荐使用6.19.2版本更加稳定

init 会生成 prisma/schema.prisma 和 .env。改 .env 的 DATABASE_URL:

DATABASE_URL="postgresql://postgres:你的密码@localhost:5432/数据库名?schema=public"

为什么 Postgres 而不是 MySQL?

Postgres 天生支持 JSON、数组字段、全文搜索,Prisma 的优化也最到位。不是说MySQL不好 ——复杂关系(如多对多标签系统)里,Postgres 丝滑如 butter,MySQL 容易卡壳。实际项目里,高并发查询 Postgres 更适合。

Schema 设计:蓝图变现实

schema.prisma 用 model 定义表、字段、关系,像写 TS 接口一样直观。

generator client { provider = "prisma-client-js" }
datasource db { provider = "postgresql" url = env("DATABASE_URL") }

model User {
  id Int @id @default(autoincrement())
  name String @unique @db.VarChar(255)
  password String @db.VarChar(255)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  posts Post[]
  comments Comment[]
  likes UserLikePost[]
  avatars Avatar[]
  files File[]
}

model Post {
  id Int @id @default(autoincrement())
  title String @db.VarChar(255)
  content String? @db.Text
  userId Int?
  user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
  comments Comment[]
  tags PostTag[]
  likes UserLikePost[]
  files File[]
}

// ...(需要什么加什么)

设计拆解

  • @id @default(autoincrement()):主键自增,Postgres 用序列(sequence)实现——高效、原子,不会撞车。

  • @unique:数据库级唯一约束,防用户名重复入库。

  • 关系:posts Post[] 是一对多(用户 → 多帖子);user User? @relation(fields: [userId], references: [id], onDelete: SetNull) 是外键,可空(?)设计防删用户时帖子全灭。

  • onDelete: Cascade/SetNull:级联策略。删帖子?评论/标签/文件删除(Cascade);删用户?帖子 userId 置空(SetNull),内容保留。

  • 多对多:PostTag 中间表 + @@id([postId, tagId]) 复合主键,Prisma 自动帮你 join 查询,零 SQL 手写。

迁移 & 生成:从蓝图到真实表

迁移命令一键搞定:

Bash

npx prisma migrate dev --name init  # 生成 migration SQL + 执行到 DB
npx prisma generate  # 更新 @prisma/client 类型,让 TS 知道新变化

这里是 Prisma Client 的真身大显

  • Prisma Client 是运行时库(@prisma/client),它基于 schema 生成一个类型安全的 JS/TS 客户端。查询像 prisma.post.findMany({ include: { user: true } }) 这么写,底层自动转 SQL + 执行。没它,你就得 raw SQL 裸奔。

迁移后发生了啥?

  • Prisma 先吐出一个 SQL 文件(在 prisma/migrations/xxx_init/migration.sql),里面是建表/索引/外键的全套脚本。如我们上面建的Model Posts:

SQL

-- CreateTable
CREATE TABLE "posts" (
    "id" SERIAL NOT NULL,
    "title" VARCHAR(255) NOT NULL,
    "content" TEXT,
    "userId" INTEGER,

    CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "posts_userId_idx" ON "posts"("userId");

-- AddForeignKey
ALTER TABLE "posts" ADD CONSTRAINT "posts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
  • 然后,它直接插到你的 Postgres DB(psql 里一看,表/约束全建好了)。
  • 坑点:schema 改了忘 migrate + generate?Service 里 prisma.post.findMany 直接类型报错,运行崩锅。
  • 收益:数据库可视化设计,改表像改 JS 类;团队 review 超轻松,迁移历史全记录(SQL 文件可 git 管),回滚稳。

全局 PrismaService:单例连接池 & 自动注入

PrismaClient 默认每次 new 一个连接,项目大时浪费资源。

NestJS 的正确打开方式:把 PrismaClient 包装成一个全局单例 Service,实现连接池复用,顺便让整个项目任何地方都能轻松注入。

// prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();  // 模块启动时自动连 DB
  }
}
PrismaModule:全局导出
// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()  // 重磅!全局模块
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

然后在 app.module.ts 里只 import 一次:

@Module({
  imports: [PrismaModule, PostsModule, UsersModule, ...],
  // 其他模块都不需要再 import PrismaModule
})
export class AppModule {}

为什么这样?

  • extends PrismaClient:继承所有查询方法。
  • OnModuleInit:Nest 生命周期钩子,app 启动时自动 $connect()。
  • @Global():无需每个 module imports PrismaModule,任何 service 直接 constructor(private prisma: PrismaService) 用。

真实使用示例(PostsService 里):

TypeScript

@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}  // 直接注入,零配置

  async findAll() {
    return this.prisma.post.findMany({ ... });
  }
}

不需要在 PostsModule 里 import PrismaModule,也不需要 providers 里再声明一次——干净、优雅

一句话总结:只要你需要访问数据库的 service,直接 private prisma: PrismaService 注入就行。

文章列表接口:分页 + 关联 + 优化

接下来,我们来一个prisma实战使用:查询文章信息

PostsModule:controller + service 分层。controller 收请求,service 干脏活。

// posts.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostQueryDto } from './dto/post-query.dto';

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Get()
  async getPosts(@Query() query: PostQueryDto) {
    return this.postsService.findAll(query);
  }
}
// posts.module.ts
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';

@Module({
  controllers: [PostsController],
  providers: [PostsService],
})
export class PostsModule {}

核心 service:

// posts.service.ts
import { Injectable } from '@nestjs/common';
import { PostQueryDto } from './dto/post-query.dto';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  async findAll(query: PostQueryDto) {
    const { page = 1, limit = 10 } = query;
    const skip = (page - 1) * limit;

    const [total, posts] = await Promise.all([
      this.prisma.post.count(),  // 总条数
      this.prisma.post.findMany({
        skip,
        take: limit,
        orderBy: { id: 'desc' },
        include: {
          user: {
            select: { id: true, name: true, avatars: { take: 1, select: { filename: true } } },
          },
          tags: { select: { tag: { select: { name: true } } } },
          _count: { select: { likes: true, comments: true } },
          files: {
            where: { mimetype: { startsWith: 'image/' } },
            select: { filename: true },
          },
        },
      }),
    ]);

    const data = posts.map(post => ({
      id: post.id,
      title: post.title,
      brief: post.content?.substring(0, 100) ?? '',
      user: {
        id: post.user?.id,
        name: post.user?.name,
        avatar: post.user?.avatars[0]?.filename
          ? `http://localhost:3000/uploads/avatar/resized/${post.user.avatars[0].filename}-small.jpg`
          : '',
      },
      tags: post.tags.map(t => t.tag.name),
      totalLikes: post._count.likes,
      totalComments: post._count.comments,
      thumbnail: post.files[0]?.filename
        ? `http://localhost:3000/uploads/resized/${post.files[0].filename}-thumbnail.jpg`
        : '',
    }));

    return { items: data, total };
  }
}

详解

  • skip/take:Postgres OFFSET/LIMIT 分页。为什么 (page-1)*limit?页码从1开始,偏移从0。
  • Promise.all:count 和 findMany 是独立的查询,并行执行,互不阻塞,时间损耗减少,最慢的执行完就算完成
  • include + select:嵌套查询,只取需要字段。为什么 select?全 include 会拉一堆垃圾数据,性能炸。
  • _count:Prisma 内置,高效计算子表行数(likes/comments),防 N+1 查询(循环查每帖点赞)。
  • where: { mimetype: { startsWith: 'image/' } }:过滤图片文件,取首张做 thumbnail。
  • map 后处理:生成 URL(用 uploads 服务),brief 截取内容。为什么后端做?前端统一,防客户端逻辑散乱。

通过 Prisma 的这些 API,我们终于摆脱了手写 JOIN 和 N+1 的噩梦,一口气查出帖子 + 用户 + 标签 + 点赞数 + 缩略图,数据结构干净、前端直接用。

DTO 校验:防前端“胡作非为”

前端传参这事儿,永远别指望它“正常”。 page=“abc”、limit=-999、甚至多传个 hacker 字段 isAdmin=true……这些都是分分钟能让你的接口爆炸、数据库脏写、甚至被注入的定时炸弹。

DTO(Data Transfer Object)就是后端的“门卫 + 过滤器”:只允许定义好的字段进来,多余的直接踹出去,类型不对直接 400 Bad Request。 用 class-validator + class-transformer + Nest 的全局 ValidationPipe,校验几乎零成本、零侵入。

经典示例:PostQueryDto(分页查询专用)

// src/posts/dto/post-query.dto.ts
import { IsOptional, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class PostQueryDto {
  @IsOptional()
  @Type(() => Number)           // 必须!字符串转数字的核心
  @IsInt({ message: 'page 必须是整数' })
  @Min(1, { message: 'page 不能小于 1' })
  page?: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: 'limit 必须是整数' })
  @Min(1, { message: 'limit 不能小于 1' })
  @Max(100, { message: '一次最多拉 100 条,别贪' })  // 防前端恶意拉取全表
  limit?: number = 10;
}

在 controller 里这么用:

TypeScript

@Get()
async getPosts(@Query() query: PostQueryDto) {
  return this.postsService.findAll(query);
}

全局 ValidationPipe 已经帮你自动校验 + 转换(main.ts 里配好就行):

  • 前端传 page=abc → 直接 400 + { message: "page 必须是整数" }
  • 传 page=0 → 400 + "page 不能小于 1"
  • 传 page=1&extra=hacker → extra 字段自动丢弃(whitelist: true)
  • 传 page=1.5 → 自动转 1(或报错,取决于配置)

为什么必须这么写

装饰器作用为什么缺一不可?(真实坑点)
@Type(() => Number)把 query string "1" 转成 number 类型忘了这个?page 永远是 string,skip = (page-1)*limit → NaN,接口直接崩。
@IsInt()校验是不是整数(非小数、非字符串)防 page=1.5、page="1abc" 这种脏输入,自动 400
@Min(1)最小值 1防 page=0 或负数导致 skip 负值,Prisma 直接报错或查错数据
@Max(100)(推荐加)最大值限制防前端恶意传 limit=999999,把数据库拉崩(DDOS 式请求)
@IsOptional()字段可选,不传不报错分页常见 page/limit 不传用默认值

全局 Pipe 配置回顾(main.ts)

TypeScript

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,               // 只保留 DTO 定义的字段,多余的自动剔除
    forbidNonWhitelisted: true,    // 多传字段直接 400(防 hacker 注入)
    transform: true,               // 自动类型转换(配合 @Type)
    exceptionFactory: (errors) => new BadRequestException(errors), // 可自定义错误格式
  }),
);

前端传参永远像脱缰的野马,你不设门卫,它就敢把“page=abc”“limit=-999”“extra=isAdmin=true”这些炸弹一股脑塞进来——轻则接口 500 崩锅,重则脏数据入库、权限绕过、SQL 注入风险拉满。

DTO + ValidationPipe 是后端不得不防一手的武器:

  • 规范化输入 → 只允许定义好的字段和类型进来
  • 自动类型转换 + 校验 → 脏数据直接 400,service 层零负担
  • whitelist/forbidNonWhitelisted → 多余字段/hacker 试探一律pass
  • 全局零侵入 → 每个接口都不用重复写 if 判断

正如一句后端工程师经常说的"永远不要相信前端", 这就是为什么很多后端的代码看着“贼长”: 业务逻辑其实就占 20-30%,剩下 70% 都在防小人——防前端 bug、防用户作恶、防黑客扫描、防意外输入、防未来维护时自己挖的坑。 用了 DTO 之后,你会发现:debug 时间少一半

JWT 登录:无状态认证全解析

HTTP 天生无状态,传统的 Cookie + Session 在移动端和分布式场景下越来越吃力:

  • Session 需要服务器存储,水平扩展时得搞 Redis 共享
  • Cookie 在跨域、App 内嵌 H5 容易出问题
  • 移动端断网重连后,Session 容易失效,用户被迫重新登录

JWT(JSON Web Token)成了现代移动端认证的标配:无状态、可自验证、跨域友好

核心流程一句话: 服务器签发一个加密的 token(包含用户身份 + 过期时间),前端每次请求带在 Authorization: Bearer xxx 头里,后端只验证签名 + 过期,不用查数据库,就能知道“你是谁”。

依赖安装(NestJS 生态标配)

pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
pnpm add -D @types/passport-jwt @types/bcrypt

核心组件搭建

  1. UserService:密码哈希 + 校验 密码永不明文存储,用 bcrypt 哈希。

    // user.service.ts 核心方法
    async validateUser(name: string, password: string) {
      const user = await this.prisma.user.findUnique({ where: { name } });
      if (!user) return null;
      const isValid = await bcrypt.compare(password, user.password);
      return isValid ? user : null;
    }
    
    async createUser(dto: CreateUserDto) {
      const hashed = await bcrypt.hash(dto.password, 10);
      return this.prisma.user.create({
        data: { name: dto.name, password: hashed },
      });
    }
    
  2. 登录接口:签发 token

    // user.controller.ts
    @Post('login')
    async login(@Body() dto: LoginDto) {
      const user = await this.userService.validateUser(dto.name, dto.password);
      if (!user) {
        throw new UnauthorizedException('用户名或密码错误');
      }
    
      const payload = { sub: user.id, name: user.name }; // sub 是 JWT 标准字段
      return {
        access_token: this.jwtService.sign(payload, {
          expiresIn: '7d', // 建议生产用短过期 + refresh_token
        }),
      };
    }
    
  3. JwtStrategy:验证 token

    // jwt.strategy.ts
    import { Injectable } from '@nestjs/common';
    import { PassportStrategy } from '@nestjs/passport';
    import { ExtractJwt, Strategy } from 'passport-jwt';
    
    @Injectable()
    export class JwtStrategy extends PassportStrategy(Strategy) {
      constructor() {
        super({
          jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
          ignoreExpiration: false,
          secretOrKey: process.env.JWT_SECRET, // 从 .env 读取
        });
      }
    
      async validate(payload: any) {
        return { userId: payload.sub, name: payload.name }; // 返回到 req.user
      }
    }
    
  4. JwtAuthGuard:保护路由

    // protected route 示例
    @Get('profile')
    @UseGuards(JwtAuthGuard)
    getProfile(@Req() req) {
      return req.user; // { userId, name }
    }
    

前端配合:Axios 拦截器自动带 token

在前端(React + Zustand)项目中,通常用 Axios 做请求封装。 核心要点:不要在拦截器里用 useUserStore() (这是 React Hook,会报错),而是用 Zustand 的 getState() 方法在纯 JS 环境中安全读取最新 token。

// src/utils/axios.ts 典型写法
import axios from 'axios';
import { useUserStore } from '@/store/useUserStore';

axios.defaults.baseURL = 'http://localhost:3000/api';

axios.interceptors.request.use(config => {
  const token = useUserStore.getState().token; // 同步获取最新状态
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

axios.interceptors.response.use(
  res => res.data, // 统一返回 data
  error => {
    // 可加 401 自动登出逻辑
    if (error.response?.status === 401) {
      useUserStore.getState().logout(); // Zustand action
    }
    return Promise.reject(error);
  },
);

export default axios;

这样,前端每次请求自动带上最新 token,后端验证通过后 req.user 就能拿到用户信息,前端 Zustand persist 存储 token,实现“重启 App 仍登录”的体验。

总结一下JWT 登录验证流程
  1. 用户第一次登录(输入用户名 + 密码)

    • 前端把用户名 + 密码 POST → /login
    • 后端收到 → 去数据库查这个用户
    • 用 bcrypt 比对密码是否正确
    • 如果正确 → 立刻生成一个 JWT token

    这个 token 长这样(肉眼看是乱码,实际是三段 base64 拼接):

    text

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpxIiwiaWF0IjoxNTE2MjM5MDIyfQ.
    SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    

    三段分别代表:

    • Header(算法 + token 类型)
    • Payload(你放的数据,比如 { sub: 用户ID, name: "小明", iat: 签发时间 })
    • Signature(用密钥 + 前两段算出来的签名)

    后端把这个长字符串返回给前端:

    JSON

    { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpxIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" }
    
  2. 前端拿到 token 后做什么?

    • 存起来(React + Zustand 通常存到 store 里,有时还会 localStorage / AsyncStorage 做持久化)

    • 以后每一次发请求,都在请求头里带上它:

      text

      Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
      

    这就是为什么你看到 Axios 拦截器里写:

    config.headers.Authorization = `Bearer ${token}`;
    
  3. 用户访问需要登录的接口(比如 /profile)

    • 前端发 GET /profile,自动带上上面的 Authorization 头
    • NestJS 路由上写了 @UseGuards(JwtAuthGuard)
    • → 触发 JwtAuthGuard → 它会自动调用 JwtStrategy
  4. JwtStrategy 到底干了什么?

    1. Passport-jwt 自动从请求头里抠出 Bearer xxxx 的 xxxx 部分
    2. 用你配置的 secretOrKey 去验证签名是否正确
    3. 检查过期时间(iat + exp)
    4. 如果以上都通过,就把 token 里原本加密的 payload 解出来
    5. 调用 validate() 方法,把 payload 传给你
    6. 你在 validate() 里返回什么,NestJS 就会把什么挂到 req.user 上

    所以你后面才能在 controller 里写:

    getProfile(@Req() req) {
      return req.user;   // ← 这里能拿到 { userId, name }
    }
    

这样就实现了在同一个请求的控制器、服务、拦截器、管道等任何地方,都能通过 req.user 拿到这个用户信息

总结来说:

后端根本不需要再去查数据库,只要签名对 + 没过期,就信任这个 token 里写的是真的用户信息。

这就是 JWT “无状态” 的本质。

总结

后端基础架构搭建完成后,前端只需将 Mock 地址切换为 http://localhost:3000/api,即可无缝进入真实联调阶段,整个系统从“空中楼阁”瞬间落地为可运行的生产级原型。

关键点

  • NestJS 的模块化设计:Posts、User、Prisma 等模块高度解耦,未来扩展 FileUploadModule、NotificationModule 等功能时,几乎零侵入、无需大规模重构。
  • Prisma 的类型安全与开发体验:复杂关联查询彻底摆脱手写 JOIN 和 SQL 字符串拼接,类型推导严谨,运行时错误大幅减少;schema 修改后一键 migrate + generate,业务代码无需任何调整即可同步数据库结构——这正是现代 ORM 带来的生产力飞跃。
  • 全局配置一站式:ValidationPipe、静态资源、CORS、前缀等在 main.ts 集中管理,开发阶段配置一次,全局生效,极大降低重复工作。
  • JWT + DTO 的安全:无状态认证 + 严格输入校验相结合,构建了移动端友好的认证体系,同时将脏数据和非法输入挡在服务层之外,显著提升接口稳定性与安全性。
  • 性能优化红利:Promise.all 并行查询 + 精准 select + _count 防 N+1,实际测试中小型数据集下 QPS 轻松提升 1.5–2 倍,响应延迟明显降低。

最令人印象深刻的时刻,往往出现在改动 schema.prisma 后执行 prisma migrate devprisma generate,service 层代码一行不动,数据库表结构、类型定义、关联关系全部自动同步——这才是现代全栈开发的真正魅力:让开发者把精力真正聚焦在业务价值上,而不是基础设施的重复劳动

至此,我们完成了从 0 到 1 的后端全链路搭建,系统已具备良好的扩展性、安全性和可维护性。