普通视图

发现新文章,点击刷新页面。
昨天以前博客友圈

16 NestJS 企业级 RBAC 权限控制体系

作者 木灵鱼儿
2026年8月16日 11:04

前言

权限控制是企业级应用中最容易"看起来做了,实则一捅就破"的模块。常见的伪 RBAC 实现往往是在 Controller 里直接判断 user.role === 'admin',把角色硬编码散落在各处,后期维护极其痛苦。

本文将从架构设计出发,基于 NestJS + Prisma + Redis + CASL 构建一套真正可扩展的 RBAC 体系,覆盖以下核心问题:

  • 纯角色判断为何不够?如何建模"资源:动作"级别的原子权限?
  • NestJS 的守卫管道中,认证与授权如何职能分离?
  • 每次请求都查数据库权限太慢,如何用 Redis 将查询压缩到毫秒级?
  • 用户拥有 article:delete 权限,如何限制只能删自己的文章?

阅读本文需要具备 NestJS 模块化开发经验,熟悉 Prisma 基本用法和 JWT 认证流程。建议先阅读本系列的 《10 NestJS JWT 身份验证完全指南》 和 《12 NestJS 集成 Prisma ORM 完全指南》。


[hide]

一、架构设计与理论基础

1.1 RBAC 模型的演进

基础 RBAC(User ➔ Role)

最简单的实现:给用户打上 admineditorviewer 标签,守卫里判断角色字符串。问题在于角色是粗粒度的——同样是 editor,有人可以发布文章,有人只能保存草稿。随着业务增长,角色数量膨胀(senior_editorjunior_editor……),维护成本失控。

标准 RBAC(User ➔ Role ➔ Permission)

在角色和用户之间引入"权限"这一中间层。权限采用 [资源]:[动作] 的命名规范:

article:create    article:read    article:update    article:delete    article:publish
user:create       user:read       user:update       user:delete
system:config:read  system:config:update

角色是权限的集合,用户通过角色继承权限。这样可以精确控制每个角色的能力边界,新增权限点只需修改角色配置,不用改代码。

RBAC + ABAC 融合(行级数据权限)

标准 RBAC 仍然无法解决"只能操作自己创建的数据"这类行级权限问题。此时需要引入属性访问控制(ABAC)的思想——判断资源的属性(authorId)是否与当前用户匹配。本文第六节使用 CASL 处理这个场景。

1.2 NestJS 授权生命周期

请求在 NestJS 管道中的执行顺序:

HTTP 请求
   ↓
Middleware(如日志、请求 ID)
   ↓
Guards(身份验证 → 权限验证)    ← 授权在这里发生
   ↓
Interceptors(前置)
   ↓
Pipes(参数转换与校验)
   ↓
Route Handler
   ↓
Interceptors(后置)
   ↓
Exception Filters(异常时触发)

守卫(Guard)是授权逻辑的正确位置。本文将守卫拆为两层:

守卫职责
JwtAuthGuard认证:验证 token 有效性,将用户信息挂载到 req.user
PermissionsGuard授权:检查当前用户是否拥有访问该端点所需的权限

两者注册顺序必须保证认证先于授权,详见第四节。

1.3 技术栈

用途
@nestjs/jwtJWT 签发与验证
prismaORM,权限数据持久化
ioredisRedis 客户端,权限缓存
@casl/abilityABAC 策略引擎,处理行级权限

二、数据库建模

2.1 Prisma Schema 设计

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  /// 用户唯一标识,自增主键
  id        Int       @id @default(autoincrement())
  /// 登录邮箱,全局唯一
  email     String    @unique
  /// Argon2id 哈希后的密码,禁止明文存储
  password  String
  /// 记录创建时间,由数据库自动填充
  createdAt DateTime  @default(now())
  /// 记录最后更新时间,由 Prisma 自动维护
  updatedAt DateTime  @updatedAt
  /// 软删除时间戳;非 null 表示该用户已被删除,所有查询必须附加 deletedAt: null 过滤条件
  deletedAt DateTime?

  @@map("users")
}

model Role {
  /// 角色唯一标识,自增主键
  id          Int      @id @default(autoincrement())
  /// 角色英文标识符,如 super_admin、editor、viewer,全局唯一,用于代码逻辑判断
  name        String   @unique
  /// 角色描述,供管理界面展示,可为空
  description String?
  /// 记录创建时间,由数据库自动填充
  createdAt   DateTime @default(now())

  @@map("roles")
}

model Permission {
  /// 权限唯一标识,自增主键
  id          Int      @id @default(autoincrement())
  /// 权限标识符,格式为 [模块]:[资源]:[动作],如 cms:article:publish、system:user:delete,全局唯一
  action      String   @unique
  /// 权限描述,供管理界面展示,可为空
  description String?
  /// 记录创建时间,由数据库自动填充
  createdAt   DateTime @default(now())

  @@map("permissions")
}

model UserRole {
  /// 用户 ID,逻辑上关联 users.id,不设数据库外键约束,由应用层保证一致性
  userId    Int
  /// 角色 ID,逻辑上关联 roles.id,不设数据库外键约束,由应用层保证一致性
  roleId    Int
  /// 角色分配时间,由数据库自动填充
  createdAt DateTime @default(now())

  /// 复合主键,天然防止同一用户重复分配同一角色
  @@id([userId, roleId])
  @@index([userId])
  @@index([roleId])
  @@map("user_roles")
}

model RolePermission {
  /// 角色 ID,逻辑上关联 roles.id,不设数据库外键约束,由应用层保证一致性
  roleId       Int
  /// 权限 ID,逻辑上关联 permissions.id,不设数据库外键约束,由应用层保证一致性
  permissionId Int
  /// 权限分配时间,由数据库自动填充
  createdAt    DateTime @default(now())

  /// 复合主键,天然防止同一角色重复分配同一权限
  @@id([roleId, permissionId])
  @@index([roleId])
  @@map("role_permissions")
}

几个设计要点:

  1. 软删除User 表的 deletedAt 字段在查询时需要配合 where: { deletedAt: null } 过滤,防止已删除用户仍能登录。
  2. 复合主键@@id([userId, roleId]) 比单独的自增 id 加唯一索引更简洁,同时避免重复分配。
  3. 无外键约束:遵循阿里规范,UserRoleRolePermission 中的关联 ID 均为普通整型字段,不设数据库外键。删除用户或角色时,需在应用层手动清理关联记录(见 Service 层事务处理)。

2.2 权限命名规范

推荐使用三段式命名,确保全局唯一且语义清晰:

[模块]:[资源]:[动作]

示例:

权限字符串说明
cms:article:createCMS 模块,创建文章
cms:article:publishCMS 模块,发布文章
system:user:delete系统模块,删除用户
system:role:assign系统模块,分配角色
*:*:*超级管理员通配符

在 TypeScript 中强类型化:

// src/common/types/permission.type.ts

// 从字符串字面量构造权限类型,IDE 可以提供自动补全
export const PERMISSIONS = {
    CMS_ARTICLE_CREATE: "cms:article:create",
    CMS_ARTICLE_READ: "cms:article:read",
    CMS_ARTICLE_UPDATE: "cms:article:update",
    CMS_ARTICLE_DELETE: "cms:article:delete",
    CMS_ARTICLE_PUBLISH: "cms:article:publish",
    SYSTEM_USER_CREATE: "system:user:create",
    SYSTEM_USER_READ: "system:user:read",
    SYSTEM_USER_UPDATE: "system:user:update",
    SYSTEM_USER_DELETE: "system:user:delete",
    SYSTEM_ROLE_ASSIGN: "system:role:assign",
    WILDCARD: "*:*:*",
} as const;

export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];

2.3 Seed 脚本

执行迁移后,通过 seed 脚本初始化系统预置数据:

// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma";
import { hash } from "@node-rs/argon2";

const prisma = new PrismaClient();

async function main() {
    // 1. 创建原子权限
    const permissions = await Promise.all([
        prisma.permission.upsert({
            where: { action: "cms:article:create" },
            update: {},
            create: { action: "cms:article:create", description: "创建文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:read" },
            update: {},
            create: { action: "cms:article:read", description: "查看文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:update" },
            update: {},
            create: { action: "cms:article:update", description: "编辑文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:delete" },
            update: {},
            create: { action: "cms:article:delete", description: "删除文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:publish" },
            update: {},
            create: { action: "cms:article:publish", description: "发布文章" },
        }),
        prisma.permission.upsert({
            where: { action: "system:user:delete" },
            update: {},
            create: { action: "system:user:delete", description: "删除用户" },
        }),
        prisma.permission.upsert({
            where: { action: "system:role:assign" },
            update: {},
            create: { action: "system:role:assign", description: "分配角色" },
        }),
        prisma.permission.upsert({
            where: { action: "*:*:*" },
            update: {},
            create: { action: "*:*:*", description: "超级管理员" },
        }),
    ]);

    const permMap = Object.fromEntries(permissions.map((p) => [p.action, p]));

    // 2. 创建角色并分配权限
    const superAdminRole = await prisma.role.upsert({
        where: { name: "super_admin" },
        update: {},
        create: { name: "super_admin", description: "超级管理员,拥有所有权限" },
    });

    const editorRole = await prisma.role.upsert({
        where: { name: "editor" },
        update: {},
        create: { name: "editor", description: "内容编辑,可管理文章" },
    });

    // 为 super_admin 分配通配符权限
    await prisma.rolePermission.upsert({
        where: {
            roleId_permissionId: { roleId: superAdminRole.id, permissionId: permMap["*:*:*"].id },
        },
        update: {},
        create: { roleId: superAdminRole.id, permissionId: permMap["*:*:*"].id },
    });

    // 为 editor 分配文章相关权限(不含删除)
    for (const action of [
        "cms:article:create",
        "cms:article:read",
        "cms:article:update",
        "cms:article:publish",
    ]) {
        await prisma.rolePermission.upsert({
            where: { roleId_permissionId: { roleId: editorRole.id, permissionId: permMap[action].id } },
            update: {},
            create: { roleId: editorRole.id, permissionId: permMap[action].id },
        });
    }

    // 3. 创建超级管理员账号
    const hashedPassword = await hash("Admin@123456");
    const superAdmin = await prisma.user.upsert({
        where: { email: "admin@example.com" },
        update: {},
        create: { email: "admin@example.com", password: hashedPassword },
    });

    await prisma.userRole.upsert({
        where: { userId_roleId: { userId: superAdmin.id, roleId: superAdminRole.id } },
        update: {},
        create: { userId: superAdmin.id, roleId: superAdminRole.id },
    });

    console.log("Seed 完成");
}

main()
    .catch(console.error)
    .finally(() => prisma.$disconnect());

Prisma v7 通过 prisma.config.ts 统一管理配置,seed 命令不再需要写在 package.json 中。在项目根目录创建 prisma.config.ts

// 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"),
    },
});

执行:

pnpm prisma db seed

三、认证前置与上下文传递

3.1 JWT Payload 设计

权限数据不应存入 JWT。原因有两点:

  1. Token 体积:一个用户可能拥有数十条权限,全部写入 payload 会使 token 体积膨胀数倍,每次请求都在 Header 中传输。
  2. 权限实时性:JWT 签发后在有效期内不可更改。若管理员在 token 有效期内撤销了某用户的角色,token 中的权限信息仍然有效,存在安全漏洞。

正确做法是 payload 只携带最小必要字段,每次请求到守卫时动态查询(或命中 Redis 缓存):

// src/auth/types/jwt-payload.type.ts

export interface JwtPayload {
    sub: number; // 用户 ID(标准字段)
    email: string; // 少量辅助信息,方便日志
    iat?: number; // 签发时间(自动注入)
    exp?: number; // 过期时间(自动注入)
}

3.2 自定义装饰器封装

@CurrentUser() 参数装饰器:从 req.user 安全提取当前用户,避免在每个 Handler 里重复写 @Req() req

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { Request } from "express";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

export const CurrentUser = createParamDecorator(
    (_data: unknown, ctx: ExecutionContext): JwtPayload => {
        const request = ctx.switchToHttp().getRequest<Request>();
        return request.user as JwtPayload;
    },
);

@Public() 元数据装饰器:标记无需鉴权的路由(登录、注册、公开接口):

// src/common/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

JwtAuthGuard 整合 @Public()

// src/common/guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(
        private readonly jwtService: JwtService,
        private readonly reflector: Reflector,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        // 检查路由是否标记为公开
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);
        if (isPublic) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const token = this.extractTokenFromHeader(request);

        if (!token) throw new UnauthorizedException("缺少认证 Token");

        try {
            const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
            // 挂载到 request,供后续守卫和装饰器使用
            request["user"] = payload;
        } catch {
            throw new UnauthorizedException("Token 无效或已过期");
        }

        return true;
    }

    private extractTokenFromHeader(request: Request): string | null {
        const [type, token] = request.headers.authorization?.split(" ") ?? [];
        return type === "Bearer" ? token : null;
    }
}

四、核心实现:声明式权限守卫

4.1 权限元数据装饰器

// src/common/decorators/require-permissions.decorator.ts
import { Reflector } from "@nestjs/core";
import { Permission } from "../types/permission.type";

export interface PermissionOptions {
    permissions: Permission[];
    // ALL = 需要满足所有权限;ANY = 满足其中一个即可(默认)
    mode?: "ALL" | "ANY";
}

export const RequirePermissions = Reflector.createDecorator<PermissionOptions>();

使用示例:

// 需要同时拥有 create 和 publish 权限
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_CREATE, PERMISSIONS.CMS_ARTICLE_PUBLISH], mode: 'ALL' })
@Post('publish')
publishArticle() {}

// 拥有 read 或 wildcard 其中一个即可
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_READ] })
@Get()
listArticles() {}

4.2 权限查询服务

将数据库查询封装到独立服务,便于 Guard 调用和测试:

// src/auth/permission.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";

@Injectable()
export class PermissionService {
    constructor(private readonly prisma: PrismaService) {}

    // 分三步独立查询,避免嵌套联表
    async getUserPermissions(userId: number): Promise<Set<string>> {
        // 第一步:查出用户拥有的所有角色 ID
        const userRoles = await this.prisma.userRole.findMany({
            where: { userId },
            select: { roleId: true },
        });
        const roleIds = userRoles.map((ur) => ur.roleId);
        if (roleIds.length === 0) return new Set();

        // 第二步:查出这些角色关联的所有权限 ID
        const rolePermissions = await this.prisma.rolePermission.findMany({
            where: { roleId: { in: roleIds } },
            select: { permissionId: true },
        });
        const permissionIds = rolePermissions.map((rp) => rp.permissionId);
        if (permissionIds.length === 0) return new Set();

        // 第三步:查出权限的 action 字符串
        const permissions = await this.prisma.permission.findMany({
            where: { id: { in: permissionIds } },
            select: { action: true },
        });

        return new Set(permissions.map((p) => p.action));
    }
}

4.3 核心 PermissionsGuard 实现

// src/common/guards/permissions.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { PermissionOptions, RequirePermissions } from "../decorators/require-permissions.decorator";
import { PermissionService } from "../../auth/permission.service";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
import { PERMISSIONS } from "../types/permission.type";

@Injectable()
export class PermissionsGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly permissionService: PermissionService,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        // 合并 Class 级别和 Handler 级别的元数据,Handler 优先
        const options = this.reflector.getAllAndOverride<PermissionOptions>(RequirePermissions, [
            context.getHandler(),
            context.getClass(),
        ]);

        // 未声明权限要求,直接放行
        if (!options) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const user = request["user"] as JwtPayload;

        // JwtAuthGuard 应在此之前执行,正常不会走到这里
        if (!user) throw new ForbiddenException("无法识别当前用户");

        const userPermissions = await this.permissionService.getUserPermissions(user.sub);

        // 超级管理员通配符快速放行
        if (userPermissions.has(PERMISSIONS.WILDCARD)) return true;

        const { permissions, mode = "ANY" } = options;

        const hasPermission =
            mode === "ALL"
                ? permissions.every((p) => userPermissions.has(p))
                : permissions.some((p) => userPermissions.has(p));

        if (!hasPermission) {
            throw new ForbiddenException("权限不足");
        }

        return true;
    }
}

4.4 全局注册

将两个守卫通过 APP_GUARD 注册为全局守卫,注意顺序:JwtAuthGuard 必须在 PermissionsGuard 之前,因为 PermissionsGuard 依赖 req.user

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { PermissionsGuard } from "./common/guards/permissions.guard";

@Module({
    providers: [
        {
            provide: APP_GUARD,
            useClass: JwtAuthGuard, // 第一个执行
        },
        {
            provide: APP_GUARD,
            useClass: PermissionsGuard, // 第二个执行
        },
    ],
})
export class AppModule {}

确保 PermissionService 所在模块已在 AppModule 中导入,或将其放在 AuthModule 并 export:

// src/auth/auth.module.ts(片段)
@Module({
    providers: [AuthService, PermissionService],
    exports: [AuthService, PermissionService, JwtModule],
})
export class AuthModule {}

Controller 中的实际用法:

// src/cms/article.controller.ts
import { Controller, Get, Post, Delete, Param, Body } from "@nestjs/common";
import { RequirePermissions } from "../common/decorators/require-permissions.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { PERMISSIONS } from "../common/types/permission.type";
import { JwtPayload } from "../auth/types/jwt-payload.type";

@Controller("articles")
export class ArticleController {
    @Public()
    @Get()
    listPublished() {
        // 公开接口,无需登录
    }

    @RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_CREATE] })
    @Post()
    create(@Body() dto: CreateArticleDto, @CurrentUser() user: JwtPayload) {}

    @RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_DELETE] })
    @Delete(":id")
    remove(@Param("id") id: string, @CurrentUser() user: JwtPayload) {}
}

五、性能优化:Redis 权限缓存

5.1 问题分析

每次 HTTP 请求到达 PermissionsGuard 时,getUserPermissions 都会触发三次独立数据库查询。在并发较高的场景下,这既是数据库压力,也是响应时延的主要来源。

解决方案:用户登录后(或首次权限查询时)将权限集合缓存到 Redis,后续请求直接读 Redis,权限变更时主动清除缓存。

5.2 Redis 客户端模块

// src/redis/redis.module.ts
import { Module, Global } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";

export const REDIS_CLIENT = "REDIS_CLIENT";

@Global()
@Module({
    providers: [
        {
            provide: REDIS_CLIENT,
            inject: [ConfigService],
            useFactory: (config: ConfigService) => {
                return new Redis({
                    host: config.get("REDIS_HOST", "localhost"),
                    port: config.get<number>("REDIS_PORT", 6379),
                    password: config.get("REDIS_PASSWORD"),
                    db: config.get<number>("REDIS_DB", 0),
                });
            },
        },
    ],
    exports: [REDIS_CLIENT],
})
export class RedisModule {}

5.3 缓存键规范与 TTL 策略

// src/auth/permission-cache.service.ts
import { Inject, Injectable } from "@nestjs/common";
import { Redis } from "ioredis";
import { REDIS_CLIENT } from "../redis/redis.module";

const PERM_CACHE_KEY = (userId: number) => `user:perms:${userId}`;
// 权限缓存 TTL 设为 15 分钟,与 access token 有效期对齐
const PERM_CACHE_TTL = 15 * 60;

@Injectable()
export class PermissionCacheService {
    constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}

    async getPermissions(userId: number): Promise<Set<string> | null> {
        const key = PERM_CACHE_KEY(userId);
        const members = await this.redis.smembers(key);
        if (members.length === 0) return null;
        return new Set(members);
    }

    async setPermissions(userId: number, permissions: Set<string>): Promise<void> {
        const key = PERM_CACHE_KEY(userId);
        const pipeline = this.redis.pipeline();
        pipeline.del(key);
        if (permissions.size > 0) {
            pipeline.sadd(key, ...permissions);
            pipeline.expire(key, PERM_CACHE_TTL);
        }
        await pipeline.exec();
    }

    /** 管理员修改角色权限时,批量清除受影响用户的缓存 */
    async invalidateByUserIds(userIds: number[]): Promise<void> {
        if (userIds.length === 0) return;
        const keys = userIds.map(PERM_CACHE_KEY);
        await this.redis.del(...keys);
    }

    async invalidate(userId: number): Promise<void> {
        await this.redis.del(PERM_CACHE_KEY(userId));
    }
}

5.4 改造 PermissionService

// src/auth/permission.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";
import { PermissionCacheService } from "./permission-cache.service";

@Injectable()
export class PermissionService {
    constructor(
        private readonly prisma: PrismaService,
        private readonly cache: PermissionCacheService,
    ) {}

    async getUserPermissions(userId: number): Promise<Set<string>> {
        // 1. 优先命中缓存
        const cached = await this.cache.getPermissions(userId);
        if (cached) return cached;

        // 2. 缓存未命中,分三步独立查询
        const userRoles = await this.prisma.userRole.findMany({
            where: { userId },
            select: { roleId: true },
        });
        const roleIds = userRoles.map((ur) => ur.roleId);

        const permissions = new Set<string>();
        if (roleIds.length > 0) {
            const rolePermissions = await this.prisma.rolePermission.findMany({
                where: { roleId: { in: roleIds } },
                select: { permissionId: true },
            });
            const permissionIds = rolePermissions.map((rp) => rp.permissionId);

            if (permissionIds.length > 0) {
                const permRecords = await this.prisma.permission.findMany({
                    where: { id: { in: permissionIds } },
                    select: { action: true },
                });
                permRecords.forEach((p) => permissions.add(p.action));
            }
        }

        // 3. 回写缓存
        await this.cache.setPermissions(userId, permissions);

        return permissions;
    }
}

5.5 权限变更时的缓存失效

当管理员修改角色的权限时,需要找出所有拥有该角色的用户并清除其缓存:

// src/system/role.service.ts(片段)
async updateRolePermissions(roleId: number, permissionIds: number[]): Promise<void> {
  await this.prisma.$transaction(async (tx) => {
    // 删除旧权限关联
    await tx.rolePermission.deleteMany({ where: { roleId } });
    // 写入新权限关联
    await tx.rolePermission.createMany({
      data: permissionIds.map(permissionId => ({ roleId, permissionId })),
    });
  });

  // 查找所有拥有该角色的用户 ID
  const affectedUserRoles = await this.prisma.userRole.findMany({
    where: { roleId },
    select: { userId: true },
  });
  const userIds = affectedUserRoles.map(ur => ur.userId);

  // 批量清除缓存,下次请求时重新从数据库加载
  await this.permissionCache.invalidateByUserIds(userIds);
}

六、进阶:CASL 策略权限控制

6.1 纯 RBAC 的局限

假设 editor 角色拥有 cms:article:delete 权限,但业务规则是"编辑只能删除自己创建的文章"。纯 RBAC 无法表达这种"属于谁"的条件,需要引入 CASL。

安装:

pnpm add @casl/ability

6.2 定义 Ability 类型

// src/casl/casl.types.ts
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";

// 定义系统中所有可操作的动作
export type Action = "create" | "read" | "update" | "delete" | "publish" | "manage";

// 定义所有受保护的资源类型
export type Subject = "Article" | "User" | "Role" | "all";

export type AppAbility = MongoAbility<[Action, Subject]>;
export type AbilityBuilderType = AbilityBuilder<AppAbility>;

6.3 CaslAbilityFactory

// src/casl/casl-ability.factory.ts
import { Injectable } from "@nestjs/common";
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
import { AppAbility, Action, Subject } from "./casl.types";
import { JwtPayload } from "../auth/types/jwt-payload.type";
import { PermissionService } from "../auth/permission.service";

// 文章实体的简化类型,包含 authorId 供行级检查
export interface ArticleSubject {
    __type: "Article";
    id: number;
    authorId: number;
    [key: string]: unknown;
}

@Injectable()
export class CaslAbilityFactory {
    constructor(private readonly permissionService: PermissionService) {}

    async createForUser(user: JwtPayload): Promise<AppAbility> {
        const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
        const permissions = await this.permissionService.getUserPermissions(user.sub);

        // 超级管理员拥有所有能力
        if (permissions.has("*:*:*")) {
            can("manage", "all");
            return build();
        }

        // 根据权限集合构建 Ability 规则
        if (permissions.has("cms:article:read")) can("read", "Article");
        if (permissions.has("cms:article:create")) can("create", "Article");
        if (permissions.has("cms:article:publish")) can("publish", "Article");

        if (permissions.has("cms:article:update")) {
            // 普通用户只能编辑自己的文章
            can("update", "Article", { authorId: user.sub });
        }

        if (permissions.has("cms:article:delete")) {
            // 普通用户只能删除自己的文章
            can("delete", "Article", { authorId: user.sub });
        }

        return build();
    }
}

6.4 策略守卫与装饰器

策略接口

// src/casl/casl.types.ts(追加)
export interface IPolicyHandler {
    handle(ability: AppAbility): boolean;
}

export type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

@CheckPolicies() 装饰器

// src/common/decorators/check-policies.decorator.ts
import { Reflector } from "@nestjs/core";
import { PolicyHandler } from "../../casl/casl.types";

export const CheckPolicies = Reflector.createDecorator<PolicyHandler[]>();

PoliciesGuard

// src/common/guards/policies.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { CheckPolicies } from "../decorators/check-policies.decorator";
import { CaslAbilityFactory } from "../../casl/casl-ability.factory";
import { AppAbility, PolicyHandler } from "../../casl/casl.types";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

@Injectable()
export class PoliciesGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly caslAbilityFactory: CaslAbilityFactory,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const policyHandlers = this.reflector.getAllAndOverride<PolicyHandler[]>(CheckPolicies, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (!policyHandlers) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const user = request["user"] as JwtPayload;

        const ability = await this.caslAbilityFactory.createForUser(user);

        const allowed = policyHandlers.every((handler) =>
            typeof handler === "function" ? handler(ability) : handler.handle(ability),
        );

        if (!allowed) throw new ForbiddenException("权限不足");

        return true;
    }
}

Controller 中的实际应用

// src/cms/article.controller.ts(行级权限场景)
import { CheckPolicies } from "../common/decorators/check-policies.decorator";
import { AppAbility } from "../casl/casl.types";

@Controller("articles")
export class ArticleController {
    constructor(private readonly articleService: ArticleService) {}

    // 删除文章:守卫先检查 RBAC 层(有无 cms:article:delete 权限),
    // 再通过 CASL 检查行级(是否为作者)
    @RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_DELETE] })
    @CheckPolicies([(ability: AppAbility) => ability.can("delete", "Article")])
    @Delete(":id")
    async remove(@Param("id") id: string, @CurrentUser() user: JwtPayload) {
        // 此处还需在 Service 层查出文章,结合 subject 做最终检查
        return this.articleService.removeIfAllowed(+id, user.sub);
    }
}

Service 层的最终校验

// src/cms/article.service.ts(片段)
async removeIfAllowed(articleId: number, currentUserId: number): Promise<void> {
  const article = await this.prisma.article.findUniqueOrThrow({
    where: { id: articleId },
  });

  // 构造带 __type 标记的 subject 供 CASL 匹配
  const subject = { __type: 'Article' as const, ...article };
  const ability = await this.caslAbilityFactory.createForUser({ sub: currentUserId } as any);

  if (ability.cannot('delete', subject)) {
    throw new ForbiddenException('只能删除自己创建的文章');
  }

  await this.prisma.article.delete({ where: { id: articleId } });
}

七、异常处理与安全审计

7.1 精细化 403 响应

到这里为止,权限判断已经可以工作,但还缺少生产环境必须关注的两件事:

  1. 对外响应必须稳定:前端、网关、客户端 SDK 不能因为不同守卫抛出的异常不同,就收到不同结构的错误对象。
  2. 对内日志必须足够具体:安全团队和后端排查问题时,需要知道是谁、在什么时候、访问了哪个接口、为什么被拒绝。

这两者不能混在一起。对外响应越克制越好,避免暴露内部权限点;对内日志越完整越好,便于审计和追踪。

权限不足时,PermissionsGuardPoliciesGuard 或 Service 层最终校验都会抛出 ForbiddenException。它不应该在守卫内部手动拼响应,而是交给全局异常过滤器统一处理。

如果项目已经按本系列 《14 NestJS 生产级错误过滤方案》 和 《15 NestJS 统一响应体设计(信封模式)》 实现了过滤器链路,那么最终对外响应应保持统一的信封格式:

{
    "code": 40301,
    "message": "权限不足",
    "data": null,
    "requestId": "abc-123"
}

其中 code 可以使用通用的 ErrorCode.FORBIDDEN。如果想区分“登录了但没有权限”和“具备权限点但不满足行级条件”,也可以在第 15 篇定义的错误码枚举中增加更细的权限错误码:

// src/common/exceptions/error-codes.ts
export enum ErrorCode {
    // ...
    FORBIDDEN = 40301,
    PERMISSION_DENIED = 40302,
    RESOURCE_OWNERSHIP_DENIED = 40303,
}

然后在业务代码中抛出带业务码的异常:

// src/common/exceptions/business.exception.ts
throw new BusinessException("权限不足", HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED);

对于普通的 NestJS ForbiddenException("权限不足"),第 15 篇里的 AllExceptionsFilter 会根据 HTTP 状态码生成默认业务码,最终仍然返回统一的 ApiResponseDto.failed() 结构:

// src/common/filters/all-exceptions.filter.ts(关键逻辑)
const responseBody = ApiResponseDto.failed(code ?? this.getDefaultCode(statusCode), message);

if (meta.requestId) responseBody.requestId = meta.requestId;
response.status(statusCode).json(responseBody);

注意不要在响应中暴露“需要 cms:article:delete 权限”“缺少 system:user:delete 权限”之类的具体提示。这类信息应该进入服务端日志,而不是返回给客户端,否则会给攻击者提供枚举权限点和接口能力边界的线索。

推荐策略:

场景对外 message对内日志
未登录访问受保护接口请先登录记录 IP、URL、User-Agent、requestId
登录但缺少接口权限权限不足记录 userId、URL、requiredPermissions、userPermissions
不满足行级权限权限不足记录 userId、resourceType、resourceId、ownerId、action
权限配置异常权限不足记录 routeKey、metadata、缺失的权限配置,并触发告警

7.2 安全审计日志

权限系统的日志不能只依赖普通应用日志。普通日志关注“接口是否报错”,而安全审计关注“是否存在越权尝试、权限探测、异常访问模式”。

审计日志建议覆盖三类事件:

事件触发位置示例
认证失败JwtAuthGuardToken 缺失、过期、伪造
接口权限不足PermissionsGuard没有 cms:article:delete 权限
行级权限不足Service 层或 CASL 最终校验试图删除不属于自己的文章

最简单的做法是在全局拦截器中捕获 ForbiddenException,记录越权访问尝试:

// src/common/interceptors/audit.interceptor.ts
import {
    CallHandler,
    ExecutionContext,
    Injectable,
    NestInterceptor,
    ForbiddenException,
    Logger,
} from "@nestjs/common";
import { Observable, catchError, throwError } from "rxjs";
import { Request } from "express";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

@Injectable()
export class AuditInterceptor implements NestInterceptor {
    private readonly logger = new Logger("AuditLog");

    intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
        const request = context.switchToHttp().getRequest<Request>();
        const user = request["user"] as JwtPayload | undefined;
        const { method, url, ip } = request;
        const requestId = request.headers["x-request-id"] as string | undefined;
        const userAgent = request.headers["user-agent"];

        return next.handle().pipe(
            catchError((err) => {
                if (err instanceof ForbiddenException) {
                    this.logger.warn({
                        event: "UNAUTHORIZED_ACCESS_ATTEMPT",
                        userId: user?.sub ?? "anonymous",
                        method,
                        url,
                        ip,
                        userAgent,
                        requestId,
                        timestamp: new Date().toISOString(),
                    });
                }
                return throwError(() => err);
            }),
        );
    }
}

全局注册:

// src/app.module.ts
import { APP_INTERCEPTOR } from "@nestjs/core";
import { AuditInterceptor } from "./common/interceptors/audit.interceptor";

@Module({
    providers: [{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor }],
})
export class AppModule {}

审计日志样例输出:

{
    "event": "UNAUTHORIZED_ACCESS_ATTEMPT",
    "userId": 42,
    "method": "DELETE",
    "url": "/articles/99",
    "ip": "::1",
    "userAgent": "Mozilla/5.0 ...",
    "requestId": "abc-123",
    "timestamp": "2026-08-16T00:24:10.201Z"
}

这个拦截器能覆盖进入 Controller 之后抛出的 403,但有一个边界要注意:如果全局 Guard 在进入拦截器之前就拒绝了请求,某些场景下拦截器可能拿不到这次异常。因此更稳妥的生产实践是:

  1. 守卫负责抛异常,也可以记录必要的权限上下文,例如 requiredPermissionsmodeuserId
  2. 全局异常过滤器负责统一响应结构,并记录所有 4xx/5xx 的基础请求信息。
  3. 安全审计服务负责沉淀结构化事件,可以写入日志平台、数据库、Kafka 或 SIEM 系统。

如果希望把权限上下文记录得更完整,可以在 PermissionsGuard 中增加审计日志:

// src/common/guards/permissions.guard.ts(片段)
if (!hasPermission) {
    this.logger.warn({
        event: "PERMISSION_DENIED",
        userId: user.sub,
        requiredPermissions: permissions,
        mode,
        path: request.url,
        timestamp: new Date().toISOString(),
    });

    throw new ForbiddenException("权限不足");
}

生产环境还可以进一步把审计日志抽成独立服务,避免每个守卫都直接依赖 Logger

// src/security/audit-log.service.ts
import { Injectable, Logger } from "@nestjs/common";

export interface AuditEvent {
    event: string;
    userId?: number | "anonymous";
    method?: string;
    url?: string;
    requestId?: string;
    metadata?: Record<string, unknown>;
}

@Injectable()
export class AuditLogService {
    private readonly logger = new Logger("AuditLog");

    warn(event: AuditEvent): void {
        this.logger.warn({
            ...event,
            timestamp: new Date().toISOString(),
        });
    }
}

这样 Guard、Service、异常过滤器都可以写入同一套结构化审计日志,后续接入 ELK、Loki、Datadog、Sentry 或安全审计平台时,不需要再改业务代码。

这里是通过拦截器的方式,捕获控制器抛出的 ForbiddenException,记录越权访问尝试。

7.3 权限变更审计

除了记录“谁被拒绝访问”,还必须记录“谁改了权限”。RBAC 系统中真正高风险的操作往往不是访问某个接口,而是修改角色、权限和用户角色关系。

