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:
Yaojia Wang
2026-06-21 19:32:24 +02:00
parent 2282d4fd24
commit 345cc73965
37 changed files with 737 additions and 115 deletions

View File

@@ -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否则流被截断、
# 客户端收不到 errorP0-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_failedP0-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,