Skip to content

Lesson 19:数据库设计 — Prisma ORM 建模与迁移 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:90~150 分钟(首次学习)
  • 先修要求:完成 L17–18,已有商品列表和详情页
  • 学习产出:建立 Prisma 6 schema、迁移和种子数据,让列表与详情查询同一数据库
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:用 Prisma 设计电商数据模型,连接真实数据库,告别硬编码假数据。

📦 本节产出:课程所需的数据库 Schema(用户、商品、订单),并在商品列表页从数据库读取真实数据。

一、Prisma 是什么? ​

直接使用 SQL 需要自行维护查询、参数和返回值类型。把外部输入拼进 SQL 字符串还有注入风险,应使用参数化查询。ORM 可以减少这部分重复工作,但仍需理解数据库约束和查询成本。

Prisma 是一个 Node.js / TypeScript 的 ORM(对象关系映射)。它让你:

  1. 用一种声明式语言描述数据库结构(Schema)
  2. 自动生成类型安全的 TypeScript 客户端
  3. 用类型化查询方法和参数对象表达常见 SQL 操作

二、安装与初始化 ​

bash
npm install @prisma/client@6 server-only
npm install -D prisma@6 dotenv
npx prisma init --datasource-provider sqlite

TIP

本地使用 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
// 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 创建数据库 ​

bash
npx prisma migrate dev --name init

这条命令会:

  1. 根据 Schema 生成 SQL 创建所有的表
  2. 在 prisma/migrations/ 目录下记录版本历史
  3. 自动执行 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:

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 设置。

ts
// 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') },
})
bash
npm install -D tsx        # 用来运行 .ts 的 seed 脚本
npx prisma db seed        # 执行填充

五、在 Server Component 中查询数据库 ​

5.1 创建 Prisma 客户端单例 ​

ts
// 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 改造商品列表页 ​

tsx
// 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 替换为下面的数据库访问函数,详情页就会读取同一份数据:

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,不需要再为同一字段重复建索引。以下只是字段和索引的节选,应合并到完整模型中:

prisma
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 多列索引文档。


七、练习 ​

  1. 打开 npx prisma studio(Prisma 自带的数据库可视化管理界面),浏览和编辑你刚才填充的数据。
  2. 在 Prisma Studio 修改商品名称、价格和库存,再刷新列表和详情页,确认两处读取同一数据库且金额换算一致。

📌 本节小结 ​

你做了什么你学到了什么
用 Prisma Schema 定义了课程所需的电商数据模型ORM 的概念和 Prisma 的声明式建模
执行了数据库迁移和数据填充prisma migrate + prisma db seed
在 Server Component 里直接查询了数据库无需 API 层、无需 useEffect!
—数据库设计中的价格快照和索引知识

项目驱动 · 边写边学 · React 19