跳转到主要内容

Nest 使用笔记

第十章:Prisma——schema-first 重写帖子服务,和 TypeORM 共存对照

在 learnhub 已接入 TypeORM 的基础上引入 Prisma:用 db pull 从已有库内省出 schema.prisma、PrismaService 全局注入、/api/v2/posts 和 v1 共用同一个 MySQL。每段代码都在 learnhub 里指到对应文件,最后给 TypeORM 和 Prisma 的诚实选型。

  • Nest
  • Prisma
  • 数据层

第八章用 TypeORM 写了 /api/v1/posts。这一章换一套 ORM——Prisma——把同一个帖子服务再做一遍,挂在 /api/v2/posts。和旧版课程「写个 Note 表跑通 CRUD」不同,这一章直接打开 learnhub 已经在跑的 Prisma 接入:schema 不是手写的,是从 TypeORM 建好的库里 db pull 内省出来的;Prisma 和 TypeORM 共用同一个 MySQL;两套实现靠 URI 版本化并存。在真实代码里讲透三件最容易模糊的事:schema-first 和 entity-first 到底差在哪、PrismaService 怎么做成全局可注入、Prisma 的 include/$transaction 对照 TypeORM 的 leftJoinAndSelect/getManyAndCount。最后给一个老生常谈的诚实答案:什么时候真该从 TypeORM 切到 Prisma。

先搞懂:Prisma 是什么 / 为什么需要 / 企业级怎么用

Prisma 是什么:新一代 ORM,思路和 TypeORM 完全不同——TypeORM 是「写一个 TS 类 + 一堆装饰器描述表」(entity-first),Prisma 是「写一份 schema.prisma 文件描述模型,CLI 按它自动生成类型安全的 Client」(schema-first)。生成的 prisma.post.findMany(...) 带完整自动补全,字段拼错编译期就红。它把 ORM、schema 管理、Client 生成、迁移工具打包成一套工具链,不是单个库。

为什么需要它:TypeORM 的痛点很真实——装饰器一多、Repository / QueryBuilder / EntityManager 三套 API 风格混用、类型推导弱(泛型要手填、关联类型经常 any)。Prisma 一次性解决:schema 是单一真相、Client 类型按 schema 自动生成、查询 API 统一(prisma.post.xxx 一套到底)。前提是你接受 schema-first 这套工作流——schema 文件、generate 步骤、migrate 命令都得学,不是装个包就完事。

企业级怎么用(每一条 learnhub 都真做了,下面分步落地):

  • schema 来源有两条路:全新项目prisma migrate dev 推进式建库;已有库prisma db pull 内省式反推 schema。learnhub 走第二条——库早就被 TypeORM 的 migration 建好了,让 Prisma 反推比手写 schema 准。
  • Prisma 接进 Nest 的标准模式:PrismaService extends PrismaClient + onModuleInit.$connect + onModuleDestroy.$disconnect,包在一个 @Global()PrismaModule 里——下面第三步落地。
  • 一个数据库正常只该一套 ORM 管 schema。learnhub 让 TypeORM 和 Prisma 共存是为了教学对照(同一份业务、两套实现 curl 对比),生产上别这么干——下面第五步讲清楚为什么。

这一章你会做出什么

  • 打开 learnhub 真实的 Prisma 接入:prisma/schema.prismaPrismaModulePrismaService/api/v2/posts 的 controller/service。
  • 在真实代码里吃透:schema-first vs entity-firstdb pull 内省 vs migrate 迁移PrismaService 的全局注入include 对照 leftJoinAndSelect$transaction 批量查询
  • 理解 URI 版本化怎么让 /api/v1/posts(TypeORM)和 /api/v2/posts(Prisma)读写同一份 MySQL 表、并存不冲突。
  • 学会一个判断:什么场景该选 Prisma、什么场景该留 TypeORM、什么时候真该迁移

前置:第八章的 TypeORM 帖子服务跑通了(TypeORM 把库建好、灌了种子),learnhub 代码拉到本地能打开看。

第一步:装 Prisma——init / db pull / migrate 三条接入路径

Prisma 的装包分两个:prisma(CLI 工具,dev 依赖)和 @prisma/client(运行时 Client,生产依赖)。learnhub 的 package.json 里看得很清楚:

// learnhub/package.json
"dependencies": {
  "@prisma/client": "^5.22.0",   // 运行时 Client
  ...
},
"devDependencies": {
  "prisma": "^5.22.0",            // CLI 工具
  ...
}

