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

@@ -16,17 +16,35 @@ def configure_logging() -> None:
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
]
renderer = (
renderer: Processor = (
structlog.processors.JSONRenderer()
if settings.log_json
else structlog.dev.ConsoleRenderer()
)
processors: list[Processor] = [*shared, renderer]
structlog.configure(
processors=processors,
processors=[
*shared,
# 交给 stdlibProcessorFormatter 在 root handler 上做最终渲染,统一结构化输出。
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
cache_logger_on_first_use=True,
)
# 接管 stdlib rootSQLAlchemy/httpx/uvicorn 等绕过 structlog 的日志也走同一 formatter
# → 统一 JSON/console 输出P2日志接管 stdlib root
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(logging.INFO)
def get_logger(name: str = "ww") -> structlog.stdlib.BoundLogger:

View File

@@ -11,10 +11,10 @@ from fastapi.responses import JSONResponse
from ww_config import get_settings
from ww_core.domain.job_repo import SqlJobRepo
from ww_db import get_sessionmaker
from ww_shared import AppError, ErrorBody, ErrorEnvelope
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import request_id_middleware
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
from ww_api.routers import (
foreshadow,
generation,
@@ -27,6 +27,7 @@ from ww_api.routers import (
settings_providers,
style,
)
from ww_api.security.credentials import _fernet
from ww_api.services.project_deps import seed_stub_user
configure_logging()
@@ -35,6 +36,9 @@ log = get_logger("ww.api")
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
# 加密 key 启动校验:缺失/非法则**快速失败**(不带病启动接流量)。`_fernet` 缺/非法抛
# CredentialKeyError此处让其冒泡使进程启动失败P0-2而非首次凭据读写才 500。
_fernet(get_settings().credential_enc_key.get_secret_value())
# 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas
async with get_sessionmaker()() as session:
await seed_stub_user(session)
@@ -53,12 +57,18 @@ def create_app() -> FastAPI:
# RSC 服务端取数同源、无需 CORS故此前同进程测试从未暴露此缺口。原型单用户、
# 本地开发:放行 localhost:3000可经 env `CORS_ORIGINS`(逗号分隔)覆盖。
settings = get_settings()
# `allow_credentials=True` 与通配 origin `"*"` 并存会被浏览器拒(且语义危险)——启动断言
# cors_origins 不含通配强制显式白名单P2 收窄)。
assert "*" not in settings.cors_origins, (
"cors_origins 不能含通配 '*'(与 allow_credentials=True 不兼容);请配置显式来源白名单"
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# 收窄方法/头白名单(避免 `*` 放行任意方法/头P2
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
)
app.middleware("http")(request_id_middleware)
@@ -76,6 +86,26 @@ def create_app() -> FastAPI:
)
return JSONResponse(status_code=exc.http_status, content=envelope.model_dump())
@app.exception_handler(Exception)
async def _unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
# 兜底:任何未被 AppError 捕获的异常 → 统一 INTERNAL 信封(不回显原始异常给客户端),
# 但服务端记完整上下文 + request_id 便于端到端排查P0-2
request_id = getattr(request.state, "request_id", None)
log.error(
"unhandled_exception",
request_id=request_id,
error_type=type(exc).__name__,
exc_info=exc,
)
envelope = ErrorEnvelope(
error=ErrorBody(
code=ErrorCode.INTERNAL,
message="服务器内部错误",
request_id=request_id,
)
)
return JSONResponse(status_code=500, content=envelope.model_dump())
app.include_router(health.router)
app.include_router(jobs.router)
app.include_router(projects.router)

View File

@@ -6,6 +6,7 @@
from __future__ import annotations
import re
import uuid
from collections.abc import Awaitable, Callable
@@ -15,11 +16,22 @@ from starlette.responses import Response
REQUEST_ID_HEADER = "x-request-id"
# 放行安全字符集(字母数字 . _ -1128 位):覆盖 uuid4().hex、常见 trace id 与客户端
# 自定义短 id含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
def _sanitize_request_id(raw: str | None) -> str:
if raw is not None and _REQUEST_ID_PATTERN.match(raw):
return raw
return uuid.uuid4().hex
async def request_id_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
request_id = _sanitize_request_id(request.headers.get(REQUEST_ID_HEADER))
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
request.state.request_id = request_id

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,
)

View File

@@ -0,0 +1,27 @@
"""长任务轮询响应 schemaC3 / ARCH §7.4)。
snake_case前端经 OpenAPI→TS 客户端消费——改字段须 `pnpm gen:api` 重生成。
此前 `GET /jobs/{id}` 返回裸 `dict`TS 端拿到弱类型;改为具体 Pydantic 模型提升类型。
"""
from __future__ import annotations
import uuid
from typing import Any
from pydantic import BaseModel
class JobResponse(BaseModel):
"""长任务状态(`GET /jobs/{id}`):状态 + 进度 + 结果/错误。
`result` 是任务成功后的非密摘要(如 `{"connected": true, ...}``error` 是失败后的
面向用户文案(已脱敏,绝不含 `str(exc)`,见 `services/job_runner._classify_job_error`)。
"""
id: uuid.UUID
kind: str
status: str
progress: int = 0
result: dict[str, Any] | None = None
error: str | None = None

View File

@@ -19,7 +19,7 @@ class ProjectCreateRequest(BaseModel):
logline: str | None = None
premise: str | None = None
theme: str | None = None
selling_points: list[Any] = Field(default_factory=list)
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
@@ -32,7 +32,7 @@ class ProjectResponse(BaseModel):
logline: str | None = None
premise: str | None = None
theme: str | None = None
selling_points: list[Any] = Field(default_factory=list)
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
@@ -97,6 +97,18 @@ class ReviewRequest(BaseModel):
draft: str | None = None
class ReviewConflictView(BaseModel):
"""单条一致性冲突chapter_reviews.conflicts JSONB 子项;形对齐 SSE `conflict` 事件)。
强类型化此前的裸 `dict[str, Any]`,给 TS 客户端真实字段类型。容忍历史/缺省(默认值)。
"""
type: str = ""
where: str = ""
refs: list[str] = Field(default_factory=list)
suggestion: str = ""
class ReviewHistoryItem(BaseModel):
"""单条审稿留痕GET .../reviews 历史项snake_case"""
@@ -104,7 +116,7 @@ class ReviewHistoryItem(BaseModel):
project_id: uuid.UUID
chapter_no: int
chapter_version: int | None = None
conflicts: list[dict[str, Any]] = Field(default_factory=list)
conflicts: list[ReviewConflictView] = Field(default_factory=list)
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
style: dict[str, Any] | None = None
pace: dict[str, Any] | None = None

View File

@@ -7,7 +7,7 @@ snake_case命名契约见 memory/gotchas。前端经 OpenAPI→TS 客
from __future__ import annotations
import uuid
from typing import Any, Literal
from typing import Literal
from pydantic import BaseModel, Field
@@ -25,11 +25,22 @@ class StyleLearnResponse(BaseModel):
job_id: uuid.UUID
class StyleFingerprintResponse(BaseModel):
"""最新文风指纹(`GET /style`):完整 16 维 + 证据 + 版本(对齐 UX §6.9)。"""
class DimensionEntry(BaseModel):
"""单个文风维度:名称 + 判定值 + 原文证据摘录(强类型,替代裸 dict[str, Any])。"""
dimensions: dict[str, Any] = Field(default_factory=dict)
evidence: dict[str, Any] = Field(default_factory=dict)
name: str
value: str
evidence: list[str] = Field(default_factory=list)
class StyleFingerprintResponse(BaseModel):
"""最新文风指纹(`GET /style`):完整 16 维(名称/值/证据)+ 版本(对齐 UX §6.9)。
DB 存两列并行 dict`{name:value}` + `{name:[evidence]}`);响应合并为
`list[DimensionEntry]`,给 TS 客户端强类型(替代弱类型 `dict[str, Any]`)。
"""
dimensions: list[DimensionEntry] = Field(default_factory=list)
version: int

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Protocol
import structlog
from ww_core.domain.chapter_repo import ChapterRepo
@@ -27,6 +28,15 @@ from ww_api.schemas.projects import ConflictDecision
log = structlog.get_logger(__name__)
class Committable(Protocol):
"""验收事务对 session 的**最小**依赖:仅需 `commit()`(去掉 object + type ignore
真实运行注 `AsyncSession`;测试注 fake session同形即可——两者都满足本 Protocol。
"""
async def commit(self) -> None: ...
@dataclass(frozen=True)
class AcceptOutcome:
"""验收事务结果(供端点组「本次将更新」清单)。"""
@@ -82,7 +92,7 @@ def _serialize_decisions(
async def run_accept_transaction(
*,
session: object,
session: Committable,
chapter_repo: ChapterRepo,
digest_repo: DigestAppendRepo,
review_repo: ReviewRepo,
@@ -97,7 +107,8 @@ async def run_accept_transaction(
`digest_facts` 已在事务外提炼好R2。各 repo 写方法只 flush本函数统一在末尾
`await session.commit()`,任一步抛错由调用方/上下文回滚(不显式半提交)。
`session` 类型用 object 以免绑定 SQLAlchemy测试注入 fake session 亦可)。
`session` 用最小 `Committable` Protocol只需 `commit()`)以免绑定 SQLAlchemy
测试注入 fake session 亦满足(去掉了 `object` + `type: ignore`)。
"""
# 步骤 1终稿晋升 accepted 新 versionmax+1草稿行保留R4
chapter = await chapter_repo.promote_to_accepted(project_id, chapter_no, content=final_text)
@@ -120,7 +131,7 @@ async def run_accept_transaction(
# 的副作用、且自建独立 session请求 session 此时已关闭)。
# 人物 latest_state 更新仍留后续M4+):本事务只落晋升 + digest + 裁决留痕。
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()fake 同形)
await session.commit()
log.info(
"chapter_accepted",

View File

@@ -49,7 +49,12 @@ class StoredRouting:
class CredentialStore(Protocol):
"""凭据 + 档位路由的读写接口(按 owner_id 隔离)。"""
"""凭据 + 档位路由的读写接口(按 owner_id 隔离)。
写方法upsert/delete**只 flush 不 commit**——提交交调用方(端点/服务)经
`commit()` 统一一次,保证「多凭据一请求」的原子性(任一步失败整体回滚,不留半更新)。
无 session 句柄的服务侧调用方(如 token 刷新落库)则直接调 `commit()`。
"""
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]: ...
@@ -71,6 +76,8 @@ class CredentialStore(Protocol):
async def upsert_routing(self, routing: StoredRouting) -> None: ...
async def commit(self) -> None: ...
class ProviderProbe(Protocol):
"""最小连通探测:验证 Key + 返回能力矩阵。测试注入假探测,绝不联网。"""
@@ -156,7 +163,8 @@ class SqlCredentialStore:
existing.api_key_enc = api_key_enc
existing.auth_type = AUTH_TYPE_API_KEY
existing.oauth_enc = None
await self._session.commit()
# 仓储只 flush提交交调用方端点/服务)统一一次——保证多凭据一请求的原子性。
await self._session.flush()
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
@@ -191,7 +199,8 @@ class SqlCredentialStore:
existing.api_key_enc = None
existing.auth_type = AUTH_TYPE_OAUTH
existing.oauth_enc = oauth_enc
await self._session.commit()
# 仓储只 flush提交交调用方统一一次。
await self._session.flush()
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
"""删除凭据行OAuth disconnect / 撤销)。返回是否删到行。"""
@@ -207,7 +216,8 @@ class SqlCredentialStore:
if existing is None:
return False
await self._session.delete(existing)
await self._session.commit()
# 仓储只 flush提交交调用方统一一次。
await self._session.flush()
return True
async def upsert_routing(self, routing: StoredRouting) -> None:
@@ -233,4 +243,9 @@ class SqlCredentialStore:
existing.provider = routing.provider
existing.model = routing.model
existing.fallback = routing.fallback
# 仓储只 flush提交交调用方统一一次。
await self._session.flush()
async def commit(self) -> None:
"""统一提交点:端点/服务侧在一组 flush 后调一次,落库所有挂起写入。"""
await self._session.commit()

View File

@@ -25,9 +25,13 @@ from typing import Any, Protocol
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.job_repo import JobView, SqlJobRepo
from ww_shared import AppError
from ww_api.services.foreshadow_scan import SessionFactory
# 非 AppError 异常落库的通用文案(绝不回传 str(exc)避免泄露内部细节P0-3
_GENERIC_JOB_ERROR = "任务执行失败"
log = structlog.get_logger(__name__)
# 业务工作缝:拿 session 跑真正的长任务,返回写回 job.result 的摘要 dict。
@@ -63,7 +67,8 @@ async def run_job(
"""跑一个长任务:新建独立 session → set_running → await work → complete/fail → commit。
成功:`complete(job_id, result)`status=done, progress=100, result=work 返回值)后 commit。
异常:回滚 work 的部分写 → 新 session 里 `fail(job_id, str(exc))` → commitjob 失败可见)。
异常分类落库P0-3`AppError` 存其 `code: message`(已是面向用户的安全文案);其余
`Exception` 存通用「任务执行失败」——**绝不**把 `str(exc)` 落库/回传前端(防泄露内部细节)。
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
`session_factory`/`repo_factory` 是可注入缝:测试直接 await、注 fake绝不联网/起线程。
"""
@@ -76,8 +81,21 @@ async def run_job(
await session.commit()
log.info("job_done", job_id=str(job_id), request_id=request_id)
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed不冒泡。
# 服务端日志记完整错误(含原始异常);落库的 job.error 经分类脱敏。
log.error("job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
await _mark_failed(session_factory, job_id, str(exc), repo_factory, request_id)
stored_error = _classify_job_error(exc)
await _mark_failed(session_factory, job_id, stored_error, repo_factory, request_id)
def _classify_job_error(exc: Exception) -> str:
"""把异常映射为可安全落库/回传前端的错误文案P0-3
`AppError` 的 `message` 是设计为面向用户的安全文案,连同 `code` 一起呈现;其余异常一律
用通用文案,**绝不**回传 `str(exc)`(可能含解密失败提示/内部路径等敏感信息)。
"""
if isinstance(exc, AppError):
return f"{exc.code}: {exc.message}"
return _GENERIC_JOB_ERROR
async def _mark_failed(

View File

@@ -264,7 +264,9 @@ async def _build_provider_adapter(store: CredentialStore, provider: str) -> Prov
if cred.auth_type == AUTH_TYPE_OAUTH or provider == KIMI_CODE_PROVIDER:
# OAuth 凭据Kimi Code解密 token 包 → 临近过期则刷新并持久化 → access token
# 当 api_key 喂工厂(工厂为 kimi-code 构建带伪造头 + coding base 的客户端)。
access_token = await _resolve_kimi_code_token(store, cred, settings.credential_enc_key)
access_token = await _resolve_kimi_code_token(
store, cred, settings.credential_enc_key.get_secret_value()
)
return build_adapter(
provider, api_key=access_token, base_url=_PROVIDER_BASE_URLS.get(provider)
)
@@ -273,7 +275,9 @@ async def _build_provider_adapter(store: CredentialStore, provider: str) -> Prov
# api_key 凭据但密文缺失(数据不一致)——视作未配置,回退链跳过。
return None
try:
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
api_key = decrypt_api_key(
cred.api_key_enc, key=settings.credential_enc_key.get_secret_value()
)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
# OpenAI 兼容 provider 需 base_urlAnthropic/Gemini 走原生 SDKbase_url=None
@@ -305,10 +309,12 @@ async def _resolve_kimi_code_token(
return token.access_token
# 临近过期 → 刷新并持久化新包。
async with httpx.AsyncClient() as http:
async with httpx.AsyncClient(timeout=30.0) as http:
refreshed = await kimi_refresh(http, token.refresh_token)
new_blob = encrypt_oauth_bundle(refreshed, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, new_blob)
# token 刷新是独立可持久的副作用(下次建网关复用),须立即提交,不依赖请求后续是否提交。
await store.commit()
return refreshed.access_token

View File

@@ -10,12 +10,11 @@ import uuid
from typing import Annotated
from fastapi import Depends
from openai import AsyncOpenAI
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_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_llm_gateway.factory import build_adapter
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import (
@@ -84,11 +83,12 @@ class GatewayProviderProbe:
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=provider, client=client)
# 经网关工厂构造适配器base_url 单点归网关 build_adapter不在此本地构造 AsyncOpenAI
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
await client.models.list()
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS读私有客户端属同仓既有约定。
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
@@ -101,4 +101,4 @@ class GatewayProviderProbe:
def get_provider_probe(
store: Annotated[CredentialStore, Depends(get_credential_store)],
) -> GatewayProviderProbe:
return GatewayProviderProbe(store, get_settings().credential_enc_key)
return GatewayProviderProbe(store, get_settings().credential_enc_key.get_secret_value())