From af8ccb0f180019793db5fceff4e73677b5ec22e4 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Thu, 27 Aug 2026 23:24:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge):=20=E4=BF=9D=E8=AF=81=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E5=88=9B=E5=BB=BA=E4=B8=8E=E7=B4=A2=E5=BC=95=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/repository.py | 14 ++++++--- backend/app/services/note_service.py | 30 +++++++++++++++++-- backend/tests/test_retrieval.py | 43 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/backend/app/repository.py b/backend/app/repository.py index 7be9169..c02d2e1 100644 --- a/backend/app/repository.py +++ b/backend/app/repository.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import sqlite3 +from contextlib import nullcontext from dataclasses import dataclass, field from datetime import datetime @@ -235,14 +236,19 @@ def get_block_hits(block_ids: list[str]) -> list[BlockHit]: conn.close() -def set_index_meta(kv: dict[str, str]) -> None: - conn = connect() +def set_index_meta( + kv: dict[str, str], *, conn: sqlite3.Connection | None = None +) -> None: + """写入索引元信息;传入连接时加入调用方现有事务。""" + owns = conn is None + conn = conn or connect() try: - with transaction(conn): + with transaction(conn) if owns else nullcontext(): for key, value in kv.items(): conn.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", (key, value)) finally: - conn.close() + if owns: + conn.close() def get_index_meta() -> dict[str, str]: diff --git a/backend/app/services/note_service.py b/backend/app/services/note_service.py index 7e6060e..51a17b3 100644 --- a/backend/app/services/note_service.py +++ b/backend/app/services/note_service.py @@ -83,6 +83,22 @@ def _write_markdown(rel_path: str, markdown: str) -> None: 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: path = _abs_path(rel_path) if path.exists(): @@ -123,9 +139,12 @@ async def index_note(parsed: ParsedNote) -> None: if block.block_id in missing_ids ] await vector_store.upsert(records, conn=conn) + repository.set_index_meta( + {"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)}, + conn=conn, + ) finally: 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: @@ -137,7 +156,14 @@ async def create_note(*, title: str, markdown: str, folder: str | None, tags: li created_at=now, updated_at=now, ) 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: await index_note(parsed) except BaseException: diff --git a/backend/tests/test_retrieval.py b/backend/tests/test_retrieval.py index 5f137dd..a8ab47b 100644 --- a/backend/tests/test_retrieval.py +++ b/backend/tests/test_retrieval.py @@ -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 # 文件已回滚,无部分提交 +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: from app.database.db import connect from app.services import note_service