Files
group_fqcd_jr/app/infrastructure/document_storage.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

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