Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface. Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass Confidence: medium Scope-risk: moderate Reversibility: messy Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from fastapi import UploadFile
|
||||
@@ -18,7 +19,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import aiofiles
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ class DocumentService:
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValueError(f"不支持的文件类型: {ext}")
|
||||
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
file_id = str(uuid.uuid4())
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}")
|
||||
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
file_path = self._resolve_unique_file_path(folder_path, file.filename)
|
||||
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
@@ -64,7 +64,7 @@ class DocumentService:
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
parsed = await self._parse_document(file_path, ext)
|
||||
parsed = await self._parse_document(str(file_path), ext)
|
||||
parsed.structured_markdown = self._render_structured_markdown(parsed)
|
||||
|
||||
doc = Document(
|
||||
@@ -73,7 +73,7 @@ class DocumentService:
|
||||
filename=file.filename,
|
||||
file_type=ext[1:],
|
||||
file_size=file_size,
|
||||
file_path=file_path,
|
||||
file_path=str(file_path),
|
||||
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
|
||||
folder_id=folder_id,
|
||||
ingestion_status="uploaded",
|
||||
@@ -171,6 +171,83 @@ class DocumentService:
|
||||
|
||||
return "/" + "/".join(path_parts) if path_parts else None
|
||||
|
||||
async def ensure_folder_directory(self, user_id: str, folder_id: str | None) -> Path:
|
||||
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
return folder_path
|
||||
|
||||
async def delete_folder_directory(self, user_id: str, folder_id: str) -> None:
|
||||
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||
if folder_path.exists():
|
||||
shutil.rmtree(folder_path, ignore_errors=True)
|
||||
|
||||
async def rename_folder_directory(self, user_id: str, folder_id: str, old_name: str, new_name: str) -> None:
|
||||
folder = await self.db.get(Folder, folder_id)
|
||||
if folder is None:
|
||||
return
|
||||
|
||||
parent_path = await self._get_storage_directory(user_id, folder.parent_id)
|
||||
old_path = parent_path / self._sanitize_storage_name(old_name)
|
||||
new_path = parent_path / self._sanitize_storage_name(new_name)
|
||||
|
||||
if old_path != new_path:
|
||||
parent_path.mkdir(parents=True, exist_ok=True)
|
||||
if old_path.exists():
|
||||
old_path.rename(new_path)
|
||||
else:
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
new_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
document_result = await self.db.execute(
|
||||
select(Document).where(Document.user_id == user_id)
|
||||
)
|
||||
for document in document_result.scalars().all():
|
||||
try:
|
||||
relative_path = Path(document.file_path).relative_to(old_path)
|
||||
except ValueError:
|
||||
continue
|
||||
document.file_path = str(new_path / relative_path)
|
||||
|
||||
async def _get_storage_directory(self, user_id: str, folder_id: str | None) -> Path:
|
||||
base_path = Path(settings.UPLOAD_DIR) / user_id
|
||||
if not folder_id:
|
||||
return base_path
|
||||
|
||||
folders = await self.db.execute(
|
||||
select(Folder).where(Folder.user_id == user_id)
|
||||
)
|
||||
folder_map = {folder.id: folder for folder in folders.scalars().all()}
|
||||
|
||||
path_segments: list[str] = []
|
||||
current_id = folder_id
|
||||
while current_id:
|
||||
folder = folder_map.get(current_id)
|
||||
if folder is None:
|
||||
raise ValueError("鐖舵枃浠跺す涓嶅瓨鍦?")
|
||||
path_segments.insert(0, self._sanitize_storage_name(folder.name))
|
||||
current_id = folder.parent_id
|
||||
|
||||
return base_path.joinpath(*path_segments)
|
||||
|
||||
def _resolve_unique_file_path(self, directory: Path, original_name: str) -> Path:
|
||||
safe_name = self._sanitize_storage_name(Path(original_name).name, is_file=True)
|
||||
stem = Path(safe_name).stem
|
||||
suffix = Path(safe_name).suffix
|
||||
candidate = directory / safe_name
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = directory / f"{stem}-{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
def _sanitize_storage_name(self, name: str, is_file: bool = False) -> str:
|
||||
invalid_chars = '<>:"/\\|?*'
|
||||
sanitized = ''.join('_' if char in invalid_chars or ord(char) < 32 else char for char in name).strip().rstrip('.')
|
||||
if not sanitized:
|
||||
return 'untitled' if is_file else 'folder'
|
||||
return sanitized
|
||||
|
||||
async def delete_document(self, user_id: str, document_id: str):
|
||||
result = await self.db.execute(
|
||||
select(Document).where(
|
||||
|
||||
108
backend/app/services/remote_sync_service.py
Normal file
108
backend/app/services/remote_sync_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from io import BytesIO
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from app.models.folder import Folder
|
||||
from app.models.remote_mount import RemoteMount, RemoteSyncItem
|
||||
from app.services.document_service import DocumentService
|
||||
from app.services.webdav_service import WebDavNode, WebDavService
|
||||
|
||||
|
||||
class RemoteSyncService:
|
||||
def __init__(self, db: AsyncSession, user_id: str):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
|
||||
async def sync_remote_path(
|
||||
self,
|
||||
mount: RemoteMount,
|
||||
remote_path: str,
|
||||
local_folder_id: str,
|
||||
mode: str = "file",
|
||||
) -> dict:
|
||||
folder = await self.db.execute(
|
||||
select(Folder).where(and_(Folder.id == local_folder_id, Folder.user_id == self.user_id))
|
||||
)
|
||||
if folder.scalar_one_or_none() is None:
|
||||
raise ValueError("本地目标文件夹不存在")
|
||||
|
||||
webdav = WebDavService(mount)
|
||||
document_service = DocumentService(self.db, self.user_id)
|
||||
|
||||
synced = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
document_ids: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
if mode == "folder":
|
||||
nodes = await webdav.list_tree(remote_path)
|
||||
targets = self._flatten_files(nodes)
|
||||
else:
|
||||
name = remote_path.rstrip("/").split("/")[-1] or "remote-file"
|
||||
targets = [WebDavNode(path=remote_path, name=name, is_dir=False)]
|
||||
|
||||
for node in targets:
|
||||
try:
|
||||
content, filename = await webdav.download_file(node.path)
|
||||
upload = UploadFile(filename=filename, file=BytesIO(content))
|
||||
document = await document_service.upload_document(self.user_id, upload, folder_id=local_folder_id)
|
||||
await self._upsert_sync_item(mount.id, node, local_folder_id, document.id)
|
||||
document_ids.append(document.id)
|
||||
synced += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{node.path}: {exc}")
|
||||
await self._upsert_sync_item(mount.id, node, local_folder_id, None, status="failed", error=str(exc))
|
||||
|
||||
mount.last_sync_at = datetime.now(UTC).isoformat()
|
||||
await self.db.commit()
|
||||
return {
|
||||
"synced": synced,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"document_ids": document_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
def _flatten_files(self, nodes: list[WebDavNode]) -> list[WebDavNode]:
|
||||
results: list[WebDavNode] = []
|
||||
for node in nodes:
|
||||
if node.is_dir:
|
||||
results.extend(self._flatten_files(node.children))
|
||||
else:
|
||||
results.append(node)
|
||||
return results
|
||||
|
||||
async def _upsert_sync_item(
|
||||
self,
|
||||
mount_id: str,
|
||||
node: WebDavNode,
|
||||
local_folder_id: str,
|
||||
local_document_id: str | None,
|
||||
status: str = "synced",
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
result = await self.db.execute(
|
||||
select(RemoteSyncItem).where(
|
||||
and_(RemoteSyncItem.mount_id == mount_id, RemoteSyncItem.remote_path == node.path)
|
||||
)
|
||||
)
|
||||
sync_item = result.scalar_one_or_none()
|
||||
if sync_item is None:
|
||||
sync_item = RemoteSyncItem(
|
||||
mount_id=mount_id,
|
||||
remote_path=node.path,
|
||||
)
|
||||
self.db.add(sync_item)
|
||||
|
||||
sync_item.remote_etag = node.etag
|
||||
sync_item.remote_modified_at = node.modified_at
|
||||
sync_item.local_folder_id = local_folder_id
|
||||
sync_item.local_document_id = local_document_id
|
||||
sync_item.sync_status = status
|
||||
sync_item.last_error = error
|
||||
sync_item.last_synced_at = datetime.now(UTC).isoformat()
|
||||
24
backend/app/services/secret_service.py
Normal file
24
backend/app/services/secret_service.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _build_fernet() -> Fernet:
|
||||
digest = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
|
||||
key = base64.urlsafe_b64encode(digest)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||
127
backend/app/services/webdav_service.py
Normal file
127
backend/app/services/webdav_service.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import quote, urljoin
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models.remote_mount import RemoteMount
|
||||
from app.services.secret_service import decrypt_secret
|
||||
|
||||
|
||||
WEBDAV_NAMESPACE = {
|
||||
"d": "DAV:",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebDavNode:
|
||||
path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int | None = None
|
||||
modified_at: str | None = None
|
||||
etag: str | None = None
|
||||
children: list["WebDavNode"] = field(default_factory=list)
|
||||
|
||||
|
||||
class WebDavService:
|
||||
def __init__(self, mount: RemoteMount):
|
||||
self.mount = mount
|
||||
self.username = mount.username or None
|
||||
self.password = decrypt_secret(mount.password_encrypted)
|
||||
|
||||
def _normalize_remote_path(self, remote_path: str | None = None) -> str:
|
||||
path = remote_path or self.mount.root_path or "/"
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return path
|
||||
|
||||
def _build_url(self, remote_path: str | None = None) -> str:
|
||||
path = self._normalize_remote_path(remote_path)
|
||||
encoded = "/".join(quote(segment) for segment in path.split("/") if segment)
|
||||
if not encoded:
|
||||
return self.mount.base_url.rstrip("/") + "/"
|
||||
return urljoin(self.mount.base_url.rstrip("/") + "/", encoded)
|
||||
|
||||
async def list_directory(self, remote_path: str | None = None) -> list[WebDavNode]:
|
||||
path = self._normalize_remote_path(remote_path)
|
||||
body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:resourcetype />
|
||||
<d:getcontentlength />
|
||||
<d:getlastmodified />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
async with httpx.AsyncClient(timeout=30.0, auth=self._auth()) as client:
|
||||
response = await client.request(
|
||||
"PROPFIND",
|
||||
self._build_url(path),
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self._parse_propfind(path, response.text)
|
||||
|
||||
async def list_tree(self, remote_path: str | None = None, max_depth: int = 4) -> list[WebDavNode]:
|
||||
path = self._normalize_remote_path(remote_path)
|
||||
nodes = await self.list_directory(path)
|
||||
if max_depth <= 1:
|
||||
return nodes
|
||||
|
||||
for node in nodes:
|
||||
if node.is_dir:
|
||||
node.children = await self.list_tree(node.path, max_depth=max_depth - 1)
|
||||
return nodes
|
||||
|
||||
async def download_file(self, remote_path: str) -> tuple[bytes, str]:
|
||||
normalized = self._normalize_remote_path(remote_path)
|
||||
async with httpx.AsyncClient(timeout=120.0, auth=self._auth()) as client:
|
||||
response = await client.get(self._build_url(normalized))
|
||||
response.raise_for_status()
|
||||
name = normalized.rstrip("/").split("/")[-1] or "remote-file"
|
||||
return response.content, name
|
||||
|
||||
def _auth(self) -> httpx.BasicAuth | None:
|
||||
if self.username and self.password:
|
||||
return httpx.BasicAuth(self.username, self.password)
|
||||
return None
|
||||
|
||||
def _parse_propfind(self, parent_path: str, payload: str) -> list[WebDavNode]:
|
||||
root = ET.fromstring(payload)
|
||||
nodes: list[WebDavNode] = []
|
||||
|
||||
for response in root.findall("d:response", WEBDAV_NAMESPACE):
|
||||
href = response.findtext("d:href", default="", namespaces=WEBDAV_NAMESPACE)
|
||||
if not href:
|
||||
continue
|
||||
|
||||
normalized_href = "/" + href.split("://", 1)[-1].split("/", 1)[-1].strip("/")
|
||||
normalized_href = "/" if normalized_href == "/" else normalized_href.rstrip("/")
|
||||
normalized_parent = self._normalize_remote_path(parent_path).rstrip("/") or "/"
|
||||
if normalized_href.rstrip("/") == normalized_parent.rstrip("/"):
|
||||
continue
|
||||
|
||||
prop = response.find("d:propstat/d:prop", WEBDAV_NAMESPACE)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
is_dir = prop.find("d:resourcetype/d:collection", WEBDAV_NAMESPACE) is not None
|
||||
display_name = prop.findtext("d:displayname", default="", namespaces=WEBDAV_NAMESPACE) or normalized_href.split("/")[-1]
|
||||
size_text = prop.findtext("d:getcontentlength", default="", namespaces=WEBDAV_NAMESPACE)
|
||||
etag = prop.findtext("d:getetag", default=None, namespaces=WEBDAV_NAMESPACE)
|
||||
modified_at = prop.findtext("d:getlastmodified", default=None, namespaces=WEBDAV_NAMESPACE)
|
||||
|
||||
nodes.append(WebDavNode(
|
||||
path=normalized_href,
|
||||
name=display_name,
|
||||
is_dir=is_dir,
|
||||
size=int(size_text) if size_text.isdigit() else None,
|
||||
etag=etag,
|
||||
modified_at=modified_at,
|
||||
))
|
||||
|
||||
nodes.sort(key=lambda item: (not item.is_dir, item.name.lower()))
|
||||
return nodes
|
||||
Reference in New Issue
Block a user