Getting Started (更新: 2026/6/3)

Claude Code 与 Prisma ORM 实战:Schema、迁移、事务和上线前检查

用 Claude Code 设计 Prisma schema、审查迁移、实现 TypeScript 查询和事务,并避开上线前最常见的数据库坑。

Claude Code 与 Prisma ORM 实战:Schema、迁移、事务和上线前检查

Prisma ORM 是一个面向 Node.js 和 TypeScript 的数据库层。对初学者来说,可以把它理解成“让 TypeScript 安全地读写数据库的工具”:你先在 schema.prisma 里定义 User、Post、Order 这样的数据模型,再用 Prisma Migrate 生成迁移 SQL,最后通过自动生成的 Prisma Client 在应用代码里查询数据。

Claude Code 很适合辅助 Prisma 项目,但不能只让它“帮我写 CRUD”。数据库代码一旦上线,错误会直接影响真实用户、订单、权限和报表。正确的做法是让 Claude Code 负责起草、解释、补测试、列风险,而人负责确认 schema、迁移和删除策略。本文会用一个博客 API 的例子,展示从 schema 设计到事务、seed、测试和上线前 checklist 的完整流程。

建议同时打开官方文档:Prisma ORM、Prisma Schema、Transactions、Prisma Migrate、Raw queries。如果你还在搭建 Claude Code 的基本使用方式,可以先看 /zh/blog/claude-code-getting-started-guide。如果项目后端是 Supabase 或需要缓存,也可以连着读 /zh/blog/claude-code-supabase-integration 和 /zh/blog/claude-code-redis-caching。

推荐的工作流

flowchart LR
  A["写清业务规则"] --> B["让 Claude Code 起草 schema"]
  B --> C["人工审查 migration.sql"]
  C --> D["实现 Prisma Client 查询"]
  D --> E["补 seed 与测试"]
  E --> F["按 checklist 决定是否上线"]

这里的重点是“先规则,后代码”。不要从 schema.prisma 开始,而要先写一份需求说明。比如:谁可以发布文章、删除用户时文章怎么办、评论是否要保留、列表页需要哪些筛选条件、是否需要审计日志。Claude Code 只有拿到这些约束,才能生成接近真实项目的 schema。

交给 Claude Code 的提示词

请为一个 TypeScript 博客 API 设计 Prisma ORM 数据层。

前提:
- 本地先用 SQLite 验证,之后可能迁移到 PostgreSQL
- 需要 User, Post, Category, Comment, Notification, AuditLog
- Post 有 DRAFT, PUBLISHED, ARCHIVED 三种状态
- email 和 slug 必须唯一
- 列表页需要按 status, publishedAt, author, category 查询
- 删除 Post 时可以删除 Comment 和中间表记录
- 删除 User 时不要自动删除文章,请限制删除并让业务层先处理

请输出:
1. prisma/schema.prisma
2. 迁移 SQL 的审查重点
3. Prisma Client 的 create/list/publish 示例
4. seed 与 test 命令
5. 上线前 review checklist

这个提示词故意写得很细。比如“删除 User 时不要自动删除文章”会影响 onDelete 的选择;“列表页需要按 status, publishedAt, author, category 查询”会影响索引;“之后可能迁移到 PostgreSQL”会提醒你不要依赖 SQLite 独有的行为。Claude Code 生成后,不要直接复制上线,而是逐条让它解释为什么这样设计。

最小项目结构

{
  "type": "module",
  "scripts": {
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:deploy": "prisma migrate deploy",
    "db:seed": "prisma db seed",
    "dev": "tsx src/demo.ts",
    "test": "vitest run"
  },
  "dependencies": {
    "@prisma/client": "latest",
    "dotenv": "latest"
  },
  "devDependencies": {
    "prisma": "latest",
    "tsx": "latest",
    "typescript": "latest",
    "vitest": "latest"
  }
}
npm install
echo 'DATABASE_URL="file:./dev.db"' > .env
mkdir prisma src

在 Prisma v7 系列中,使用 prisma.config.ts 管理 schema、migration 路径和数据库连接更加清晰。把这些设置写成代码后,CI、本地和 staging 环境到底使用哪一套 schema 与 migration,就不会只藏在口头约定里。

// 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",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

schema.prisma 的实现例

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String
  role          String         @default("editor")
  posts         Post[]
  comments      Comment[]
  notifications Notification[]
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt

  @@index([role])
}

model Post {
  id          String              @id @default(cuid())
  slug        String              @unique
  title       String
  body        String
  status      String              @default("DRAFT")
  authorId    String
  author      User                @relation(fields: [authorId], references: [id], onDelete: Restrict)
  categories  CategoriesOnPosts[]
  comments    Comment[]
  publishedAt DateTime?
  createdAt   DateTime            @default(now())
  updatedAt   DateTime            @updatedAt

  @@index([authorId])
  @@index([status, publishedAt])
}