装完之后,怎么得到第一份 schema.prisma 有三条路,对应三种项目状态:

  • prisma init:从零开始。生成空的 schema.prisma + .env(带 DATABASE_URL),自己手写 model。适合全新项目。
  • prisma db pull:已有数据库,让 Prisma 反推 schema。learnhub 走这条——TypeORM 的 Init migration 早就把表建好了,让 Prisma 连上去 introspect,得到一份和库严格一致的 schema。
  • prisma migrate dev:schema-first 推进。你先改 schema,Prisma 生成迁移 SQL 落库。适合「Prisma 是唯一 ORM」的新项目。

learnhub 选 db pull 是有原因的:库的「单一事实源」是 TypeORM 的 migration(第八章第六步那套),让 Prisma 去读这份库、反推 schema,不会出现「TypeORM 建的库和 Prisma 的 schema 各说各话」。看 package.json 把命令封好了:

// learnhub/package.json
"scripts": {
  "prisma:generate": "prisma generate --schema prisma/schema.prisma",
  "prisma:pull": "prisma db pull --schema prisma/schema.prisma",
  "postinstall": "prisma generate --schema prisma/schema.prisma",
  ...
}

postinstall: prisma generate 这条是关键,新手很容易漏。@prisma/client 装下来是个空壳——真正的查询 API、字段类型、模型关系是按你的 schema.prisma 现场生成的(生成到 node_modules/.prisma/client)。没跑 generateimport { PrismaClient } from '@prisma/client' 拿到的东西没有 prisma.post 这个属性。所以每次 npm install 之后要自动 generate 一次——这就是 postinstall 钩子干的事。CI 拉代码、Docker 构建镜像,都得保证这一步跑过,否则应用启动就报错。

注意prisma db pull覆盖式内省——它读库、然后重写 schema.prisma。如果你之前手改过 schema(比如改了字段名、加了 @map),一 pull 全丢。规则:要么完全不手改 schema(让库当唯一事实源),要么 pull 完立刻 commit 再手改,改完别再 pull;要改字段类型走 migrate 流程。learnhub 的 schema 一看就是 pull 出来的——里面那些 FK_c6fb082a3114f35d0cc27c518e0IDX_240853a0c3353c25fb12434ad3 命名是 TypeORM migration 自动生成的 hash,Prisma 照搬,没人会手写成这样。

第二步:schema.prisma——learnhub 内省出来的真实模型

打开 learnhub 的 schema,挑帖子相关的几个 model 看:

// learnhub/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"   // 生成 TS Client
}

datasource db {
  provider = "mysql"              // learnhub 用 MySQL
  url      = env("DATABASE_URL")  // 连接串走 env,不进 git
}

model post {
  id          Int      @id @default(autoincrement())
  title       String   @db.VarChar(100)
  content     String   @db.Text
  view_count  Int      @default(0)
  like_count  Int      @default(0)
  pinned      Int      @default(0) @db.TinyInt
  create_time DateTime @default(now()) @db.DateTime(6)
  update_time DateTime @default(now()) @db.DateTime(6)
  authorId    Int?
  user              user?              @relation(fields: [authorId], references: [id])
  comment           comment[]
  post_favorite_user post_favorite_user[]
  post_tag          post_tag[]

  @@index([authorId], map: "FK_c6fb082a3114f35d0cc27c518e0")
}

model post_tag {
  postId Int
  tagId  Int
  tag    tag  @relation(fields: [tagId], references: [id])
  post   post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@index([tagId], map: "IDX_346168a19727fca1b1835790a1")
  @@index([postId], map: "IDX_444c1b4f6cd7b632277f557935")
}

model tag {
  id       Int        @id @default(autoincrement())
  name     String     @unique @db.VarChar(30)
  post_tag post_tag[]
}

三件事和 TypeORM entity 对照着看:

字段名直接是数据库列名view_countcreate_timeauthorId)。TypeORM 的 entity 是 TS 驼峰属性 + @Column({ name: 'view_count' }) 显式映射;Prisma 是属性名 = 列名,没有中间层。这是因为 schema 是 db pull 反推的,Prisma 直接抄列名。注意:这会让 v2 接口返回的 JSON 字段也是下划线(view_count),和 v1(TypeORM 返回驼峰 viewCount)不一致——前端同时接两个版本时得适配,或者在 v2 service 里手动 transform。这不是 bug,是 schema 来源决定的。