以下操作建议全部进入审计日志:

操作风险
创建、禁用权限点可能改变系统能力边界
修改角色权限可能扩大或收缩一批用户的访问范围
给用户分配角色可能直接授予管理能力
移除用户角色可能影响线上业务操作
清空权限缓存可能导致短时间内权限判断结果变化

以修改角色权限为例,建议同时记录修改前后的权限集合:

// src/system/role.service.ts(片段)
async updateRolePermissions(roleId: number, permissionIds: number[], operatorId: number): Promise<void> {
    const before = await this.prisma.rolePermission.findMany({
        where: { roleId },
        select: { permissionId: true },
    });

    await this.prisma.$transaction(async (tx) => {
        await tx.rolePermission.deleteMany({ where: { roleId } });
        await tx.rolePermission.createMany({
            data: permissionIds.map((permissionId) => ({ roleId, permissionId })),
        });
    });

    const affectedUserRoles = await this.prisma.userRole.findMany({
        where: { roleId },
        select: { userId: true },
    });
    const affectedUserIds = affectedUserRoles.map((item) => item.userId);

    await this.permissionCache.invalidateByUserIds(affectedUserIds);

    this.auditLog.warn({
        event: "ROLE_PERMISSIONS_UPDATED",
        userId: operatorId,
        metadata: {
            roleId,
            before: before.map((item) => item.permissionId),
            after: permissionIds,
        },
    });
}

这里的 operatorId 是当前执行管理操作的管理员 ID,不是被修改权限的用户 ID。审计日志必须能回答三个问题:

谁改的?改了什么?什么时候改的?

如果是多租户系统,还要额外记录 tenantId,否则后期排查跨租户越权问题会非常困难。

7.4 生产环境注意事项

RBAC 的异常和审计设计,最终目标不是“报错好看”,而是让系统在出问题时可追踪、可定位、可止损。落地时建议遵循以下规则:

  1. 对外统一:所有权限失败都返回统一信封结构,例如 { code, message, data, requestId }
  2. 对外克制:不要返回具体缺失的权限点、角色名、策略条件。
  3. 对内详细:日志中记录用户、接口、权限点、资源 ID、请求 ID、IP、User-Agent。
  4. 高危操作留痕:角色授权、权限禁用、用户角色变更必须记录操作人和变更前后内容。
  5. 日志避免敏感数据:不要记录 token、密码、完整手机号、身份证号等敏感字段。
  6. 异常和审计分层:过滤器负责响应结构,守卫和 Service 负责提供权限上下文,审计服务负责统一落盘或上报。

[/hide]

15 NestJS 统一响应体设计(信封模式)

作者 木灵鱼儿
2026年8月16日 08:09

NestJS 统一响应体设计:信封格式与正确 HTTP 状态码

在前后端分离项目中,API 响应格式会同时影响前端开发体验、错误处理和系统可观测性。本文以 NestJS 为例,设计一套“信封格式 + 正确 HTTP 状态码”的统一响应方案,覆盖成功响应、异常响应和 Swagger 文档三个部分。

本文默认读者已经了解 NestJS 的拦截器、异常过滤器,以及上一篇《NestJS 生产级错误过滤方案》中的 Prisma 过滤器链路。


[hide]

一、先确定响应格式

1.1 两种常见方案

业界主要有两种 API 响应风格。

HTTP 语义派(Stripe、GitHub、Google API):

  • 成功请求返回 HTTP 2xx,body 直接是数据;
  • 失败请求返回 HTTP 4xx/5xx,body 是结构化错误信息;
  • 代理、网关、监控系统可以直接根据 HTTP 状态码判断请求是否成功。

统一信封派

  • 成功和失败都返回 HTTP 200;
  • 前端通过 body 中的 code 字段判断业务结果;
  • 前端拦截器实现简单,但基础设施层无法准确感知失败请求。

1.2 本文采用的折中方案

本文保留信封格式,同时使用正确的 HTTP 状态码:

// 成功:HTTP 200
{ "code": 0, "message": "success", "data": { "id": 1 } }

// 失败:HTTP 404
{ "code": 40402, "message": "用户不存在", "data": null }

这样,前端可以统一读取 codemessagedata,Nginx、网关、Prometheus 等基础设施也仍然可以根据 HTTP 状态码进行统计和告警。

需要特别区分两个字段:

  • HTTP 状态码:表示 HTTP 层面的请求结果;
  • code:表示业务层面的结果,成功固定为 0,失败使用数字业务码。

1.3 这套方案需要改造什么

目标实现方式
统一成功响应全局响应拦截器
统一错误响应异常过滤器返回信封格式
统一业务错误码数字枚举集中管理
正确展示泛型响应自定义 Swagger 响应装饰器
避免重复声明公共错误Swagger 文档后处理器

Controller 和 Service 不需要手动调用 success()failed()。正常返回值由拦截器包装,异常则由过滤器处理。


二、目录结构与实现顺序

建议按下面的顺序落地:先定义响应模型,再接入运行时组件,最后补齐 Swagger 描述。

src/
├── main.ts
├── app.module.ts
├── common/
│   ├── dto/
│   │   └── api-response.dto.ts
│   ├── decorators/
│   │   ├── skip-transform.decorator.ts
│   │   ├── api-object-response.decorator.ts
│   │   ├── api-array-response.decorator.ts
│   │   └── api-paginated-response.decorator.ts
│   ├── exceptions/
│   │   ├── business.exception.ts
│   │   └── error-codes.ts
│   ├── filters/
│   │   ├── all-exceptions.filter.ts
│   │   └── prisma-exception.filter.ts
│   ├── interceptors/
│   │   └── transform.interceptor.ts
│   └── swagger/
│       └── inject-global-errors.ts
└── users/
    ├── dto/
    │   └── create-user.dto.ts
    ├── entities/
    │   └── user.entity.ts
    └── users.controller.ts

三、定义统一响应模型

3.1 通用响应 DTO

ApiResponseDto<T> 同时表示成功和失败响应。普通对象和数组可以复用同一个类,区别只在于泛型参数是 T 还是 T[]

// src/common/dto/api-response.dto.ts
import { ApiProperty } from "@nestjs/swagger";

export class ApiResponseDto<T> {
    @ApiProperty({ example: 0, description: "业务状态码,0 表示成功" })
    code: number;

    @ApiProperty({ example: "success" })
    message: string;

    data: T | null;

    readonly requestId?: string; // 可选字段,便于追踪请求

    static success<T>(data: T, message = "success"): ApiResponseDto<T> {
        const response = new ApiResponseDto<T>();
        response.code = 0;
        response.message = message;
        response.data = data ?? null;
        return response;
    }

    static failed(code: number, message: string, requestId?: string): ApiResponseDto<never> {
        const response = new ApiResponseDto<never>();
        response.code = code;
        response.message = message;
        response.data = null;
        if (requestId) response.requestId = requestId;
        return response;
    }
}

这里使用 data ?? null,可以把 undefined 统一转换为 null。例如删除接口没有返回值时,响应仍然保持稳定:

{ "code": 0, "message": "success", "data": null }

3.2 分页响应 DTO

分页响应的 data 不是任意类型,而是包含列表和分页元数据的对象,因此需要单独定义基础 DTO:

// src/common/dto/api-response.dto.ts
export class PaginatedResponseDto<T> {
    @ApiProperty({ example: 0 })
    code: number;

    @ApiProperty({ example: "success" })
    message: string;

    data: {
        items: T[];
        total: number;
        page: number;
        pageSize: number;
        totalPages: number;
    };
}

TypeScript 泛型只在编译期存在。运行时的 ApiResponseDto<UserEntity> 仍然只是 ApiResponseDto,所以后面的 Swagger 装饰器必须手动描述 data 的具体结构。


四、统一管理业务错误码

建议使用数字枚举,并让错误码的前缀与 HTTP 状态码保持一致。规则可以定义为:HTTP 状态码 * 100 + 序号

// src/common/exceptions/error-codes.ts
export enum ErrorCode {
    SUCCESS = 0,

    BAD_REQUEST = 40001,
    VALIDATION_FAILED = 40002,

    UNAUTHORIZED = 40101,
    TOKEN_EXPIRED = 40102,
    TOKEN_INVALID = 40103,
    INVALID_CREDENTIALS = 40104,

    FORBIDDEN = 40301,
    PERMISSION_DENIED = 40302,

    NOT_FOUND = 40401,
    USER_NOT_FOUND = 40402,
    RESOURCE_NOT_FOUND = 40403,

    CONFLICT = 40901,
    USER_ALREADY_EXISTS = 40902,
    RESOURCE_CONFLICT = 40903,

    INTERNAL_ERROR = 50001,
}
错误码范围含义
0成功
40001 ~ 40099通用请求错误
40101 ~ 40199认证错误
40301 ~ 40399权限错误
40401 ~ 40499资源不存在
40901 ~ 40999冲突错误
50001 ~ 50099服务器内部错误

业务错误码应集中维护,避免在 Controller 或 Service 中散落魔法数字。前端也可以根据 code 做国际化映射。

4.1 业务异常基类

业务异常继承 HttpException,因此仍然可以由全局异常过滤器统一处理:

// src/common/exceptions/business.exception.ts
import { HttpException, HttpStatus } from "@nestjs/common";
import { ErrorCode } from "./error-codes";

export class BusinessException extends HttpException {
    constructor(
        message: string,
        statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
        public readonly code: number = ErrorCode.BAD_REQUEST,
    ) {
        super({ message, code }, statusCode);
    }
}

使用时,HTTP 状态码和业务错误码同时指定:

throw new BusinessException("该邮箱已被注册", HttpStatus.CONFLICT, ErrorCode.USER_ALREADY_EXISTS);

五、统一包装成功响应

5.1 跳过包装的装饰器

文件下载、流式响应等内容不是 JSON,不能套用信封格式。用元数据提供显式的排除开关:

// src/common/decorators/skip-transform.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const SKIP_TRANSFORM_KEY = "skipTransform";

export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);

5.2 TransformInterceptor

拦截器只负责包装正常返回值,不处理业务判断,也不捕获异常。异常会进入异常过滤器链路。

// src/common/interceptors/transform.interceptor.ts
import {
    CallHandler,
    ExecutionContext,
    Injectable,
    NestInterceptor,
    StreamableFile,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { ApiResponseDto } from "../dto/api-response.dto";
import { SKIP_TRANSFORM_KEY } from "../decorators/skip-transform.decorator";

@Injectable()
export class TransformInterceptor implements NestInterceptor {
    constructor(private readonly reflector: Reflector) {}

    intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
        const skip = this.reflector.getAllAndOverride<boolean>(SKIP_TRANSFORM_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (skip) return next.handle();

        // NestJS 中 @Post() 默认返回 201,统一覆盖为 200,与信封格式保持一致
        context.switchToHttp().getResponse().status(200);

        return next.handle().pipe(
            map((data) => {
                if (data instanceof StreamableFile) return data;
                return ApiResponseDto.success(data);
            }),
        );
    }
}

文件下载接口使用 @SkipTransform()

@Get("export")
@SkipTransform()
exportFile(): StreamableFile {
    // 返回文件流
}

5.3 注册全局拦截器

推荐在模块中注册,这样 NestJS 可以注入 Reflector 及其他依赖:

// src/app.module.ts
import { APP_INTERCEPTOR } from "@nestjs/core";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";

@Module({
    providers: [
        {
            provide: APP_INTERCEPTOR,
            useClass: TransformInterceptor,
        },
    ],
})
export class AppModule {}

也可以在 main.ts 中手动实例化,但这种方式需要自行传入依赖:

app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));

5.4 边界行为

场景处理方式
返回 undefined转为 data: null
返回 StreamableFile直接透传
文件下载使用 @SkipTransform()
SSE / WebSocket不属于普通 HTTP JSON 响应,按专用协议处理

六、让异常过滤器返回同一种信封

成功响应由拦截器包装,异常响应不会经过 map(),因此必须由异常过滤器直接构造信封。

6.1 AllExceptionsFilter 的处理原则

上一篇文章中的过滤器链路可以保持不变:Prisma 过滤器优先处理 Prisma 异常,非 Prisma 异常通过 throw exception 继续交给 AllExceptionsFilter。本篇只改造过滤器最终返回的 body。

对于 HttpException,需要从 exception.getResponse() 中读取业务异常携带的 code;没有业务码时,根据 HTTP 状态码生成默认业务码。

// src/common/filters/all-exceptions.filter.ts(关键方法)
private handleHttpException(
    exception: HttpException,
    response: Response,
    meta: { path: string; requestId?: string },
) {
    const statusCode = exception.getStatus();
    const exceptionResponse = exception.getResponse();

    let message: string;
    let code: number | undefined;

    if (typeof exceptionResponse === "string") {
        message = exceptionResponse;
    } else if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
        const body = exceptionResponse as Record<string, unknown>;
        message = typeof body.message === "string" ? body.message : exception.message;
        code = typeof body.code === "number" ? body.code : undefined;
    } else {
        message = exception.message;
    }

    const responseBody = ApiResponseDto.failed(
        code ?? this.getDefaultCode(statusCode),
        message,
    );

    if (meta.requestId) responseBody.requestId = meta.requestId;
    response.status(statusCode).json(responseBody);
}

private handleUnknownError(
    exception: unknown,
    response: Response,
    meta: { path: string; requestId?: string },
    isProduction: boolean,
) {
    const message = exception instanceof Error ? exception.message : String(exception);
    const errorMessage = isProduction ? "服务器内部错误,请稍后重试" : message;
    const responseBody = ApiResponseDto.failed(ErrorCode.INTERNAL_ERROR, errorMessage);

    if (meta.requestId) responseBody.requestId = meta.requestId;
    response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(responseBody);
}

private getDefaultCode(statusCode: number): number {
    const map: Record<number, number> = {
        400: ErrorCode.BAD_REQUEST,
        401: ErrorCode.UNAUTHORIZED,
        403: ErrorCode.FORBIDDEN,
        404: ErrorCode.NOT_FOUND,
        409: ErrorCode.CONFLICT,
        500: ErrorCode.INTERNAL_ERROR,
        503: ErrorCode.INTERNAL_ERROR,
    };

    return map[statusCode] ?? ErrorCode.INTERNAL_ERROR;
}

实际项目中仍应在过滤器里记录 path、时间、堆栈和请求 ID。这里省略日志代码,只展示响应结构的关键变化。

6.2 PrismaExceptionFilter 的处理方式

Prisma 过滤器仍然只负责识别和翻译 Prisma 错误,非 Prisma 异常继续抛出。返回响应时改用 ApiResponseDto.failed()

// src/common/filters/prisma-exception.filter.ts(关键分支)
case "P2002": {
    const fields = extractUniqueConstraintFields(exception);
    const label = fields.length > 0 ? fields.join(", ") : "字段";

    return response.status(HttpStatus.CONFLICT).json(
        ApiResponseDto.failed(
            ErrorCode.CONFLICT,
            `${label} 已存在,请使用其他值`,
        ),
    );
}

case "P2025":
    return response.status(HttpStatus.NOT_FOUND).json(
        ApiResponseDto.failed(
            ErrorCode.NOT_FOUND,
            "请求的记录不存在",
        ),
    );

其余 Prisma 错误码按相同规则映射:选择合适的 HTTP 状态码、选择对应的业务错误码、记录详细日志、返回对用户友好的消息。不要把 Prisma 原始错误信息直接放进生产响应。


七、Swagger 正确描述泛型响应

运行时泛型会被擦除,Swagger 无法自动知道 ApiResponseDto<UserEntity>data 是什么类型。因此需要组合使用:

  • ApiExtraModels:把 DTO 和业务模型加入 Swagger 的全局 Schema;
  • getSchemaPath:获取某个模型的 $ref 路径;
  • allOf:复用信封基础结构,并覆盖 data 字段。

7.1 对象响应装饰器

// src/common/decorators/api-object-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { ApiResponseDto } from "../dto/api-response.dto";

export const ApiObjectResponse = <TModel extends Type>(model: TModel, status = 200) =>
    applyDecorators(
        ApiExtraModels(ApiResponseDto, model),
        ApiOkResponse({
            status,
            schema: {
                title: `${model.name}Response`,
                allOf: [
                    { $ref: getSchemaPath(ApiResponseDto) },
                    { properties: { data: { $ref: getSchemaPath(model) } } },
                ],
            },
        }),
    );

7.2 数组响应装饰器

数组不需要单独的响应类,只需把 data 描述成数组:

// src/common/decorators/api-array-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { ApiResponseDto } from "../dto/api-response.dto";

export const ApiArrayResponse = <TModel extends Type>(model: TModel) =>
    applyDecorators(
        ApiExtraModels(ApiResponseDto, model),
        ApiOkResponse({
            schema: {
                title: `${model.name}ArrayResponse`,
                allOf: [
                    { $ref: getSchemaPath(ApiResponseDto) },
                    {
                        properties: {
                            data: {
                                type: "array",
                                items: { $ref: getSchemaPath(model) },
                            },
                        },
                    },
                ],
            },
        }),
    );

7.3 分页响应装饰器

分页 data 需要同时描述 items 和分页字段,因此引用 PaginatedResponseDto

// src/common/decorators/api-paginated-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { PaginatedResponseDto } from "../dto/api-response.dto";

export const ApiPaginatedResponse = <TModel extends Type>(model: TModel) =>
    applyDecorators(
        ApiExtraModels(PaginatedResponseDto, model),
        ApiOkResponse({
            schema: {
                title: `Paginated${model.name}Response`,
                allOf: [
                    { $ref: getSchemaPath(PaginatedResponseDto) },
                    {
                        properties: {
                            data: {
                                type: "object",
                                properties: {
                                    items: {
                                        type: "array",
                                        items: { $ref: getSchemaPath(model) },
                                    },
                                    total: { type: "number", example: 100 },
                                    page: { type: "number", example: 1 },
                                    pageSize: { type: "number", example: 20 },
                                    totalPages: { type: "number", example: 5 },
                                },
                            },
                        },
                    },
                ],
            },
        }),
    );

7.4 Controller 中的使用方式

@Post()
@ApiObjectResponse(UserEntity, 201)
create(@Body() dto: CreateUserDto): Promise<UserEntity> {
    return this.usersService.create(dto);
}

@Get()
@ApiArrayResponse(UserEntity)
findAll(): Promise<UserEntity[]> {
    return this.usersService.findAll();
}

@Get(":id")
@ApiObjectResponse(UserEntity)
findOne(@Param("id", ParseIntPipe) id: number): Promise<UserEntity> {
    return this.usersService.findOne(id);
}

7.5 开启 Swagger CLI 插件

CLI 插件可以从 DTO 类型、JSDoc 注释和 class-validator 装饰器推断字段级 Schema:

{
    "collection": "@nestjs/schematics",
    "sourceRoot": "src",
    "compilerOptions": {
        "plugins": [
            {
                "name": "@nestjs/swagger",
                "options": {
                    "classValidatorShim": true,
                    "introspectComments": true
                }
            }
        ]
    }
}

插件只能补充 DTO 和 Entity 的字段信息,不能推断 Controller 的泛型响应,因此 @ApiObjectResponse@ApiArrayResponse 等装饰器仍然需要保留。

使用 SWC 时,CLI Plugin 的 AST 转换不会由 SWC 自动执行。可以使用 nest start -b swc --type-check,让 SWC 负责编译,同时由 TypeScript 处理类型检查和元数据生成。

7.6 全局注入公共错误响应

401、403、500 等公共错误不必在每个接口上重复声明,可以在生成文档后统一注入:

// src/common/swagger/inject-global-errors.ts
import { OpenAPIObject } from "@nestjs/swagger";

const GLOBAL_ERROR_RESPONSES = {
    "401": {
        description: "未授权,Token 无效或过期",
        content: {
            "application/json": {
                schema: {
                    properties: {
                        code: { type: "number", example: 40101 },
                        message: { type: "string", example: "Token 已过期,请重新登录" },
                        data: { nullable: true, example: null },
                    },
                },
            },
        },
    },
    "403": {
        description: "权限不足",
        content: {
            "application/json": {
                schema: {
                    properties: {
                        code: { type: "number", example: 40301 },
                        message: { type: "string", example: "权限不足,无法执行此操作" },
                        data: { nullable: true, example: null },
                    },
                },
            },
        },
    },
    "500": {
        description: "服务器内部错误",
        content: {
            "application/json": {
                schema: {
                    properties: {
                        code: { type: "number", example: 50001 },
                        message: { type: "string", example: "服务器内部错误,请稍后重试" },
                        data: { nullable: true, example: null },
                    },
                },
            },
        },
    },
} as const;

export function injectGlobalErrors(document: OpenAPIObject): OpenAPIObject {
    for (const pathItem of Object.values(document.paths)) {
        for (const operation of Object.values(pathItem)) {
            if (operation && typeof operation === "object" && "responses" in operation) {
                Object.assign(operation.responses, GLOBAL_ERROR_RESPONSES);
            }
        }
    }

    return document;
}

main.ts 中包裹 SwaggerModule.createDocument()

const documentFactory = () => {
    const document = SwaggerModule.createDocument(app, config);
    return injectGlobalErrors(document);
};

SwaggerModule.setup("docs", app, documentFactory);

业务特有的 404、409 等错误,仍建议在具体接口上单独声明,以便文档准确反映接口行为。


八、完整请求链路

8.1 成功请求

Controller.findOne(1)
    → UsersService.findOne(1)
    → 返回 UserEntity
    → TransformInterceptor.map()
    → HTTP 200 { code: 0, message: "success", data: UserEntity }

8.2 业务异常

UsersService.findOne(999)
    → throw BusinessException(..., 404, ErrorCode.USER_NOT_FOUND)
    → PrismaExceptionFilter 判断不是 Prisma 错误,继续抛出
    → AllExceptionsFilter 读取 code 和 message
    → HTTP 404 { code: 40402, message: "用户不存在", data: null }

8.3 Prisma 异常

prisma.user.create() → P2002
    → PrismaExceptionFilter 将其转换为 HTTP 409
    → 返回 { code: 40901, message: "字段已存在,请使用其他值", data: null }

8.4 无返回值接口

UsersService.remove(1) → undefined
    → TransformInterceptor 将 undefined 转为 null
    → HTTP 200 { code: 0, message: "success", data: null }

[/hide]

14 NestJS 生产级错误过滤方案

作者 木灵鱼儿
2026年8月16日 06:23

前言:为什么需要生产级错误过滤

默认响应的局限

NestJS 内置的异常处理机制已经相当完善,但在生产项目中仍然存在明显不足:

格式不统一:不同层抛出的异常,响应结构各不相同。HttpException 返回 { statusCode, message },未捕获的 Error 返回 500 的通用格式,Prisma 错误则直接泄露为 500 且没有任何业务上下文。

泄露内部细节:默认情况下,数据库错误、堆栈信息、内部路径等敏感内容可能出现在生产环境的响应体中。

可观测性差:没有统一的日志格式,无法关联请求链路,难以在监控系统中定位问题根源。

生产环境的核心诉求

诉求说明
统一格式前端对接时只需处理一种响应结构
安全隐藏内部细节生产环境不暴露数据库错误码、堆栈、文件路径
可观测性每条错误日志可追溯到具体请求
可维护性错误码集中管理,便于国际化和前端对接

本文方案概览

本文构建两层过滤器链路:

请求进入
   ↓
业务逻辑 / ORM 操作
   ↓ 抛出异常
PrismaExceptionFilter     ← 优先处理 Prisma 错误,非 Prisma 错误继续上抛
   ↓ 非 Prisma 错误
AllExceptionsFilter        ← 兜底处理所有其余异常(HttpException、业务异常、未知 Error)
   ↓
统一格式的 JSON 响应

涉及的文件结构:

src/
├── common/
│   ├── filters/
│   │   ├── all-exceptions.filter.ts        # 兜底过滤器
│   │   └── prisma-exception.filter.ts      # Prisma 专用过滤器
│   ├── exceptions/
│   │   ├── business.exception.ts           # 自定义业务异常基类
│   │   └── error-codes.ts                  # 统一错误码枚举
│   └── interfaces/
│       └── error-response.interface.ts     # 统一响应结构接口
└── database/
    └── utils/
        └── prisma-error.util.ts            # Prisma 错误判断工具函数

[hide]

二、统一响应格式设计

2.1 先回答一个问题:错误格式要和成功格式统一吗?

很多项目的成功响应采用"信封"结构:

{ "code": 0, "message": "success", "data": { "id": 1 } }

自然会有人问:错误响应是否也应该套同一层信封?这是一个架构决策,业界存在两种流派。

流派一:统一信封,HTTP 状态码始终 200

// 成功
{ "code": 0, "message": "success", "data": { "id": 1 } }

// 失败(HTTP 200)
{ "code": 40401, "message": "用户不存在", "data": null }

优点:前端只需一个响应拦截器,统一判断 code !== 0 即为错误。国内对接微信小程序的项目偏好这种方式。

缺点:滥用 HTTP 200 会让代理、网关、监控系统(Nginx 访问日志、Prometheus)无法通过状态码区分成功和失败,运维可观测性大幅下降。

流派二:HTTP 语义,成功和失败结构不同(本文方案)

// 成功 HTTP 200,body 直接是数据
{ "id": 1, "email": "user@example.com" }

// 失败 HTTP 4xx/5xx,body 是结构化错误体
{ "statusCode": 404, "error": "Not Found", "message": "用户不存在" }

优点:HTTP 状态码有原生语义,监控告警、负载均衡、CDN 都能直接感知;符合 RESTful 规范,与 Stripe、GitHub、Google API 的设计一致。

折中方案:信封 + 正确的 HTTP 状态码并存

如果团队有强烈的统一信封需求,推荐的做法是两者并存,而不是牺牲 HTTP 语义:

// 失败 HTTP 404(状态码仍然正确)
{ "code": 40401, "message": "用户不存在", "data": null }

// 成功 HTTP 200
{ "code": 0, "message": "success", "data": { "id": 1 } }

这样既满足前端统一解析,又不破坏基础设施层的感知能力。本文过滤器实现中,只需将 response.status(xxx).json(body)body 替换为信封格式,过滤器主体逻辑无需任何改动。

本文结论:采用流派二,不强行统一成功和失败结构。理由是:成功响应 data 字段的形态千变万化,强行套一层信封并不能简化前端逻辑;而错误响应只需保证自身结构一致即可。如果项目已有信封规范,按折中方案适配即可。


2.2 错误响应接口定义

定义整个项目的错误响应接口,所有异常过滤器都应遵循此结构:

// src/common/interfaces/error-response.interface.ts
export interface ErrorResponse {
    statusCode: number; // HTTP 状态码
    error: string; // 错误类型(如 "Not Found")
    message: string; // 对用户友好的提示
    code?: string; // 业务/数据库错误码(仅开发环境)
    requestId?: string; // 请求追踪 ID
    timestamp: string; // ISO 时间戳
    path: string; // 请求路径
}

字段设计意图

  • statusCode + error:与 HTTP 标准对齐,前端可直接用状态码分支处理
  • message:面向用户的提示,不含技术细节
  • code:业务错误码,开发环境辅助调试,生产环境应隐藏
  • requestId:与日志系统关联,方便问题溯源
  • timestamp + path:快速定位问题发生的时间和接口

环境差异

字段开发环境生产环境
code返回(含 Prisma 错误码)隐藏
stack可附加严格隐藏
message较详细友好简洁

三、自定义业务异常

3.1 业务异常基类

NestJS 内置的 HttpException 足以处理 HTTP 层面的错误,但无法携带业务语义(如错误码)。封装一个 BusinessException 基类:

// src/common/exceptions/business.exception.ts
import { HttpException, HttpStatus } from "@nestjs/common";

export class BusinessException extends HttpException {
    constructor(
        message: string,
        statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
        public readonly code?: string,
    ) {
        super({ message, code }, statusCode);
    }
}

继承 HttpException 的好处:AllExceptionsFilter 中可以用 instanceof HttpException 统一捕获,而不需要为 BusinessException 单独分支。

3.2 统一错误码枚举

// src/common/exceptions/error-codes.ts
export enum ErrorCode {
    // 用户相关
    USER_NOT_FOUND = "USER_NOT_FOUND",
    USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
    INVALID_CREDENTIALS = "INVALID_CREDENTIALS",

    // 权限相关
    PERMISSION_DENIED = "PERMISSION_DENIED",
    TOKEN_EXPIRED = "TOKEN_EXPIRED",
    TOKEN_INVALID = "TOKEN_INVALID",

    // 资源相关
    RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND",
    RESOURCE_CONFLICT = "RESOURCE_CONFLICT",
}

枚举集中管理有两个好处:前端可以直接基于 code 字段做国际化映射;后端重构时修改一处即可全局生效。

3.3 使用示例

// 在 Service 中抛出业务异常
import { BusinessException } from "../common/exceptions/business.exception";
import { ErrorCode } from "../common/exceptions/error-codes";

// 直接抛出 HttpException(简单场景,无需错误码)
throw new NotFoundException("用户不存在");

// 抛出业务异常(需要错误码的场景)
throw new BusinessException("该邮箱已被注册", HttpStatus.CONFLICT, ErrorCode.USER_ALREADY_EXISTS);

四、Prisma 错误工具函数

Prisma 没有提供统一的错误基类,各错误类型独立存在。创建工具函数封装这些判断逻辑:

// src/database/utils/prisma-error.util.ts
import { Prisma } from "../../generated/prisma/client";

/** 判断是否为任意 Prisma 错误 */
export function isPrismaError(error: unknown): boolean {
    return (
        error instanceof Prisma.PrismaClientKnownRequestError ||
        error instanceof Prisma.PrismaClientUnknownRequestError ||
        error instanceof Prisma.PrismaClientRustPanicError ||
        error instanceof Prisma.PrismaClientInitializationError ||
        error instanceof Prisma.PrismaClientValidationError
    );
}

/** 判断是否为特定错误码的 Prisma 已知错误 */
export function isPrismaErrorWithCode(
    error: unknown,
    code: string,
): error is Prisma.PrismaClientKnownRequestError {
    return error instanceof Prisma.PrismaClientKnownRequestError && error.code === code;
}

/** 提取唯一约束冲突的字段名列表(P2002) */
export function extractUniqueConstraintFields(
    error: Prisma.PrismaClientKnownRequestError,
): string[] {
    if (error.code === "P2002" && error.meta?.target) {
        return Array.isArray(error.meta.target)
            ? (error.meta.target as string[])
            : [error.meta.target as string];
    }
    return [];
}

/** 获取调试用的详细错误消息(不应出现在生产响应中) */
export function getPrismaErrorMessage(error: unknown): string {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
        return `[${error.code}] ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientValidationError) {
        return `ValidationError: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientInitializationError) {
        return `InitializationError: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientRustPanicError) {
        return `RustPanicError: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientUnknownRequestError) {
        return `UnknownRequestError: ${error.message}`;
    }
    return "Unknown error";
}

将这些判断封装为工具函数而不是散落在过滤器中,好处是:过滤器逻辑更清晰,且工具函数可在 Service 层复用。


五、Prisma 专用异常过滤器

5.1 五种错误类型处理策略

错误类型HTTP 状态码处理要点
PrismaClientKnownRequestError按错误码映射覆盖 P2002/P2003/P2025/P2014/P2011/P2024/P2034 等
PrismaClientValidationError400隐藏内部验证细节,只告知"参数格式错误"
PrismaClientInitializationError503记录完整堆栈,上报监控
PrismaClientRustPanicError500记录后调用 process.exit(1) 触发 PM2 重启
PrismaClientUnknownRequestError500记录完整日志,返回通用 500

5.2 过滤器实现

// src/common/filters/prisma-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from "@nestjs/common";
import { Response, Request } from "express";
import { Prisma } from "../../generated/prisma/client";
import {
    isPrismaError,
    extractUniqueConstraintFields,
} from "../../database/utils/prisma-error.util";

@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(PrismaExceptionFilter.name);

    catch(exception: unknown, host: ArgumentsHost) {
        // 仅处理 Prisma 错误,其他错误继续向上传递给兜底过滤器
        if (!isPrismaError(exception)) {
            throw exception;
        }

        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest<Request>();
        const path = request.url;
        const timestamp = new Date().toISOString();

        if (exception instanceof Prisma.PrismaClientKnownRequestError) {
            return this.handleKnownRequestError(exception, response, path, timestamp);
        }
        if (exception instanceof Prisma.PrismaClientValidationError) {
            return this.handleValidationError(exception, response, path, timestamp);
        }
        if (exception instanceof Prisma.PrismaClientInitializationError) {
            return this.handleInitializationError(exception, response, path, timestamp);
        }
        if (exception instanceof Prisma.PrismaClientRustPanicError) {
            return this.handleRustPanicError(exception, response, path, timestamp);
        }
        // PrismaClientUnknownRequestError
        return this.handleUnknownRequestError(exception, response, path, timestamp);
    }

    private handleKnownRequestError(
        exception: Prisma.PrismaClientKnownRequestError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        const { code, meta } = exception;
        this.logger.warn(`Prisma Known Error [${code}]: ${exception.message}`, { meta });

        const base = { timestamp, path, code };

        switch (code) {
            case "P2002": {
                const fields = extractUniqueConstraintFields(exception);
                const label = fields.length > 0 ? fields.join(", ") : "字段";
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: `${label} 已存在,请使用其他值`,
                    ...base,
                });
            }
            case "P2025":
                return response.status(HttpStatus.NOT_FOUND).json({
                    statusCode: HttpStatus.NOT_FOUND,
                    error: "Not Found",
                    message: "请求的记录不存在",
                    ...base,
                });
            case "P2003": {
                const field = (meta?.field_name as string) || "关联字段";
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `关联记录不存在(${field})`,
                    ...base,
                });
            }
            case "P2014":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "无法操作,存在关联数据",
                    ...base,
                });
            case "P2011": {
                const constraint = (meta?.constraint as string) || "必填字段";
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `${constraint} 不能为空`,
                    ...base,
                });
            }
            case "P2024":
                return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
                    statusCode: HttpStatus.SERVICE_UNAVAILABLE,
                    error: "Service Unavailable",
                    message: "数据库连接池超时,请稍后重试",
                    ...base,
                });
            case "P2034":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "事务冲突,请重试",
                    ...base,
                });
            default:
                this.logger.error(`Unhandled Prisma error code: ${code}`);
                return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
                    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
                    error: "Internal Server Error",
                    message: "数据库操作失败",
                    ...base,
                });
        }
    }

    private handleValidationError(
        exception: Prisma.PrismaClientValidationError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        // 不暴露内部验证细节,只记录日志
        this.logger.warn(`Prisma Validation Error: ${exception.message}`);
        return response.status(HttpStatus.BAD_REQUEST).json({
            statusCode: HttpStatus.BAD_REQUEST,
            error: "Bad Request",
            message: "请求参数格式错误或缺少必填字段",
            timestamp,
            path,
        });
    }

    private handleInitializationError(
        exception: Prisma.PrismaClientInitializationError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        // 连接级错误,记录完整堆栈并上报监控
        this.logger.error(`Prisma Initialization Error: ${exception.message}`, exception.stack);
        // Sentry.captureException(exception);
        return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
            statusCode: HttpStatus.SERVICE_UNAVAILABLE,
            error: "Service Unavailable",
            message: "数据库服务暂时不可用,请稍后重试",
            timestamp,
            path,
        });
    }

    private handleRustPanicError(
        exception: Prisma.PrismaClientRustPanicError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        // 引擎崩溃,发送响应后立即退出,由 PM2 / systemd 自动重启
        this.logger.fatal(`Prisma Rust Panic Error: ${exception.message}`, exception.stack);
        // Sentry.captureException(exception);
        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "服务器内部错误,请联系管理员",
            timestamp,
            path,
        });
        // 响应写出后退出,触发进程管理器重启
        process.exit(1);
    }

    private handleUnknownRequestError(
        exception: Prisma.PrismaClientUnknownRequestError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        this.logger.error(`Prisma Unknown Request Error: ${exception.message}`, exception.stack);
        return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "数据库操作失败",
            timestamp,
            path,
        });
    }
}

