Skip to content

Commit f89292e

Browse files
committed
feat: implement cursor-based pagination across various components and APIs
1 parent 56b48b2 commit f89292e

12 files changed

Lines changed: 281 additions & 154 deletions

File tree

api/response.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,25 @@ def success(data: Any = None, msg: str = "ok", code: int = 0):
99
def page(items: list[Any], total: int, page: int, page_size: int):
1010
"""统一分页数据结构。"""
1111
pages = (total + page_size - 1) // page_size if page_size else 0
12-
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
12+
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages, "pagination_mode": "paged"}
13+
14+
15+
def cursor_page(
16+
items: list[Any],
17+
page_size: int,
18+
*,
19+
cursor: str | None = None,
20+
next_cursor: str | None = None,
21+
):
22+
"""无总数游标分页结构。"""
23+
return {
24+
"items": items,
25+
"page_size": page_size,
26+
"pagination_mode": "cursor",
27+
"cursor": cursor,
28+
"next_cursor": next_cursor,
29+
"has_next": bool(next_cursor),
30+
}
1331

1432

1533
def error(msg: str, code: int = 1, data: Optional[Any] = None):

domain/adapters/providers/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator
1+
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator, Any
22
from models import StorageAdapter
33

44
# 约定:任意新适配器模块需定义:
@@ -9,7 +9,7 @@
99
@runtime_checkable
1010
class BaseAdapter(Protocol):
1111
record: StorageAdapter
12-
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: ...
12+
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc", cursor: str | None = None) -> Tuple[List[Dict], int] | Dict[str, Any]: ...
1313
async def read_file(self, root: str, rel: str) -> bytes: ...
1414
async def write_file(self, root: str, rel: str, data: bytes): ...
1515
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ...

domain/adapters/providers/onedrive.py

Lines changed: 35 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from fastapi.responses import StreamingResponse, Response
55
from fastapi import HTTPException
66
from models import StorageAdapter
7+
from api.response import cursor_page
78

89
MS_GRAPH_URL = "https://graph.microsoft.com/v1.0"
910
MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
@@ -114,65 +115,51 @@ def _format_item(self, item: Dict) -> Dict:
114115
"type": "dir" if is_dir else "file",
115116
}
116117

117-
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
118+
async def list_dir(
119+
self,
120+
root: str,
121+
rel: str,
122+
page_num: int = 1,
123+
page_size: int = 50,
124+
sort_by: str = "name",
125+
sort_order: str = "asc",
126+
cursor: str | None = None,
127+
):
118128
"""
119129
列出目录内容。
120-
由于 Graph API 不支持基于偏移($skip)的分页,此方法将获取所有项目,
130+
Graph API 不提供目录总数,使用 nextLink 游标分页。
121131
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
122132
:param rel: 相对路径。
123133
:param page_num: 页码。
124134
:param page_size: 每页大小。
125135
:param sort_by: 排序字段
126136
:param sort_order: 排序顺序
127-
:return: 文件/目录列表和总数。
137+
:param cursor: Graph nextLink。
138+
:return: 游标分页结果。
128139
"""
129-
api_path = self._get_api_path(rel)
130-
children_path = f"{api_path}:/children" if api_path else "/children"
131-
all_items = []
132-
params = {"$top": 999}
133-
resp = await self._request("GET", api_path_segment=children_path, params=params)
134-
135-
while True:
136-
if resp.status_code == 404 and not all_items:
137-
return [], 0
138-
resp.raise_for_status()
139-
140-
try:
141-
data = resp.json()
142-
except Exception as e:
143-
raise IOError(f"解析 Graph API 响应失败: {e}") from e
144-
145-
all_items.extend(data.get("value", []))
146-
next_link = data.get("@odata.nextLink")
147-
148-
if not next_link:
149-
break
150-
151-
resp = await self._request("GET", full_url=next_link)
152-
153-
formatted_items = [self._format_item(item) for item in all_items]
154-
155-
# 排序
156-
reverse = sort_order.lower() == "desc"
157-
def get_sort_key(item):
158-
key = (not item["is_dir"],)
159-
sort_field = sort_by.lower()
160-
if sort_field == "name":
161-
key += (item["name"].lower(),)
162-
elif sort_field == "size":
163-
key += (item["size"],)
164-
elif sort_field == "mtime":
165-
key += (item["mtime"],)
166-
else:
167-
key += (item["name"].lower(),)
168-
return key
169-
formatted_items.sort(key=get_sort_key, reverse=reverse)
140+
if cursor:
141+
resp = await self._request("GET", full_url=cursor)
142+
else:
143+
api_path = self._get_api_path(rel)
144+
children_path = f"{api_path}:/children" if api_path else "/children"
145+
resp = await self._request("GET", api_path_segment=children_path, params={"$top": page_size})
170146

