Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定

Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定

各位看官,今天聊一个每个后台系统都绕不开、又最容易写出几百行样板代码的东西------列表查询

只要你的系统有"管理后台",就必然有无数个列表:用户列表、订单列表、客户列表、日志列表......每一个都要支持过滤、分页、排序、按字段投影、敏感字段脱敏。我接手后端时,这类接口有二三十个,几乎都是复制粘贴出来的:

ts 复制代码
// 早期的样子:每个列表接口都这么拼一遍
let where = and(eq(table.tenantId, tid), isNull(table.deletedAt));
if (status) where = and(where, eq(table.status, status));
if (name) where = and(where, like(table.name, `%${name}%`));
if (minAge) where = and(where, gte(table.age, minAge));
// 还有排序、分页、脱敏......
const total = await db.select({ n: count() }).from(table).where(where);
const rows = await db.select().from(table).where(where).orderBy(...).limit(size).offset(...);

问题在哪?三个:重复 (改一个逻辑要改 N 处)、易漏 (谁都可能忘掉租户隔离、忘掉脱敏、忘掉给 size 封顶)、不安全(手写字符串拼接迟早碰上注入)。

后来我把这套逻辑抽成了一个声明式查询层 :一个 buildListQuery 负责解析,一个 runList 负责执行,一个 paginate 负责响应。二三十个列表接口从此收敛成几行声明。下面把完整实现拆开讲,代码都来自真实项目,可以直接抄。

一、核心思路:不写 if,写"声明"

关键转变是------不再在每个接口里用 if 拼条件,而是声明"这张表允许被怎么查"

查询参数约定一套简单的 DSL:用 字段__操作符=值 表达过滤,比如 status=assignedname__like=张age__gte=18projectId__in=p1,p2。后端只认白名单里的字段和算子,其余一律拒绝。

先看"允许查询字段"的声明长什么样(这是整个体系的安全基座):

ts 复制代码
/** 客户表可查询字段白名单(实际项目里每张表一份) */
const CUSTOMER_ALLOWED = {
  id:         { column: customers.id,         type: "string" },
  name:       { column: customers.name,       type: "string" },
  phone:      { column: customers.phone,      type: "string", sensitive: true, phone: true },
  company:    { column: customers.company,    type: "string" },
  ownerId:    { column: customers.ownerId,    type: "string" },
  projectId:  { column: customers.projectId,  type: "string" },
  level:      { column: customers.level,      type: "enum" },
  createdAt:  { column: customers.createdAt,  type: "date" },
  // ......其余字段
} as const;

三个元信息决定了整个查询的边界:type (决定能用哪些操作符)、sensitive (是否脱敏)、phone(是否手机号、查询前是否标准化)。写错一个类型,后面就少一个攻击面。

二、解析器 buildListQuery:把 URL 参数变成 Drizzle 条件

buildListQuery 是一个纯函数,输入白名单 + 原始参数,输出可以直接 and(...) 的条件数组、排序、分页、投影和敏感字段清单。

ts 复制代码
export const buildListQuery = (input: BuildListQueryInput): BuildListQueryResult => {
  const { allowedFields, params } = input;
  const conditions: SQL[] = [];

  // 1) 遍历扁平参数,解析 field__op=value
  for (const [key, rawValue] of Object.entries(params)) {
    if (RESERVED.has(key)) continue;              // 跳过 q/sort/page/size/fields...
    const sep = key.indexOf("__");
    const fieldName = sep === -1 ? key : key.slice(0, sep);
    const op = sep === -1 ? "eq" : key.slice(sep + 2);
    const field = allowedFields[fieldName];
    if (!field) throw err("INVALID_FILTER_FIELD", `unknown field ${fieldName}`);
    if (!OPS.has(op)) throw err("INVALID_OPERATOR", `unknown operator ${op}`);
    conditions.push(buildCondition(fieldName, field, op, rawValue));
  }

  // 2) q 跨字段 OR 搜索(如按 姓名/公司/手机号 同时搜)
  const q = params["q"];
  if (q && q.trim()) {
    if (input.requireFilterWithQ && conditions.length === 0) {
      throw err("INVALID_FILTER_VALUE", "q requires at least one filter condition");
    }
    scanLimit = LIST.Q_SCAN_LIMIT;                // 触发扫描上限保护(见第三节)
    const escaped = `%${escapeLike(q.trim())}%`;
    conditions.push(or(...input.qFields!.map((c) => like(c, escaped)))!);
  }

  // 3) 排序白名单:sort=-createdAt,company
  // 4) 分页:page 最小 1,size 封顶 MAX_SIZE
  // 5) 投影:fields=name,phone 只取指定列;全列时收集敏感字段供脱敏
  // 6) 返回 { conditions, sort, page, size, projection, sensitiveFields, scanLimit }
};

白名单为什么是安全基座

很多团队做动态查询习惯"黑名单"------默认允许一切,只拦几个危险字段。这是反的。白名单的逻辑是:不在清单里的字段,直接 INVALID_FILTER_FIELD 拒绝。好处有三:

