nex_docus/backend/app/services/project_file_service.py

271 lines
9.7 KiB
Python
Raw Normal View History

2026-04-08 17:03:57 +00:00
"""
项目文件业务服务
"""
from pathlib import Path
from typing import Optional
import asyncio
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
from app.services.zvec_service import zvec_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
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",
)
if path.endswith(".md"):
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, content))
2026-04-08 17:03:57 +00:00
await db.commit()
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":
await storage_service.delete_file(current_path)
await self._remove_markdown_index(project_id, path)
if path.endswith(".md"):
await zvec_service.delete_vectors(db, project_id, 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()
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)
await storage_service.rename_file(current_path, destination_path)
await self._sync_markdown_index_for_move(project_id, path, new_path, destination_path)
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",
)
if path.endswith(".md") and new_path.endswith(".md"):
asyncio.create_task(zvec_service.sync_vector_move(db, project_id, path, new_path))
2026-04-08 17:03:57 +00:00
await db.commit()
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",
)
if path.endswith(".md"):
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, file_content))
2026-04-08 17:03:57 +00:00
await db.commit()
return "文件创建成功"
raise HTTPException(status_code=400, detail="不支持的操作类型")
project_file_service = ProjectFileService()