Skip to content

从零搭建一个全栈数据库学习项目:Docker 启动 PostgreSQL → Prisma Schema 建模 → NestJS DTO + Zod 校验 → 完整 CRUD 接口 → 分页/筛选/排序。这篇文章记录完整的开发过程。


一、项目定位

Card Learning Demo 是 AI-Journey-Land 的全栈数据库学习项目,定位为渐进式知识管理系统——用"一张知识卡片的一生"串联全部数据库技术栈:

text
创建 Card → PostgreSQL 持久化 → Redis 缓存 → MongoDB 行为日志
         → Elasticsearch 搜索 → IndexedDB 离线 → AI 能力

v0.1 MVP 阶段已完成:PostgreSQL + Prisma + NestJS CRUD + 分页/筛选/排序。


二、技术栈

技术说明
数据库PostgreSQL 17主业务数据库,Docker Compose 一键启动
ORMPrisma 7类型安全、自动 migration、Prisma Studio GUI
后端NestJSModule / Controller / Service 三层
校验Zod + nestjs-zod运行时校验 + TypeScript 类型推导
部署Docker Compose本地开发环境

三、数据模型设计

text
cards ──┬── card_tags ──┬── tags         (多对多)

        └── learning_records              (一对多)

四张表:

cards:卡片主表

字段类型说明
idUUID主键
titleString标题
contentString正文
summaryString?摘要
categoryString?分类
difficultyCardDifficulty枚举:basic / medium / advanced
statusCardStatus枚举:todo / doing / done
created_atDateTime自动生成
updated_atDateTime自动更新

tags:标签表(name UNIQUE)

card_tags:多对多关联表(cardId + tagId 联合主键)

learning_records:学习记录(cardId 外键指向 Card)

Prisma Enum

prisma
enum CardStatus { todo doing done }
enum CardDifficulty { basic medium advanced }

选择 Enum 而非 String 的理由:数据库层面约束合法值,TypeScript 编译时检查。


四、项目结构

text
apps/api/src/cards/
├── cards.module.ts          # NestJS 模块声明
├── cards.controller.ts      # REST 路由
├── cards.service.ts         # 业务逻辑
└── dto/
    ├── card.schema.ts       # Zod schema 集中定义
    ├── create-card.dto.ts   # POST 请求校验
    ├── update-card.dto.ts   # PATCH 请求校验
    └── query-cards.dto.ts   # GET 查询参数校验

五、Zod DTO 设计

5.1 集中式 Schema 定义

typescript
// card.schema.ts
export const cardStatusSchema = z.enum(['todo', 'doing', 'done'])
export const cardDifficultySchema = z.enum(['basic', 'medium', 'advanced'])
export const cardSortBySchema = z.enum(['createdAt', 'updatedAt', 'title', 'difficulty', 'status'])
export const sortOrderSchema = z.enum(['asc', 'desc'])

枚举集中定义,Create / Update / Query DTO 复用同一份 Schema。

5.2 Create DTO

typescript
export const createCardSchema = z.object({
  title: z.string().trim().min(1),
  summary: z.string().trim().optional(),
  content: z.string().trim().optional(),
  category: z.string().trim().min(1).optional(),
  difficulty: cardDifficultySchema.default('basic'),
  status: cardStatusSchema.default('todo'),
}).strict()

export class CreateCardDto extends createZodDto(createCardSchema) {}

5.3 Query DTO(核心亮点)

typescript
export const queryCardsSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(10),
  keyword: z.string().trim().min(1).optional(),
  status: cardStatusSchema.optional(),
  difficulty: cardDifficultySchema.optional(),
  category: z.string().trim().min(1).optional(),
  sortBy: cardSortBySchema.default('createdAt'),
  sortOrder: sortOrderSchema.default('desc'),
}).strict()

export class QueryCardsDto extends createZodDto(queryCardsSchema) {}

z.coerce.number() 是关键——URL query string 传参是字符串(?page=1),coerce 自动转为 number。Service 层不再需要手动 Number(query.page)

5.4 Update DTO

typescript
export const updateCardSchema = z.object({
  title: z.string().trim().min(1).optional(),
  /* ... 所有字段都是 optional ... */
}).strict()
  .refine((data) => Object.keys(data).length > 0, {
    message: '至少需要提供一个要更新的字段',
  })

.refine() 自定义校验——PATCH {} 空对象直接 400。


六、Controller 路由设计

typescript
@Controller('cards')
export class CardsController {
  constructor(private readonly cardsService: CardsService) {}

  @Get()       findAll(@Query() query: QueryCardsDto) { ... }
  @Get(':id')  findOne(@Param('id') id: string) { ... }
  @Post()      create(@Body() dto: CreateCardDto) { ... }
  @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateCardDto) { ... }
  @Delete(':id') remove(@Param('id') id: string) { ... }
}

路由表:

方法路径说明
GET/api/cards?page=1&sortBy=title&status=todo分页列表+筛选+排序
GET/api/cards/:id单条详情
POST/api/cards创建
PATCH/api/cards/:id部分更新
DELETE/api/cards/:id删除

七、Service 核心逻辑

7.1 完整 findAll