多对多是显式中间表。TypeORM 里 @ManyToMany(() => Tag) @JoinTable({ name: 'post_tag' }) 把中间表藏起来,你只看到 post.tags: Tag[]。Prisma 这里把 post_tag 显式建模成一个 model——查帖子的标签要 post_tag: { include: { tag: true } } 套两层。啰嗦但更接近 SQL 真相:中间表本来就是一张独立的表。Prisma 也支持隐式多对多(直接 tags Tag[] / posts Post[]),但 db pull 出来的 schema 一定是显式的,因为库里那张中间表是实打实存在的。思考:这是 schema-first 的代价还是收益?看你问谁——写查询时显式中间表多两层嵌套,但调试时你能精确知道会跑几条 JOIN、用哪个外键。

类型注解贴近 SQL@db.VarChar(100)@db.Text@db.TinyInt@db.DateTime(6) 直接对应 MySQL 列类型——比 TypeORM 的 @Column({ type: 'varchar', length: 100 }) 更紧凑、更不容易漂移。注意 update_time 这里 pull 出来是 @default(now()) 而不是 Prisma 的 @updatedAt——因为 TypeORM 那边的 @UpdateDateColumn 是 TypeORM 应用层维护的,DB 层只看到 DEFAULT CURRENT_TIMESTAMP,Prisma 内省不到应用层逻辑。如果你想让 Prisma 也自动维护 updatedAt,得手把 update_time 改成 @updatedAt(但下次 db pull 又会被覆盖)。

第三步:PrismaService + @Global PrismaModule

PrismaClient 是个连接池,要绑到 Nest 的生命周期上——启动时连、关停时断。learnhub 的写法:

// learnhub/src/modules/prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit(): Promise<void> {
    await this.$connect();
  }

  async onModuleDestroy(): Promise<void> {
    await this.$disconnect();
  }
}

extends PrismaClientPrismaService 本身就是个 PrismaClient——this.post.findMany()this.$transaction() 全能调。@Injectable() 让它能进 Nest 的 IoC 容器被注入。两个生命周期 hook:onModuleInit 在模块初始化时 $connect() 探活(连接失败这里就炸,不让应用带着坏连接起来);onModuleDestroy 在应用关闭时 $disconnect() 优雅释放连接池。

注意:不挂 hook 其实也能跑——PrismaClient 是 lazy connect 的,第一次查询才真正建连。但显式 hook 的好处是启动期就把连接问题暴露(密码错、库没起、DNS 没解析),而不是等到第一个用户请求才发现。生产服务该挂。

PrismaService 要全局可用,包一个 @Global() 模块:

// learnhub/src/modules/prisma/prisma.module.ts
@Global()
@Module({
  controllers: [PostV2Controller],
  providers: [PrismaService, PostV2Service],
  exports: [PrismaService],
})
export class PrismaModule {}

@Global() 是关键——它让 PrismaService 不用再被各业务模块 imports 一遍,直接构造函数注入就行。和 TypeORM 的全局连接方式对比:TypeORM 在 AppModuleTypeOrmModule.forRootAsync(...) 建一次连接,每个用到的模块 TypeOrmModule.forFeature([Post]) 声明实体、才能注入 Repository<Post>。Prisma 不分这两步——一个全局 PrismaService 通吃所有 model,因为 prisma.postprisma.userprisma.tag 本来就是同一个 client 的属性,没有「按实体注册」这一说。

思考:架构差异背后是设计取舍。TypeORM「按实体分仓」(每个实体一个 Repository)更接近 Spring/JPA 那套、适合复杂领域分层;Prisma「一个 client 通行」更轻、上手快,但你失去了「按实体隔离注入」的细粒度——任何注入了 PrismaService 的地方都能读写所有表,权限边界要靠代码规范守。

最后在 AppModule.imports 里加上 PrismaModule

// learnhub/src/app.module.ts
imports: [
  // ...ConfigModule、TypeOrmModule.forRootAsync(...) 等已有配置
  // 阶段8:Prisma(全局)+ Post v2
  PrismaModule,
  // ...
]

到这一步 PrismaService 在整个应用里都能注入了。

第四步:Prisma 查询——include / $transaction / 字段名,对照 TypeORM

这是和 TypeORM 差距最大的一节。把 learnhub v2 的 service 三个方法逐个拆,和第八章 v1 的 TypeORM 写法并排看。

findMany:include + $transaction 对照 QueryBuilder + getManyAndCount

