feat(review): 审稿选章可用——列章端点 + 选章下拉全章可见 + 默认最近章
- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)----
|
||||
|
||||
|
||||
|
||||
109
apps/api/ww_api/services/chapter_list.py
Normal file
109
apps/api/ww_api/services/chapter_list.py
Normal file
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user