liveBlog

用 NestJS + Prisma + PostgreSQL 从零搭建后端

用 NestJS + Prisma + PostgreSQL 从零搭建博客后端

本文是我面试备战期间动手实现的项目总结,涵盖从数据库设计到 JWT 认证的完整链路。

项目源码:"https://github.com/Zjianzhi/nestjs-postgres-backend


技术选型

技术版本用途NestJS10.x后端框架Prisma5.xORMPostgreSQL14数据库Passport + JWT0.x认证bcryptjs2.x密码加密Swagger7.xAPI 文档class-validator0.xDTO 校验

NestJS 的模块化架构配合 Prisma 的类型安全让整个开发体验非常顺畅——每次改完 Schema 马上能看到 TypeScript 报错,几乎不会写出"能跑但数据不对"的代码。


数据库设计

整个博客系统涉及 4 张表,关系设计如下:

User 1────< N Post         (一个用户写多篇文章)
Post 1────< N Comment      (一篇文章有多条评论)
User 1────< N Comment      (一个用户发多条评论)
Comment 1──< N Comment     (评论自引用,支持嵌套回复)
Post M ──<>── N Tag        (文章和标签多对多)

Prisma Schema 定义

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  password  String
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  posts     Post[]
  comments  Comment[]
  @@map("users")
}

model Post {
  id          Int       @id @default(autoincrement())
  title       String
  content     String?
  published   Boolean   @default(false)
  viewCount   Int       @default(0) @map("view_count")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")
  authorId    Int       @map("author_id")
  author      User      @relation(fields: [authorId], references: [id])
  comments    Comment[]
  tags        Tag[]     @relation("PostTags")
  @@map("posts")
}

model Comment {
  id        Int       @id @default(autoincrement())
  content   String
  createdAt DateTime  @default(now()) @map("created_at")
  postId    Int       @map("post_id")
  post      Post      @relation(fields: [postId], references: [id], onDelete: Cascade)
  authorId  Int       @map("author_id")
  author    User      @relation(fields: [authorId], references: [id])
  parentId  Int?      @map("parent_id")
  parent    Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
  replies   Comment[] @relation("CommentReplies")
  @@map("comments")
}

model Tag {
  id        Int    @id @default(autoincrement())
  name      String @unique
  posts     Post[] @relation("PostTags")
  @@map("tags")
}

几个关键设计决策:

  • Comment 自引用parentId 可空,null 表示顶级评论,有值则是某条评论的回复

  • onDelete: Cascade:文章删除时自动级联删除旗下所有评论,不用手动处理

  • @@map():Prisma 模型名用驼峰,数据库表名用 snake_case,两边都干净

Prisma 命令全流程

从零到上线,命令按顺序执行:

# 1. 初始化(只创建 schema.prisma 和 .env 模板)
npx prisma init

# 2. 首次同步(生成迁移文件 + 建表)
npx prisma migrate dev --name init

# 3. 每次改完 schema 后增量同步
npx prisma migrate dev --name add-user-avatar

# 4. 生产环境部署(只执行已有迁移,不生成新文件)
npx prisma migrate deploy

# 5. 填充测试数据
npx prisma db seed

# 6. 可视化查看数据
npx prisma studio

migrate devdb push 的区别经常被问到:前者生成迁移文件保留历史,适合团队协作;后者直接推送不留历史,适合原型阶段快速验证。


Prisma 服务封装

NestJS 中 PrismaClient 需要封装为单例,否则多次实例化会导致连接池耗尽。

// src/prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
// src/prisma/prisma.module.ts
@Global()   // 全局模块,其他 Module 无需重复 import
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

加了 @Global() 之后,任何 Service 只需在 constructor 中注入即可直接用,不用每个 module 都显式 import。


应用入口与全局配置

// src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 全局异常过滤器(捕获 Prisma 错误转换为 HTTP 响应)
  app.useGlobalFilters(new PrismaExceptionFilter());

  // 全局验证管道
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,            // 自动剥离 DTO 未定义的字段
    transform: true,            // 自动类型转换(Query 参数 string → number)
    forbidNonWhitelisted: true, // 传多余字段直接 400
  }));

  // Swagger 文档(访问 /api)
  const config = new DocumentBuilder()
    .setTitle('Blog API')
    .addBearerAuth()
    .build();
  SwaggerModule.setup('api', app, SwaggerModule.createDocument(app, config));

  await app.listen(3000);
}

ValidationPipewhitelist + forbidNonWhitelisted 组合非常好用,能在入口层把脏数据拦住,不用在 Service 里各种手动判断。


JWT 认证系统

这是整个项目最核心的部分。完整认证链路如下:

请求 → JwtAuthGuard → JwtStrategy.validate() → 解析 Token → request.user
                                                            → @CurrentUser() 装饰器取出

AuthModule 配置

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
        signOptions: { expiresIn: '7d' },
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

JWT Strategy

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
    private prisma: PrismaService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: { sub: number; email: string; name: string }) {
    // 每次请求都校验用户是否仍然存在
    const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
    if (!user) throw new UnauthorizedException('用户不存在');
    return { userId: payload.sub, email: payload.email, name: payload.name };
  }
}

注册和登录核心逻辑

// 注册
async register(dto: RegisterDto) {
  const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
  if (existing) throw new ConflictException('该邮箱已被注册');

  const hashedPassword = await bcrypt.hash(dto.password, 10);
  const user = await this.prisma.user.create({
    data: { ...dto, password: hashedPassword },
    select: { id: true, name: true, email: true, createdAt: true },  // 不返回 password
  });

  return { user, access_token: this.generateToken(user.id, user.email, user.name) };
}