5.3 关键设计:throw exception 透传

过滤器开头的这段代码是整个链路正确运转的关键:

if (!isPrismaError(exception)) {
    throw exception; // 透传给下一个过滤器(AllExceptionsFilter)
}

@Catch() 无参数意味着捕获所有异常,但 PrismaExceptionFilter 只应处理 Prisma 错误。通过主动 throw 将非 Prisma 异常继续传递,而不是吞掉或返回错误的响应。

@Catch() vs @Catch(Prisma.PrismaClientKnownRequestError) 的取舍

使用 @Catch() 无参数更合适,原因是 Prisma 有五种错误类型,如果用 @Catch(A, B, C, D, E) 虽然可行,但每次 Prisma 增加新的错误类型都需要修改装饰器。无参数 + 内部 isPrismaError 判断更具扩展性。


六、全局兜底异常过滤器

AllExceptionsFilter 处理所有未被 PrismaExceptionFilter 拦截的异常:HttpException(含 BusinessException)、未知 Error,以及任何其他未预期的异常。

// src/common/filters/all-exceptions.filter.ts
import {
    ArgumentsHost,
    Catch,
    ExceptionFilter,
    HttpException,
    HttpStatus,
    Inject,
    Logger,
    Optional,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request, Response } from "express";
import { ErrorResponse } from "../interfaces/error-response.interface";

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
    private readonly logger = new Logger(AllExceptionsFilter.name);

    constructor(
        @Optional()
        @Inject(ConfigService)
        private readonly configService?: ConfigService,
    ) {}

    catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest<Request>();

        const isProduction = this.configService?.get<string>("NODE_ENV") === "production";

        // 从请求头中读取追踪 ID(由网关或 Nginx 注入)
        const requestId = (request.headers["x-request-id"] as string) || undefined;

        const path = request.url;
        const timestamp = new Date().toISOString();

        if (exception instanceof HttpException) {
            return this.handleHttpException(
                exception,
                response,
                { path, timestamp, requestId },
                isProduction,
            );
        }

        // 未知错误(编程错误、运行时异常等)
        return this.handleUnknownError(
            exception,
            response,
            { path, timestamp, requestId },
            isProduction,
        );
    }

    private handleHttpException(
        exception: HttpException,
        response: Response,
        meta: { path: string; timestamp: string; requestId?: string },
        isProduction: boolean,
    ) {
        const statusCode = exception.getStatus();
        const exceptionResponse = exception.getResponse();

        // HttpException 的 response 可能是字符串或对象
        let message: string;
        let code: string | undefined;

        if (typeof exceptionResponse === "string") {
            message = exceptionResponse;
        } else if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
            const res = exceptionResponse as Record<string, unknown>;
            message = (res.message as string) || exception.message;
            code = res.code as string | undefined;
        } else {
            message = exception.message;
        }

        const body: ErrorResponse = {
            statusCode,
            error: this.getHttpErrorText(statusCode),
            message,
            timestamp: meta.timestamp,
            path: meta.path,
        };

        if (meta.requestId) body.requestId = meta.requestId;

        // code 字段仅在非生产环境返回
        if (!isProduction && code) body.code = code;

        if (statusCode >= 500) {
            this.logger.error(`[${statusCode}] ${meta.path} - ${message}`, exception.stack, {
                requestId: meta.requestId,
            });
        } else if (statusCode >= 400) {
            this.logger.warn(`[${statusCode}] ${meta.path} - ${message}`, { requestId: meta.requestId });
        }

        response.status(statusCode).json(body);
    }

    private handleUnknownError(
        exception: unknown,
        response: Response,
        meta: { path: string; timestamp: string; requestId?: string },
        isProduction: boolean,
    ) {
        const message = exception instanceof Error ? exception.message : String(exception);
        const stack = exception instanceof Error ? exception.stack : undefined;

        this.logger.error(`[500] ${meta.path} - Unhandled exception: ${message}`, stack, {
            requestId: meta.requestId,
        });

        // Sentry.captureException(exception);

        const body: ErrorResponse = {
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            // 生产环境不暴露内部错误信息
            message: isProduction ? "服务器内部错误,请稍后重试" : message,
            timestamp: meta.timestamp,
            path: meta.path,
        };

        if (meta.requestId) body.requestId = meta.requestId;

        // 开发环境附加堆栈,方便调试
        if (!isProduction && stack) {
            (body as Record<string, unknown>).stack = stack;
        }

        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(body);
    }

    private getHttpErrorText(statusCode: number): string {
        const map: Record<number, string> = {
            400: "Bad Request",
            401: "Unauthorized",
            403: "Forbidden",
            404: "Not Found",
            405: "Method Not Allowed",
            409: "Conflict",
            410: "Gone",
            422: "Unprocessable Entity",
            429: "Too Many Requests",
            500: "Internal Server Error",
            502: "Bad Gateway",
            503: "Service Unavailable",
            504: "Gateway Timeout",
        };
        return map[statusCode] ?? "Error";
    }
}

关键设计点

  • @Optional() 注入 ConfigService:使过滤器在没有 ConfigModule 时也能实例化,降低耦合
  • x-request-id:由反向代理(Nginx/网关)注入,过滤器只读取不生成,保证分布式链路 ID 的一致性
  • 生产环境隐藏 messagestack:未知错误的内部信息绝不对外暴露

七、过滤器注册顺序(链路设计)

NestJS 过滤器的执行规则

NestJS 过滤器遵循后注册先执行的规则:

app.useGlobalFilters(
    new AllExceptionsFilter(configService), // 后执行(先注册)
    new PrismaExceptionFilter(), // 先执行(后注册)
);

执行顺序:

异常抛出
  → PrismaExceptionFilter.catch()
      → Prisma 错误:处理并响应,结束
      → 非 Prisma 错误:throw exception
          → AllExceptionsFilter.catch()
              → HttpException / 未知错误:处理并响应,结束

方式一:在 main.ts 注册(简单场景)

// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ConfigService } from "@nestjs/config";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    const configService = app.get(ConfigService);

    app.useGlobalFilters(new AllExceptionsFilter(configService), new PrismaExceptionFilter());

    await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

适合过滤器不需要额外依赖注入的场景。

方式二:通过 APP_FILTER 注册(支持依赖注入)

当过滤器内部需要注入其他服务时,必须使用模块注册方式:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

@Module({
    providers: [
        // 注意:APP_FILTER 注册时,先声明的后执行
        // 即:AllExceptionsFilter 先声明 → 后执行(兜底)
        //     PrismaExceptionFilter 后声明 → 先执行
        {
            provide: APP_FILTER,
            useClass: AllExceptionsFilter,
        },
        {
            provide: APP_FILTER,
            useClass: PrismaExceptionFilter,
        },
    ],
})
export class AppModule {}

通过 APP_FILTER 注册的过滤器支持完整的依赖注入,可以在构造函数中注入任何已注册的 Provider。


八、在 Service 层主动捕获特定错误

过滤器是被动兜底,而某些业务场景需要主动控制错误语义。例如:用户注册时唯一冲突应该明确提示"邮箱已被注册",而不是由过滤器输出通用的"字段已存在"。

// src/modules/user/user.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { Prisma } from "../../generated/prisma/client";
import { isPrismaErrorWithCode } from "../../database/utils/prisma-error.util";
import { BusinessException } from "../../common/exceptions/business.exception";
import { ErrorCode } from "../../common/exceptions/error-codes";

@Injectable()
export class UserService {
    constructor(private readonly prisma: PrismaService) {}

    async create(data: Prisma.UserCreateInput) {
        try {
            return await this.prisma.user.create({ data });
        } catch (e) {
            if (isPrismaErrorWithCode(e, "P2002")) {
                // 主动转换为语义明确的业务异常
                throw new BusinessException("该邮箱已被注册", 409, ErrorCode.USER_ALREADY_EXISTS);
            }
            throw e; // 其余错误继续上抛,由过滤器统一处理
        }
    }

    async findOneOrThrow(id: number) {
        const user = await this.prisma.user.findUnique({ where: { id } });
        if (!user) {
            throw new BusinessException(`用户 #${id} 不存在`, 404, ErrorCode.USER_NOT_FOUND);
        }
        return user;
    }

    async remove(id: number) {
        try {
            return await this.prisma.user.delete({ where: { id } });
        } catch (e) {
            if (isPrismaErrorWithCode(e, "P2025")) {
                throw new NotFoundException(`用户 #${id} 不存在`);
            }
            throw e;
        }
    }
}

原则:对于可预期的业务场景(唯一冲突、记录不存在等),在 Service 层主动捕获并转换为语义明确的异常;对于不可预期的错误,直接 throw e 交由过滤器链处理。


九、可观测性:日志与监控接入

日志分级策略

NestJS 内置 Logger 支持五个日志级别,错误处理中的分级建议:

场景级别说明
4xx 客户端错误warn用户输入问题,无需告警
5xx 服务端错误error需要关注,可触发告警
数据库连接失败error需要立即处理
Prisma 引擎崩溃fatal最高优先级,立即告警
// 在过滤器中按严重程度分级
this.logger.warn("4xx 错误");
this.logger.error("5xx 错误", stack);
this.logger.fatal("引擎崩溃", stack);

结构化日志

在生产环境中,推荐结合 nestjs-pinowinston 输出结构化 JSON 日志,便于 ELK、Loki 等日志系统收集和检索:

this.logger.error("数据库错误", {
    requestId,
    path,
    prismaCode: exception.code,
    // 不记录原始 SQL 或敏感数据
});

监控集成预留点

handleRustPanicErrorhandleUnknownError 中,已标注 Sentry 集成点:

// Sentry 集成(取消注释即可启用)
// Sentry.captureException(exception, {
//   extra: { requestId, path },
// });

// OpenTelemetry 集成
// span.setStatus({ code: SpanStatusCode.ERROR, message });
// span.recordException(exception);

实际接入时,只需取消注释并安装对应 SDK,不需要修改过滤器的主体逻辑。


十、完整集成示例

以 User 模块为例,串联整个链路:

10.1 场景一:注册时邮箱重复(P2002 → 409)

POST /users { email: "existing@example.com" }
  → UserService.create()
  → prisma.user.create() 抛出 PrismaClientKnownRequestError(P2002)
  → Service catch → 抛出 BusinessException('该邮箱已被注册', 409, USER_ALREADY_EXISTS)
  → PrismaExceptionFilter.catch() → isPrismaError = false → throw exception
  → AllExceptionsFilter.catch() → instanceof HttpException → 处理
  → 响应:{ statusCode: 409, error: "Conflict", message: "该邮箱已被注册", code: "USER_ALREADY_EXISTS" }

10.2 场景二:查询不存在的用户(主动抛出 BusinessException)

GET /users/999
  → UserService.findOneOrThrow(999)
  → prisma.user.findUnique() → null
  → 抛出 BusinessException('用户 #999 不存在', 404, USER_NOT_FOUND)
  → PrismaExceptionFilter.catch() → isPrismaError = false → throw exception
  → AllExceptionsFilter.catch() → instanceof HttpException → 处理
  → 响应:{ statusCode: 404, error: "Not Found", message: "用户 #999 不存在" }

10.3 场景三:Prisma 唯一冲突未在 Service 层捕获(直接由过滤器处理)

POST /posts { slug: "existing-slug" }
  → PostService.create() → prisma.post.create() 抛出 PrismaClientKnownRequestError(P2002)
  → 未在 Service 层捕获,直接上抛
  → PrismaExceptionFilter.catch() → isPrismaError = true → handleKnownRequestError
  → 响应:{ statusCode: 409, error: "Conflict", message: "slug 已存在,请使用其他值" }

10.4 完整的 UserController + UserService

// src/modules/user/user.controller.ts
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UserController {
    constructor(private readonly userService: UserService) {}

    @Post()
    create(@Body() dto: CreateUserDto) {
        return this.userService.create(dto);
    }

    @Get(":id")
    findOne(@Param("id", ParseIntPipe) id: number) {
        return this.userService.findOneOrThrow(id);
    }

    @Delete(":id")
    remove(@Param("id", ParseIntPipe) id: number) {
        return this.userService.remove(id);
    }
}

[/hide]

13 NestJS 集成 TypeORM 完全指南

作者 木灵鱼儿
2026年8月16日 05:21

前言

TypeORM 是 Node.js 生态中历史最悠久的 ORM,也是 NestJS 官方文档中首推的数据库解决方案之一。它以装饰器驱动的 Entity 定义为核心,支持 Active Record 与 Data Mapper 两种模式,与 TypeScript 有着天然的契合。

相比 Prisma 的 Schema-first 理念,TypeORM 更贴近传统 ORM 思想:Entity 类既是数据库表的映射,也是业务对象。对于习惯 Java/Spring 或 C#/EF 体系的开发者来说,TypeORM 的上手成本更低。

本文使用 Data Mapper 模式(Repository 模式),这是 NestJS 中更推荐的生产方式。与 Active Record 相比,Repository 模式将数据库操作与业务逻辑分离,测试更友好,依赖关系更清晰。

本文基于 TypeORM 最新版NestJS 最新版,以 MySQL 为主要示例(PostgreSQL 差异处会单独说明),从安装到生产级集成,覆盖以下内容:

  • 生产级目录结构与 data-source.ts 的设计原则
  • ConfigService 整合,异步读取配置,避免硬编码
  • 自定义日志系统,与 NestJS Logger 对接
  • Entity 定义:公共基类、软删除、索引、钩子
  • 数据库迁移(CLI 工作流)
  • Repository 模式 CRUD 与 QueryBuilder 进阶
  • 关联关系(OneToOne / OneToMany / ManyToMany)
  • 事务管理(DataSource.transaction / QueryRunner)
  • 生产级全局异常过滤器
本文假设你已有一个使用 @nestjs/config 的 NestJS 项目。ConfigModule 的配置请参考本系列第 01 篇。

[hide]

一、生产级项目目录结构

在集成 TypeORM 之前,先规划一个符合业界最佳实践的目录结构:

project-root/
├── src/
│   ├── common/
│   │   └── filters/
│   │       └── typeorm-exception.filter.ts   # 全局异常过滤器
│   ├── config/
│   │   └── database.config.ts                # 数据源配置(registerAs)
│   ├── database/
│   │   ├── database.module.ts                # 全局数据库模块
│   │   ├── typeorm-logger.ts                 # 自定义 TypeORM 日志
│   │   └── migrations/                       # 迁移文件(CLI 生成)
│   │       └── 1700000000000-Init.ts
│   ├── modules/
│   │   └── user/
│   │       ├── entities/
│   │       │   └── user.entity.ts
│   │       ├── user.module.ts
│   │       ├── user.service.ts
│   │       └── user.controller.ts
│   ├── app.module.ts
│   └── main.ts
├── data-source.ts                            # CLI 专用数据源(迁移用)
├── .env
└── tsconfig.json

data-source.ts 为什么需要单独存在:TypeORM CLI(typeorm migration:generate 等命令)在运行时无法访问 NestJS 的 DI 容器,它只能读取一个导出 DataSource 实例的独立文件。因此需要将数据源配置提取为一个可独立运行的文件,与 NestJS 应用共享同一份配置逻辑,但不依赖任何 NestJS 模块。


二、安装与初始化

2.1 安装依赖

# NestJS TypeORM 集成包 + TypeORM 核心
pnpm add @nestjs/typeorm typeorm

# MySQL 驱动(选其一)
pnpm add mysql2

# PostgreSQL 驱动(选其一)
pnpm add pg
pnpm add -D @types/pg

2.2 配置 tsconfig.json

TypeORM 的 Entity 装饰器(@Entity@Column 等)依赖 TypeScript 的装饰器元数据功能,必须在 tsconfig.json 中开启以下两个选项:

// tsconfig.json
{
    "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

三、与 ConfigService 整合(DataSource 配置)

3.1 创建数据库配置文件

.env 文件:

# .env
DB_TYPE=mysql
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=secret
DB_DATABASE=mydb
DB_SSL=false
DB_POOL_SIZE=10

3.2 创建数据库模块

使用 TypeOrmModule.forRootAsync 异步读取配置,避免在模块初始化时同步访问尚未加载的环境变量:

// src/database/database.module.ts
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmLogger } from "./typeorm-logger";
import databaseConfig from "../config/database.config";

@Global()
@Module({
    imports: [
        TypeOrmModule.forRootAsync({
            imports: [ConfigModule],
            inject: [ConfigService],
            useFactory: (config: ConfigService) => {
                const db = config.get("database");
                const isProduction = config.get("NODE_ENV") === "production";

                return {
                    type: db.type as "mysql" | "postgres",
                    host: db.host,
                    port: db.port,
                    username: db.username,
                    password: db.password,
                    database: db.database,
                    ssl: db.ssl,
                    extra: db.extra,

                    // 自动加载通过 TypeOrmModule.forFeature() 注册的 Entity
                    autoLoadEntities: true,

                    // 生产环境必须关闭:会直接修改数据库结构,导致数据丢失
                    synchronize: !isProduction,

                    // 迁移文件位置
                    migrations: [__dirname + "/migrations/*{.ts,.js}"],

                    // 生产级日志配置:使用自定义 Logger 对接 NestJS Logger
                    logger: new TypeOrmLogger(isProduction),
                    logging: isProduction ? ["error", "warn"] : ["error", "warn", "query", "schema"],

                    // 慢查询告警阈值(毫秒)
                    maxQueryExecutionTime: 2000,
                };
            },
        }),
    ],
})
export class DatabaseModule {}

重要配置项说明

配置项说明
synchronize开发环境可开启(自动同步 schema),生产必须关闭,否则 Entity 变更会直接修改数据库结构,可能导致数据丢失
autoLoadEntities配合 forFeature() 自动注册 Entity,无需手动维护 entities 数组
logging生产建议仅开启 ['error', 'warn'],避免大量 SQL 日志影响性能和泄露敏感数据
maxQueryExecutionTime超过此阈值的查询会触发 logQuerySlow,用于慢查询告警

3.3 在 AppModule 中注册

确保 ConfigModuleDatabaseModule 都在 AppModule 中加载:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import databaseConfig from "./config/database.config";
import { DatabaseModule } from "./database/database.module";

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true,
            load: [databaseConfig],
        }),
        DatabaseModule,
        // ...业务模块
    ],
})
export class AppModule {}

四、与日志系统整合

TypeORM 提供了 Logger 接口,允许我们将其内部日志输出对接到 NestJS 的 Logger,统一日志格式并支持慢查询告警:

// src/database/typeorm-logger.ts
import { Logger as NestLogger } from "@nestjs/common";
import { Logger, QueryRunner } from "typeorm";

export class TypeOrmLogger implements Logger {
    private readonly logger = new NestLogger("TypeORM");

    constructor(private readonly isProduction: boolean) {}

    // 记录 SQL 查询
    logQuery(query: string, parameters?: unknown[], _queryRunner?: QueryRunner) {
        if (!this.isProduction) {
            this.logger.debug(
                `Query: ${query}${parameters?.length ? ` -- Parameters: ${JSON.stringify(parameters)}` : ""}`,
            );
        }
    }

    // 记录查询错误
    logQueryError(
        error: string | Error,
        query: string,
        parameters?: unknown[],
        _queryRunner?: QueryRunner,
    ) {
        this.logger.error(
            `Query Failed: ${query}${parameters?.length ? ` -- Parameters: ${JSON.stringify(parameters)}` : ""}`,
            typeof error === "string" ? error : error.stack,
        );
    }

    // 慢查询告警
    logQuerySlow(time: number, query: string, parameters?: unknown[], _queryRunner?: QueryRunner) {
        this.logger.warn(
            `Slow Query (${time}ms): ${query}${parameters?.length ? ` -- Parameters: ${JSON.stringify(parameters)}` : ""}`,
        );
    }

    // schema 构建日志(开发环境)
    logSchemaBuild(message: string, _queryRunner?: QueryRunner) {
        if (!this.isProduction) {
            this.logger.log(`Schema: ${message}`);
        }
    }

    // 迁移日志
    logMigration(message: string, _queryRunner?: QueryRunner) {
        this.logger.log(`Migration: ${message}`);
    }

    // 普通日志
    log(level: "log" | "info" | "warn", message: unknown, _queryRunner?: QueryRunner) {
        switch (level) {
            case "warn":
                this.logger.warn(String(message));
                break;
            case "info":
                if (!this.isProduction) this.logger.verbose(String(message));
                break;
            default:
                if (!this.isProduction) this.logger.log(String(message));
        }
    }
}

这样所有 TypeORM 内部日志都会通过 NestJS Logger 输出,格式统一,慢查询会以 [WARN] 级别打印,方便监控系统采集。


五、定义 Entity

5.1 公共基类 BaseEntity

将所有 Entity 通用的字段(主键、时间戳、软删除)抽取到基类中,避免重复:

// src/common/entities/base.entity.ts
import {
    PrimaryGeneratedColumn,
    CreateDateColumn,
    UpdateDateColumn,
    DeleteDateColumn,
} from "typeorm";

export abstract class BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @CreateDateColumn({ comment: "创建时间" })
    createdAt: Date;

    @UpdateDateColumn({ comment: "更新时间" })
    updatedAt: Date;

    // 软删除列:有值则表示已删除
    @DeleteDateColumn({ comment: "删除时间", nullable: true, select: false })
    deletedAt: Date | null;
}
  • @CreateDateColumn:insert 时自动填充当前时间
  • @UpdateDateColumn:每次 save/update 时自动更新为当前时间
  • @DeleteDateColumn:配合 softDelete() 使用,TypeORM 会自动在查询中过滤已软删除的记录

5.2 业务 Entity 示例

// src/modules/user/entities/user.entity.ts
import { Entity, Column, Index, BeforeInsert, BeforeUpdate, OneToMany } from "typeorm";
import * as bcrypt from "bcrypt";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Post } from "../../post/entities/post.entity";

export enum UserRole {
    USER = "user",
    ADMIN = "admin",
}

@Entity("users")
@Index(["email"]) // 单列索引
@Index(["createdAt", "role"]) // 复合索引
export class User extends BaseEntity {
    @Column({ length: 100, comment: "用户名" })
    name: string;

    @Column({ unique: true, length: 200, comment: "邮箱" })
    email: string;

    // select: false 防止查询时自动返回密码
    @Column({ select: false, comment: "密码哈希" })
    password: string;

    @Column({
        type: "enum",
        enum: UserRole,
        default: UserRole.USER,
        comment: "角色",
    })
    role: UserRole;

    @Column({ nullable: true, length: 500, comment: "头像 URL" })
    avatar: string | null;

    @OneToMany(() => Post, (post) => post.author)
    posts: Post[];

    // 保存前自动哈希密码
    @BeforeInsert()
    @BeforeUpdate()
    async hashPassword() {
        // 仅当 password 字段被修改时才重新哈希
        if (this.password) {
            this.password = await bcrypt.hash(this.password, 12);
        }
    }
}

5.3 常用列类型速查

TypeORM 类型MySQL 对应PostgreSQL 对应说明
varchar(n)VARCHAR(n)VARCHAR(n)字符串(有长度限制)
textTEXTTEXT长文本
intINTINTEGER整数
bigintBIGINTBIGINT大整数(JS 中为 string)
decimal(p,s)DECIMAL(p,s)NUMERIC(p,s)精确小数
floatFLOATREAL浮点数
booleanTINYINT(1)BOOLEAN布尔值
jsonJSONJSONBJSON 数据
enumENUM自定义类型枚举
timestampDATETIMETIMESTAMP时间戳
dateDATEDATE日期

5.4 索引最佳实践

// 唯一索引
@Column({ unique: true })
email: string;

// 单列显式索引
@Index()
@Column()
phone: string;

// 复合索引(类级别)
@Index(['lastName', 'firstName'])
@Entity('users')
export class User {}

// 唯一复合索引
@Index(['tenantId', 'email'], { unique: true })
@Entity('users')
export class User {}

六、数据库迁移(Migration)

6.1 为什么生产环境必须禁用 synchronize

synchronize: true 会在每次应用启动时,对比 Entity 定义与数据库 schema 的差异并自动执行 DDL。这在生产环境极其危险:

  • 数据丢失:删除字段、修改列类型时会直接 DROP 列或 TRUNCATE 表
  • 不可预期:无法预先审查 SQL 变更
  • 无法回滚:出问题后没有回滚路径

正确做法:开发环境只在初始阶段使用 synchronize: true 快速成型,随后切换为迁移工作流;生产环境始终使用迁移。

6.2 创建 CLI 专用数据源文件

TypeORM CLI 无法访问 NestJS DI 容器,需要一个独立的 data-source.ts 文件:

// data-source.ts(项目根目录)
import "reflect-metadata";
import { DataSource } from "typeorm";
import * as dotenv from "dotenv";

// 手动加载 .env(NestJS 不可用时)
dotenv.config();

export const AppDataSource = new DataSource({
    type: (process.env.DB_TYPE as "mysql" | "postgres") || "mysql",
    host: process.env.DB_HOST || "localhost",
    port: parseInt(process.env.DB_PORT ?? "3306", 10),
    username: process.env.DB_USERNAME || "root",
    password: process.env.DB_PASSWORD || "",
    database: process.env.DB_DATABASE || "mydb",

    // 明确指定 Entity 和迁移文件的路径
    entities: ["src/**/*.entity{.ts,.js}"],
    migrations: ["src/database/migrations/*{.ts,.js}"],
    migrationsTableName: "migrations",

    // CLI 场景下不自动同步
    synchronize: false,
});
注意:data-source.tsDatabaseModule 使用相同的环境变量,但不依赖 NestJS。这是唯一需要维护"双份"配置的地方,但代价是可接受的。

6.3 配置迁移相关 scripts

package.json 中添加常用迁移命令,并安装 dotenvts-node

pnpm add -D ts-node dotenv
// package.json
{
    "scripts": {
        "migration:generate": "typeorm-ts-node-commonjs migration:generate -d data-source.ts",
        "migration:create": "typeorm-ts-node-commonjs migration:create",
        "migration:run": "typeorm-ts-node-commonjs migration:run -d data-source.ts",
        "migration:revert": "typeorm-ts-node-commonjs migration:revert -d data-source.ts",
        "migration:show": "typeorm-ts-node-commonjs migration:show -d data-source.ts"
    }
}

6.4 迁移 CLI 命令一览

生成迁移文件

# 对比 Entity 与数据库差异,自动生成迁移 SQL
pnpm migration:generate src/database/migrations/AddUserAvatar

生成文件示例 src/database/migrations/1700000000001-AddUserAvatar.ts

import { MigrationInterface, QueryRunner } from "typeorm";

export class AddUserAvatar1700000000001 implements MigrationInterface {
    name = "AddUserAvatar1700000000001";

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(
            `ALTER TABLE \`users\` ADD \`avatar\` varchar(500) NULL COMMENT '头像 URL'`,
        );
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`avatar\``);
    }
}
  • up:执行迁移(向前)
  • down:回滚迁移(向后),必须完整实现,方便生产事故回滚

创建空迁移文件

# 手动编写 SQL 时使用
pnpm migration:create src/database/migrations/SeedInitialData

执行迁移

# 执行所有未运行的迁移
pnpm migration:run

回滚最近一次迁移

pnpm migration:revert

查看迁移状态

pnpm migration:show

输出示例:

[X] AddUserAvatar1700000000001
[ ] AddPostTable1700000000002   # 未执行

七、Repository 模式与 CRUD 实战

7.1 在 Feature 模块中注册 Entity

// src/modules/user/user.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./entities/user.entity";
import { UserService } from "./user.service";
import { UserController } from "./user.controller";

@Module({
    imports: [TypeOrmModule.forFeature([User])],
    providers: [UserService],
    controllers: [UserController],
    exports: [UserService],
})
export class UserModule {}

TypeOrmModule.forFeature([User]) 会:

  1. User 注册到 DatabaseModuleautoLoadEntities
  2. 在当前模块的 DI 容器中提供 Repository<User>

7.2 注入并使用 Repository

// src/modules/user/user.service.ts
import { Injectable, NotFoundException, ConflictException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, FindManyOptions } from "typeorm";
import { User } from "./entities/user.entity";

@Injectable()
export class UserService {
    constructor(
        @InjectRepository(User)
        private readonly userRepo: Repository<User>,
    ) {}

    // 创建
    async create(data: Partial<User>): Promise<User> {
        const user = this.userRepo.create(data);
        return this.userRepo.save(user);
    }

    // 查询列表(带分页)
    async findAll(page = 1, limit = 20): Promise<[User[], number]> {
        return this.userRepo.findAndCount({
            skip: (page - 1) * limit,
            take: limit,
            order: { createdAt: "DESC" },
        });
    }

    // 查询单条
    async findOne(id: number): Promise<User> {
        const user = await this.userRepo.findOne({ where: { id } });
        if (!user) throw new NotFoundException(`用户 #${id} 不存在`);
        return user;
    }

    // 查询(包含密码字段,用于登录验证)
    async findOneWithPassword(email: string): Promise<User | null> {
        return this.userRepo
            .createQueryBuilder("user")
            .addSelect("user.password") // password 字段 select:false,需显式 addSelect
            .where("user.email = :email", { email })
            .getOne();
    }

    // 更新
    async update(id: number, data: Partial<User>): Promise<User> {
        const user = await this.findOne(id);
        Object.assign(user, data);
        return this.userRepo.save(user);
    }

    // 软删除
    async remove(id: number): Promise<void> {
        await this.findOne(id); // 确认存在
        await this.userRepo.softDelete(id);
    }

    // 硬删除
    async hardRemove(id: number): Promise<void> {
        await this.userRepo.delete(id);
    }

    // 恢复软删除
    async restore(id: number): Promise<void> {
        await this.userRepo.restore(id);
    }
}

7.3 常用 Repository API

方法说明
create(data)创建 Entity 实例(不写数据库)
save(entity)插入或更新(有 id 则更新,无则插入)
find(options)查询多条记录
findAndCount(options)查询多条记录 + 总数(分页用)
findOne(options)查询单条,不存在返回 null
findOneOrFail(options)查询单条,不存在抛出 EntityNotFoundError
update(criteria, partialEntity)部分更新(不触发 Entity 钩子)
delete(criteria)硬删除
softDelete(criteria)软删除(需有 @DeleteDateColumn
restore(criteria)恢复软删除
count(options)统计数量
exists(options)判断是否存在
save vs update 的区别save 会触发 @BeforeInsert/@BeforeUpdate 钩子(如密码哈希),update 直接执行 SQL UPDATE,不触发钩子。修改密码等需要触发钩子的场景应使用 save

7.4 QueryBuilder 进阶

对于复杂查询,QueryBuilderfind 选项更灵活:

// 复杂查询:多条件 + 联表 + 分页 + 排序
async findUsersWithPosts(
  keyword: string,
  role: UserRole,
  page: number,
  limit: number,
) {
  const qb = this.userRepo
    .createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post', 'post.published = :published', {
      published: true,
    })
    .where('user.role = :role', { role });

  // 动态条件拼接
  if (keyword) {
    qb.andWhere('(user.name LIKE :kw OR user.email LIKE :kw)', {
      kw: `%${keyword}%`,
    });
  }

  return qb
    .orderBy('user.createdAt', 'DESC')
    .skip((page - 1) * limit)
    .take(limit)
    .getManyAndCount();
}

// 子查询
async findActiveUsers() {
  return this.userRepo
    .createQueryBuilder('user')
    .where((qb) => {
      const subQuery = qb
        .subQuery()
        .select('post.authorId')
        .from(Post, 'post')
        .where('post.createdAt > :date', {
          date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
        })
        .getQuery();
      return `user.id IN ${subQuery}`;
    })
    .getMany();
}

7.5 自定义 Repository

对于复杂业务查询,可以继承 Repository<T> 封装:

// src/modules/user/user.repository.ts
import { Repository, DataSource } from "typeorm";
import { Injectable } from "@nestjs/common";
import { User, UserRole } from "./entities/user.entity";

@Injectable()
export class UserRepository extends Repository<User> {
    constructor(private dataSource: DataSource) {
        super(User, dataSource.createEntityManager());
    }

    async findAdminUsers(): Promise<User[]> {
        return this.createQueryBuilder("user")
            .where("user.role = :role", { role: UserRole.ADMIN })
            .andWhere("user.deletedAt IS NULL")
            .orderBy("user.createdAt", "DESC")
            .getMany();
    }

    async countByRole(): Promise<{ role: string; count: string }[]> {
        return this.createQueryBuilder("user")
            .select("user.role", "role")
            .addSelect("COUNT(*)", "count")
            .groupBy("user.role")
            .getRawMany();
    }
}

在模块中注册:

// user.module.ts
@Module({
    imports: [TypeOrmModule.forFeature([User])],
    providers: [UserService, UserRepository],
    controllers: [UserController],
})
export class UserModule {}