// learnhub/src/modules/prisma/post-v2.service.ts
const POST_INCLUDE = {
  user: true,
  post_tag: { include: { tag: true } },
} as const;

async findMany(q: QueryPostDto): Promise<PageResult<any>> {
  const where = q.keyword
    ? { OR: [{ title: { contains: q.keyword } }, { content: { contains: q.keyword } }] }
    : {};
  const [list, total] = await this.prisma.$transaction([
    this.prisma.post.findMany({
      where,
      include: POST_INCLUDE,
      orderBy: { create_time: 'desc' },
      skip: q.skip,
      take: q.take,
    }),
    this.prisma.post.count({ where }),
  ]);
  return PageResult.of(list, total, q.page, q.pageSize);
}

逐项对照第八章 TypeORM 的 findMany

  • 关联:Prisma include: { user: true, post_tag: { include: { tag: true } } } 对照 TypeORM 的 leftJoinAndSelect('p.author', 'author') + leftJoinAndSelect('p.tags', 'tag')。Prisma 的 include声明式的「我要带哪些关联」,不写 JOIN 细节;TypeORM 的 leftJoinAndSelect指令式的「LEFT JOIN 这张表」。功能等价,但 Prisma 不暴露 JOIN 类型——它自己决定是 JOIN 还是子查询。注意:Prisma 的 include 不会 N+1(一次查询带出关联),但嵌套 include(比如再带 post_tag.tag.post_tag)层数一深会生成多条 SQL,需要看日志确认性能。
  • 过滤:Prisma where: { OR: [{ title: { contains: q.keyword } }] } 对照 TypeORM andWhere("(p.title LIKE :kw OR p.content LIKE :kw)", { kw: '%'+kw+'%' })。Prisma 的 contains 默认等价于 LIKE '%kw%'(大小写敏感性看库 collation)。对象字面量 vs SQL 字符串:Prisma 的好处是类型安全(字段名拼错编译期就红),TypeORM 的好处是复杂动态条件用 QueryBuilder 链式 .andWhere().orWhere() 拼起来更顺手——Prisma 拼动态 where 得自己 Object.assign 或 reduce,复杂场景别扭。
  • 分页skip / take 两边名字一样、语义一样。
  • list + count:Prisma 用 $transaction([findMany, count]) 把两条查询包进一个事务;TypeORM 用 getManyAndCount() 一次返回。Prisma 为什么要包事务:保证列表和总数在同一快照取——否则高并发下可能在两条查询之间有新数据写入,列表和 total 对不上。TypeORM 的 getManyAndCount 内部也跑两条 SQL 但没显式包事务,这是个容易被忽略的细节差异。

findOne:findUnique + include 对照 findOne + relations

// learnhub/src/modules/prisma/post-v2.service.ts
async findOne(id: number) {
  const post = await this.prisma.post.findUnique({ where: { id }, include: POST_INCLUDE });
  if (!post) throw new NotFoundException('帖子不存在');
  return post;
}

对照第八章 TypeORM 的 this.postRepo.findOne({ where: { id }, relations: { author: true, tags: true } })。两个差异:

  • findUnique vs findFirst/findOnefindUnique 强制 where唯一索引或主键(这里是 id),传非唯一字段(比如 where: { title: 'xxx' })TS 编译期就报错。这是 Prisma 在类型层做的安全约束——你写不出「按非唯一字段查单条」这种有歧义的查询。TypeORM 的 findOne 没这个约束,传啥都行,运行时取第一条(可能不是你想要的那条)。
  • 查不到抛 404:两边逻辑一样,findUnique 返回 null 就抛 NotFoundException,让全局异常过滤器统一回 404。和 TypeORM 章讲过的「不要返回 null 给前端」一回事。

create:嵌套 write 对照事务 + manager.save

// learnhub/src/modules/prisma/post-v2.service.ts
async create(dto: CreatePostDto, userId: number) {
  return this.prisma.post.create({
    data: {
      title: dto.title,
      content: dto.content,
      authorId: userId,
      post_tag: dto.tagIds?.length
        ? { create: dto.tagIds.map((tagId) => ({ tagId })) }
        : undefined,
    },
    include: POST_INCLUDE,
  });
}

