成为全栈·Node 后端篇·列表接口三件套:分页、筛选、排序

成为全栈·Node 后端篇·列表接口三件套:分页、筛选、排序

列表接口是后端最容易被低估的东西------"不就是查一批数据、翻个页吗"。直到有一天运营告诉你第 50 页和第 51 页出现了同一篇文章,或者有人往 sort 参数里塞了一段 SQL。分页、筛选、排序这三件事,每一件都有它专属的翻车方式。

这一篇把列表接口的三件套一次讲透:分页怎么保证不重不漏、筛选怎么用白名单挡住 SQL 注入、排序怎么避开连表后的 ambiguous column,顺便给 LIKE 全表扫描装一个刹车。顺带说清一件事:分页要用的 total / totalPages 一律由后端算好返回,别让前端拿着一页数据去猜总数。

一、分页:offset 分页 + 稳定键

最常用的分页是 offset 分页 :?page=2&pageSize=20 算出 offset = (page-1)*pageSize,SQL 里 LIMIT 20 OFFSET 20。我们的 parsePage 在解析时就把边界钳死了:

ts 复制代码
// pagination.ts --- parsePage
const page = Math.max(1, Number(c.req.query('page') ?? 1) || 1);
const pageSize = Math.min(100, Math.max(1, Number(c.req.query('pageSize') ?? 20) || 20));
return { page, pageSize, offset: (page - 1) * pageSize };

两个细节:

  • page 和 pageSize 都要钳制 。page 最小 1(不能传 0 或负数搞出负 offset),pageSize 夹在 [1, 100]------既防传 0 的离谱请求,也防有人传 pageSize=10000 一次性拖垮数据库。这是列表接口的第一道护栏。
  • 返回 total 和 totalPages (契约 Pagination 四件套)。前端才能画"第 2 / 共 8 页"的分页器。

还有个更隐蔽的点:排序必须有稳定键,否则翻页会"重漏" 。想象按 viewCount 排序,第 1 页末尾和第 2 页开头有几篇文章 viewCount 恰好相等。如果排序只写 ORDER BY view_count DESC,数据库不保证这些"并列"行的相对顺序,两次查询可能把同一行既放第 1 页又放第 2 页(重复),或两边都漏掉(丢失)。解法是在末尾恒接一个唯一稳定键:

ts 复制代码
// pagination.ts --- buildSortSql 末尾
return sql`${sql.raw(column)} ${sql.raw(dir)}, articles.id DESC`;

articles.id DESC 作为兜底键,保证并列时顺序确定,翻页绝不重漏。这是列表接口的"基本功",但新手十个有九个漏写。

关于分页还有一个常被问的问题:为什么用 offset 而不是游标(cursor)分页?游标分页(WHERE id > lastId LIMIT 20)在超大数据集上更高效、且不受中间插入删除影响,但它有两个代价:一是不能"跳到第 10 页"(必须顺序翻),二是实现复杂。我们的内容站规模有限(文章几千到几万),offset 分页配合稳定键完全够用,还能让前端直接做"页码跳转"。等真到了百万级、且不需要跳页的场景,再切游标也不迟------分页策略是和业务规模绑定的,别提前为想象中的流量过度设计。

再补一句:分页的 total 和 totalPages 必须来自后端,别让前端自己猜------前端不知道全集大小,算出来的页码必然错。所以 meta() 把 total 和 totalPages 一起算好塞进信封,前端只管渲染分页器。

二、筛选:白名单 DSL,绝不让用户拼 SQL

筛选是列表接口最大的注入风险源。最危险的写法是把用户传的字段名直接拼进 SQL:

ts 复制代码
// ❌ 致命:用户传 ?sort=id; DROP TABLE articles;-- 直接拼进 SQL
const sql = `SELECT * FROM articles ORDER BY ${c.req.query('sort')}`;

我们的 queryArticles 用"白名单 + 条件数组"的方式构造筛选,从根上堵死注入:

ts 复制代码
const conds: SQL[] = [isNull(articles.deletedAt)];   // base:永远 AND 上"未软删"
if (q.forcedStatus) conds.push(eq(articles.status, q.forcedStatus));
if (q.authorId !== undefined) conds.push(eq(articles.authorId, q.authorId));
// ...keyword / tag / category 各自追加
const where = and(...conds);

