【AI全栈】Card Learning Demo——一个全栈数据库学习项目的设计与实现

1648 字
8 分钟
【AI全栈】Card Learning Demo——一个全栈数据库学习项目的设计与实现

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


一、项目定位#

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

创建 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本地开发环境

三、数据模型设计#

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#

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

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


四、项目结构#

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 定义#

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#

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(核心亮点)#

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#

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 路由设计#

@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/单条详情
POST/api/cards创建
PATCH/api/cards/部分更新
DELETE/api/cards/删除

七、Service 核心逻辑#

7.1 完整 findAll#

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 全貌#

// 创建
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 部署#

# 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:
Terminal window
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 ⭐

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【AI全栈】Card Learning Demo——一个全栈数据库学习项目的设计与实现
https://blog.fridolph.top/posts/2026-05-19__card-demo/
作者
Fridolph
发布于
2026-05-19
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录