第一步
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""存储服务抽象(扩展接口 2:本地文件 → MinIO/S3)。"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StorageService(Protocol):
|
||||
"""文件存储统一接口。所有业务代码通过此接口读写文件,禁止直接 open()/Path()。"""
|
||||
|
||||
def save(self, key: str, data: bytes) -> str:
|
||||
"""保存数据,返回实际存储路径。"""
|
||||
...
|
||||
|
||||
def read(self, key: str) -> bytes:
|
||||
"""读取数据。"""
|
||||
...
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""删除文件。"""
|
||||
...
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""文件是否存在。"""
|
||||
...
|
||||
|
||||
def get_size(self, key: str) -> int:
|
||||
"""获取文件大小(字节)。"""
|
||||
...
|
||||
@@ -0,0 +1,67 @@
|
||||
"""本地文件系统存储实现(MVP)。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LocalStorageService:
|
||||
"""文件存储到服务器本地 data/ 目录。
|
||||
|
||||
key 格式示例:
|
||||
users/{uid}/kbs/{kb_id}/original/{rand}_{safe_name}
|
||||
users/{uid}/kbs/{kb_id}/markdown/{rand}.md
|
||||
|
||||
实际路径 = storage_root / key
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self._root = settings.storage_root_path
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _resolve(self, key: str) -> Path:
|
||||
"""解析 key 为绝对路径,同时防路径穿越。"""
|
||||
path = (self._root / key).resolve()
|
||||
# 安全:确保解析后路径仍在 root 内
|
||||
if not str(path).startswith(str(self._root)):
|
||||
raise ValueError(f"路径穿越检测:key={key}")
|
||||
return path
|
||||
|
||||
def save(self, key: str, data: bytes) -> str:
|
||||
path = self._resolve(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
logger.debug("Saved file: %s (%d bytes)", key, len(data))
|
||||
return key
|
||||
|
||||
def read(self, key: str) -> bytes:
|
||||
path = self._resolve(key)
|
||||
return path.read_bytes()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = self._resolve(key)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.debug("Deleted file: %s", key)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
return self._resolve(key).exists()
|
||||
|
||||
def get_size(self, key: str) -> int:
|
||||
path = self._resolve(key)
|
||||
return path.stat().st_size
|
||||
|
||||
|
||||
# 单例
|
||||
_instance: LocalStorageService | None = None
|
||||
|
||||
|
||||
def get_storage() -> LocalStorageService:
|
||||
global _instance
|
||||
if _instance is None:
|
||||
_instance = LocalStorageService()
|
||||
return _instance
|
||||
@@ -0,0 +1,50 @@
|
||||
"""对象存储 key 生成。
|
||||
|
||||
布局:users/{user_id}/kbs/{kb_id}/original/{rand8}_{safe_filename}
|
||||
users/{user_id}/kbs/{kb_id}/markdown/{rand8}.md
|
||||
|
||||
规则(需求 §27 / §23):
|
||||
- 不信任用户文件名:取 basename,剔除路径穿越与控制字符,截断长度。
|
||||
- 主体是内部 UUID,公共 URL 使用 token,绝不回显此 key。
|
||||
"""
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]+")
|
||||
_MAX_FILENAME_LEN = 120
|
||||
|
||||
|
||||
def sanitize_filename(original: str) -> str:
|
||||
name = original.replace("\\", "/").split("/")[-1]
|
||||
name = _SAFE_NAME_RE.sub("_", name).strip("._")
|
||||
if not name:
|
||||
name = "file"
|
||||
return name[:_MAX_FILENAME_LEN]
|
||||
|
||||
|
||||
def original_object_key(
|
||||
*,
|
||||
user_id: str,
|
||||
knowledge_base_id: str,
|
||||
document_id: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
rand = secrets.token_urlsafe(6) # ~8 字符
|
||||
safe = sanitize_filename(original_filename)
|
||||
return (
|
||||
f"users/{user_id}/kbs/{knowledge_base_id}/docs/{document_id}/original/{rand}_{safe}"
|
||||
)
|
||||
|
||||
|
||||
def markdown_object_key(
|
||||
*,
|
||||
user_id: str,
|
||||
knowledge_base_id: str,
|
||||
document_id: str,
|
||||
) -> str:
|
||||
rand = secrets.token_urlsafe(6)
|
||||
return (
|
||||
f"users/{user_id}/kbs/{knowledge_base_id}/docs/{document_id}/markdown/{rand}.md"
|
||||
)
|
||||
Reference in New Issue
Block a user