feat: add DocumentService coordinating MinIO writes and search indexing
This commit is contained in:
parent
c931b870ed
commit
6a286d422d
87
backend/app/document_service.py
Normal file
87
backend/app/document_service.py
Normal file
@ -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)}
|
||||||
146
backend/tests/test_document_service.py
Normal file
146
backend/tests/test_document_service.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user