Files
group_fqcd_jr/app/infrastructure/document_storage.py

67 lines
2.6 KiB
Python

"""文档存储抽象。
一期只实现本地文件系统(`LocalDocumentStorage`):用户尚未提供可用的 MinIO 实例,
业务链路先用本地实现跑通;将来补 `MinioDocumentStorage` 时 `DocumentStorage` 协议不变,
调用方零改动(`fin_knowledge_meta.minio_path` 字段沿用同一 key 语义)。
`key` 使用相对 POSIX 路径语义(如 `kb/2026/faq.txt`):
不得为空、不得为绝对路径、不得包含 `..` 上级引用,也不得指向 `archive/` 保留前缀。
"""
from pathlib import Path, PurePosixPath
from typing import Protocol, runtime_checkable
ARCHIVE_PREFIX = "archive"
@runtime_checkable
class DocumentStorage(Protocol):
"""文档字节存取协议;返回的 key 即业务侧记录到元数据表的存储路径。"""
async def save(self, *, key: str, content: bytes, content_type: str) -> str: ...
async def read(self, *, key: str) -> bytes: ...
async def delete(self, *, key: str) -> None: ...
async def archive(self, *, key: str) -> None: ...
class LocalDocumentStorage:
"""本地文件系统实现;仅用于开发与环境未就绪时的真机替代。"""
def __init__(self, root: Path) -> None:
self._root = Path(root)
async def save(self, *, key: str, content: bytes, content_type: str) -> str:
del content_type # 本地实现不记录 MIME,MinIO 实现需要
path = self._resolve(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return key
async def read(self, *, key: str) -> bytes:
return self._resolve(key).read_bytes()
async def delete(self, *, key: str) -> None:
self._resolve(key).unlink(missing_ok=True)
async def archive(self, *, key: str) -> None:
"""把文档移入 `archive/` 子树(软删除语义,字节仍保留)。"""
source = self._resolve(key)
if not source.exists():
return
target = self._root / ARCHIVE_PREFIX / key
target.parent.mkdir(parents=True, exist_ok=True)
source.replace(target)
def _resolve(self, key: str) -> Path:
if not key.strip() or "\\" in key or ":" in key or key.startswith("/"):
raise ValueError("key 必须是非空的相对 POSIX 路径")
parts = PurePosixPath(key).parts
if ".." in parts or "." in parts:
raise ValueError("key 不得包含上级目录引用")
if parts[0] == ARCHIVE_PREFIX:
raise ValueError("archive/ 是归档保留前缀,不得作为写入目标")
return self._root / key