这套"列表 DSL"有几个纪律(P-37):

  1. 白名单优于黑名单 :允许筛选的字段是写死在代码里的(status/authorId/keyword/tag/category),用户传的任何不在清单里的参数直接被忽略,根本进不了 SQL。
  2. base 永远 AND :isNull(articles.deletedAt) 是底座条件,所有筛选都和它 AND 在一起。这样无论怎么筛,软删文章永远不会漏出来------不用在每个分支里重复写"排除软删"。
  3. 动态条件安全拼接 :用 Drizzle 的 and(...conds) 把条件数组拼成参数化 SQL,每个值都走占位符,不存在字符串拼接注入。
  4. 投影摘要列 :列表查询 select 只取 id/title/summary/slug/... 这些摘要列,绝不取 content 长文本 。一篇 content 几万字,列表一页 20 篇全取出来就是几十万字符的内存和带宽浪费。详情接口才取 content。

把筛选串起来看,一次 GET /articles?status=pending&authorId=7 最终生成的 SQL 大致是:SELECT ... FROM articles WHERE deleted_at IS NULL AND status = 'pending' AND author_id = 7 ORDER BY ... LIMIT 20 OFFSET 0。注意 deleted_at IS NULL 是底座、永远在场,业务分支只管往上叠自己的条件------这就是白名单 DSL 的好处:新增一个筛选维度,只需在 queryArticles 里加一个 if (q.xxx) conds.push(...),底座和注入防护全自动复用,不用每次重写"排除软删"。可维护性和安全性是一起拿到的。

三、排序:带符号字段名 + 基表限定(P-11)

排序我们用"带符号字段名"约定:-publishedAt 表示倒序、publishedAt 表示正序。解析时剥掉 - 前缀得到裸字段名,再查白名单:

ts 复制代码
// pagination.ts --- SORT_COLUMNS 白名单
const SORT_COLUMNS: Record<string, string> = {
  publishedAt: 'COALESCE(articles.published_at, articles.created_at)',
  viewCount: 'articles.view_count',
  createdAt: 'articles.created_at',
};

两个安全/正确要点:

第一,未知字段直接回退默认。 bare && bare in SORT_COLUMNS 不成立时,排序回退到 -publishedAt。用户传 ?sort=hack 不会报错,也不会拼进 SQL,只是"无效排序被忽略"。这又是一道注入护栏------排序字段也只能从白名单里来。

第二(P-11,曾经的真实 500),ORDER BY 必须显式限定基表。 注意白名单里写的都是 articles.xxx 全限定列名,不是裸的 published_at。为什么?因为列表查询在按标签筛选时会 INNER JOIN article_tags:

ts 复制代码
if (q.tag) rowsQuery.innerJoin(articleTags, eq(articleTags.articleId, articles.id));

JOIN 之后,created_at、id 这种两表都可能有的列就成了歧义列(ambiguous column) 。如果排序写的是裸 ORDER BY created_at,数据库不知道你指的是 articles.created_at 还是 article_tags.created_at,直接报 ambiguous column → 500。我们白名单里一律用 articles. 限定基表,从根上根除这个 500。这条坑是 M1-05 提过的 P-11 在列表场景的真实落地------JOIN 一上,裸列名就是雷。

另外 publishedAt 用了 COALESCE(articles.published_at, articles.created_at):草稿没有 published_at(为 NULL),排序时把它当成 created_at 兜底,避免 NULL 被排到诡异位置。

为什么用 -publishedAt 这种"带符号字段名"而不是 sortField=publishedAt&sortDir=desc 两个参数?因为单参数更 URL 友好、更短、也更好校验------一个字符串就能完整表达"排哪个字段、什么方向",白名单只需校验这一个字符串的裸字段部分。两个参数方案要分别校验字段和方向,攻击面更大。另外注意 meta() 在 buildSortSql 之外独立构造 Pagination 四件套,排序和分页元数据各司其职,路由里一行 paginate(result.list, result.pagination) 就包好信封,职责边界很清楚。排序字段白名单和分页 pageSize 上限一样,都是"用户能控制、但必须在我们划定的圈里控制"的典型例子------既要给人灵活,又不能让人把系统玩坏。

四、SCAN_LIMIT 封顶:LIKE 全表扫描的刹车(P-37 / P-35)

