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

📅 2026/8/17 21:49:22
Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
Node 后端实战 · 列表查询到底怎么写一个通用 DSL 封装过滤分页排序一次搞定各位看官今天聊一个每个后台系统都绕不开、又最容易写出几百行样板代码的东西——列表查询。只要你的系统有管理后台就必然有无数个列表用户列表、订单列表、客户列表、日志列表……每一个都要支持过滤、分页、排序、按字段投影、敏感字段脱敏。我接手后端时这类接口有二三十个几乎都是复制粘贴出来的// 早期的样子每个列表接口都这么拼一遍letwhereand(eq(table.tenantId,tid),isNull(table.deletedAt));if(status)whereand(where,eq(table.status,status));if(name)whereand(where,like(table.name,%${name}%));if(minAge)whereand(where,gte(table.age,minAge));// 还有排序、分页、脱敏……consttotalawaitdb.select({n:count()}).from(table).where(where);constrowsawaitdb.select().from(table).where(where).orderBy(...).limit(size).offset(...);问题在哪三个重复改一个逻辑要改 N 处、易漏谁都可能忘掉租户隔离、忘掉脱敏、忘掉给size封顶、不安全手写字符串拼接迟早碰上注入。后来我把这套逻辑抽成了一个声明式查询层一个buildListQuery负责解析一个runList负责执行一个paginate负责响应。二三十个列表接口从此收敛成几行声明。下面把完整实现拆开讲代码都来自真实项目可以直接抄。一、核心思路不写 if写声明关键转变是——不再在每个接口里用 if 拼条件而是声明这张表允许被怎么查。查询参数约定一套简单的 DSL用字段__操作符值表达过滤比如statusassigned、name__like张、age__gte18、projectId__inp1,p2。后端只认白名单里的字段和算子其余一律拒绝。先看允许查询字段的声明长什么样这是整个体系的安全基座/** 客户表可查询字段白名单实际项目里每张表一份 */constCUSTOMER_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},// ……其余字段}asconst;三个元信息决定了整个查询的边界type决定能用哪些操作符、sensitive是否脱敏、phone是否手机号、查询前是否标准化。写错一个类型后面就少一个攻击面。二、解析器 buildListQuery把 URL 参数变成 Drizzle 条件buildListQuery是一个纯函数输入白名单 原始参数输出可以直接and(...)的条件数组、排序、分页、投影和敏感字段清单。exportconstbuildListQuery(input:BuildListQueryInput):BuildListQueryResult{const{allowedFields,params}input;constconditions:SQL[][];// 1) 遍历扁平参数解析 field__opvaluefor(const[key,rawValue]ofObject.entries(params)){if(RESERVED.has(key))continue;// 跳过 q/sort/page/size/fields…constsepkey.indexOf(__);constfieldNamesep-1?key:key.slice(0,sep);constopsep-1?eq:key.slice(sep2);constfieldallowedFields[fieldName];if(!field)throwerr(INVALID_FILTER_FIELD,unknown field${fieldName});if(!OPS.has(op))throwerr(INVALID_OPERATOR,unknown operator${op});conditions.push(buildCondition(fieldName,field,op,rawValue));}// 2) q 跨字段 OR 搜索如按 姓名/公司/手机号 同时搜constqparams[q];if(qq.trim()){if(input.requireFilterWithQconditions.length0){throwerr(INVALID_FILTER_VALUE,q requires at least one filter condition);}scanLimitLIST.Q_SCAN_LIMIT;// 触发扫描上限保护见第三节constescaped%${escapeLike(q.trim())}%;conditions.push(or(...input.qFields!.map((c)like(c,escaped)))!);}// 3) 排序白名单sort-createdAt,company// 4) 分页page 最小 1size 封顶 MAX_SIZE// 5) 投影fieldsname,phone 只取指定列全列时收集敏感字段供脱敏// 6) 返回 { conditions, sort, page, size, projection, sensitiveFields, scanLimit }};白名单为什么是安全基座很多团队做动态查询习惯黑名单——默认允许一切只拦几个危险字段。这是反的。白名单的逻辑是不在清单里的字段直接INVALID_FILTER_FIELD拒绝。好处有三维度白名单做法手写 if 拼接收益任意列过滤拒绝未知字段拼不出任意列容易漏校验拼出任意列防越权查询SQL 注入值经 Drizzle 构造器参数化绑定手写字符串拼接易漏天然防注入类型/算子错用like仅限 string、gt仅限 number/date错用即报错全靠人肉注意防类型错乱buildCondition里还有两个很实用的安全细节是踩坑后才加的// ① 敏感字段拒绝脱敏掩码查询防止用 138****1234 反查明文绕过脱敏if(field.sensitiveVALUE_OPS.has(op)isMasked(value)){throwerr(INVALID_FILTER_VALUE,masked value not queryable on${fieldName});}// ② 手机号查询前标准化138-1234-5678 → 13812345678否则查不到constqueryValuefield.phone?normalizePhone(value):value;isMasked就是判断字符串里有没有*。这个坑真实存在——列表返回的是138****5678如果用户拿这个掩码去过滤要么查不到要么被有心人用来试探。直接在查询层掐掉最干净。算子全集声明式的好处之一是算子是固定的、可枚举的前端和后端共用一套语义类别算子含义等值eqne等于 / 不等于数值/日期gtgteltltebetween大小比较、min~max区间字符串likenotLikestartsWithendsWith模糊 / 前缀 / 后缀集合innotIna,b,c逗号分隔空值isNullisNotNullisEmptyisNotEmpty空 / 非空 / 空串between要求min~max两段缺一段直接INVALID_FILTER_VALUElike类只允许string字段对number用like直接INVALID_OPERATOR。这些约束把参数怎么乱传都不会炸焊死在了解析层。三、执行器 runList解析之后真正的跑buildListQuery只负责把意图翻译成条件不碰数据库。真正的执行交给runList它最关键的设计是强制基础条件baseexportconstrunListasyncT(opts:RunListOptions):PromiseRunListResultT{const{db,table,allowedFields,params,base[],defaultSort,qFields}opts;constqbuildListQuery({allowedFields,params,defaultSort,qFields});// base 永远 AND 生效租户隔离 软删调用方绕不过constwherebase.length?and(...base,...q.conditions):and(...q.conditions);lettotal:number;if(q.scanLimit!null){// DB-01有 q 搜索时count 包一层 LIMIT 子查询防全表 LIKE 扫描constwhereClausewhere?sqlWHERE${where}:sql;constrowawaitdb.get{n:number}(sqlSELECT count(*) AS n FROM (SELECT 1 FROM${table}${whereClause}LIMIT${q.scanLimit}),);totalNumber(row?.n??0);}else{consttotalRowawaitdb.select({n:count()}).from(table).where(where);totalNumber(totalRow[0]?.n??0);}constrowsawaitdb.select(q.projectionasnever).from(table).where(where).orderBy(...q.sort).limit(q.size).offset((q.page-1)*q.size);constitemsmaskRows(rows,q.sensitiveFields)asT[];// 脱敏在最后一关统一做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再数SELECTcount(*)ASnFROM(SELECT1FROMcustomersWHERE条件AND(nameLIKE?ORphoneLIKE?)LIMIT2000);Q_SCAN_LIMIT 2000把搜索扫描量钉死。代价是极端情况下匹配数超过 2000总数会显示≥2000 封顶但这是搜索可用性 vs 性能的合理取舍——真要搜超大结果集应该上全文索引或 ES而不是让列表接口裸奔。我把它叫DB-01是这套系统里最重要的一个性能护栏。四、分页响应信封前端要的只是一个结构查询跑了最后统一交给paginate出响应保证全站列表返回结构一致exportconstpaginateT(c:Context,items:T[],total:number,page:number,size:number):Responsec.json({success:true,data:{items,total,page,size,pages:Math.max(1,Math.ceil(total/size))},error:null,},200);pages直接算好给前端前端不用自己再除一次。失败用同结构的fail(c, code, message)——成功失败同一个信封前端一个分支全吃下。五、真实接线一个列表接口到底有多短把上面拼起来一个带租户隔离 软删 角色作用域 脱敏 搜索的完整列表接口落到代码上长这样tenantCustomerRoutes.get(/,async(c){consttidrequireTid(c);// 取租户 ID隔离基石constuserc.get(user)!;constparams{...c.req.query()};// base强制条件永远 AND调用方无法省略constbase[eq(customers.tenantId,tid),isNull(customers.deletedAt)];if(params.erased1)base.push(isNotNull(customers.erasedAt));elseif(params.erased0)base.push(isNull(customers.erasedAt));// 角色作用域一线人员只看自己归属if(user.roleROLES.TE)base.push(eq(customers.ownerId,user.id));constresultawaitrunList({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 搜索范围});returnpaginate(c,result.items,result.total,result.page,result.size);});一个请求到 SQL 的完整映射给个直观例子URL 参数含义落到查询statusassigned状态等于 assignedeq(status,assigned)projectId__inp1,p2项目在 p1/p2inArray(projectId,[p1,p2])name__like张姓名含张like(name,%张%)sort-createdAt,company按创建时间降序、公司升序orderBy(desc(createdAt), asc(company))page2size50第 2 页每页 50limit(50) offset(50)size 超 200 自动封顶fieldsname,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 优化校阅转发请注明首发地址谢谢大家