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:
130
backend/app/routers/remote_mount.py
Normal file
130
backend/app/routers/remote_mount.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.remote_mount import RemoteMount
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.remote_mount import (
|
||||
RemoteMountCreate,
|
||||
RemoteMountOut,
|
||||
RemoteMountTreeOut,
|
||||
RemoteNodeOut,
|
||||
RemoteSyncRequest,
|
||||
RemoteSyncResultOut,
|
||||
)
|
||||
from app.services.remote_sync_service import RemoteSyncService
|
||||
from app.services.secret_service import encrypt_secret
|
||||
from app.services.webdav_service import WebDavNode, WebDavService
|
||||
|
||||
router = APIRouter(prefix="/api/remote-mounts", tags=["远程挂载"])
|
||||
|
||||
|
||||
def _to_node_out(node: WebDavNode) -> RemoteNodeOut:
|
||||
return RemoteNodeOut(
|
||||
path=node.path,
|
||||
name=node.name,
|
||||
is_dir=node.is_dir,
|
||||
size=node.size,
|
||||
modified_at=node.modified_at,
|
||||
etag=node.etag,
|
||||
children=[_to_node_out(child) for child in node.children],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RemoteMountOut])
|
||||
async def list_remote_mounts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RemoteMount).where(RemoteMount.user_id == current_user.id).order_by(RemoteMount.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=RemoteMountOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_remote_mount(
|
||||
payload: RemoteMountCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
existing = await db.execute(
|
||||
select(RemoteMount).where(and_(RemoteMount.user_id == current_user.id, RemoteMount.name == payload.name))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="同名远程挂载已存在")
|
||||
|
||||
mount = RemoteMount(
|
||||
user_id=current_user.id,
|
||||
name=payload.name,
|
||||
mount_type="webdav",
|
||||
base_url=str(payload.base_url),
|
||||
username=payload.username,
|
||||
password_encrypted=encrypt_secret(payload.password),
|
||||
root_path=payload.root_path,
|
||||
is_active=True,
|
||||
)
|
||||
try:
|
||||
await WebDavService(mount).list_directory(payload.root_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"WebDAV 连接失败: {exc}") from exc
|
||||
|
||||
db.add(mount)
|
||||
await db.commit()
|
||||
await db.refresh(mount)
|
||||
return mount
|
||||
|
||||
|
||||
async def _get_user_mount(db: AsyncSession, user_id: str, mount_id: str) -> RemoteMount:
|
||||
result = await db.execute(
|
||||
select(RemoteMount).where(and_(RemoteMount.id == mount_id, RemoteMount.user_id == user_id))
|
||||
)
|
||||
mount = result.scalar_one_or_none()
|
||||
if mount is None:
|
||||
raise HTTPException(status_code=404, detail="远程挂载不存在")
|
||||
return mount
|
||||
|
||||
|
||||
@router.get("/{mount_id}/tree", response_model=RemoteMountTreeOut)
|
||||
async def get_remote_tree(
|
||||
mount_id: str,
|
||||
path: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
mount = await _get_user_mount(db, current_user.id, mount_id)
|
||||
try:
|
||||
nodes = await WebDavService(mount).list_tree(path or mount.root_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"远程目录读取失败: {exc}") from exc
|
||||
|
||||
return RemoteMountTreeOut(
|
||||
mount_id=mount.id,
|
||||
root_path=path or mount.root_path,
|
||||
nodes=[_to_node_out(node) for node in nodes],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{mount_id}/sync", response_model=RemoteSyncResultOut)
|
||||
async def sync_remote_mount(
|
||||
mount_id: str,
|
||||
payload: RemoteSyncRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
mount = await _get_user_mount(db, current_user.id, mount_id)
|
||||
try:
|
||||
result = await RemoteSyncService(db, current_user.id).sync_remote_path(
|
||||
mount,
|
||||
payload.remote_path,
|
||||
payload.local_folder_id,
|
||||
payload.mode,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=f"远程同步失败: {exc}") from exc
|
||||
|
||||
return RemoteSyncResultOut(**result)
|
||||
Reference in New Issue
Block a user