Claude Code 与 Prisma ORM 实战:Schema、迁移、事务和上线前检查
用 Claude Code 设计 Prisma schema、审查迁移、实现 TypeScript 查询和事务,并避开上线前最常见的数据库坑。
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/。把类型、安全性和运维步骤提前整理清楚,通常比上线后再补救便宜得多。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code首次仓库审计清单:第一次编辑前先画清地图
面向初学者的20分钟仓库审计:确认入口、风险区、验证命令和收入CTA路径。
Claude Code 轻量 Harness:新手安全修改代码的最小脚手架
把阅读、修改、验证、公开页面检查和收入 CTA 分开的 Claude Code 新手工作流。
Claude Code Repo Map 初次梳理:在不浪费上下文的情况下读懂旧项目
用 Claude Code 阅读既有仓库的安全第一步:先做 repo map,再选小任务,并把免费 PDF、Gumroad 教材和咨询入口串起来。