JavaPub聊AI头像

Gin 与 GORM 列表查询实战:读懂 ShiyuAdmin 的筛选、分页和排序

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 负责打破排序并列,不负责表达业务时间。

这段实现也有清晰的边界:

  1. Count 与 Find 是两次查询,没有在这个方法中建立统一的读取快照。期间发生写入时,总数与本页结果可能反映不同瞬间。
  2. 确定性排序不能阻止偏移分页受数据变化影响。用户读取第一页后,有新记录插入列表前部,第二页的偏移位置仍可能移动。
  3. 当前排序固定在服务端,没有开放任意排序字段。前端表格也没有配置由用户切换的列排序。

源码:统计、分页和排序,事项主键与时间字段。

五、分页参数有上限,但上限不等于查询一定便宜

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。

如果继续完善这一模块,建议补充下面几类用例:

  1. 通配符反例:同时准备包含字面量 %、_、[ 的标题和容易被误匹配的普通标题,确认搜索不会扩大结果集。
  2. 转义字符自身:搜索 !、连续两个 !、!%,检查转义组合。
  3. 相同时间分页:插入多条 created_at 相同的记录,按小页长连续查询,检查顺序与重复项。
  4. 参数边界:覆盖页码 0、负数、超上限、非整数,以及每页条数 0、100、101。
  5. 响应乱序:让旧筛选请求晚于新请求完成,检查最终展示的是新筛选结果。
  6. 历史导航:筛选后翻页,再使用浏览器前进、后退,检查 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--