fix(txn+security): 仓储改 flush + 启动校验/兜底 + job.error 脱敏 + SSE 异常硬化
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)。
This commit is contained in:
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -33,13 +33,14 @@ from ww_core.orchestrator import (
|
||||
SseEvent,
|
||||
build_review_context,
|
||||
build_review_graph,
|
||||
error_event,
|
||||
normalize_deltas,
|
||||
normalize_review,
|
||||
stream_chapter_draft,
|
||||
)
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.injection import (
|
||||
@@ -58,6 +59,7 @@ from ww_api.schemas.projects import (
|
||||
ProjectCreateRequest,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
ReviewConflictView,
|
||||
ReviewHistoryItem,
|
||||
ReviewHistoryResponse,
|
||||
ReviewRequest,
|
||||
@@ -87,6 +89,16 @@ log = get_logger("ww.api.projects")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
# OpenAPI 错误响应声明(让 TS 客户端拿到类型化错误形,§7.1)。
|
||||
_NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "资源不存在"}
|
||||
}
|
||||
_ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "资源不存在"},
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
@@ -127,7 +139,7 @@ async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
|
||||
return ProjectListResponse(projects=[_to_response(v) for v in views])
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
@router.get("/{project_id}", responses=_NOT_FOUND)
|
||||
async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectResponse:
|
||||
view = await repo.get(STUB_OWNER_ID, project_id)
|
||||
if view is None:
|
||||
@@ -164,7 +176,7 @@ async def _injection_response(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/injection")
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/injection", responses=_NOT_FOUND)
|
||||
async def get_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -191,7 +203,7 @@ async def get_injection(
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/injection")
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/injection", responses=_NOT_FOUND)
|
||||
async def save_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -276,7 +288,11 @@ async def stream_draft(
|
||||
# 网关在流末经 SqlAlchemyLedgerSink.record 把 usage_ledger 行 flush 进本请求 session;
|
||||
# sink 按设计不提交(写库事务由编排层控制,见不变量)。draft 端点无其他写副作用,
|
||||
# 故流耗尽后在此提交,确保「每次调用一条 usage_ledger」真正落库(T1.9 暴露)。
|
||||
await session.commit()
|
||||
# 尾部 commit 包 try/except:失败则记 sse_commit_failed(账本静默丢失须可查,P0-4)。
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 流已发完,commit 失败不能再改响应;至少记错误。
|
||||
log.error("sse_commit_failed", request_id=request_id, endpoint="draft")
|
||||
|
||||
return StreamingResponse(
|
||||
_frames(),
|
||||
@@ -291,9 +307,12 @@ async def save_draft(
|
||||
chapter_no: int,
|
||||
body: DraftSaveRequest,
|
||||
repo: ChapterRepoDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> DraftResponse:
|
||||
"""自动保存:幂等 upsert 草稿(同章节覆盖同一行,版次不爆炸)。"""
|
||||
view = await repo.save_draft(project_id, chapter_no, text=body.text)
|
||||
# repo.save_draft 只 flush,端点统一提交。
|
||||
await session.commit()
|
||||
log.info(
|
||||
"draft_saved",
|
||||
project_id=str(project_id),
|
||||
@@ -310,7 +329,7 @@ async def save_draft(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/draft")
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/draft", responses=_NOT_FOUND)
|
||||
async def get_draft(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -404,13 +423,36 @@ async def review_chapter(
|
||||
}
|
||||
|
||||
async def _frames() -> AsyncIterator[str]:
|
||||
final = await graph.ainvoke(initial)
|
||||
# graph.ainvoke 包 try/except:抛错则先发 error 事件再 return(否则流被截断、
|
||||
# 客户端收不到 error,P0-4)。AppError 用其 code/message;其余归一为 INTERNAL(不泄异常)。
|
||||
try:
|
||||
final = await graph.ainvoke(initial)
|
||||
except AppError as exc:
|
||||
log.warning("review_stream_error", code=str(exc.code), request_id=request_id)
|
||||
yield _encode_sse(
|
||||
error_event(code=str(exc.code), message=exc.message, request_id=request_id)
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 — 边界兜底:任何意外归一为 error 事件,不泄异常
|
||||
log.error("review_stream_unexpected_error", error=str(exc), request_id=request_id)
|
||||
yield _encode_sse(
|
||||
error_event(
|
||||
code=str(ErrorCode.INTERNAL),
|
||||
message="internal error during review",
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
reviews = final.get("reviews") or {}
|
||||
async for event in normalize_review(reviews, request_id=request_id):
|
||||
yield _encode_sse(event)
|
||||
# collect 经 review_repo.record 落 chapter_reviews(只 flush)+ 网关 ledger 只 flush
|
||||
# → 流耗尽后在此提交,确保审稿留痕 + usage_ledger 真正落库(同 draft 端点)。
|
||||
await session.commit()
|
||||
# 尾部 commit 包 try/except:失败则记 sse_commit_failed(P0-4)。
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 流已发完,commit 失败不能再改响应;至少记错误。
|
||||
log.error("sse_commit_failed", request_id=request_id, endpoint="review")
|
||||
|
||||
return StreamingResponse(
|
||||
_frames(),
|
||||
@@ -433,7 +475,7 @@ async def list_reviews(
|
||||
project_id=v.project_id,
|
||||
chapter_no=v.chapter_no,
|
||||
chapter_version=v.chapter_version,
|
||||
conflicts=v.conflicts,
|
||||
conflicts=[ReviewConflictView.model_validate(c) for c in v.conflicts],
|
||||
foreshadow_sug=v.foreshadow_sug,
|
||||
style=v.style,
|
||||
pace=v.pace,
|
||||
@@ -445,7 +487,7 @@ async def list_reviews(
|
||||
return ReviewHistoryResponse(reviews=items)
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/accept")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/accept", responses=_ACCEPT_ERRORS)
|
||||
async def accept_chapter(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
|
||||
Reference in New Issue
Block a user