From a67f1b2a0f152fd977af4e6967959093cdd0a92c Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Wed, 17 Jun 2026 08:49:26 +0800 Subject: [PATCH] feat(pagination): add generic pagination support - Implement PaginationParams with offset/limit calculation - Add PaginationMeta with navigation metadata - Create generic PaginatedResponse[T] with type safety - Support both in-memory and database pagination - Include has_next/has_prev navigation flags - Add comprehensive pagination documentation - Include frontend integration examples (React/Vue) - Cover cursor pagination for large datasets Phase 4 Task 47/68 completed --- docs/PAGINATION.md | 330 ++++++++++++++++++++++ packages/application/common/pagination.py | 98 +++++++ 2 files changed, 428 insertions(+) create mode 100644 docs/PAGINATION.md create mode 100644 packages/application/common/pagination.py diff --git a/docs/PAGINATION.md b/docs/PAGINATION.md new file mode 100644 index 000000000..98c9c0441 --- /dev/null +++ b/docs/PAGINATION.md @@ -0,0 +1,330 @@ +# 分页使用指南 + +## 📋 概述 + +小虾 SaaS 提供统一的分页接口,支持内存分页和数据库分页。 + +--- + +## 🔧 基本使用 + +### 1. API 端点添加分页 + +```python +from fastapi import APIRouter, Depends +from packages.application.common.pagination import PaginationParams, PaginatedResponse + +router = APIRouter() + +@router.get("/projects", response_model=PaginatedResponse[ProjectResponse]) +async def list_projects( + pagination: PaginationParams = Depends(), + user_id: str = Depends(get_current_user_id), +): + # 获取分页数据 + projects, total = project_repo.find_by_user_paginated( + user_id=user_id, + offset=pagination.offset, + limit=pagination.limit, + ) + + # 返回分页响应 + return PaginatedResponse.create( + data=projects, + params=pagination, + total=total, + ) +``` + +### 2. 请求分页数据 + +```bash +# 默认第一页,每页 20 条 +GET /api/v1/projects + +# 指定页码和每页数量 +GET /api/v1/projects?page=2&page_size=10 + +# 获取更多数据 +GET /api/v1/projects?page=1&page_size=50 +``` + +### 3. 响应格式 + +```json +{ + "data": [ + { + "id": "project-1", + "name": "项目 1" + }, + { + "id": "project-2", + "name": "项目 2" + } + ], + "pagination": { + "page": 1, + "page_size": 20, + "total": 156, + "total_pages": 8, + "has_next": true, + "has_prev": false + } +} +``` + +--- + +## 🗄️ Repository 实现 + +### InMemory Repository(内存分页) + +```python +from packages.application.common.pagination import paginate, PaginationParams + +class InMemoryProjectRepository: + def find_by_workspace_paginated( + self, + workspace_id: str, + pagination: PaginationParams, + ): + # 获取所有项目 + all_projects = [p for p in self._storage.values() + if p.workspace_id == workspace_id] + + # 使用内存分页 + return paginate(all_projects, pagination) +``` + +### PostgreSQL Repository(数据库分页) + +```python +class PostgresProjectRepository: + def find_by_workspace_paginated( + self, + workspace_id: str, + offset: int, + limit: int, + ) -> tuple[List[Project], int]: + conn = self._get_connection() + try: + with conn.cursor() as cur: + # 获取总数 + cur.execute( + "SELECT COUNT(*) FROM projects WHERE workspace_id = %s", + (workspace_id,) + ) + total = cur.fetchone()["count"] + + # 获取分页数据 + cur.execute(""" + SELECT * FROM projects + WHERE workspace_id = %s + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, (workspace_id, limit, offset)) + + projects = [self._row_to_project(row) for row in cur.fetchall()] + + return projects, total + finally: + conn.close() +``` + +--- + +## 📱 前端使用 + +### React 示例 + +```typescript +import { useState, useEffect } from 'react'; + +function ProjectList() { + const [projects, setProjects] = useState([]); + const [pagination, setPagination] = useState(null); + const [page, setPage] = useState(1); + + useEffect(() => { + fetch(`/api/v1/projects?page=${page}&page_size=20`) + .then(res => res.json()) + .then(data => { + setProjects(data.data); + setPagination(data.pagination); + }); + }, [page]); + + return ( +
+ + + +
+ ); +} +``` + +### Vue 示例 + +```vue + + + +``` + +--- + +## 🎯 最佳实践 + +### 1. 合理的页面大小 + +```python +# ✅ 推荐 +page_size: int = Field(20, ge=1, le=100) # 默认 20,最大 100 + +# ❌ 不推荐 +page_size: int = Field(1000, ge=1, le=10000) # 太大,性能差 +``` + +### 2. 默认排序 + +```python +# ✅ 总是指定排序 +ORDER BY created_at DESC + +# ❌ 不指定排序(结果不稳定) +SELECT * FROM projects LIMIT 20 +``` + +### 3. 使用游标分页(大数据量) + +```python +# 基于 ID 的游标分页(更快) +@router.get("/projects") +async def list_projects( + cursor: Optional[str] = None, # 上一页最后一个 ID + limit: int = 20, +): + if cursor: + projects = project_repo.find_after_cursor(cursor, limit) + else: + projects = project_repo.find_first_page(limit) + + return { + "data": projects, + "next_cursor": projects[-1].id if projects else None, + "has_more": len(projects) == limit + } +``` + +### 4. 缓存总数(可选) + +```python +# 对于不常变化的列表,缓存总数 +from functools import lru_cache + +@lru_cache(maxsize=128) +def get_total_projects(workspace_id: str) -> int: + return project_repo.count_by_workspace(workspace_id) +``` + +--- + +## 📊 性能对比 + +### Offset/Limit 分页 + +**优点:** +- 简单易用 +- 支持跳转到任意页 + +**缺点:** +- 深分页慢(OFFSET 10000 需要扫描 10000 行) +- 数据变化时可能重复/遗漏 + +**适用场景:** +- 数据量小(< 10,000 条) +- 用户很少翻到后面 + +### 游标分页 + +**优点:** +- 性能稳定(始终快速) +- 不受数据变化影响 + +**缺点:** +- 不支持跳转 +- 只能上一页/下一页 + +**适用场景:** +- 数据量大(> 100,000 条) +- 无限滚动 + +--- + +## 🔗 相关资源 + +- [PostgreSQL LIMIT/OFFSET 优化](https://www.postgresql.org/docs/current/queries-limit.html) +- [游标分页最佳实践](https://use-the-index-luke.com/sql/partial-results/fetch-next-page) +- [GraphQL Cursor Connections](https://relay.dev/graphql/connections.htm) + +--- + +**最后更新:** 2026-06-17 diff --git a/packages/application/common/pagination.py b/packages/application/common/pagination.py new file mode 100644 index 000000000..f700fb689 --- /dev/null +++ b/packages/application/common/pagination.py @@ -0,0 +1,98 @@ +""" +通用分页器 +""" +from typing import Generic, TypeVar, List, Optional +from pydantic import BaseModel, Field +from math import ceil + + +T = TypeVar("T") + + +class PaginationParams(BaseModel): + """分页参数""" + page: int = Field(1, ge=1, description="页码(从 1 开始)") + page_size: int = Field(20, ge=1, le=100, description="每页数量(最大 100)") + + @property + def offset(self) -> int: + """计算偏移量""" + return (self.page - 1) * self.page_size + + @property + def limit(self) -> int: + """返回限制数量""" + return self.page_size + + +class PaginationMeta(BaseModel): + """分页元数据""" + page: int = Field(..., description="当前页码") + page_size: int = Field(..., description="每页数量") + total: int = Field(..., description="总记录数") + total_pages: int = Field(..., description="总页数") + has_next: bool = Field(..., description="是否有下一页") + has_prev: bool = Field(..., description="是否有上一页") + + @classmethod + def from_params( + cls, + params: PaginationParams, + total: int, + ) -> "PaginationMeta": + """从参数和总数创建元数据""" + total_pages = ceil(total / params.page_size) if total > 0 else 0 + + return cls( + page=params.page, + page_size=params.page_size, + total=total, + total_pages=total_pages, + has_next=params.page < total_pages, + has_prev=params.page > 1, + ) + + +class PaginatedResponse(BaseModel, Generic[T]): + """分页响应""" + data: List[T] = Field(..., description="数据列表") + pagination: PaginationMeta = Field(..., description="分页信息") + + @classmethod + def create( + cls, + data: List[T], + params: PaginationParams, + total: int, + ) -> "PaginatedResponse[T]": + """创建分页响应""" + return cls( + data=data, + pagination=PaginationMeta.from_params(params, total), + ) + + +def paginate( + items: List[T], + params: PaginationParams, +) -> PaginatedResponse[T]: + """ + 内存分页(适用于 InMemory Repository) + + Args: + items: 完整列表 + params: 分页参数 + + Returns: + 分页响应 + """ + total = len(items) + start = params.offset + end = start + params.limit + page_data = items[start:end] + + return PaginatedResponse.create( + data=page_data, + params=params, + total=total, + )