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=assigned、name__like=张、age__gte=18、projectId__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_VALUE;like 类只允许 string 字段,对 number 用 like 直接 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 一样的"数量护栏"思维。
六、踩坑后的几条心得
-
白名单优于黑名单。未知字段一律拒绝,比"只允许这几个安全字段,其余拦一下"稳得多。前者默认安全,后者默认漏。
-
解析与执行分离 。
buildListQuery是纯函数,不依赖数据库,单测极好写------我有一整套query.test.ts覆盖"未知字段报错 / 算子越权报错 / 手机号标准化 / 掩码查询拒绝"。这层一旦测试覆盖,所有列表接口共用同一份正确性保证。 -
脱敏要在查询层统一收口 。
sensitiveFields跟着投影走,列表和详情走同一套maskRows,不会出现"列表脱敏、详情漏脱敏"的缝。 -
数量护栏(scanLimit / MAX_SIZE)是性价比最高的性能保险。列表接口最怕两种打爆:一是 LIKE 全表扫,二是 size 失控拉全表。两个常量把这两件事钉死,代码层面就不可能再犯。
-
base 条件不可省略 。租户隔离和软删放在
runList的base里由调用方传入但强制 AND------哪怕某个接口忘了传业务过滤,隔离和软删也绝不会丢。这是多租户系统最后一道兜底。
做完这套之后,二三十个列表接口从"每个几百行样板 + 各写各的隔离脱敏",收敛成"一份白名单声明 + 几行 runList 调用"。安全(注入、越权、超量)和体验(分页、投影、脱敏)在一次封装里全收口,后面加新列表,爽得不行。
相关阅读:
- Node 后端实战 · 边缘 Cron 定时任务怎么写?Cloudflare 三个实战任务与踩坑
- Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
- Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
- Node 后端实战 · 多租户 SaaS 的数据隔离
- Koa 如何设计安全的 JWT 用户会话系统
- Node.js 使用 RSA 非对称加密保护接口数据安全
- Nodejs 实现 Mysql 数据库的全量备份的代码演示
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!