维度 白名单做法 手写 if 拼接 收益
任意列过滤 拒绝未知字段,拼不出任意列 容易漏校验拼出任意列 防越权查询
SQL 注入 值经 Drizzle 构造器参数化绑定 手写字符串拼接易漏 天然防注入
类型/算子错用 like 仅限 string、gt 仅限 number/date,错用即报错 全靠人肉注意 防类型错乱

buildCondition 里还有两个很实用的安全细节,是踩坑后才加的:

ts 复制代码
// ① 敏感字段拒绝"脱敏掩码"查询:防止用 138****1234 反查明文,绕过脱敏
if (field.sensitive && VALUE_OPS.has(op) && isMasked(value)) {
  throw err("INVALID_FILTER_VALUE", `masked value not queryable on ${fieldName}`);
}
// ② 手机号查询前标准化:"138-1234-5678" → "13812345678",否则查不到
const queryValue = field.phone ? normalizePhone(value) : value;

isMasked 就是判断字符串里有没有 *。这个坑真实存在------列表返回的是 138****5678,如果用户拿这个掩码去过滤,要么查不到,要么被有心人用来试探。直接在查询层掐掉最干净。

算子全集

声明式的好处之一是算子是固定的、可枚举的,前端和后端共用一套语义:

类别 算子 含义
等值 eq ne 等于 / 不等于
数值/日期 gt gte lt lte between 大小比较、min~max 区间
字符串 like notLike startsWith endsWith 模糊 / 前缀 / 后缀
集合 in notIn a,b,c 逗号分隔
空值 isNull isNotNull isEmpty isNotEmpty 空 / 非空 / 空串

between 要求 min~max 两段,缺一段直接 INVALID_FILTER_VALUElike 类只允许 string 字段,对 numberlike 直接 INVALID_OPERATOR。这些约束把"参数怎么乱传都不会炸"焊死在了解析层。

三、执行器 runList:解析之后,真正的"跑"

buildListQuery 只负责"把意图翻译成条件",不碰数据库。真正的执行交给 runList,它最关键的设计是强制基础条件(base)

ts 复制代码
export const runList = async <T>(opts: RunListOptions): Promise<RunListResult<T>> => {
  const { db, table, allowedFields, params, base = [], defaultSort, qFields } = opts;
  const q = buildListQuery({ allowedFields, params, defaultSort, qFields });

  // base 永远 AND 生效:租户隔离 + 软删,调用方绕不过
  const where = base.length ? and(...base, ...q.conditions) : and(...q.conditions);

  let total: number;
  if (q.scanLimit !== null) {
    // DB-01:有 q 搜索时,count 包一层 LIMIT 子查询,防全表 LIKE 扫描
    const whereClause = where ? sql`WHERE ${where}` : sql``;
    const row = await db.get<{ n: number }>(
      sql`SELECT count(*) AS n FROM (SELECT 1 FROM ${table} ${whereClause} LIMIT ${q.scanLimit})`,

    );
    total = Number(row?.n ?? 0);
  } else {
    const totalRow = await db.select({ n: count() }).from(table).where(where);
    total = Number(totalRow[0]?.n ?? 0);
  }

  const rows = await db.select(q.projection as never)
    .from(table).where(where).orderBy(...q.sort)
    .limit(q.size).offset((q.page - 1) * q.size);

  const items = maskRows(rows, q.sensitiveFields) as T[];   // 脱敏在最后一关统一做
  return { items, total, page: q.page, size: q.size };
};

一个真实性能坑:q 搜索别直接 count

注意 if (q.scanLimit !== null) 那段。这背后是个实打实的事故:当用户用 q 做模糊搜索时,如果用普通的 SELECT count(*) FROM table WHERE ... LIKE '%关键词%',在 SQLite / D1 上会退化为全表扫描 ------LIKE 带前导通配符无法命中索引,表里几十万行就全扫一遍,列表接口直接被打慢。

解法很朴素:把 count 包一层子查询,先 LIMIT 2000 再数:

sql 复制代码
SELECT count(*) AS n
FROM (SELECT 1 FROM customers WHERE <条件> AND (name LIKE ? OR phone LIKE ?) LIMIT 2000);

Q_SCAN_LIMIT = 2000 把"搜索扫描量"钉死。代价是:极端情况下(匹配数超过 2000)总数会显示"≥2000 封顶",但这是"搜索可用性 vs 性能"的合理取舍------真要搜超大结果集,应该上全文索引或 ES,而不是让列表接口裸奔。我把它叫 DB-01,是这套系统里最重要的一个性能护栏。

四、分页响应信封:前端要的只是一个结构

查询跑了,最后统一交给 paginate 出响应,保证全站列表返回结构一致:

ts 复制代码
export const paginate = <T>(c: Context, items: T[], total: number, page: number, size: number): Response =>
  c.json({
    success: true,
    data: { items, total, page, size, pages: Math.max(1, Math.ceil(total / size)) },
    error: null,
  }, 200);

