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 ORMPrisma SchemaTransactionsPrisma MigrateRaw 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 时连文章一起删除,就很容易变成运营事故。第二,列表页的 whereorderBy 是否都有对应索引。第三,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 TABLEDROP 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 测试也要验证不能读取其他租户的数据。

第三个场景是订单、付款、发票这样的状态流转。可以拆出 OrderPaymentInvoiceAuditLog,并把状态更新放进 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,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

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