BiSheng 列表 Cursor 翻页与无限滚动:从 OFFSET 到 Keyset 的深翻页性能治理实战(F027)
【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng
导读:本文是 BiSheng 开源项目高频列表(知识库 / 应用 / 知识空间文件)从
page_num + COUNT(*)偏移翻页改造为cursor(keyset)翻页 + 前端无限滚动的完整技术手册。改造的核心驱动力不是 UI 体验,而是根治翻深页时 OpenFGA 细权限请求量随页号线性增长的问题:OFFSET 模式下翻第 50 页要为前 49 页全部资源各跑一次 ReBAC 过滤,cursor 模式下每次只对当前 keyset 窗口跑一次。读完本文,你将掌握 BiSheng 的 cursor envelope 协议、common/cursor.py的 token 编解码、database/utils/keyset.py的 DM8 兼容展开式 WHERE、fetch-until-enough 扫描循环、前端useInfiniteCursorTable/ LoadMore sentinel 的实现,以及全部关键陷阱和新增列表的改造清单。
1. 总览:为什么要把列表翻页从 OFFSET 换成 Cursor
在 OFFSET 翻页模型下,LIMIT 20 OFFSET 980这类查询有两个天然缺陷:
- COUNT 开销:为了渲染「共 X 个」,每次都要跑
COUNT(*),在 ReBAC 细权限场景下这意味着把全量可见资源数一遍; - 翻深页的权限放大:BiSheng 的列表数据在 DAO 之外还有一层 OpenFGA 细权限过滤。OFFSET 模式下翻第 50 页,数据库要先把前 49 页全部行取出来,每行都要过一次 ReBAC 过滤才能确定哪些「可见」、哪些被过滤掉,才能定位到第 50 页的起点。页号越深,这个开销线性增长。
cursor(keyset)翻页的思路是:不再用「偏移量」定位,而是用「上一页最后一条记录的排序键」定位下一页的起点。每页只查当前 keyset 窗口之后的一小批数据,ReBAC 过滤也只需要针对当前窗口执行一次。翻页成本与页号解耦,恒定为 O(窗口大小)。
整体调用链路如下:
┌──────────────────────────────────────────────────────────────────┐ │ 前端 │ │ ┌──────────────┐ ┌───────────────────────┐ │ │ │ useInfinite- │ │ LoadMore sentinel │ │ │ │ CursorTable │ │ (IntersectionObserver)│ │ │ └──────┬───────┘ └───────────┬───────────┘ │ │ │ cursor=next_cursor │ 滚到底自动触发 │ │ ▼ ▼ │ │ GET /api/v1/<list>?cursor=<token>&page_size=20 │ └──────────────────────────┬────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 后端 Service │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ decode_cursor(token, expected_context, expected_key_len) │ │ │ │ 失败 → 抛业务错误码 (10550 / 10991 / 18070) │ │ │ └────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ fetch-until-enough scan loop (若细权限过滤可能减少) │ │ │ │ 每轮: DAO.aget_xxx(cursor=batch_cursor, limit=batch) │ │ │ │ 过滤: ApplicationPermissionService.get_app_permission │ │ │ │ 累积: 到 page_size + 1 (探 has_more) 或 DB 拉空 │ │ │ │ 推进: batch_cursor = last DB row (非 last visible) │ │ │ └────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ encode_cursor((sort_key_tuple), context) → next_cursor │ │ │ └────────────────────────┬───────────────────────────────────┘ │ └──────────────────────────┬────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ DAO + DB (MySQL / DM8) │ │ SELECT ... WHERE <keyset predicate> │ │ ORDER BY <sort_cols> LIMIT <batch+1> │ │ 注: DM8 不支持 row-value tuple 比较,统一走展开 OR ladder │ └──────────────────────────────────────────────────────────────────┘1.1 改造范围
| 接口 | 改造内容 |
|---|---|
GET /api/v1/knowledge | OFFSET → cursor;砍 COUNT;sort_by=name用复合索引(name, id) |
GET /api/v1/workflow/list | OFFSET → cursor;砍 COUNT;workflow + assistant UNION;fetch-until-enough 循环 |
GET /api/v1/knowledge/space/{id}/children | OFFSET → cursor;scan loop 凑够即停;ext_rank 用 CASE WHEN |
GET /api/v1/departments/tree | 删member_count字段及对应COUNT(*) GROUP BY |
GET /api/v1/tool | 后端不动,只清前端「共 X 个」文案 |
注意最后一行的边界:并不是所有接口都要动后端。/api/v1/tool后端保持原样,只清掉前端「共 X 个」文案,因为 tool 列表本身的量级和权限模型不构成深翻页瓶颈——改造是「按需」而非「一刀切」。
2. 协议层:Cursor Envelope
所有 cursor 接口返回统一的响应 envelope,定义在 common/schemas/api.py:
class PageInfiniteCursorData(BaseModel, Generic[T]): data: List[T] page_size: int has_more: bool next_cursor: Optional[str]与旧PageData[T]的区别:
- 无
total字段——砍 COUNT 是本次性能优化的核心,前端不再依赖总数; - 用
has_more替代「是否最后一页」的推断; next_cursor为None时表示已到末页,前端 LoadMore sentinel 应停止触发。
请求侧约定:cursor是 query 参数,空值或省略 = 第一页;page_size用户可控,典型 20。前端拿到next_cursor后,把它作为下一次请求的cursorquery 参数;当has_more为False时停止加载。这套契约在 src/frontend/platform/src/util/hook.ts 的类型签名里有镜像声明。
2.1 Cursor token 编解码
common/cursor.py提供两个核心函数:
def encode_cursor(values: Sequence, *, context: str) -> str def decode_cursor(token: str, *, expected_key_len: int, expected_context: str) -> Listtoken 结构(简化):base64url(json({"v": 1, "s": context, "k": [sort_key_values...]}))。从源码看,实际 payload 有三个字段:
v:schema 版本(当前恒为1,见CURSOR_SCHEMA_VERSION = 1),允许未来格式演进而不静默破坏旧 cursor;s:context 签名,例如"flow|sort=update_time"、"knowledge|sort_by=update_time"、"space_children|order=file_type_asc"。它是「这个 cursor 是哪个查询发的」的标签,用来防御「用户拿 A 接口的 cursor 喂 B 接口」或「排序条件变了还在用旧 cursor」;k:有序排序键值数组,最后一个元素必须是 id 决胜键(tie-breaker),保证 keyset 比较严格单调。
decode_cursor的解码校验链(common/cursor.py):
- 空值 / 空串直接返回
None(表示第一页); - 还原被
encode_cursor剥掉的 base64 padding("=" * (-len(cursor) % 4)),然后urlsafe_b64decode+json.loads,任何异常包装为CursorDecodeError; payload["v"] != CURSOR_SCHEMA_VERSION→CursorDecodeError;payload["s"] != expected_context→CursorDecodeError(context 不匹配,说明调用方的排序/过滤条件在 cursor 签发之后变了);payload["k"]非 list 或长度 !=expected_key_len→CursorDecodeError。
decode_cursor失败(token 篡改 / context 不匹配 / key 长度对不上)统一抛CursorDecodeError,由 Service/API 层翻译成模块专属业务错误码:
| 错误码 | 模块 | 异常类 | 含义 |
|---|---|---|---|
10550 | flow (105) | AppInvalidCursorError(common/errcode/flow.py) | workflow/app 列表 cursor 解码失败 |
10991 | knowledge (109) | KnowledgeInvalidCursorError(common/errcode/knowledge.py) | 知识库列表 cursor 解码失败 |
18070 | knowledge_space (180) | KnowledgeSpaceInvalidCursorError(common/errcode/knowledge_space.py) | 空间文件列表 cursor 解码失败 |
前端拿到这些错误码后必须reset cursor=null重新从第一页拉(useInfiniteCursorTable暴露的reset()正是为此设计,其注释明确列了 10991 / 10550 / 18070 三个码)。
编码侧的 JSON 兼容处理:F027 的排序键经常包含datetime(update_time/create_time)。encode_cursor通过_json_defaultfallback 把datetime/date转成 ISO 8601 字符串,否则json.dumps会抛TypeError。解码后 keyset WHERE 直接把 ISO 字符串字面量与列比较,MySQL 和 DM 都会隐式 cast,所以比较语义不变。
3. Keyset WHERE 子句:DM8 兼容的展开式
database/utils/keyset.py的build_keyset_where()是所有 cursor DAO 的统一 WHERE 子句生成器。SQL-92 标准写法是row-value tuple 比较:
WHERE (update_time, id) < (?, ?)MySQL / Postgres / SQLite 都支持。但DM8 v8 不支持(报[CODE:-2007] line N, column M, nearby [?] has error: Syntax error),即使 T001 的 dialect-stub smoke test 用DefaultDialect编译能通过——编译能过 ≠ 运行时能跑,这是达梦方言栈最容易踩的坑。
因此模块级开关_USE_EXPANDED_FALLBACK = True必须始终开启(源码注释明确要求:改动前必须在真实 DM8 环境验证)。开启后 helper 自动把 tuple 比较展开成 OR ladder:
WHERE update_time > ? OR (update_time = ? AND id > ?)语义等价,索引使用一样(复合索引(update_time, id)同样能 seek)。
3.1 混合方向 ASC/DESC
space_children 的 keyset 是file_type ASC, ext_rank ASC, update_time DESC, id DESC—— 混合方向用 tuple 表达不了,必须用展开 OR。helper 接受descending: Sequence[bool]参数,自动按列方向生成>或<:
build_keyset_where( sort_cols=(t.c.file_type, t.c.update_time, t.c.id), cursor_values=(0, dt0, 100), descending=(False, True, True), )从 keyset.py 的实现看,展开逻辑是标准的 OR ladder 构造:第 i 列生成col_0 = v_0 AND ... AND col_{i-1} = v_{i-1} AND col_i >|< v_i,全部用or_()串起来。descending为单个bool时(所有列同方向),若_USE_EXPANDED_FALLBACK开启同样走展开式;只有未来关闭 fallback 且方向一致时才会退回 SQLAlchemy 的tuple_() < tuple_()紧凑写法。helper 还会校验sort_cols/cursor_values/descending三者长度一致,不一致直接ValueError。
3.2 CASE 表达式作 sort_col
knowledge_file的ext_rank(扩展名优先级:pdf=1 / docx=2 / ...)是一个 15-WHEN 的 CASE 表达式。helper 接受任意 SQLAlchemyColumnElement,包括case(),所以 cursor 排序键可以是计算值。但这里有一个必须维护的**「双函数对」一致性约束**:
- SQL 侧:
case()表达式算ext_rank,用于 DAO 排序与 keyset WHERE; - Python 侧:
_compute_ext_rank_python()(定义在 knowledge_space_file.py),用于收到 DAO 一批数据后给最后一行算ext_rank推进batch_cursor。
_scan_visible_child_items的 cursor 推进就是典型用法(knowledge_space_service.py):
last_db = batch_items[-1] batch_cursor = [ last_db.file_type, _compute_ext_rank_python(last_db.file_name), last_db.update_time, last_db.id, ]Python 侧如果和 SQL CASE 错位(比如 CASE WHEN 的优先级顺序改了一边没改另一边),下一批 keyset 边界就会算错,导致漏行或重复——这是改动ext_rank定义时必须同时改两处的原因。
4. Fetch-until-enough Scan Loop
OFFSET 翻页时代不存在「页缺数」问题:细权限把当前页过滤剩 7 条,下一页就是第 N+1 行起步,页永远填满。但 cursor 模式下,如果 service 在 DAO 之后做 ReBAC 细过滤,page_size=20拉来的 20 行过滤后可能只剩 7 条,直接返给前端就是「列表突然短」。
解决套路:在 service 层加循环——DAO 拉一批 → 过滤 → 累积到page_size + 1(探到 has_more)或 DB 拉空才返回。两个真实落地案例:
| 接口 | 实现 | batch_size 常量 |
|---|---|---|
workflow/list | WorkFlowService._scan_visible_flows_cursor(api/services/workflow.py) | _FLOW_PERMISSION_SCAN_BATCH_SIZE = 50 |
knowledge_space/children | KnowledgeSpaceService._scan_visible_child_items(knowledge_space_service.py) | _CHILD_PERMISSION_SCAN_BATCH_SIZE = 100 |
骨架(伪代码,与_scan_visible_flows_cursor逐行对应):
visible: List[Dict] = [] batch_cursor = decoded_cursor # 从前端 cursor 解出来,或 None 表示第一页 while True: batch, db_has_more = await DAO.fetch(cursor=batch_cursor, limit=BATCH_SIZE) if not batch: return visible[:page_size], False kept = filter_by_fine_grained_permission(batch) for item in kept: visible.append(item) if len(visible) > page_size: return visible[:page_size], True # has_more=True if not db_has_more: return visible[:page_size], False # 关键: cursor 推进用 last DB row,不是 last visible batch_cursor = encode_sort_key_from(batch[-1])最容易写错的一行:batch_cursor = batch[-1]必须用 DAO 返回的最后一行(last DB row),不能用过滤后的最后一行(last visible)。源码中两处实现都带着同样的注释警告:
"Advance batch_cursor to the LAST DB row of this batch (not last visible) so the next batch picks up strictly after; if we used the last visible, items filtered out between them would be re-emitted on the next batch."
如果误用 last visible,被过滤掉的中间行会落在 keyset 的「严格大于」边界之内,下一批会被 DAO 重新返出来,最终重复累积进visible,造成列表出现重复条目且永无尽头。
4.1 不同接口的过滤位置差异
三条 cursor 线的 OpenFGA 过滤策略不同,决定了是否需要 scan loop:
| 接口 | 过滤位置 | 是否需要 scan loop |
|---|---|---|
knowledge | DB 之前:PermissionService.list_accessible_ids()一次拉出可见 id 集,作为 DAOWHERE id IN (...)条件 | 否,DB 拉多少 = 返多少 |
workflow/list | DB 前粗筛 + DB 后细筛:粗筛只看类型维度(view_app/edit_app),DAO 后对结果再跑get_app_permission_map_async | 是,因为细筛可能缩水 |
knowledge_space/children | DB 后逐批过滤:_build_child_permission_context+ per-item check | 是,且过滤率可能 > 50% |
knowledge 走「先算清楚再查」,代价是首次进入要并发跑全集 ReBAC,但走 Redis 缓存基本毫秒级;workflow / space_children 走「先查再过滤」,所以必须 fetch-until-enough。这个取舍在源码里还有一条补充:workflow 的 scan loop 还会跨批次聚合writeable_ids,保证响应里的can_write标志不受批次边界影响(workflow.py)。
另外注意:workflow 的next_cursor编码用的是last["update_time"], last["id"],但 workflow/list 是workflow(int id)与 assistant(UUID 字符串 id)的 UNION。源码注释解释了为何这里不会崩:encode_cursor不强转类型,JSON 保留原类型(int 和 str 都能序列化),keyset WHERE 与sub_query.c.id列比较时由 SQLAlchemy 的 literal binding 吸收两种类型。如果哪个环节手贱写了int(last['id']),UNION 里遇到 UUID 行就会抛ValueError——这正是「关键陷阱速查」表里那一行的由来。
5. 前端模式
5.1 Platform:useInfiniteCursorTable
复用 hook src/frontend/platform/src/util/hook.ts:
const { data, hasMore, loading, reload, loadMore } = useInfiniteCursorTable({ queryFn: ({ cursor }) => getKnowledgeList({ cursor, page_size: 20, ...filters }), deps: [searchText, sortBy], // 这些变化时自动 reload(reset cursor=null) })hook 内部维护nextCursor/accumulated data;调用方只暴露data、hasMore、loadMore()。源码里的实现细节很值得注意:
requestIdRef序列化在途请求:每次loadPage自增requestIdRef.current,响应回来时若reqId !== requestIdRef.current则丢弃,保证并发下只有最新一次请求的结果生效,避免旧响应覆盖新数据;loadMore三重守卫:loading || !hasMore || !cursor任一成立就 BLOCKED(源码里有console.log('[useInfiniteCursorTable] loadMore BLOCKED by guard')),防止重复触发与末页空转;filterData(params)语义对齐旧useTable:把任意过滤参数 merge 进paramsRef后从第一页重载,调用方换 hook 时无需改下拉框处理逻辑;refreshData(predicate, patch):按谓词匹配行并局部 merge patch,对齐旧 hook 的 mutation 调用点;reset():清空 keyword 并从第一页重载,供*InvalidCursorError场景使用。
搜索/过滤/排序变化时search()/filterData()都会把 cursor 重置为null(loadPage(null, false)),因为旧的 cursor 的 context 签名已经不再匹配。
5.2 Client:useFileManager
Client 没有通用 hook(useFileManager.ts是 SpaceDetail 专用,src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts)。useFileManager把「page 1 替换、page>1 append」「默认路径用 nextCursor、搜索路径用 nextSearchPage 拼接」合在loadFiles(page)一个方法里,外部用onPageChange(currentPage + 1)触发下一批。
5.3 LoadMore sentinel
src/frontend/platform/src/components/bs-comp/loadMore/index.tsx 和 src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx 是同一模式的两个版本。核心实现:
const sentinelRef = useRef<HTMLDivElement>(null) useEffect(() => { const root = findScrollableAncestor(sentinelRef.current) // ↑ 必须传 root,否则容器内滚动不触发 const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) onLoadRef.current?.() }, { root, threshold: 0.1 }) observer.observe(sentinelRef.current) return () => observer.disconnect() }, [])两个最坑的陷阱:
- IntersectionObserver
root: null默认走 viewport。BiSheng 大部分列表是「列表区在固定高度容器里 overflow:scroll」,容器内滚动不改变 sentinel 跟 viewport 的关系 → observer 只在 mount 时触发一次,之后永远不再触发。必须用findScrollableAncestor()走 DOM 找最近overflow-y: auto / scroll / overlay祖先作 root。 onLoad闭包冻结 stalenextCursor。observer 是 mount 时创建的([]deps),callback 里用的onLoad是首次渲染时的版本。必须用useRef同步:onLoadRef.current = onLoad每次 render 都更新,observer callback 调onLoadRef.current?.()拿最新版本。
不解决这两个,代码看起来对、第一页加载也对,然后下拉就再也不触发,且没有任何报错——这是无限滚动实现里最典型的「静默失效」。
5.4 短列表「mount 即触发」副作用
如果首屏数据不足以撑满 scroll container,sentinel mount 时就跟 viewport 相交 → 立刻触发一次 LoadMore。如果第二页数据还不满,继续触发 → 直到hasMore=false。这是正确行为(数据够少就该一次全拉),但 UX 上「没滚就在加载」可能让用户疑惑。需要时可加 500ms mount 缓冲期。
5.5 5s 状态轮询不能动 cursor 链
useFileManager在有「处理中文件」时每 5s 轮询刷状态。append 模式下,不能再用loadFiles(currentPage)——那会把累积 files 替换成最新一批,前面累积的尾部全丢,且 cursor 会前进。
正确做法(refreshLoadedStatuses()):
- 调一次
cursor=null, page_size=files.length,拿前 N 条最新数据; - 按
idmerge:已加载行用回包覆盖 status / progress 字段;回包里有但本地没有(新上传)append 到头部;本地有但回包没有的不删; nextCursor / hasMore不动。
搜索状态下不轮询(搜索结果是「截图」,实时刷状态意义不大且接口语义不同)。
6. 关键陷阱速查
| 现象 | 根因 | 修法 |
|---|---|---|
DM8 报[CODE:-2007] line N nearby [?] Syntax error,SQL 含(col_a, col_b) < (?, ?) | DM8 不支持 row-value tuple compare | _USE_EXPANDED_FALLBACK = True(已是默认) |
| Workflow/space_children 列表「页缺数」(每页返 7 条) | 细权限过滤后没补 | scan loop,batch_cursor 推进用 last DB row |
| LoadMore mount 后只触发一次,滚动再不触发 | IntersectionObserverroot: null默认 viewport,但 sentinel 在 overflow 容器里 | findScrollableAncestor()找最近 scroll 祖先作 root |
LoadMore 触发但onLoad用的是首次 render 的 cursor | []deps 的 useEffect 闭包冻结了 onLoad | useRef同步:onLoadRef.current = onLoad每 render |
| Client SpaceDetail 跳到第 5 页拿到第 2 页数据 | cursor: page > 1 ? nextCursor : null中 nextCursor 只是「下一页」的 cursor,跨页跳无中间历史 | 不允许跳页:UI 改成 LoadMore append 即可 |
| 5s 轮询把无限滚动列表「截短」回首页 | 轮询调loadFiles(currentPage)替换了累积数据 | 改成refreshLoadedStatuses(),只 merge status,不动 cursor 链 |
int(last['id'])抛 ValueError | workflow/list UNION:flow id 是 int,assistant id 是 UUID 字符串 | encode_cursor不强转类型,JSON 保留原类型 |
datetime is not JSON serializable | update_time是 datetime,cursor 编码崩 | encode_cursor加 datetime → ISO 字符串 fallback |
部署 backend 镜像时拉不到dataelement/bisheng-backend:base.v8 | base image 在 docker.io 上 403,cr.dataelem.com 上没有 | 写Dockerfile.beta3:FROM cr.dataelem.com/dataelement/bisheng-backend:feat_2.6.0-beta2+COPY ./ ./增量构建 |
最后一行是 F027 期间的部署侧教训:base image 在 docker.io 上被 403 拒绝,而内部镜像仓库 cr.dataelem.com 上没有该 tag,解决方式是改用内部可用的feat_2.6.0-beta2作为基础镜像并增量 COPY 代码。这提醒我们:改造上线时镜像可达性同样要提前验证。
7. 关键文件路径速查
文档体系 - 本文档 → docs/architecture/13-cursor-pagination.md - spec / tasks → features/v2.6.0/027-rebac-list-perf-optim/{spec,tasks}.md - release-contract → features/v2.6.0/release-contract.md (F027 entry + INV-6) cursor 编解码 → src/backend/bisheng/common/cursor.py keyset WHERE → src/backend/bisheng/database/utils/keyset.py (_USE_EXPANDED_FALLBACK = True) envelope → src/backend/bisheng/common/schemas/api.py (PageInfiniteCursorData) errcodes → src/backend/bisheng/common/errcode/{knowledge,flow,knowledge_space}.py 10550 / 10991 / 18070 后端 cursor 实现 - knowledge → src/backend/bisheng/knowledge/domain/services/knowledge_service.py - workflow → src/backend/bisheng/api/services/workflow.py _scan_visible_flows_cursor (fetch-until-enough) get_all_flows_envelope - space_children → src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py _scan_visible_child_items (fetch-until-enough) list_space_children _compute_ext_rank_python (SQL CASE 的 Python 等价) - departments tree → src/backend/bisheng/department/domain/services/department_service.py (member_count 已移除) 前端 platform - hook → src/frontend/platform/src/util/hook.ts → useInfiniteCursorTable - LoadMore → src/frontend/platform/src/components/bs-comp/loadMore/index.tsx - 入口 → pages/BuildPage/apps.tsx pages/KnowledgePage/KnowledgeFile.tsx (兼 /build/knowledge 和 ?type=1 QA 库) 前端 client - hook → src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts - LoadMore → src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx - 入口 → src/frontend/client/src/pages/knowledge/SpaceDetail/index.tsx 测试 - cursor 编解码 → src/backend/test/common/test_cursor.py - keyset DAO → src/backend/test/database/test_keyset.py - knowledge cursor → src/backend/test/knowledge/test_knowledge_list_cursor.py - workflow cursor → src/backend/test/api/test_workflow_list_cursor.py - space children → src/backend/test/knowledge/test_knowledge_space_children_cursor.py - 部门树 → src/backend/test/department/test_department_tree_no_member_count.py - client SpaceDetail → src/frontend/client/src/pages/knowledge/hooks/useFileManager.test.ts8. 给「下一个改这块的人」的清单
要新加一个「列表 X」走 cursor + 无限滚动,按以下七步走:
- DAO 层:把现有
query_xxx(page, page_size)改成query_xxx(cursor, limit),WHERE 加build_keyset_where(sort_cols, cursor)(cursor is None时跳过,即首页不加 keyset 谓词——helper 本身不特判None,调用方要在首页省略谓词),fetch_limit = limit + 1探 has_more。返回(data, has_more),不返 total。 - Service 层:加
xxx_envelope():decode_cursor → fetch-until-enough(如果有细权限过滤)→ encode_cursor(last visible) → PageInfiniteCursorData。 - Endpoint:
cursor: Optional[str] = Query(None)+page_size: int = Query(20),return envelope。 - errcode:在所属模块
errcode/<module>.py加一个<XxxInvalidCursorError>(5 位 MMMEE,格式参照10550的 flow 模块),context 字符串配套(例如"xxx|sort=update_time")。 - 索引:评估是否需要新加复合索引
(sort_col_1, ..., id),DM8 + MySQL 双方言验证(alembic migration 注意dialect_helpers)。 - 前端:platform 用
useInfiniteCursorTable一行接;client 仿照useFileManager.ts写 hook +<LoadMore>sentinel(注意findScrollableAncestor与onLoadRef两个坑)。 - 测试:单元测 envelope 路径(mock DAO 测 cursor 解码 / encode / has_more);单元 / 静态测覆盖 fetch-until-enough 循环存在。
实施前先读 spec(features/v2.6.0/027-rebac-list-perf-optim/spec.md)的 AD 节,里面沉淀了 F027 期间的 architectural decisions,包括为什么选 keyset(update_time, id)而不是其他组合、为什么 file_type 排序要用 ext_rank 复合 cursor(AD-14)、以及 name 排序为何走「伪 cursor」(内部 offset,AD-15,cursor key 是[page_num],knowledge_service.py的list_knowledge中offset_scan = sort_by == "name"分支就是它的实现)等关键决策。
9. 设计权衡小结
| 维度 | OFFSET 翻页 | Cursor(keyset)翻页 |
|---|---|---|
| 深翻页成本 | 随页号线性增长(每页都要重扫 + ReBAC 过滤前页) | 恒定 O(窗口大小),与页号解耦 |
| 总数统计 | 依赖COUNT(*)(大表 + ReBAC 场景昂贵) | 彻底砍掉total,用has_more替代 |
| 数据一致性 | 翻页期间插入/删除会导致错位/重复 | keyset 边界严格单调,天然免疫插入错位 |
| 随机跳页 | 支持 | 不支持(UI 只能无限滚动 append) |
| 细权限过滤 | 定位页号需过滤前 N 页全部行 | 每页只需过滤当前窗口 |
| 数据库兼容 | 所有方言 | DM8 需展开 OR ladder,混合方向必须展开 |
这套模式并非银弹:cursor 翻页牺牲了随机跳页能力,且next_cursor与排序/过滤条件强绑定(context 签名就是为此设计的防御)。但对 BiSheng 的高频列表场景——用户从第一页顺序往下滚、权限过滤发生在 DAO 之后、深翻页访问频繁——它是把 ReBAC 请求量从「随页号线性」压回「恒定窗口」的正确取舍,也是 F027 的核心收益来源。
【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考