// 登录
async login(dto: LoginDto) {
  const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
  if (!user) throw new UnauthorizedException('邮箱或密码错误');  // 故意不区分,防枚举攻击

  const isValid = await bcrypt.compare(dto.password, user.password);
  if (!isValid) throw new UnauthorizedException('邮箱或密码错误');

  return {
    user: { id: user.id, name: user.name, email: user.email },
    access_token: this.generateToken(user.id, user.email, user.name),
  };
}

// 生成 Token
private generateToken(userId: number, email: string, name: string) {
  return this.jwtService.sign({ sub: userId, email, name });
}

自定义装饰器:@CurrentUser()

export const CurrentUser = createParamDecorator(
  (data: keyof UserPayload | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user as UserPayload;
    return data ? user?.[data] : user;
  },
);

// 用法
@Get('profile')
@UseGuards(JwtAuthGuard)
async getProfile(@CurrentUser() user: UserPayload) { ... }

// 只取 userId
async create(@CurrentUser('userId') userId: number) { ... }

业务模块:Posts(含搜索/分页/权限)

Posts 是最复杂的模块,涉及搜索、分页、多对多标签关联和权限控制。

动态搜索 + 分页

async findAll(query: QueryPostDto) {
  const { search, authorId, published, page = 1, pageSize = 10 } = query;
  const where: any = {};

  // 标题/内容模糊搜索(不区分大小写)
  if (search) {
    where.OR = [
      { title: { contains: search, mode: 'insensitive' } },
      { content: { contains: search, mode: 'insensitive' } },
    ];
  }
  if (authorId) where.authorId = authorId;
  if (published !== undefined) where.published = published;

  const total = await this.prisma.post.count({ where });
  const posts = await this.prisma.post.findMany({
    where,
    skip: (page - 1) * pageSize,
    take: pageSize,
    orderBy: { createdAt: 'desc' },
    include: { author: { select: { id: true, name: true } }, tags: true },
  });

  return {
    data: posts,
    meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
  };
}

权限校验

async update(id: number, updateData: any, userId: number) {
  const post = await this.findOne(id);
  if (post.authorId !== userId) {
    throw new ForbiddenException('您没有权限修改此文章');
  }
  // ... 更新逻辑
}

浏览量原子递增

// 原子操作,并发安全
await this.prisma.post.update({
  where: { id },
  data: { viewCount: { increment: 1 } },
});

嵌套评论树形结构

评论支持嵌套回复,查询时只取顶级评论并 include 其回复:

async findByPost(postId: number) {
  return this.prisma.comment.findMany({
    where: { postId, parentId: null },    // 只取顶级评论
    orderBy: { createdAt: 'desc' },
    include: {
      author: { select: { id: true, name: true } },
      replies: {                          // 嵌套的回复
        orderBy: { createdAt: 'asc' },
        include: { author: { select: { id: true, name: true } } },
      },
    },
  });
}

全局异常处理

Prisma 错误不会自动变成 HTTP 响应,需要用 ExceptionFilter 捕获转换:

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
  catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse<Response>();

    switch (exception.code) {
      case 'P2002':  // 唯一约束冲突(重复邮箱等)
        response.status(409).json({ message: '该数据已存在' });
        break;
      case 'P2025':  // 记录不存在
        response.status(404).json({ message: '请求的资源不存在' });
        break;
      default:
        response.status(400).json({ message: '数据操作失败' });
    }
  }
}

P2002P2025 是最常见的两个错误码,几乎所有 CRUD 项目都会遇到。


SQL ↔ Prisma 对照速查

操作SQLPrisma查全部SELECT * FROM users;prisma.user.findMany()按 ID 查WHERE id = 1findUnique({ where:{id:1} })模糊搜索ILIKE '%keyword%'{contains:'keyword', mode:'insensitive'}分页LIMIT 10 OFFSET 10{skip:10, take:10}统计COUNT(*)prisma.post.count()原子递增SET view_count=view_count+1{viewCount:{increment:1}}多对多关联操作中间表tags:{connect:[{id:2},{id:3}]}更新多对多DELETE + INSERT{set:[], connect:[...]}


API 路由总览

方法路径认证功能POST/auth/register❌用户注册POST/auth/login❌用户登录GET/auth/profile✅获取当前用户POST/posts✅创建文章GET/posts❌文章列表(搜索/分页)GET/posts/:id❌文章详情PATCH/posts/:id✅更新文章(作者本人)DELETE/posts/:id✅删除文章(作者本人)POST/posts/:id/publish✅发布文章POST/comments❌创建评论/回复GET/comments/post/:postId❌文章评论(树形)GET/tags❌所有标签GET/tags/popular❌热门标签


小结

整个项目最有意思的几个点:

  1. Prisma 的类型安全比 Mongoose 舒服太多,schema 改了 TypeScript 直接报错

  2. NestJS 的 Guard + Strategy 分层逻辑清晰,"什么时候验证"和"怎么验证"彻底解耦

  3. Comment 自引用实现嵌套评论只用一张表就够了,查询时 parentId: null 过滤顶级评论

  4. 全局 ExceptionFilter 统一处理 Prisma 错误码,Service 层不用到处 try-catch

如果你也在准备后端面试或者想入门 NestJS 生态,可以直接 clone 项目跑起来看看:

👉 https://github.com/Zjianzhi/nestjs-postgres-backend

本地启动只需要:

# 安装依赖
npm install

# 配置 .env(数据库连接 + JWT 密钥)
cp .env.example .env

# 建表 + 填充测试数据
npx prisma migrate dev
npx prisma db seed

# 启动
npm run start:dev
# 然后访问 http://localhost:3000/api 查看 Swagger 文档