diff --git a/apps/api/tests/fakes_providers.py b/apps/api/tests/fakes_providers.py index a945855..8038423 100644 --- a/apps/api/tests/fakes_providers.py +++ b/apps/api/tests/fakes_providers.py @@ -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:返回固定能力矩阵,绝不联网。""" diff --git a/apps/api/tests/test_foreshadow.py b/apps/api/tests/test_foreshadow.py index 450fcde..af8966d 100644 --- a/apps/api/tests/test_foreshadow.py +++ b/apps/api/tests/test_foreshadow.py @@ -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 diff --git a/apps/api/tests/test_generation.py b/apps/api/tests/test_generation.py index 5b11214..712027a 100644 --- a/apps/api/tests/test_generation.py +++ b/apps/api/tests/test_generation.py @@ -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, diff --git a/apps/api/tests/test_injection.py b/apps/api/tests/test_injection.py index 7e194e0..0907fc6 100644 --- a/apps/api/tests/test_injection.py +++ b/apps/api/tests/test_injection.py @@ -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 ( diff --git a/apps/api/tests/test_job_runner.py b/apps/api/tests/test_job_runner.py index 4289a87..80e537e 100644 --- a/apps/api/tests/test_job_runner.py +++ b/apps/api/tests/test_job_runner.py @@ -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-3:AppError 的 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 设备授权轮询超时" diff --git a/apps/api/tests/test_middleware_request_id.py b/apps/api/tests/test_middleware_request_id.py new file mode 100644 index 0000000..a5089b5 --- /dev/null +++ b/apps/api/tests/test_middleware_request_id.py @@ -0,0 +1,54 @@ +"""P2:request_id 头校验——安全字符集白名单(字母数字 . _ -,1–128 位),非法则生成新 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) diff --git a/apps/api/tests/test_outline.py b/apps/api/tests/test_outline.py index 1adccb3..dc14c7e 100644 --- a/apps/api/tests/test_outline.py +++ b/apps/api/tests/test_outline.py @@ -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") diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 2d72003..df62961 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -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, diff --git a/apps/api/tests/test_review_accept.py b/apps/api/tests/test_review_accept.py index de7dc07..96de313 100644 --- a/apps/api/tests/test_review_accept.py +++ b/apps/api/tests/test_review_accept.py @@ -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, diff --git a/apps/api/tests/test_rules.py b/apps/api/tests/test_rules.py index 2150473..99c2a07 100644 --- a/apps/api/tests/test_rules.py +++ b/apps/api/tests/test_rules.py @@ -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 diff --git a/apps/api/tests/test_settings_providers_atomicity.py b/apps/api/tests/test_settings_providers_atomicity.py new file mode 100644 index 0000000..2047647 --- /dev/null +++ b/apps/api/tests/test_settings_providers_atomicity.py @@ -0,0 +1,138 @@ +"""P0-1 原子性:`PUT /settings/providers` 多凭据一请求,中途失败整体回滚。 + +不变量:仓储写方法只 `flush()`,端点末尾**统一一次** `commit()`。若某条 upsert 中途 +抛错,端点绝不到达 `commit()` → 请求 session 回滚,DB 不留半更新(无部分提交)。 + +用可注入 fake:store 在第 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 diff --git a/apps/api/tests/test_style.py b/apps/api/tests/test_style.py index 904efad..b652f9e 100644 --- a/apps/api/tests/test_style.py +++ b/apps/api/tests/test_style.py @@ -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 codegen:dimensions 改为 list[DimensionEntry](name/value/evidence 合并), + # 替代此前两列并行裸 dict(强类型给 TS 客户端)。 + assert body["dimensions"] == [{"name": "句长", "value": "短", "evidence": ["他来了。"]}] @pytest.mark.asyncio diff --git a/apps/api/ww_api/logging_config.py b/apps/api/ww_api/logging_config.py index 530da2c..1251013 100644 --- a/apps/api/ww_api/logging_config.py +++ b/apps/api/ww_api/logging_config.py @@ -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, + # 交给 stdlib:ProcessorFormatter 在 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 root:SQLAlchemy/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: diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index 2df20a2..870d7ef 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -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) diff --git a/apps/api/ww_api/middleware.py b/apps/api/ww_api/middleware.py index 480d240..23102db 100644 --- a/apps/api/ww_api/middleware.py +++ b/apps/api/ww_api/middleware.py @@ -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" +# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 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 diff --git a/apps/api/ww_api/routers/foreshadow.py b/apps/api/ww_api/routers/foreshadow.py index c119ae5..36bbf44 100644 --- a/apps/api/ww_api/routers/foreshadow.py +++ b/apps/api/ww_api/routers/foreshadow.py @@ -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, diff --git a/apps/api/ww_api/routers/generation.py b/apps/api/ww_api/routers/generation.py index 4353770..43b7281 100644 --- a/apps/api/ww_api/routers/generation.py +++ b/apps/api/ww_api/routers/generation.py @@ -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( diff --git a/apps/api/ww_api/routers/jobs.py b/apps/api/ww_api/routers/jobs.py index d14330e..515a481 100644 --- a/apps/api/ww_api/routers/jobs.py +++ b/apps/api/ww_api/routers/jobs.py @@ -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, + ) diff --git a/apps/api/ww_api/routers/kimi_oauth.py b/apps/api/ww_api/routers/kimi_oauth.py index 97352e9..d68650e 100644 --- a/apps/api/ww_api/routers/kimi_oauth.py +++ b/apps/api/ww_api/routers/kimi_oauth.py @@ -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) diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index c65a967..fa7a075 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -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, diff --git a/apps/api/ww_api/routers/settings_providers.py b/apps/api/ww_api/routers/settings_providers.py index 13f3831..991de02 100644 --- a/apps/api/ww_api/routers/settings_providers.py +++ b/apps/api/ww_api/routers/settings_providers.py @@ -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) diff --git a/apps/api/ww_api/routers/style.py b/apps/api/ww_api/routers/style.py index 9cc6d7a..6c76125 100644 --- a/apps/api/ww_api/routers/style.py +++ b/apps/api/ww_api/routers/style.py @@ -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, ) diff --git a/apps/api/ww_api/schemas/jobs.py b/apps/api/ww_api/schemas/jobs.py new file mode 100644 index 0000000..ec0a4b8 --- /dev/null +++ b/apps/api/ww_api/schemas/jobs.py @@ -0,0 +1,27 @@ +"""长任务轮询响应 schema(C3 / 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 diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 5cc8afe..85c55f0 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -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 diff --git a/apps/api/ww_api/schemas/style.py b/apps/api/ww_api/schemas/style.py index da26d65..7a4062d 100644 --- a/apps/api/ww_api/schemas/style.py +++ b/apps/api/ww_api/schemas/style.py @@ -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 diff --git a/apps/api/ww_api/services/accept_service.py b/apps/api/ww_api/services/accept_service.py index 17a3f19..dba12dd 100644 --- a/apps/api/ww_api/services/accept_service.py +++ b/apps/api/ww_api/services/accept_service.py @@ -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 新 version(max+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", diff --git a/apps/api/ww_api/services/credentials.py b/apps/api/ww_api/services/credentials.py index bb42221..46530e4 100644 --- a/apps/api/ww_api/services/credentials.py +++ b/apps/api/ww_api/services/credentials.py @@ -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() diff --git a/apps/api/ww_api/services/job_runner.py b/apps/api/ww_api/services/job_runner.py index cc2d4a7..220a89f 100644 --- a/apps/api/ww_api/services/job_runner.py +++ b/apps/api/ww_api/services/job_runner.py @@ -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))` → commit(job 失败可见)。 + 异常分类落库(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( diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 1a7523b..a6d143a 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -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_url;Anthropic/Gemini 走原生 SDK(base_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 diff --git a/apps/api/ww_api/services/provider_deps.py b/apps/api/ww_api/services/provider_deps.py index 8fbae9d..5fe6c2c 100644 --- a/apps/api/ww_api/services/provider_deps.py +++ b/apps/api/ww_api/services/provider_deps.py @@ -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()) diff --git a/packages/config/ww_config/settings.py b/packages/config/ww_config/settings.py index a27ee71..85a2eec 100644 --- a/packages/config/ww_config/settings.py +++ b/packages/config/ww_config/settings.py @@ -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 = "" + # 提供商凭据对称加密 key(Fernet)。用 SecretStr 防误序列化/误入日志; + # 取明文须显式 `.get_secret_value()`。启动校验见 `main._lifespan`。 + credential_enc_key: SecretStr = SecretStr("") # 浏览器端跨源访问(前端 :3000 → API :8000)放行的来源;env `CORS_ORIGINS` 逗号分隔覆盖。 cors_origins: list[str] = [ diff --git a/packages/core/tests/test_chapter_version_lock.py b/packages/core/tests/test_chapter_version_lock.py new file mode 100644 index 0000000..60d6500 --- /dev/null +++ b/packages/core/tests/test_chapter_version_lock.py @@ -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) + + # 无现有行 → 0(read-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 diff --git a/packages/core/tests/test_foreshadow_scan_sql.py b/packages/core/tests/test_foreshadow_scan_sql.py new file mode 100644 index 0000000..cccbce4 --- /dev/null +++ b/packages/core/tests/test_foreshadow_scan_sql.py @@ -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 diff --git a/packages/core/ww_core/domain/chapter_repo.py b/packages/core/ww_core/domain/chapter_repo.py index c36058a..77a5589 100644 --- a/packages/core/ww_core/domain/chapter_repo.py +++ b/packages/core/ww_core/domain/chapter_repo.py @@ -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( - Chapter.project_id == project_id, - Chapter.chapter_no == chapter_no, + # 消除 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 diff --git a/packages/core/ww_core/domain/foreshadow_repo.py b/packages/core/ww_core/domain/foreshadow_repo.py index a3f97ab..3a258c7 100644 --- a/packages/core/ww_core/domain/foreshadow_repo.py +++ b/packages/core/ww_core/domain/foreshadow_repo.py @@ -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 diff --git a/tests/test_k1_kimi_oauth_e2e.py b/tests/test_k1_kimi_oauth_e2e.py index ae439b7..46449d6 100644 --- a/tests/test_k1_kimi_oauth_e2e.py +++ b/tests/test_k1_kimi_oauth_e2e.py @@ -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) diff --git a/tests/test_m4_e2e.py b/tests/test_m4_e2e.py index 7d33cff..98d68be 100644 --- a/tests/test_m4_e2e.py +++ b/tests/test_m4_e2e.py @@ -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(