model Category {
  id    String              @id @default(cuid())
  slug  String              @unique
  name  String
  posts CategoriesOnPosts[]
}

model CategoriesOnPosts {
  postId     String
  categoryId String
  assignedAt DateTime @default(now())
  post       Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  category   Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)

  @@id([postId, categoryId])
}

model Comment {
  id        String   @id @default(cuid())
  body      String
  postId    String
  authorId  String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Restrict)
  createdAt DateTime @default(now())

  @@index([postId, createdAt])
  @@index([authorId])
}

model Notification {
  id        String    @id @default(cuid())
  userId    String
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  type      String
  message   String
  readAt    DateTime?
  createdAt DateTime  @default(now())

  @@index([userId, readAt])
}

model AuditLog {
  id        String   @id @default(cuid())
  action    String
  targetId  String
  metadata  String?
  createdAt DateTime @default(now())

  @@index([action, createdAt])
}

针对这个 schema,建议一定让 Claude Code 回答三个问题。第一,删除时的行为是否符合业务规则。删除 Post 时删除 Comment 可以接受,但删除 User 时连文章一起删除,就很容易变成运营事故。第二,列表页的 where 和 orderBy 是否都有对应索引。第三,status 应该继续用字符串,还是改成 enum。最初用字符串也能运行,但如果状态集合固定,enum 通常更安全。

migration 必须由人来读

npx prisma format
npx prisma migrate dev --create-only --name init_blog
# 在 PR 中阅读 prisma/migrations/*/migration.sql
npx prisma migrate dev
npx prisma generate

生产环境通常不要使用开发用的 migrate dev,而是用下面的命令应用已经提交的迁移。

npx prisma migrate deploy

让 Claude Code 生成 migration 后,重点检查 DROP TABLE、DROP COLUMN、给已有数据列追加 NOT NULL、意外的 unique 约束,以及范围过大的 cascade delete。对已经上线的服务来说,“本地能跑”并不等于安全。最好在 staging 的数据库副本中验证,至少也要用填充过 seed 的验证数据库复现失败场景。

rollback 指南也要写进 PR。已经成功应用的 migration,通常应通过新的 forward migration 反向修改;只有失败中的 migration 才按 Prisma 的 down migration workflow 处理:确认备份,运行 migrate status,必要时执行审查过的 down SQL,然后用 migrate resolve 标记 rolled back。

npx prisma migrate status
npx prisma migrate resolve --rolled-back "20260603090000_failed_change"

Prisma Client 的使用方式

// src/db.ts
import { PrismaClient } from "./generated/prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ["warn", "error"],
  });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
// src/posts.ts
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";

export async function listPublishedPosts(params: {
  page?: number;
  perPage?: number;
  categorySlug?: string;
  search?: string;
}) {
  const page = Math.max(params.page ?? 1, 1);
  const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);

  const where: Prisma.PostWhereInput = {
    status: "PUBLISHED",
    ...(params.categorySlug
      ? { categories: { some: { category: { slug: params.categorySlug } } } }
      : {}),
    ...(params.search
      ? { OR: [{ title: { contains: params.search } }, { body: { contains: params.search } }] }
      : {}),
  };

  const [items, total] = await prisma.$transaction([
    prisma.post.findMany({
      where,
      skip: (page - 1) * perPage,
      take: perPage,
      orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
      select: {
        id: true,
        slug: true,
        title: true,
        publishedAt: true,
        author: { select: { name: true } },
        _count: { select: { comments: true } },
      },
    }),
    prisma.post.count({ where }),
  ]);

  return { items, pagination: { page, perPage, total } };
}

列表数据和总数必须基于同一组条件读取,所以这里用 $transaction([]) 放在一起。include: true 会把关联数据全部带回,开发时很方便,但 API 响应会迅速变大。面向用户的接口里,通常用 select 只返回需要的字段更稳。

// src/publish.ts
import { prisma } from "./db";

export async function publishPost(postId: string) {
  return prisma.$transaction(async (tx) => {
    const post = await tx.post.findUnique({
      where: { id: postId },
      select: { id: true, title: true, status: true, authorId: true },
    });

    if (!post) throw new Error("Post not found");
    if (post.status === "PUBLISHED") return post;

    const published = await tx.post.update({
      where: { id: post.id },
      data: { status: "PUBLISHED", publishedAt: new Date() },
      select: { id: true, title: true, status: true, publishedAt: true },
    });

    await tx.notification.create({
      data: {
        userId: post.authorId,
        type: "POST_PUBLISHED",
        message: `${post.title} was published`,
      },
    });

    await tx.auditLog.create({
      data: {
        action: "POST_PUBLISHED",
        targetId: post.id,
        metadata: JSON.stringify({ title: post.title }),
      },
    });

    return published;
  });
}