对照第八章 TypeORM 用 dataSource.transaction(async (manager) => { ... manager.save(post) }) 包住「帖子 + 标签」的原子写。Prisma 这里没显式 $transaction——因为 prisma.post.createdata.post_tag.create 是嵌套写,Prisma 默认把单次 client 调用里的嵌套写包进一个事务(官方文档原话:nested writes are wrapped in a transaction automatically)。一条 create 同时落 post 主表和 post_tag 中间表,要么都成功、要么都回滚,不用你写 transaction

注意:这个「自动事务」只对单次 client 调用内的嵌套生效。如果你要「先 post.create、再 user.update 改用户的发帖计数」,这是两次独立调用,得手动 $transaction(async (tx) => { await tx.post.create(...); await tx.user.update(...) }) 用回调形式包——回调形式(不是数组形式)才能让多条语句共用一个 tx 客户端、顺序执行、整体原子。

另外 include: POST_INCLUDE 出现在 create 的返回里——创建完顺手把关联带回来,省一次 find。TypeORM 那边 manager.save(post) 返回的是 post 本体,关联要再查。

第五步:URI 版本化——/api/v1 和 /api/v2 怎么并存

PrismaService 接好了、查询也写了,最后让它和 v1 共存。Nest 的 URI 版本化做这件事:

// learnhub/src/main.ts
app.setGlobalPrefix('api', { exclude: [] });
app.enableVersioning({
  type: VersioningType.URI,
  defaultVersion: '1',   // 不显式声明 version 的 controller 走 v1
});
// learnhub/src/modules/prisma/post-v2.controller.ts
@ApiTags('帖子 v2(Prisma)')
@ApiBearerAuth()
@Controller({ path: 'posts', version: '2' })   // → /api/v2/posts
export class PostV2Controller {
  constructor(private readonly postService: PostV2Service) {}

  @Post()
  @RequirePermission('post:create')
  create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser) {
    return this.postService.create(dto, user.userId);
  }

  @Public()
  @Get()
  list(@Query() q: QueryPostDto): Promise<PageResult<any>> {
    return this.postService.findMany(q);
  }

  @Public()
  @Get(':id')
  detail(@Param('id') id: number) {
    return this.postService.findOne(Number(id));
  }
}
// learnhub/src/modules/post/post.controller.ts (第八章 v1)
@Controller({ path: 'posts', version: '1' })   // → /api/v1/posts
export class PostController { ... }

同一个 path: 'posts',不同 version → 路由不冲突,/api/v1/posts 走 TypeORM、/api/v2/posts 走 Prisma。defaultVersion: '1' 让老 controller 不写 version 也自动归到 v1(learnhub 这里 v1/v2 都显式写了,更清楚)。第一章说过 URI 版本化是 learnhub 启动 main.ts 的 5 件事之一,这里就是它的用武之地。

两套实现读写的是同一份 MySQL 表——v1 和 v2 都打 post / post_tag / user 这几张表。所以你在 v2 发的帖,v1 列表也能看到;反之亦然。这不是复制数据,是同一个库的两个读镜头

learnhub 这么干是为了教学对照——你可以同时 curl /api/v1/postscurl /api/v2/posts,对比 TypeORM 和 Prisma 返回的 JSON 结构差异(驼峰 vs 下划线、tags 直接数组 vs post_tag 套层),直观感受两套 ORM。真实业务里版本化的用途更广:接口要大改又不忍心直接破坏老前端,新开 /api/v2,老版本保留,前端切完了再下掉 v1。从 TypeORM 迁到 Prisma 也是同理——新接口先走 v2 Prisma 跑一段时间、和 v1 对比稳定了,再把 v1 也切过去。

思考:既然 TypeORM 和 Prisma 共用一个库、都能改 post 表,谁负责一致性?答案在 DB schema 本身——表结构、外键、列类型、默认值都是 TypeORM 的 Init migration 建的,无论 TypeORM 还是 Prisma 写入都得遵守这套约束(FK 不对就报错、字段类型不符就报错)。两套 ORM 只是同一个 schema 的两个镜头,单一事实源是 DB schema,不是任何一套 ORM 的 entity 或 prisma 文件。这也是第八章强调「migration 是铁律」的深层原因:schema 变更权必须收口到一个地方。

注意(生产):让两套 ORM 共用一个库写、各自都能改 schema,迟早出事——TypeORM migration 改了表、Prisma 没 db pull,schema 就漂移了;反之亦然。learnhub 是 demo 才这么干。生产上:一个库的 schema 归一套 ORM 管(learnhub 里是 TypeORM migration),另一套要么只读、要么走自己的迁移。教学期对照完了,真要迁移就把一套彻底下掉。

