From 6a286d422d8d513d86a5219fe8db85bfd9b92591 Mon Sep 17 00:00:00 2001 From: Tianyang Date: Sat, 1 Aug 2026 02:28:58 +0800 Subject: [PATCH] feat: add DocumentService coordinating MinIO writes and search indexing --- backend/app/document_service.py | 87 +++++++++++++++ backend/tests/test_document_service.py | 146 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 backend/app/document_service.py create mode 100644 backend/tests/test_document_service.py diff --git a/backend/app/document_service.py b/backend/app/document_service.py new file mode 100644 index 0000000..5c730b7 --- /dev/null +++ b/backend/app/document_service.py @@ -0,0 +1,87 @@ +import logging +from datetime import datetime, timezone + +from app.text_extract import html_to_text, markdown_to_text + +logger = logging.getLogger(__name__) + + +class DocumentNotFoundError(Exception): + pass + + +def _guess_content_type(key: str) -> str: + if key.endswith(".md"): + return "text/markdown" + if key.endswith(".html") or key.endswith(".htm"): + return "text/html" + return "application/octet-stream" + + +class DocumentService: + def __init__(self, minio_client, search_client): + self._minio_client = minio_client + self._search_client = search_client + + def list_documents(self, path: str) -> dict: + prefix = path if not path or path.endswith("/") else f"{path}/" + entries = self._minio_client.list_objects(prefix) + folders = [] + files = [] + for entry in entries: + key = entry["key"] + if entry.get("is_dir"): + name = key[len(prefix):].rstrip("/") + if name: + folders.append(name) + else: + name = key[len(prefix):] + files.append({ + "key": key, + "name": name, + "size": entry["size"], + "modifiedAt": entry["modifiedAt"], + }) + return {"folders": folders, "files": files} + + def get_document(self, key: str) -> dict: + try: + data = self._minio_client.get_object(key) + except FileNotFoundError as exc: + raise DocumentNotFoundError(key) from exc + return { + "key": key, + "content": data.decode("utf-8"), + "contentType": _guess_content_type(key), + } + + def _extract_text(self, content: bytes, content_type: str) -> str: + text = content.decode("utf-8", errors="ignore") + if content_type == "text/html": + return html_to_text(text) + if content_type == "text/markdown": + return markdown_to_text(text) + return text + + def save_document(self, key: str, content: bytes, content_type: str) -> dict: + self._minio_client.put_object(key, content, content_type) + try: + text = self._extract_text(content, content_type) + title = key.rsplit("/", 1)[-1] + path = key.rsplit("/", 1)[0] if "/" in key else "" + self._search_client.index_document(key, path, title, text) + except Exception: + logger.exception("Failed to update search index for %s", key) + return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()} + + def delete_document(self, key: str) -> None: + self._minio_client.delete_object(key) + try: + self._search_client.delete_document(key) + except Exception: + logger.exception("Failed to delete search index entry for %s", key) + + def upload_document(self, key: str, data: bytes, content_type: str) -> dict: + self.save_document(key, data, content_type) + name = key.rsplit("/", 1)[-1] + return {"key": key, "name": name, "size": len(data)} diff --git a/backend/tests/test_document_service.py b/backend/tests/test_document_service.py new file mode 100644 index 0000000..b62007d --- /dev/null +++ b/backend/tests/test_document_service.py @@ -0,0 +1,146 @@ +import pytest + +from app.document_service import DocumentService, DocumentNotFoundError + + +class FakeMinioClient: + def __init__(self): + self.entries = [] + self.stored = {} + self.deleted_keys = [] + self.put_calls = [] + + def list_objects(self, prefix): + return [e for e in self.entries if e["key"].startswith(prefix)] + + def get_object(self, key): + if key not in self.stored: + raise FileNotFoundError(key) + return self.stored[key] + + def put_object(self, key, data, content_type): + self.stored[key] = data + self.put_calls.append((key, content_type)) + + def delete_object(self, key): + if key not in self.stored: + raise FileNotFoundError(key) + del self.stored[key] + self.deleted_keys.append(key) + + +class FakeSearchClient: + def __init__(self, fail_on_index=False, fail_on_delete=False): + self.indexed = [] + self.deleted = [] + self.fail_on_index = fail_on_index + self.fail_on_delete = fail_on_delete + + def index_document(self, key, path, title, content): + if self.fail_on_index: + raise RuntimeError("meilisearch unavailable") + self.indexed.append({"key": key, "path": path, "title": title, "content": content}) + + def delete_document(self, key): + if self.fail_on_delete: + raise RuntimeError("meilisearch unavailable") + self.deleted.append(key) + + +def test_list_documents_at_root_returns_folders_and_files(): + minio_client = FakeMinioClient() + minio_client.entries = [ + {"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None}, + {"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None}, + {"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, + ] + service = DocumentService(minio_client, FakeSearchClient()) + + result = service.list_documents("") + + assert result["folders"] == ["产品文档", "团队规范"] + assert result["files"] == [ + {"key": "README.md", "name": "README.md", "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, + ] + + +def test_get_document_returns_content_and_content_type(): + minio_client = FakeMinioClient() + minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8") + service = DocumentService(minio_client, FakeSearchClient()) + + result = service.get_document("产品文档/架构设计.md") + + assert result["key"] == "产品文档/架构设计.md" + assert result["contentType"] == "text/markdown" + assert "架构设计" in result["content"] + + +def test_get_document_raises_not_found_for_missing_key(): + service = DocumentService(FakeMinioClient(), FakeSearchClient()) + + with pytest.raises(DocumentNotFoundError): + service.get_document("不存在/文档.md") + + +def test_save_document_writes_to_minio_then_indexes_content(): + minio_client = FakeMinioClient() + search_client = FakeSearchClient() + service = DocumentService(minio_client, search_client) + content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8") + + result = service.save_document("产品文档/架构设计.md", content, "text/markdown") + + assert minio_client.stored["产品文档/架构设计.md"] == content + assert search_client.indexed[0]["key"] == "产品文档/架构设计.md" + assert search_client.indexed[0]["path"] == "产品文档" + assert search_client.indexed[0]["title"] == "架构设计.md" + assert "MinIO" in search_client.indexed[0]["content"] + assert "#" not in search_client.indexed[0]["content"] + assert "key" in result and "updatedAt" in result + + +def test_save_document_does_not_fail_when_index_update_fails(): + minio_client = FakeMinioClient() + search_client = FakeSearchClient(fail_on_index=True) + service = DocumentService(minio_client, search_client) + content = b"# doc" + + result = service.save_document("产品文档/架构设计.md", content, "text/markdown") + + assert minio_client.stored["产品文档/架构设计.md"] == content + assert result["key"] == "产品文档/架构设计.md" + + +def test_delete_document_removes_from_minio_and_search_index(): + minio_client = FakeMinioClient() + minio_client.stored["产品文档/旧文档.md"] = b"old content" + search_client = FakeSearchClient() + service = DocumentService(minio_client, search_client) + + service.delete_document("产品文档/旧文档.md") + + assert "产品文档/旧文档.md" not in minio_client.stored + assert search_client.deleted == ["产品文档/旧文档.md"] + + +def test_delete_document_does_not_fail_when_index_delete_fails(): + minio_client = FakeMinioClient() + minio_client.stored["产品文档/旧文档.md"] = b"old content" + search_client = FakeSearchClient(fail_on_delete=True) + service = DocumentService(minio_client, search_client) + + service.delete_document("产品文档/旧文档.md") + + assert "产品文档/旧文档.md" not in minio_client.stored + + +def test_upload_document_writes_file_and_returns_metadata(): + minio_client = FakeMinioClient() + service = DocumentService(minio_client, FakeSearchClient()) + data = b"# uploaded doc" + + result = service.upload_document("产品文档/新上传.md", data, "text/markdown") + + assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)} + assert minio_client.stored["产品文档/新上传.md"] == data