""" 文件系统操作相关 API """ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi.responses import StreamingResponse, FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import List import os import zipfile import io import mimetypes import json from pathlib import Path from urllib.parse import quote 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 from app.models.log import OperationLog from app.models.share import ShareLink from app.models.project import Project, ProjectMember from app.schemas.file import ( FileTreeNode, FileSaveRequest, FileOperateRequest, FileUploadResponse, ) from app.schemas.response import success_response from app.services.project_file_service import project_file_service from app.services.storage import storage_service from app.services.log_service import log_service from app.services.project_service import ( get_project_or_404, require_project_read_access, require_project_write_access, ) from app.core.enums import OperationType 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 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) @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) ): """获取项目目录树""" project, user_role = await require_project_read_access(db, project_id, current_user) # 获取项目根目录 project_root = storage_service.get_secure_path(project.storage_key) # 生成目录树 tree = storage_service.generate_tree(project_root) 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) # 获取当前用户角色 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 return success_response(data={ "tree": tree, "user_role": user_role, "project_name": project.name, "project_description": project.description }) @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) ): """获取文件内容""" project, _ = await require_project_read_access(db, project_id, current_user) # 获取文件路径 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, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """保存文件内容""" 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, request=request, ) return success_response(message=message) @router.post("/{project_id}/file/operate", response_model=dict) async def operate_file( project_id: int, operation: FileOperateRequest, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """文件操作(重命名、删除、创建目录、创建文件、移动)""" 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) @router.post("/{project_id}/upload", response_model=dict) async def upload_file( project_id: int, file: UploadFile = File(...), subfolder: str = "images", request: Request = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """上传文件(图片/附件)""" project, _ = await require_project_write_access(db, project_id, current_user) # 上传文件 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']}" # 记录日志 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, ) return success_response(data=file_info, message="文件上传成功") @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等)到项目目录 """ project, _ = await require_project_write_access(db, project_id, current_user) # 只允许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, request: Request, current_user: User = Depends(get_user_from_token_or_query), db: AsyncSession = Depends(get_db) ): """ 获取文档文件(PDF等)- 支持 HTTP Range 请求 """ import re import aiofiles project, _ = await require_project_read_access(db, project_id, current_user) # 获取文件路径 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" # 获取文件大小 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 头) return FileResponse( path=str(file_path), media_type=content_type, filename=file_path.name, headers={"Accept-Ranges": "bytes"} ) @router.get("/{project_id}/assets/{subfolder}/{filename}") async def get_asset_file( project_id: int, subfolder: str, filename: str, db: AsyncSession = Depends(get_db) ): """获取资源文件(公开访问,支持分享)""" project = await get_project_or_404(db, project_id) # 获取文件路径 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="文件不存在") # 根据文件扩展名确定 MIME 类型 mime_type, _ = mimetypes.guess_type(filename) if mime_type is None: mime_type = "application/octet-stream" # 返回文件(流式响应) return FileResponse( path=file_path, filename=filename, media_type=mime_type ) @router.post("/{project_id}/import-documents", response_model=dict) async def import_documents( project_id: int, files: List[UploadFile] = File(...), target_path: str = "", request: Request = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """批量导入Markdown文档""" project, _ = await require_project_write_access(db, project_id, current_user) # 验证所有文件都是.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) # 更新索引 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 # 记录日志 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, ) 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 = "", request: Request = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """导出目录为ZIP包""" project, _ = await require_project_read_access(db, project_id, current_user) # 获取目标目录路径 source_dir = storage_service.get_secure_path(project.storage_key, directory_path) if not source_dir.exists() or not source_dir.is_dir(): raise HTTPException(status_code=404, detail="目录不存在") # 创建ZIP文件在内存中 zip_buffer = io.BytesIO() file_count = 0 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) file_count += 1 # 重置buffer位置 zip_buffer.seek(0) # 生成ZIP文件名 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) # 记录日志 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, ) return StreamingResponse( zip_buffer, media_type="application/zip", headers={ "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_zip_filename}" } ) @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 project, _ = await require_project_read_access(db, project_id, current_user) 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) 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 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" } )