PAYATHON 2026

NestJS 如何统一处理分页和服务端搜索过滤?

数据小许

建议用可复用的查询 DTO 定义公共参数,配合全局 ValidationPipe 执行转换和校验,再用纯函数计算分页偏移量。搜索字段和查询条件的组合由业务 Service 或查询层决定。

这些机制可以按职责组合,比把所有逻辑塞进一个全局 Interceptor 更容易维护。

各部分适合承担什么职责?

机制适合承担的职责
DTO 基类定义 page、limit、search 的类型、默认值和校验规则
全局 ValidationPipe根据路由声明的 DTO 执行转换和校验
Helper Utility将分页参数转换为 skip、take 等查询选项
业务 Service 或查询层决定可搜索字段、权限范围、筛选条件和排序规则
自定义装饰器封装重复的参数提取方式,改善 Controller 的调用形式
Interceptor统一分页响应结构等横切行为

全局配置 ValidationPipe 不会让所有接口自动使用同一组分页参数。需要这些参数的接口仍应显式声明对应的 DTO。

1. 用 DTO 统一参数规则

以下示例使用 class-transformer 和 class-validator,前提是项目已安装并按 NestJS 的常规方式配置它们。示例默认页码为 1、每页 20 条、最多 100 条,可按业务调整。

import { Type } from 'class-transformer';
import {
  IsInt,
  IsOptional,
  IsString,
  Max,
  MaxLength,
  Min,
} from 'class-validator';

export class PaginationQueryDto {
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page: number = 1;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit: number = 20;
}

export class SearchPaginationQueryDto extends PaginationQueryDto {
  @IsOptional()
  @IsString()
  @MaxLength(200)
  search?: string;
}

如果只有部分接口支持搜索,可以让它们使用 SearchPaginationQueryDto;仅支持分页的接口使用 PaginationQueryDto。业务专属的筛选字段可以由各自的 DTO 扩展。

查询参数通常以字符串形式进入应用,TypeScript 的 number 类型标注不会自动完成运行时转换。示例通过 @Type(() => Number) 声明转换规则,再校验整数和取值范围。

在应用启动时注册全局 Pipe:

import { ValidationPipe } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    whitelist: true,
  }),
);

transform: true 让转换后的 DTO 实例进入处理函数;whitelist: true 会移除没有校验装饰器的字段。如果现有接口依赖未声明的查询字段,启用前应先补齐 DTO。

2. Controller 只负责接收参数

在已有 Controller 中,可以直接声明 DTO:

@Get()
list(@Query() query: SearchPaginationQueryDto) {
  return this.itemsService.list(query);
}

这里使用 NestJS 自带的 @Query() 就够了。只有多个接口确实重复了复杂的参数提取操作时,才需要考虑自定义装饰器。装饰器本身不应负责数据库查询。

DTO 应使用运行时存在的 class;如果改成 interface,或仅通过 import type 导入该类,就无法提供相同的运行时类型信息。

3. 复用分页计算,保留业务查询规则

偏移量计算可以写成纯函数,并约定调用方传入已校验的参数:

export function toOffsetPagination(query: PaginationQueryDto) {
  return {
    skip: (query.page - 1) * query.limit,
    take: query.limit,
  };
}

如果项目使用 TypeORM,下面的方法可以放入已有 Service。示例中的 itemsRepository、name 和 id 需要对应实际实体与字段:

async list(query: SearchPaginationQueryDto) {
  const { skip, take } = toOffsetPagination(query);
  const search = query.search?.trim();
  const qb = this.itemsRepository.createQueryBuilder('item');

  if (search) {
    qb.andWhere('item.name LIKE :search', {
      search: `%${search}%`,
    });
  }

  const [items, total] = await qb
    .orderBy('item.id', 'ASC')
    .skip(skip)
    .take(take)
    .getManyAndCount();

  return {
    items,
    total,
    page: query.page,
    limit: query.limit,
  };
}

过滤条件应在数据库分页之前加入查询。先取一页数据再用 JavaScript 过滤,会导致页内条数和总数与筛选结果不一致。

参数规则和分页计算可以复用。不同实体的搜索字段、关联条件、数据权限和索引情况通常不同,由具体查询实现决定更合适。等多个查询形成稳定的共同模式后,再提取查询组件。

需要注意的边界

  • 全局 Interceptor 不适合决定实体字段和数据库过滤规则。如果它负责包装分页响应,Service 仍需提供总数等必要信息。
  • 分页需要明确且稳定的排序。示例按 id 排序;使用可能重复的排序字段时,可以追加唯一字段作为排序依据。
  • SQL 参数绑定用于安全传值,但不会取消 LIKE 中 % 和 _ 的通配符含义。如果要求按字面搜索,需要根据数据库规则转义;大小写匹配行为也取决于数据库及其配置。
  • 客户端传入的过滤字段、排序字段应经过白名单映射,避免直接拼接到 SQL 中。租户和访问权限条件仍由服务端强制添加。
  • DTO 校验只覆盖经过相应 Pipe 的调用路径。如果 Service 也被定时任务或其他内部逻辑调用,需要明确这些调用方是否已经完成参数校验。

备注:内容仅供参考。