04 · 列表、详情与缓存:把保存的数据正确读回来#
读取接口也有业务规则:用户只能读取可访问的数据,分页与总数必须使用相同条件,页面必须区分等待、空结果和错误。本章从工作台列表走到详情。
1. 查询参数就是接口合同#
listTicketsSchema 限制页码为 1~100000、每页 1~50 条;默认第一页、每页 10 条,状态可选。当前还支持标题搜索、优先级和分配筛选。
default() 补齐缺失值,不代表自动接受任何输入。z.number() 不会自动把字符串 '2' 当成数字。当前页面用 nuqs 解析 URL 状态,再向接口传入约定的数据;不能只依赖 URL 看上去“像数字”。
为什么限制页大小?因为接口成本不能完全由浏览器决定。一次读取几十万行会拖累数据库、网络和页面渲染;前端下拉选项只列 10、20、50,也不能阻止别人直接构造大请求。
2. 分页如何转换成数据库查询#
服务层的核心关系是:
// 概念示意:从前端页码计算数据库偏移
skip = (page - 1) * pageSize;
take = pageSize;第 3 页、每页 10 条,就是跳过 20 条再取 10 条。列表接口返回 { items, total },其中 total 是全部匹配记录数,表格才能计算总页数。
当前 ticket-filter.ts 生成统一的 where 条件:始终限制团队,客户另限制创建者,再加入状态等筛选。findMany 与 count 共用它,避免“这一页只显示本团队,但总数统计了全站”的错误。
分页不是权限措施:取十条越权数据也仍然是越权。先限制允许访问的数据集合,再从这个集合取页。
3. 排序与事务让同一次返回更可靠#
源码的排序是:
orderBy: [{ createdAt: 'desc' }, { number: 'desc' }],创建时间相同也用唯一编号继续排序,避免数据库对同时间记录给出不确定顺序。稳定排序不意味着翻页期间数据不再变化;第一页读完后有人新建工单,第二页仍可能受到偏移变化影响。
服务层将列表与总数放进同一个 RepeatableRead 事务:
// 概念示意:具体 select、where 见服务源码
const [items, total] = await db.$transaction(
[
db.ticket.findMany({ where, skip, take, orderBy }),
db.ticket.count({ where }),
],
{ isolationLevel: 'RepeatableRead' },
);假设先查列表得到 10 条,随后另一请求插入新记录,再查总数。没有同一快照时,这次响应中的两部分可能描述不同时间的数据。这里的隔离级别让两条查询基于同一个事务快照。
事务不是“数据库全部锁住”,也不会让用户两次独立翻页固定在同一个快照中。具体锁和可见性取决于数据库与隔离级别;不要从“用了事务”推导出“所有并发问题都解决了”。
拓展阅读什么时候换游标分页?
偏移分页容易做页码跳转,适合当前工作台;深分页扫描成本较高,频繁插入也可能造成跨页重复或遗漏。长信息流可用稳定排序键设计游标,但任意跳到第 100 页会变复杂。选型应围绕访问方式,而不是只比较 API 写法。
4. TanStack Query 管理远程数据#
TicketList 使用:
// 源码节选:实际还传入其他筛选字段
const query = useQuery(
trpc.ticket.list.queryOptions({
teamId: team.id,
page,
pageSize,
status: status ?? undefined,
priority: priority ?? undefined,
assignment,
q,
}),
);查询键由接口与输入区分。团队、页码、筛选不同,对应不同查询结果。筛选变化时回到第一页,否则用户在第 8 页筛选出只有 1 页的数据,就会误以为没有结果。
页面需要区分:
| 状态 | 含义 | 当前展示方式 |
|---|---|---|
isLoading |
首次等待数据 | 表格 loading / 详情 Spin |
isFetching |
正在请求,可能已有旧数据 | 刷新按钮 loading |
isError |
接口失败 | Alert 与重试 |
成功但 items 为空 |
查询正常,只是没有匹配工单 | Empty |
items ?? [] 避免表格收到 undefined,不能因此把网络失败伪装成“暂无工单”。当前界面还单独显示错误提示。
5. 详情页识别的是 UUID#
详情路由是 /tickets/[id],不是展示编号 #0001。服务层 detail 先确认成员关系,再按 id + teamId 查询;客户附加 creatorId。
如果 UUID 格式错误,输入校验就会失败;如果 UUID 格式合法但记录不存在或不在可访问范围,服务返回 NOT_FOUND。页面据此显示明确错误,而不是永远转圈。
对同团队成员查询外团队工单,统一返回未找到,避免泄露其存在性;如果请求者连目标团队的成员都不是,则成员检查可能先返回 FORBIDDEN。不要笼统认为所有越权请求都返回同一种错误。
team URL 参数让页面保留当前上下文,但不赋予权限。改地址栏只是改变请求目标,服务端仍重新验证。
6. 为什么修改成功后还需要刷新缓存#
数据库与浏览器缓存是两份数据。创建或修改状态成功,只能说明服务端已经接受操作;原来缓存的列表和详情不会自动变成最新值。
当前页面调用 queryClient.invalidateQueries(...):把相关查询标记为失效,活动查询默认会重新请求,非活动查询之后使用时再按规则获取。列表用接口级查询键,可以覆盖不同分页和筛选;详情使用工单与团队精确定位。
client.tsx 配置 staleTime: 30000,含义是正常情况下数据在 30 秒内可视为新鲜,不是每 30 秒自动轮询一次。是否请求还受挂载、焦点、手动刷新和失效等行为影响。显式失效可以打破这段新鲜期。
拓展阅读为什么先不用乐观更新?
状态修改可能被权限、业务路径或版本冲突拒绝。先由服务端确认,再失效并读取,能让第一阶段容易理解且结果准确。乐观更新适合进一步优化交互,但需要处理回滚、并发和多个列表筛选的同步。
7. 不同状态放在哪里#
| 状态 | 归属 | 原因 |
|---|---|---|
| 工单及总数 | 数据库为事实来源,Query 保存前端缓存 | 多用户共享,需服务端确认 |
| 已提交的筛选与页码 | 当前仓库使用 URL / nuqs | 刷新、分享、前进后退可恢复 |
| 未提交字段与校验错误 | Ant Design Form | 属于编辑过程 |
| 创建弹窗是否打开 | 组件 state | 局部交互 |
| 当前版本的草稿 | Zustand | 未提交内容,按用户和团队隔离管理 |
不要因为熟悉 Zustand 就再复制一份全部工单,否则 Query 与 store 可能各显示一个版本。缓存负责减少读取成本,数据库负责持久化,它们不能互相替代。
8. 跟着页面验证#
创建不同优先级的工单,切换状态筛选与页码,观察请求参数和返回总数。打开详情后修改状态,再返回原筛选列表,确认新状态对应的记录进出结果集。
这条读取链路是:页面参数 → tRPC 校验与会话 → 服务层成员和资源过滤 → Prisma 查询 → 事务中的列表与总数 → JSON → Query 缓存 → 表格或详情。 下一章解释为什么合法的读取结果仍可能在提交修改时被判为过期。