fix(knowledge): 保证笔记创建与索引更新一致性

This commit is contained in:
2026-08-27 23:24:38 +08:00
parent 9348225e16
commit af8ccb0f18
3 changed files with 81 additions and 6 deletions
+10 -4
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
from contextlib import nullcontext
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -235,14 +236,19 @@ def get_block_hits(block_ids: list[str]) -> list[BlockHit]:
conn.close() conn.close()
def set_index_meta(kv: dict[str, str]) -> None: def set_index_meta(
conn = connect() kv: dict[str, str], *, conn: sqlite3.Connection | None = None
) -> None:
"""写入索引元信息;传入连接时加入调用方现有事务。"""
owns = conn is None
conn = conn or connect()
try: try:
with transaction(conn): with transaction(conn) if owns else nullcontext():
for key, value in kv.items(): for key, value in kv.items():
conn.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", (key, value)) conn.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", (key, value))
finally: finally:
conn.close() if owns:
conn.close()
def get_index_meta() -> dict[str, str]: def get_index_meta() -> dict[str, str]:
+28 -2
View File
@@ -83,6 +83,22 @@ def _write_markdown(rel_path: str, markdown: str) -> None:
path.write_text(markdown, encoding="utf-8") path.write_text(markdown, encoding="utf-8")
def _create_markdown(rel_path: str, markdown: str) -> None:
"""排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。"""
path = _abs_path(rel_path)
path.parent.mkdir(parents=True, exist_ok=True)
try:
with path.open("x", encoding="utf-8") as handle:
handle.write(markdown)
except FileExistsError as exc:
raise ApiError(
409,
"RESOURCE_CONFLICT",
"a note already exists at this path",
{"file_path": rel_path},
) from exc
def _delete_markdown(rel_path: str) -> None: def _delete_markdown(rel_path: str) -> None:
path = _abs_path(rel_path) path = _abs_path(rel_path)
if path.exists(): if path.exists():
@@ -123,9 +139,12 @@ async def index_note(parsed: ParsedNote) -> None:
if block.block_id in missing_ids if block.block_id in missing_ids
] ]
await vector_store.upsert(records, conn=conn) await vector_store.upsert(records, conn=conn)
repository.set_index_meta(
{"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)},
conn=conn,
)
finally: finally:
conn.close() conn.close()
repository.set_index_meta({"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)})
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note: async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
@@ -137,7 +156,14 @@ async def create_note(*, title: str, markdown: str, folder: str | None, tags: li
created_at=now, updated_at=now, created_at=now, updated_at=now,
) )
parsed.title = title # 显式传入的 title 优先于正文推导(与 update_note 保持一致) parsed.title = title # 显式传入的 title 优先于正文推导(与 update_note 保持一致)
_write_markdown(rel_path, markdown) if repository.get_note_record(parsed.note_id) is not None:
raise ApiError(
409,
"RESOURCE_CONFLICT",
"a note already exists at this path",
{"note_id": parsed.note_id, "file_path": rel_path},
)
_create_markdown(rel_path, markdown)
try: try:
await index_note(parsed) await index_note(parsed)
except BaseException: except BaseException:
+43
View File
@@ -281,6 +281,49 @@ def test_update_note_rolls_back_file_on_index_error(vault, monkeypatch) -> None:
assert path.read_text(encoding="utf-8") == before # 文件已回滚,无部分提交 assert path.read_text(encoding="utf-8") == before # 文件已回滚,无部分提交
def test_update_note_rolls_back_index_when_index_meta_fails(vault, monkeypatch) -> None:
"""索引元信息失败时,Markdown 与完整索引都保持旧版本。"""
from app import repository
from app.services import note_service
note = asyncio.run(
note_service.create_note(title="原子更新", markdown="旧正文", folder="", tags=[])
)
def _boom(*_args, **_kwargs):
raise RuntimeError("index meta failed")
monkeypatch.setattr(repository, "set_index_meta", _boom)
with pytest.raises(RuntimeError):
asyncio.run(note_service.update_note(note.note_id, markdown="新正文"))
got = asyncio.run(note_service.get_note(note.note_id))
record = repository.get_note_record(note.note_id)
assert got is not None and got.markdown == "旧正文"
assert record is not None
assert [block.content for block in record.blocks] == ["旧正文"]
def test_create_note_rejects_existing_path_without_overwrite(vault) -> None:
"""POST 同目录同标题返回 409,且不改动已有 Markdown 和索引。"""
from app.errors import ApiError
from app.services import note_service
original = asyncio.run(
note_service.create_note(title="不能覆盖", markdown="原始正文", folder="测试", tags=[])
)
with pytest.raises(ApiError) as exc:
asyncio.run(
note_service.create_note(title="不能覆盖", markdown="替换正文", folder="测试", tags=[])
)
assert exc.value.status_code == 409
assert exc.value.code == "RESOURCE_CONFLICT"
got = asyncio.run(note_service.get_note(original.note_id))
assert got is not None and got.markdown == "原始正文"
def test_update_removes_stale_vectors(vault) -> None: def test_update_removes_stale_vectors(vault) -> None:
from app.database.db import connect from app.database.db import connect
from app.services import note_service from app.services import note_service