""" 项目文件业务服务 """ from pathlib import Path from typing import Optional import asyncio 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 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)) 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) 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)) 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)) await db.commit() return "文件创建成功" raise HTTPException(status_code=400, detail="不支持的操作类型") project_file_service = ProjectFileService()