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:
Xiaoxia AI
2026-06-17 08:49:26 +08:00
parent 1982487f9c
commit a67f1b2a0f
2 changed files with 428 additions and 0 deletions
+330
View File
@@ -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
+98
View File
@@ -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,
)