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

@@ -69,6 +69,10 @@ class FakeCredentialStore:
async def upsert_routing(self, routing: StoredRouting) -> None:
self.routing[routing.tier] = routing
async def commit(self) -> None:
# 内存替身无事务commit 为 no-op写入在 upsert 时即生效)。
return None
class FakeProviderProbe:
"""实现 `ProviderProbe` Protocol返回固定能力矩阵绝不联网。"""

View File

@@ -14,6 +14,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeForeshadowRepo,
FakeSession,
@@ -29,7 +30,7 @@ def _make_client(
) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
from ww_db import get_session

View File

@@ -16,6 +16,7 @@ from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos
from ww_agents import (
@@ -161,7 +162,7 @@ def _make_app(
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_character_gen_gateway,

View File

@@ -10,6 +10,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.injection_repo import InjectionOverride
@@ -124,7 +125,7 @@ def _make_client(
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from fakes_projects import FakeSession
from ww_api.main import create_app
from ww_api.services.project_deps import (

View File

@@ -134,7 +134,7 @@ async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise RuntimeError("extraction blew up")
raise RuntimeError("extraction blew up: secret=/internal/path")
# 异常被吞(后台任务边界),不冒泡
await run_job(
@@ -147,7 +147,25 @@ async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
assert "complete" not in repo.calls
assert "fail" in repo.calls
assert repo.status == STATUS_FAILED
assert repo.error == "extraction blew up"
# P0-3非 AppError 一律落通用文案,**绝不**回传 str(exc)(防泄露内部细节)。
assert repo.error == "任务执行失败"
assert "secret" not in (repo.error or "")
# 失败置态在一个**全新** session 里完成并 commit原事务作废
assert len(factory.sessions) == 2
assert factory.sessions[1].commits == 1
async def test_run_job_apperror_stores_code_and_safe_message() -> None:
"""P0-3AppError 的 message 是面向用户的安全文案,连同 code 一起落库。"""
from ww_shared import AppError, ErrorCode
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "Kimi 设备授权轮询超时")
await run_job(factory, JOB_ID, work, repo_factory=lambda _s: repo)
assert repo.status == STATUS_FAILED
assert repo.error == "LLM_UNAVAILABLE: Kimi 设备授权轮询超时"

View File

@@ -0,0 +1,54 @@
"""P2request_id 头校验——安全字符集白名单(字母数字 . _ -1128 位),非法则生成新 uuid。
放行 uuid.hex / 常见 trace id / 客户端自定义短 id丢弃含空白/控制字符/换行/注入序列/
超长(>128的值防止把未经校验的客户端值原样写进日志/响应头(日志注入防护)。
"""
from __future__ import annotations
import re
from ww_api.middleware import _sanitize_request_id
_HEX_32 = re.compile(r"^[0-9a-f]{32}$")
def test_valid_hex_passthrough() -> None:
raw = "a" * 32
assert _sanitize_request_id(raw) == raw
def test_valid_uppercase_hex_passthrough() -> None:
raw = "ABCDEF0123456789"
assert _sanitize_request_id(raw) == raw
def test_none_generates_new_uuid() -> None:
out = _sanitize_request_id(None)
assert _HEX_32.match(out)
def test_short_alnum_passthrough() -> None:
# 客户端自定义短 id如 'abc123')属合法字符集 → 原样透传(与 /health 传播契约一致)。
raw = "abc123"
assert _sanitize_request_id(raw) == raw
def test_too_long_rejected() -> None:
out = _sanitize_request_id("a" * 129) # 129 位 > 128 上限 → 丢弃改生成。
assert _HEX_32.match(out)
def test_whitespace_rejected() -> None:
# 含空格(日志注入风险,非白名单字符)→ 丢弃改生成。
out = _sanitize_request_id("abc 123")
assert out != "abc 123"
assert _HEX_32.match(out)
def test_injection_rejected() -> None:
# 含非十六进制字符(注入/控制字符)→ 丢弃改生成。
out = _sanitize_request_id("../../etc/passwd\n")
assert "/" not in out
assert "\n" not in out
assert _HEX_32.match(out)

View File

@@ -14,6 +14,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeForeshadowRepo,
FakeOutlineReadRepo,
@@ -51,7 +52,7 @@ def _make_client(
gateway: FakeReviewGateway | None = None,
session: FakeSession | None = None,
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
@@ -142,7 +143,7 @@ async def test_generate_outline_without_credentials_maps_to_llm_unavailable() ->
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
@@ -196,7 +197,7 @@ def _make_read_client(
outline_read_repo: FakeOutlineReadRepo,
) -> httpx.AsyncClient:
"""装配 GET /outline 测试客户端(注入 fake 项目 repo + 读侧 outline repo"""
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_outline_read_repo, get_project_repo
@@ -271,16 +272,19 @@ async def test_get_outline_unknown_project_404() -> None:
@pytest.mark.asyncio
async def test_foreshadow_board_filters_by_status() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
await repo.register(pid, code="F1", title="开放伏笔")
await repo.register(pid, code="F2", title="部分回收")
await repo.transition(pid, "F2", to_status="PARTIAL")
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
from ww_api.services.project_deps import get_foreshadow_repo, get_project_repo
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
# 读端点现做 project 存在性 404 校验P2——注入有该 project 的 FakeProjectRepo。
app.dependency_overrides[get_project_repo] = lambda: project_repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
all_resp = await client.get(f"/projects/{pid}/foreshadow")

View File

@@ -9,6 +9,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from ww_core.domain.repositories import (
CharacterView,
@@ -95,7 +96,7 @@ def _make_client(
) -> tuple[httpx.AsyncClient, FakeProjectRepo, FakeChapterRepo, FakeWriterGateway]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,

View File

@@ -11,6 +11,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeChapterRepo,
FakeDigestAppendRepo,
@@ -42,7 +43,7 @@ def _make_client(
]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,

View File

@@ -12,6 +12,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_core.domain.rule_repo import RuleWriteView
@@ -29,7 +30,7 @@ class _FakeRuleWriteRepo:
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_rule_write_repo
from ww_db import get_session

View File

@@ -0,0 +1,138 @@
"""P0-1 原子性:`PUT /settings/providers` 多凭据一请求,中途失败整体回滚。
不变量:仓储写方法只 `flush()`,端点末尾**统一一次** `commit()`。若某条 upsert 中途
抛错,端点绝不到达 `commit()` → 请求 session 回滚DB 不留半更新(无部分提交)。
用可注入 fakestore 在第 N 次 upsert 抛错fake session 记录 `commit()` 调用次数。
断言:① 请求返回 500兜底信封② `session.commit()` **从未被调用**(无半提交)。
"""
from __future__ import annotations
import os
import uuid
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fastapi import FastAPI
from ww_api.services.credentials import StoredRouting
class _ExplodingStore:
"""凭据 store fake前 N 次 upsert 成功,第 fail_at 次1-based抛错。
模拟「多凭据一请求」里第 K 条写入失败——验证端点不会半提交。
"""
def __init__(self, *, fail_at: int) -> None:
self._fail_at = fail_at
self._upsert_calls = 0
self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
async def list_credentials(self, owner_id: uuid.UUID) -> list[Any]:
return []
async def list_routing(self) -> list[Any]:
return []
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> Any:
return None
async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None:
self._upsert_calls += 1
if self._upsert_calls == self._fail_at:
raise RuntimeError("simulated mid-request write failure")
self.creds[(owner_id, provider)] = api_key_enc
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None: # pragma: no cover - 不用于本测试
raise NotImplementedError
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
return False
async def upsert_routing(self, routing: StoredRouting) -> None:
self.creds[(uuid.UUID(int=0), routing.tier)] = b""
class _RecordingSession:
"""fake AsyncSession只记 commit/rollback 调用次数(端点统一提交的探针)。"""
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def _build_app(store: _ExplodingStore, session: _RecordingSession) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.main import create_app
from ww_api.services.provider_deps import get_credential_store
from ww_db import get_session
app = create_app()
app.dependency_overrides[get_credential_store] = lambda: store
app.dependency_overrides[get_session] = lambda: session
return app
@pytest.mark.asyncio
async def test_upsert_providers_rolls_back_on_mid_request_failure() -> None:
# Arrange第 2 条凭据写入时抛错(前一条已 flush 到 session但尚未 commit
store = _ExplodingStore(fail_at=2)
session = _RecordingSession()
app = _build_app(store, session)
payload = {
"credentials": [
{"provider": "deepseek", "api_key": "sk-a"},
{"provider": "kimi", "api_key": "sk-b"},
{"provider": "qwen", "api_key": "sk-c"},
]
}
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put("/settings/providers", json=payload)
# Assert兜底信封catch-all → 500 INTERNAL不回显原始异常且**从未提交**(无半提交)。
assert resp.status_code == 500
assert resp.json()["error"]["code"] == "INTERNAL"
assert "simulated mid-request write failure" not in resp.text
assert session.commits == 0
@pytest.mark.asyncio
async def test_upsert_providers_commits_once_on_success() -> None:
# Arrange全部成功fail_at 远大于条数)。
store = _ExplodingStore(fail_at=99)
session = _RecordingSession()
app = _build_app(store, session)
payload = {
"credentials": [
{"provider": "deepseek", "api_key": "sk-a"},
{"provider": "kimi", "api_key": "sk-b"},
]
}
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put("/settings/providers", json=payload)
# Assert成功路径**恰好一次** commit所有写入统一提交
assert resp.status_code == 200
assert session.commits == 1

View File

@@ -20,6 +20,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeJobRepo,
FakeProjectRepo,
@@ -59,7 +60,7 @@ def _app_with_overrides(
extract_gateway: object | None = None,
refine_gateway: object | None = None,
) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_job_repo,
@@ -298,8 +299,9 @@ async def test_get_style_returns_latest_fingerprint() -> None:
assert resp.status_code == 200
body = resp.json()
assert body["version"] == 1
assert body["dimensions"] == {"句长": ""}
assert body["evidence"] == {"句长": ["他来了。"]}
# P2 codegendimensions 改为 list[DimensionEntry]name/value/evidence 合并),
# 替代此前两列并行裸 dict强类型给 TS 客户端)。
assert body["dimensions"] == [{"name": "句长", "value": "", "evidence": ["他来了。"]}]
@pytest.mark.asyncio

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 暴露)。
# 尾部 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]:
# 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 端点)。
# 尾部 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())

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from functools import lru_cache
from pydantic import field_validator
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -15,7 +15,9 @@ class Settings(BaseSettings):
log_json: bool = False
database_url: str = "postgresql+asyncpg://writer:writer@localhost:5432/writer"
database_url_sync: str = "postgresql+psycopg://writer:writer@localhost:5432/writer"
credential_enc_key: str = ""
# 提供商凭据对称加密 keyFernet。用 SecretStr 防误序列化/误入日志;
# 取明文须显式 `.get_secret_value()`。启动校验见 `main._lifespan`。
credential_enc_key: SecretStr = SecretStr("")
# 浏览器端跨源访问(前端 :3000 → API :8000放行的来源env `CORS_ORIGINS` 逗号分隔覆盖。
cors_origins: list[str] = [

View File

@@ -0,0 +1,56 @@
"""P1-4`SqlChapterRepo.max_version` 用行锁消除 read-then-insert 的 TOCTOU 竞态。
并发验收同一章时,两请求若都读到同一 max(version) 再各自 +1 插入,会撞
`(project_id, chapter_no, version)` 唯一约束抛 500。修法`max_version` 的 SELECT 加
`with_for_update()` 锁住该章现有行——后到者阻塞到前者提交后才读到更新后的 max。
真正的双事务阻塞行为需真实 PG由 tests/ 下 E2E 覆盖);此处以**不连库**的方式断言
编译出的 SQL 确实带 `FOR UPDATE`(行锁已正确接线),保持单测确定性、不联网。
"""
from __future__ import annotations
import uuid
from typing import Any
import pytest
from sqlalchemy.dialects import postgresql
from ww_core.domain.chapter_repo import SqlChapterRepo
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
class _CapturingResult:
def scalars(self) -> _CapturingResult:
return self
def __iter__(self) -> Any:
return iter([])
class _CapturingSession:
"""fake session捕获 execute 的 statement返回空结果不连库"""
def __init__(self) -> None:
self.statements: list[Any] = []
async def execute(self, statement: Any) -> _CapturingResult:
self.statements.append(statement)
return _CapturingResult()
@pytest.mark.asyncio
async def test_max_version_query_uses_for_update_row_lock() -> None:
session = _CapturingSession()
repo = SqlChapterRepo(session) # type: ignore[arg-type] # fake 同形(仅用 execute
result = await repo.max_version(PROJECT, 1)
# 无现有行 → 0read-then-insert 的基线)。
assert result == 0
# 断言编译出的 SQL 带 FOR UPDATE行锁即 with_for_update() 已正确接线P1-4
assert len(session.statements) == 1
compiled = str(
session.statements[0].compile(dialect=postgresql.dialect()) # type: ignore[no-untyped-call]
).upper()
assert "FOR UPDATE" in compiled

View File

@@ -0,0 +1,67 @@
"""P1-5`SqlForeshadowLedgerRepo.scan_overdue` 谓词下推 + 批量 UPDATE ... RETURNING。
旧实现拉全项目伏笔行到内存再 Python 过滤/逐条 UPDATE新实现把判据下推为单条
`UPDATE ... WHERE ... RETURNING`(命中行一次置 OVERDUE。判据须等价于
`foreshadow_state.is_overdue``expected_close_to IS NOT NULL AND expected_close_to <
current AND status NOT IN (CLOSED, OVERDUE)`。
真实批量更新行为由 tests/ 下 E2E真 PG覆盖此处以不连库方式断言编译出的 SQL
确实是带正确谓词 + RETURNING 的 UPDATE谓词下推已正确接线保持单测确定性。
"""
from __future__ import annotations
import uuid
from typing import Any
import pytest
from sqlalchemy.dialects import postgresql
from ww_core.domain.foreshadow_repo import SqlForeshadowLedgerRepo
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
class _CapturingResult:
def scalars(self) -> _CapturingResult:
return self
def all(self) -> list[Any]:
return []
class _CapturingSession:
"""fake session捕获 execute 的 statement返回空 RETURNING 结果(不连库)。"""
def __init__(self) -> None:
self.statements: list[Any] = []
self.expired = False
async def execute(self, statement: Any) -> _CapturingResult:
self.statements.append(statement)
return _CapturingResult()
def expire_all(self) -> None:
self.expired = True
@pytest.mark.asyncio
async def test_scan_overdue_pushes_predicate_into_update_returning() -> None:
session = _CapturingSession()
repo = SqlForeshadowLedgerRepo(session) # type: ignore[arg-type] # fake 同形
changed = await repo.scan_overdue(PROJECT, current_chapter=15)
# 空结果 → 无命中行;且发了一条语句、并 expire_all 同步 ORM 身份。
assert changed == []
assert session.expired is True
assert len(session.statements) == 1
compiled = str(
session.statements[0].compile(dialect=postgresql.dialect()) # type: ignore[no-untyped-call]
).upper()
# 谓词下推 + 批量更新 + RETURNING不再全表扫到内存逐行
assert "UPDATE" in compiled
assert "RETURNING" in compiled
assert "EXPECTED_CLOSE_TO" in compiled
# CLOSED / OVERDUE 被排除NOT IN——命中即真正状态翻转的行。
assert "NOT IN" in compiled

View File

@@ -13,7 +13,7 @@ import uuid
from dataclasses import dataclass
from typing import Protocol
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Chapter
@@ -48,7 +48,11 @@ class ChapterView:
class ChapterRepo(Protocol):
"""章节草稿读写 + 验收晋升接口(按 project_id 隔离)。"""
"""章节草稿读写 + 验收晋升接口(按 project_id 隔离)。
写方法save_draft/promote_to_accepted**只 flush 不 commit**——提交交调用方
(端点/验收事务)统一一次,保证原子性与全仓「仓储只 flush」契约一致。
"""
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
@@ -116,6 +120,7 @@ class SqlChapterRepo:
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
) -> ChapterDraftView:
# 唯一约束 (project_id, chapter_no, version);草稿版次固定 → 覆盖同一行,幂等。
# **只 flush 不 commit**:提交交调用方(端点)统一一次,与全仓「仓储只 flush」契约一致。
existing = await self._find_draft(project_id, chapter_no)
if existing is None:
row = Chapter(
@@ -127,12 +132,12 @@ class SqlChapterRepo:
version=DRAFT_VERSION,
)
self._s.add(row)
await self._s.commit()
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
existing.content = text
existing.status = DRAFT_STATUS
await self._s.commit()
await self._s.flush()
await self._s.refresh(existing)
return _to_view(existing)
@@ -143,13 +148,21 @@ class SqlChapterRepo:
return _to_view(row)
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
result = await self._s.execute(
select(func.max(Chapter.version)).where(
# 消除 read-then-insert 的 TOCTOU 竞态:`SELECT version ... FOR UPDATE` 锁住该章
# 现有行PG 不允许聚合函数与 FOR UPDATE 并用,故选具体行加锁再 Python 取 max
# 并发验收同一章时,后到者阻塞到前者提交后才读到更新后的 max避免撞唯一约束抛 500
rows = (
await self._s.execute(
select(Chapter.version)
.where(
Chapter.project_id == project_id,
Chapter.chapter_no == chapter_no,
)
.with_for_update()
)
return result.scalar_one_or_none() or 0
).scalars()
versions = list(rows)
return max(versions) if versions else 0
async def promote_to_accepted(
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1

View File

@@ -20,13 +20,14 @@ import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Foreshadow
from ww_core.domain.foreshadow_state import (
CLOSED,
OVERDUE,
ForeshadowStatus,
apply_overdue_scan,
)
from ww_core.domain.foreshadow_state import (
transition as apply_transition,
@@ -201,19 +202,24 @@ class SqlForeshadowLedgerRepo:
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
rows = (
await self._s.execute(select(Foreshadow).where(Foreshadow.project_id == project_id))
).scalars()
changed: list[ForeshadowLedgerView] = []
for row in rows:
new = apply_overdue_scan(
ForeshadowStatus(row.status),
current_chapter=current_chapter,
expected_close_to=row.expected_close_to,
).value
if new != row.status:
row.status = new
changed.append(_to_view(row))
if changed:
await self._s.flush()
return changed
# 谓词下推 + 批量 UPDATE ... RETURNING不再拉全表到内存逐行过滤/更新P1-5
# 判据与 `foreshadow_state.is_overdue` 一致expected_close_to 非空且
# current_chapter > expected_close_to 且当前状态既非 CLOSED终态也非 OVERDUE
# (已逾期则无变更)——后两者排除使本批量更新等价于「只改真正发生状态翻转的行」。
stmt = (
update(Foreshadow)
.where(
Foreshadow.project_id == project_id,
Foreshadow.expected_close_to.is_not(None),
Foreshadow.expected_close_to < current_chapter,
Foreshadow.status.not_in([CLOSED.value, OVERDUE.value]),
)
.values(status=OVERDUE.value)
.returning(Foreshadow)
)
rows = (await self._s.execute(stmt)).scalars().all()
# 先把命中行物化为不可变 View脱离 ORM 身份),再 expire——避免 UPDATE...RETURNING
# 与 session 里已加载实例的状态不一致(后续读重取最新)。
views = [_to_view(row) for row in rows]
self._s.expire_all()
return views

View File

@@ -197,6 +197,8 @@ async def _seed_oauth_credential(e2e_sm: async_sessionmaker[AsyncSession], blob:
async with e2e_sm() as session:
store = SqlCredentialStore(session)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
# store 只 flush测试前置须显式提交否则独立 session建网关/校验)看不到(新契约)。
await session.commit()
async def test_k1_connect_device_flow_persists_encrypted_token(
@@ -344,6 +346,8 @@ async def test_k1_gateway_builds_kimi_code_adapter_with_headers_and_bearer(
fallback=[],
)
)
# store 只 flush显式提交使独立的建网关 session 可见该路由(新契约)。
await seed.commit()
async with e2e_sm() as session:
store = SqlCredentialStore(session)

View File

@@ -339,8 +339,10 @@ async def test_m4_learn_style_job_done_and_fingerprint_persisted(
assert style_resp.status_code == 200
fp = style_resp.json()
assert fp["version"] == 1
assert fp["dimensions"]["句长节奏"] == "短句为主"
assert fp["evidence"]["句长节奏"] == ["他来了。", "她走了。"]
# 新契约dimensions 为 list[DimensionEntry{name,value,evidence}](不再裸 dict
dims = {d["name"]: d for d in fp["dimensions"]}
assert dims["句长节奏"]["value"] == "短句为主"
assert dims["句长节奏"]["evidence"] == ["他来了。", "她走了。"]
# 4) mode="update" 再学一次 → version+1。
update_resp = await client.post(