"""文档存储抽象。 一期只实现本地文件系统(`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