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.prisma、PrismaModule、PrismaService、/api/v2/posts的 controller/service。 - 在真实代码里吃透:schema-first vs entity-first、db 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 的Initmigration 早就把表建好了,让 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)。没跑 generate,import { 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_c6fb082a3114f35d0cc27c518e0、IDX_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_count、create_time、authorId)。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 PrismaClient 让 PrismaService 本身就是个 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 在 AppModule 里 TypeOrmModule.forRootAsync(...) 建一次连接,每个用到的模块 TypeOrmModule.forFeature([Post]) 声明实体、才能注入 Repository<Post>。Prisma 不分这两步——一个全局 PrismaService 通吃所有 model,因为 prisma.post、prisma.user、prisma.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 } }] }对照 TypeORMandWhere("(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 } })。两个差异:
findUniquevsfindFirst/findOne:findUnique强制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.create 的 data.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/posts 和 curl /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三套风格里来回切。 - 重度依赖类型推导——
findMany的where、include后的返回类型都自动推,前端拿到的类型不用手写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 工作流的学习成本、复杂动态查询的不那么顺手。按团队和项目状态选,别为了「现代」硬切。
这一章的成果
- 接通了 learnhub 的 Prisma:
schema.prisma(db pull内省得来)、PrismaService extends PrismaClient($connect/$disconnect生命周期)、@Global() PrismaModule(全局可注入)。 - 在真实
PostV2Service里吃透 Prisma 查询:include对照leftJoinAndSelect、findUnique的唯一约束类型安全、$transaction批量查询、嵌套 write 的自动事务——和第八章 TypeORM 实现一一对照。 - 理解 URI 版本化让
/api/v1/posts(TypeORM)和/api/v2/posts(Prisma)读写同一份 MySQL、并存不冲突;DB schema 是单一事实源,两套 ORM 只是两个镜头。 - 学会 TypeORM 和 Prisma 的选型判断和真实迁移路径(新接口走新 ORM、老接口留着、分批切、schema 变更权收口一套)。
常见问题
@prisma/client报prisma.post找不到 / 字段类型不识别:没跑prisma generate。npm 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 验明身份。