171-
total_count = len(formatted_items)
172-
start_idx = (page_num - 1) * page_size
173-
end_idx = start_idx + page_size
147+
if resp.status_code == 404:
148+
return cursor_page([], page_size, cursor=cursor)
149+
resp.raise_for_status()
174150

175-
return formatted_items[start_idx:end_idx], total_count
151+
try:
152+
data = resp.json()
153+
except Exception as e:
154+
raise IOError(f"解析 Graph API 响应失败: {e}") from e
155+
156+
formatted_items = [self._format_item(item) for item in data.get("value", [])]
157+
return cursor_page(
158+
formatted_items,
159+
page_size,
160+
cursor=cursor,
161+
next_cursor=data.get("@odata.nextLink"),
162+
)
176163

177164
async def read_file(self, root: str, rel: str) -> bytes:
178165
"""

domain/adapters/providers/telegram.py

Lines changed: 64 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import struct
77
import time
88
from models import StorageAdapter
9+
from api.response import cursor_page
910
from telethon import TelegramClient, errors, utils
1011
from telethon.crypto import AuthKey
1112
from telethon.sessions import StringSession
@@ -280,81 +281,79 @@ def _parse_message_id(rel: str) -> int:
280281
def get_effective_root(self, sub_path: str | None) -> str:
281282
return ""
282283

283-
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
284+
async def list_dir(
285+
self,
286+
root: str,
287+
rel: str,
288+
page_num: int = 1,
289+
page_size: int = 50,
290+
sort_by: str = "name",
291+
sort_order: str = "asc",
292+
cursor: str | None = None,
293+
):
284294
if rel:
285-
return [], 0
295+
return cursor_page([], page_size, cursor=cursor)
286296

287297
client = self._get_client()
288298
entries = []
299+
next_cursor = None
289300
try:
290301
await client.connect()
291-
messages = await client.get_messages(self.chat_id, limit=200)
292-
for message in messages:
293-
if not message:
294-
continue
295-
296-
media = message.document or message.video or message.photo
297-
if not media:
298-
continue
299-
300-
file_meta = message.file
301-
if not file_meta:
302-
continue
303-
304-
filename = file_meta.name
305-
if not filename:
306-
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
307-
filename = message.text
308-
else:
309-
filename = f"unknown_{message.id}"
310-
311-
size = file_meta.size
312-
if size is None:
313-
# 兼容缺失 size 的情况
314-
if hasattr(media, "size") and media.size is not None:
315-
size = media.size
316-
elif message.photo and getattr(message.photo, "sizes", None):
317-
photo_size = message.photo.sizes[-1]
318-
size = getattr(photo_size, "size", 0) or 0
319-
else:
320-
size = 0
321-
322-
entries.append({
323-
"name": f"{message.id}_{filename}",
324-
"is_dir": False,
325-
"size": size,
326-
"mtime": int(message.date.timestamp()),
327-
"type": "file",
328-
"has_thumbnail": False,
329-
})
302+
offset_id = int(cursor) if cursor else 0
303+
batch_limit = min(max(page_size, 50), 200)
304+
while len(entries) < page_size:
305+
messages = await client.get_messages(self.chat_id, limit=batch_limit, offset_id=offset_id)
306+
if not messages:
307+
next_cursor = None
308+
break
309+
310+
offset_id = messages[-1].id
311+
next_cursor = str(offset_id)
312+
for message in messages:
313+
if not message:
314+
continue
315+
316+
media = message.document or message.video or message.photo
317+
if not media:
318+
continue
319+
320+
file_meta = message.file
321+
if not file_meta:
322+
continue
323+
324+
filename = file_meta.name
325+
if not filename:
326+
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
327+
filename = message.text
328+
else:
329+
filename = f"unknown_{message.id}"
330+
331+
size = file_meta.size
332+
if size is None:
333+
# 兼容缺失 size 的情况
334+
if hasattr(media, "size") and media.size is not None:
335+
size = media.size
336+
elif message.photo and getattr(message.photo, "sizes", None):
337+
photo_size = message.photo.sizes[-1]
338+
size = getattr(photo_size, "size", 0) or 0
339+
else:
340+
size = 0
341+
342+
entries.append({
343+
"name": f"{message.id}_{filename}",
344+
"is_dir": False,
345+
"size": size,
346+
"mtime": int(message.date.timestamp()),
347+
"type": "file",
348+
"has_thumbnail": False,
349+
})
350+
if len(entries) >= page_size:
351+
break
330352
finally:
331353
if client.is_connected():
332354
await client.disconnect()
333355

334-
# 排序
335-
reverse = sort_order.lower() == "desc"
336-
def get_sort_key(item):
337-
key = (not item["is_dir"],)
338-
sort_field = sort_by.lower()
339-
if sort_field == "name":
340-
key += (item["name"].lower(),)
341-
elif sort_field == "size":
342-
key += (item["size"],)
343-
elif sort_field == "mtime":
344-
key += (item["mtime"],)
345-
else:
346-
key += (item["name"].lower(),)
347-
return key
348-
entries.sort(key=get_sort_key, reverse=reverse)
349-
350-
total_count = len(entries)
351-
352-
# 分页
353-
start_idx = (page_num - 1) * page_size
354-
end_idx = start_idx + page_size
355-
page_entries = entries[start_idx:end_idx]
356-
357-
return page_entries, total_count
356+
return cursor_page(entries, page_size, cursor=cursor, next_cursor=next_cursor)
358357

359358
async def read_file(self, root: str, rel: str) -> bytes:
360359
message_id = self._parse_message_id(rel)

domain/virtual_fs/api.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,10 @@ async def browse_fs(
183183
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
184184
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
185185
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
186+
cursor: str | None = Query(None, description="游标分页位置"),
186187
):
187188
data = await VirtualFSService.list_directory_with_permission(
188-
full_path, current_user.id, page_num, page_size, sort_by, sort_order
189+
full_path, current_user.id, page_num, page_size, sort_by, sort_order, cursor
189190
)
190191
return success(data)
191192

@@ -211,9 +212,10 @@ async def root_listing(
211212
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
212213
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
213214
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
215+
cursor: str | None = Query(None, description="游标分页位置"),
214216
):
215217
# 根目录不需要权限检查,但需要过滤无权限的子目录
216218
data = await VirtualFSService.list_directory_with_permission(
217-
"/", current_user.id, page_num, page_size, sort_by, sort_order
219+
"/", current_user.id, page_num, page_size, sort_by, sort_order, cursor
218220
)
219221
return success(data)

domain/virtual_fs/listing.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def list_virtual_dir(
5757
page_size: int = 50,
5858
sort_by: str = "name",
5959
sort_order: str = "asc",
60+
cursor: str | None = None,
6061
) -> Dict:
6162
norm = cls._normalize_path(path).rstrip("/") or "/"
6263
adapters = await StorageAdapter.filter(enabled=True)
@@ -119,12 +120,28 @@ def annotate_entry(entry: Dict) -> None:
119120
adapter_entries_for_merge: List[Dict] = []
120121
adapter_entries_page: List[Dict] | None = None
121122
adapter_total: int | None = None
123+
adapter_listing: Dict[str, Any] | None = None
122124
if adapter_model and adapter_instance:
123125
list_dir = getattr(adapter_instance, "list_dir", None)
124126
if callable(list_dir):
125-
adapter_entries_page, adapter_total = await list_dir(
126-
effective_root, rel, page_num, page_size, sort_by, sort_order
127-
)
127+
try:
128+
parameters = inspect.signature(list_dir).parameters
129+
except (TypeError, ValueError):
130+
parameters = {}
131+
if "cursor" in parameters:
132+
raw_listing = await list_dir(
133+
effective_root, rel, page_num, page_size, sort_by, sort_order, cursor=cursor
134+
)
135+
else:
136+
raw_listing = await list_dir(
137+
effective_root, rel, page_num, page_size, sort_by, sort_order
138+
)
139+
if isinstance(raw_listing, dict):
140+
adapter_listing = raw_listing
141+
adapter_entries_page = raw_listing.get("items", [])
142+
adapter_total = raw_listing.get("total")
143+
else:
144+
adapter_entries_page, adapter_total = raw_listing
128145
if rel:
129146
parent_rel = cls._parent_rel(rel)
130147
if rel:
@@ -189,6 +206,9 @@ def annotate_entry(entry: Dict) -> None:
189206
annotate_entry_list = adapter_entries_page or []
190207
for ent in annotate_entry_list:
191208
annotate_entry(ent)
209+
if adapter_listing and adapter_listing.get("pagination_mode") == "cursor":
210+
adapter_listing["items"] = annotate_entry_list
211+
return adapter_listing
192212
return page(adapter_entries_page, adapter_total, page_num, page_size)
193213

194214
@classmethod
@@ -296,13 +316,14 @@ async def list_virtual_dir_with_permission(
296316
page_size: int = 50,
297317
sort_by: str = "name",
298318
sort_order: str = "asc",
319+
cursor: str | None = None,
299320
) -> Dict:
300321
"""
301322
带权限过滤的目录列表
302323
303324
过滤掉用户没有读取权限的条目
304325
"""
305-
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
326+
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order, cursor)
306327
items = result.get("items", [])
307328
if not items:
308329
return result

0 commit comments

Comments
 (0)