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

563 lines
17 KiB
Python
Raw Normal View History

2025-12-20 11:18:59 +00:00
"""
文件系统操作相关 API
"""
2025-12-29 12:53:50 +00:00
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
2025-12-20 11:18:59 +00:00
from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
2026-07-24 07:37:22 +00:00
from sqlalchemy import select
2025-12-20 11:18:59 +00:00
from typing import List
import os
import zipfile
import io
2025-12-23 11:10:38 +00:00
import mimetypes
2025-12-29 12:53:50 +00:00
import json
2025-12-20 11:18:59 +00:00
from pathlib import Path
2026-04-08 17:03:57 +00:00
from urllib.parse import quote
2025-12-20 11:18:59 +00:00
from app.core.database import get_db
from app.core.deps import get_current_user, get_user_from_token_or_query
from app.models.user import User
2025-12-29 12:53:50 +00:00
from app.models.log import OperationLog
2026-05-09 02:45:30 +00:00
from app.models.share import ShareLink
2026-07-24 07:37:22 +00:00
from app.models.project import Project, ProjectMember
2025-12-20 11:18:59 +00:00
from app.schemas.file import (
FileTreeNode,
FileSaveRequest,
FileOperateRequest,
FileUploadResponse,
)
from app.schemas.response import success_response
2026-04-08 17:03:57 +00:00
from app.services.project_file_service import project_file_service
2025-12-20 11:18:59 +00:00
from app.services.storage import storage_service
2025-12-29 12:53:50 +00:00
from app.services.log_service import log_service
2026-04-08 17:03:57 +00:00
from app.services.project_service import (
get_project_or_404,
require_project_read_access,
require_project_write_access,
)
2025-12-29 12:53:50 +00:00
from app.core.enums import OperationType
2025-12-20 11:18:59 +00:00
router = APIRouter()
async def check_project_access(
project_id: int,
current_user: User,
db: AsyncSession,
require_write: bool = False
):
"""检查项目访问权限"""
# 查询项目
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:
return project
# 检查是否是项目成员
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:
if project.is_public == 1 and not require_write:
return project
raise HTTPException(status_code=403, detail="无权访问该项目")
# 如果需要写权限,检查成员角色
if require_write and member.role == "viewer":
raise HTTPException(status_code=403, detail="无写入权限")
return project
2026-05-09 02:45:30 +00:00
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
"""为文件树节点补充分享状态"""
for node in tree:
node.is_shared = bool(node.isLeaf and node.key in shared_paths)
if node.children:
annotate_shared_files(node.children, shared_paths)
2025-12-20 11:18:59 +00:00
@router.get("/{project_id}/tree", response_model=dict)
async def get_project_tree(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取项目目录树"""
2026-04-08 17:03:57 +00:00
project, user_role = await require_project_read_access(db, project_id, current_user)
2025-12-20 11:18:59 +00:00
# 获取项目根目录
project_root = storage_service.get_secure_path(project.storage_key)
# 生成目录树
tree = storage_service.generate_tree(project_root)
2026-05-09 02:45:30 +00:00
share_result = await db.execute(
select(ShareLink.file_path).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.status == 1,
)
)
shared_paths = {
file_path
for file_path in share_result.scalars().all()
if file_path
}
annotate_shared_files(tree, shared_paths)
2025-12-29 12:53:50 +00:00
# 获取当前用户角色
user_role = "owner" # 默认是所有者
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 member:
user_role = member.role
2026-01-13 13:21:47 +00:00
return success_response(data={
"tree": tree,
"user_role": user_role,
"project_name": project.name,
"project_description": project.description
})
2025-12-20 11:18:59 +00:00
@router.get("/{project_id}/file", response_model=dict)
async def get_file_content(
project_id: int,
path: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""获取文件内容"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(db, project_id, current_user)
2025-12-20 11:18:59 +00:00
# 获取文件路径
file_path = storage_service.get_secure_path(project.storage_key, path)
# 读取文件内容
content = await storage_service.read_file(file_path)
return success_response(data={"path": path, "content": content})
@router.post("/{project_id}/file", response_model=dict)
async def save_file(
project_id: int,
file_data: FileSaveRequest,
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)
):
"""保存文件内容"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_write_access(db, project_id, current_user)
message = await project_file_service.save_file(
db,
project_id,
project,
file_data.path,
file_data.content,
current_user,
2025-12-29 12:53:50 +00:00
request=request,
)
2026-04-08 17:03:57 +00:00
return success_response(message=message)
2025-12-20 11:18:59 +00:00
@router.post("/{project_id}/file/operate", response_model=dict)
async def operate_file(
project_id: int,
operation: FileOperateRequest,
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)
):
"""文件操作(重命名、删除、创建目录、创建文件、移动)"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_write_access(db, project_id, current_user)
message = await project_file_service.operate_file(
db,
project_id,
project,
operation.action,
operation.path,
current_user,
new_path=operation.new_path,
content=operation.content,
request=request,
)
return success_response(message=message)
2025-12-20 11:18:59 +00:00
@router.post("/{project_id}/upload", response_model=dict)
async def upload_file(
project_id: int,
file: UploadFile = File(...),
subfolder: str = "images",
2025-12-29 12:53:50 +00:00
request: Request = None,
2025-12-20 11:18:59 +00:00
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""上传文件(图片/附件)"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_write_access(db, project_id, current_user)
2025-12-20 11:18:59 +00:00
# 上传文件
file_info = await storage_service.upload_file(
project.storage_key,
file,
subfolder=subfolder
)
# 构建访问 URL
file_info["url"] = f"/api/v1/files/{project_id}/assets/{subfolder}/{file_info['filename']}"
2025-12-29 12:53:50 +00:00
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.UPLOAD_IMAGE,
project_id=project_id,
file_path=f"_assets/{subfolder}/{file_info['filename']}",
user=current_user,
detail={
"original_filename": file.filename,
"file_size": file_info.get("size", 0),
},
request=request,
)
2025-12-20 11:18:59 +00:00
return success_response(data=file_info, message="文件上传成功")
2025-12-31 05:44:03 +00:00
@router.post("/{project_id}/upload-document", response_model=dict)
async def upload_document(
project_id: int,
file: UploadFile = File(...),
target_dir: str = "",
request: Request = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
上传文档文件PDF等到项目目录
"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_write_access(db, project_id, current_user)
2025-12-31 05:44:03 +00:00
# 只允许PDF文件
allowed_extensions = [".pdf"]
# 上传文件
file_info = await storage_service.upload_document(
project.storage_key,
file,
target_dir=target_dir,
allowed_extensions=allowed_extensions
)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.UPLOAD_IMAGE, # 复用上传图片的日志类型
project_id=project_id,
file_path=file_info['path'],
user=current_user,
detail={
"original_filename": file_info['original_filename'],
"size": file_info['size'],
"target_dir": target_dir
},
request=request,
)
return success_response(data=file_info, message="文档上传成功")
@router.get("/{project_id}/document/{path:path}")
async def get_document_file(
project_id: int,
path: str,
2026-01-01 14:41:10 +00:00
request: Request,
2025-12-31 05:44:03 +00:00
current_user: User = Depends(get_user_from_token_or_query),
db: AsyncSession = Depends(get_db)
):
"""
2026-01-01 14:41:10 +00:00
获取文档文件PDF等- 支持 HTTP Range 请求
2025-12-31 05:44:03 +00:00
"""
2026-01-01 14:41:10 +00:00
import re
import aiofiles
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(db, project_id, current_user)
2025-12-31 05:44:03 +00:00
# 获取文件路径
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
# 判断文件类型
content_type, _ = mimetypes.guess_type(str(file_path))
if not content_type:
content_type = "application/octet-stream"
2026-01-01 14:41:10 +00:00
# 获取文件大小
file_size = file_path.stat().st_size
# 检查是否有 Range 请求头
range_header = request.headers.get("range")
if range_header:
# 解析 Range 头: bytes=start-end
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
if range_match:
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
end = min(end, file_size - 1)
# 读取指定范围的文件内容
async def file_iterator():
async with aiofiles.open(file_path, 'rb') as f:
await f.seek(start)
chunk_size = 1024 * 1024 # 1MB chunks
remaining = end - start + 1
while remaining > 0:
chunk = await f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
}
return StreamingResponse(
file_iterator(),
status_code=206, # Partial Content
headers=headers,
media_type=content_type
)
# 正常请求,返回完整文件(添加 Accept-Ranges 头)
2025-12-31 05:44:03 +00:00
return FileResponse(
path=str(file_path),
media_type=content_type,
2026-01-01 14:41:10 +00:00
filename=file_path.name,
headers={"Accept-Ranges": "bytes"}
2025-12-31 05:44:03 +00:00
)
2026-01-01 14:41:10 +00:00
2025-12-20 11:18:59 +00:00
@router.get("/{project_id}/assets/{subfolder}/{filename}")
async def get_asset_file(
project_id: int,
subfolder: str,
filename: str,
db: AsyncSession = Depends(get_db)
):
"""获取资源文件(公开访问,支持分享)"""
2026-04-08 17:03:57 +00:00
project = await get_project_or_404(db, project_id)
2025-12-20 11:18:59 +00:00
# 获取文件路径
asset_path = f"_assets/{subfolder}/{filename}"
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
2025-12-23 11:10:38 +00:00
# 根据文件扩展名确定 MIME 类型
mime_type, _ = mimetypes.guess_type(filename)
if mime_type is None:
mime_type = "application/octet-stream"
2025-12-20 11:18:59 +00:00
# 返回文件(流式响应)
return FileResponse(
path=file_path,
filename=filename,
2025-12-23 11:10:38 +00:00
media_type=mime_type
2025-12-20 11:18:59 +00:00
)
@router.post("/{project_id}/import-documents", response_model=dict)
async def import_documents(
project_id: int,
files: List[UploadFile] = File(...),
target_path: str = "",
2025-12-29 12:53:50 +00:00
request: Request = None,
2025-12-20 11:18:59 +00:00
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""批量导入Markdown文档"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_write_access(db, project_id, current_user)
2025-12-20 11:18:59 +00:00
# 验证所有文件都是.md格式
for file in files:
if not file.filename.endswith('.md'):
raise HTTPException(status_code=400, detail=f"文件 {file.filename} 不是Markdown格式")
# 获取目标目录路径
target_dir = storage_service.get_secure_path(project.storage_key, target_path)
# 确保目标目录存在
if not target_dir.exists():
target_dir.mkdir(parents=True, exist_ok=True)
# 保存所有文件
imported_files = []
for file in files:
file_path = target_dir / file.filename
content = await file.read()
# 写入文件
with open(file_path, 'wb') as f:
f.write(content)
# 构建相对路径
relative_path = f"{target_path}/{file.filename}" if target_path else file.filename
imported_files.append(relative_path)
2026-01-23 07:00:03 +00:00
# 更新索引
try:
text_content = content.decode('utf-8')
file_title = Path(file.filename).stem
await search_service.update_doc(project_id, relative_path, file_title, text_content)
except Exception:
pass
2025-12-20 11:18:59 +00:00
2025-12-29 12:53:50 +00:00
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.IMPORT_DOCUMENTS,
project_id=project_id,
file_path=target_path or "/",
user=current_user,
detail={
"file_count": len(imported_files),
"files": imported_files,
},
request=request,
)
2025-12-20 11:18:59 +00:00
return success_response(
data={"imported_files": imported_files},
message=f"成功导入 {len(imported_files)} 个文档"
)
@router.get("/{project_id}/export-directory")
async def export_directory(
project_id: int,
directory_path: str = "",
2025-12-29 12:53:50 +00:00
request: Request = None,
2025-12-20 11:18:59 +00:00
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""导出目录为ZIP包"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(db, project_id, current_user)
2025-12-20 11:18:59 +00:00
# 获取目标目录路径
source_dir = storage_service.get_secure_path(project.storage_key, directory_path)
2026-04-08 17:03:57 +00:00
if not source_dir.exists() or not source_dir.is_dir():
2025-12-20 11:18:59 +00:00
raise HTTPException(status_code=404, detail="目录不存在")
# 创建ZIP文件在内存中
zip_buffer = io.BytesIO()
2025-12-29 12:53:50 +00:00
file_count = 0
2025-12-20 11:18:59 +00:00
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# 遍历目录添加所有文件
for file_path in source_dir.rglob('*'):
if file_path.is_file():
# 计算相对路径
arcname = file_path.relative_to(source_dir)
zip_file.write(file_path, arcname)
2025-12-29 12:53:50 +00:00
file_count += 1
2025-12-20 11:18:59 +00:00
# 重置buffer位置
zip_buffer.seek(0)
# 生成ZIP文件名
2026-04-08 17:03:57 +00:00
safe_project_name = project.name.replace("/", "_").replace("\\", "_")
zip_filename = (
f"{safe_project_name}.zip"
if not directory_path
else f"{safe_project_name}_{directory_path.replace('/', '_')}.zip"
)
encoded_zip_filename = quote(zip_filename)
2025-12-20 11:18:59 +00:00
2025-12-29 12:53:50 +00:00
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.EXPORT_DOCUMENTS,
project_id=project_id,
file_path=directory_path or "/",
user=current_user,
detail={
"file_count": file_count,
"zip_filename": zip_filename,
},
request=request,
)
2025-12-20 11:18:59 +00:00
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={
2026-04-08 17:03:57 +00:00
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_zip_filename}"
2025-12-20 11:18:59 +00:00
}
2026-02-26 11:19:29 +00:00
)
@router.get("/{project_id}/export-pdf")
async def export_pdf(
project_id: int,
path: str,
request: Request,
current_user: User = Depends(get_user_from_token_or_query),
db: AsyncSession = Depends(get_db)
):
"""已登录用户导出 Markdown 为 PDF"""
import re
from app.services.pdf_service import pdf_service
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(db, project_id, current_user)
2026-02-26 11:19:29 +00:00
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content = await storage_service.read_file(file_path)
filename = Path(path).stem + ".pdf"
# 将 API 图片 URL 转为文件系统相对路径
content = re.sub(r'/api/v1/files/\d+/assets/', '_assets/', content)
project_root = storage_service.get_secure_path(project.storage_key)
2026-04-08 17:03:57 +00:00
try:
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
2026-02-26 11:19:29 +00:00
encoded_filename = quote(filename)
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Type": "application/pdf"
}
2026-04-08 17:03:57 +00:00
)