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:
@@ -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,
|
||||
|
||||
@@ -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":[...]}` → 裸 list(worldbuilder 形变的逆向)。
|
||||
"""
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user