第六步:选型——什么时候真该从 TypeORM 切到 Prisma

老生常谈的问题,给个诚实答案,不无脑推 Prisma。

选 Prisma 的场景

  • 新项目、团队吃 schema-first 工作流、重 DX 和类型安全。
  • 想要查询 API 统一(prisma.post.xxx 一套到底),不想在 Repository / QueryBuilder / EntityManager 三套风格里来回切。
  • 重度依赖类型推导——findManywhereinclude 后的返回类型都自动推,前端拿到的类型不用手写 interface

留 TypeORM 的场景

  • 已深度用 TypeORM entity + decorator 生态(learnhub 就是——一大堆 entity、migration、relation 装饰器都写好了,硬切成本高)。
  • 复杂动态查询多用 QueryBuilder——它的链式 .andWhere().orWhere().orderBy() 在拼动态条件时比 Prisma 的对象字面量顺手(Prisma 拼 where 要自己 reduce,复杂场景别扭,虽然有 $queryRaw 兜底)。
  • 团队习惯 ActiveRecord / Data Mapper 这两种 OOP 风格的 ORM 模式。

真要迁移怎么办:别一刀切。learnhub 的 v2 套路就是真实迁移路径的预演——新功能、新接口走 Prisma(v2),老接口留 TypeORM(v1)跑着不动,等新接口稳定了、前端切完了,再分批把老接口迁过来、下掉 v1。这样任何时刻系统都在跑、回滚成本可控。注意:迁移期间 schema 变更权必须收口——选一套(通常是留的那套)当单一事实源,另一套只读镜头或 db pull 跟随,别两边都改。

Prisma 不是 TypeORM 的升级版,是另一套取舍——DX 和类型安全换的是 schema-first 工作流的学习成本、复杂动态查询的不那么顺手。按团队和项目状态选,别为了「现代」硬切。

这一章的成果

  1. 接通了 learnhub 的 Prisma:schema.prismadb pull 内省得来)、PrismaService extends PrismaClient$connect/$disconnect 生命周期)、@Global() PrismaModule(全局可注入)。
  2. 在真实 PostV2Service 里吃透 Prisma 查询:include 对照 leftJoinAndSelectfindUnique 的唯一约束类型安全、$transaction 批量查询、嵌套 write 的自动事务——和第八章 TypeORM 实现一一对照。
  3. 理解 URI 版本化让 /api/v1/posts(TypeORM)和 /api/v2/posts(Prisma)读写同一份 MySQL、并存不冲突;DB schema 是单一事实源,两套 ORM 只是两个镜头。
  4. 学会 TypeORM 和 Prisma 的选型判断和真实迁移路径(新接口走新 ORM、老接口留着、分批切、schema 变更权收口一套)。

常见问题

  • @prisma/clientprisma.post 找不到 / 字段类型不识别:没跑 prisma generatenpm run prisma:generate 一下;npm install 后没自动跑就检查 postinstall 脚本(learnhub 封了)。CI 和 Docker 构建也得保证这一步跑过。
  • 改了 schema.prisma 没生效:改完要 prisma generate(重新生成 Client 类型);要落库还要 prisma migrate dev(新项目,生成 migration)或 prisma db push(快速同步、不生成 migration 文件、仅开发用)。
  • db pull 把我手改的 schema 覆盖了db pull 是覆盖式内省,手改前先 commit;改完别再 pull,要改字段类型走 migrate 流程。
  • v2 接口返回字段是下划线(view_count),前端不适应:Prisma schema 是 db pull 来的,列名 = 属性名。要驼峰得在 schema 里加 @map 重命名字段(等于重写 schema、要重新 generate,而且下次 db pull 会覆盖);更省事的做法是 v2 service 里 transform 一下,或让前端适配。
  • $transaction([...]):数组形式是「顺序执行多条查询、整体一个事务」,大批量写用回调形式 $transaction(async (tx) => { ... }) 共用一个连接、更快;超长事务会占连接池,注意控制粒度。
  • Prisma + TypeORM 共用一个库 schema 漂移:让一套当单一事实源(learnhub 是 TypeORM migration),另一套 db pull 跟随、不改 schema。两套都改 schema 迟早撞车。

数据层到此真正收尾(TypeORM + 关系 + Prisma)。下一章进入认证——用 JWT 实现登录注册:注册时密码加密存库、登录成功签发 token、后续请求带 token 验明身份。