八、关联关系(Relations)

8.1 OneToOne — 一对一

// profile.entity.ts
@Entity('profiles')
export class Profile extends BaseEntity {
  @Column({ nullable: true })
  bio: string;

  // 外键存在于 profiles 表(@JoinColumn 所在方)
  @OneToOne(() => User, (user) => user.profile, { onDelete: 'CASCADE' })
  @JoinColumn()
  user: User;
}

// user.entity.ts
@OneToOne(() => Profile, (profile) => profile.user)
profile: Profile;
  • @JoinColumn 必须且只能在关系的拥有方(外键所在表)声明
  • onDelete: 'CASCADE' 表示删除 User 时自动删除关联的 Profile

8.2 OneToMany / ManyToOne — 一对多

// post.entity.ts
@Entity('posts')
export class Post extends BaseEntity {
  @Column()
  title: string;

  // 外键 authorId 在 posts 表
  @ManyToOne(() => User, (user) => user.posts, { onDelete: 'SET NULL', nullable: true })
  @JoinColumn({ name: 'author_id' })
  author: User;

  @Column({ nullable: true })
  authorId: number;
}

// user.entity.ts
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
  • ManyToOne 不需要 @JoinColumn(默认添加),但可用 @JoinColumn({ name: 'author_id' }) 自定义列名
  • @OneToMany 没有对应数据库列,只是关系的反向引用

8.3 ManyToMany — 多对多

// tag.entity.ts
@Entity('tags')
export class Tag extends BaseEntity {
  @Column({ unique: true })
  name: string;

  @ManyToMany(() => Post, (post) => post.tags)
  posts: Post[];
}

// post.entity.ts
// @JoinTable 在关系的拥有方声明,TypeORM 会自动创建中间表 post_tags_tag
@ManyToMany(() => Tag, (tag) => tag.posts, { cascade: true })
@JoinTable({
  name: 'post_tags',           // 自定义中间表名
  joinColumn: { name: 'post_id' },
  inverseJoinColumn: { name: 'tag_id' },
})
tags: Tag[];

8.4 加载关联数据

方式一:relations 选项(简单场景)

// 查询时附带关联数据
const user = await this.userRepo.findOne({
    where: { id },
    relations: { posts: true, profile: true },
});

方式二:QueryBuilder leftJoinAndSelect(复杂场景)

const user = await this.userRepo
    .createQueryBuilder("user")
    .leftJoinAndSelect("user.posts", "post")
    .leftJoinAndSelect("post.tags", "tag")
    .where("user.id = :id", { id })
    .getOne();

方式二优势:可以在 JOIN 时添加额外条件、选择特定列,避免 N+1 查询。

关于懒加载(lazy: true:TypeORM 支持将关联声明为 Promise<T> 类型实现懒加载,但在 NestJS 中不推荐使用,原因是懒加载在异步上下文中容易引发连接泄漏,且行为难以预测。始终优先使用 eager 加载(relations 或 QueryBuilder)

九、事务(Transaction)

9.1 方式一:DataSource.transaction(推荐)

适合大多数业务场景,写法简洁,出现异常时自动回滚:

// src/modules/order/order.service.ts
import { Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";

@Injectable()
export class OrderService {
    constructor(private readonly dataSource: DataSource) {}

    async createOrder(userId: number, items: OrderItem[]) {
        return this.dataSource.transaction(async (manager) => {
            // 在事务中,使用 manager 而非注入的 Repository
            const order = manager.create(Order, { userId });
            await manager.save(order);

            for (const item of items) {
                // 检查并扣减库存
                const product = await manager.findOneOrFail(Product, {
                    where: { id: item.productId },
                    lock: { mode: "pessimistic_write" }, // 悲观锁防止超卖
                });

                if (product.stock < item.quantity) {
                    throw new Error(`商品 ${product.name} 库存不足`);
                    // 抛出异常 → 事务自动回滚
                }

                product.stock -= item.quantity;
                await manager.save(product);

                const orderItem = manager.create(OrderItem, {
                    orderId: order.id,
                    productId: item.productId,
                    quantity: item.quantity,
                    price: product.price,
                });
                await manager.save(orderItem);
            }

            return order;
        });
    }
}

9.2 方式二:QueryRunner(细粒度控制)

适合需要分阶段控制事务、或需要在事务中执行原始 SQL 的复杂流程:

async transferBalance(fromId: number, toId: number, amount: number) {
  const queryRunner = this.dataSource.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    const from = await queryRunner.manager.findOneOrFail(Account, {
      where: { id: fromId },
      lock: { mode: 'pessimistic_write' },
    });

    if (from.balance < amount) {
      throw new Error('余额不足');
    }

    await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount);
    await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount);

    // 记录流水
    await queryRunner.manager.save(Transaction, {
      fromId,
      toId,
      amount,
      type: 'transfer',
    });

    await queryRunner.commitTransaction();
  } catch (err) {
    await queryRunner.rollbackTransaction();
    throw err; // 重新抛出,由上层处理
  } finally {
    // 必须释放,否则连接泄漏
    await queryRunner.release();
  }
}

注意finally 中的 release() 是必须的,无论事务成功还是失败都必须执行,否则连接不会归还连接池,最终导致连接耗尽。


十、错误处理

10.1 TypeORM 常见错误类型

错误类型触发场景处理建议
QueryFailedError数据库执行 SQL 失败(约束冲突、语法错误等)检查 driverError.code,映射为业务错误
EntityNotFoundErrorfindOneOrFail / findOneByOrFail 未找到记录映射为 404
TypeORMErrorTypeORM 内部错误基类记录日志,返回 500
CannotCreateEntityIdMapError主键未定义(通常是代码 bug)检查 Entity 定义

10.2 QueryFailedError 数据库原生错误码

QueryFailedErrordriverError 属性包含数据库驱动抛出的原始错误,通过 code 字段区分具体类型:

MySQL 常用错误码

错误码说明示例场景
ER_DUP_ENTRY唯一约束冲突插入重复 email
ER_NO_REFERENCED_ROW_2外键约束失败(引用的记录不存在)引用不存在的 userId
ER_ROW_IS_REFERENCED_2外键约束失败(被其他表引用)删除有关联数据的记录
ER_DATA_TOO_LONG数据超过字段长度字符串过长
ER_BAD_NULL_ERROR非空字段插入 NULL必填字段缺失

PostgreSQL 常用错误码

错误码说明
23505唯一约束冲突(等同于 ER_DUP_ENTRY
23503外键约束失败
23502NOT NULL 约束失败
22001字符串过长

10.3 生产级全局异常过滤器

// src/common/filters/typeorm-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from "@nestjs/common";
import { Response } from "express";
import { QueryFailedError, EntityNotFoundError, TypeORMError } from "typeorm";

type DatabaseError = QueryFailedError & { driverError: { code: string; sqlMessage?: string } };

@Catch(QueryFailedError, EntityNotFoundError, TypeORMError)
export class TypeOrmExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(TypeOrmExceptionFilter.name);

    catch(exception: QueryFailedError | EntityNotFoundError | TypeORMError, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();

        if (exception instanceof EntityNotFoundError) {
            return response.status(HttpStatus.NOT_FOUND).json({
                statusCode: HttpStatus.NOT_FOUND,
                error: "Not Found",
                message: "请求的记录不存在",
            });
        }

        if (exception instanceof QueryFailedError) {
            return this.handleQueryFailed(exception as DatabaseError, response);
        }

        // 其他 TypeORM 内部错误
        this.logger.error(`TypeORM Error: ${exception.message}`, exception.stack);
        return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "数据库操作失败",
        });
    }

    private handleQueryFailed(exception: DatabaseError, response: Response) {
        this.logger.warn(`QueryFailedError: ${exception.message}`);

        const code = exception.driverError?.code;

        // MySQL
        switch (code) {
            case "ER_DUP_ENTRY":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "数据已存在,请勿重复提交",
                    code,
                });

            case "ER_NO_REFERENCED_ROW_2":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "关联的记录不存在",
                    code,
                });

            case "ER_ROW_IS_REFERENCED_2":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "存在关联数据,无法删除",
                    code,
                });

            case "ER_DATA_TOO_LONG":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "输入数据超过字段长度限制",
                    code,
                });

            // PostgreSQL
            case "23505":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "数据已存在,请勿重复提交",
                    code,
                });

            case "23503":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "关联的记录不存在",
                    code,
                });

            case "23502":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "必填字段不能为空",
                    code,
                });

            default:
                this.logger.error(`Unhandled DB error code: ${code}`, exception.message);
                return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
                    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
                    error: "Internal Server Error",
                    message: "数据库操作失败",
                });
        }
    }
}

10.4 注册全局过滤器

方式一:main.ts(简洁,无依赖注入)

// src/main.ts
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { TypeOrmExceptionFilter } from "./common/filters/typeorm-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalFilters(new TypeOrmExceptionFilter());
    await app.listen(3000);
}
bootstrap();

方式二:AppModule(支持依赖注入)

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { TypeOrmExceptionFilter } from "./common/filters/typeorm-exception.filter";

@Module({
    providers: [
        {
            provide: APP_FILTER,
            useClass: TypeOrmExceptionFilter,
        },
    ],
})
export class AppModule {}

10.5 在 Service 中主动处理特定错误

全局过滤器处理通用情况,Service 层可以针对业务场景进行更精细的控制:

async createUser(data: CreateUserDto) {
  try {
    const user = this.userRepo.create(data);
    return await this.userRepo.save(user);
  } catch (e) {
    if (e instanceof QueryFailedError) {
      const code = (e as any).driverError?.code;
      if (code === 'ER_DUP_ENTRY' || code === '23505') {
        throw new ConflictException('该邮箱已被注册,请更换邮箱');
      }
    }
    throw e; // 其他错误交由全局过滤器处理
  }
}

十一、总结

通过本文,我们完成了在 NestJS 中集成 TypeORM 的生产级完整流程:

环节关键点
项目结构data-source.ts 独立存在,供 CLI 使用;DatabaseModule 负责 NestJS 集成
tsconfigexperimentalDecorators + emitDecoratorMetadata 是 Entity 装饰器的必要前提
DataSource 配置forRootAsync + ConfigService 异步读取,autoLoadEntities 自动注册 Entity
生产配置synchronize: falselogging: ['error', 'warn']maxQueryExecutionTime 慢查询告警
日志整合实现 TypeORM Logger 接口,对接 NestJS Logger,按环境分级输出
BaseEntity集中管理 id、时间戳、软删除,避免重复代码
迁移生产环境只用 migration:run,开发用 migration:generate + migration:run
Repository@InjectRepository(Entity) 注入,save 触发钩子,update 不触发
事务简单场景用 DataSource.transaction,复杂流程用 QueryRunnerfinally 必须 release()
错误处理QueryFailedError 处理数据库约束错误,EntityNotFoundError 映射 404,全局过滤器统一收口

TypeORM vs Prisma 选型建议

  • 选 TypeORM:团队熟悉 JPA/Hibernate/EF 风格;需要复杂 QueryBuilder;项目已有大量 TypeORM Entity;偏好装饰器驱动的代码组织
  • 选 Prisma:重视类型安全和自动补全;Schema-first 开发流程;对 SQL 细节不要求完全控制;新项目绿地开发

两者在 NestJS 生态中都有成熟的支持,根据团队偏好和项目需求选择即可。

[/hide]

12 NestJS 集成 Prisma ORM 完全指南(Prisma v7)

作者 木灵鱼儿
2026年8月16日 04:51

前言

Prisma 是 Node.js 和 TypeScript 生态中最受欢迎的 ORM 之一。相比 TypeORM,它提供了更强的类型安全性、更直观的 Schema 语法,以及自动生成的迁移文件。Prisma v7 已全面转向 ES Module,而 NestJS 默认使用 CommonJS,因此需要做一些特殊处理——本文会详细说明。

本文基于 Prisma v7NestJS 最新版,从安装到生产级集成,覆盖以下内容:

  • 正确安装配置 Prisma,与 ConfigService 和日志系统整合
  • 使用 prisma.config.ts 统一管理 schema、迁移、seed 与数据源配置
  • 生产级项目目录结构设计
  • 定义数据模型,生成和执行迁移
  • 常用 CLI 命令详解(包括迁移解决和回滚)
  • Prisma 完整错误类型与处理策略
  • 生产级全局异常过滤器实现
本文假设你已有一个使用 @nestjs/config 的 NestJS 项目。如需了解 ConfigModule 的配置,请参考本系列第 01 篇。

[hide]

一、生产级项目目录结构

在集成 Prisma 之前,先规划一个符合业界最佳实践的目录结构:

project-root/
├── prisma/
│   ├── schema.prisma           # 数据模型定义
│   ├── seed.ts                 # 数据库种子文件
│   └── migrations/             # 迁移文件目录(自动生成)
│       └── 20240101000000_init/
│           └── migration.sql
├── prisma.config.ts            # Prisma CLI 配置(v7 新增)
├── src/
│   ├── common/                 # 通用模块
│   │   ├── filters/            # 全局过滤器
│   │   │   └── prisma-exception.filter.ts
│   │   ├── interceptors/       # 全局拦截器
│   │   └── guards/             # 全局守卫
│   ├── config/                 # 配置相关
│   │   └── database.config.ts  # 数据库配置
│   ├── database/               # 数据库基础设施层
│   │   ├── prisma.service.ts   # Prisma 服务
│   │   ├── prisma.module.ts    # Prisma 模块
│   │   └── utils/
│   │       └── prisma-error.util.ts  # Prisma 错误判断工具
│   ├── modules/                # 业务模块
│   │   ├── user/
│   │   │   ├── user.module.ts
│   │   │   ├── user.service.ts
│   │   │   ├── user.controller.ts
│   │   │   └── dto/
│   │   └── post/
│   ├── generated/              # Prisma 生成的客户端(.gitignore)
│   │   └── prisma/
│   ├── app.module.ts
│   └── main.ts
├── .env                        # 环境变量
├── .env.example                # 环境变量示例
├── .gitignore
├── package.json
└── tsconfig.json

核心设计原则

  1. 关注点分离:数据库基础设施(database/)与业务逻辑(modules/)分离
  2. 可维护性:通用功能(过滤器、拦截器)集中在 common/ 目录
  3. 安全性:生成的代码(generated/)和环境变量(.env)不提交到版本控制
  4. 可测试性:清晰的模块划分便于单元测试和集成测试

二、安装与初始化配置

2.1 安装依赖

首先安装 Prisma CLI(开发依赖)和运行时所需的包:

# Prisma CLI(仅开发阶段使用)
pnpm add prisma --save-dev

# Prisma 客户端 + PostgreSQL 驱动适配器
pnpm add @prisma/client @prisma/adapter-pg pg

# Prisma Config 加载 .env,以及执行 TypeScript seed 脚本
pnpm add dotenv
pnpm add -D tsx

pnpm add -D @types/pg

数据库驱动说明:Prisma v7 采用驱动适配器(Driver Adapter)架构,不同数据库需安装对应包:

数据库驱动适配器包
PostgreSQL@prisma/adapter-pg + pg
MySQL@prisma/adapter-mysql2 + mysql2
SQLite@prisma/adapter-better-sqlite3 + better-sqlite3
SQL Server@prisma/adapter-mssql + mssql

2.2 初始化 Prisma

在项目根目录运行以下命令,将 Prisma 客户端的生成路径指定到 src 目录内:

npx prisma init --output ../src/generated/prisma

命令执行后会生成以下文件:

prisma/
└── schema.prisma      # 数据库 schema 定义
prisma.config.ts       # Prisma 项目配置(v7 新增,默认在项目根目录)
.env                   # 数据库连接字符串
src/
└── generated/
    └── prisma/        # 生成的 Prisma 客户端(勿手动修改)

将生成的客户端目录加入 .gitignore

# .gitignore
src/generated/

2.3 配置 schema.prisma

打开 prisma/schema.prisma,按以下方式配置生成器和数据源:

// prisma/schema.prisma

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  // 关键:Prisma v7 默认生成 ESM,NestJS 使用 CommonJS,必须指定 cjs
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
为什么需要 moduleFormat = "cjs":Prisma v7 默认以 ESM 格式输出客户端代码,而 NestJS 项目默认是 CommonJS 模块系统,两者不兼容。显式设置 moduleFormat = "cjs" 即可解决。

三、配置 prisma.config.ts

Prisma v7 新增了 prisma.config.ts,用于统一管理 Prisma CLI 的项目级配置。prisma init 会默认在项目根目录生成该文件,CLI 命令会自动读取它,因此 schema 路径、迁移目录、seed 命令和数据源地址都应集中写在这里。

在项目根目录创建或修改 prisma.config.ts

// 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"),
    },
});

配置项说明:

配置项说明
schemaPrisma schema 文件路径,也可以指向包含多个 .prisma 文件的目录
migrations.path迁移文件目录,Prisma Migrate 会在这里读取和生成迁移
migrations.seed执行 prisma db seed 时运行的命令,v7 推荐写在这里
datasource.url数据库连接地址,通常通过 env("DATABASE_URL") 从环境变量读取
datasource.shadowDatabaseUrl可选,Prisma Migrate 使用的 shadow database 地址,云数据库场景常见
typedSql.path可选,TypedSQL SQL 文件目录
views.path可选,数据库视图 SQL 定义目录

几个需要注意的细节:

  1. 环境变量需要显式加载:Node.js 项目推荐在文件顶部写 import "dotenv/config";,否则 Prisma CLI 加载配置时不一定能读取 .env对于环境变量的用法,建议参考《01 NestJS 环境变量与配置管理(Config 模块)》文章的处理方式,这里只是简单使用
  2. 路径相对配置文件解析schemamigrations.path 等相对路径都以 prisma.config.ts 所在目录为基准,而不是以执行命令时的当前目录为基准。
  3. env() 会强校验变量存在env("DATABASE_URL") 在变量缺失时会直接报错。如果 CI 中只运行 prisma generate 且没有数据库地址,可以改用 process.env.DATABASE_URL ?? ""
  4. seed 不再写入 package.json:旧版本常见的 package.jsonprisma.seed 写法,在 v7 中应迁移到 migrations.seed

如果项目较大,也可以使用官方支持的 .config/prisma.ts 作为配置文件位置;小型或常规 NestJS 项目直接使用根目录 prisma.config.ts 最清晰。


四、与 ConfigService 整合

4.1 环境变量配置

.env 文件中设置数据库连接字符串:

# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

确保 AppModule 中已注册 ConfigModule(参考本系列第 01 篇),这样 .env 才能被正确加载:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

@Module({
    imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        // ...其他模块
    ],
})
export class AppModule {}

4.2 创建 PrismaService

src/database 目录下创建 prisma.service.ts,通过 ConfigService 读取数据库连接字符串:

// src/database/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
    private readonly logger = new Logger(PrismaService.name);

    constructor(private readonly configService: ConfigService) {
        const adapter = new PrismaPg({
            connectionString: configService.get<string>("DATABASE_URL"),
        });
        super({ adapter });
    }

    async onModuleInit(): Promise<void> {
        await this.$connect();
        this.logger.log("Database connection established");
    }

    async onModuleDestroy(): Promise<void> {
        await this.$disconnect();
        this.logger.log("Database connection closed");
    }
}

关于生命周期钩子的说明

  • OnModuleInit:NestJS 模块初始化完成后调用,此时依赖注入已完成,是建立数据库连接的最佳时机
  • OnModuleDestroy:应用关闭前调用,确保数据库连接被优雅地关闭,避免连接泄漏
  • 为什么需要显式连接:虽然 Prisma Client 支持延迟连接(首次查询时自动连接),但在 NestJS 中显式管理连接有以下优势:

    1. 启动时故障快速发现:如果数据库不可达,应用启动时立即报错,而不是等到第一次查询
    2. 优雅关闭:应用关闭时正确释放数据库连接,避免连接池耗尽
    3. 健康检查:便于实现应用健康检查端点(Health Check)

五、与日志系统整合

Prisma 支持将内部查询日志、警告等事件通过 NestJS Logger 输出,便于开发调试和生产监控。完善的日志配置应当:

  1. 开发环境:输出详细的 SQL 查询语句、参数和执行时间
  2. 生产环境:只记录警告和错误,避免敏感数据泄露和性能开销
  3. 结构化日志:使用 NestJS Logger 统一日志格式,便于集中式日志收集

修改 PrismaService,实现环境感知的日志配置:

// src/database/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
    private readonly logger = new Logger(PrismaService.name);

    constructor(private readonly configService: ConfigService) {
        const adapter = new PrismaPg({
            connectionString: configService.get<string>("DATABASE_URL"),
        });

        const isProduction = configService.get<string>("NODE_ENV") === "production";

        super({
            adapter,
            // 根据环境配置日志级别
            log: isProduction
                ? [
                        // 生产环境:只记录警告和错误
                        { emit: "event", level: "warn" },
                        { emit: "event", level: "error" },
                    ]
                : [
                        // 开发环境:记录查询、警告和错误
                        { emit: "event", level: "query" },
                        { emit: "event", level: "warn" },
                        { emit: "event", level: "error" },
                        { emit: "stdout", level: "info" },
                    ],
        });
    }

    async onModuleInit(): Promise<void> {
        // 注册日志事件监听器
        const isProduction = process.env.NODE_ENV === "production";

        // 开发环境下记录 SQL 查询详情
        if (!isProduction) {
            this.$on("query" as never, (e: any) => {
                this.logger.debug(`Query: ${e.query}`);
                this.logger.debug(`Params: ${e.params}`);
                this.logger.debug(`Duration: ${e.duration}ms`);
            });
        }

        // 所有环境都记录警告和错误
        this.$on("warn" as never, (e: any) => {
            this.logger.warn(e.message);
        });

        this.$on("error" as never, (e: any) => {
            this.logger.error(e.message);
        });

        // 建立数据库连接
        await this.$connect();
        this.logger.log("Database connection established");
    }

    async onModuleDestroy(): Promise<void> {
        await this.$disconnect();
        this.logger.log("Database connection closed");
    }
}

生产环境日志最佳实践

日志级别开发环境生产环境原因
querySQL 可能包含敏感数据(如用户输入),且影响性能
info一般信息,可选
warn需要关注的潜在问题
error必须记录的错误信息

日志输出示例(开发环境):

[PrismaService] Database connection established
[PrismaService] Query: SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = $1 LIMIT $2
[PrismaService] Params: [1, 1]
[PrismaService] Duration: 12ms

六、定义数据模型与数据库迁移

6.1 数据模型定义

prisma/schema.prisma 中添加数据模型(model 块):

// prisma/schema.prisma

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  role      Role      @default(USER)
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  posts     Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
}

enum Role {
  USER
  ADMIN
}

6.2 常用字段修饰符速览

修饰符说明示例
@id主键id Int @id
@default(...)默认值@default(autoincrement()) / @default(now()) / @default(uuid())
@unique唯一约束email String @unique
@updatedAt更新时自动填充当前时间updatedAt DateTime @updatedAt
@relation定义外键关联@relation(fields: [authorId], references: [id])
@map映射到数据库列名createdAt DateTime @map("created_at")
@@map映射到数据库表名@@map("users")
@@index添加索引@@index([email, name])
?可空字段name String?

6.3 常用 CLI 命令完全指南

prisma migrate dev — 开发环境迁移(最常用)

npx prisma migrate dev --name <迁移名称>

功能

  • 将 schema 变更转换为 SQL 迁移文件
  • 自动执行迁移
  • 自动运行 prisma generate 更新客户端类型
  • 仅用于开发环境,会重置开发数据库

常见用法

# 初始化迁移
npx prisma migrate dev --name init

# 添加新字段后执行
npx prisma migrate dev --name add-user-avatar

# 创建迁移但不执行(预览 SQL)
npx prisma migrate dev --create-only --name add-index

prisma migrate deploy — 生产环境迁移

npx prisma migrate deploy

功能

  • 执行所有待执行的迁移(不生成新迁移,不重置数据)
  • 用于 CI/CD 和生产环境,安全且不具破坏性
  • 只能向前迁移,不能回滚

使用场景

  • 生产环境部署
  • CI/CD 流水线中的自动化部署
  • 预生产环境(Staging)数据库更新
# 典型 CI/CD 用法
npx prisma migrate deploy
npx prisma generate

prisma migrate resolve — 解决迁移问题

npx prisma migrate resolve --applied <迁移名称>
npx prisma migrate resolve --rolled-back <迁移名称>

功能

  • 手动标记迁移状态,用于修复迁移历史记录
  • 不实际执行或回滚 SQL,只更新 _prisma_migrations 表中的记录

使用场景

  1. 标记已应用的迁移(当迁移已手动执行但未记录):
npx prisma migrate resolve --applied "20240101000000_init"
  1. 标记已回滚的迁移(当迁移失败需要重新应用):
npx prisma migrate resolve --rolled-back "20240101000000_failed_migration"

典型故障恢复流程

# 1. 假设迁移失败,先手动修复数据库
# 2. 标记失败的迁移为已回滚
npx prisma migrate resolve --rolled-back "20240101000000_failed_migration"

# 3. 修复迁移 SQL 文件
# 4. 重新执行迁移
npx prisma migrate deploy

prisma migrate diff — 对比 schema 差异

npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datasource prisma/schema.prisma \
  --script

功能

  • 对比两个数据源(schema 文件、数据库、迁移目录)之间的差异
  • 输出 SQL 脚本或 JSON 格式的差异

常见用法

# 查看 schema 与数据库的差异(生成 SQL 脚本)
npx prisma migrate diff \
  --from-schema-datasource prisma/schema.prisma \
  --to-url "postgresql://user:pass@localhost:5432/mydb" \
  --script

# 对比两个数据库
npx prisma migrate diff \
  --from-url "postgresql://user:pass@localhost:5432/dev" \
  --to-url "postgresql://user:pass@localhost:5432/prod" \
  --script

# 输出 JSON 格式的差异
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datasource prisma/schema.prisma

prisma migrate status — 查看迁移状态

npx prisma migrate status

功能

  • 显示所有迁移的执行状态
  • 检测是否有待执行的迁移
  • 检测 schema 与数据库是否同步

输出示例

Database schema is up to date!

Migrations:
  ✓ 20240101000000_init
  ✓ 20240102000000_add_user_avatar
  ✗ 20240103000000_add_posts (pending)

prisma db push — 快速原型开发

npx prisma db push

功能

  • 直接将 schema 同步到数据库,不生成迁移文件
  • 适合快速原型开发或实验性 schema 变更
  • 不推荐在有生产数据的库上使用

对比 migrate dev

特性db pushmigrate dev
生成迁移文件
版本控制
数据保留尽力保留,不保证保证(通过迁移 SQL)
使用场景原型开发、实验正式开发、团队协作

prisma generate — 生成/更新客户端

npx prisma generate

功能

  • 根据 schema 生成 TypeScript 类型和查询客户端
  • 修改 schema 后必须运行此命令

自动触发场景

  • 运行 prisma migrate dev 时自动执行
  • 不会prisma migrate deploy 时自动执行(需手动运行)

prisma studio — 可视化数据库管理

npx prisma studio

功能

  • 在浏览器中打开图形化界面(默认 http://localhost:5555)
  • 可查看和编辑数据库数据
  • 仅用于开发调试

prisma migrate reset — 重置开发数据库

npx prisma migrate reset

功能

  • 删除数据库,重新执行所有迁移(危险操作,会清空所有数据
  • 自动运行 seed 脚本(如果配置了)
  • 仅用于开发环境

使用场景

  • 开发环境迁移历史混乱,需要重新开始
  • 测试数据污染,需要恢复到初始状态

prisma db seed — 填充种子数据

Prisma v7 的 seed 命令配置写在 prisma.config.tsmigrations.seed 中,不再写 package.json

// 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"),
    },
});
npx prisma db seed

执行 prisma migrate reset 时,如果已配置 migrations.seed,Prisma 也会在重置后自动运行 seed 脚本。

种子文件示例

// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";

const prisma = new PrismaClient();

async function main() {
    // 创建测试用户
    await prisma.user.upsert({
        where: { email: "admin@example.com" },
        update: {},
        create: {
            email: "admin@example.com",
            name: "Admin User",
            role: "ADMIN",
        },
    });

    console.log("Seed data created successfully");
}

main()
    .catch((e) => {
        console.error(e);
        process.exit(1);
    })
    .finally(async () => {
        await prisma.$disconnect();
    });

prisma validate — 验证 schema 语法

npx prisma validate

功能

  • 检查 schema 文件的语法错误
  • 验证模型关系、字段类型等是否正确
  • 不连接数据库,纯静态检查

使用场景

  • CI/CD 中的代码质量检查
  • 提交前验证 schema 修改

prisma format — 格式化 schema 文件

npx prisma format

功能

  • 自动格式化 schema 文件(对齐、排序)
  • 确保团队代码风格一致

prisma db pull — 从数据库反向生成 schema

npx prisma db pull

功能

  • 内省(introspection)现有数据库,生成 Prisma schema
  • 用于将现有数据库迁移到 Prisma

使用场景

  • 将遗留项目迁移到 Prisma
  • 从其他 ORM 迁移到 Prisma

6.4 CLI 命令速查表

命令用途环境修改数据库生成迁移文件
migrate dev开发环境迁移开发
migrate deploy生产环境迁移生产/CI
migrate resolve手动修复迁移状态所有
migrate diff对比差异所有
migrate status查看迁移状态所有
migrate reset重置数据库开发✅(危险)
db push快速同步 schema开发
db pull从数据库生成 schema所有
db seed填充种子数据开发
generate生成客户端所有
studio打开可视化界面开发
validate验证 schema 语法所有
format格式化 schema所有

七、注册 PrismaService

创建 PrismaModule 并将其设为全局模块,避免在每个功能模块中重复导入:

// src/database/prisma.module.ts
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";

@Global()
@Module({
    providers: [PrismaService],
    exports: [PrismaService],
})
export class PrismaModule {}

AppModule 中注册:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./database/prisma.module";

@Module({
    imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        // ...其他业务模块
    ],
})
export class AppModule {}

八、Prisma 错误类型与处理

8.1 Prisma 错误类型完整概览

Prisma 提供了五种主要错误类型,每种都有特定的触发场景和处理策略:

1. PrismaClientKnownRequestError

触发场景:数据库返回已知的、可预测的错误(如约束冲突)

属性

  • code:Prisma 特定错误码(如 P2002
  • meta:错误的额外信息(如冲突的字段名)
  • message:错误描述
  • clientVersion:Prisma Client 版本

常见错误码

错误码含义示例场景
P2002唯一约束冲突尝试插入重复的 email
P2003外键约束失败引用的关联记录不存在
P2025记录不存在更新或删除不存在的记录
P2014关系约束冲突删除有关联数据的记录
P2000字段值过长输入的字符串超过字段长度限制
P2011非空约束冲突必填字段为 null
P2024连接池超时并发连接数超过限制

2. PrismaClientUnknownRequestError

触发场景:数据库返回未知的、无错误码的错误

属性

  • message:错误描述
  • clientVersion:Prisma Client 版本

处理策略:记录完整错误信息,作为内部服务器错误处理


3. PrismaClientRustPanicError

触发场景:Prisma 查询引擎(Rust 编写)崩溃

属性

  • message:崩溃信息
  • clientVersion:Prisma Client 版本

处理策略

  • 这是严重的引擎错误,需要重启整个 Node.js 进程
  • 记录完整堆栈信息并上报监控系统
  • 在 NestJS 中,可以通过进程管理器(如 PM2)自动重启

4. PrismaClientInitializationError

触发场景:数据库连接初始化失败

常见原因

  • 数据库连接字符串错误
  • 数据库服务器不可达
  • 网络问题
  • 环境变量缺失
  • 查询引擎二进制文件缺失

属性

  • errorCode:错误码(如 P1001P1002
  • message:错误描述
  • clientVersion:Prisma Client 版本

常见错误码

错误码含义
P1000数据库认证失败
P1001无法连接到数据库服务器
P1002数据库连接超时
P1003数据库不存在
P1008操作超时
P1017服务器关闭了连接

5. PrismaClientValidationError

触发场景:Prisma Client 查询参数验证失败(在发送到数据库之前)

常见原因

  • 必填字段缺失
  • 字段类型错误
  • 查询参数格式错误

属性

  • message:验证错误详情
  • clientVersion:Prisma Client 版本

示例

// 错误:缺少必填字段 email
await prisma.user.create({
    data: {
        name: "John",
        // email 字段缺失
    },
});
// 抛出 PrismaClientValidationError

8.2 错误码分类与完整列表

通用错误(P1xxx)- 连接和认证

错误码说明
P1000数据库认证失败
P1001无法连接到数据库服务器
P1002数据库连接超时
P1003数据库不存在
P1008操作超时
P1009数据库已存在
P1010用户访问被拒绝
P1011TLS 连接错误
P1012Schema 验证错误
P1013数据库连接字符串无效
P1014模型对应的表不存在
P1015数据库版本不支持当前特性
P1016原始查询参数数量不匹配
P1017服务器关闭了连接

查询引擎错误(P2xxx)- 数据操作

错误码说明
P2000字段值过长
P2001记录不存在
P2002唯一约束冲突
P2003外键约束失败
P2004数据库约束失败
P2005字段值类型无效
P2006提供的字段值无效
P2007数据验证错误
P2008查询解析失败
P2009查询验证失败
P2010原始查询失败
P2011非空约束冲突
P2012缺少必填值
P2013缺少必填参数
P2014关系约束冲突
P2015找不到关联记录
P2016查询解释错误
P2017关系记录未连接
P2018找不到必需的关联记录
P2019输入错误
P2020值超出范围
P2021表不存在
P2022列不存在
P2023列数据不一致
P2024连接池超时
P2025操作依赖的记录不存在
P2026数据库不支持该特性
P2027多个错误
P2028事务 API 错误
P2029查询参数超限
P2030找不到全文索引
P2031MongoDB 需要副本集
P2033数字超出 64 位整数范围
P2034事务冲突或死锁
P2035数据库断言失败
P2036外部连接器错误
P2037数据库连接过多

迁移引擎错误(P3xxx)- 迁移操作

错误码说明
P3000创建数据库失败
P3001迁移可能导致数据丢失
P3002迁移回滚
P3003迁移格式已更改
P3004系统数据库不应被更改
P3005数据库 schema 不为空
P3006迁移未能应用到影子数据库
P3008迁移已被标记为已应用
P3009发现失败的迁移
P3010迁移名称过长
P3011迁移无法回滚(未应用)
P3012迁移无法回滚(未失败)
P3014无法创建影子数据库
P3015找不到迁移文件
P3017找不到迁移
P3018迁移应用失败
P3019数据源提供者不匹配
P3020Azure SQL 禁用影子数据库

内省错误(P4xxx)- Schema 拉取

错误码说明
P4000内省操作失败
P4001数据库为空
P4002数据库 schema 不一致

8.3 创建 Prisma 错误判断工具

由于 Prisma 没有提供统一的错误基类,我们需要创建一个工具函数来判断是否为 Prisma 错误:

// src/database/utils/prisma-error.util.ts
import { Prisma } from "../../generated/prisma/client";

/**
 * 判断是否为 Prisma 错误
 */
export function isPrismaError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
    return (
        error instanceof Prisma.PrismaClientKnownRequestError ||
        error instanceof Prisma.PrismaClientUnknownRequestError ||
        error instanceof Prisma.PrismaClientRustPanicError ||
        error instanceof Prisma.PrismaClientInitializationError ||
        error instanceof Prisma.PrismaClientValidationError
    );
}

/**
 * 判断是否为特定错误码的 Prisma 错误
 */
export function isPrismaErrorWithCode(
    error: unknown,
    code: string,
): error is Prisma.PrismaClientKnownRequestError {
    return error instanceof Prisma.PrismaClientKnownRequestError && error.code === code;
}

/**
 * 获取 Prisma 错误的友好消息
 */
export function getPrismaErrorMessage(error: unknown): string {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
        return `数据库操作失败: ${error.code} - ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientValidationError) {
        return `数据验证失败: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientInitializationError) {
        return `数据库连接失败: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientRustPanicError) {
        return `数据库引擎崩溃: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientUnknownRequestError) {
        return `未知数据库错误: ${error.message}`;
    }
    return "未知错误";
}