发布处理需要把文章更新、通知创建和审计日志创建一起成功或一起失败,所以这里使用 interactive transaction。注意不要在 transaction 内调用外部 API,也不要放入耗时任务。锁持有时间越长,并发上来后越容易出现难以解释的延迟。

安全使用 SQL

优先使用 Prisma Client。只有 Prisma Client 很难表达的报表查询才考虑 raw SQL。这里最大的 pitfall 是字符串拼接,它会带来 SQL-injection risk。官方 raw query 文档建议使用 tagged template 或 Prisma.sql 让变量参数化。

import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";

export async function topAuthors(limit = 10) {
  return prisma.$queryRaw<
    { authorId: string; postCount: bigint }[]
  >(Prisma.sql`
    SELECT "authorId", COUNT(*) AS "postCount"
    FROM "Post"
    WHERE "status" = ${"PUBLISHED"}
    GROUP BY "authorId"
    ORDER BY "postCount" DESC
    LIMIT ${Math.min(limit, 50)}
  `);
}

如果 Claude Code 提出 $queryRawUnsafe,要求它说明不能使用安全写法的理由。动态表名和列名不能直接来自用户输入,应改成人工审查过的 allowlist。

常见使用场景

第一个场景是带管理后台的内容网站。文章、分类、评论、发布状态和审计日志都很适合用 Prisma 建模。可以把每个页面的搜索条件交给 Claude Code,让它提出需要的 @@index。

第二个场景是 B2B SaaS 的多租户数据管理。主要表都带上 tenantId,再让 Claude Code 检查“所有 query 是否都包含 tenant 条件”,可以帮助发现权限遗漏。不过最终确认必须由人完成,API 测试也要验证不能读取其他租户的数据。

第三个场景是订单、付款、发票这样的状态流转。可以拆出 Order、Payment、Invoice、AuditLog,并把状态更新放进 transaction。让 Claude Code 解释“如果中途失败,会留下什么数据”,往往能发现回滚设计的弱点。

容易踩的坑

最危险的是不读 migration SQL 就应用到生产环境。AI 生成的 schema 表面上可能很漂亮,但对已有数据可能包含破坏性变更。第二个常见问题是滥用 include。把关联数据全部返回在开发中很省事,但列表 API 很容易出现 N+1 或巨大响应。第三个问题是误用 delete cascade。cascade 很方便,但范围一旦太宽,管理员的一次操作可能把评论、通知,甚至审计所需的历史一起删除。

建议每次都把下面的 review prompt 发给 Claude Code。

请 review 这次 Prisma 变更。
- 是否存在破坏性 migration
- 如果生产环境已有数据,是否有会失败的 NOT NULL 或 unique 追加
- 主要 query 是否有必要的 index
- 是否有遗漏 tenantId 或 userId 权限条件的地方
- 是否存在 include 过多或 transaction 过长的问题
- 哪些业务规则还没有被 seed 和 test 覆盖

seed 与测试

// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";

const prisma = new PrismaClient();

async function main() {
  const user = await prisma.user.upsert({
    where: { email: "editor@example.com" },
    update: { name: "Editor" },
    create: { email: "editor@example.com", name: "Editor", role: "admin" },
  });

  await prisma.post.upsert({
    where: { slug: "claude-code-prisma-demo" },
    update: { status: "PUBLISHED", publishedAt: new Date() },
    create: {
      slug: "claude-code-prisma-demo",
      title: "Claude Code Prisma demo",
      body: "Seeded article for local verification.",
      status: "PUBLISHED",
      publishedAt: new Date(),
      authorId: user.id,
    },
  });
}

main().finally(async () => prisma.$disconnect());
npm run db:migrate -- --name init_blog
npm run db:generate
npm run db:seed
npm run test

测试不能只覆盖成功路径。要确认不存在的文章不能发布、已经发布的文章再次发布不会重复创建通知、用户不能读取其他人的草稿、不能直接删除仍有文章的 User。让 Claude Code 写测试时,也要明确告诉它“哪些例子应该失败”。

实际试用后的结论

Masa 在一个小型博客 API 的验证中把 Claude Code 和 Prisma 组合使用时,最初生成的 schema 看起来很整洁,但暴露了 User 删除 cascade、列表 API 过度 include、migration SQL 没有认真审查等问题。把需求先写成文字,并把 migration review、seed、test 设为必做项之后,Claude Code 就不只是代码生成器,而更像一个能帮忙做设计 review 的搭档。个人学习可以从 /products/ 开始,团队想建立数据库 review workflow 和 Claude Code 培训则可以看 /training/。把类型、安全性和运维步骤提前整理清楚,通常比上线后再补救便宜得多。

#Claude Code #Prisma #ORM #database #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。