diff --git a/apps/api/tests/fakes_projects.py b/apps/api/tests/fakes_projects.py index 2d706ce..fefa97c 100644 --- a/apps/api/tests/fakes_projects.py +++ b/apps/api/tests/fakes_projects.py @@ -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) diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 6660688..0a72b1e 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -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 422(limit∈[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() diff --git a/apps/api/tests/test_templates.py b/apps/api/tests/test_templates.py index 9b4a034..9fd9894 100644 --- a/apps/api/tests/test_templates.py +++ b/apps/api/tests/test_templates.py @@ -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: diff --git a/apps/api/ww_api/pagination.py b/apps/api/ww_api/pagination.py new file mode 100644 index 0000000..05c89dd --- /dev/null +++ b/apps/api/ww_api/pagination.py @@ -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)] diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index 66bb003..30daa56 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -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]) diff --git a/apps/api/ww_api/routers/templates.py b/apps/api/ww_api/routers/templates.py index f3c9a2a..636f71f 100644 --- a/apps/api/ww_api/routers/templates.py +++ b/apps/api/ww_api/routers/templates.py @@ -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 diff --git a/packages/core/tests/test_template_repo.py b/packages/core/tests/test_template_repo.py index 57be064..02d1293 100644 --- a/packages/core/tests/test_template_repo.py +++ b/packages/core/tests/test_template_repo.py @@ -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: diff --git a/packages/core/ww_core/domain/project_repo.py b/packages/core/ww_core/domain/project_repo.py index f6474a7..7bbc2b6 100644 --- a/packages/core/ww_core/domain/project_repo.py +++ b/packages/core/ww_core/domain/project_repo.py @@ -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) diff --git a/packages/core/ww_core/domain/template_repo.py b/packages/core/ww_core/domain/template_repo.py index 72b6833..7db3ce8 100644 --- a/packages/core/ww_core/domain/template_repo.py +++ b/packages/core/ww_core/domain/template_repo.py @@ -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: