Lesson 19:数据库设计 — Prisma ORM 建模与迁移
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:90~150 分钟(首次学习)
- 先修要求:完成 L17–18,已有商品列表和详情页
- 学习产出:建立 Prisma 6 schema、迁移和种子数据,让列表与详情查询同一数据库
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:用 Prisma 设计电商数据模型,连接真实数据库,告别硬编码假数据。
📦 本节产出:课程所需的数据库 Schema(用户、商品、订单),并在商品列表页从数据库读取真实数据。
一、Prisma 是什么?
直接使用 SQL 需要自行维护查询、参数和返回值类型。把外部输入拼进 SQL 字符串还有注入风险,应使用参数化查询。ORM 可以减少这部分重复工作,但仍需理解数据库约束和查询成本。
Prisma 是一个 Node.js / TypeScript 的 ORM(对象关系映射)。它让你:
- 用一种声明式语言描述数据库结构(Schema)
- 自动生成类型安全的 TypeScript 客户端
- 用类型化查询方法和参数对象表达常见 SQL 操作
二、安装与初始化
npm install @prisma/client@6 server-only
npm install -D prisma@6 dotenv
npx prisma init --datasource-provider sqliteTIP
本地使用 SQLite,数据保存在文件中。本阶段的代码使用 Prisma 6(CLI 与 Client 保持同一 6.x 版本,校对时为 6.19.3),并提交 lockfile。Prisma 7 的生成器、连接和 seed 配置有变化,不能直接套用本节,见 官方升级指南。
部署时若改用 PostgreSQL,需要检查字段类型和查询差异、重新建立该数据库的迁移历史,并迁移已有数据;SQLite 的 SQL 迁移不能原样用于 PostgreSQL。参见 Prisma Migrate 限制。
初始化后项目中多出:
prisma/
└── schema.prisma ← 数据模型定义文件
.env ← 数据库连接字符串
prisma.config.ts ← Prisma CLI 配置(6.19.x 初始化会生成)在 .env 设置 DATABASE_URL="file:./dev.db",数据库文件相对于 schema 目录解析。不要提交 .env 或开发数据库。
三、设计数据模型
编辑 prisma/schema.prisma:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// 用户表
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role String @default("customer") // "customer" | "admin"
orders Order[]
createdAt DateTime @default(now())
}
// 商品表
model Product {
id String @id @default(cuid())
name String
description String?
price Int // 人民币分,例如 ¥99.00 存 9900
image String?
category String @default("general")
stock Int @default(0)
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category, price])
}
// 订单表
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
items OrderItem[]
total Int // 人民币分
status String @default("pending") // pending | payment_pending | paid | expired | failed | cancelled | shipped | completed
currency String @default("cny")
checkoutKey String @unique // 客户端请求标识,仅用于防止重复建单
stripeSessionId String? @unique
paidAt DateTime?
@@index([userId, createdAt])
createdAt DateTime @default(now())
}
// 订单-商品 多对多关联表
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
quantity Int
name String // 下单时的商品名快照
price Int // 下单时的单价快照,单位:分
@@unique([orderId, productId])
@@index([productId])
}3.1 关系图解
四、执行迁移与数据填充
4.1 创建数据库
npx prisma migrate dev --name init这条命令会:
- 根据 Schema 生成 SQL 创建所有的表
- 在
prisma/migrations/目录下记录版本历史 - 自动执行
prisma generate生成类型化客户端
若在 macOS arm64、Node.js 24、Prisma 6.19.3 上首次创建 SQLite 文件时仅收到 Schema engine error,先确认路径和权限,再用 node -e "require('node:fs').closeSync(require('node:fs').openSync('prisma/dev.db', 'a'))" 预建空文件后重试。a 模式不会清空已有文件;仅在使用上述 file:./dev.db 配置时使用这个路径。
4.2 填充测试数据 (Seed)
创建 prisma/seed.ts:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// 固定 ID + upsert 可重复运行,不删除已有用户和订单。
const products = [
{ id: '1', name: 'React 19 实战手册', price: 9900, description: 'React 入门与实战', category: 'book', stock: 100 },
{ id: '2', name: 'TypeScript 进阶指南', price: 12900, description: '类型体操与工程实践', category: 'book', stock: 50 },
{ id: '3', name: 'Next.js 全栈开发', price: 15900, description: 'App Router 深度解析', category: 'book', stock: 80 },
{ id: '4', name: '机械键盘 Pro', price: 59900, description: '87键 茶轴 RGB', category: 'electronics', stock: 30 },
{ id: '5', name: '程序员 T 恤', price: 7900, description: '100% 纯棉 黑色', category: 'clothing', stock: 200 },
]
for (const product of products) {
await prisma.product.upsert({
where: { id: product.id },
update: {}, // 已有商品保留当前价格和库存
create: product,
})
}
console.log('✅ 种子数据已填充!')
}
main()
.catch(error => {
console.error(error)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())Prisma 6.19.x 的初始化命令还会生成 prisma.config.ts。保留它,并把 seed 配置统一写在这里;不要再同时添加 package.json#prisma.seed。schema 使用本课上方的 prisma-client-js,不要保留新模板中不同的生成器/output 设置。
// prisma.config.ts
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations', seed: 'tsx prisma/seed.ts' },
engine: 'classic',
datasource: { url: env('DATABASE_URL') },
})npm install -D tsx # 用来运行 .ts 的 seed 脚本
npx prisma db seed # 执行填充五、在 Server Component 中查询数据库
5.1 创建 Prisma 客户端单例
// src/lib/prisma.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}WARNING
为什么需要单例? Next.js 开发模式下热重载会反复执行模块,重复创建客户端可能增加连接和资源占用。缓存实例可在开发热重载时复用;生产环境的多进程或多实例仍各有自己的客户端,需要另外评估连接池容量。
5.2 改造商品列表页
// src/app/products/page.tsx
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
export const dynamic = 'force-dynamic' // 本节每次请求查询,缓存放到后续课程讨论
export default async function ProductsPage() {
// 🎉 直接在 Server Component 里查询数据库!
const products = await prisma.product.findMany({
orderBy: { createdAt: 'desc' }
})
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">全部商品 ({products.length})</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(product => (
<Link
key={product.id}
href={`/products/${product.id}`}
className="group bg-white rounded-2xl border border-gray-200 overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="h-48 bg-gray-100 flex items-center justify-center text-5xl">
📦
</div>
<div className="p-5">
<h2 className="font-semibold text-lg group-hover:text-indigo-600 transition-colors">
{product.name}
</h2>
<p className="mt-1 text-sm text-gray-500">{product.description}</p>
<p className="mt-3 text-2xl font-bold text-indigo-600">¥{(product.price / 100).toFixed(2)}</p>
</div>
</Link>
))}
</div>
</div>
)
}这里在服务器上直接等待查询结果,不需要客户端的 useEffect 或请求状态。加载和错误界面仍要设计,可使用 loading.tsx、error.tsx 或 Suspense。
同时将 L18 的 src/lib/products.ts 替换为下面的数据库访问函数,详情页就会读取同一份数据:
// src/lib/products.ts
import 'server-only'
import { prisma } from '@/lib/prisma'
export async function getProduct(id: string) {
return prisma.product.findUnique({ where: { id } })
}在详情页也加上 export const dynamic = 'force-dynamic',避免把本节库存展示误当成构建时快照。金额字段保持整数分,传给客户端时是普通 number。
六、🧠 深度专题:数据库范式与索引
6.1 为什么 OrderItem 要记录 price?
注意我们的 OrderItem 有 price 字段,即使 Product 已经有了。 这叫 价格快照:商品价格可能随时变,但订单里的价格必须是下单那一刻的价格。这是电商系统的铁律设计。
6.2 数据库范式 (Normal Forms)
范式是数据库设计中减少数据冗余、避免更新异常的规则:
| 范式 | 要求 | 通俗理解 |
|---|---|---|
| 1NF | 属性值在所选模型中不可再分,避免重复组 | 多个标签通常建关联表,便于单独约束和查询 |
| 2NF | 满足 1NF,非主属性不能只依赖候选键的一部分 | 复合键 (orderId, productId) 下,当前商品说明仅依赖 productId,应放在 Product |
| 3NF | 满足 2NF,避免非主属性经其他非主属性传递依赖候选键 | 用户当前联系方式放在 User,订单通过 userId 关联 |
范式讨论的是函数依赖,不是“相同字段名只能出现一次”。OrderItem.price 表示下单时单价,Product.price 表示当前单价,语义不同;订单中的 name 同样保存当时名称。商品后来改名或调价时,历史订单仍使用自己的快照。subtotal = price × quantity 可计算出来,通常无需重复保存,但这也不能单凭字段重复就判断违反哪一范式。
金额不直接以浮点小数保存。本教程只处理人民币,统一存整数分;如果改为多币种,必须根据币种的最小单位换算。这里的 Int 还有数据库取值范围,服务端会限制单价、数量和总金额。字符串角色、订单状态也需由服务端校验,数据库不会自动把注释中的状态列表当作约束。
6.3 索引 (Index) — 给数据库加"目录"
没有可用索引时,等值查询可能扫描全表;是否使用索引由数据库根据数据分布和成本决定。User.email 已有 @unique,不需要再为同一字段重复建索引。以下只是字段和索引的节选,应合并到完整模型中:
model User {
email String @unique // @unique 自动创建唯一索引
}
model Product {
category String
price Int
@@index([category, price]) // 复合索引:按分类 + 价格排序双重加速
}
model Order {
userId String
createdAt DateTime @default(now())
@@index([userId, createdAt]) // 查某用户的最近订单
}复合索引的顺序很重要(最左前缀原则):
@@index([category, price])可以加速WHERE category = 'book'- 也可以加速
WHERE category = 'book' AND price < 100 - 单独按
price查询通常不能有效利用该索引的前导列,是否受益取决于数据库和查询计划,不能一概说“绝对不能”;用EXPLAIN验证。
索引也会占空间并增加写入成本。上述说明针对常见 B-tree 查询,参见 PostgreSQL 多列索引文档。
七、练习
- 打开
npx prisma studio(Prisma 自带的数据库可视化管理界面),浏览和编辑你刚才填充的数据。 - 在 Prisma Studio 修改商品名称、价格和库存,再刷新列表和详情页,确认两处读取同一数据库且金额换算一致。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 用 Prisma Schema 定义了课程所需的电商数据模型 | ORM 的概念和 Prisma 的声明式建模 |
| 执行了数据库迁移和数据填充 | prisma migrate + prisma db seed |
| 在 Server Component 里直接查询了数据库 | 无需 API 层、无需 useEffect! |
| — | 数据库设计中的价格快照和索引知识 |