pages 直接算好给前端,前端不用自己再除一次。失败用同结构的 fail(c, code, message)------成功失败同一个信封,前端一个分支全吃下。

五、真实接线:一个列表接口到底有多短

把上面拼起来,一个带"租户隔离 + 软删 + 角色作用域 + 脱敏 + 搜索"的完整列表接口,落到代码上长这样:

ts 复制代码
tenantCustomerRoutes.get("/", async (c) => {
  const tid = requireTid(c);                 // 取租户 ID(隔离基石)
  const user = c.get("user")!;
  const params = { ...c.req.query() };

  // base:强制条件,永远 AND,调用方无法省略
  const base = [eq(customers.tenantId, tid), isNull(customers.deletedAt)];
  if (params.erased === "1") base.push(isNotNull(customers.erasedAt));
  else if (params.erased === "0") base.push(isNull(customers.erasedAt));

  // 角色作用域:一线人员只看自己归属
  if (user.role === ROLES.TE) base.push(eq(customers.ownerId, user.id));

  const result = await runList({
    db: getDb(c.env),
    table: customers,
    allowedFields: CUSTOMER_ALLOWED,
    params,
    base,
    defaultSort: [{ column: customers.createdAt, dir: "desc" }],
    qFields: [customers.name, customers.company, customers.phone],  // 跨字段 OR 搜索范围
  });
  return paginate(c, result.items, result.total, result.page, result.size);
});

一个请求到 SQL 的完整映射,给个直观例子:

URL 参数 含义 落到查询
status=assigned 状态等于 assigned eq(status,'assigned')
projectId__in=p1,p2 项目在 p1/p2 inArray(projectId,['p1','p2'])
name__like=张 姓名含"张" like(name,'%张%')
sort=-createdAt,company 按创建时间降序、公司升序 orderBy(desc(createdAt), asc(company))
page=2&size=50 第 2 页,每页 50 limit(50) offset(50)(size 超 200 自动封顶)
fields=name,phone 只取这两列 投影到指定列,其余不查

注意 size 即使传 9999,在 buildListQuery 里也被 Math.min(MAX_SIZE, ...) 钉在 200------防止有人恶意拉全表把实例内存打爆 ,这是和 scanLimit 一样的"数量护栏"思维。

六、踩坑后的几条心得

  1. 白名单优于黑名单。未知字段一律拒绝,比"只允许这几个安全字段,其余拦一下"稳得多。前者默认安全,后者默认漏。

  2. 解析与执行分离buildListQuery 是纯函数,不依赖数据库,单测极好写------我有一整套 query.test.ts 覆盖"未知字段报错 / 算子越权报错 / 手机号标准化 / 掩码查询拒绝"。这层一旦测试覆盖,所有列表接口共用同一份正确性保证。

  3. 脱敏要在查询层统一收口sensitiveFields 跟着投影走,列表和详情走同一套 maskRows,不会出现"列表脱敏、详情漏脱敏"的缝。

  4. 数量护栏(scanLimit / MAX_SIZE)是性价比最高的性能保险。列表接口最怕两种打爆:一是 LIKE 全表扫,二是 size 失控拉全表。两个常量把这两件事钉死,代码层面就不可能再犯。

  5. base 条件不可省略 。租户隔离和软删放在 runListbase 里由调用方传入但强制 AND------哪怕某个接口忘了传业务过滤,隔离和软删也绝不会丢。这是多租户系统最后一道兜底。

做完这套之后,二三十个列表接口从"每个几百行样板 + 各写各的隔离脱敏",收敛成"一份白名单声明 + 几行 runList 调用"。安全(注入、越权、超量)和体验(分页、投影、脱敏)在一次封装里全收口,后面加新列表,爽得不行。


相关阅读:

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
抓蛙师1 小时前
多租户 tenant_id SQL 注入漏洞分析与应急响应报告
数据库·sql
ACP广源盛139246256731 小时前
Qwen3.8‑2.4T 开源落地@ACP#国产 MoE 私有化部署下 GSV2221 视频转换芯片机遇分析
大数据·数据库·人工智能
七牛开发者2 小时前
为什么 Go 很适合 AI 辅助开发?
数据库·人工智能·python·elasticsearch·log4j
_codemonster3 小时前
npm run dev 是在开发模式运行,怎么在生产环境运行
前端·npm·node.js
何以解忧,唯有..4 小时前
数据库索引失效的常见情况与优化策略
数据库·sql·oracle
这个DBA有点耶5 小时前
分布式数据库到底该不该上?从判断标准到架构选型的实战思考
数据库·架构·dba
01_ice5 小时前
MySQL库和表的操作
数据库·mysql
oradh6 小时前
Oracle UNDO表空间管理维护总结
数据库·oracle·undo表空间·undo表空间管理·undo表空间管理维护
布莱克6056 小时前
数据库索引分类:数据结构、物理存储与逻辑角度详解
数据结构·数据库