typescript
async findAll(query: QueryCardsDto) {
  const { page, pageSize } = query
  const skip = (page - 1) * pageSize
  const where: Prisma.CardWhereInput = {}

  // 关键词搜索(三字段 OR)
  if (query.keyword) {
    where.OR = [
      { title: { contains: query.keyword, mode: 'insensitive' } },
      { summary: { contains: query.keyword, mode: 'insensitive' } },
      { content: { contains: query.keyword, mode: 'insensitive' } },
    ]
  }

  // 枚举等值筛选
  if (query.status) where.status = query.status
  if (query.difficulty) where.difficulty = query.difficulty
  if (query.category) where.category = query.category

  // 动态排序
  const orderBy = {
    [query.sortBy]: query.sortOrder,
  } satisfies Prisma.CardOrderByWithRelationInput

  // 并发查询:数据 + 总数
  const [items, total] = await this.prisma.$transaction([
    this.prisma.card.findMany({ where, skip, take: pageSize, orderBy }),
    this.prisma.card.count({ where }),
  ])

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

7.2 逐段拆解

功能实现Prisma API
分页skip = (page-1) * pageSizefindMany({ skip, take })
模糊搜索contains + mode: 'insensitive'PostgreSQL ILIKE
等值筛选where.status = query.statusPrisma enum 直接比较
动态排序{ [sortBy]: sortOrder } + satisfiesTypeScript 类型安全
事务$transaction([findMany, count])一次网络往返
总数.count({ where })分页 total

7.3 CRUD 全貌

typescript
// 创建
async create(dto: CreateCardDto) {
  return this.prisma.card.create({
    data: {
      title: dto.title,
      content: dto.content || '',
      category: dto.category,
      difficulty: dto.difficulty,
      status: dto.status,
    },
  })
}

// 更新(PATCH 语义:只更新传了的字段)
async update(id: string, dto: UpdateCardDto) {
  await this.findOne(id)  // 404 保护
  return this.prisma.card.update({ where: { id }, data: dto })
}

// 删除
async remove(id: string) {
  await this.findOne(id)  // 404 保护
  return this.prisma.card.delete({ where: { id } })
}

设计要点

  • findOne 先做存在性检查——不存在的 ID 返回 404 而非 Prisma 底层错误
  • update 只传实际提供了的字段(Prisma 忽略 undefined),天然 PATCH 语义
  • content || ''——数据库列 NOT NULL,DTO 允许缺省

八、Docker 部署

yaml
# docker-compose.yml(项目根目录)
services:
  postgres:
    image: postgres:17-alpine
    container_name: ai-journey-pg
    restart: unless-stopped
    ports: ['15432:5432']
    environment:
      POSTGRES_USER: journey
      POSTGRES_PASSWORD: journey123
      POSTGRES_DB: ai_journey_lab
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
bash
docker compose up -d          # 启动 PG
npx prisma migrate dev        # 建表
npx prisma db seed            # 初始化数据
pnpm dev                      # 启动 NestJS

九、完整数据流(一次 POST /api/cards)

前端 Admin 页面
  └─ fetch POST /api/cards  { title: "PG 索引", difficulty: "advanced" }
       └─ NestJS Router → CardsController.create(@Body() dto: CreateCardDto)
            └─ ValidationPipe → createCardSchema.parse(body)
                 ├─ title 非空 ✅
                 ├─ difficulty = "advanced" ✅ (z.enum)
                 └─ status = "todo" (z.default)
            └─ CardsService.create(dto)
                 └─ prisma.card.create({ data: { title, content, ... } })
                      └─ PrismaClient → pg driver → PostgreSQL
                           INSERT INTO cards (...) VALUES (...)
            └─ ResponseInterceptor → { code: 200, data: { id, title, ... } }
  └─ 前端拿到 { id, title, difficulty: "advanced", status: "todo", ... }

十、开发过程中踩到的关键决策

决策选型理由
DTO 方案Zod + nestjs-zod运行时校验 + 类型推导,比 class-validator 更简洁
枚举Prisma Enum数据库层约束 + TS 类型安全,优于 String
排序动态 { [sortBy]: sortOrder } + satisfies支持任意字段排序,satisfies 保证类型检查
分页skip/takePrisma 原生支持,简单直接
搜索contains + insensitivePostgreSQL ILIKE,不区分大小写
404 保护Service 层手动 findOne比 Prisma 原生错误信息更友好
模糊搜索三字段 ORtitle/summary/content 同时搜

十一、后续版本路线

版本新增学习点
v0.1 ✅PostgreSQL + Prisma CRUD分页/筛选/排序/枚举
v0.2索引与查询优化EXPLAIN / 复合索引
v0.3事务与并发乐观锁 / 隔离级别
v0.4Redis 缓存Cache Aside
v0.5MongoDB 日志文档模型
v0.6Elasticsearch 搜索倒排索引
v0.7IndexedDB 离线离线优先
v0.8AI 能力RAG / 摘要 / 测验

写在最后

Card Learning Demo 是一个"麻雀虽小五脏俱全"的全栈项目。从 Docker 启动数据库 → Prisma 建模 → Zod DTO 校验 → NestJS CRUD → 分页/筛选/排序,每一步都有明确的工程决策和代码落地。

它不是"一次性写完就丢"的 Demo——它设计成渐进式的:每个版本只加一个新技术,逐步深入。这也是 AI-Journey-Land 平台一贯的理念:每个 Demo 都保留来源上下文,并按独立 feature 边界沉淀接口、页面和运行契约

项目地址:AI-Journey-Land,欢迎 star ⭐