【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 一键启动 |
| ORM | Prisma 7 | 类型安全、自动 migration、Prisma Studio GUI |
| 后端 | NestJS | Module / Controller / Service 三层 |
| 校验 | Zod + nestjs-zod | 运行时校验 + TypeScript 类型推导 |
| 部署 | Docker Compose | 本地开发环境 |
三、数据模型设计
cards ──┬── card_tags ──┬── tags (多对多) │ └── learning_records (一对多)四张表:
cards:卡片主表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | UUID | 主键 |
| title | String | 标题 |
| content | String | 正文 |
| summary | String? | 摘要 |
| category | String? | 分类 |
| difficulty | CardDifficulty | 枚举:basic / medium / advanced |
| status | CardStatus | 枚举:todo / doing / done |
| created_at | DateTime | 自动生成 |
| updated_at | DateTime | 自动更新 |
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 定义
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) * pageSize | findMany({ skip, take }) |
| 模糊搜索 | contains + mode: 'insensitive' | PostgreSQL ILIKE |
| 等值筛选 | where.status = query.status | Prisma enum 直接比较 |
| 动态排序 | { [sortBy]: sortOrder } + satisfies | TypeScript 类型安全 |
| 事务 | $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:docker compose up -d # 启动 PGnpx 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/take | Prisma 原生支持,简单直接 |
| 搜索 | contains + insensitive | PostgreSQL ILIKE,不区分大小写 |
| 404 保护 | Service 层手动 findOne | 比 Prisma 原生错误信息更友好 |
| 模糊搜索 | 三字段 OR | title/summary/content 同时搜 |
十一、后续版本路线
| 版本 | 新增 | 学习点 |
|---|---|---|
| v0.1 ✅ | PostgreSQL + Prisma CRUD | 分页/筛选/排序/枚举 |
| v0.2 | 索引与查询优化 | EXPLAIN / 复合索引 |
| v0.3 | 事务与并发 | 乐观锁 / 隔离级别 |
| v0.4 | Redis 缓存 | Cache Aside |
| v0.5 | MongoDB 日志 | 文档模型 |
| v0.6 | Elasticsearch 搜索 | 倒排索引 |
| v0.7 | IndexedDB 离线 | 离线优先 |
| v0.8 | AI 能力 | RAG / 摘要 / 测验 |
写在最后
Card Learning Demo 是一个”麻雀虽小五脏俱全”的全栈项目。从 Docker 启动数据库 → Prisma 建模 → Zod DTO 校验 → NestJS CRUD → 分页/筛选/排序,每一步都有明确的工程决策和代码落地。
它不是”一次性写完就丢”的 Demo——它设计成渐进式的:每个版本只加一个新技术,逐步深入。这也是 AI-Journey-Land 平台一贯的理念:每个 Demo 都保留来源上下文,并按独立 feature 边界沉淀接口、页面和运行契约。
项目地址:AI-Journey-Land,欢迎 star ⭐
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!