扩展实战:软删除、审计日志与共享扩展
本教程共 54 篇 · 第 36 篇 · 更新于 2026-08-11 · 约 6 分钟阅读
本节目标:把第 35 章的四种组件用起来,做出软删除、审计日志和可发布的共享扩展。
上一章认识了组件,这一章看真活。三个场景最典型:审计日志、软删除、字段脱敏。最后把扩展打包成 npm 包分享出去。
审计日志:$allOperations 一把抓
审计要回答「谁在什么时候改了什么」。在扩展里集中实现,比在每个业务方法里手动记日志干净得多。$allOperations 能拦下所有模型的所有操作,回调里拿到 { model, operation, args, query }:
const prisma = new PrismaClient({ adapter }).$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
const start = performance.now();
const result = await query(args);
console.log(
`${model}.${operation} 耗时 ${Math.round(performance.now() - start)}ms`,
);
return result;
},
},
},
});
只读操作记耗时,写操作再落审计表。这里有个坑:审计写入本身也会触发 $allOperations,一不留神就无限递归。加一道守卫,跳过 AuditLog 自己的写操作:
async $allOperations({ model, operation, args, query }) {
const result = await query(args);
if (
["create", "update", "delete"].includes(operation) &&
model !== "AuditLog"
) {
await prisma.auditLog.create({
data: { model, operation, args: args as object },
});
}
return result;
},
Note审计表写入建议用未扩展的原始客户端,或者按 model 名跳过,避免递归。需要强一致时,把业务操作和审计写入包进同一个
$transaction,保证「操作成功日志必在」。
软删除:query 钩子全局注入
先解释为什么软删。用户删了帖子,管理员还要能恢复;报表要统计历史数据;法律要求的数据保留期没到,不能真删。所以删不是删,是打标记。硬删留给确定性的清理任务。反过来也有代价:所有查询都得记得过滤,忘一处就漏出已删数据,这正是扩展要解决的。
先在 Schema 里加字段和索引:
model User {
id String @id @default(cuid())
email String @unique
deletedAt DateTime?
@@index([deletedAt])
}
deletedAt 为 null 表示未删除。然后让所有读操作自动加上过滤:
const prisma = new PrismaClient({ adapter }).$extends({
query: {
$allModels: {
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
});
应用代码写 findMany 就自动只看到未删除数据,不用每个查询都手动加条件。删除操作也改写:delete 变成打标记。注意 query 回调只会执行原操作,不能直接透传,要改调同模型的 update:
query: {
user: {
async delete({ args }) {
return prisma.user.update({
where: args.where,
data: { deletedAt: new Date() },
});
},
},
},
想对所有模型生效,把 user 换成 $allModels,update 调用处需要类型断言,因为运行时才知道模型名。恢复就是把标记清掉:update({ where, data: { deletedAt: null } })。管理后台要硬删或查已删数据,用未扩展的原始客户端即可,绕过一切拦截。注意软删不会触发数据库的级联删除,关联数据要自己决定处理方式。
Tip合并 where 时用展开语法
{ ...args.where, deletedAt: null },别覆盖调用方传入的过滤条件。
result 组件:计算字段与脱敏
result 组件给查询结果加字段,两个配置:needs 声明依赖哪些字段,compute 计算值。访问时才计算,不访问不产生开销:
const prisma = new PrismaClient({ adapter }).$extends({
result: {
user: {
fullName: {
needs: { firstName: true, lastName: true },
compute(user) {
return `${user.firstName} ${user.lastName}`;
},
},
},
},
});
const user = await prisma.user.findFirst();
console.log(user.fullName); // "Ada Lovelace"
compute 的参数类型由 needs 自动推导,needs 里没声明的字段在 compute 里访问不到,编译期就拦住。脱敏也用它。password 不出现在结果里,只给一个打码版本:
const prisma = new PrismaClient({ adapter }).$extends({
result: {
user: {
password: {
needs: { password: true },
compute() {
return "******";
},
},
},
},
});
两点注意:needs 只能引用标量字段,关系字段不支持;被 omit 掉的依赖字段仍会从数据库读取,只是不出现在结果里。真要不读库,把自定义字段和依赖一起 omit。
model 与 client 组件:自定义方法
model 组件可以给所有模型加通用方法。下面这个 exists 判断记录是否存在。Prisma.Args<T, "findFirst">["where"] 复用 findFirst 的 where 类型,参数自动补全;Prisma.getExtensionContext(this) 拿到当前模型的运行时上下文,才能调 findFirst:
import { Prisma, PrismaClient } from "./generated/prisma/client";
const prisma = new PrismaClient({ adapter }).$extends({
model: {
$allModels: {
async exists<T>(this: T, where: Prisma.Args<T, "findFirst">["where"]) {
const context = Prisma.getExtensionContext(this);
const result = await (context as any).findFirst({ where });
return result !== null;
},
},
},
});
await prisma.user.exists({ email: "alice@example.com" });
client 组件加顶层方法,适合放统计、辅助函数:
const prisma = new PrismaClient({ adapter }).$extends({
client: {
$log: (s: string) => console.log(s),
},
});
prisma.$log("hello");
打包成共享扩展
通用逻辑可以发布成 npm 包。三个要点:
- 用
Prisma.defineExtension定义,配合$allModels、$allOperations写成与 Schema 无关的通用扩展。 - 包名遵循
prisma-extension-<name>约定,比如 prisma-extension-soft-delete,方便检索。 - peerDependencies 声明
@prisma/client,并在文档里写清扩展依赖哪些 Schema 字段(比如必须有 deletedAt)。
官方提供了 prisma-client-extension-starter 模板,照着初始化即可。安装使用:
npm install prisma-extension-soft-delete
import softDelete from "prisma-extension-soft-delete";
const xprisma = new PrismaClient({ adapter }).$extends(softDelete);
await xprisma.user.delete({ where: { id: 1 } }); // 实际是软删
Note扩展方法只存在于扩展客户端上。
xprisma.user.delete生效,原prisma.user.delete不受影响。
发布前记得测试。单元测试里 mock 掉 query 函数,验证拦截逻辑:传什么参数、改了什么、返回值透不透。集成测试连真实数据库,用事务回滚清理数据,验证软删后查不到、恢复后查得到。
现成的扩展
动手之前先搜一圈。官方维护 @prisma/extension-read-replicas(读取副本读写分离)、@prisma/extension-accelerate(缓存),还有 obfuscated-fields、exists-method、readonly-client 等一批示例扩展仓库,都是很好的学习范本。
社区生态也很丰富:prisma-extension-soft-delete(软删除)、prisma-paginate(游标分页)、prisma-rbac(角色权限)、prisma-extension-supabase-rls(行级安全)、prisma-extension-bark(树形结构)、prisma-gpt(自然语言查询)等。先找现成的,再决定要不要自己造。
参考来源
- Prisma 官方文档:Client extensions query / result / model / client 组件
- Prisma 官方文档:Shared packages & examples
- Prisma 官方文档:Shared Prisma Client extensions
- Mapagam:Implementing Soft Delete Pattern / Implementing Security Best Practices
- HireNodeJS:Prisma ORM for Node.js: The Complete Production Guide 2026