用 NestJS + Prisma + PostgreSQL 从零搭建博客后端
本文是我面试备战期间动手实现的项目总结,涵盖从数据库设计到 JWT 认证的完整链路。
技术选型
技术版本用途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 dev 和 db 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);
}
ValidationPipe 的 whitelist + 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: '数据操作失败' });
}
}
}
P2002 和 P2025 是最常见的两个错误码,几乎所有 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❌热门标签
小结
整个项目最有意思的几个点:
Prisma 的类型安全比 Mongoose 舒服太多,schema 改了 TypeScript 直接报错
NestJS 的 Guard + Strategy 分层逻辑清晰,"什么时候验证"和"怎么验证"彻底解耦
Comment 自引用实现嵌套评论只用一张表就够了,查询时
parentId: null过滤顶级评论全局 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 文档