wip: 客服Agent + RAG + 画像收尾(基于 6516ccb)
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""文档存储抽象。
|
||||
|
||||
一期只实现本地文件系统(`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
|
||||
Reference in New Issue
Block a user