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

@@ -24,6 +24,7 @@ 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
@@ -34,13 +35,15 @@ from ww_api.schemas.foreshadow import (
ForeshadowTransitionRequest,
ForeshadowView,
)
from ww_api.services.project_deps import get_foreshadow_repo
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)]
@@ -92,13 +95,17 @@ async def register_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,

View File

@@ -336,11 +336,15 @@ async def ingest_characters(
async def list_characters(
project_id: uuid.UUID,
memory: MemoryReposDep,
project_repo: ProjectRepoDep,
) -> CharacterListResponse:
"""已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo
项目不存在 → 404与写端点一致避免不存在 project 返回误导性空 200
DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
cards = await _existing_characters(memory, project_id)
return CharacterListResponse(characters=[_card_to_view(c) for c in cards])
@@ -349,11 +353,15 @@ async def list_characters(
async def list_world_entities(
project_id: uuid.UUID,
memory: MemoryReposDep,
project_repo: ProjectRepoDep,
) -> WorldEntityListResponse:
"""已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo
项目不存在 → 404与写端点一致
DB `rules` JSONB dict `{"rules":[...]}` → 裸 listworldbuilder 形变的逆向)。
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
views = await memory.world_entity.list_for_project(project_id)
entities = [
WorldEntityCardView(

View File

@@ -10,24 +10,29 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db import get_session
from ww_db.models import Job
from ww_shared import AppError, ErrorCode
from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.schemas.jobs import JobResponse
router = APIRouter(prefix="/jobs", tags=["jobs"])
@router.get("/{job_id}")
@router.get(
"/{job_id}",
responses={404: {"model": ErrorEnvelope, "description": "job 不存在"}},
)
async def get_job(
job_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> dict[str, object]:
) -> JobResponse:
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
if job is None:
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
return {
"id": str(job.id),
"kind": job.kind,
"status": job.status,
"progress": job.progress,
"result": job.result,
"error": job.error,
}
return JobResponse(
id=job.id,
kind=job.kind,
status=job.status,
progress=job.progress,
result=job.result,
error=job.error,
)

View File

@@ -83,7 +83,7 @@ def _make_poll_work(device: DeviceAuth) -> Any:
"""
async def work(session: AsyncSession) -> dict[str, Any]:
enc_key = get_settings().credential_enc_key
enc_key = get_settings().credential_enc_key.get_secret_value()
store = SqlCredentialStore(session)
interval = max(1, device.interval)
@@ -147,11 +147,11 @@ async def start_oauth(
request_id=request_id,
)
# 不记 user_code授权窗口内日志可见者可冒用授权意图P1-10。仅留 request_id/job_id 关联。
log.info(
"kimi_oauth_started",
request_id=request_id,
job_id=str(job.id),
user_code=device.user_code,
)
response.status_code = 202
return OAuthStartResponse(
@@ -168,10 +168,13 @@ async def start_oauth(
async def disconnect_oauth(
request: Request,
store: CredentialStoreDep,
session: SessionDep,
) -> OAuthDisconnectResponse:
"""断开 Kimi Code删除 OAuth 凭据行token 一并消失)。"""
request_id = getattr(request.state, "request_id", None)
deleted = await store.delete_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
# store.delete_credential 只 flush端点统一提交。
await session.commit()
log.info("kimi_oauth_disconnected", request_id=request_id, deleted=deleted)
return OAuthDisconnectResponse(disconnected=deleted)
@@ -179,7 +182,7 @@ async def disconnect_oauth(
@router.get("/status")
async def oauth_status(store: CredentialStoreDep) -> OAuthStatusResponse:
"""连接状态:是否已连接 + access token 过期时刻(**无 token 本体**)。"""
enc_key = get_settings().credential_enc_key
enc_key = get_settings().credential_enc_key.get_secret_value()
cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
if cred is None or cred.auth_type != AUTH_TYPE_OAUTH or cred.oauth_enc is None:
return OAuthStatusResponse(connected=False)

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,

View File

@@ -12,7 +12,9 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_db import get_session
from ww_llm_gateway.adapters.base import Capabilities
from ww_api.logging_config import get_logger
@@ -44,6 +46,7 @@ router = APIRouter(prefix="/settings/providers", tags=["settings"])
StoreDep = Annotated[CredentialStore, Depends(get_credential_store)]
ProbeDep = Annotated[ProviderProbe, Depends(get_provider_probe)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _mask_credential(cred: StoredCredential, plaintext: str | None) -> ProviderView:
@@ -76,8 +79,9 @@ async def list_providers(store: StoreDep) -> ProvidersResponse:
async def upsert_providers(
body: ProvidersUpsertRequest,
store: StoreDep,
session: SessionDep,
) -> ProvidersResponse:
enc_key = get_settings().credential_enc_key
enc_key = get_settings().credential_enc_key.get_secret_value()
for cred in body.credentials:
api_key_enc = encrypt_api_key(cred.api_key, key=enc_key)
await store.upsert_credential(STUB_OWNER_ID, cred.provider, api_key_enc)
@@ -102,6 +106,8 @@ async def upsert_providers(
provider=routing.provider,
model=routing.model,
)
# 所有凭据/路由写入只 flush此处统一一次提交——任一步失败则整体回滚原子性
await session.commit()
return await _build_response(store)

View File

@@ -29,10 +29,11 @@ from ww_core.orchestrator import run_style_extraction
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
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.style import (
DimensionEntry,
RefineRequest,
RefineResponse,
StyleFingerprintResponse,
@@ -151,7 +152,22 @@ async def learn_style(
return StyleLearnResponse(job_id=job.id)
@router.get("/{project_id}/style")
def _merge_dimensions(dimensions: dict[str, Any], evidence: dict[str, Any]) -> list[DimensionEntry]:
"""把 DB 两列并行 dict 合并为 `list[DimensionEntry]`(按维度名稳定排序,便于前端展示)。"""
return [
DimensionEntry(
name=name,
value=str(dimensions[name]),
evidence=[str(e) for e in (evidence.get(name) or [])],
)
for name in sorted(dimensions)
]
@router.get(
"/{project_id}/style",
responses={404: {"model": ErrorEnvelope, "description": "项目或指纹不存在"}},
)
async def get_style(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
@@ -166,8 +182,7 @@ async def get_style(
if latest is None:
raise AppError(ErrorCode.NOT_FOUND, f"no style fingerprint for project: {project_id}")
return StyleFingerprintResponse(
dimensions=latest.dimensions,
evidence=latest.evidence,
dimensions=_merge_dimensions(latest.dimensions, latest.evidence),
version=latest.version,
)