/**
 * 提取唯一约束冲突的字段名
 */
export function extractUniqueConstraintFields(
    error: Prisma.PrismaClientKnownRequestError,
): string[] {
    if (error.code === "P2002" && error.meta?.target) {
        return Array.isArray(error.meta.target) ? error.meta.target : [error.meta.target as string];
    }
    return [];
}

8.4 生产级全局异常过滤器

创建一个完善的全局过滤器,处理所有 Prisma 错误类型:

// src/common/filters/prisma-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from "@nestjs/common";
import { Response } from "express";
import { Prisma } from "../../generated/prisma/client";
import {
    isPrismaError,
    getPrismaErrorMessage,
    extractUniqueConstraintFields,
} from "../../database/utils/prisma-error.util";

/**
 * Prisma 全局异常过滤器
 * 捕获所有 Prisma 相关错误并转换为标准 HTTP 响应
 */
@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(PrismaExceptionFilter.name);

    catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();

        // 仅处理 Prisma 错误,其他错误继续抛出
        if (!isPrismaError(exception)) {
            throw exception;
        }

        // 处理已知的数据库请求错误
        if (exception instanceof Prisma.PrismaClientKnownRequestError) {
            this.handleKnownRequestError(exception, response);
            return;
        }

        // 处理验证错误
        if (exception instanceof Prisma.PrismaClientValidationError) {
            this.handleValidationError(exception, response);
            return;
        }

        // 处理初始化错误
        if (exception instanceof Prisma.PrismaClientInitializationError) {
            this.handleInitializationError(exception, response);
            return;
        }

        // 处理引擎崩溃
        if (exception instanceof Prisma.PrismaClientRustPanicError) {
            this.handleRustPanicError(exception, response);
            return;
        }

        // 处理未知请求错误
        if (exception instanceof Prisma.PrismaClientUnknownRequestError) {
            this.handleUnknownRequestError(exception, response);
            return;
        }
    }

    /**
     * 处理已知的数据库请求错误
     */
    private handleKnownRequestError(
        exception: Prisma.PrismaClientKnownRequestError,
        response: Response,
    ) {
        this.logger.warn(`Prisma Known Error [${exception.code}]: ${exception.message}`);

        const { code, meta } = exception;

        switch (code) {
            case "P2002": {
                // 唯一约束冲突
                const fields = extractUniqueConstraintFields(exception);
                const fieldNames = fields.length > 0 ? fields.join(", ") : "字段";
                response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: `${fieldNames} 已存在,请使用其他值`,
                    code,
                });
                break;
            }

            case "P2025": {
                // 记录不存在
                response.status(HttpStatus.NOT_FOUND).json({
                    statusCode: HttpStatus.NOT_FOUND,
                    error: "Not Found",
                    message: "请求的记录不存在",
                    code,
                });
                break;
            }

            case "P2003": {
                // 外键约束失败
                const field = meta?.field_name || "关联字段";
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `关联的记录不存在,外键约束失败: ${field}`,
                    code,
                });
                break;
            }

            case "P2014": {
                // 关系约束冲突
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "无法删除或修改,存在关联数据",
                    code,
                });
                break;
            }

            case "P2000": {
                // 字段值过长
                const column = meta?.column_name || "字段";
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `${column} 的值过长,超过字段长度限制`,
                    code,
                });
                break;
            }

            case "P2011": {
                // 非空约束冲突
                const constraint = meta?.constraint || "必填字段";
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `${constraint} 不能为空`,
                    code,
                });
                break;
            }

            case "P2024": {
                // 连接池超时
                response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
                    statusCode: HttpStatus.SERVICE_UNAVAILABLE,
                    error: "Service Unavailable",
                    message: "数据库连接池超时,请稍后重试",
                    code,
                });
                break;
            }

            case "P2034": {
                // 事务冲突或死锁
                response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "事务冲突,请重试",
                    code,
                });
                break;
            }

            default: {
                // 其他未明确处理的错误
                this.logger.error(`Unhandled Prisma error code: ${code}`);
                response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
                    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
                    error: "Internal Server Error",
                    message: "数据库操作失败",
                    code,
                });
            }
        }
    }

    /**
     * 处理参数验证错误
     */
    private handleValidationError(exception: Prisma.PrismaClientValidationError, response: Response) {
        this.logger.warn(`Prisma Validation Error: ${exception.message}`);

        response.status(HttpStatus.BAD_REQUEST).json({
            statusCode: HttpStatus.BAD_REQUEST,
            error: "Bad Request",
            message: "请求参数格式错误或缺少必填字段",
        });
    }

    /**
     * 处理初始化错误(数据库连接失败)
     */
    private handleInitializationError(
        exception: Prisma.PrismaClientInitializationError,
        response: Response,
    ) {
        this.logger.error(`Prisma Initialization Error: ${exception.message}`, exception.stack);

        response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
            statusCode: HttpStatus.SERVICE_UNAVAILABLE,
            error: "Service Unavailable",
            message: "数据库服务暂时不可用,请稍后重试",
        });
    }

    /**
     * 处理引擎崩溃(严重错误,需要重启)
     */
    private handleRustPanicError(exception: Prisma.PrismaClientRustPanicError, response: Response) {
        this.logger.fatal(`Prisma Rust Panic Error: ${exception.message}`, exception.stack);

        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "服务器内部错误,请联系管理员",
        });

        // 在生产环境中,这里应该触发进程重启
        // 如使用 PM2:process.exit(1)
    }

    /**
     * 处理未知请求错误
     */
    private handleUnknownRequestError(
        exception: Prisma.PrismaClientUnknownRequestError,
        response: Response,
    ) {
        this.logger.error(`Prisma Unknown Request Error: ${exception.message}`, exception.stack);

        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "数据库操作失败",
        });
    }
}

8.5 注册全局异常过滤器

有两种方式注册全局过滤器:

方式一:在 main.ts 中注册(推荐)

// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // 注册 Prisma 全局异常过滤器
    app.useGlobalFilters(new PrismaExceptionFilter());

    await app.listen(3000);
}
bootstrap();

优点:简单直接,适合不需要依赖注入的过滤器

缺点:过滤器无法使用依赖注入(如注入 ConfigService


方式二:在 AppModule 中注册(支持依赖注入)

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./database/prisma.module";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

@Module({
    imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        // ...其他模块
    ],
    providers: [
        // 注册全局过滤器(支持依赖注入)
        {
            provide: APP_FILTER,
            useClass: PrismaExceptionFilter,
        },
    ],
})
export class AppModule {}

优点:过滤器可以使用依赖注入,访问其他服务

缺点:稍微复杂一些

如果需要在过滤器中使用依赖注入,例如注入 ConfigService 来区分环境:

// src/common/filters/prisma-exception.filter.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

@Injectable() // 添加 @Injectable 装饰器
@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
    constructor(private readonly configService: ConfigService) {}

    catch(exception: unknown, host: ArgumentsHost) {
        const isProduction = this.configService.get("NODE_ENV") === "production";

        // 在生产环境中隐藏详细错误信息
        if (isProduction) {
            // 返回简化的错误信息
        }
        // ...
    }
}

8.6 在 Service 中主动处理特定错误

对于需要业务定制的场景,也可以在 Service 层直接捕获:

// src/modules/user/user.service.ts
import { Injectable, ConflictException, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { Prisma } from "../../generated/prisma/client";
import { isPrismaErrorWithCode } from "../../database/utils/prisma-error.util";

@Injectable()
export class UserService {
    constructor(private readonly prisma: PrismaService) {}

    async create(data: Prisma.UserCreateInput) {
        try {
            return await this.prisma.user.create({ data });
        } catch (e) {
            // 处理特定的唯一约束冲突
            if (isPrismaErrorWithCode(e, "P2002")) {
                throw new ConflictException("该邮箱已被注册");
            }
            throw e; // 其余错误交由全局过滤器处理
        }
    }

    async findOneOrFail(id: number) {
        const user = await this.prisma.user.findUnique({ where: { id } });
        if (!user) {
            throw new NotFoundException(`用户 #${id} 不存在`);
        }
        return user;
    }

    async remove(id: number) {
        try {
            return await this.prisma.user.delete({ where: { id } });
        } catch (e) {
            // P2025: 记录不存在
            if (isPrismaErrorWithCode(e, "P2025")) {
                throw new NotFoundException(`用户 #${id} 不存在`);
            }
            // P2014: 有关联数据,无法删除
            if (isPrismaErrorWithCode(e, "P2014")) {
                throw new ConflictException("该用户有关联数据,无法删除");
            }
            throw e;
        }
    }
}

九、总结

通过本文,我们完成了在 NestJS 中集成 Prisma v7 的生产级完整流程:

环节关键点
项目结构数据库基础设施(database/)与业务逻辑(modules/)分离
安装配置moduleFormat = "cjs" 解决 ESM/CJS 兼容问题
ConfigService 整合在 constructor 参数中直接使用 config.get(),无需等待 super()
生命周期管理OnModuleInit 显式建立连接,OnModuleDestroy 优雅关闭,支持启动时故障检测
日志整合环境感知的日志配置:开发环境详细日志,生产环境仅警告和错误
数据库迁移开发用 migrate dev,生产用 migrate deploy,故障用 migrate resolve
CLI 命令完整的迁移、回滚、对比、验证等命令,支持复杂的生产环境需求
全局模块@Global() + PrismaModule 避免重复导入
错误处理五种错误类型全覆盖,工具函数辅助判断,全局过滤器统一处理
过滤器注册支持 main.ts 简单注册和 AppModule 依赖注入两种方式

[/hide]

11 NestJS 注册与登录接口的密码安全设计

作者 木灵鱼儿
2026年8月16日 01:34

前言

在上一篇《10 NestJS JWT 身份验证完全指南》中,我们完整实现了基于 JWT 的身份验证体系,其中已涉及 Argon2id 哈希和时序攻击防护。但密码安全不止于此 —— 注册和登录接口是攻击者最频繁的目标,一旦设计失误,后果往往是大规模账号泄露。

本文聚焦于密码在整个生命周期(注册 → 存储 → 验证 → 重置)中的威胁模型和防护实践,所有代码示例均为生产级实现,可直接集成到已有项目中。

阅读本文需要完成《10 NestJS JWT 身份验证完全指南》的实践,或具备等同的 NestJS 认证体系基础。


[hide]

第一部分:威胁模型

在写代码前,先明确我们要对抗的攻击类型。不理解攻击原理,就无法评估防御是否有效。

1.1 彩虹表攻击(Rainbow Table Attack)

攻击原理:

攻击者预先计算大量明文密码的哈希值,建成一张"哈希 → 明文"的查找表(即彩虹表)。一旦获取数据库中的哈希值,只需查表就能反查出原始密码,无需逆向哈希算法本身。

攻击流程:
1. 攻击者拖库,获得 user 表中的 password_hash 字段
2. 对比彩虹表:e10adc3949ba59abbe56e057f20f883e → 123456
3. 无需暴力破解,直接得到明文密码

为什么彩虹表能奏效?

因为哈希是确定性的:同一个输入永远产生同一个输出。MD5("123456") 永远是 e10adc3...,全球所有使用 MD5 存储 123456 的系统哈希值都一样。彩虹表一次构建,处处可用。

防御:加盐(Salt)

盐是一段随机字符串,在哈希前拼接到密码上:

hash("123456")              → e10adc3...(可查彩虹表)
hash("123456" + "x7k9mQ2p") → a3f8c1...(彩虹表中没有这条记录)

每个用户的盐都不同,即便两个用户的密码相同,哈希值也不同,彩虹表完全失效。

Argon2id 自动处理盐: 调用 hash() 时,库会在内部生成随机盐并嵌入哈希结果字符串中,验证时自动提取——你无需手动管理盐。

解释:

  1. 通过加盐的方式,可以让相同的密码在不同用户之间产生不同的哈希值,从而防止彩虹表攻击(1:1对照成为不可能)。
  2. 即便攻击者获取了数据库中的哈希值,从哈希中解析出盐 (盐(Salt)从来就不是用来保密的,它的公开完全在设计预期之内)和密码的哈希值,也无法直接通过彩虹表反查出原始密码,它需要加上盐生成一份新的彩虹表,而每个用户的盐都不同(不设置固定Salt属性值情况下),攻击者无法为每个用户单独生成彩虹表,成本极高。
  3. 方式2上就已经不能称之为彩虹表攻击了,因为彩虹表是预先计算好的,而重新针对性生成一般都是暴力破解的方式。

1.2 字典攻击(Dictionary Attack)

攻击者不穷举所有组合,而是使用包含数百万常用密码的字典(RockYou、SecLists 等),逐一尝试。弱密码(123456passwordqwerty)在秒级内被破解。

防御: 密码强度策略 + 禁止使用已知弱密码(HIBP API)。

1.3 凭证填充攻击(Credential Stuffing)

其他网站泄露的"用户名 + 密码"组合,被自动化工具批量在你的系统上尝试登录。由于大量用户在多个网站使用相同密码,成功率远高于暴力破解。

2024 年已公开泄露:RockYou2024,含 100 亿条明文密码记录
这些记录被直接用于凭证填充

防御: 登录限流 + IP 封锁 + 异常行为检测 + 强制注册时使用未泄露密码(HIBP)。

1.4 暴力破解(Brute Force)

穷举所有可能的密码组合。现代 GPU 每秒可计算数十亿次 MD5,对于 8 位纯数字密码(10^8 = 1 亿种组合)只需数秒。

防御: 使用计算成本高的哈希算法(Argon2id)+ 账号锁定 + 登录限流。

1.5 时序攻击(Timing Attack)

通过精确测量操作响应时间,推断内部逻辑分支。例如:

  • 用户不存在:服务器 2ms 返回"用户名或密码错误"
  • 用户存在但密码错误:服务器 350ms 返回"用户名或密码错误"

攻击者遍历用户名,响应时间明显更长的表示该用户名已注册——这叫用户枚举(User Enumeration)

防御: 无论用户是否存在,都执行完整的 verify 操作,响应时间趋于一致。


第二部分:密码存储安全

2.1 哈希算法选型

先看清楚现状:

算法状态原因
MD5禁止使用非密码学安全哈希,GPU 每秒数十亿次,彩虹表完备
SHA-1/2禁止使用同上,设计用于速度,密码哈希恰恰需要"慢"
bcrypt可用老牌算法,自带盐,但内存需求低,GPU 并行破解较易
scrypt可用内存硬化,强于 bcrypt,但参数调优复杂
Argon2id推荐使用2015 密码哈希竞赛冠军,内存+CPU 双硬化,OWASP 2024 首选

为什么"慢"是优点: 你的服务器每次登录花 200ms 验证密码,用户感知不到差异;攻击者用 GPU 每秒尝试 10 亿次密码,却要等每次 200ms——直接把破解时间从数秒拉到数十年。

2.2 Argon2id 生产参数

OWASP Authentication Cheat Sheet(2024)推荐的最低参数:

// src/auth/utils/password.util.ts
import { hash, verify, Algorithm } from "@node-rs/argon2";

/**
 * OWASP 推荐参数(2024):
 * - memoryCost: 19456 (19 MiB) — 内存硬化,限制 GPU 并行
 * - timeCost: 2              — 迭代次数,增加 CPU 开销
 * - parallelism: 1           — 并行度,单核场景保持 1
 *
 * 生产环境建议在压测后适当提高 memoryCost(64 MiB 更佳),
 * 以系统单次 hash 耗时 300~500ms 为基准调整。
 */
const ARGON2_OPTIONS = {
    algorithm: Algorithm.Argon2id,
    memoryCost: 19456,
    timeCost: 2,
    parallelism: 1,
} as const;

export async function hashPassword(plain: string): Promise<string> {
    return hash(plain, ARGON2_OPTIONS);
}

export async function verifyPassword(hashed: string, plain: string): Promise<boolean> {
    return verify(hashed, plain);
}

2.3 渐进式参数升级

随着硬件性能提升,当前参数在几年后可能不够安全。生产系统需要支持在用户下次登录时静默升级哈希:

// src/auth/utils/password.util.ts(扩展)
import { needsRehash } from "@node-rs/argon2";

/**
 * 检测存储的哈希是否使用了旧参数,是则在验证通过后重新哈希。
 * needsRehash 通过解析哈希字符串中的参数段实现,无需明文密码。
 */
export function isHashOutdated(hashed: string): boolean {
    return needsRehash(hashed, ARGON2_OPTIONS);
}

AuthService.signIn 中,验证通过后检测并升级:

// src/auth/auth.service.ts(片段)
async signIn(username: string, plain: string): Promise<TokenPair> {
  const user = await this.usersService.findByUsername(username);

  // 防时序攻击:用户不存在时仍执行 verify,使响应时间趋于一致
  const passwordHash = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await verifyPassword(passwordHash, plain);

  if (!user || !isValid) {
    throw new UnauthorizedException("用户名或密码错误");
  }

  // 渐进式哈希升级:参数过时则用最新参数重新哈希,用户无感知
  if (isHashOutdated(user.passwordHash)) {
    const newHash = await hashPassword(plain);
    await this.usersService.updatePasswordHash(user.id, newHash);
  }

  return this.issueTokens(user);
}

// 占位哈希:与真实哈希耗时相当,防止时序差异
// 用 hashPassword("dummy") 在启动时预计算,而非硬编码
const DUMMY_HASH =
  "$argon2id$v=19$m=19456,t=2,p=1$placeholder$placeholder";

第三部分:注册接口安全设计

3.1 密码强度校验

仅靠 MinLength(8) 远远不够,需要结合熵值和字符多样性:

// src/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, Matches, IsNotEmpty } from "class-validator";

export class RegisterDto {
    @IsEmail()
    email: string;

    @IsString()
    @IsNotEmpty()
    @MinLength(3)
    @MaxLength(32)
    // 用户名:字母开头,只含字母数字下划线,禁止纯数字
    @Matches(/^[a-zA-Z][a-zA-Z0-9_]{2,31}$/, {
        message: "用户名须以字母开头,只含字母、数字和下划线",
    })
    username: string;

    @IsString()
    @MinLength(12, { message: "密码至少 12 位" })
    @MaxLength(128, { message: "密码不能超过 128 位" })
    password: string;
}
MaxLength(128):bcrypt 存在 72 字节截断问题;Argon2 无此限制,但设置上限防止超长输入导致的 DoS(故意提交 10MB 密码拖垮服务器)。

密码强度推荐使用 zxcvbn 库进行基于模式的评估,它能识别键盘走位(qwerty123)、字典词、日期、重复字符等攻击者真正会利用的弱密码模式,比纯正则校验更接近实际破解能力:

pnpm add zxcvbn
pnpm add -D @types/zxcvbn
// src/auth/utils/password-strength.util.ts
import zxcvbn from "zxcvbn";

export interface PasswordStrengthResult {
    valid: boolean;
    score: number; // 0-4,对应 zxcvbn 评分等级
    feedback: string[];
}

/**
 * 使用 zxcvbn 评估密码强度。
 * 评分说明:0-1 太弱(拒绝),2 一般,3 较强,4 非常强。
 * 生产建议要求 score >= 3。
 */
export function checkPasswordStrength(password: string): PasswordStrengthResult {
    const result = zxcvbn(password);
    const feedback: string[] = [];

    if (result.feedback.warning) {
        feedback.push(result.feedback.warning);
    }
    feedback.push(...result.feedback.suggestions);

    return {
        valid: result.score >= 3,
        score: result.score,
        feedback,
    };
}

zxcvbn 返回的 feedback.warning 是具体问题描述(如"这是一个常用密码"),feedback.suggestions 是改进建议,可以直接回传给前端展示。

3.2 集成 Have I Been Pwned(HIBP)检测

HIBP 收录了超过 130 亿条已泄露密码。注册时检测用户密码是否出现在历史泄露中,强制拒绝已知弱密码。

HIBP 使用 k-Anonymity 模型:客户端只发送密码 SHA-1 哈希的前 5 位,服务端返回所有匹配前缀的哈希后缀列表,本地比对——服务端永远不会看到完整密码哈希。

// src/auth/utils/hibp.util.ts
import { createHash } from "node:crypto";

/**
 * 使用 HIBP k-Anonymity API 检测密码是否在已知泄露数据库中。
 * 仅发送 SHA-1 的前 5 字节(10 位十六进制),不泄露完整密码。
 *
 * @returns 泄露次数,0 表示未泄露
 */
export async function checkPasswordPwned(password: string): Promise<number> {
    const sha1 = createHash("sha1").update(password).digest("hex").toUpperCase();
    const prefix = sha1.slice(0, 5);
    const suffix = sha1.slice(5);

    const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
        headers: {
            // 减少 NTLM 哈希传输开销(默认),此处使用 SHA-1 模式
            "Add-Padding": "true", // 启用填充,防止流量分析
        },
        signal: AbortSignal.timeout(3000), // 3 秒超时,防止外部接口拖慢注册
    });

    if (!response.ok) {
        // HIBP 不可用时静默降级,不阻断注册流程(可配置为严格模式)
        return 0;
    }

    const text = await response.text();
    // 响应格式:SUFFIX:COUNT\n...
    const line = text.split("\n").find((l) => l.startsWith(suffix));
    if (!line) return 0;

    return parseInt(line.split(":")[1], 10);
}

在注册服务中使用:

// src/auth/auth.service.ts(注册方法)
async register(dto: RegisterDto): Promise<void> {
  // 1. 密码强度
  const strength = checkPasswordStrength(dto.password);
  if (!strength.valid) {
    throw new BadRequestException({
      message: "密码强度不足",
      feedback: strength.feedback,
    });
  }

  // 2. HIBP 泄露检测(失败降级,不阻断)
  const pwnedCount = await checkPasswordPwned(dto.password).catch(() => 0);
  if (pwnedCount > 0) {
    throw new BadRequestException(
      `该密码已出现在 ${pwnedCount.toLocaleString()} 次数据泄露中,请更换密码`,
    );
  }

  // 3. 用户名/邮箱唯一性检测(防用户枚举——见 3.3 节)
  const exists = await this.usersService.existsByEmailOrUsername(
    dto.email,
    dto.username,
  );
  if (exists) {
    // 不要区分"邮箱已注册"和"用户名已注册",防止枚举
    throw new ConflictException("该用户名或邮箱已被使用");
  }

  // 4. 哈希密码
  const passwordHash = await hashPassword(dto.password);

  // 5. 写库 + 发送邮箱验证邮件
  const user = await this.usersService.create({
    email: dto.email,
    username: dto.username,
    passwordHash,
    emailVerified: false,
  });

  await this.emailService.sendVerification(user.id, user.email);
}

3.3 防用户枚举(注册场景)

注册接口的用户枚举攻击:攻击者批量尝试邮箱注册,通过响应判断哪些邮箱已注册,进而发动精准的钓鱼或凭证填充攻击。

错误做法:

// ❌ 明确告知枚举信息
if (emailExists) throw new ConflictException("该邮箱已注册");
if (usernameExists) throw new ConflictException("该用户名已被占用");

正确做法:

// ✅ 统一返回,不区分具体冲突原因
if (exists) throw new ConflictException("该用户名或邮箱已被使用");

对于邮箱注册场景,更隐蔽的做法是"统一发邮件":

// 无论邮箱是否已注册,都返回 200 并声称"如果邮箱有效,将收到邮件"
// 若已注册:发送"有人尝试用你的邮箱注册,如果不是你请忽略"的通知邮件
// 若未注册:发送正常的验证邮件
// 攻击者无法通过响应区分两种情况
async register(dto: RegisterDto) {
  // ... 验证逻辑 ...

  const existingUser = await this.usersService.findByEmail(dto.email);
  if (existingUser) {
    // 已存在:发送安全提醒邮件,不泄露信息
    await this.emailService.sendAccountExistsAlert(dto.email);
    return; // 正常返回,不抛错
  }

  // 正常注册流程
  // ...
}

3.4 限流前置:反向代理与真实 IP 配置

本文所有限流均依赖 @nestjs/throttler 以真实客户端 IP 为单位计数。当应用部署在 Nginx 反向代理后面时,NestJS 直接读到的 req.ip 是 Nginx 与应用之间的内网地址(如 127.0.0.1),而非客户端真实 IP——所有用户在限流层都被归为同一来源,相当于限流形同虚设。

要让整条链路正确工作,Nginx 和 NestJS 两端都需要配置:

真实客户端 (IP: 1.2.3.4)
    │
    │  HTTP 请求
    ▼
Nginx(公网 443)
    │  注入请求头:
    │    X-Real-IP: 1.2.3.4
    │    X-Forwarded-For: 1.2.3.4
    │
    │  转发给内网
    ▼
NestJS(内网 127.0.0.1:3000)
    │  trust proxy = 1 告诉 Express:
    │  "读取 X-Forwarded-For 作为 req.ip"
    ▼
req.ip === "1.2.3.4"  ✅
ThrottlerGuard 按 "1.2.3.4" 计数  ✅

第一步:Nginx 注入客户端 IP 请求头

# nginx.conf(server 块内)
location /api/ {
    proxy_pass          http://127.0.0.1:3000;

    # 注入真实客户端 IP,供后端读取
    proxy_set_header    X-Real-IP         $remote_addr;
    proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Proto $scheme;
    proxy_set_header    Host              $host;
}
  • $remote_addr:Nginx 直接收到的连接 IP,即真实客户端 IP(此处 Nginx 是第一层代理,没有更上层的代理了)。
  • $proxy_add_x_forwarded_for:在原有 X-Forwarded-For 后追加 $remote_addr,多层代理时形成完整 IP 链。

第二步:NestJS 开启 trust proxy

// src/main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // 告诉 Express 信任距离应用最近的 1 层代理(即 Nginx)
    // 此后 req.ip 会从 X-Forwarded-For 中读取真实客户端 IP
    app.getHttpAdapter().getInstance().set("trust proxy", 1);

    await app.listen(3000);
}
bootstrap();
为什么是 1 而不是 true 设为 true 表示信任所有来源的代理声明,此时攻击者只需在请求头中手动添加 X-Forwarded-For: 任意IP 即可伪造来源,完全绕过限流。设为 1 只信任最后一跳(Nginx 追加的 IP),用户请求中伪造的 X-Forwarded-For 会被追加到链末尾而不会被当作 req.ip 读取,无法伪造。

完成以上两步后,后续所有 @Throttle 装饰器都能正确以真实客户端 IP 为维度计数。

3.5 注册限流

注册接口若不限流,会被用来批量创建垃圾账号或枚举用户名:

// src/auth/auth.controller.ts
import { Throttle } from "@nestjs/throttler";

@Public()
@Throttle({ default: { ttl: 3_600_000, limit: 5 } }) // 每 IP 每小时最多注册 5 次
@Post("register")
@HttpCode(HttpStatus.CREATED)
async register(@Body() dto: RegisterDto) {
  await this.authService.register(dto);
  return { message: "注册成功,请检查邮箱完成验证" };
}

第四部分:登录接口安全设计

4.1 完整的时序攻击防护

上一篇文章已有介绍,这里给出更完整的生产实现,包括占位哈希的预热:

// src/auth/auth.service.ts
import { OnModuleInit } from "@nestjs/common";

@Injectable()
export class AuthService implements OnModuleInit {
    private dummyHash!: string;

    /**
     * 模块初始化时预计算占位哈希。
     * 不能硬编码,因为 Argon2 会在哈希中嵌入随机盐,
     * 每次调用 hash() 的结果都不同,但 verify() 耗时稳定。
     */
    async onModuleInit() {
        this.dummyHash = await hashPassword("dummy-warmup-value");
    }

    /**
     * 验证用户名和密码,成功后签发 access + refresh token 对。
     * @param username 用户名(不是邮箱),用于查询用户记录
     * @param plain    用户提交的明文密码,仅在此方法内参与 verify,不会持久化或传播
     * @returns        包含 access_token、refresh_token 及过期信息的 TokenPair
     */
    async signIn(username: string, plain: string): Promise<TokenPair> {
        const user = await this.usersService.findByUsername(username);

        // 用户不存在时使用预计算的占位哈希,使耗时与真实验证趋于一致
        const hashToVerify = user?.passwordHash ?? this.dummyHash;
        const isValid = await verifyPassword(hashToVerify, plain);

        // 合并判断:不区分"用户不存在"和"密码错误",防止信息泄露
        if (!user || !isValid) {
            // 登录失败:记录审计日志
            this.logger.warn("登录失败", {
                username,
                reason: !user ? "user_not_found" : "wrong_password",
            });
            throw new UnauthorizedException("用户名或密码错误");
        }

        // 检查账号状态
        if (user.status === "banned") {
            throw new ForbiddenException("账号已被封禁");
        }
        if (!user.emailVerified) {
            throw new ForbiddenException("请先完成邮箱验证");
        }

        // 渐进式哈希升级
        if (isHashOutdated(user.passwordHash)) {
            const newHash = await hashPassword(plain);
            await this.usersService.updatePasswordHash(user.id, newHash);
        }

        this.logger.log("登录成功", { userId: user.id, username });
        return this.issueTokens(user);
    }
}

4.2 登录失败锁定机制

纯限流(throttler)是按 IP 限制,无法防止分布式攻击(多 IP 攻击单一账号)。账号维度的失败计数是必要补充:

// src/auth/auth.service.ts(账号锁定部分)

private readonly MAX_FAILED_ATTEMPTS = 5;
private readonly LOCKOUT_DURATION_SECONDS = 15 * 60; // 15 分钟

/**
 * 验证用户名和密码,成功后签发 access + refresh token 对。
 * @param username 用户名(不是邮箱),用于查询用户记录
 * @param plain    用户提交的明文密码,仅在此方法内参与 verify,不会持久化或传播
 * @returns        包含 access_token、refresh_token 及过期信息的 TokenPair
 */
async signIn(username: string, plain: string): Promise<TokenPair> {
  const user = await this.usersService.findByUsername(username);
  const hashToVerify = user?.passwordHash ?? this.dummyHash;

  // 检查账号锁定状态(在 verify 前,避免锁定账号仍消耗算力)
  if (user) {
    const lockKey = `auth:lockout:${user.id}`;
    const locked = await this.redis.get(lockKey);
    if (locked) {
      const ttl = await this.redis.ttl(lockKey);
      throw new TooManyRequestsException(
        `账号已临时锁定,请在 ${Math.ceil(ttl / 60)} 分钟后重试`,
      );
    }
  }

  const isValid = await verifyPassword(hashToVerify, plain);

  if (!user || !isValid) {
    if (user) {
      await this.recordFailedAttempt(user.id);
    }
    throw new UnauthorizedException("用户名或密码错误");
  }

  // 登录成功:清除失败计数
  if (user) {
    await this.redis.del(`auth:attempts:${user.id}`);
  }

  return this.issueTokens(user);
}

/** 累加账号登录失败次数,达到阈值后写入锁定 key 并通知用户 */
private async recordFailedAttempt(userId: number): Promise<void> {
  const attemptsKey = `auth:attempts:${userId}`;
  const attempts = await this.redis.incr(attemptsKey);

  // 首次计数时设置过期,防止永久累积
  if (attempts === 1) {
    await this.redis.expire(attemptsKey, 3600); // 1 小时重置
  }

  if (attempts >= this.MAX_FAILED_ATTEMPTS) {
    const lockKey = `auth:lockout:${userId}`;
    await this.redis.setex(lockKey, this.LOCKOUT_DURATION_SECONDS, "1");
    // 触发锁定时发送安全邮件通知用户
    await this.notifyAccountLocked(userId).catch(() => void 0);
    this.logger.warn("账号触发锁定", { userId, attempts });
  }
}
锁定策略权衡: 永久锁定(需人工解锁)防护最强,但攻击者可故意触发锁定来拒绝服务(DoS 合法用户)。临时锁定 + 指数退避是更合理的折中。

4.3 登录限流分层策略

@nestjs/throttlerreq.ip 为单位计数,确保 IP 正确传递需要先完成 3.4 节的 Nginx + trust proxy 配置。先在 AppModule 中注册命名的限流规则:

// src/app.module.ts
import { ThrottlerModule } from "@nestjs/throttler";

@Module({
    imports: [
        ThrottlerModule.forRoot([
            { name: "short", ttl: 60_000, limit: 100 }, // 全局默认:1 分钟 100 次
            { name: "long", ttl: 3_600_000, limit: 500 }, // 全局默认:1 小时 500 次
        ]),
        // ...
    ],
})
export class AppModule {}

在登录接口上通过 @Throttle 覆盖全局配置,单独收紧为 5/20 次:

