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
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<ul>
|
||||
{projects.map(p => <li key={p.id}>{p.name}</li>)}
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
current={pagination?.page}
|
||||
total={pagination?.total}
|
||||
pageSize={pagination?.page_size}
|
||||
onChange={setPage}
|
||||
showPrevNext={true}
|
||||
disabled={{
|
||||
prev: !pagination?.has_prev,
|
||||
next: !pagination?.has_next
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Vue 示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<ul>
|
||||
<li v-for="project in projects" :key="project.id">
|
||||
{{ project.name }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
projects: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async fetchProjects(page = 1) {
|
||||
const response = await fetch(
|
||||
`/api/v1/projects?page=${page}&page_size=20`
|
||||
);
|
||||
const data = await response.json();
|
||||
this.projects = data.data;
|
||||
this.pagination = data.pagination;
|
||||
},
|
||||
handlePageChange(page) {
|
||||
this.fetchProjects(page);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchProjects();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user