nex_docus/backend/app/api/v1/projects.py

710 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
项目管理相关 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.core.enums import OperationType, ResourceType
router = APIRouter()
@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 = [ProjectResponse.from_orm(p).dict() for p in all_projects]
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 = [ProjectResponse.from_orm(p).dict() for p in projects]
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 # 添加用户角色
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 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,
)
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})")