// src/auth/auth.controller.ts
@Public()
@Throttle({
  short: { ttl: 60_000,    limit: 5  }, // 1 分钟最多 5 次,防止脚本高频尝试
  long:  { ttl: 3_600_000, limit: 20 }, // 1 小时最多 20 次,防止低速持续攻击
})
@HttpCode(HttpStatus.OK)
@Post("login")
signIn(@Body() dto: SignInDto) {
  return this.authService.signIn(dto.username, dto.password);
}

其他业务接口不加 @Throttle,沿用全局的宽松配置;只有登录这类高敏感接口需要收紧。


第五部分:密码重置安全设计

密码重置是最容易被忽视的攻击面。错误实现可导致任意账号接管。

5.1 完整流程概览

在写代码之前,先明确整条链路的用户操作路径与服务端对应动作:

sequenceDiagram
    actor 用户
    participant 前端
    participant NestJS
    participant DB as 数据库 (password_resets 表)
    participant 邮件服务

    Note over 用户,邮件服务: ── 申请重置 ──
    用户->>前端: 输入邮箱,点击"忘记密码"
    前端->>NestJS: POST /auth/forgot-password { email }
    NestJS->>DB: 查询用户是否存在
    alt 用户不存在
        NestJS-->>前端: 200(响应与存在时相同,防枚举)
    else 用户存在
        NestJS->>NestJS: 生成 rawToken(32字节随机)
        NestJS->>NestJS: SHA-256(rawToken) → tokenHash
        NestJS->>DB: UPSERT { userId, tokenHash, expiresAt, used:false }
        NestJS->>邮件服务: 发送含重置链接的邮件(链接携带 rawToken)
        NestJS-->>前端: 200
    end
    前端-->>用户: 提示"若邮箱存在,将收到重置邮件"

    Note over 用户,邮件服务: ── 执行重置 ──
    用户->>邮件服务: 打开邮件,点击重置链接
    邮件服务-->>前端: 跳转到重置密码页面(URL 携带 token 参数)
    用户->>前端: 输入新密码,提交
    前端->>NestJS: POST /auth/reset-password { token: rawToken, newPassword }
    NestJS->>NestJS: SHA-256(rawToken) → tokenHash
    NestJS->>DB: 按 tokenHash 查询记录
    alt 记录不存在 / 已使用 / 已过期
        NestJS-->>前端: 400 重置链接无效或已过期
    else 记录有效
        NestJS->>NestJS: 密码强度 + HIBP 检测
        NestJS->>NestJS: Argon2id(newPassword) → passwordHash
        NestJS->>DB: 事务:<br/>1. 标记 used=true<br/>2. 更新 password_hash<br/>3. 递增 tokenVersion(吊销旧 JWT)
        NestJS->>邮件服务: 发送"密码已修改"安全通知
        NestJS-->>前端: 200
    end
    前端-->>用户: 提示重置成功,跳转登录页

5.2 数据库表设计

password_resets 表仅存储令牌哈希,从不存储原始令牌

// src/auth/entities/password-reset.entity.ts
import {
    Column,
    CreateDateColumn,
    Entity,
    Index,
    ManyToOne,
    PrimaryGeneratedColumn,
} from "typeorm";
import { UserEntity } from "../../users/entities/user.entity";

@Entity("password_resets")
export class PasswordResetEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @ManyToOne(() => UserEntity, { onDelete: "CASCADE" })
    user: UserEntity;

    @Column()
    userId: number;

    // 存 SHA-256 哈希:即便数据库泄露,攻击者拿到 tokenHash 也无法直接用——
    // 服务端验证时要求提供原始 rawToken,再 hash 后比对
    @Index({ unique: true })
    @Column({ length: 64 })
    tokenHash: string;

    @Column()
    expiresAt: Date;

    // 令牌消费后立即置 true,防止同一令牌被使用两次
    @Column({ default: false })
    used: boolean;

    @CreateDateColumn()
    createdAt: Date;
}

如果使用 Prisma,对应的 schema 如下:

// prisma/schema.prisma

model PasswordReset {
  id        Int      @id @default(autoincrement())
  userId    Int
  // 只存 SHA-256 哈希,不存原始 token
  tokenHash String   @unique @db.VarChar(64)
  expiresAt DateTime
  used      Boolean  @default(false)
  createdAt DateTime @default(now())

  // 每个用户同一时刻只允许存在一条重置记录,upsert 以此为冲突键
  @@unique([userId])
  @@map("password_resets")
}

字段说明:

字段内容为什么这样设计
tokenHashSHA-256(rawToken)不存原文,泄露后无法直接使用;以此字段查询记录
expiresAt当前时间 + 30 分钟限制令牌有效窗口,超时自动失效
used消费后设为 true一次性语义,防止重放攻击
唯一索引tokenHash 上建唯一索引按哈希查询效率高,同时保证数据库层不存在重复令牌

5.3 申请重置令牌

createHash 来自 Node.js 内置模块 node:crypto,无需安装额外依赖:

// src/auth/auth.service.ts
import { randomBytes, createHash } from "node:crypto";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { PasswordResetEntity } from "./entities/password-reset.entity";

@Injectable()
export class AuthService {
    constructor(
        // ...其他依赖...
        @InjectRepository(PasswordResetEntity)
        private readonly passwordResetRepo: Repository<PasswordResetEntity>,
    ) {}

    async requestPasswordReset(email: string): Promise<void> {
        const user = await this.usersService.findByEmail(email);

        // 防用户枚举:无论邮箱是否存在,返回外观相同的响应
        if (!user) {
            // 随机延迟,使响应耗时与真实流程趋于一致
            await new Promise((resolve) => setTimeout(resolve, 200 + Math.random() * 100));
            return;
        }

        // 生成高熵随机令牌(32 字节 = 256 位,CSPRNG,不可预测)
        const rawToken = randomBytes(32).toString("hex");

        // SHA-256 哈希:来自 node:crypto,无需第三方依赖
        const tokenHash = createHash("sha256").update(rawToken).digest("hex");
        const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 分钟有效

        // UPSERT:若该用户已有未使用的旧令牌,直接覆盖
        // 好处:用户多次点击"忘记密码"不会产生多个有效令牌,旧链接自动失效
        await this.passwordResetRepo.upsert(
            { userId: user.id, tokenHash, expiresAt, used: false },
            { conflictPaths: ["userId"] }, // 以 userId 为冲突检测键
        );

        // 邮件链接示例:https://example.com/reset-password?token=<rawToken>
        // rawToken 是 64 位十六进制字符串(32 字节 hex 编码)
        await this.emailService.sendPasswordReset(user.email, rawToken);
    }
}

5.4 消费令牌,完成密码重置

// src/auth/auth.service.ts(续)
import { DataSource } from "typeorm";

@Injectable()
export class AuthService {
    constructor(
        // ...其他依赖...
        private readonly dataSource: DataSource,
    ) {}

    async resetPassword(rawToken: string, newPassword: string): Promise<void> {
        // 密码强度与 HIBP 检测(与注册流程一致)
        const strength = checkPasswordStrength(newPassword);
        if (!strength.valid) {
            throw new BadRequestException({
                message: "密码强度不足",
                feedback: strength.feedback,
            });
        }
        const pwnedCount = await checkPasswordPwned(newPassword).catch(() => 0);
        if (pwnedCount > 0) {
            throw new BadRequestException("该密码已在历史泄露中出现,请更换");
        }

        // 将前端提交的 rawToken 哈希后,按 tokenHash 查询记录
        // 数据库中从未存过 rawToken 本身
        const tokenHash = createHash("sha256").update(rawToken).digest("hex");
        const record = await this.passwordResetRepo.findOne({
            where: { tokenHash },
        });

        // 三重验证:记录存在 + 未使用 + 未过期
        if (!record || record.used || record.expiresAt < new Date()) {
            throw new BadRequestException("重置链接无效或已过期");
        }

        const passwordHash = await hashPassword(newPassword);

        // 事务保证原子性:三个操作要么全部成功,要么全部回滚
        await this.dataSource.transaction(async (manager) => {
            // 1. 令牌标记为已使用,防止重放
            await manager.update(PasswordResetEntity, { id: record.id }, { used: true });
            // 2. 更新密码哈希
            await manager.update(UserEntity, { id: record.userId }, { passwordHash });
            // 3. 递增 tokenVersion,使该用户所有历史 JWT 立即失效(见上一篇"用户版本号"方案)
            await manager.increment(UserEntity, { id: record.userId }, "tokenVersion", 1);
        });

        // 事务成功后发通知邮件(非关键路径,失败不影响重置结果)
        await this.emailService.sendPasswordChangedNotice(record.userId).catch(() => void 0);

        this.logger.log("密码重置成功", { userId: record.userId });
    }
}

5.5 重置令牌安全属性清单

属性实现要点
高熵随机性randomBytes(32) 产生 256 位 CSPRNG 随机数,不可预测
短期有效30 分钟内有效,超时作废
一次性使用消费后立即标记 used=true,不可重复使用
存储安全数据库只存 SHA-256 哈希,泄露后攻击者无法直接使用令牌
新请求作废旧upsert 覆盖旧记录,用户多次请求不产生多个有效令牌
重置后失效密码修改后递增 tokenVersion,吊销所有历史 access/refresh token
通知用户重置成功发通知邮件,包含操作时间,让合法用户能发现异常

第六部分:密码修改接口

已登录用户修改密码,与重置流程不同,需要验证当前密码:

// src/auth/dto/change-password.dto.ts
import { IsString, MinLength, MaxLength } from "class-validator";

export class ChangePasswordDto {
    @IsString()
    currentPassword: string;

    @IsString()
    @MinLength(12)
    @MaxLength(128)
    newPassword: string;
}
// src/auth/auth.service.ts
async changePassword(
  userId: number,
  currentPassword: string,
  newPassword: string,
): Promise<void> {
  const user = await this.usersService.findById(userId);
  if (!user) throw new NotFoundException("用户不存在");

  // 验证当前密码
  const isCurrentValid = await verifyPassword(user.passwordHash, currentPassword);
  if (!isCurrentValid) {
    throw new UnauthorizedException("当前密码不正确");
  }

  // 禁止新旧密码相同
  const isSamePassword = await verifyPassword(user.passwordHash, newPassword);
  if (isSamePassword) {
    throw new BadRequestException("新密码不能与当前密码相同");
  }

  // 强度和 HIBP 检测
  const strength = checkPasswordStrength(newPassword);
  if (!strength.valid) {
    throw new BadRequestException({ message: "密码强度不足", feedback: strength.feedback });
  }
  const pwnedCount = await checkPasswordPwned(newPassword).catch(() => 0);
  if (pwnedCount > 0) {
    throw new BadRequestException("该密码已在历史泄露中出现");
  }

  const newHash = await hashPassword(newPassword);

  await this.dataSource.transaction(async (manager) => {
    await manager.update(UserEntity, { id: userId }, { passwordHash: newHash });
    await manager.increment(UserEntity, { id: userId }, "tokenVersion", 1);
    // 可选:在 Redis 中撤销该用户所有 refresh token
    await this.revokeAllUserRefreshTokens(userId);
  });

  await this.emailService.sendPasswordChangedNotice(userId).catch(() => void 0);
}
// src/auth/auth.controller.ts
@Post("change-password")
@HttpCode(HttpStatus.OK)
changePassword(
  @CurrentUser("sub") userId: number,
  @Body() dto: ChangePasswordDto,
) {
  return this.authService.changePassword(
    userId,
    dto.currentPassword,
    dto.newPassword,
  );
}

第七部分:整体安全加固清单

以下是本文涉及的所有安全措施的结构化清单,可用于代码审查和上线前自检:

密码存储

  • [ ] 使用 Argon2id,memoryCost ≥ 19456timeCost ≥ 2
  • [ ] 绝对不存储明文密码或可逆加密形式
  • [ ] 绝对不使用 MD5 / SHA-1 / SHA-256 直接哈希密码
  • [ ] 实现渐进式哈希升级(needsRehash

注册接口

  • [ ] 密码最短 12 位,最长 128 位
  • [ ] 密码强度多维度校验(长度 + 字符多样性 + 模式检测)
  • [ ] 集成 HIBP API 检测已泄露密码(超时降级,不阻断)
  • [ ] 唯一性冲突不区分"用户名"和"邮箱",统一错误信息
  • [ ] 注册接口独立限流(每 IP 每小时上限)

登录接口

  • [ ] 防时序攻击:用户不存在时执行占位 hash verify
  • [ ] 登录失败统一错误信息,不区分"用户不存在"和"密码错误"
  • [ ] 账号失败次数计数 + 临时锁定(Redis 实现)
  • [ ] 登录接口短时 + 长时双层限流
  • [ ] 反向代理场景配置 trust proxy,确保 IP 来源正确
  • [ ] 登录成功和失败均记录审计日志(含 IP、UA)

密码重置

  • [ ] 重置令牌使用 randomBytes(32) 生成,不可预测
  • [ ] 令牌 30 分钟内有效
  • [ ] 令牌只能使用一次
  • [ ] 数据库存储 SHA-256 哈希,不存原文
  • [ ] 重置成功后递增 tokenVersion,吊销所有历史 token
  • [ ] 重置成功发送邮件通知用户

密码修改

  • [ ] 修改前验证当前密码
  • [ ] 禁止新旧密码相同
  • [ ] 修改成功后吊销所有 refresh token,强制重新登录
  • [ ] 修改成功发送邮件通知

[/hide]

10 NestJS JWT 身份验证完全指南

作者 木灵鱼儿
2026年8月15日 21:53

NestJS JWT 身份验证完全指南

前言

JWT(JSON Web Token)是目前 RESTful API 身份验证领域的主流方案。其核心优势在于无状态性——服务端无需维护会话存储,token 本身即携带经签名保护的身份信息。

然而,仅理解 JWT 的基本签发与验证远远不够。在实际工程落地中,还需要系统性地考量以下问题:

  • 安全边界:payload 明文可解码,哪些字段可以放,哪些绝对不能放?
  • token 生命周期:单一长期 token 的泄露风险如何通过双 Token 策略加以控制?
  • 主动吊销:JWT 无状态的特性与"立即登出"的业务需求之间,如何取得平衡?
  • 密码安全:bcrypt 之外,为何 Argon2id 是当前更优的选择?
  • 配置安全:密钥、过期时间等敏感配置,如何在启动阶段完成强类型校验而非等到运行时才暴露错误?

本文基于 @nestjs/jwt 原生方案,不引入 Passport(后者在纯 JWT 场景下引入了额外的策略抽象层,增加理解成本而收益有限)。文章从模块骨架搭建出发,逐步深入到 Refresh Token Rotation、时序攻击防护、反向代理下的限流陷阱,最终给出一份覆盖 OWASP 核心要点的安全加固清单。

阅读本文需要具备 NestJS 模块化开发经验(依赖注入、守卫、装饰器),以及对 JWT 基本结构有初步认识。完成本文的实践后,你将得到一套结构清晰、类型安全的认证体系骨架,可直接在此基础上扩展 RBAC 权限模型或 OAuth2 三方登录。


[hide]

前置准备

安装所需依赖:

pnpm add @nestjs/jwt @nestjs/config @node-rs/argon2 class-validator class-transformer

依赖说明:

  • @nestjs/jwt:NestJS 官方 JWT 工具,封装 token 的签发与验证。
  • @nestjs/config:环境变量加载,配合 JwtModule.registerAsync 使用。
  • @node-rs/argon2:密码哈希库。选它不选 bcrypt 的原因:

    1. bcrypt 是 C++ 原生扩展,Windows 下需要 node-gyp + Python + 编译工具链,极易踩坑;原版 argon2 同理。
    2. @node-rs/argon2 基于 Rust,提供全平台预编译二进制,pnpm add 即用,性能也优于 bcrypt
    3. Argon2id 是 2015 年密码哈希竞赛冠军,在抗 GPU/ASIC 并行破解上强于 bcrypt,是目前的推荐算法。
  • class-validator / class-transformer:DTO 校验。

目录结构约定

在中大型项目中,守卫、装饰器、拦截器等横切关注点应放在 src/common 下,Auth 领域内部只保留自己的业务:

src/
├── common/
│   ├── decorators/
│   │   ├── current-user.decorator.ts
│   │   └── public.decorator.ts
│   └── guards/
│       └── jwt-auth.guard.ts
├── auth/
│   ├── dto/
│   │   └── sign-in.dto.ts
│   ├── types/
│   │   └── jwt-payload.type.ts
│   ├── auth.controller.ts
│   ├── auth.module.ts
│   └── auth.service.ts
├── users/
│   ├── users.module.ts
│   └── users.service.ts
├── config/
│   └── env.validation.ts
└── app.module.ts

这么拆的好处:JwtAuthGuard 会被 UsersModuleOrdersModule 等多个业务模块使用,放 common 目录避免循环依赖,同时职责清晰。


第一部分:创建身份验证模块

1. 生成模块骨架

nest g module auth
nest g controller auth --no-spec
nest g service auth --no-spec
nest g module users
nest g service users --no-spec

2. 环境变量与 JWT 配置

.env 文件:

# 至少 32 字节的随机字符串,可用 `openssl rand -base64 48` 生成
JWT_ACCESS_SECRET=your-access-secret-at-least-256-bits
JWT_ACCESS_EXPIRES_IN=15m

# refresh token 使用独立密钥,与 access token 隔离
JWT_REFRESH_SECRET=your-refresh-secret-different-from-access
JWT_REFRESH_EXPIRES_IN=7d

JWT_ISSUER=your-app-name
JWT_AUDIENCE=your-app-clients

借助 Zod 在应用启动时对环境变量做强类型校验,JWT 相关字段直接并入全局的 envSchema,无需单独的 jwt.config.ts

// src/config/env.validation.ts
import { z } from "zod";

const envSchema = z.object({
    // ... 其他字段
    JWT_ACCESS_SECRET: z.string().min(32, "ACCESS secret 至少 32 字节"),
    JWT_ACCESS_EXPIRES_IN: z.string().default("15m"),
    JWT_REFRESH_SECRET: z.string().min(32, "REFRESH secret 至少 32 字节"),
    JWT_REFRESH_EXPIRES_IN: z.string().default("7d"),
    JWT_ISSUER: z.string().optional(),
    JWT_AUDIENCE: z.string().optional(),
});

export type EnvConfig = z.infer<typeof envSchema>;

export function validateEnv(config: Record<string, unknown>): EnvConfig {
    const result = envSchema.safeParse(config);
    if (!result.success) {
        const messages = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n");
        throw new Error(`环境变量校验失败:\n${messages}`);
    }
    return result.data;
}

AppModule 中传入 validate 选项,应用启动时若缺少必填项则直接报错退出:

// src/app.module.ts(片段)
ConfigModule.forRoot({
    isGlobal: true,
    validate: validateEnv,
}),

具体逻辑可以参考之前的文章:《01 NestJS 环境变量与配置管理(Config 模块)》

3. 使用 registerAsync 注册 JwtModule

JwtModule 支持异步注册,直接从 ConfigService 读取环境变量,无需在源码中手动 process.env

// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UsersModule } from "../users/users.module";
import { EnvConfig } from "../config/env.validation";

@Module({
    imports: [
        UsersModule,
        JwtModule.registerAsync({
            inject: [ConfigService],
            useFactory: (config: ConfigService<EnvConfig, true>) => ({
                // 默认签发 access token 的配置
                secret: config.get("JWT_ACCESS_SECRET", { infer: true }),
                signOptions: {
                    expiresIn: config.get("JWT_ACCESS_EXPIRES_IN", { infer: true }),
                    issuer: config.get("JWT_ISSUER", { infer: true }), // iss:签发方,标识 token 来源
                    audience: config.get("JWT_AUDIENCE", { infer: true }), // aud:接收方,标识 token 使用者
                    algorithm: "HS256", // 默认即 HS256,显式声明便于审计
                },
                verifyOptions: {
                    issuer: config.get("JWT_ISSUER", { infer: true }), // 验证时校验 iss 与 aud,防止跨系统 token 复用
                    audience: config.get("JWT_AUDIENCE", { infer: true }),
                    algorithms: ["HS256"], // 显式限定算法,防止 alg=none 攻击
                },
            }),
        }),
    ],
    providers: [AuthService],
    controllers: [AuthController],
    exports: [AuthService, JwtModule], // 导出 JwtModule 供守卫所在模块复用
})
export class AuthModule {}

signOptions 常用配置一览:

字段作用推荐值
expiresIntoken 有效期,支持数字(秒)或 zeit/ms 字符串(15m7daccess 15m,refresh 7d ~ 30d
issueriss 标准字段,标识签发方。多系统场景下用于识别 token 来源应用名或域名
audienceaud 标准字段,标识 token 使用方。同一密钥签发的多种 token 可用它区分客户端标识
algorithm签名算法。对称加密用 HS256,非对称场景(微服务分发)用 RS256/ES256单体 HS256,跨服务 RS256
notBeforenbf 生效时间,token 在此之前不可用一般不用,特殊场景(预签发)才配置
jwtidjti 唯一 ID,配合黑名单可以主动吊销单枚 token需要吊销能力时开启,见"安全加固"
HS256 vs RS256:单体应用直接 HS256 即可;如果你有多个微服务需要验证 token,用 RS256——私钥只放签发服务,其他服务只需公钥即可验证,避免密钥扩散。

4. Payload 设计原则

先理解 JWT 的安全模型:

一个 JWT 由三段组成,用 . 分隔:

header.payload.signature
  • header / payload:Base64URL 编码,不是加密。任何人拿到 token 都能直接解码读取内容:

    JSON.parse(atob("eyJzdWIiOjEsInJvbGVzIjpbInVzZXIiXX0="));
    // { sub: 1, roles: ["user"] }
  • signature:服务端用 secret 对前两段做 HMAC 签名(注意:是签名,不是加密):

    HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

    验证时,服务端用同一个 secret 对收到的 header + payload 重新算一遍签名,再与第三段比对。

为什么篡改后无法伪造:

攻击者把 roles: ["user"] 改成 roles: ["admin"] 后,payload 变了,签名就对不上了。想伪造合法签名,必须知道 secret——而 secret 只在服务端。HMAC 也是不可逆的,无法从签名"推算"出 secret。

所以 JWT 的 payload 是 明文可解码 的(Base64URL 不是加密),因此设计原则非常严格:

推荐字段:

// src/auth/types/jwt-payload.type.ts
export interface JwtPayload {
    sub: number; // subject,用户唯一 ID(数据库主键),JWT 标准字段
    username: string; // 便于日志记录,不含敏感信息
    roles?: string[]; // RBAC 权限,避免每次请求都查库
    tokenType: "access" | "refresh"; // 区分 token 类型,防止 refresh 被当 access 使用
    // 以下由 JwtService 自动注入,无需手动设置
    iat?: number; // 签发时间
    exp?: number; // 过期时间
    iss?: string; // 签发方
    aud?: string; // 接收方
    jti?: string; // 唯一 ID
}

绝对不要放入 payload:

  • 密码、密码哈希
  • 支付信息、身份证、银行卡号
  • 大段的用户资料(头像、简介等,会撑大 token 体积)
  • 会频繁变动的数据(如用户当前余额)

设计原则:

  1. 越小越好:token 会随每个请求发送,HTTP header 有大小限制(一般 8KB),payload 控制在 1KB 以内。
  2. 只放不敏感、稳定、频繁使用的字段。
  3. 权限信息可放rolespermissions 放入 payload 可以避免守卫每次都查库,但修改权限后需要用户重新登录才能生效(这是 JWT 无状态的固有代价)。
  4. 敏感操作二次校验:转账、改密码等高危操作不要只依赖 JWT,应额外验证密码或短信验证码。

5. 实现 UsersService

真实项目中 UsersService 对接 TypeORM / Prisma / Mongoose,此处用内存数据演示:

// src/users/users.service.ts
import { Injectable } from "@nestjs/common";

export interface UserEntity {
    id: number;
    username: string;
    passwordHash: string;
    roles: string[];
}

@Injectable()
export class UsersService {
    private readonly users: UserEntity[] = [
        {
            id: 1,
            username: "john",
            // 原始密码: changeme(实际项目中从数据库读取)
            passwordHash: "$argon2id$v=19$m=65536,t=2,p=1$...",
            roles: ["user"],
        },
    ];

    async findByUsername(username: string): Promise<UserEntity | undefined> {
        return this.users.find((u) => u.username === username);
    }

    async findById(id: number): Promise<UserEntity | undefined> {
        return this.users.find((u) => u.id === id);
    }
}
// src/users/users.module.ts
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";

@Module({
    providers: [UsersService],
    exports: [UsersService], // 供 AuthService 与守卫使用
})
export class UsersModule {}

6. 实现 AuthService

AuthService 负责登录、签发 access + refresh token:

// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { verify } from "@node-rs/argon2";
import { randomUUID } from "node:crypto";
import { UsersService, UserEntity } from "../users/users.service";
import { JwtPayload } from "./types/jwt-payload.type";
import { EnvConfig } from "../config/env.validation";

export interface TokenPair {
    access_token: string;
    refresh_token: string;
    token_type: "Bearer";
    expires_in: number; // access token 剩余秒数,前端用于提前刷新
}

@Injectable()
export class AuthService {
    constructor(
        private readonly usersService: UsersService,
        private readonly jwtService: JwtService,
        private readonly configService: ConfigService<EnvConfig, true>,
    ) {}

    async signIn(username: string, pass: string): Promise<TokenPair> {
        const user = await this.usersService.findByUsername(username);

        // ⚠️ 时序旁路攻击防护(Timing Attack)
        // 问题:argon2 verify 耗时约 200~400ms。若用户不存在时直接返回错误(跳过 verify),
        //       攻击者可通过响应时间区分"用户不存在(2ms)"和"密码错误(300ms)",
        //       从而批量探测哪些用户名已注册——这叫"用户枚举"。
        // 解法:无论用户是否存在,都执行一次 verify。用户不存在时用占位 hash 凑足耗时,
        //       让两种情况的响应时间趋于一致,攻击者无法通过时间差区分。
        const passwordHash = user?.passwordHash ?? "$argon2id$v=19$m=65536,t=2,p=1$dummy";
        const isPasswordValid = await verify(passwordHash, pass);

        if (!user || !isPasswordValid) {
            throw new UnauthorizedException("用户名或密码错误");
        }

        return this.issueTokens(user);
    }

    private async issueTokens(user: UserEntity): Promise<TokenPair> {
        const basePayload: Omit<JwtPayload, "tokenType"> = {
            sub: user.id,
            username: user.username,
            roles: user.roles,
            jti: randomUUID(), // 唯一 ID,用于吊销
        };

        const [access_token, refresh_token] = await Promise.all([
            this.jwtService.signAsync(
                { ...basePayload, tokenType: "access" },
                {
                    secret: this.configService.get("JWT_ACCESS_SECRET", { infer: true }),
                    expiresIn: this.configService.get("JWT_ACCESS_EXPIRES_IN", { infer: true }),
                },
            ),
            this.jwtService.signAsync(
                { ...basePayload, tokenType: "refresh" },
                {
                    secret: this.configService.get("JWT_REFRESH_SECRET", { infer: true }),
                    expiresIn: this.configService.get("JWT_REFRESH_EXPIRES_IN", { infer: true }),
                },
            ),
        ]);

        return {
            access_token,
            refresh_token,
            token_type: "Bearer",
            expires_in: this.parseExpiresIn(
                this.configService.get("JWT_ACCESS_EXPIRES_IN", { infer: true }),
            ),
        };
    }

    private parseExpiresIn(value: string): number {
        const match = /^(\d+)([smhd])$/.exec(value);
        if (!match) return Number(value);
        const [, num, unit] = match;
        const map = { s: 1, m: 60, h: 3600, d: 86400 } as const;
        return Number(num) * map[unit as keyof typeof map];
    }
}

关键点:

  • 使用 ConfigService<EnvConfig, true> 注入配置,配合 Zod schema 在启动时校验环境变量,缺少必填项直接报错退出。
  • 无论用户存在与否都执行 verify,避免时序旁路攻击(timing attack)。
  • 每次登录生成新的 jti,为后续主动吊销打基础。

7. 实现登录接口

// src/auth/dto/sign-in.dto.ts
import { IsString, IsNotEmpty, MinLength, MaxLength } from "class-validator";

export class SignInDto {
    @IsString()
    @IsNotEmpty()
    @MinLength(3)
    @MaxLength(32)
    username: string;

    @IsString()
    @IsNotEmpty()
    @MinLength(8)
    @MaxLength(72)
    password: string;
}
// src/auth/auth.controller.ts
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SignInDto } from "./dto/sign-in.dto";
import { Public } from "../common/decorators/public.decorator";

@Controller("auth")
export class AuthController {
    constructor(private readonly authService: AuthService) {}

    @Public()
    @HttpCode(HttpStatus.OK)
    @Post("login")
    signIn(@Body() dto: SignInDto) {
        return this.authService.signIn(dto.username, dto.password);
    }
}

第二部分:实现身份验证守卫

1. 定义公共装饰器

// src/common/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

2. 实现 JwtAuthGuard

守卫放在 common/guards 下,多个业务模块都能复用:

// src/common/guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { EnvConfig } from "../../config/env.validation";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";

@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(
        private readonly jwtService: JwtService,
        private readonly reflector: Reflector,
        private readonly configService: ConfigService<EnvConfig, true>,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);
        if (isPublic) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const token = this.extractTokenFromHeader(request);
        if (!token) throw new UnauthorizedException("缺少认证 Token");

        try {
            const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
                secret: this.configService.get("JWT_ACCESS_SECRET", { infer: true }),
            });

            // 拒绝用 refresh token 访问业务接口
            if (payload.tokenType !== "access") {
                throw new UnauthorizedException("Token 类型错误");
            }

            request["user"] = payload;
        } catch {
            throw new UnauthorizedException("Token 无效或已过期");
        }

        return true;
    }

    private extractTokenFromHeader(request: Request): string | undefined {
        const [type, token] = request.headers.authorization?.split(" ") ?? [];
        return type === "Bearer" ? token : undefined;
    }
}

3. 注册为全局守卫

生产项目中绝大多数接口都需要认证,采用"默认保护 + @Public() 显式开放"的模式:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";

@Module({
    imports: [ConfigModule.forRoot({ isGlobal: true }), AuthModule, UsersModule],
    providers: [
        {
            provide: APP_GUARD,
            useClass: JwtAuthGuard,
        },
    ],
})
export class AppModule {}

注册后,所有路由默认受保护,只有加 @Public() 装饰器的接口才能匿名访问。


第三部分:Token 刷新策略

为什么需要双 Token

单一长期 access token 有两个致命问题:

  1. 泄露风险:token 会随每个请求发送,一旦泄露(XSS、代理日志、错误上报),攻击者拥有的时间越长危害越大。
  2. 无法主动登出:JWT 无状态,服务端签发后无法撤回。

双 Token 方案

Token有效期传输方式存储位置作用
access_token15 分钟Authorization: Bearer <token>内存(前端变量)业务接口鉴权
refresh_token7 ~ 30 天HttpOnly + Secure + SameSite Cookie服务端 Redis(哈希后)兑换新的 access token

为什么这样设计:

  • access token 短期有效,即便泄露也只有 15 分钟窗口。
  • refresh token 存 HttpOnly Cookie,JavaScript 无法读取,避免 XSS 窃取。
  • refresh token 服务端存副本,登出时删除,可实现真正的主动吊销。
  • 前端拿到 access token 只放内存,刷新页面重新兑换,不用 localStorage 避免 XSS。

实现刷新接口

先在 UsersService 或独立的 TokenService 中存 refresh token 的哈希值。这里为清晰起见,直接在 AuthService 中扩展,实际项目建议拆分:

// src/auth/auth.service.ts(新增方法)
import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2";

// 假设注入了 Redis 客户端;也可以用数据库表 refresh_tokens 存储
constructor(
  private readonly usersService: UsersService,
  private readonly jwtService: JwtService,
  private readonly configService: ConfigService<EnvConfig, true>,
  @Inject("REDIS") private readonly redis: RedisClient,
) {}

private async storeRefreshToken(userId: number, jti: string, token: string) {
  // 存储哈希值而非原文,即使 Redis 泄露也无法直接用
  const tokenHash = await argon2Hash(token);
  const ttlSeconds = this.parseExpiresIn(this.configService.get("JWT_REFRESH_EXPIRES_IN", { infer: true }));
  await this.redis.setex(`refresh:${userId}:${jti}`, ttlSeconds, tokenHash);
}

async refreshTokens(refreshToken: string): Promise<TokenPair> {
  let payload: JwtPayload;
  try {
    payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
      secret: this.configService.get("JWT_REFRESH_SECRET", { infer: true }),
    });
  } catch {
    throw new UnauthorizedException("Refresh token 无效或已过期");
  }

  if (payload.tokenType !== "refresh") {
    throw new UnauthorizedException("Token 类型错误");
  }

  // 校验服务端记录,实现主动吊销能力
  const storedHash = await this.redis.get(`refresh:${payload.sub}:${payload.jti}`);
  if (!storedHash) {
    throw new UnauthorizedException("Refresh token 已被撤销");
  }
  const isMatch = await argon2Verify(storedHash, refreshToken);
  if (!isMatch) {
    // 严重情况:token 有效但服务端记录对不上,可能是被复用
    // 业界做法:撤销该用户所有 refresh token(refresh token 轮转防复用)
    await this.revokeAllUserTokens(payload.sub);
    throw new UnauthorizedException("Refresh token 异常,请重新登录");
  }

  // 关键:一次性使用,用后即弃
  await this.redis.del(`refresh:${payload.sub}:${payload.jti}`);

  const user = await this.usersService.findById(payload.sub);
  if (!user) throw new UnauthorizedException("用户不存在");

  return this.issueTokens(user);
}

async logout(userId: number, jti: string) {
  await this.redis.del(`refresh:${userId}:${jti}`);
}

async revokeAllUserTokens(userId: number) {
  const keys = await this.redis.keys(`refresh:${userId}:*`);
  if (keys.length) await this.redis.del(...keys);
}

Refresh Token Rotation(轮转) 是业界公认的最佳实践:

  • 每次刷新都签发新的 refresh token,旧的立即作废。
  • 如果同一个 refresh token 被使用两次(合法用户 + 攻击者),第二次使用会失败,服务端应立即撤销该用户所有 token,强制重新登录。
  • OAuth 2.1 草案与 Auth0、Okta 等主流身份服务都采用此方案。

刷新接口

