"""伏笔登记 / 状态变更端点(C3 扩 / ARCH §6.2, §7.2;不变量 #3)。 - POST /projects/:id/foreshadow 作者显式登记一条伏笔(status=OPEN)。 - PATCH /projects/:id/foreshadow/:code 状态机转移 和/或 追加进展。 登记/改状态是**作者显式动作**(不变量 #3:伏笔入库不经 AI 静默写库;验收后到期扫描 是确定性纯函数置位,见 `services/foreshadow_scan.py`)。 提交边界:`ForeshadowLedgerRepo` 写方法只 `flush()`,端点写后 `await session.commit()`。 **T3.5 续接点**:本 router 是 foreshadow 端点的归属处。T3.5 的看板查询 `GET /projects/:id/foreshadow?status=` 直接加到本 router(调 `repo.list_by_status`); 大纲 `POST /projects/:id/outline` 与伏笔无关,建议落独立 `routers/outline.py` (或 projects router),不混进本文件。 """ from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Request from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ww_core.domain import ForeshadowLedgerRepo, ForeshadowLedgerView from ww_core.domain.foreshadow_state import ForeshadowStatus, InvalidTransition from ww_core.domain.project_repo import ProjectRepo from ww_db import get_session from ww_shared import AppError, ErrorCode from ww_api.logging_config import get_logger from ww_api.schemas.foreshadow import ( ForeshadowBoardResponse, ForeshadowRegisterRequest, ForeshadowTransitionRequest, ForeshadowView, ) from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.project_deps import get_foreshadow_repo, get_project_repo log = get_logger("ww.api.foreshadow") router = APIRouter(prefix="/projects", tags=["foreshadow"]) ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] SessionDep = Annotated[AsyncSession, Depends(get_session)] def _to_view(v: ForeshadowLedgerView) -> ForeshadowView: # ForeshadowLedgerView 与 ForeshadowView 字段同名,逐字段映射(snake_case 契约)。 return ForeshadowView.model_validate(v, from_attributes=True) @router.post("/{project_id}/foreshadow", status_code=201) async def register_foreshadow( project_id: uuid.UUID, body: ForeshadowRegisterRequest, request: Request, repo: ForeshadowRepoDep, session: SessionDep, ) -> ForeshadowView: """登记一条伏笔(status=OPEN)。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。""" request_id = getattr(request.state, "request_id", None) try: view = await repo.register( project_id, code=body.code, title=body.title, planted_at=body.planted_at, content=body.content, expected_close_from=body.expected_close_from, expected_close_to=body.expected_close_to, importance=body.importance, ) await session.commit() except IntegrityError as exc: # `(project_id, code)` 唯一约束冲突(register 仅 INSERT、不 upsert,见 foreshadow_repo)。 await session.rollback() raise AppError( ErrorCode.VALIDATION, f"伏笔代号已存在:{body.code}", {"field": "code", "code": body.code, "reason": "duplicate"}, ) from exc log.info( "foreshadow_registered", project_id=str(project_id), request_id=request_id, code=body.code, ) return _to_view(view) @router.get("/{project_id}/foreshadow") async def list_foreshadow( project_id: uuid.UUID, repo: ForeshadowRepoDep, project_repo: ProjectRepoDep, status: str | None = None, ) -> ForeshadowBoardResponse: """伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。 项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。 `status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端 据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。 """ if await project_repo.get(STUB_OWNER_ID, project_id) is None: raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found") if status is not None and status not in {s.value for s in ForeshadowStatus}: raise AppError( ErrorCode.VALIDATION, f"非法状态过滤:{status}", {"field": "status", "reason": "invalid_status"}, ) views = await repo.list_by_status(project_id, status) return ForeshadowBoardResponse(foreshadow=[_to_view(v) for v in views]) @router.patch("/{project_id}/foreshadow/{code}") async def update_foreshadow( project_id: uuid.UUID, code: str, body: ForeshadowTransitionRequest, request: Request, repo: ForeshadowRepoDep, session: SessionDep, ) -> ForeshadowView: """状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。 `to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION(无操作)。先转移、后追加。 """ request_id = getattr(request.state, "request_id", None) if body.to_status is None and body.progress_entry is None: raise AppError( ErrorCode.VALIDATION, "至少提供 `to_status` 或 `progress_entry` 之一", {"reason": "empty_update"}, ) view: ForeshadowLedgerView try: if body.to_status is not None: view = await repo.transition(project_id, code, to_status=body.to_status) if body.progress_entry is not None: view = await repo.record_progress(project_id, code, entry=body.progress_entry) await session.commit() except InvalidTransition as exc: await session.rollback() raise AppError( ErrorCode.VALIDATION, str(exc), {"field": "to_status", "code": code, "reason": "invalid_transition"}, ) from exc except LookupError as exc: await session.rollback() raise AppError(ErrorCode.NOT_FOUND, f"foreshadow not found: {code}") from exc log.info( "foreshadow_updated", project_id=str(project_id), request_id=request_id, code=code, to_status=body.to_status, has_progress=body.progress_entry is not None, ) return _to_view(view)