""" 项目管理相关 API """ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from typing import List import uuid from app.core.database import get_db from app.core.deps import get_current_user from app.models.user import User from app.models.project import Project, ProjectMember from app.models.git_repo import ProjectGitRepo from app.schemas.project import ( ProjectCreate, ProjectUpdate, ProjectResponse, ProjectMemberAdd, ProjectMemberUpdate, ProjectMemberResponse, ProjectShareSettings, ProjectShareInfo, ) from app.schemas.response import success_response from app.services.storage import storage_service from app.services.log_service import log_service from app.services.git_service import git_service from app.services.notification_service import notification_service from app.core.enums import OperationType, ResourceType router = APIRouter() def get_document_count(storage_key: str) -> int: """计算项目中的文档数量(.md 和 .pdf)""" try: project_path = storage_service.get_secure_path(storage_key) if not project_path.exists(): return 0 md_count = len(list(project_path.rglob("*.md"))) pdf_count = len(list(project_path.rglob("*.pdf"))) # 排除 _assets 目录下的文件 assets_md = len(list((project_path / "_assets").rglob("*.md"))) if (project_path / "_assets").exists() else 0 assets_pdf = len(list((project_path / "_assets").rglob("*.pdf"))) if (project_path / "_assets").exists() else 0 return md_count + pdf_count - assets_md - assets_pdf except Exception: return 0 @router.get("/", response_model=dict) async def get_my_projects( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """获取我的项目列表(包括创建的和协作的)""" # 查询我创建的项目 owned_result = await db.execute( select(Project).where(Project.owner_id == current_user.id, Project.status == 1) ) owned_projects = owned_result.scalars().all() # 查询我协作的项目 member_result = await db.execute( select(Project) .join(ProjectMember, ProjectMember.project_id == Project.id) .where( ProjectMember.user_id == current_user.id, Project.owner_id != current_user.id, Project.status == 1 ) ) member_projects = member_result.scalars().all() # 合并结果 all_projects = owned_projects + member_projects projects_data = [] for p in all_projects: p_dict = ProjectResponse.from_orm(p).dict() p_dict['doc_count'] = get_document_count(p.storage_key) projects_data.append(p_dict) return success_response(data=projects_data) @router.get("/my", response_model=dict) async def get_owned_projects( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """获取我创建的项目列表""" result = await db.execute( select(Project).where(Project.owner_id == current_user.id, Project.status == 1) ) projects = result.scalars().all() projects_data = [] for p in projects: p_dict = ProjectResponse.from_orm(p).dict() p_dict['doc_count'] = get_document_count(p.storage_key) projects_data.append(p_dict) return success_response(data=projects_data) @router.get("/shared", response_model=dict) async def get_shared_projects( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """获取我参与的项目列表(不包括我创建的)""" result = await db.execute( select(Project, User, ProjectMember) .join(ProjectMember, ProjectMember.project_id == Project.id) .join(User, User.id == Project.owner_id) .where( ProjectMember.user_id == current_user.id, Project.owner_id != current_user.id, Project.status == 1 ) ) projects_with_info = result.all() projects_data = [] for project, owner, member in projects_with_info: project_dict = ProjectResponse.from_orm(project).dict() project_dict['owner_name'] = owner.username project_dict['owner_nickname'] = owner.nickname project_dict['user_role'] = member.role # 添加用户角色 project_dict['doc_count'] = get_document_count(project.storage_key) projects_data.append(project_dict) return success_response(data=projects_data) @router.post("/", response_model=dict) async def create_project( project_in: ProjectCreate, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """创建新项目""" # 生成 UUID 作为存储键 storage_key = str(uuid.uuid4()) # 创建项目记录 db_project = Project( name=project_in.name, description=project_in.description, storage_key=storage_key, owner_id=current_user.id, is_public=project_in.is_public, status=1, ) db.add(db_project) await db.commit() await db.refresh(db_project) # 创建物理文件夹结构 try: storage_service.create_project_structure(storage_key) except Exception as e: # 如果文件夹创建失败,回滚数据库记录 await db.delete(db_project) await db.commit() raise HTTPException(status_code=500, detail=f"项目文件夹创建失败: {str(e)}") # 添加项目所有者为管理员成员 db_member = ProjectMember( project_id=db_project.id, user_id=current_user.id, role="admin", ) db.add(db_member) await db.commit() # 记录操作日志 await log_service.log_project_operation( db=db, operation_type=OperationType.CREATE_PROJECT, project_id=db_project.id, user=current_user, detail={"project_name": project_in.name}, request=request, ) project_data = ProjectResponse.from_orm(db_project) return success_response(data=project_data.dict(), message="项目创建成功") @router.get("/{project_id}", response_model=dict) async def get_project( project_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """获取项目详情""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 检查权限(项目所有者或成员可访问) if project.owner_id != current_user.id: member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id ) ) member = member_result.scalar_one_or_none() if not member and project.is_public != 1: raise HTTPException(status_code=403, detail="无权访问该项目") # 增加访问次数 (简单计数) project.visit_count += 1 await db.commit() project_data = ProjectResponse.from_orm(project) return success_response(data=project_data.dict()) @router.put("/{project_id}", response_model=dict) async def update_project( project_id: int, project_in: ProjectUpdate, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """更新项目信息""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 只有项目所有者可以更新 if project.owner_id != current_user.id: raise HTTPException(status_code=403, detail="无权修改该项目") # 更新字段 update_data = project_in.dict(exclude_unset=True) for field, value in update_data.items(): setattr(project, field, value) await db.commit() await db.refresh(project) # 记录操作日志 await log_service.log_project_operation( db=db, operation_type=OperationType.UPDATE_PROJECT, project_id=project_id, user=current_user, detail={"updated_fields": list(update_data.keys())}, request=request, ) project_data = ProjectResponse.from_orm(project) return success_response(data=project_data.dict(), message="项目更新成功") @router.delete("/{project_id}", response_model=dict) async def delete_project( project_id: int, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """删除项目""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 只有项目所有者可以删除 if project.owner_id != current_user.id: raise HTTPException(status_code=403, detail="无权删除该项目") # 检查项目目录下是否有文件(排除_assets目录和隐藏文件) project_root = storage_service.get_secure_path(project.storage_key) has_files = False if project_root.exists() and project_root.is_dir(): for item in project_root.iterdir(): # 跳过 _assets 目录和以.开头的隐藏文件(如.DS_Store) if item.name == "_assets" or item.name.startswith("."): continue has_files = True break if has_files: raise HTTPException( status_code=400, detail="项目目录下存在文件,无法删除。请先清空项目文件。" ) # 删除物理目录 if project_root.exists(): import shutil try: shutil.rmtree(project_root) except Exception as e: raise HTTPException( status_code=500, detail=f"删除项目目录失败: {str(e)}" ) project_name = project.name # 删除数据库记录 await db.delete(project) await db.commit() # 记录操作日志 await log_service.log_project_operation( db=db, operation_type=OperationType.DELETE_PROJECT, project_id=project_id, user=current_user, detail={"project_name": project_name}, request=request, ) return success_response(message="项目已删除") @router.get("/{project_id}/members", response_model=dict) async def get_project_members( project_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """获取项目成员列表""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 检查权限 if project.owner_id != current_user.id: member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=403, detail="无权访问该项目") # 查询成员列表并关联用户信息 members_result = await db.execute( select(ProjectMember, User) .join(User, ProjectMember.user_id == User.id) .where(ProjectMember.project_id == project_id) ) members_with_users = members_result.all() # 构建返回数据,包含用户名信息 members_data = [] for member, user in members_with_users: members_data.append({ "id": member.id, "project_id": member.project_id, "user_id": member.user_id, "role": member.role, "joined_at": member.joined_at.isoformat() if member.joined_at else None, "username": user.username, "nickname": user.nickname, }) return success_response(data=members_data) @router.post("/{project_id}/members", response_model=dict) async def add_project_member( project_id: int, member_in: ProjectMemberAdd, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """添加项目成员""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 只有项目所有者和管理员可以添加成员 if project.owner_id != current_user.id: member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id, ProjectMember.role == "admin" ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=403, detail="无权添加成员") # 检查用户是否已是成员 existing_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == member_in.user_id ) ) existing_member = existing_result.scalar_one_or_none() if existing_member: raise HTTPException(status_code=400, detail="用户已是项目成员") # 添加成员 db_member = ProjectMember( project_id=project_id, user_id=member_in.user_id, role=member_in.role, invited_by=current_user.id, ) db.add(db_member) await db.commit() await db.refresh(db_member) # 发送通知给被邀请人 await notification_service.create_notification( db=db, user_id=member_in.user_id, title=f"新项目协作邀请", content=f"用户 {current_user.nickname or current_user.username} 邀请您参与项目 [{project.name}] 的协作。", category="collaboration", link=f"/projects/{project_id}/docs", type="info" ) await db.commit() # 记录操作日志 await log_service.log_member_operation( db=db, operation_type=OperationType.ADD_MEMBER, project_id=project_id, target_user_id=member_in.user_id, user=current_user, detail={"role": member_in.role}, request=request, ) member_data = ProjectMemberResponse.from_orm(db_member) return success_response(data=member_data.dict(), message="成员添加成功") @router.delete("/{project_id}/members/{user_id}", response_model=dict) async def remove_project_member( project_id: int, user_id: int, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """删除项目成员""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 只有项目所有者和管理员可以删除成员 if project.owner_id != current_user.id: member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id, ProjectMember.role == "admin" ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=403, detail="无权删除成员") # 不能删除项目所有者 if user_id == project.owner_id: raise HTTPException(status_code=400, detail="不能删除项目所有者") # 查询要删除的成员 member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == user_id ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=404, detail="成员不存在") # 删除成员 await db.delete(member) await db.commit() # 记录操作日志 await log_service.log_member_operation( db=db, operation_type=OperationType.REMOVE_MEMBER, project_id=project_id, target_user_id=user_id, user=current_user, request=request, ) return success_response(message="成员删除成功") @router.get("/{project_id}/share", response_model=dict) async def get_project_share_info( project_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """获取项目分享信息""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 检查是否是项目所有者或成员 is_owner = project.owner_id == current_user.id if not is_owner: # 检查是否是项目成员 member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=403, detail="无权访问该项目") # 构建分享链接 share_url = f"/preview/{project_id}" # 只有项目所有者可以看到实际密码,成员只能知道是否设置了密码 share_info = ProjectShareInfo( share_url=share_url, has_password=bool(project.access_pass), access_pass=project.access_pass if is_owner else None ) return success_response(data=share_info.dict()) @router.post("/{project_id}/share/settings", response_model=dict) async def update_share_settings( project_id: int, settings: ProjectShareSettings, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """更新分享设置(设置或取消访问密码)""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 只有项目所有者可以修改分享设置 if project.owner_id != current_user.id: raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置") # 更新访问密码 project.access_pass = settings.access_pass await db.commit() # 记录操作日志 await log_service.log_operation( db=db, operation_type=OperationType.UPDATE_SHARE_SETTINGS, resource_type=ResourceType.SHARE, user=current_user, resource_id=project_id, detail={ "has_password": bool(settings.access_pass), "project_name": project.name, }, request=request, ) message = "访问密码已取消" if not settings.access_pass else "访问密码已设置" return success_response(message=message) @router.post("/{project_id}/git/pull", response_model=dict) async def git_pull( project_id: int, request: Request, repo_id: int = None, force: bool = False, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """执行 Git Pull""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 权限检查:需要是所有者或管理员/编辑者 if project.owner_id != current_user.id: member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id, ProjectMember.role.in_(['admin', 'editor']) ) ) if not member_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="无权执行Git操作") # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) if repo_id: query = query.where(ProjectGitRepo.id == repo_id) else: # 优先使用默认仓库,然后按创建时间倒序(最新的) query = query.order_by(ProjectGitRepo.is_default.desc(), ProjectGitRepo.created_at.desc()) result = await db.execute(query) repos = result.scalars().all() if not repos: raise HTTPException(status_code=400, detail="未配置Git仓库") target_repo = repos[0] project_path = storage_service.get_secure_path(project.storage_key) success, msg = await git_service.pull( project_path=project_path, repo_url=target_repo.repo_url, branch=target_repo.branch or "main", username=target_repo.username, token=target_repo.token, force=force ) if not success: raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}") # 记录日志 await log_service.log_project_operation( db=db, operation_type=OperationType.GIT_PULL, project_id=project_id, user=current_user, detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force}, request=request, ) # 发送通知给其他成员 await notification_service.notify_project_members( db=db, project_id=project_id, exclude_user_id=current_user.id, title=f"项目文档已通过 Git 同步", content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新。", link=f"/projects/{project_id}/docs", category="project" ) await db.commit() return success_response(message=f"Git Pull 成功 ({target_repo.name})") @router.post("/{project_id}/git/push", response_model=dict) async def git_push( project_id: int, request: Request, repo_id: int = None, force: bool = False, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """执行 Git Push""" # 查询项目 result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") # 权限检查:需要是所有者或管理员/编辑者 if project.owner_id != current_user.id: member_result = await db.execute( select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id, ProjectMember.role.in_(['admin', 'editor']) ) ) if not member_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="无权执行Git操作") # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) if repo_id: query = query.where(ProjectGitRepo.id == repo_id) else: # 优先使用默认仓库 query = query.order_by(ProjectGitRepo.is_default.desc(), ProjectGitRepo.created_at.desc()) result = await db.execute(query) repos = result.scalars().all() if not repos: raise HTTPException(status_code=400, detail="未配置Git仓库") target_repo = repos[0] project_path = storage_service.get_secure_path(project.storage_key) success, msg = await git_service.push( project_path=project_path, repo_url=target_repo.repo_url, branch=target_repo.branch or "main", username=target_repo.username, token=target_repo.token, force=force ) if not success: raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}") # 记录日志 await log_service.log_project_operation( db=db, operation_type=OperationType.GIT_PUSH, project_id=project_id, user=current_user, detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force}, request=request, ) return success_response(message=f"Git Push 成功 ({target_repo.name})")