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:
@@ -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:返回固定能力矩阵,绝不联网。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 设备授权轮询超时"
|
||||
|
||||
54
apps/api/tests/test_middleware_request_id.py
Normal file
54
apps/api/tests/test_middleware_request_id.py
Normal file
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
138
apps/api/tests/test_settings_providers_atomicity.py
Normal file
138
apps/api/tests/test_settings_providers_atomicity.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import ForeshadowLedgerRepo, ForeshadowLedgerView
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus, InvalidTransition
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -34,13 +35,15 @@ from ww_api.schemas.foreshadow import (
|
||||
ForeshadowTransitionRequest,
|
||||
ForeshadowView,
|
||||
)
|
||||
from ww_api.services.project_deps import get_foreshadow_repo
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_foreshadow_repo, get_project_repo
|
||||
|
||||
log = get_logger("ww.api.foreshadow")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["foreshadow"])
|
||||
|
||||
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@@ -92,13 +95,17 @@ async def register_foreshadow(
|
||||
async def list_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
repo: ForeshadowRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
status: str | None = None,
|
||||
) -> ForeshadowBoardResponse:
|
||||
"""伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
|
||||
项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。
|
||||
`status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
if status is not None and status not in {s.value for s in ForeshadowStatus}:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
|
||||
@@ -336,11 +336,15 @@ async def ingest_characters(
|
||||
async def list_characters(
|
||||
project_id: uuid.UUID,
|
||||
memory: MemoryReposDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
) -> CharacterListResponse:
|
||||
"""已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo)。
|
||||
|
||||
项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。
|
||||
DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
cards = await _existing_characters(memory, project_id)
|
||||
return CharacterListResponse(characters=[_card_to_view(c) for c in cards])
|
||||
|
||||
@@ -349,11 +353,15 @@ async def list_characters(
|
||||
async def list_world_entities(
|
||||
project_id: uuid.UUID,
|
||||
memory: MemoryReposDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
) -> WorldEntityListResponse:
|
||||
"""已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo)。
|
||||
|
||||
项目不存在 → 404(与写端点一致)。
|
||||
DB `rules` JSONB dict `{"rules":[...]}` → 裸 list(worldbuilder 形变的逆向)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
views = await memory.world_entity.list_for_project(project_id)
|
||||
entities = [
|
||||
WorldEntityCardView(
|
||||
|
||||
@@ -10,24 +10,29 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db import get_session
|
||||
from ww_db.models import Job
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.schemas.jobs import JobResponse
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
@router.get(
|
||||
"/{job_id}",
|
||||
responses={404: {"model": ErrorEnvelope, "description": "job 不存在"}},
|
||||
)
|
||||
async def get_job(
|
||||
job_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> dict[str, object]:
|
||||
) -> JobResponse:
|
||||
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
if job is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
||||
return {
|
||||
"id": str(job.id),
|
||||
"kind": job.kind,
|
||||
"status": job.status,
|
||||
"progress": job.progress,
|
||||
"result": job.result,
|
||||
"error": job.error,
|
||||
}
|
||||
return JobResponse(
|
||||
id=job.id,
|
||||
kind=job.kind,
|
||||
status=job.status,
|
||||
progress=job.progress,
|
||||
result=job.result,
|
||||
error=job.error,
|
||||
)
|
||||
|
||||
@@ -83,7 +83,7 @@ def _make_poll_work(device: DeviceAuth) -> Any:
|
||||
"""
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
enc_key = get_settings().credential_enc_key
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
store = SqlCredentialStore(session)
|
||||
interval = max(1, device.interval)
|
||||
|
||||
@@ -147,11 +147,11 @@ async def start_oauth(
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
# 不记 user_code:授权窗口内日志可见者可冒用授权意图(P1-10)。仅留 request_id/job_id 关联。
|
||||
log.info(
|
||||
"kimi_oauth_started",
|
||||
request_id=request_id,
|
||||
job_id=str(job.id),
|
||||
user_code=device.user_code,
|
||||
)
|
||||
response.status_code = 202
|
||||
return OAuthStartResponse(
|
||||
@@ -168,10 +168,13 @@ async def start_oauth(
|
||||
async def disconnect_oauth(
|
||||
request: Request,
|
||||
store: CredentialStoreDep,
|
||||
session: SessionDep,
|
||||
) -> OAuthDisconnectResponse:
|
||||
"""断开 Kimi Code:删除 OAuth 凭据行(token 一并消失)。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
deleted = await store.delete_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
|
||||
# store.delete_credential 只 flush,端点统一提交。
|
||||
await session.commit()
|
||||
log.info("kimi_oauth_disconnected", request_id=request_id, deleted=deleted)
|
||||
return OAuthDisconnectResponse(disconnected=deleted)
|
||||
|
||||
@@ -179,7 +182,7 @@ async def disconnect_oauth(
|
||||
@router.get("/status")
|
||||
async def oauth_status(store: CredentialStoreDep) -> OAuthStatusResponse:
|
||||
"""连接状态:是否已连接 + access token 过期时刻(**无 token 本体**)。"""
|
||||
enc_key = get_settings().credential_enc_key
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
|
||||
if cred is None or cred.auth_type != AUTH_TYPE_OAUTH or cred.oauth_enc is None:
|
||||
return OAuthStatusResponse(connected=False)
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -33,13 +33,14 @@ from ww_core.orchestrator import (
|
||||
SseEvent,
|
||||
build_review_context,
|
||||
build_review_graph,
|
||||
error_event,
|
||||
normalize_deltas,
|
||||
normalize_review,
|
||||
stream_chapter_draft,
|
||||
)
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.injection import (
|
||||
@@ -58,6 +59,7 @@ from ww_api.schemas.projects import (
|
||||
ProjectCreateRequest,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
ReviewConflictView,
|
||||
ReviewHistoryItem,
|
||||
ReviewHistoryResponse,
|
||||
ReviewRequest,
|
||||
@@ -87,6 +89,16 @@ log = get_logger("ww.api.projects")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
# OpenAPI 错误响应声明(让 TS 客户端拿到类型化错误形,§7.1)。
|
||||
_NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "资源不存在"}
|
||||
}
|
||||
_ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "资源不存在"},
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
@@ -127,7 +139,7 @@ async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
|
||||
return ProjectListResponse(projects=[_to_response(v) for v in views])
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
@router.get("/{project_id}", responses=_NOT_FOUND)
|
||||
async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectResponse:
|
||||
view = await repo.get(STUB_OWNER_ID, project_id)
|
||||
if view is None:
|
||||
@@ -164,7 +176,7 @@ async def _injection_response(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/injection")
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/injection", responses=_NOT_FOUND)
|
||||
async def get_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -191,7 +203,7 @@ async def get_injection(
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/injection")
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/injection", responses=_NOT_FOUND)
|
||||
async def save_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -276,7 +288,11 @@ async def stream_draft(
|
||||
# 网关在流末经 SqlAlchemyLedgerSink.record 把 usage_ledger 行 flush 进本请求 session;
|
||||
# sink 按设计不提交(写库事务由编排层控制,见不变量)。draft 端点无其他写副作用,
|
||||
# 故流耗尽后在此提交,确保「每次调用一条 usage_ledger」真正落库(T1.9 暴露)。
|
||||
# 尾部 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(否则流被截断、
|
||||
# 客户端收不到 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 端点)。
|
||||
# 尾部 commit 包 try/except:失败则记 sse_commit_failed(P0-4)。
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 流已发完,commit 失败不能再改响应;至少记错误。
|
||||
log.error("sse_commit_failed", request_id=request_id, endpoint="review")
|
||||
|
||||
return StreamingResponse(
|
||||
_frames(),
|
||||
@@ -433,7 +475,7 @@ async def list_reviews(
|
||||
project_id=v.project_id,
|
||||
chapter_no=v.chapter_no,
|
||||
chapter_version=v.chapter_version,
|
||||
conflicts=v.conflicts,
|
||||
conflicts=[ReviewConflictView.model_validate(c) for c in v.conflicts],
|
||||
foreshadow_sug=v.foreshadow_sug,
|
||||
style=v.style,
|
||||
pace=v.pace,
|
||||
@@ -445,7 +487,7 @@ async def list_reviews(
|
||||
return ReviewHistoryResponse(reviews=items)
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/accept")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/accept", responses=_ACCEPT_ERRORS)
|
||||
async def accept_chapter(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
|
||||
@@ -12,7 +12,9 @@ from __future__ import annotations
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
@@ -44,6 +46,7 @@ router = APIRouter(prefix="/settings/providers", tags=["settings"])
|
||||
|
||||
StoreDep = Annotated[CredentialStore, Depends(get_credential_store)]
|
||||
ProbeDep = Annotated[ProviderProbe, Depends(get_provider_probe)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _mask_credential(cred: StoredCredential, plaintext: str | None) -> ProviderView:
|
||||
@@ -76,8 +79,9 @@ async def list_providers(store: StoreDep) -> ProvidersResponse:
|
||||
async def upsert_providers(
|
||||
body: ProvidersUpsertRequest,
|
||||
store: StoreDep,
|
||||
session: SessionDep,
|
||||
) -> ProvidersResponse:
|
||||
enc_key = get_settings().credential_enc_key
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
for cred in body.credentials:
|
||||
api_key_enc = encrypt_api_key(cred.api_key, key=enc_key)
|
||||
await store.upsert_credential(STUB_OWNER_ID, cred.provider, api_key_enc)
|
||||
@@ -102,6 +106,8 @@ async def upsert_providers(
|
||||
provider=routing.provider,
|
||||
model=routing.model,
|
||||
)
|
||||
# 所有凭据/路由写入只 flush;此处统一一次提交——任一步失败则整体回滚(原子性)。
|
||||
await session.commit()
|
||||
return await _build_response(store)
|
||||
|
||||
|
||||
|
||||
@@ -29,10 +29,11 @@ from ww_core.orchestrator import run_style_extraction
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.style import (
|
||||
DimensionEntry,
|
||||
RefineRequest,
|
||||
RefineResponse,
|
||||
StyleFingerprintResponse,
|
||||
@@ -151,7 +152,22 @@ async def learn_style(
|
||||
return StyleLearnResponse(job_id=job.id)
|
||||
|
||||
|
||||
@router.get("/{project_id}/style")
|
||||
def _merge_dimensions(dimensions: dict[str, Any], evidence: dict[str, Any]) -> list[DimensionEntry]:
|
||||
"""把 DB 两列并行 dict 合并为 `list[DimensionEntry]`(按维度名稳定排序,便于前端展示)。"""
|
||||
return [
|
||||
DimensionEntry(
|
||||
name=name,
|
||||
value=str(dimensions[name]),
|
||||
evidence=[str(e) for e in (evidence.get(name) or [])],
|
||||
)
|
||||
for name in sorted(dimensions)
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/style",
|
||||
responses={404: {"model": ErrorEnvelope, "description": "项目或指纹不存在"}},
|
||||
)
|
||||
async def get_style(
|
||||
project_id: uuid.UUID,
|
||||
project_repo: ProjectRepoDep,
|
||||
@@ -166,8 +182,7 @@ async def get_style(
|
||||
if latest is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"no style fingerprint for project: {project_id}")
|
||||
return StyleFingerprintResponse(
|
||||
dimensions=latest.dimensions,
|
||||
evidence=latest.evidence,
|
||||
dimensions=_merge_dimensions(latest.dimensions, latest.evidence),
|
||||
version=latest.version,
|
||||
)
|
||||
|
||||
|
||||
27
apps/api/ww_api/schemas/jobs.py
Normal file
27
apps/api/ww_api/schemas/jobs.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
56
packages/core/tests/test_chapter_version_lock.py
Normal file
56
packages/core/tests/test_chapter_version_lock.py
Normal 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)
|
||||
|
||||
# 无现有行 → 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
|
||||
67
packages/core/tests/test_foreshadow_scan_sql.py
Normal file
67
packages/core/tests/test_foreshadow_scan_sql.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user