P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交); 补多凭据一请求中途失败整体回滚集成测试。 P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope; credential_enc_key 改 SecretStr。 P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。 P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。 P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。 P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。 P1-10 移除 OAuth user_code 日志。 P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol; CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验; httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/ ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
167 lines
6.4 KiB
Python
167 lines
6.4 KiB
Python
"""伏笔登记 / 状态变更端点(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)
|