首页 / Prisma ORM 入门教程 / 扩展实战:软删除、审计日志与共享扩展

Prisma ORM 入门教程

扩展实战:软删除、审计日志与共享扩展

本教程共 54 篇 · 第 36 篇 · 更新于 2026-08-11 · 约 6 分钟阅读

客户端扩展软删除审计日志字段脱敏共享扩展$allOperationsresult 组件

本节目标:把第 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 包。三个要点:

  1. Prisma.defineExtension 定义,配合 $allModels$allOperations 写成与 Schema 无关的通用扩展。
  2. 包名遵循 prisma-extension-<name> 约定,比如 prisma-extension-soft-delete,方便检索。
  3. 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