From 9aa0cddeaf25f2610edabb51daf4c08aff28afcd Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 12 Jul 2026 17:52:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E5=AE=A1=E7=A8=BF=E9=80=89?= =?UTF-8?q?=E7=AB=A0=E5=8F=AF=E7=94=A8=E2=80=94=E2=80=94=E5=88=97=E7=AB=A0?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=20+=20=E9=80=89=E7=AB=A0=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E5=85=A8=E7=AB=A0=E5=8F=AF=E8=A7=81=20+=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测 - gen:api 重生成 TS 客户端(ChapterListItem) - 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构, 无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章 - 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章 --- apps/api/tests/test_chapters_list.py | 213 ++++++++++++++++++++ apps/api/ww_api/routers/projects.py | 26 +++ apps/api/ww_api/schemas/projects.py | 17 ++ apps/api/ww_api/services/chapter_list.py | 109 ++++++++++ apps/api/ww_api/services/project_deps.py | 8 + apps/web/app/projects/[id]/review/page.tsx | 21 +- apps/web/components/review/ReviewReport.tsx | 19 +- apps/web/lib/api/schema.d.ts | 90 +++++++++ apps/web/lib/api/server.ts | 13 ++ apps/web/lib/api/types.ts | 2 + apps/web/lib/review/chapterNav.test.ts | 95 ++++++++- apps/web/lib/review/chapterNav.ts | 57 ++++++ 12 files changed, 652 insertions(+), 18 deletions(-) create mode 100644 apps/api/tests/test_chapters_list.py create mode 100644 apps/api/ww_api/services/chapter_list.py diff --git a/apps/api/tests/test_chapters_list.py b/apps/api/tests/test_chapters_list.py new file mode 100644 index 0000000..5078c3f --- /dev/null +++ b/apps/api/tests/test_chapters_list.py @@ -0,0 +1,213 @@ +"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。 + +分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致: +- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/ + reviewed_at、去重、按章号升序、空正文草稿不计(这是端点真正的业务逻辑,脱库直测)。 +- **端点集成**:FastAPI client + 覆盖 `get_chapter_lister` 为 Fake(同 test_projects.py 风格), + 断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM)。 +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +import httpx +import pytest +from cryptography.fernet import Fernet +from fakes_projects import FakeProjectRepo +from ww_api.services.chapter_list import ( + ChapterRow, + ReviewRow, + build_chapter_list, +) +from ww_api.services.credentials import STUB_OWNER_ID +from ww_core.domain.project_repo import ProjectView +from ww_shared import ErrorCode + +# ---- 纯聚合逻辑(build_chapter_list)---- + + +def _dt(day: int) -> datetime: + return datetime(2026, 7, day, tzinfo=UTC) + + +def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None: + # 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。 + chapter_rows = [ + ChapterRow(chapter_no=1, status="draft", content="草稿正文"), + ChapterRow(chapter_no=2, status="draft", content="待验收"), + ChapterRow(chapter_no=2, status="accepted", content="终稿"), + ChapterRow(chapter_no=3, status="draft", content="被审的草稿"), + ] + review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))] + + items = build_chapter_list(chapter_rows, review_rows) + + by_no = {it.chapter_no: it for it in items} + assert by_no[1].has_draft is True + assert by_no[1].accepted is False + assert by_no[1].reviewed_at is None + assert by_no[2].accepted is True + assert by_no[2].has_draft is True + assert by_no[3].reviewed_at == _dt(5) + + +def test_build_chapter_list_empty_returns_empty() -> None: + assert build_chapter_list([], []) == [] + + +def test_build_chapter_list_sorted_ascending_and_deduped() -> None: + # 乱序 + 同章多版本行 → 去重 + 章号升序。 + chapter_rows = [ + ChapterRow(chapter_no=3, status="draft", content="c3"), + ChapterRow(chapter_no=1, status="draft", content="c1"), + ChapterRow(chapter_no=1, status="accepted", content="c1a"), + ChapterRow(chapter_no=2, status="accepted", content="c2"), + ] + items = build_chapter_list(chapter_rows, []) + assert [it.chapter_no for it in items] == [1, 2, 3] + + +def test_build_chapter_list_reviewed_at_is_latest() -> None: + # 同章多次审稿 → reviewed_at 取最近一次。 + chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")] + review_rows = [ + ReviewRow(chapter_no=1, created_at=_dt(3)), + ReviewRow(chapter_no=1, created_at=_dt(9)), + ReviewRow(chapter_no=1, created_at=_dt(6)), + ] + items = build_chapter_list(chapter_rows, review_rows) + assert items[0].reviewed_at == _dt(9) + + +def test_build_chapter_list_empty_content_draft_not_marked() -> None: + # 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。 + chapter_rows = [ + ChapterRow(chapter_no=1, status="draft", content=" "), + ChapterRow(chapter_no=1, status="draft", content=None), + ] + items = build_chapter_list(chapter_rows, []) + assert items[0].chapter_no == 1 + assert items[0].has_draft is False + + +def test_build_chapter_list_review_only_chapter_appears() -> None: + # 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。 + items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))]) + assert len(items) == 1 + assert items[0].chapter_no == 7 + assert items[0].has_draft is False + assert items[0].accepted is False + assert items[0].reviewed_at == _dt(2) + + +# ---- 端点集成(FastAPI client + Fake lister)---- + + +class _FakeChapterLister: + """实现 ChapterLister Protocol 的内存版:回放预置的列章结果。""" + + def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None: + self.items_by_project = items_by_project or {} + + async def list_chapters(self, project_id: uuid.UUID) -> list[object]: + return self.items_by_project.get(project_id, []) + + +def _make_client( + *, + project_repo: FakeProjectRepo, + lister: _FakeChapterLister, +) -> httpx.AsyncClient: + import os + + os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) + from ww_api.main import create_app + from ww_api.services.project_deps import get_chapter_lister, get_project_repo + + app = create_app() + app.dependency_overrides[get_project_repo] = lambda: project_repo + app.dependency_overrides[get_chapter_lister] = lambda: lister + transport = httpx.ASGITransport(app=app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID: + pid = uuid.uuid4() + project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试")) + return pid + + +@pytest.mark.asyncio +async def test_list_chapters_returns_items_with_fields() -> None: + from ww_api.schemas.projects import ChapterListItem + + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + lister = _FakeChapterLister( + { + pid: [ + ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None), + ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)), + ] + } + ) + client = _make_client(project_repo=project_repo, lister=lister) + async with client: + resp = await client.get(f"/projects/{pid}/chapters") + + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert body[0] == { + "chapter_no": 1, + "has_draft": True, + "accepted": False, + "reviewed_at": None, + } + assert body[1]["chapter_no"] == 2 + assert body[1]["accepted"] is True + assert body[1]["reviewed_at"].startswith("2026-07-04") + + +@pytest.mark.asyncio +async def test_list_chapters_empty_project_returns_empty_list() -> None: + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + client = _make_client(project_repo=project_repo, lister=_FakeChapterLister()) + async with client: + resp = await client.get(f"/projects/{pid}/chapters") + assert resp.status_code == 200 + assert resp.json() == [] + + +@pytest.mark.asyncio +async def test_list_chapters_unknown_project_404() -> None: + client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister()) + async with client: + resp = await client.get(f"/projects/{uuid.uuid4()}/chapters") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_list_chapters_ascending_order() -> None: + from ww_api.schemas.projects import ChapterListItem + + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + lister = _FakeChapterLister( + { + pid: [ + ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None), + ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None), + ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None), + ] + } + ) + client = _make_client(project_repo=project_repo, lister=lister) + async with client: + resp = await client.get(f"/projects/{pid}/chapters") + assert resp.status_code == 200 + assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5] diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index 3258833..02e3398 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -56,6 +56,7 @@ from ww_api.schemas.injection import ( from ww_api.schemas.projects import ( AcceptRequest, AcceptResponse, + ChapterListItem, DraftResponse, DraftSaveRequest, DraftStreamRequest, @@ -75,10 +76,12 @@ from ww_api.services.accept_service import ( assert_conflicts_resolved, run_accept_transaction, ) +from ww_api.services.chapter_list import ChapterLister from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.digest_extraction import extract_digest_facts from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan from ww_api.services.project_deps import ( + get_chapter_lister, get_chapter_repo, get_clarify_gateway, get_digest_append_repo, @@ -115,6 +118,7 @@ _SSE_RESPONSE: dict[int | str, dict[str, Any]] = { ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] +ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)] DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)] @@ -166,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes return _to_response(view) +@router.get("/{project_id}/chapters", responses=_NOT_FOUND) +async def list_chapters( + project_id: uuid.UUID, + project_repo: ProjectRepoDep, + lister: ChapterListerDep, +) -> list[ChapterListItem]: + """列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。 + + 来源 `chapters`(草稿/accepted)+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。 + 项目不存在 → 404(同其它端点,owner 走 project_repo stub 校验)。 + """ + if await project_repo.get(STUB_OWNER_ID, project_id) is None: + raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found") + items = await lister.list_chapters(project_id) + log.info( + "chapters_listed", + project_id=str(project_id), + chapter_count=len(items), + ) + return items + + async def _injection_response( repos: MemoryRepos, project_id: uuid.UUID, diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index b2f735d..89b2b10 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -189,6 +189,23 @@ class DraftView(BaseModel): length: int +# ---- 列章(审稿选章枚举)---- + + +class ChapterListItem(BaseModel): + """GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记(snake_case)。 + + 数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。 + `has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本; + `reviewed_at`=最近一次审稿时间(无则 null)。 + """ + + chapter_no: int + has_draft: bool = Field(description="是否有非空草稿正文(可审)") + accepted: bool = Field(description="是否已验收(存在 accepted 版本)") + reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null") + + # ---- 审稿(T2.5)---- diff --git a/apps/api/ww_api/services/chapter_list.py b/apps/api/ww_api/services/chapter_list.py new file mode 100644 index 0000000..babf1d1 --- /dev/null +++ b/apps/api/ww_api/services/chapter_list.py @@ -0,0 +1,109 @@ +"""列章只读服务:枚举项目「真实存在的章」+ 可审/已审标记(审稿选章下拉用)。 + +数据来源:`chapters`(草稿/accepted 版本)+ `chapter_reviews`。核心业务逻辑是把这两张表 +的行**聚合**为「按章号去重、升序」的列章视图,抽成纯函数 `build_chapter_list` 以便脱库直测 +(真 PG 行为交 tests/ E2E,符合本仓单测确定性纪律)。`SqlChapterLister` 只负责取行 + 调聚合。 + +不变量:只读、不写库、不触 LLM;按 project_id 隔离(owner 校验在端点走 project_repo,同其它端点)。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from ww_core.domain.chapter_repo import ACCEPTED_STATUS, DRAFT_STATUS +from ww_db.models import Chapter, ChapterReview + +from ww_api.schemas.projects import ChapterListItem + + +@dataclass(frozen=True) +class ChapterRow: + """`chapters` 行的最小读投影(聚合所需字段)。""" + + chapter_no: int + status: str + content: str | None + + +@dataclass(frozen=True) +class ReviewRow: + """`chapter_reviews` 行的最小读投影(章号 + 审稿时间)。""" + + chapter_no: int + created_at: datetime + + +def build_chapter_list( + chapter_rows: list[ChapterRow], review_rows: list[ReviewRow] +) -> list[ChapterListItem]: + """把 chapters/reviews 行聚合为按章号去重、升序的列章视图(纯函数,无副作用)。 + + - `has_draft`:该章存在 status='draft' 且正文非空白的行(与 GET .../draft「空草稿=无」一致)。 + - `accepted`:该章存在 status='accepted' 行。 + - `reviewed_at`:该章审稿行 created_at 的最大值(无审稿则 None)。 + - 章号来自两表并集(含仅审稿未存草稿的章),去重后升序。 + """ + has_draft: dict[int, bool] = {} + accepted: dict[int, bool] = {} + reviewed_at: dict[int, datetime] = {} + + for row in chapter_rows: + if row.status == DRAFT_STATUS and row.content is not None and row.content.strip(): + has_draft[row.chapter_no] = True + if row.status == ACCEPTED_STATUS: + accepted[row.chapter_no] = True + + for review in review_rows: + current = reviewed_at.get(review.chapter_no) + if current is None or review.created_at > current: + reviewed_at[review.chapter_no] = review.created_at + + chapter_nos = sorted({r.chapter_no for r in chapter_rows} | {r.chapter_no for r in review_rows}) + return [ + ChapterListItem( + chapter_no=no, + has_draft=has_draft.get(no, False), + accepted=accepted.get(no, False), + reviewed_at=reviewed_at.get(no), + ) + for no in chapter_nos + ] + + +class ChapterLister(Protocol): + """列章只读接口(按 project_id 隔离)——端点依赖此协议,测试可注入 fake。""" + + async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: ... + + +class SqlChapterLister: + """SQLAlchemy 实现:两条只读查询取行 → 交纯聚合。单项目章数有界,聚合放 Python 侧(KISS)。""" + + def __init__(self, session: AsyncSession) -> None: + self._s = session + + async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: + chapter_result = await self._s.execute( + select(Chapter.chapter_no, Chapter.status, Chapter.content).where( + Chapter.project_id == project_id + ) + ) + review_result = await self._s.execute( + select(ChapterReview.chapter_no, ChapterReview.created_at).where( + ChapterReview.project_id == project_id + ) + ) + chapter_rows = [ + ChapterRow(chapter_no=r.chapter_no, status=r.status, content=r.content) + for r in chapter_result.all() + ] + review_rows = [ + ReviewRow(chapter_no=r.chapter_no, created_at=r.created_at) for r in review_result.all() + ] + return build_chapter_list(chapter_rows, review_rows) diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index cea94a2..800ae8f 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -53,6 +53,7 @@ from ww_api.security.credentials import ( CredentialKeyError, decrypt_api_key, ) +from ww_api.services.chapter_list import ChapterLister, SqlChapterLister from ww_api.services.credentials import ( AUTH_TYPE_OAUTH, STUB_OWNER_ID, @@ -99,6 +100,13 @@ def get_chapter_repo( return SqlChapterRepo(session) +def get_chapter_lister( + session: Annotated[AsyncSession, Depends(get_session)], +) -> ChapterLister: + """列章只读服务(GET /projects/:id/chapters)。只读、不写库;测试注入 fake lister。""" + return SqlChapterLister(session) + + def get_memory_repos( session: Annotated[AsyncSession, Depends(get_session)], ) -> MemoryRepos: diff --git a/apps/web/app/projects/[id]/review/page.tsx b/apps/web/app/projects/[id]/review/page.tsx index 4903df8..c3e53f2 100644 --- a/apps/web/app/projects/[id]/review/page.tsx +++ b/apps/web/app/projects/[id]/review/page.tsx @@ -2,12 +2,17 @@ import { notFound } from "next/navigation"; import { ReviewReport } from "@/components/review/ReviewReport"; import { + fetchChapters, fetchDraft, fetchOutline, fetchProject, fetchReviews, } from "@/lib/api/server"; import type { ProjectResponse } from "@/lib/api/types"; +import { + buildReviewChapterOptions, + defaultReviewChapter, +} from "@/lib/review/chapterNav"; import { latestReview } from "@/lib/review/history"; import type { ChapterEntry } from "@/lib/workbench/chapter"; @@ -16,14 +21,11 @@ interface PageProps { searchParams: Promise<{ chapter?: string }>; } -const DEFAULT_CHAPTER_NO = 1; - // 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧); // ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。 export default async function ReviewPage({ params, searchParams }: PageProps) { const { id } = await params; const { chapter } = await searchParams; - const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO; // 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。 let project: ProjectResponse; @@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) { notFound(); } + // 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。 + const chapterList = await fetchChapters(id); + // 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。 + const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList); + // 审稿留痕(GET .../reviews,失败→空历史)。 let initialReview; try { @@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) { title: c.beats?.[0], })); + // 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。 + const chapterOptions = buildReviewChapterOptions( + chapterList, + chapters, + chapterNo, + ); + return ( // key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态, // 避免 ReviewReport 内 useState 基线沿用上一章。 @@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) { initialReview={initialReview} initialDraft={initialDraft} chapters={chapters} + chapterOptions={chapterOptions} /> ); } diff --git a/apps/web/components/review/ReviewReport.tsx b/apps/web/components/review/ReviewReport.tsx index 5d240a4..63ac7ae 100644 --- a/apps/web/components/review/ReviewReport.tsx +++ b/apps/web/components/review/ReviewReport.tsx @@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Select } from "@/components/ui/Select"; -import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav"; +import { + reviewChapterHref, + reviewChapterOptions, + type ReviewChapterOption, +} from "@/lib/review/chapterNav"; import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types"; import { friendlyError } from "@/lib/errors/messages"; @@ -74,8 +78,10 @@ interface ReviewReportProps { chapterNo: number; initialReview: ReviewHistoryItem | undefined; initialDraft: string; - // 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。 + // 章节导航目录(大纲章节):无 chapterOptions 时回退用。 chapters?: ChapterEntry[]; + // 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。 + chapterOptions?: ReviewChapterOption[]; } // 审稿报告页主体(UX §6.4 / §8.3 / §9)。 @@ -87,6 +93,7 @@ export function ReviewReport({ initialReview, initialDraft, chapters, + chapterOptions, }: ReviewReportProps) { const router = useRouter(); const review = useReviewStream(); @@ -460,7 +467,7 @@ export function ReviewReport({