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

1009 lines
34 KiB
Python
Raw Normal View History

2025-12-20 11:18:59 +00:00
"""
项目管理相关 API
"""
2026-08-24 04:18:01 +00:00
import asyncio
2025-12-29 12:53:50 +00:00
from fastapi import APIRouter, Depends, HTTPException, Request
2025-12-20 11:18:59 +00:00
from sqlalchemy.ext.asyncio import AsyncSession
2026-08-17 16:55:44 +00:00
from sqlalchemy import delete, select, or_, func
2025-12-20 11:18:59 +00:00
from typing import List
import uuid
2026-05-09 02:45:30 +00:00
import secrets
2025-12-20 11:18:59 +00:00
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
2025-12-29 12:53:50 +00:00
from app.models.project import Project, ProjectMember
2026-01-05 10:50:29 +00:00
from app.models.git_repo import ProjectGitRepo
2026-05-09 02:45:30 +00:00
from app.models.share import ShareLink
2025-12-20 11:18:59 +00:00
from app.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectMemberAdd,
ProjectMemberUpdate,
ProjectMemberResponse,
2026-02-09 10:08:38 +00:00
ProjectTransfer,
2025-12-20 11:18:59 +00:00
)
from app.schemas.response import success_response
from app.services.storage import storage_service
2025-12-29 12:53:50 +00:00
from app.services.log_service import log_service
2026-01-05 10:50:29 +00:00
from app.services.git_service import git_service
2026-01-06 10:04:06 +00:00
from app.services.notification_service import notification_service
2025-12-29 12:53:50 +00:00
from app.core.enums import OperationType, ResourceType
2025-12-20 11:18:59 +00:00
router = APIRouter()
2026-05-09 02:45:30 +00:00
def generate_share_code() -> str:
"""生成公开分享码"""
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
2026-01-23 07:00:03 +00:00
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
2026-08-18 16:58:02 +00:00
def get_project_document_stats(storage_key: str):
"""
计算项目文档统计信息返回 (doc_count, last_activity_at)
last_activity 取项目内 .md/.pdf 文档的最新文件修改时间排除 _assets
反映文档被编辑导入Git 拉取等真实活动时间无需额外数据库字段
"""
from datetime import datetime
try:
project_path = storage_service.get_secure_path(storage_key)
if not project_path.exists():
return 0, None
assets_dir = project_path / "_assets"
count = 0
last_ts = None
for p in project_path.rglob("*"):
if not p.is_file():
continue
if p.suffix.lower() not in (".md", ".pdf"):
continue
if assets_dir in p.parents:
continue
count += 1
try:
ts = p.stat().st_mtime
except OSError:
continue
if last_ts is None or ts > last_ts:
last_ts = ts
last_activity = datetime.fromtimestamp(last_ts).isoformat() if last_ts else None
return count, last_activity
except Exception:
return 0, None
2026-08-17 16:55:44 +00:00
async def attach_member_counts(db: AsyncSession, projects) -> dict:
"""批量查询项目的参与人数量(含所有者),返回 {project_id: count}"""
if not projects:
return {}
result = await db.execute(
select(ProjectMember.project_id, func.count(ProjectMember.id))
.where(ProjectMember.project_id.in_([p.id for p in projects]))
.group_by(ProjectMember.project_id)
)
return dict(result.all())
2026-08-24 04:18:01 +00:00
async def attach_git_repo_counts(db: AsyncSession, projects) -> dict:
"""批量查询项目已配置的Git仓库数量返回 {project_id: count}"""
if not projects:
return {}
result = await db.execute(
select(ProjectGitRepo.project_id, func.count(ProjectGitRepo.id))
.where(ProjectGitRepo.project_id.in_([p.id for p in projects]))
.group_by(ProjectGitRepo.project_id)
)
return dict(result.all())
2025-12-20 11:18:59 +00:00
@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
2026-08-17 16:55:44 +00:00
member_counts = await attach_member_counts(db, all_projects)
2026-08-24 04:18:01 +00:00
git_repo_counts = await attach_git_repo_counts(db, all_projects)
2026-01-23 07:00:03 +00:00
projects_data = []
for p in all_projects:
p_dict = ProjectResponse.from_orm(p).dict()
2026-08-18 16:58:02 +00:00
doc_count, last_activity_at = get_project_document_stats(p.storage_key)
p_dict['doc_count'] = doc_count
p_dict['last_activity_at'] = last_activity_at
2026-08-17 16:55:44 +00:00
p_dict['member_count'] = member_counts.get(p.id, 0)
2026-08-24 04:18:01 +00:00
p_dict['git_repo_count'] = git_repo_counts.get(p.id, 0)
2026-01-23 07:00:03 +00:00
projects_data.append(p_dict)
2025-12-20 11:18:59 +00:00
return success_response(data=projects_data)
2025-12-29 12:53:50 +00:00
@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()
2026-08-17 16:55:44 +00:00
member_counts = await attach_member_counts(db, projects)
2026-08-24 04:18:01 +00:00
git_repo_counts = await attach_git_repo_counts(db, projects)
2026-01-23 07:00:03 +00:00
projects_data = []
for p in projects:
p_dict = ProjectResponse.from_orm(p).dict()
2026-08-18 16:58:02 +00:00
doc_count, last_activity_at = get_project_document_stats(p.storage_key)
p_dict['doc_count'] = doc_count
p_dict['last_activity_at'] = last_activity_at
2026-08-17 16:55:44 +00:00
p_dict['member_count'] = member_counts.get(p.id, 0)
2026-08-24 04:18:01 +00:00
p_dict['git_repo_count'] = git_repo_counts.get(p.id, 0)
2026-01-23 07:00:03 +00:00
projects_data.append(p_dict)
2025-12-29 12:53:50 +00:00
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()
2026-08-17 16:55:44 +00:00
projects = [project for project, _, _ in projects_with_info]
member_counts = await attach_member_counts(db, projects)
2026-08-24 04:18:01 +00:00
git_repo_counts = await attach_git_repo_counts(db, projects)
2026-08-17 16:55:44 +00:00
2025-12-29 12:53:50 +00:00
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 # 添加用户角色
2026-08-18 16:58:02 +00:00
doc_count, last_activity_at = get_project_document_stats(project.storage_key)
project_dict['doc_count'] = doc_count
project_dict['last_activity_at'] = last_activity_at
2026-08-17 16:55:44 +00:00
project_dict['member_count'] = member_counts.get(project.id, 0)
2026-08-24 04:18:01 +00:00
project_dict['git_repo_count'] = git_repo_counts.get(project.id, 0)
2025-12-29 12:53:50 +00:00
projects_data.append(project_dict)
return success_response(data=projects_data)
2025-12-20 11:18:59 +00:00
@router.post("/", response_model=dict)
async def create_project(
project_in: ProjectCreate,
2025-12-29 12:53:50 +00:00
request: Request,
2025-12-20 11:18:59 +00:00
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,
2025-12-29 12:53:50 +00:00
role="admin",
2025-12-20 11:18:59 +00:00
)
db.add(db_member)
await db.commit()
2025-12-29 12:53:50 +00:00
# 记录操作日志
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,
)
2025-12-20 11:18:59 +00:00
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="无权访问该项目")
2026-01-23 07:00:03 +00:00
# 增加访问次数 (简单计数)
2025-12-20 11:18:59 +00:00
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,
2025-12-29 12:53:50 +00:00
request: Request,
2025-12-20 11:18:59 +00:00
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="无权修改该项目")
2026-05-09 02:45:30 +00:00
old_is_public = project.is_public
2025-12-20 11:18:59 +00:00
# 更新字段
update_data = project_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
2026-05-09 02:45:30 +00:00
if "is_public" in update_data:
next_is_public = int(update_data.get("is_public") or 0)
if next_is_public == 0:
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
elif next_is_public == 1:
if old_is_public != 1:
# 重新公开项目时丢弃历史公开链接,生成新的项目分享链接。
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
)
)
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1,
)
)
share = share_result.scalar_one_or_none()
if not share:
db.add(ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id,
))
2025-12-20 11:18:59 +00:00
await db.commit()
await db.refresh(project)
2025-12-29 12:53:50 +00:00
# 记录操作日志
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,
)
2025-12-20 11:18:59 +00:00
project_data = ProjectResponse.from_orm(project)
return success_response(data=project_data.dict(), message="项目更新成功")
2026-02-09 10:08:38 +00:00
@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)
):
"""转移项目所有权"""
# 查询项目
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="只有项目所有者可以转移项目")
# 检查新所有者是否存在
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. 如果新所有者已经是成员,删除成员记录
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == new_owner.id
)
)
existing_member = member_result.scalar_one_or_none()
if existing_member:
await db.delete(existing_member)
# 2. 将旧所有者添加为管理员成员
# 检查旧所有者是否已经在member表中理论上owner不在member表中但为了健壮性检查一下
old_member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
if not old_member_result.scalar_one_or_none():
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="项目所有权转移成功")
2025-12-20 11:18:59 +00:00
@router.delete("/{project_id}", response_model=dict)
async def delete_project(
project_id: int,
2025-12-29 12:53:50 +00:00
request: Request,
2025-12-20 11:18:59 +00:00
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
2025-12-29 12:53:50 +00:00
"""删除项目"""
2025-12-20 11:18:59 +00:00
# 查询项目
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="无权删除该项目")
2025-12-29 12:53:50 +00:00
# 检查项目目录下是否有文件排除_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)
2025-12-20 11:18:59 +00:00
await db.commit()
2025-12-29 12:53:50 +00:00
# 记录操作日志
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="项目已删除")
2025-12-20 11:18:59 +00:00
@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="无权访问该项目")
2025-12-29 12:53:50 +00:00
# 查询成员列表并关联用户信息
2025-12-20 11:18:59 +00:00
members_result = await db.execute(
2025-12-29 12:53:50 +00:00
select(ProjectMember, User)
.join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id)
2025-12-20 11:18:59 +00:00
)
2025-12-29 12:53:50 +00:00
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,
})
2025-12-20 11:18:59 +00:00
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,
2025-12-29 12:53:50 +00:00
request: Request,
2025-12-20 11:18:59 +00:00
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,
2025-12-29 12:53:50 +00:00
ProjectMember.role == "admin"
2025-12-20 11:18:59 +00:00
)
)
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)
2026-01-06 10:04:06 +00:00
# 发送通知给被邀请人
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()
2025-12-29 12:53:50 +00:00
# 记录操作日志
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,
)
2025-12-20 11:18:59 +00:00
member_data = ProjectMemberResponse.from_orm(db_member)
return success_response(data=member_data.dict(), message="成员添加成功")
2025-12-29 12:53:50 +00:00
@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="成员删除成功")
2026-08-24 04:18:01 +00:00
def _resolve_sync_sub_path(
sync_scope: str = None, sub_path: str = None, repo_sync_path: str = ""
) -> str:
"""Resolve the configured default scope separately from a one-off operation scope."""
if sync_scope is None:
return (sub_path if sub_path is not None else repo_sync_path or "").strip().strip("/")
if sync_scope == "all":
return ""
if sync_scope != "path":
raise ValueError("同步范围必须是 all 或 path")
resolved = (sub_path or "").strip().strip("/")
if not resolved:
raise ValueError("请选择要同步的目录")
return resolved
2026-01-05 10:50:29 +00:00
@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,
2026-08-24 04:18:01 +00:00
sync_scope: str = None,
sub_path: str = None,
2026-01-05 10:50:29 +00:00
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
2026-08-24 04:18:01 +00:00
"""执行 Git Pullsub_path 非空时仅同步指定目录)"""
2026-01-05 10:50:29 +00:00
# 查询项目
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]
2026-08-24 04:18:01 +00:00
try:
effective_sub_path = _resolve_sync_sub_path(sync_scope, sub_path, target_repo.sync_path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
2026-01-05 10:50:29 +00:00
project_path = storage_service.get_secure_path(project.storage_key)
2026-08-24 04:18:01 +00:00
try:
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,
sub_path=effective_sub_path
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
2026-01-05 10:50:29 +00:00
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,
2026-08-24 04:18:01 +00:00
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force, "sub_path": effective_sub_path or None},
2026-01-05 10:50:29 +00:00
request=request,
)
2026-01-06 10:04:06 +00:00
# 发送通知给其他成员
2026-08-24 04:18:01 +00:00
scope_desc = f",同步目录 [{effective_sub_path}]" if effective_sub_path else ""
2026-01-06 10:04:06 +00:00
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"项目文档已通过 Git 同步",
2026-08-24 04:18:01 +00:00
content=f"{current_user.nickname or current_user.username} 执行了 Git Pull项目 [{project.name}] 的内容已从远程仓库同步更新{scope_desc}",
2026-01-06 10:04:06 +00:00
link=f"/projects/{project_id}/docs",
category="project"
)
await db.commit()
2026-01-05 10:50:29 +00:00
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,
2026-08-24 04:18:01 +00:00
sync_scope: str = None,
sub_path: str = None,
2026-01-05 10:50:29 +00:00
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
2026-08-24 04:18:01 +00:00
"""执行 Git Pushsub_path 非空时仅推送指定目录的变更)"""
2026-01-05 10:50:29 +00:00
# 查询项目
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]
2026-08-24 04:18:01 +00:00
try:
effective_sub_path = _resolve_sync_sub_path(sync_scope, sub_path, target_repo.sync_path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
2026-01-05 10:50:29 +00:00
project_path = storage_service.get_secure_path(project.storage_key)
2026-08-24 04:18:01 +00:00
try:
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,
sub_path=effective_sub_path
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
2026-01-05 10:50:29 +00:00
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,
2026-08-24 04:18:01 +00:00
detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force, "sub_path": effective_sub_path or None},
2026-01-05 10:50:29 +00:00
request=request,
)
return success_response(message=f"Git Push 成功 ({target_repo.name})")
2026-08-24 04:18:01 +00:00
def _build_remote_dir_tree(paths):
"""Build TreeSelect options where each node's value is its full repository path."""
root = {"children": {}}
for path in paths:
parts = path.split("/")[:-1]
node = root
for part in parts:
node = node["children"].setdefault(part, {"children": {}})
def to_options(node, prefix=""):
options = []
for name in sorted(node["children"]):
child = node["children"][name]
value = f"{prefix}/{name}" if prefix else name
options.append({
"title": name,
"value": value,
"children": to_options(child, value),
})
return options
return to_options(root)
@router.post("/{project_id}/git/directories", response_model=dict)
async def list_git_directories(
project_id: int,
request: Request,
repo_id: int = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""列出指定 Git 仓库远端目录结构(用于选择同步目录)"""
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仓库")
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)
try:
# Git/网络操作是同步阻塞调用,不能直接在 async 事件循环中执行。
success, tree_text = await asyncio.to_thread(
git_service.list_remote_tree,
project_path,
target_repo.repo_url,
target_repo.branch or "main",
target_repo.username,
target_repo.token,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not success:
raise HTTPException(status_code=500, detail=f"获取远端目录失败: {tree_text}")
paths = [line for line in tree_text.splitlines() if line.strip()]
return success_response(data={"directories": _build_remote_dir_tree(paths), "repo": target_repo.name})