关键词搜索用的是 LIKE '%keyword%',这东西在小表上没问题,但本质上是全表扫描 ------它会逐行比对,数据量一大就拖死库。我们的应对是给扫描量封顶(SCAN_LIMIT = 2000):

ts 复制代码
// article.ts --- queryArticles 计数分支
if (q.keyword) {
  const scanned = await scannedQuery.where(where).limit(SCAN_LIMIT).all();
  total = scanned.length;   // 命中超量就显示封顶值
}

当用户带 keyword 搜索时,计数不再 count(*) 全表数,而是最多扫 SCAN_LIMIT 行,扫到上限就显示封顶值(比如"结果 2000+")。这是搜索可用性和性能的取舍:用户得到"大概很多条"的反馈,数据库不会被一次恶意/手滑的全量 LIKE 拖垮。真要支撑大规模全文检索,得上升级到专用引擎(M1-19 专门讲)。

这里其实藏着一个产品判断:搜索的"精确总数"在大规模下是奢侈品。与其让用户等一个精确的 count(*),不如用封顶值快速返回、把真实计数留给"点进下一页"的懒加载或异步统计。列表接口的设计,很多时候不是"能不能算出来",而是"值不值得为这一次请求付出这个代价"------SCAN_LIMIT 就是把这个判断写进了代码。

顺带一提,limit(SCAN_LIMIT) 不只是为了保护用户体验------一次无上限的 LIKE 全表扫,在数据库层面会吃满 CPU、锁住大量行,进而拖慢同库的其他读写请求,是典型的"一个慢查询拖垮全站"。封顶扫描量,本质也是在保护数据库的"邻居"。

顺带,q.tag 的标签过滤现在切的是 article_tags 关联表精确 INNER JOIN(呼应 M1-16 的 P-35),不再像早期那样对 tags JSON 做 LIKE 子串匹配------既准又不会因为 JOIN 引入歧义(因为排序白名单已全限定基表)。

五、小结与前瞻

列表接口三件套,是后端的"日常基本功":

  1. 分页 :offset 分页,parsePage 钳制 page≥1、pageSize∈[1,100];排序恒接 articles.id DESC 稳定键,翻页不重漏。
  2. 筛选 :白名单 DSL------允许字段写死、未知参数忽略;base 条件 isNull(deletedAt) 永远 AND;动态条件用 and(...conds) 参数化拼接,零注入;列表只投影摘要列,不取 content。
  3. 排序 :带符号字段名(-publishedAt 倒序);白名单 SORT_COLUMNS,未知回退默认;P-11 全限定基表列 ,根除 JOIN 后 ambiguous column 的 500。
  4. P-37 / P-35 :SCAN_LIMIT=2000 封顶 LIKE 扫描量,超量显示封顶值;标签过滤走 article_tags 精确 JOIN。

下一篇({{LINK:M1-18}})我们进"文件上传":为什么需要 R2 对象存储和本地磁盘双实现、上传接口怎么设计、以及"同图不同时间上传存几份"背后的内容寻址去重。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
濮水大叔2 小时前
Cabloy全栈框架的两个SSR入口:Vona集成式SSR vs Zova独立式SSR
typescript·node.js·全栈
怕浪猫2 小时前
DeepSeek Harness 系列图解
面试·前端框架·node.js
百万蹄蹄向前冲3 小时前
双端同步!云服务器装最新Node.js v26.10全过程追踪
服务器·人工智能·node.js
前端snow3 小时前
ai agent--- 后端概念补充:Docker Compose、ElasticSearch、IK、BM25等
node.js
EdgeEcho3 小时前
Node 里那个"只解第一帧"的坑,我用 172 行代码绕过去了
node.js
光影少年3 小时前
Redis + Node 如何支撑百万级并发
redis·后端·node.js
半个落月3 小时前
从“等待整段答案”到边生成边展示:大模型流式输出与 SSE 实战(上)
langchain·node.js
百万蹄蹄向前冲3 小时前
一句话生成Node.js学习官网秒发布上线
前端·后端·node.js
半个落月3 小时前
让大模型稳定返回可用数据:Output Parser、Zod 与 Tool Calling(下)
langchain·node.js
FungLeo6 天前
成为全栈·React 管理后台篇·按钮级权限:能力映射、菜单过滤与自锁保护
react·rbac·路由守卫·权限控制·前端权限·成为全栈