Gin 与 GORM 列表查询实战:读懂 ShiyuAdmin 的筛选、分页和排序
后台列表页通常有几个看起来很简单的动作:输入关键词、选择状态、翻到下一页,再点开一条详情。
真正写起来,这些动作会牵出不少问题:搜索 % 为什么能匹配一大片数据?改了筛选条件,为什么还停在第八页?两条记录的提交时间相同,翻页顺序是否可靠?用户快速切换筛选时,先发出的请求晚回来,会不会覆盖新结果?
这篇文章沿着 ShiyuAdmin 的“事项与审批”列表,拆解一次完整查询。重点放在查询条件、分页边界、确定性排序,以及前端如何保存筛选状态,不展开申请创建和审批流程。
项目地址:Rodert/ShiyuAdmin。本文核对的代码版本为 20e3669,文中的源码链接均固定到该提交。下文会明确区分仓库已有实现与改造示例;本文进行的是源码分析,没有把测试文件的存在当作本次实测通过。
一、先把一次列表请求讲清楚
事项列表使用以下接口:
GET /api/v1/system/work/requests
它接收的主要参数是:
| 参数 | 用途 | 当前约定 |
|---|---|---|
view | 列表视图 | 默认 mine,另有 pending、handled |
keyword | 关键词 | 匹配标题、申请人名称、审批人名称 |
status | 状态筛选 | 可选,支持四种事项状态 |
page | 页码 | 默认 1 |
page_size | 每页数量 | 默认 20,接受 1 到 100 |
接口返回的业务数据是 items 和 total。其中 items 是当前页记录,total 是应用筛选条件后的总条数,不是全表条数。
后端路由做的事情很直接:解析分页参数,取得当前登录用户,把视图和筛选条件交给 work.Service.List,最后返回查询结果。
page, size := workPagination(c)
items, total, err := svc.List(
c.Request.Context(),
currentUserCodeFromContext(c),
c.DefaultQuery("view", "mine"),
page,
size,
work.ListFilter{
Keyword: c.Query("keyword"),
Status: c.Query("status"),
},
)
以上是原调用的换行整理。完整代码见 work.go 的列表路由。
这里没有让前端传一个用户编号来决定查谁的数据。当前用户来自服务端上下文,后续查询在这个范围内继续收窄。
二、先确定数据范围,再叠加筛选条件
List 的第一段代码,根据视图构建基础查询:
q := s.DB.WithContext(ctx).Model(&entity.WorkRequest{})
switch view {
case "mine":
q = q.Where("applicant_code = ?", user)
case "pending":
q = q.Where("approver_code = ? AND status = ?", user, "pending")
case "handled":
q = q.Where("approver_code = ? AND status <> ?", user, "pending")
default:
return nil, 0, invalid("不支持的事项列表")
}
三个视图分别表示:
mine:当前用户发起的申请。pending:当前用户负责审批,并且仍处于待审批状态的申请。handled:当前用户负责审批,并且已经不处于待审批状态的申请。
第三项值得仔细看。它查询的是 status <> pending,并没有查询“由我执行过审批动作”的记录。因此,申请人撤回的事项也可能出现在这个视图中。前端把它命名为“已结束的审批”,与查询条件更贴近。
随后才处理用户输入。关键词会先去除首尾空白,再用 len([]rune(keyword)) 检查是否超过 120 个 Unicode 码点。状态字段则使用白名单,只接受:
pending
approved
rejected
withdrawn
合法状态继续通过 Where("status = ?", filter.Status) 加入查询。
这些条件之间是“同时满足”的关系。比如直接请求:
view=pending&status=approved
基础条件要求 status = pending,新增条件要求 status = approved,结果自然为空。当前后端不会把这种组合自动修正成另一个视图,也不会专门为此返回参数错误。
前端通过禁用待审批视图中的状态下拉框、切换视图时清除旧状态,减少用户从界面构造出矛盾条件的机会。直接调用接口时,仍然要遵守后端的真实语义。
源码:List 的视图与参数校验。
三、参数绑定之后,LIKE 仍然需要转义
关键词搜索是这段代码中很值得复用的细节:
pattern := "%" + strings.NewReplacer(
"!", "!!",
"%", "!%",
"_", "!_",
"[", "![",
).Replace(keyword) + "%"
q = q.Where(
"(LOWER(title) LIKE LOWER(?) ESCAPE '!' OR "+
"LOWER(applicant_name) LIKE LOWER(?) ESCAPE '!' OR "+
"LOWER(approver_name) LIKE LOWER(?) ESCAPE '!')",
pattern, pattern, pattern,
)
这段代码对原表达式做了换行,查询逻辑没有变化。
这里有两个独立问题。
第一个问题是 SQL 参数绑定。关键词通过占位符传入,不直接拼进 SQL 结构。
第二个问题是 LIKE 模式语义。即使使用了参数绑定,用户输入的 % 和 _ 仍然会被数据库当作通配符。比如用户想找标题中的 50%,直接拼成 %50%%,就可能匹配到只含 50 的记录。
ShiyuAdmin 选用 ! 作为显式转义字符:
- 用户输入的
!变成!!。 %变成!%。_变成!_。[变成![,用于处理 SQL Server LIKE 的方括号模式语义。
然后在关键词两侧补上 %,继续提供“包含匹配”。例如:
输入:50%_[
模式:%50!%!_![%
两端的 % 是程序主动提供的通配符,中间的特殊字符按字面值匹配。
这并不等于所有数据库的搜索行为完全相同。代码使用 LOWER 处理大小写,但 Unicode 大小写规则、重音字符和排序规则仍受具体数据库配置影响。跨数据库部署时,应使用实际语言样本验证。
另一个容易被忽略的细节是 OR 条件外侧的括号:
基础范围条件
AND (
标题匹配
OR 申请人名称匹配
OR 审批人名称匹配
)
括号保证三个搜索字段都受前面的数据范围约束。拼接复杂查询时,不能只关注“有没有加上用户条件”,还要看 AND 与 OR 的组合方式。
源码:关键词转义与三个字段的匹配。
四、Count 和分页共用条件,排序补上唯一字段
完成筛选后,代码先统计,再取当前页:
var total int64
items := []entity.WorkRequest{}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
err := q.Order("created_at DESC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&items).Error
return items, total, err
这里的顺序很重要:Count 发生在添加 Offset、Limit 之前,统计和列表使用相同的基础范围与筛选条件。因此,页面展示的总数对应的是筛选后的结果集。
分页本身是常见的偏移分页:
第 1 页,每页 20 条:Offset = 0
第 2 页,每页 20 条:Offset = 20
第 3 页,每页 20 条:Offset = 40
排序则用了两个字段:
ORDER BY created_at DESC, id DESC
只按 created_at 排序时,如果多条记录的时间相同,它们之间的顺序没有被这个排序表达式完整规定。加入主键 id 后,在相同数据集合与数据库比较规则下,同一时间的记录也有确定顺序。
但不要把 id DESC 理解为“同一时刻按创建先后排序”。仓库创建事项时,使用随机字节生成十六进制 ID,它不是递增编号。这里的 ID 负责打破排序并列,不负责表达业务时间。
这段实现也有清晰的边界:
Count与Find是两次查询,没有在这个方法中建立统一的读取快照。期间发生写入时,总数与本页结果可能反映不同瞬间。- 确定性排序不能阻止偏移分页受数据变化影响。用户读取第一页后,有新记录插入列表前部,第二页的偏移位置仍可能移动。
- 当前排序固定在服务端,没有开放任意排序字段。前端表格也没有配置由用户切换的列排序。
五、分页参数有上限,但上限不等于查询一定便宜
HTTP 层使用 workPagination 处理页码:
func workPagination(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 || page > 1000000 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
它采取的是“超出范围则回退默认值”的策略:
page=0或page=1000001,回退到第 1 页。page_size=101,回退到每页 20 条,并不是截断成 100 条。- 非法整数输入也会经过转换和范围检查,最终落回有效默认值。
这能限制一次响应最多返回多少条记录,但页码允许到一百万,意味着合法输入仍可能产生很大的偏移量。例如页码一百万、每页一百条,对应偏移量 99,999,900。
不能据此断言实际查询一定很慢,因为成本取决于数据规模、索引和执行计划;同样也不能因为参数有上限,就认定深分页已经优化完成。
还有一个调用边界:上述规范化发生在 HTTP 层,Service.List 本身没有重复校验 page 和 size。当前路由会先规范化再调用服务,但今后如果增加内部调用入口,需要继续满足这个前提,或者把分页规范化提取成可复用逻辑。
源码:workPagination。
六、前端把列表状态放进 URL
后端确定了查询规则,前端还要保证用户知道自己正在看哪一组数据。
ShiyuAdmin 的事项页面从 URL 中读取 view、keyword、status、page 和详情记录 id。修改参数时,使用下面这个函数:
const navigate = (values: Record<string, string | undefined>) => {
const next = new URLSearchParams(location.search);
Object.entries(values).forEach(([key, value]) => {
if (value) next.set(key, value);
else next.delete(key);
});
history.push(`/work/requests?${next.toString()}`);
};
它先复制当前查询参数,再更新指定字段。这个做法让不同操作可以保留应当保留的状态:
- 搜索关键词:更新
keyword,清除页码和详情id。 - 切换状态:更新
status,清除页码和详情id。 - 切换视图:更新
view,清除页码、状态和详情id,保留关键词。 - 打开详情:只更新
id。 - 关闭详情:只删除
id。
因此,从搜索结果打开详情再关闭,关键词和状态不会消失。筛选变化时清除页码,则避免了“原来在第八页,新条件只有两页,却继续请求第八页”的常见问题。
URL 保存的是查询状态,不是权限。把这个地址发给另一个用户,对方仍会按自己的身份查询,并不会继承原用户的数据范围。
表格使用 Ant Design Table,通过 current、total 和固定的 pageSize: 20 展示服务端分页,showSizeChanger 为 false。后端虽然接受 1 到 100 的每页条数,当前页面没有开放每页数量切换。
源码:URL 参数解析和更新,筛选控件和分页表格。
快速切换条件时,旧响应如何处理
页面请求数据的 Effect 还使用了一个局部 active 标记:
useEffect(() => {
let active = true;
setLoading(true);
setError(false);
getRequests({ view, page, page_size: 20, keyword, status })
.then(({ data }) => {
if (active) {
setRows(data.items);
setTotal(data.total);
}
})
.catch(() => {
if (active) setError(true);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [view, page, revision, keyword, status]);
依赖变化后,上一次 Effect 的清理函数会把自己的 active 设为 false。即使旧请求随后完成,也不会再通过这几个回调覆盖当前列表、错误状态或加载状态。
这里要说准确:它阻止了失效响应更新页面,没有取消已经发出的 HTTP 请求。若要减少无用请求消耗,需要另外接入请求取消能力。
源码:列表加载 Effect。
七、现有测试验证了什么,还应该补什么
仓库中有与这条查询链路直接相关的测试。
后端的 file_maintenance_test.go 创建标题为 采购 50%_[预算] 的事项,然后使用 采购、50%_[、files-owner 分别搜索,并断言当前用户的结果总数为 1。它还检查管理员以自己的 mine 视图搜索时结果为 0,以及非法状态返回 400。
前端的 maintenance-flow.ts 会搜索“补充发票”、筛选“待审批”,检查新申请可见、旧申请不可见,再打开并关闭详情,确认 URL 仍保留关键词和状态。
这些是测试源码中明确写出的检查,不代表本文重新运行并通过了整个项目。它们说明查询正确性已经延伸到数据范围、特殊字符和界面状态,而不只检查接口能不能返回 200。
如果继续完善这一模块,建议补充下面几类用例:
- 通配符反例:同时准备包含字面量
%、_、[的标题和容易被误匹配的普通标题,确认搜索不会扩大结果集。 - 转义字符自身:搜索
!、连续两个!、!%,检查转义组合。 - 相同时间分页:插入多条
created_at相同的记录,按小页长连续查询,检查顺序与重复项。 - 参数边界:覆盖页码 0、负数、超上限、非整数,以及每页条数 0、100、101。
- 响应乱序:让旧筛选请求晚于新请求完成,检查最终展示的是新筛选结果。
- 历史导航:筛选后翻页,再使用浏览器前进、后退,检查 URL、控件和请求参数是否一致。
源码:后端搜索测试,前端筛选与详情返回测试。
八、数据量变大后,可以怎样改
以下是基于现有查询结构的改造建议,不是仓库已经实现的功能。
1. 为主要访问路径评估联合索引
实体当前声明了申请人索引 idx_work_applicant,以及审批人与状态的联合索引 idx_work_approver_status。这反映了主要过滤条件,但模型标签中没有把 created_at、id 纳入这两个索引。
可以针对常用视图评估如下候选索引:
-- 改造示例,需要结合实际数据库及执行计划评估
CREATE INDEX idx_work_mine_page
ON work_requests (applicant_code, created_at DESC, id DESC);
CREATE INDEX idx_work_pending_page
ON work_requests (approver_code, status, created_at DESC, id DESC);
不要直接把候选索引全部加到生产库。先用实际数据量和常用参数查看执行计划,再决定是否新增、替换或保留现有索引。尤其是 handled 使用不等条件,不能照搬待审批视图的效果判断。
关键词查询还包含前置 % 和 LOWER,普通 B-tree 索引通常无法直接高效完成这类任意包含搜索。联合索引可以帮助缩小基础数据范围,但不能据此声称关键词搜索已经获得理想索引访问路径。搜索成为主要瓶颈后,需要按目标数据库评估专门的文本检索方案,并确认是否接受搜索语义变化。
2. 连续浏览场景可以评估游标分页
如果用户主要不断加载下一批,而不是跳到第几百页,可以沿用现有双字段排序,改为基于上一页最后一条记录继续查询。
下面仅展示核心 SQL 思路:
-- 改造示例:保留当前用户范围与其他筛选条件
WHERE applicant_code = :current_user
AND (
created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
)
ORDER BY created_at DESC, id DESC
LIMIT :page_size;
游标必须同时包含 created_at 和 id,因为排序也使用这两个字段。只传时间戳,会在同一时间存在多条记录时丢失边界信息。
完整改造还需要处理游标格式校验、时间精度、筛选变化后的游标重置,以及前端从页码切换到“加载更多”的交互。如果不再展示精确总页数,也可以评估取消每次请求的 Count,通过多取一条记录判断是否还有下一批。
游标分页也不提供跨多次请求的数据快照,记录状态变化仍可能改变结果集合。它主要解决深偏移与连续读取的问题,不能替代对一致性需求的判断。
3. 为列表设计更轻的返回结构
当前 Find(&items) 直接读取 WorkRequest 实体,列表响应会包含 content,而列表表格没有展示申请说明。创建逻辑允许申请说明达到一万个 Unicode 码点,多条长说明会增加列表读取与传输成本。
一个可行方向是新增专用列表 DTO,只查询列表所需字段;详情接口继续返回完整说明。前端也要同步调整类型,不能只在 SQL 中省略字段,却继续把完整实体类型当成接口承诺。
这一改造通常比立刻引入复杂分页协议更容易落地,也更容易通过响应体大小和查询耗时确认收益。
结语
ShiyuAdmin 的这条列表查询链路,有几处很适合在日常开发中借鉴:在当前用户的数据范围内叠加筛选;分别处理 SQL 参数绑定和 LIKE 字面量转义;让总数统计与列表共用条件;用唯一字段补全排序;在筛选变化时重置页码;用 URL 保存查询状态,并防止失效响应覆盖新结果。
这些细节共同决定了列表是否“查得对、翻得稳、返回时还记得原来的条件”。等到数据规模继续增长,再根据执行计划和真实交互需求,选择联合索引、轻量 DTO 或游标分页,改造目标才会足够明确。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_40374604/article/details/170287530



