feat(backend): 无界列表端点加分页——projects/templates 支持 limit/offset

GET /projects 与 GET /templates 原一次返回全部 owner 行,行数随数据线性增长。
新增可复用分页依赖 ww_api.pagination.get_page(limit∈[1,200] 默认 50、offset≥0,
越界 → 422),两端点接入,Repository.list_for_owner 加 keyword-only limit/offset
把 LIMIT/OFFSET 下推 DB。向后兼容:不传参返回第一页。补 projects 分页 + 边界 422 测试。
This commit is contained in:
Yaojia Wang
2026-07-08 12:39:31 +02:00
parent 1392830e57
commit 0892ff6cda
9 changed files with 121 additions and 31 deletions

View File

@@ -63,8 +63,13 @@ class FakeProjectRepo:
self.rows[pid] = (owner_id, view)
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
return [v for (o, v) in self.rows.values() if o == owner_id]
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[ProjectView]:
views = [v for (o, v) in self.rows.values() if o == owner_id]
if limit is None:
return views
return views[offset : offset + limit]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
entry = self.rows.get(project_id)

View File

@@ -222,6 +222,33 @@ async def test_list_projects() -> None:
assert all(p["pending_review_count"] == 0 for p in projects)
@pytest.mark.asyncio
async def test_list_projects_paginates_with_limit_and_offset() -> None:
client, _, _, _ = _make_client()
async with client:
for t in ("", "", ""):
await client.post("/projects", json={"title": t})
page1 = await client.get("/projects?limit=2")
page2 = await client.get("/projects?limit=2&offset=2")
assert page1.status_code == 200
assert [p["title"] for p in page1.json()["projects"]] == ["", ""]
assert page2.status_code == 200
assert [p["title"] for p in page2.json()["projects"]] == [""]
@pytest.mark.asyncio
async def test_list_projects_rejects_out_of_range_pagination() -> None:
client, _, _, _ = _make_client()
async with client:
too_small = await client.get("/projects?limit=0")
too_large = await client.get("/projects?limit=201")
neg_offset = await client.get("/projects?offset=-1")
# 边界越界 → FastAPI 422limit∈[1,200]、offset≥0
assert too_small.status_code == 422
assert too_large.status_code == 422
assert neg_offset.status_code == 422
@pytest.mark.asyncio
async def test_get_project_detail() -> None:
client, _, _, _ = _make_client()

View File

@@ -33,8 +33,13 @@ class _FakeTemplateRepo:
self.rows[view.id] = view
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
return list(self.rows.values())
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[TemplateView]:
views = list(self.rows.values())
if limit is None:
return views
return views[offset : offset + limit]
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if template_id in self.rows:

View File

@@ -0,0 +1,37 @@
"""列表端点的分页参数(`limit`/`offset`,边界校验 + 合理默认)。
无界列表端点owner 维度projects / templates原来一次返回全部行行数随用户
数据线性增长。这里提供一个可复用的 FastAPI 依赖 `get_page`,把 `limit`/`offset`
在边界处校验(越界 → 422后交端点。默认返回第一页`offset=0` + `DEFAULT_PAGE_LIMIT`
不传参的老客户端行为不破坏;`limit` 有上限(`MAX_PAGE_LIMIT`)防一次性拉爆。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, Query
# 合理默认 + 上限(单用户原型:默认页足够覆盖常见数据量;上限防一次拉全表)。
DEFAULT_PAGE_LIMIT = 50
MAX_PAGE_LIMIT = 200
@dataclass(frozen=True)
class Page:
"""一页的边界(不可变):`limit` 条数上限、`offset` 起始偏移。"""
limit: int
offset: int
def get_page(
limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Page:
"""校验并封装分页参数(`limit∈[1,MAX]`、`offset≥0`;越界 FastAPI 返 422"""
return Page(limit=limit, offset=offset)
PageDep = Annotated[Page, Depends(get_page)]

View File

@@ -46,6 +46,7 @@ from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.injection import (
InjectionEntity,
InjectionEntityRef,
@@ -144,8 +145,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
@router.get("")
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
views = await repo.list_for_owner(STUB_OWNER_ID)
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
"""作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
return ProjectListResponse(projects=[_to_response(v) for v in views])

View File

@@ -23,6 +23,7 @@ from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_template_repo
@@ -36,9 +37,9 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.get("")
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
"""列出当前用户stub的模板。"""
views = await repo.list_for_owner(STUB_OWNER_ID)
async def list_templates(repo: TemplateRepoDep, page: PageDep) -> list[TemplateResponse]:
"""列出当前用户stub的模板(分页;`?limit=&offset=`,默认第一页)"""
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
return [
TemplateResponse(
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key

View File

@@ -38,8 +38,13 @@ class _FakeTemplateRepo:
self.flushed += 1
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
return [v for tid, v in self.rows.items() if self.owners[tid] == owner_id]
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[TemplateView]:
views = [v for tid, v in self.rows.items() if self.owners[tid] == owner_id]
if limit is None:
return views
return views[offset : offset + limit]
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if self.rows.get(template_id) is None or self.owners.get(template_id) != owner_id:

View File

@@ -57,7 +57,11 @@ class ProjectRepo(Protocol):
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView: ...
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]: ...
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[ProjectView]:
"""按 created_at 升序。`limit` 非 None 时在 DB 层 LIMIT/OFFSET 分页。"""
...
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: ...
@@ -110,16 +114,13 @@ class SqlProjectRepo:
await self._s.refresh(row)
return _to_view(row)
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
projects = (
(
await self._s.execute(
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
)
)
.scalars()
.all()
)
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[ProjectView]:
stmt = select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
if limit is not None:
stmt = stmt.limit(limit).offset(offset)
projects = (await self._s.execute(stmt)).scalars().all()
project_ids = [p.id for p in projects]
pending_counts = await self._pending_review_counts(project_ids)
activity_times = await self._activity_times(project_ids)

View File

@@ -45,7 +45,11 @@ class TemplateRepo(Protocol):
async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: ...
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: ...
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[TemplateView]:
"""按 created_at 升序。`limit` 非 None 时在 DB 层 LIMIT/OFFSET 分页。"""
...
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: ...
@@ -79,14 +83,17 @@ class SqlTemplateRepo:
await self._s.refresh(row)
return _to_view(row)
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
rows = (
await self._s.execute(
select(PromptTemplate)
.where(PromptTemplate.owner_id == owner_id)
.order_by(PromptTemplate.created_at)
)
).scalars()
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[TemplateView]:
stmt = (
select(PromptTemplate)
.where(PromptTemplate.owner_id == owner_id)
.order_by(PromptTemplate.created_at)
)
if limit is not None:
stmt = stmt.limit(limit).offset(offset)
rows = (await self._s.execute(stmt)).scalars()
return [_to_view(r) for r in rows]
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: