nex_docus/backend/app/services/project_file_service.py

322 lines
12 KiB
Python
Raw Normal View History

2026-04-08 17:03:57 +00:00
"""
项目文件业务服务
"""
from pathlib import Path
2026-08-03 05:59:52 +00:00
from typing import List, Optional, Tuple
2026-04-08 17:03:57 +00:00
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.enums import OperationType
from app.models.project import Project
from app.models.user import User
from app.services.log_service import log_service
from app.services.notification_service import notification_service
from app.services.search_service import search_service
from app.services.storage import storage_service
2026-08-03 05:59:52 +00:00
from app.services.file_vector_sync_service import file_vector_sync_service
2026-04-08 17:03:57 +00:00
class ProjectFileService:
"""收口项目文件写入、副作用联动和通知流程。"""
@staticmethod
def _actor_name(user: User) -> str:
return user.nickname or user.username
@staticmethod
def _source_suffix(source: str) -> str:
return " 通过 MCP" if source == "mcp" else ""
@staticmethod
def _detail_with_source(detail: Optional[dict], source: str) -> Optional[dict]:
merged = dict(detail or {})
if source != "http":
merged["source"] = source
return merged or None
@staticmethod
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
if path.endswith(".md"):
await search_service.update_doc(project_id, path, Path(path).stem, content)
@staticmethod
async def _remove_markdown_index(project_id: int, path: str) -> None:
if path.endswith(".md"):
await search_service.remove_doc(project_id, path)
@staticmethod
async def _sync_markdown_index_for_move(
project_id: int,
old_path: str,
new_path: str,
new_file_path: Path,
) -> None:
if not old_path.endswith(".md") or not new_path.endswith(".md"):
return
try:
content = await storage_service.read_file(new_file_path)
await search_service.remove_doc(project_id, old_path)
await search_service.update_doc(project_id, new_path, Path(new_path).stem, content)
except Exception:
pass
2026-08-03 05:59:52 +00:00
@staticmethod
def _markdown_paths(file_path: Path, relative_path: str) -> List[str]:
if file_path.is_file():
return [relative_path] if relative_path.endswith(".md") else []
if not file_path.is_dir():
return []
return [
(Path(relative_path) / child.relative_to(file_path)).as_posix()
for child in file_path.rglob("*.md")
if child.is_file()
]
@classmethod
def _markdown_moves(
cls,
file_path: Path,
old_path: str,
new_path: str,
) -> List[Tuple[str, str]]:
if file_path.is_file():
if old_path.endswith(".md") or new_path.endswith(".md"):
return [(old_path, new_path)]
return []
return [
(path, (Path(new_path) / Path(path).relative_to(old_path)).as_posix())
for path in cls._markdown_paths(file_path, old_path)
]
2026-04-08 17:03:57 +00:00
async def save_file(
self,
db: AsyncSession,
project_id: int,
project: Project,
path: str,
content: str,
user: User,
*,
request: Optional[Request] = None,
source: str = "http",
) -> str:
file_path = storage_service.get_secure_path(project.storage_key, path)
await storage_service.write_file(file_path, content)
await self._update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source({"content_length": len(content)}, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="项目文档更新",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {self._actor_name(user)}{self._source_suffix(source)} 更新。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
2026-04-08 17:03:57 +00:00
await db.commit()
2026-08-03 05:59:52 +00:00
if path.endswith(".md"):
file_vector_sync_service.revectorize(project_id, path)
2026-04-08 17:03:57 +00:00
return "文件保存成功" if source == "http" else "文件更新成功"
async def operate_file(
self,
db: AsyncSession,
project_id: int,
project: Project,
action: str,
path: str,
user: User,
*,
new_path: Optional[str] = None,
content: Optional[str] = None,
request: Optional[Request] = None,
source: str = "http",
) -> str:
current_path = storage_service.get_secure_path(project.storage_key, path)
if action == "delete":
2026-08-03 05:59:52 +00:00
markdown_paths = self._markdown_paths(current_path, path)
2026-04-08 17:03:57 +00:00
await storage_service.delete_file(current_path)
2026-08-03 05:59:52 +00:00
for markdown_path in markdown_paths:
await self._remove_markdown_index(project_id, markdown_path)
2026-04-08 17:03:57 +00:00
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source(None, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="项目文档删除",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {self._actor_name(user)}{self._source_suffix(source)} 删除。"
),
category="project",
)
await db.commit()
2026-08-03 05:59:52 +00:00
for markdown_path in markdown_paths:
file_vector_sync_service.delete(project_id, markdown_path)
2026-04-08 17:03:57 +00:00
return "删除成功" if source == "http" else "文件删除成功"
if action in {"rename", "move"}:
if not new_path:
detail = "缺少新路径参数" if action == "rename" else "缺少目标路径参数"
raise HTTPException(status_code=400, detail=detail)
destination_path = storage_service.get_secure_path(project.storage_key, new_path)
2026-08-03 05:59:52 +00:00
markdown_moves = self._markdown_moves(current_path, path, new_path)
2026-04-08 17:03:57 +00:00
await storage_service.rename_file(current_path, destination_path)
2026-08-03 05:59:52 +00:00
for old_markdown_path, new_markdown_path in markdown_moves:
new_file_path = storage_service.get_secure_path(
project.storage_key,
new_markdown_path,
)
if old_markdown_path.endswith(".md") and new_markdown_path.endswith(".md"):
await self._sync_markdown_index_for_move(
project_id,
old_markdown_path,
new_markdown_path,
new_file_path,
)
elif old_markdown_path.endswith(".md"):
await self._remove_markdown_index(project_id, old_markdown_path)
elif new_markdown_path.endswith(".md"):
content = await storage_service.read_file(new_file_path)
await self._update_markdown_index(project_id, new_markdown_path, content)
2026-04-08 17:03:57 +00:00
operation_type = (
OperationType.RENAME_FILE if action == "rename" else OperationType.MOVE_FILE
)
notification_title = "项目文档重命名" if action == "rename" else "项目文档移动"
notification_action = "重命名为" if action == "rename" else "移动到"
success_message = "重命名成功" if action == "rename" else "移动成功"
mcp_message = "文件重命名成功" if action == "rename" else "文件移动成功"
await log_service.log_file_operation(
db=db,
operation_type=operation_type,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source({"new_path": new_path}, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title=notification_title,
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {self._actor_name(user)}{self._source_suffix(source)} {notification_action} [{new_path}]。"
),
category="project",
)
2026-04-08 17:03:57 +00:00
await db.commit()
2026-08-03 05:59:52 +00:00
for old_markdown_path, new_markdown_path in markdown_moves:
if old_markdown_path.endswith(".md") and new_markdown_path.endswith(".md"):
file_vector_sync_service.move(
project_id,
old_markdown_path,
new_markdown_path,
)
elif old_markdown_path.endswith(".md"):
file_vector_sync_service.delete(project_id, old_markdown_path)
elif new_markdown_path.endswith(".md"):
file_vector_sync_service.revectorize(project_id, new_markdown_path)
2026-04-08 17:03:57 +00:00
return success_message if source == "http" else mcp_message
if action == "create_dir":
await storage_service.create_directory(current_path)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_DIR,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source(None, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="创建新目录",
content=(
f"{self._actor_name(user)}{self._source_suffix(source)} "
f"在项目 [{project.name}] 中创建了新目录 [{path}]。"
),
category="project",
)
await db.commit()
return "目录创建成功"
if action == "create_file":
file_content = content or ""
await storage_service.write_file(current_path, file_content)
await self._update_markdown_index(project_id, path, file_content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source(
{"content_length": len(file_content)},
source,
),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="创建新文档",
content=(
f"{self._actor_name(user)}{self._source_suffix(source)} "
f"在项目 [{project.name}] 中创建了新文档 [{path}]。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
2026-04-08 17:03:57 +00:00
await db.commit()
2026-08-03 05:59:52 +00:00
if path.endswith(".md"):
file_vector_sync_service.revectorize(project_id, path)
2026-04-08 17:03:57 +00:00
return "文件创建成功"
raise HTTPException(status_code=400, detail="不支持的操作类型")
project_file_service = ProjectFileService()