// src/auth/auth.controller.ts(新增)
import { Body, Controller, HttpCode, HttpStatus, Post, Res, Req } from "@nestjs/common";
import { Request, Response } from "express";
import { Public } from "../common/decorators/public.decorator";

@Public()
@HttpCode(HttpStatus.OK)
@Post("refresh")
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
  const refreshToken = req.cookies?.refresh_token;
  if (!refreshToken) throw new UnauthorizedException("缺少 refresh token");

  const tokens = await this.authService.refreshTokens(refreshToken);

  // 新的 refresh token 写回 HttpOnly Cookie
  res.cookie("refresh_token", tokens.refresh_token, {
    httpOnly: true,
    secure: true,          // 生产环境必须
    sameSite: "strict",    // 防 CSRF
    path: "/auth/refresh", // 限定路径,减少泄露面
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });

  return {
    access_token: tokens.access_token,
    token_type: tokens.token_type,
    expires_in: tokens.expires_in,
  };
}

登录接口同样应把 refresh token 写 Cookie,不把它放响应 body。


第四部分:类型安全的用户注入

这不是可选加分项,而是配合守卫使用的核心配套设施。直接操作 req.user 有两个问题:

  1. 类型不安全,需要在每个 controller 里手动断言。
  2. req.user 只有 payload,很多业务需要完整的用户实体(头像、邮箱、部门等)。

方案一:只从 payload 取(适合简单场景)

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

export const CurrentUser = createParamDecorator(
    (
        field: keyof JwtPayload | undefined,
        ctx: ExecutionContext,
    ): JwtPayload | JwtPayload[keyof JwtPayload] => {
        const request = ctx.switchToHttp().getRequest();
        const user: JwtPayload = request.user;
        return field ? user?.[field] : user;
    },
);

使用:

@Get("profile")
getProfile(@CurrentUser() user: JwtPayload) {
  return user;
}

@Get("me/id")
getMyId(@CurrentUser("sub") userId: number) {
  return { userId };
}

优点:零开销,不查库。
缺点:只能拿到 payload 里的字段,扩展受限。

方案二:守卫中查库并挂载完整实体(业界通用做法)

Auth0、Clerk 等 SaaS 服务的 SDK,以及大厂后台通用做法:守卫验证 token 后,用 sub 查数据库拿到完整用户,挂到 req.user 上。这样 controller 拿到的是完整实体,包含最新的角色、状态等信息。

优点:能拿到完整用户信息,权限变更立即生效(不用等 token 过期)。
缺点:每个请求多一次 DB 查询,需要配合缓存。

改造守卫,用 UsersService 查询:

// src/common/guards/jwt-auth.guard.ts(增强版)
import { UsersService } from "../../users/users.service";

@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(
        private readonly jwtService: JwtService,
        private readonly reflector: Reflector,
        private readonly usersService: UsersService,
        private readonly configService: ConfigService<EnvConfig, true>,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);
        if (isPublic) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const token = this.extractTokenFromHeader(request);
        if (!token) throw new UnauthorizedException("缺少认证 Token");

        let payload: JwtPayload;
        try {
            payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
                secret: this.configService.get("JWT_ACCESS_SECRET", { infer: true }),
            });
        } catch {
            throw new UnauthorizedException("Token 无效或已过期");
        }

        if (payload.tokenType !== "access") {
            throw new UnauthorizedException("Token 类型错误");
        }

        // 查库拿完整实体,同时校验用户是否被禁用/删除
        const user = await this.usersService.findById(payload.sub);
        if (!user) throw new UnauthorizedException("用户不存在");
        // 例如: if (user.status === "banned") throw new ForbiddenException("账号已禁用");

        request["user"] = user;
        request["jwtPayload"] = payload;
        return true;
    }

    private extractTokenFromHeader(request: Request): string | undefined {
        const [type, token] = request.headers.authorization?.split(" ") ?? [];
        return type === "Bearer" ? token : undefined;
    }
}

由于 JwtAuthGuard 现在依赖 UsersService,需要在使用它的模块中导入 UsersModule。若守卫是全局注册(APP_GUARD),则将 UsersModule 设为全局或在 AppModule 中导入即可。

装饰器同步升级:

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { UserEntity } from "../../users/users.service";

export const CurrentUser = createParamDecorator(
    (field: keyof UserEntity | undefined, ctx: ExecutionContext) => {
        const request = ctx.switchToHttp().getRequest();
        const user: UserEntity = request.user;
        return field ? user?.[field] : user;
    },
);

使用:

@Get("profile")
getProfile(@CurrentUser() user: UserEntity) {
  // user 是完整实体,含 email、avatar、roles 等
  return user;
}

性能优化:加缓存

守卫每次请求查库压力大,用 Redis 缓存用户信息,命中直接返回:

// 伪代码,实际可抽到 UsersService 内
async findByIdCached(id: number): Promise<UserEntity | null> {
  const cacheKey = `user:${id}`;
  const cached = await this.redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await this.prisma.user.findUnique({ where: { id } });
  if (user) {
    await this.redis.setex(cacheKey, 300, JSON.stringify(user)); // 缓存 5 分钟
  }
  return user;
}

修改用户信息时清缓存即可。这样兼顾了实时性与性能。

与 Prisma / TypeORM 的实际接入

以 Prisma 为例,UsersService.findById 的真实实现:

@Injectable()
export class UsersService {
    constructor(private readonly prisma: PrismaService) {}

    async findById(id: number) {
        return this.prisma.user.findUnique({
            where: { id },
            select: {
                id: true,
                username: true,
                email: true,
                roles: true,
                status: true,
                // 主动 select,避免返回 passwordHash 等敏感字段
            },
        });
    }
}

TypeORM 用户则可以在 Entity 上加 @Exclude({ toPlainOnly: true }) 装饰器排除敏感字段,配合 ClassSerializerInterceptor 使用。


第五部分:安全加固清单

1. 密码存储

生产环境务必使用 Argon2id,参数按硬件承受能力调整:

import { hash, verify, Algorithm } from "@node-rs/argon2";

// OWASP 推荐的最低参数(2024)
const HASH_OPTIONS = {
    algorithm: Algorithm.Argon2id,
    memoryCost: 19456, // 19 MiB
    timeCost: 2,
    parallelism: 1,
};

export async function hashPassword(plain: string) {
    return hash(plain, HASH_OPTIONS);
}

export async function verifyPassword(hashed: string, plain: string) {
    return verify(hashed, plain);
}

2. 传输层与 Cookie

  • 强制 HTTPS:生产环境必须全站 HTTPS,防中间人窃取 token。
  • Cookie 三件套:refresh token Cookie 必须 httpOnly: true + secure: true + sameSite: "strict"
  • CSP:配置 Content-Security-Policy 头,缓解 XSS 影响面。可用 helmet 包一键开启:
import helmet from "helmet";
app.use(helmet());

3. 登录限流与防暴力破解

@nestjs/throttler 对登录接口单独限流:

import { ThrottlerModule, Throttle } from "@nestjs/throttler";

// AppModule
imports: [
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),  // 全局:每分钟 100 次
],

// AuthController - 登录额外收紧
@Throttle({ default: { ttl: 60_000, limit: 5 } })  // 每分钟 5 次
@Public()
@Post("login")
signIn(@Body() dto: SignInDto) { /* ... */ }

进阶:按用户名 + IP 组合限流,命中阈值后要求验证码或临时锁定账号。

4. Token 吊销机制

JWT 天生无状态,主动吊销需要"有状态"支持。三种业界方案:

方案实现适用场景
短期 access + refresh 轮转access 15 分钟 + refresh 服务端存储绝大多数应用(推荐)
黑名单Redis 存已吊销的 jti,守卫查询需要立即吊销 access token
用户版本号payload 带 ver,改密码/登出时递增用户的 ver大量用户,黑名单成本高

用户版本号方案示例:

// payload
{ sub: 1, ver: 3, ... }

// 守卫中
const dbUser = await this.usersService.findById(payload.sub);
if (dbUser.tokenVersion !== payload.ver) {
  throw new UnauthorizedException("Token 已失效,请重新登录");
}

// 用户改密码/强制下线时
await this.usersService.incrementTokenVersion(userId);

只需一次 DB 查询就能全局失效该用户所有历史 token,比维护巨大的黑名单更优雅。

5. 密钥管理

  • 密钥长度:HS256 至少 32 字节;openssl rand -base64 48 生成。
  • access 与 refresh 使用不同密钥:即便 access 密钥泄露,refresh 仍安全。
  • 密钥存放:不要提交到 Git。开发用 .env,生产用密钥管理服务(AWS Secrets Manager、Vault、K8s Secret)。
  • 密钥轮换:定期更换密钥。切换期间可支持两个密钥并行验证,等旧 token 全部过期后再下线旧密钥。

6. 防用户枚举

登录失败统一返回相同错误信息与相近响应时间:

// ✅ 无论用户是否存在,都执行一次 argon2 verify,且返回相同错误
if (!user || !(await verify(user?.passwordHash ?? DUMMY_HASH, pass))) {
    throw new UnauthorizedException("用户名或密码错误");
}

注册接口也要注意:不要返回"该邮箱已注册",应改为发验证邮件"如果该邮箱已注册,我们已发送提示"。

7. 审计日志

关键动作打日志便于事后追溯:

  • 登录成功 / 失败(含 IP、UA)
  • 密码修改
  • refresh token 使用异常(触发轮转防复用)
  • 权限变更

结合 04 NestJS API版本控制策略.md03 NestJS 正确处理日志.md 中的日志方案落地。

8. 敏感字段脱敏

UsersService 返回给 controller 的用户对象绝对不能包含 passwordHash。Prisma 用 select,TypeORM 用 @Exclude,或统一在响应拦截器中 class-transformer 序列化——具体见 08 NestJS DTO 校验、Entity 脱敏与 Mapped Types 实战


[/hide]

09 NestJS 使用 @nestjs-swagger 生成 API 文档

作者 木灵鱼儿
2026年8月15日 05:24

前言

在前后端分离的项目中,API 文档是前后端协作的核心契约。手动维护文档不仅繁琐,还容易与代码脱节。@nestjs/swagger 基于 OpenAPI 规范,通过装饰器直接从代码生成交互式 API 文档,做到了文档即代码。

本文基于 @nestjs/swagger v8(NestJS v10/v11),覆盖从基础配置到生产级最佳实践的完整流程。


[hide]

安装

pnpm add @nestjs/swagger

一、基础配置(main.ts)

main.ts 中初始化 Swagger,这是所有配置的起点。

// src/main.ts
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // 全局前缀(可选)
    app.setGlobalPrefix("api");

    // 全局校验管道
    app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));

    // ---- Swagger 配置 ----
    const config = new DocumentBuilder()
        .setTitle("My API")
        .setDescription("生产级 REST API 接口文档")
        .setVersion("1.0")
        .addBearerAuth(
            {
                type: "http",
                scheme: "bearer",
                bearerFormat: "JWT",
                description: "请输入 JWT Token",
            },
            "access-token", // 安全方案名称,需与 @ApiBearerAuth() 保持一致
        )
        .addTag("用户", "用户相关接口")
        .addTag("文章", "文章相关接口")
        .build();

    const documentFactory = () => SwaggerModule.createDocument(app, config);

    SwaggerModule.setup("docs", app, documentFactory, {
        jsonDocumentUrl: "docs/json", // 暴露 JSON 规范:GET /docs/json
        yamlDocumentUrl: "docs/yaml", // 暴露 YAML 规范:GET /docs/yaml
        swaggerOptions: {
            persistAuthorization: true, // 刷新页面后保留 Token
            tagsSorter: "alpha", // 标签按字母排序
            operationsSorter: "alpha", // 接口按字母排序
        },
    });
    // ---- Swagger 配置结束 ----

    await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

启动后访问 http://localhost:3000/docs 即可看到 Swagger UI。

生产环境建议:通过环境变量控制是否启用文档,避免在生产中暴露接口细节(详见末节)。

二、DTO 装饰器

SwaggerModule 通过读取 DTO 类上的装饰器来生成 Schema。

2.1 @ApiProperty

最核心的属性装饰器,用于描述 DTO 的每个字段。

// src/users/dto/create-user.dto.ts
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsString, IsOptional, MinLength, IsEnum } from "class-validator";

export enum UserRole {
    Admin = "admin",
    Editor = "editor",
    Viewer = "viewer",
}

export class CreateUserDto {
    @ApiProperty({
        description: "用户邮箱",
        example: "user@example.com",
    })
    @IsEmail()
    email: string;

    @ApiProperty({
        description: "登录密码,至少 8 位",
        minLength: 8,
        example: "P@ssw0rd!",
    })
    @IsString()
    @MinLength(8)
    password: string;

    @ApiProperty({
        description: "用户角色",
        enum: UserRole,
        enumName: "UserRole", // 让枚举在 Schema 中生成独立定义,避免重复
        default: UserRole.Viewer,
    })
    @IsEnum(UserRole)
    role: UserRole = UserRole.Viewer;

    // 可选字段使用 @ApiPropertyOptional,等价于 @ApiProperty({ required: false })
    @ApiPropertyOptional({
        description: "用户昵称",
        example: "张三",
    })
    @IsOptional()
    @IsString()
    nickname?: string;
}

2.2 常用 @ApiProperty 选项速查

选项类型说明
descriptionstring字段描述
exampleany示例值(单个)
examplesobject多示例值
requiredboolean是否必填,默认 true
defaultany默认值
typeany显式指定类型
enumarray/object枚举值
enumNamestring枚举 Schema 名,防止重复定义
isArrayboolean是否为数组类型
minimum / maximumnumber数值范围
minLength / maxLengthnumber字符串长度限制
formatstring格式(如 date-timebinary

2.3 数组类型

@ApiProperty({ type: [String] })
tags: string[];

@ApiProperty({ type: () => CreateUserDto, isArray: true })
users: CreateUserDto[];

2.4 循环引用(Lazy function)

当两个类互相引用时,使用懒加载函数避免循环引用问题:

@ApiProperty({ type: () => CategoryEntity })
category: CategoryEntity;

2.5 Entity 用于响应文档

// src/users/entities/user.entity.ts
import { ApiProperty, ApiHideProperty } from "@nestjs/swagger";
import { Exclude } from "class-transformer";

export class UserEntity {
    @ApiProperty({ example: 1 })
    id: number;

    @ApiProperty({ example: "user@example.com" })
    email: string;

    @ApiProperty({ enum: UserRole, enumName: "UserRole" })
    role: UserRole;

    @ApiProperty({ example: "2024-01-01T00:00:00.000Z" })
    createdAt: Date;

    // 敏感字段:从文档和序列化中双重隐藏
    @ApiHideProperty()
    @Exclude()
    password: string;
}

三、Controller 装饰器

3.1 @ApiTags — 接口分组

// src/users/users.controller.ts
import {
    Controller,
    Get,
    Post,
    Put,
    Delete,
    Body,
    Param,
    Query,
    ParseIntPipe,
} from "@nestjs/common";
import {
    ApiTags,
    ApiBearerAuth,
    ApiOperation,
    ApiCreatedResponse,
    ApiOkResponse,
    ApiNotFoundResponse,
    ApiBadRequestResponse,
    ApiUnauthorizedResponse,
    ApiQuery,
    ApiParam,
} from "@nestjs/swagger";
import { UsersService } from "./users.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UserEntity } from "./entities/user.entity";
import { PaginatedUserDto } from "./dto/paginated-user.dto";

@ApiTags("用户")
@ApiBearerAuth("access-token") // 整个 Controller 需要 JWT 认证
@Controller("users")
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    @Post()
    @ApiOperation({ summary: "创建用户", description: "创建一个新用户,返回创建后的用户信息。" })
    @ApiCreatedResponse({ description: "用户创建成功", type: UserEntity })
    @ApiBadRequestResponse({ description: "请求参数错误" })
    @ApiUnauthorizedResponse({ description: "未登录或 Token 过期" })
    create(@Body() createUserDto: CreateUserDto): Promise<UserEntity> {
        return this.usersService.create(createUserDto);
    }

    @Get()
    @ApiOperation({ summary: "获取用户列表(分页)" })
    @ApiOkResponse({ description: "成功", type: PaginatedUserDto })
    @ApiQuery({ name: "page", required: false, description: "页码,默认 1", example: 1 })
    @ApiQuery({ name: "pageSize", required: false, description: "每页条数,默认 20", example: 20 })
    @ApiQuery({ name: "role", required: false, enum: UserRole, description: "按角色筛选" })
    findAll(
        @Query("page") page = 1,
        @Query("pageSize") pageSize = 20,
        @Query("role") role?: UserRole,
    ): Promise<PaginatedUserDto> {
        return this.usersService.findAll({ page, pageSize, role });
    }

    @Get(":id")
    @ApiOperation({ summary: "根据 ID 获取用户" })
    @ApiParam({ name: "id", description: "用户 ID", example: 1 })
    @ApiOkResponse({ description: "成功", type: UserEntity })
    @ApiNotFoundResponse({ description: "用户不存在" })
    findOne(@Param("id", ParseIntPipe) id: number): Promise<UserEntity> {
        return this.usersService.findOne(id);
    }

    @Put(":id")
    @ApiOperation({ summary: "更新用户信息" })
    @ApiParam({ name: "id", description: "用户 ID", example: 1 })
    @ApiOkResponse({ description: "更新成功", type: UserEntity })
    @ApiNotFoundResponse({ description: "用户不存在" })
    update(
        @Param("id", ParseIntPipe) id: number,
        @Body() updateUserDto: UpdateUserDto,
    ): Promise<UserEntity> {
        return this.usersService.update(id, updateUserDto);
    }

    @Delete(":id")
    @ApiOperation({ summary: "删除用户" })
    @ApiParam({ name: "id", description: "用户 ID", example: 1 })
    @ApiOkResponse({ description: "删除成功" })
    @ApiNotFoundResponse({ description: "用户不存在" })
    remove(@Param("id", ParseIntPipe) id: number): Promise<void> {
        return this.usersService.remove(id);
    }
}

3.2 快捷响应装饰器速查

装饰器对应 HTTP 状态码
@ApiOkResponse200
@ApiCreatedResponse201
@ApiAcceptedResponse202
@ApiNoContentResponse204
@ApiBadRequestResponse400
@ApiUnauthorizedResponse401
@ApiForbiddenResponse403
@ApiNotFoundResponse404
@ApiConflictResponse409
@ApiUnprocessableEntityResponse422
@ApiTooManyRequestsResponse429
@ApiInternalServerErrorResponse500

四、Mapped Types(减少 DTO 重复代码)

在 CRUD 场景中,Update DTO 往往是 Create DTO 的变体。@nestjs/swagger 提供了 Mapped Types 工具函数,并且能正确继承 Swagger 元数据。

注意:必须从 @nestjs/swagger 导入,而非 @nestjs/mapped-types,否则 Swagger 元数据无法继承。
// src/users/dto/update-user.dto.ts
import { PartialType, OmitType, PickType, IntersectionType } from "@nestjs/swagger";
import { CreateUserDto } from "./create-user.dto";
import { ApiPropertyOptional } from "@nestjs/swagger";

// PartialType:所有字段变为可选
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// OmitType:排除指定字段
export class CreateUserWithoutPasswordDto extends OmitType(CreateUserDto, ["password"] as const) {}

// PickType:仅保留指定字段
export class UserEmailDto extends PickType(CreateUserDto, ["email"] as const) {}

// 组合用法:排除 password 后,所有字段设为可选
export class PatchUserDto extends PartialType(OmitType(CreateUserDto, ["password"] as const)) {}

// IntersectionType:合并两个 DTO
export class AdditionalUserInfo {
    @ApiPropertyOptional({ description: "个人简介" })
    bio?: string;
}
export class FullCreateUserDto extends IntersectionType(CreateUserDto, AdditionalUserInfo) {}

五、泛型分页响应

实际项目中,分页接口的响应结构通常是固定的包装格式。下面演示如何为泛型分页响应生成正确的 Swagger Schema。

// src/common/dto/paginated.dto.ts
import { ApiProperty } from "@nestjs/swagger";

export class PaginationMeta {
    @ApiProperty({ example: 100, description: "总记录数" })
    total: number;

    @ApiProperty({ example: 1, description: "当前页码" })
    page: number;

    @ApiProperty({ example: 20, description: "每页条数" })
    pageSize: number;

    @ApiProperty({ example: 5, description: "总页数" })
    totalPages: number;
}

export class PaginatedDto<T> {
    @ApiProperty({ description: "数据列表" })
    items: T[];

    @ApiProperty({ type: PaginationMeta })
    meta: PaginationMeta;
}
// src/common/decorators/api-paginated-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { PaginatedDto } from "../dto/paginated.dto";

// 自定义装饰器,支持泛型分页响应
export const ApiPaginatedResponse = <TModel extends Type<any>>(model: TModel) => {
    return applyDecorators(
        ApiExtraModels(PaginatedDto, model), // 告知 SwaggerModule 包含这些额外 Model
        ApiOkResponse({
            schema: {
                title: `PaginatedResponseOf${model.name}`,
                allOf: [
                    { $ref: getSchemaPath(PaginatedDto) },
                    {
                        properties: {
                            items: {
                                type: "array",
                                items: { $ref: getSchemaPath(model) },
                            },
                        },
                    },
                ],
            },
        }),
    );
};
// src/users/dto/paginated-user.dto.ts
// 具体类型的分页 DTO(用于 Controller 的 TypeScript 类型提示)
import { PaginatedDto } from "../../common/dto/paginated.dto";
import { UserEntity } from "../entities/user.entity";

export class PaginatedUserDto extends PaginatedDto<UserEntity> {}

在 Controller 中使用:

@Get()
@ApiOperation({ summary: '获取用户列表(分页)' })
@ApiPaginatedResponse(UserEntity) // 使用自定义装饰器
findAll(): Promise<PaginatedDto<UserEntity>> {
  return this.usersService.findAll();
}

六、安全认证配置

6.1 JWT Bearer(最常用)

main.ts 中已通过 addBearerAuth 注册,在 Controller 中声明使用:

// 整个 Controller 使用认证
@ApiBearerAuth('access-token')
@Controller('users')
export class UsersController {}

// 仅单个接口使用认证
@Get('profile')
@ApiBearerAuth('access-token')
getProfile() {}

// 某接口不需要认证(配合全局守卫时)
@Get('public')
@ApiOperation({ summary: '公开接口' })
// 不加 @ApiBearerAuth 即可在文档中体现无需认证
getPublicInfo() {}

6.2 同时支持多种认证方式

// main.ts
const config = new DocumentBuilder()
    .addBearerAuth({ type: "http", scheme: "bearer", bearerFormat: "JWT" }, "jwt")
    .addApiKey({ type: "apiKey", in: "header", name: "X-API-Key" }, "api-key")
    .build();

// controller
@ApiBearerAuth("jwt")
@ApiSecurity("api-key")
@Controller("data")
export class DataController {}

七、全局响应文档(统一错误格式)

生产项目通常有统一的错误响应结构。下面分两个层次来处理:全局兜底错误addGlobalResponse带 Schema 的错误 用 DTO + @ApiResponse

7.1 声明统一错误响应 DTO

// src/common/dto/error-response.dto.ts
import { ApiProperty } from "@nestjs/swagger";

export class ErrorResponseDto {
    @ApiProperty({ example: 400 })
    statusCode: number;

    @ApiProperty({ example: "请求参数错误" })
    message: string;

    @ApiProperty({ example: "Bad Request" })
    error: string;
}

7.2 全局兜底响应(addGlobalResponse)

addGlobalResponse 会把指定响应附加到所有接口,适合 401、500 这类对每个接口都成立的响应,避免每个 Controller 都重复写一遍。

// main.ts
const config = new DocumentBuilder()
    .setTitle("My API")
    .setVersion("1.0")
    .addBearerAuth(/* ... */)
    .addGlobalResponse({ status: 401, description: "未授权,Token 无效或过期" })
    .addGlobalResponse({ status: 500, description: "服务器内部错误" })
    .build();
这样写在生产中是正常的。401/500 属于跨切面的响应,不属于某个具体接口的业务逻辑,放在全局配置里既不重复、也符合语义。

7.3 带 Schema 的接口级错误响应

对于需要展示具体响应结构的接口(如 400 参数校验错误),在 Controller 上引用 DTO:

import { ApiBadRequestResponse } from "@nestjs/swagger";
import { ErrorResponseDto } from "../common/dto/error-response.dto";

@Post()
@ApiCreatedResponse({ type: UserEntity })
@ApiBadRequestResponse({ type: ErrorResponseDto, description: "请求参数校验失败" })
create(@Body() dto: CreateUserDto) {}

这样 Swagger UI 里这个接口的 400 响应会展示出 ErrorResponseDto 的字段结构,而不仅是一段文字描述。


八、文件上传

// src/upload/dto/upload-file.dto.ts
import { ApiProperty } from "@nestjs/swagger";

export class UploadFileDto {
    @ApiProperty({ type: "string", format: "binary", description: "要上传的文件" })
    file: Express.Multer.File;
}

export class UploadMultipleFilesDto {
    @ApiProperty({
        type: "array",
        items: { type: "string", format: "binary" },
        description: "要上传的多个文件",
    })
    files: Express.Multer.File[];
}
// upload.controller.ts
import { UseInterceptors, UploadedFile, UploadedFiles } from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiBody } from '@nestjs/swagger';

@Post('upload')
@ApiOperation({ summary: '上传单个文件' })
@ApiConsumes('multipart/form-data')
@ApiBody({ type: UploadFileDto })
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
  return { filename: file.originalname, size: file.size };
}

@Post('upload/multiple')
@ApiOperation({ summary: '上传多个文件' })
@ApiConsumes('multipart/form-data')
@ApiBody({ type: UploadMultipleFilesDto })
@UseInterceptors(FilesInterceptor('files', 10))
uploadFiles(@UploadedFiles() files: Express.Multer.File[]) {
  return files.map((f) => ({ filename: f.originalname, size: f.size }));
}

九、CLI Plugin(推荐)

手动为每个 DTO 字段添加 @ApiProperty 非常繁琐。启用 CLI Plugin 后,插件会在编译时自动为 .dto.ts.entity.ts 文件注入 @ApiProperty

9.1 配置 nest-cli.json

{
    "collection": "@nestjs/schematics",
    "sourceRoot": "src",
    "compilerOptions": {
        "plugins": [
            {
                "name": "@nestjs/swagger",
                "options": {
                    "classValidatorShim": true,
                    "introspectComments": true
                }
            }
        ]
    }
}

9.2 启用后,DTO 可以这样写

插件自动从 TypeScript 类型和 class-validator 装饰器推断 Schema,注释会变成 description@example 标签会变成 example

// src/users/dto/create-user.dto.ts(使用 CLI Plugin 后)
import { IsEmail, IsString, IsOptional, MinLength, IsEnum } from "class-validator";

export class CreateUserDto {
    /**
     * 用户邮箱
     * @example user@example.com
     */
    @IsEmail()
    email: string;

    /**
     * 登录密码,至少 8 位
     * @example P@ssw0rd!
     */
    @IsString()
    @MinLength(8)
    password: string;

    /**
     * 用户角色
     * @example viewer
     */
    @IsEnum(UserRole)
    role: UserRole = UserRole.Viewer;

    /**
     * 用户昵称(可选)
     * @example 张三
     */
    @IsOptional()
    @IsString()
    nickname?: string; // 带 ? 的字段自动设为 required: false
}
Plugin 选项默认值说明
classValidatorShimtrue从 class-validator 装饰器(如 @Max(10))推断 Schema 约束
introspectCommentsfalse从 JSDoc 注释生成 descriptionexample
dtoFileNameSuffix['.dto.ts', '.entity.ts']需要分析的文件后缀
skipAutoHttpCodefalse禁止自动为 Controller 添加 @HttpCode()

9.3 使用 SWC 编译器时

为什么 SWC 需要单独处理?

CLI Plugin 的原理是在 TypeScript 编译阶段做 AST 转换,把 @ApiProperty 等装饰器注入到产物中。而 SWC 完全绕过了 TypeScript 编译器,所以这个 AST 转换根本不会运行,Swagger 元数据就丢失了。

解决思路是:把元数据提前生成成一个 .ts 文件,main.ts 在启动时把这个文件加载进去。


方案一:--type-check 模式(推荐,适合标准单应用)

nest-cli.json 已配置好插件的前提下,只需用 --type-check 启动,Nest CLI 会在后台同时运行 tsc 并自动处理元数据,无需手动写任何脚本:

# 开发热重载
nest start -b swc --type-check

# 生产构建
nest build -b swc --type-check

这个模式下,SWC 负责毫秒级代码编译,tsc 在后台负责类型检查和元数据生成,两者并行,DTO 每次变更后热重载也能自动更新元数据,无需手动触发


方案二:手动 generate-metadata.ts 脚本(适合 Monorepo 或自定义构建流)

当无法使用 --type-check(如 Monorepo 多应用、自定义 webpack 流水线),需要自己写一个生成脚本。这个文件是开发者手动创建的,不是工具自动生成的:

// src/generate-metadata.ts
import { PluginMetadataGenerator } from "@nestjs/swagger/plugin";
import { ReadonlyVisitor } from "@nestjs/swagger/plugin";

const generator = new PluginMetadataGenerator();
generator.generate({
    visitors: [
        new ReadonlyVisitor({
            introspectComments: true,
            pathToSource: __dirname,
        }),
    ],
    outputDir: __dirname, // metadata.ts 输出到 src/ 目录
    watch: false, // 开发时改为 true,可监听文件变更自动重新生成
    tsconfigPath: "tsconfig.build.json",
});

开发时将 watch 改为 true,它会持续监听 DTO 文件变化并重新生成,配合 nest start -b swc 一起用即可实现热更新。

package.json 中添加脚本方便调用:

{
    "scripts": {
        "swagger:meta": "ts-node src/generate-metadata.ts",
        "swagger:meta:watch": "ts-node src/generate-metadata.ts --watch"
    }
}

生成的 metadata.ts 文件提交到代码仓库,main.ts 中加载它:

// src/main.ts(SWC 模式,手动加载元数据)
import metadata from "./metadata"; // 由 generate-metadata.ts 生成

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    await SwaggerModule.loadPluginMetadata(metadata); // 必须在 createDocument 之前
    // ...
}
总结:普通项目直接用方案一,一行参数解决问题。方案二只在构建流程特殊时才需要。

十、多文档实例(按模块拆分)

大型项目中,可以为不同业务模块(如对内管理端、对外开放 API)创建独立的文档实例:

// main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.setGlobalPrefix("api");

    // 管理端文档:仅包含 AdminModule 的路由
    const adminConfig = new DocumentBuilder()
        .setTitle("管理端 API")
        .setVersion("1.0")
        .addBearerAuth({ type: "http", scheme: "bearer", bearerFormat: "JWT" }, "admin-jwt")
        .build();

    const adminDocument = () =>
        SwaggerModule.createDocument(app, adminConfig, {
            include: [AdminModule],
        });
    SwaggerModule.setup("docs/admin", app, adminDocument);

    // 开放 API 文档:包含对外模块
    const publicConfig = new DocumentBuilder()
        .setTitle("开放 API")
        .setVersion("1.0")
        .addApiKey({ type: "apiKey", in: "header", name: "X-API-Key" }, "api-key")
        .build();

    const publicDocument = () =>
        SwaggerModule.createDocument(app, publicConfig, {
            include: [UsersModule, ArticlesModule],
        });
    SwaggerModule.setup("docs/open", app, publicDocument);

    await app.listen(3000);
}

十一、生产环境最佳实践

11.1 非生产环境才挂载文档

// main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    if (process.env.NODE_ENV !== "production") {
        const config = new DocumentBuilder()
            .setTitle("My API")
            .setVersion("1.0")
            .addBearerAuth(/* ... */)
            .build();

        const documentFactory = () => SwaggerModule.createDocument(app, config);
        SwaggerModule.setup("docs", app, documentFactory);
    }

    await app.listen(3000);
}

11.2 生产环境仅暴露 JSON(供 API Gateway 消费,不暴露 UI)

SwaggerModule.setup("docs", app, documentFactory, {
    ui: false, // 禁用 Swagger UI 页面
    raw: ["json"], // 仅暴露 JSON,YAML 不暴露
    jsonDocumentUrl: "docs/openapi.json",
});

11.3 通过 IP 白名单守卫保护文档

// src/common/guards/swagger-ip.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from "@nestjs/common";

@Injectable()
export class SwaggerIpGuard implements CanActivate {
    private readonly allowedIps = (process.env.SWAGGER_ALLOWED_IPS ?? "127.0.0.1").split(",");

    canActivate(context: ExecutionContext): boolean {
        const request = context.switchToHttp().getRequest();
        const ip = request.ip || request.connection.remoteAddress;
        if (!this.allowedIps.includes(ip)) {
            throw new ForbiddenException("Access denied");
        }
        return true;
    }
}

11.4 导出静态 JSON 文档(用于 CI/CD 或代码生成)

// src/generate-swagger.ts(独立脚本,不依赖运行时服务器)
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { writeFileSync } from "fs";
import { AppModule } from "./app.module";

async function generate() {
    const app = await NestFactory.create(AppModule, { logger: false });
    await app.init();

    const config = new DocumentBuilder()
        .setTitle("My API")
        .setVersion("1.0")
        .addBearerAuth({ type: "http", scheme: "bearer", bearerFormat: "JWT" }, "access-token")
        .build();

    const document = SwaggerModule.createDocument(app, config);
    writeFileSync("./openapi.json", JSON.stringify(document, null, 2));
    console.log("openapi.json 生成成功");
    await app.close();
}
generate();

package.json 中添加脚本:

{
    "scripts": {
        "swagger:export": "ts-node src/generate-swagger.ts"
    }
}

完整目录结构参考

src/
├── main.ts                          # Swagger 初始化
├── common/
│   ├── decorators/
│   │   └── api-paginated-response.decorator.ts  # 泛型分页装饰器
│   ├── dto/
│   │   ├── paginated.dto.ts         # 泛型分页 DTO
│   │   └── error-response.dto.ts   # 统一错误响应 DTO
│   └── guards/
│       └── swagger-ip.guard.ts     # 文档 IP 白名单守卫
└── users/
    ├── dto/
    │   ├── create-user.dto.ts
    │   ├── update-user.dto.ts       # 使用 PartialType 等
    │   └── paginated-user.dto.ts
    ├── entities/
    │   └── user.entity.ts
    └── users.controller.ts

[/hide]

