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

776 lines
24 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 pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
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,
ProjectTransfer,
)
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.services.project_export_service import project_export_service
from app.services.project_service import (
get_project_member,
get_project_or_404,
require_project_read_access,
require_project_roles,
serialize_project,
)
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 = [serialize_project(project) for project 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 = [serialize_project(project) for project 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:
projects_data.append(
serialize_project(
project,
owner_name=owner.username,
owner_nickname=owner.nickname,
user_role=member.role,
)
)
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)
):
"""获取项目详情"""
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
)
# 增加访问次数 (简单计数)
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)
):
"""更新项目信息"""
project = await get_project_or_404(db, project_id)
# 只有项目所有者可以更新
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.post("/{project_id}/transfer", response_model=dict)
async def transfer_project(
project_id: int,
transfer_in: ProjectTransfer,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""转移项目所有权"""
project = await get_project_or_404(db, project_id)
# 只有项目所有者可以转移
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="只有项目所有者可以转移项目")
# 检查新所有者是否存在
user_result = await db.execute(select(User).where(User.id == transfer_in.new_owner_id))
new_owner = user_result.scalar_one_or_none()
if not new_owner:
raise HTTPException(status_code=404, detail="目标用户不存在")
if new_owner.id == current_user.id:
raise HTTPException(status_code=400, detail="不能转移给自己")
# 1. 如果新所有者已经是成员,删除成员记录
existing_member = await get_project_member(db, project_id, new_owner.id)
if existing_member:
await db.delete(existing_member)
# 2. 将旧所有者添加为管理员成员
# 检查旧所有者是否已经在member表中理论上owner不在member表中但为了健壮性检查一下
if not await get_project_member(db, project_id, current_user.id):
old_owner_member = ProjectMember(
project_id=project_id,
user_id=current_user.id,
role="admin",
invited_by=current_user.id
)
db.add(old_owner_member)
# 3. 更新项目所有者
project.owner_id = new_owner.id
await db.commit()
# 记录日志
await log_service.log_project_operation(
db=db,
operation_type=OperationType.UPDATE_PROJECT,
project_id=project_id,
user=current_user,
detail={"action": "transfer_ownership", "new_owner": new_owner.username},
request=request,
)
# 发送通知
await notification_service.create_notification(
db=db,
user_id=new_owner.id,
title="项目所有权转让",
content=f"用户 {current_user.nickname or current_user.username} 将项目 [{project.name}] 的所有权转让给了您。",
category="project",
link=f"/projects/{project_id}/docs",
type="info"
)
return success_response(message="项目所有权转移成功")
@router.post("/{project_id}/export", response_model=dict)
async def start_project_export(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""启动项目整体导出任务。"""
project, _ = await require_project_read_access(db, project_id, current_user)
project_root = storage_service.get_secure_path(project.storage_key)
return success_response(
data=await project_export_service.start_export(
project_id,
current_user.id,
project.name,
project_root,
),
message="项目导出任务已开始",
)
@router.get("/{project_id}/export/{task_id}", response_model=dict)
async def get_project_export_status(
project_id: int,
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取项目导出任务状态。"""
await require_project_read_access(db, project_id, current_user)
task = project_export_service.get_owned_task_or_404(project_id, task_id, current_user.id)
return success_response(data=project_export_service.serialize_task(task))
@router.get("/{project_id}/export/{task_id}/download")
async def download_project_export(
project_id: int,
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""下载已完成的项目导出 ZIP。"""
await require_project_read_access(db, project_id, current_user)
task = project_export_service.get_owned_task_or_404(project_id, task_id, current_user.id)
if task["status"] == "failed":
raise HTTPException(status_code=400, detail=task.get("error") or "项目导出失败")
if task["status"] != "completed":
raise HTTPException(status_code=409, detail="导出尚未完成,请稍后再试")
file_path = task.get("file_path")
if not file_path or not Path(file_path).exists():
raise HTTPException(status_code=404, detail="导出文件不存在或已过期")
encoded_filename = quote(task["zip_filename"])
return FileResponse(
path=file_path,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
background=BackgroundTask(project_export_service.cleanup_task, task_id),
)
@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)
):
"""删除项目"""
project = await get_project_or_404(db, project_id)
# 只有项目所有者可以删除
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)
):
"""获取项目成员列表"""
await require_project_read_access(db, project_id, current_user)
# 查询成员列表并关联用户信息
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)
):
"""添加项目成员"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin"],
forbidden_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)
):
"""删除项目成员"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin"],
forbidden_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)
):
"""获取项目分享信息"""
project, role = await require_project_read_access(db, project_id, current_user)
is_owner = role == "owner"
# 构建分享链接
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)
):
"""更新分享设置(设置或取消访问密码)"""
project = await get_project_or_404(db, project_id)
# 只有项目所有者可以修改分享设置
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"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
forbidden_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"""
project, _ = await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
forbidden_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})")