08 NestJS DTO 校验、Entity 脱敏与 Mapped Types 实战(TypeORM & Prisma)

作者 木灵鱼儿
2026年8月15日 04:49

前言

在了解DTO与Entity的区别之前,我们先来了解一下它们在项目中的数据路径。

[客户端 Client]
       │
       ▼ (1. 携带 Request DTO)
[Controller] ── (2. 校验与格式转换: ValidationPipe)
       │
       ▼ (3. 传递 DTO 或 解构参数)
[Service] ──── (4. 业务逻辑,将 DTO 转换为 Entity 或操作 ORM)
       │
       ▼ (5. 写入/读取 Entity)
[Database]
       │
       ▼ (6. 返回 Entity 给 Service)
[Service / Controller] ── (7. Entity 转换为 Response DTO / 序列化脱敏)
       │
       ▼ (8. 返回安全响应)
[客户端 Client]

DTO(Data Transfer Object)是数据传输对象,主要用于客户端与服务端之间的数据传输。它通常用于接收请求数据和返回响应数据,具有数据验证和格式转换的功能。

Entity(实体)是数据库中的数据模型,通常用于表示数据库表结构和数据关系。它主要用于与数据库进行交互,执行增删改查等操作。

虽然是这么说,但是在实际项目中,Entity 常常在 NestJS 中被兼职将数据安全返回给客户端(与 @Exclude()ClassSerializerInterceptor 配合使用),这也是 NestJS 提供的一种极具性价比的偷懒方案,避免了开发者每个接口都去写一个 Response DTO。

特别是在与 Prisma ORM 结合使用时,Prisma 的 Entity 是由 Prisma 自动生成的,我们常常通过 TypeScript 的 implements 来约束 Entity 类必须实现 Prisma 的表结构类型,再通过 @Exclude() 等装饰器来实现数据脱敏转化,最后返回给客户端。

[hide]

生产级目录结构

在正式展开之前,先看一下生产项目中推荐的目录结构,后续的所有示例都基于此结构。

src/
├── common/                        # 全局公共模块
│   ├── decorators/                # 自定义装饰器
│   ├── filters/                   # 异常过滤器
│   ├── guards/                    # 守卫
│   ├── interceptors/              # 拦截器(含序列化拦截器配置)
│   └── pipes/                     # 管道(含全局 ValidationPipe)
│
├── modules/
│   └── user/                      # 用户模块(以此为例)
│       ├── dto/
│       │   ├── create-user.dto.ts # 创建用户请求 DTO
│       │   ├── update-user.dto.ts # 更新用户请求 DTO(通常继承 create)
│       │   └── query-user.dto.ts  # 查询/分页 DTO
│       ├── entities/
│       │   └── user.entity.ts     # Entity(TypeORM)或 Prisma 的 Entity 包装类
│       ├── user.controller.ts
│       ├── user.service.ts
│       └── user.module.ts
│
└── main.ts

核心原则:

  • dto/ 只负责"进出",即请求校验与响应裁剪。
  • entities/ 只负责数据库映射(TypeORM)或 Prisma 类型的装饰器增强包装。
  • 两者不混用,Service 层作为转换边界。

依赖安装:class-validator 与 class-transformer

DTO 的核心能力由这两个库提供,它们是搭档关系,缺一不可。

pnpm add class-validator class-transformer

class-validator

提供基于装饰器的数据校验能力。常用装饰器:

装饰器说明
@IsString()必须是字符串
@IsInt()必须是整数
@IsEmail()必须是合法邮箱
@IsBoolean()必须是布尔值
@IsOptional()字段可选,为空时跳过后续校验
@IsNotEmpty()不能为空字符串/null/undefined
@MinLength(n) / @MaxLength(n)字符串最小/最大长度
@Min(n) / @Max(n)数值范围
@IsEnum(Enum)必须是枚举值之一
@IsArray()必须是数组
@ValidateNested()嵌套对象递归校验

需要全局启用管道,校验才会生效。有两种注册方式:

方式一:在 main.ts 中注册

// main.ts
import { ValidationPipe } from "@nestjs/common";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalPipes(
        new ValidationPipe({
            whitelist: true, // 自动剥除 DTO 中未定义的属性,防止属性注入
            forbidNonWhitelisted: true, // 发现未定义属性时直接报 400,而不是静默丢弃
            transform: true, // 自动将请求数据转换为 DTO 类的实例(启用类型转换)
        }),
    );
    await app.listen(3000);
}

方式二:在 AppModule 中通过依赖注入注册

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_PIPE } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";

@Module({
    providers: [
        {
            provide: APP_PIPE,
            useValue: new ValidationPipe({
                whitelist: true,
                forbidNonWhitelisted: true,
                transform: true,
            }),
        },
    ],
})
export class AppModule {}

两种方式效果完全一致。区别在于 AppModule 方式通过 NestJS 的依赖注入体系注册,管道本身可以注入其他 Provider;main.ts 方式在应用上下文之外实例化,无法使用依赖注入,但写法更直观。生产项目中推荐统一放在 AppModule,便于集中管理所有全局配置。

whitelist: true 是生产环境的必选配置,它能阻止客户端注入 DTO 中未声明的字段。whitelistforbidNonWhitelisted 保持一个开启状态即可。

class-transformer

提供基于装饰器的对象转换与序列化能力,主要用于控制 Entity 返回给客户端时哪些字段可见。常用装饰器:

装饰器说明
@Exclude()序列化时排除该字段(用于隐藏密码等敏感字段)
@Expose()配合 excludeExtraneousValues 模式,只暴露标记的字段
@Transform()自定义转换逻辑,例如格式化日期、枚举转文字
@Type()声明嵌套对象的类型,配合 @ValidateNested() 使用

DTO

DTO 本身是负责数据传输的对象,它的主要职责是接收请求数据和返回响应数据。DTO 通常用于以下场景:

  1. 输入过滤:配合 whitelist: true 阻挡恶意注入的未定义属性。
  2. 数据验证:通过装饰器(如 @IsString()@IsInt() 等)对请求数据进行验证,确保数据的合法性。
  3. 类型转换:将请求数据转换为指定的类型,方便后续处理。
  4. 文档自生成:配合 @nestjs/swagger 自动生成 OpenAPI 接口规范。

TypeORM 项目中的 DTO 实战

以用户模块为例,创建 create-user.dto.ts

// src/modules/user/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from "class-validator";

export enum UserRole {
    ADMIN = "admin",
    USER = "user",
}

export class CreateUserDto {
    @IsEmail({}, { message: "邮箱格式不正确" })
    email: string;

    @IsString()
    @MinLength(2, { message: "用户名至少 2 个字符" })
    @MaxLength(20, { message: "用户名最多 20 个字符" })
    username: string;

    @IsString()
    @MinLength(8, { message: "密码至少 8 位" })
    password: string;

    @IsOptional()
    @IsEnum(UserRole, { message: "角色值不合法" })
    role?: UserRole;
}

查询分页 DTO,注意 HTTP GET 请求的查询参数(Query)默认是字符串,需要用 @Type() 做类型转换:

// src/modules/user/dto/query-user.dto.ts
import { IsOptional, IsString, IsInt, Min } from "class-validator";
import { Type } from "class-transformer";

export class QueryUserDto {
    @IsOptional()
    @IsString()
    keyword?: string;

    @IsOptional()
    @Type(() => Number) // 将查询字符串 "1" 转为数字 1
    @IsInt()
    @Min(1)
    page?: number = 1;

    @IsOptional()
    @Type(() => Number)
    @IsInt()
    @Min(1)
    pageSize?: number = 20;
}

嵌套对象校验,需要同时使用 @ValidateNested()@Type()

// src/modules/user/dto/create-user-profile.dto.ts
import { IsString, IsOptional, ValidateNested } from "class-validator";
import { Type } from "class-transformer";

class AddressDto {
    @IsString()
    city: string;

    @IsOptional()
    @IsString()
    street?: string;
}

export class CreateUserProfileDto {
    @IsString()
    nickname: string;

    @ValidateNested()
    @Type(() => AddressDto) // 告知 class-transformer 嵌套对象的具体类型
    address: AddressDto;
}

Prisma 项目中的 DTO 实战

Prisma 项目的 DTO 写法与 TypeORM 完全一致,class-validator 与 class-transformer 的使用没有区别。区别在于 Service 层如何将 DTO 传递给 Prisma:

// src/modules/user/user.service.ts(Prisma 版)
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UserEntity } from "./entities/user.entity";
import * as bcrypt from "bcrypt";

@Injectable()
export class UserService {
    constructor(private readonly prisma: PrismaService) {}

    async create(dto: CreateUserDto): Promise<UserEntity> {
        const hashed = await bcrypt.hash(dto.password, 10);
        const user = await this.prisma.user.create({
            data: { ...dto, password: hashed },
        });
        // 将 Prisma 返回的原始对象包装为 Entity 类实例,激活 @Exclude() 等装饰器
        return new UserEntity(user);
    }
}

Entity

Entity 是用于数据库交互的对象,主要职责是表示数据库表结构。配合 @Exclude()ClassSerializerInterceptor,它也承担了响应序列化的工作。

同样有两种注册方式,以下示例仅展示拦截器部分(ValidationPipe 注册方式同上,不再重复):

方式一:在 main.ts 中注册

// main.ts
import { ClassSerializerInterceptor } from "@nestjs/common";
import { Reflector } from "@nestjs/core";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // 全局启用序列化,自动对返回的类实例执行 class-transformer 转换
    app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

    await app.listen(3000);
}

方式二:在 AppModule 中通过依赖注入注册

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR } from "@nestjs/core";
import { ClassSerializerInterceptor } from "@nestjs/common";

@Module({
    providers: [
        {
            provide: APP_INTERCEPTOR,
            useClass: ClassSerializerInterceptor,
        },
    ],
})
export class AppModule {}

AppModule 方式使用 useClass 而非 useValue,因为 ClassSerializerInterceptor 需要注入 Reflector,由 NestJS 的 DI 容器自动完成,无需手动传入。

TypeORM 项目中的 Entity 实战

TypeORM 的 Entity 本身就是类,可以直接附加 class-transformer 装饰器:

// src/modules/user/entities/user.entity.ts
import {
    Entity,
    Column,
    PrimaryGeneratedColumn,
    CreateDateColumn,
    UpdateDateColumn,
} from "typeorm";
import { Exclude, Transform } from "class-transformer";
import { UserRole } from "../dto/create-user.dto";

@Entity("users")
export class UserEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ unique: true })
    email: string;

    @Column()
    username: string;

    @Exclude() // 序列化时自动排除,不会出现在响应 JSON 中
    @Column()
    password: string;

    @Column({ type: "enum", enum: UserRole, default: UserRole.USER })
    role: UserRole;

    @Transform(({ value }) => value.toISOString()) // 统一日期格式
    @CreateDateColumn()
    createdAt: Date;

    @Exclude()
    @UpdateDateColumn()
    updatedAt: Date;

    // 构造函数允许通过对象字面量初始化,配合 new UserEntity(partial) 使用
    constructor(partial: Partial<UserEntity>) {
        Object.assign(this, partial);
    }
}

Controller 直接返回 Entity 实例,ClassSerializerInterceptor 会自动处理序列化:

// src/modules/user/user.controller.ts
import { Controller, Get, Post, Body, Param, ParseIntPipe } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UserEntity } from "./entities/user.entity";

@Controller("users")
export class UserController {
    constructor(private readonly userService: UserService) {}

    @Post()
    async create(@Body() dto: CreateUserDto): Promise<UserEntity> {
        return this.userService.create(dto);
    }

    @Get(":id")
    async findOne(@Param("id", ParseIntPipe) id: number): Promise<UserEntity> {
        return this.userService.findOne(id);
    }
}

TypeORM Service 层:

// src/modules/user/user.service.ts(TypeORM 版)
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { UserEntity } from "./entities/user.entity";
import { CreateUserDto } from "./dto/create-user.dto";
import * as bcrypt from "bcrypt";

@Injectable()
export class UserService {
    constructor(
        @InjectRepository(UserEntity)
        private readonly userRepo: Repository<UserEntity>,
    ) {}

    async create(dto: CreateUserDto): Promise<UserEntity> {
        const hashed = await bcrypt.hash(dto.password, 10);
        const user = this.userRepo.create({ ...dto, password: hashed });
        return this.userRepo.save(user);
    }

    async findOne(id: number): Promise<UserEntity> {
        const user = await this.userRepo.findOneBy({ id });
        if (!user) throw new NotFoundException(`用户 ${id} 不存在`);
        return user;
    }
}

Prisma 项目中的 Entity 实战

Prisma 自动生成的是 TypeScript 接口,不是类,因此无法直接附加 class-transformer 装饰器。标准做法是创建一个包装类,通过 implements 约束字段结构:

// src/modules/user/entities/user.entity.ts(Prisma 版)
import { User } from "@prisma/client"; // Prisma 自动生成的类型
import { Exclude, Transform } from "class-transformer";

export class UserEntity implements User {
    id: number;
    email: string;
    username: string;
    role: string;

    @Exclude()
    password: string;

    @Transform(({ value }) => value.toISOString())
    createdAt: Date;

    @Exclude()
    updatedAt: Date;

    constructor(partial: Partial<UserEntity>) {
        Object.assign(this, partial);
    }
}

implements User 的关键作用:当 Prisma schema 新增或修改字段时,TypeScript 编译器会立即报错,提示 Entity 包装类需要同步更新,从而避免遗漏字段导致的运行时问题。

Prisma Service 层:

// src/modules/user/user.service.ts(Prisma 版)
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { UserEntity } from "./entities/user.entity";
import { CreateUserDto } from "./dto/create-user.dto";
import * as bcrypt from "bcrypt";

@Injectable()
export class UserService {
    constructor(private readonly prisma: PrismaService) {}

    async create(dto: CreateUserDto): Promise<UserEntity> {
        const hashed = await bcrypt.hash(dto.password, 10);
        const user = await this.prisma.user.create({
            data: { ...dto, password: hashed },
        });
        return new UserEntity(user); // 必须 new,否则 @Exclude() 不生效
    }

    async findAll(): Promise<UserEntity[]> {
        const users = await this.prisma.user.findMany();
        return users.map((u) => new UserEntity(u));
    }

    async findOne(id: number): Promise<UserEntity> {
        const user = await this.prisma.user.findUnique({ where: { id } });
        if (!user) throw new NotFoundException(`用户 ${id} 不存在`);
        return new UserEntity(user);
    }
}
注意:使用 Prisma 时,Service 返回的 Prisma 原始对象是普通 plain object,ClassSerializerInterceptor 默认只处理类实例。因此必须 new UserEntity(user) 显式包装,@Exclude() 才会生效。

Mapped types 进阶用法

在实际项目中,多个 DTO 之间往往存在大量重复字段。例如更新接口的 DTO 和创建接口的 DTO 字段几乎相同,只是全部变为可选。@nestjs/mapped-types 提供了一组工具函数来复用 DTO 定义。

安装

pnpm add @nestjs/mapped-types

从哪里引入:@nestjs/mapped-types vs @nestjs/swagger

这是一个常见的选择问题。

如果项目没有集成 Swagger,从 @nestjs/mapped-types 引入:

import { PartialType, PickType, OmitType, IntersectionType } from "@nestjs/mapped-types";

如果项目集成了 Swagger(即使用了 @nestjs/swagger,必须从 @nestjs/swagger 引入同名函数:

import { PartialType, PickType, OmitType, IntersectionType } from "@nestjs/swagger";

原因:@nestjs/swagger 版本的这些函数在复用字段的同时,会同步传递父类上的 @ApiProperty() 装饰器元数据,使生成的 Swagger 文档保持完整。如果错误地从 @nestjs/mapped-types 引入,Swagger 文档中派生的 DTO 字段将没有描述信息。

判断标准:项目中只要 main.ts 或任意模块使用了 SwaggerModule.setup(),就应统一从 @nestjs/swagger 引入。

PartialType — 所有字段变可选

最常用的场景:更新接口通常允许只传部分字段。

// src/modules/user/dto/update-user.dto.ts
import { PartialType } from "@nestjs/mapped-types"; // 无 Swagger 版本
// import { PartialType } from '@nestjs/swagger';    // 有 Swagger 版本

import { CreateUserDto } from "./create-user.dto";

// UpdateUserDto 继承 CreateUserDto 的所有字段,但全部变为可选
// 同时保留所有 class-validator 装饰器,传了才校验,不传则跳过
export class UpdateUserDto extends PartialType(CreateUserDto) {}

效果等价于手动写:

export class UpdateUserDto {
    @IsOptional() @IsEmail() email?: string;
    @IsOptional() @IsString() @MinLength(2) username?: string;
    @IsOptional() @IsString() @MinLength(8) password?: string;
    @IsOptional() @IsEnum(UserRole) role?: UserRole;
}

PickType — 只保留指定字段

适合需要从父 DTO 中抽取少量字段的场景,例如登录只需要邮箱和密码:

// src/modules/auth/dto/login.dto.ts
import { PickType } from "@nestjs/mapped-types";
import { CreateUserDto } from "../../user/dto/create-user.dto";

// 只保留 email 和 password 两个字段,其余字段被丢弃
export class LoginDto extends PickType(CreateUserDto, ["email", "password"] as const) {}

OmitType — 排除指定字段

PickType 相反,适合字段多但只需排除少数字段的场景,例如响应 DTO 去掉密码字段:

// src/modules/user/dto/user-response.dto.ts
import { OmitType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

// 剔除 password 字段,其余字段全部保留
export class UserResponseDto extends OmitType(CreateUserDto, ["password"] as const) {}

IntersectionType — 合并多个类型

适合将两个独立 DTO 合并为一个,常见于分页查询附加业务过滤条件:

// src/common/dto/pagination.dto.ts
import { IsInt, IsOptional, Min } from "class-validator";
import { Type } from "class-transformer";

export class PaginationDto {
    @IsOptional()
    @Type(() => Number)
    @IsInt()
    @Min(1)
    page?: number = 1;

    @IsOptional()
    @Type(() => Number)
    @IsInt()
    @Min(1)
    pageSize?: number = 20;
}
// src/modules/user/dto/query-user.dto.ts
import { IntersectionType } from "@nestjs/mapped-types";
import { IsOptional, IsString } from "class-validator";
import { PaginationDto } from "../../../common/dto/pagination.dto";

class UserFilterDto {
    @IsOptional()
    @IsString()
    keyword?: string;
}

// 合并分页 DTO 和用户过滤 DTO,QueryUserDto 同时拥有两者的所有字段
export class QueryUserDto extends IntersectionType(PaginationDto, UserFilterDto) {}

组合使用

这四个工具可以嵌套组合,实现复杂的字段裁剪逻辑:

// 只保留 email 和 username,且都变为可选
export class PatchUserBasicDto extends PartialType(
    PickType(CreateUserDto, ["email", "username"] as const),
) {}

// 排除 password 后全部字段变为可选(常见于 PATCH 接口)
export class PatchUserDto extends PartialType(OmitType(CreateUserDto, ["password"] as const)) {}

[/hide]

07 NestJS 使用Caching缓存(cache-manager)

作者 木灵鱼儿
2026年8月15日 02:35

前言

相比较Redis,Nestjs官方还提供了一个更为通用的缓存模块 @nestjs/cache-manager,它是基于 cache-manager 封装的,支持多种缓存存储方式(内存、Redis、Memcached等),并且可以通过配置来切换不同的缓存存储。

常见的缓存场景包括:

  1. 数据库查询结果缓存
  2. 接口请求结果缓存
  3. 统一不同缓存存储的接口

在与Redis结合使用,已经是生产环境中最常见的缓存方案之一。如果条件允许,可以配合使用。


[hide]

核心依赖安装

# 核心依赖
pnpm add @nestjs/cache-manager cache-manager

全局与异步配置

常见做法是在 AppModule 中注册全局缓存模块,并通过 ConfigService 读取环境变量进行异步配置。

1. 模块注册

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheModule } from '@nestjs/cache-manager';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    CacheModule.registerAsync({
      isGlobal: true, // 设为全局模块,其他 Module 无需重复导入
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (config: ConfigService) => ({
        // TTL (Time-To-Live):毫秒为单位(cache-manager v5+ 统一为 ms)
        ttl: config.get<number>('CACHE_TTL', 30000),
        // 内存中最大缓存条目数(超出后按 LRU 算法自动淘汰)
        max: config.get<number>('CACHE_MAX_ITEMS', 500),
      }),
    }),
    UsersModule,
  ],
})
export class AppModule {}

2. 关键参数解析

参数默认行为(未配置时)生产建议
ttl (过期时间)默认为 0(表示永不过期必须按需配置。业务数据通常具备时效性,通过 TTL 保证数据在未被主动清理时能定期回源。
max (最大容量)默认为 Infinity无条目上限内存缓存场景下强烈建议配置。防止突发流量或恶意请求撑爆 Node.js 堆内存(默认约 1.4~4GB)导致 OOM 崩溃。

💡 存储介质选型差异

  • 单机内存模式(In-Memory):必须同时配置 maxttl,筑牢内存安全防线。
  • 分布式 Redis 模式:内存压力转移到了 Redis 实例,max 可交由 Redis 自身的 maxmemory-policy 策略管理,但应用层仍需配置合理的全局 ttl

接口响应自动缓存(基于 Interceptor)

如果仅需将 Controller 接口的完整响应直接缓存起来,使用 NestJS 内置的 CacheInterceptor 最为便捷。

1. 基础用法与装饰器说明

// users.controller.ts
import { Controller, Get, UseInterceptors } from "@nestjs/common";
import { CacheInterceptor, CacheKey, CacheTTL } from "@nestjs/cache-manager";
import { UsersService } from "./users.service";

@Controller("users")
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    /**
     * 基础用法:默认根据请求 URL 生成 Key,继承全局 TTL
     */
    @Get("ranking")
    @UseInterceptors(CacheInterceptor) // 启用自动响应缓存
    async getRanking() {
        return this.usersService.getHeavyRanking();
    }

    /**
     * 进阶用法:自定义固定 Key 与局部 TTL
     */
    @Get("dashboard/metrics")
    @UseInterceptors(CacheInterceptor)
    @CacheKey("static_dashboard_metrics") // 覆盖默认 URL Key,指定固定 Key
    @CacheTTL(10000) // 覆盖全局配置,当前接口缓存 10 秒 (10000ms)
    async getMetrics() {
        return this.usersService.getMetrics();
    }
}

常用装饰器速查

  • @UseInterceptors(CacheInterceptor)必选。作为开关,缺失则后续缓存装饰器均不生效。
  • @CacheKey('key')可选。若省略,Nest 默认以当前请求的 Route Path 作为 Key。
  • @CacheTTL(ms)可选。若省略,自动继承 AppModule 中注册的全局 TTL。

2. 进阶定制:重写 Interceptor(支持 Query 参数与用户隔离)

⚠️ 官方内置拦截器的局限
默认的 CacheInterceptor 仅提取静态路由 Path,会自动忽略 Query 参数(例如 /users?page=1/users?page=2 会错误地命中同一份缓存)。此外,如果接口涉及鉴权,也极易造成不同用户之间的数据混淆。
不同用户访问同一接口时,若缓存 Key 仅基于 URL Path,则可能出现 A 用户的缓存数据被 B 用户读取的情况。

解决方案:继承 CacheInterceptor 并重写 trackBy 方法:

// common/interceptors/http-cache.interceptor.ts
import { ExecutionContext, Injectable } from "@nestjs/common";
import { CacheInterceptor } from "@nestjs/cache-manager";
import { Request } from "express";

@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
    trackBy(context: ExecutionContext): string | undefined {
        const httpContext = context.switchToHttp();
        const request = httpContext.getRequest<Request>();
        const { httpAdapter } = this.httpAdapterHost;

        // 仅对 GET 请求开启缓存
        if (httpAdapter.getRequestMethod(request) !== "GET") {
            return undefined;
        }

        // 1. 提取包含 Query 参数的完整 URL(如 /users?page=2&limit=10)
        const requestUrl = httpAdapter.getRequestUrl(request);

        // 2. 隔离用户数据:已登录使用用户 ID,未登录使用 public
        const userId = (request as any).user?.id || "public";

        return `http_cache:${userId}:${requestUrl}`;
    }
}

使用方式

@Get()
@UseInterceptors(HttpCacheInterceptor) // 替换为自定义拦截器
async findAll(@Query() query: PaginationDto) {
  return this.usersService.findAll(query);
}

在 Service 中编程式操作缓存(Cache-Aside 模式)

对于复杂业务逻辑、第三方 API 调用或局部数据缓存,建议直接在 Service 中注入 CACHE_MANAGER 实现经典的 Cache-Aside(旁路缓存) 模式。

// users.service.ts
import { Injectable, Inject, Logger } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager"; // 注入令牌
import { Cache } from "cache-manager"; // 类型声明
import { User } from "./entities/user.entity";

@Injectable()
export class UsersService {
    private readonly logger = new Logger(UsersService.name);

    constructor(
        @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
        private readonly userRepository: UserRepository,
    ) {}

    async findOne(id: string): Promise<User | null> {
        const cacheKey = `user:entity:${id}`;

        // 1. 先查缓存
        const cachedData = await this.cacheManager.get<User>(cacheKey);
        if (cachedData) {
            this.logger.debug(`Cache Hit: ${cacheKey}`);
            return cachedData;
        }

        // 2. Cache Miss: 回源查询数据库
        const user = await this.userRepository.findById(id);
        if (!user) {
            return null;
        }

        // 3. 回写缓存(第 3 个参数为当前 Key 的专属 TTL,单位:毫秒)
        await this.cacheManager.set(cacheKey, user, 60000);

        return user;
    }

    async update(id: string, dto: UpdateUserDto): Promise<User> {
        const updatedUser = await this.userRepository.update(id, dto);

        // 4. 数据变更时:主动淘汰对应缓存
        await this.cacheManager.del(`user:entity:${id}`);

        return updatedUser;
    }
}

对接 Redis 分布式缓存(Keyv 体系)

NestJS 最新规范全面迁移至 Keyv 适配层体系。不再推荐使用旧版 cache-manager-redis-store,统一采用官方适配器 @keyv/redis

1. 安装依赖

pnpm add @keyv/redis

2. 改造 AppModule

使用 createKeyv 将 Redis 实例作为 Store 传入 stores 数组:

// app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { CacheModule } from "@nestjs/cache-manager";
import { createKeyv } from "@keyv/redis"; // Keyv 官方 Redis 适配函数

@Module({
    imports: [
        CacheModule.registerAsync({
            isGlobal: true,
            imports: [ConfigModule],
            inject: [ConfigService],
            useFactory: async (configService: ConfigService) => {
                const redisUri = configService.get<string>("REDIS_URI", "redis://localhost:6379");

                return {
                    // 通过 stores 挂载 Keyv 适配器
                    stores: [createKeyv(redisUri)],
                    ttl: configService.get<number>("CACHE_TTL", 60000), // 全局默认 TTL
                };
            },
        }),
    ],
})
export class AppModule {}
💡 架构扩展:多级缓存(Multi-Store)
stores 是一个数组,Keyv 天然支持多级缓存模式。若需实现 “内存(L1 极速)+ Redis(L2 分布式)”,只需按优先级在 stores 数组中依次传入多个 Store 实例,Keyv 会自动按照顺序逐级回退查找。

生产避坑指南

下面是一些在生产环境中使用缓存时常见的坑与解决方案:

1. 序列化后丢失类方法与原型(Prototype Loss)

深度原理:

当使用 Redis 作为存储介质时,所有存入的数据在底层都会被序列化为字符串(类似于 JSON.stringify())。当从缓存读取时,JSON.parse() 只能还原为纯 JavaScript 对象(Plain Object),这会引发两个典型问题:

  1. 类原型丢失:实体类上定义的 Getter 或成员方法(如 user.getFullName())直接丢失,调用时会抛出 TypeError: ... is not a function
  2. 特殊类型退化Date 实例会退化为 ISO 格式的 stringBigInt 甚至无法直接被 JSON 序列化。

解决方案与实战代码:

利用 NestJS 生态标配的 class-transformer,在读取缓存后使用 plainToInstance 重构对象原型:

// 1. 实体定义:user.entity.ts
import { Type } from "class-transformer";

export class UserEntity {
    id: string;
    firstName: string;
    lastName: string;

    // 关键:声明类型,确保反序列化时能把 string 还原为 Date 对象
    @Type(() => Date)
    createdAt: Date;

    // 成员方法 / Getter
    get fullName(): string {
        return `${this.firstName} ${this.lastName}`;
    }

    isVip(): boolean {
        return true;
    }
}
// 2. 业务层:users.service.ts
import { Injectable, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { plainToInstance } from "class-transformer";
import { UserEntity } from "./user.entity";

@Injectable()
export class UsersService {
    constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

    async getUser(id: string): Promise<UserEntity | null> {
        const cacheKey = `user:${id}`;

        // 从缓存读取(此时 cachedRaw 仅是纯 JSON 对象,没有 UserEntity 的原型)
        const cachedRaw = await this.cacheManager.get<UserEntity>(cacheKey);

        if (cachedRaw) {
            // 核心:使用 plainToInstance 将纯 Object 重新绑定回 UserEntity 原型
            const userInstance = plainToInstance(UserEntity, cachedRaw);

            //  此时可以正常调用原型方法与 Date 对象
            console.log(userInstance.fullName); // 正常运行
            console.log(userInstance.createdAt.getTime()); // 正常运行
            return userInstance;
        }

        // 缓存未命中时查库
        const userFromDb = await this.fetchFromDb(id);
        if (userFromDb) {
            await this.cacheManager.set(cacheKey, userFromDb, 60000);
        }
        return userFromDb;
    }
}

2. 缓存穿透防范(Cache Penetration)

深度原理:

  • 什么是穿透:恶意客户端高频请求数据库和缓存中都不存在的数据(例如不断请求 id = -999 或随机生成的 UUID)。
  • 危害:因为缓存中永远没有这条数据,每次请求都会“穿透”缓存直达数据库。高并发下会瞬间耗尽数据库连接池,造成 DB 宕机。

解决方案与实战代码:

最通用且低成本的防御方式是:缓存空值(Cache Null / Sentinel Value)
当数据库返回 null 时,依然将 null 写入缓存,并设置一个极短的 TTL(如 30 秒),阻断短时间内的重复穿透。

// users.service.ts
import { Injectable, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";

@Injectable()
export class UsersService {
    constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

    async findOneWithAntiPenetration(id: string) {
        const cacheKey = `user:detail:${id}`;

        // 1. 读取缓存
        const cached = await this.cacheManager.get(cacheKey);

        // 必须用 !== undefined 判断,因为 null 也是合法缓存值!
        if (cached !== undefined) {
            // 如果读出来是 null,说明命中防穿透缓存,直接返回 null,不打 DB
            if (cached === null) {
                return null;
            }
            return cached;
        }

        // 2. Cache Miss: 回源查询数据库
        const user = await this.userRepository.findById(id);

        if (!user) {
            // 3. 数据库也没有:写入空值缓存,给极短的过期时间(例如 30 秒)
            // 这样 30 秒内相同的恶意请求都会在缓存层被直接拦截,保护 DB
            await this.cacheManager.set(cacheKey, null, 30 * 1000);
            return null;
        }

        // 4. 数据正常存在:写入正常 TTL(例如 1 小时)
        await this.cacheManager.set(cacheKey, user, 3600 * 1000);
        return user;
    }
}
💡 进阶防御方案
如果系统面临海量恶意探测(如爬虫扫描),可以在进入 Service 之前结合布隆过滤器(Bloom Filter),在内存中直接判定某个 Key 是否绝对不存在。

3. 数据一致性策略:为什么淘汰缓存(Evict)优于更新缓存(Update)?

深度原理:并发写下的“脏数据覆盖”:

假设有两个并发写请求(线程 A 和 线程 B)先后修改同一条数据。如果采用 “更新数据库后,直接更新缓存” 的策略:

[ 并发写导致脏缓存的时序图 ]
1. 请求 A 更新数据库为【值 A】
2. 请求 B 更新数据库为【值 B】
3. 请求 B 先执行完毕,更新缓存为【值 B】
4. 请求 A 因网络延迟后执行,更新缓存为【值 A】

 结果:数据库最终是【值 B】,但缓存却被覆盖成了【值 A】!
      此时数据产生长期不一致,直到 TTL 过期。

此外,如果某个数据写入频繁但读取极少(写多读少),每次写操作都去算一遍并更新缓存,会产生大量的计算与 I/O 浪费

解决方案:Cache-Aside 标准做法(更新 DB -> 删除 Cache):

采用“淘汰缓存(删除)”不仅能避免并发脏覆盖,还天然具备懒加载(Lazy Load)特性,只有在下次真正有读请求时才会重新构建缓存。

// users.service.ts
import { Injectable, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { DataSource } from "typeorm";

@Injectable()
export class UsersService {
    constructor(
        @Inject(CACHE_MANAGER) private cacheManager: Cache,
        private dataSource: DataSource,
    ) {}

    /**
     * 标准修改流程:先操作数据库,成功后再删除缓存(Cache Eviction)
     */
    async updateUser(id: string, updateDto: UpdateUserDto) {
        const cacheKey = `user:detail:${id}`;

        // 1. 在数据库事务中更新数据(确保 DB 写入成功)
        const updatedUser = await this.dataSource.transaction(async (manager) => {
            return await manager.save(UserEntity, { id, ...updateDto });
        });

        // 2. 数据库更新成功后,执行淘汰删除
        // 即使并发写发生,下一次读请求也只会从 DB 读取最新的最终一致数据
        await this.cacheManager.del(cacheKey);

        return updatedUser;
    }

    /**
     * 进阶补充:针对读写分离架构的“延迟双删”(可选)
     * 如果数据库有主从复制延迟,可以在删除缓存后,延迟几百毫秒再删一次
     */
    async updateUserWithDoubleDel(id: string, updateDto: UpdateUserDto) {
        const cacheKey = `user:detail:${id}`;

        // 1. 第一次删除缓存
        await this.cacheManager.del(cacheKey);

        // 2. 更新主库
        const user = await this.userRepository.update(id, updateDto);

        // 3. 延迟 500ms(等待从库同步完成),再次删除缓存,防止从库旧数据被回写
        setTimeout(async () => {
            await this.cacheManager.del(cacheKey);
        }, 500);

        return user;
    }
}

[/hide]

❌
❌