548 lines
15 KiB
Python
548 lines
15 KiB
Python
"""
|
||
文件管理API路由
|
||
"""
|
||
from typing import List, Optional
|
||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form
|
||
from fastapi.responses import FileResponse, StreamingResponse
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.deps import get_db, get_current_user
|
||
from app.schemas.file_management import (
|
||
UploadedFileCreate,
|
||
UploadedFileUpdate,
|
||
UploadedFileResponse,
|
||
UploadedFileWithUrl,
|
||
FileUploadResponse,
|
||
FileShareCreate,
|
||
FileShareResponse,
|
||
FileBatchDelete,
|
||
FileStatistics,
|
||
ChunkUploadInit,
|
||
ChunkUploadInfo,
|
||
ChunkUploadComplete
|
||
)
|
||
from app.crud.file_management import uploaded_file
|
||
from app.services.file_service import file_service, chunk_upload_manager
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.post("/upload", response_model=FileUploadResponse)
|
||
async def upload_file(
|
||
file: UploadFile = File(..., description="上传的文件"),
|
||
remark: Optional[str] = Form(None, description="备注"),
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
上传文件
|
||
|
||
- **file**: 上传的文件
|
||
- **remark**: 备注
|
||
|
||
支持的文件类型:
|
||
- 图片: JPEG, PNG, GIF, BMP, WebP, SVG
|
||
- 文档: PDF, Word, Excel, PowerPoint, TXT, CSV
|
||
- 压缩包: ZIP, RAR, 7Z
|
||
|
||
文件大小限制:
|
||
- 图片: 最大10MB
|
||
- 其他: 最大100MB
|
||
"""
|
||
# 上传文件
|
||
file_obj = await file_service.upload_file(
|
||
db=db,
|
||
file=file,
|
||
uploader_id=current_user.id,
|
||
remark=remark
|
||
)
|
||
|
||
# 生成访问URL
|
||
base_url = "http://localhost:8000" # TODO: 从配置读取
|
||
download_url = f"{base_url}/api/v1/files/{file_obj.id}/download"
|
||
preview_url = None
|
||
if file_obj.file_type and file_obj.file_type.startswith('image/'):
|
||
preview_url = f"{base_url}/api/v1/files/{file_obj.id}/preview"
|
||
|
||
return FileUploadResponse(
|
||
id=file_obj.id,
|
||
file_name=file_obj.file_name,
|
||
original_name=file_obj.original_name,
|
||
file_size=file_obj.file_size,
|
||
file_type=file_obj.file_type,
|
||
file_path=file_obj.file_path,
|
||
download_url=download_url,
|
||
preview_url=preview_url,
|
||
message="上传成功"
|
||
)
|
||
|
||
|
||
@router.get("/", response_model=List[UploadedFileResponse])
|
||
def get_files(
|
||
skip: int = Query(0, ge=0, description="跳过条数"),
|
||
limit: int = Query(20, ge=1, le=100, description="返回条数"),
|
||
keyword: Optional[str] = Query(None, description="搜索关键词"),
|
||
file_type: Optional[str] = Query(None, description="文件类型"),
|
||
uploader_id: Optional[int] = Query(None, description="上传者ID"),
|
||
start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||
end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
获取文件列表
|
||
|
||
- **skip**: 跳过条数
|
||
- **limit**: 返回条数(最大100)
|
||
- **keyword**: 搜索关键词(文件名)
|
||
- **file_type**: 文件类型筛选
|
||
- **uploader_id**: 上传者ID筛选
|
||
- **start_date**: 开始日期
|
||
- **end_date**: 结束日期
|
||
"""
|
||
items, total = uploaded_file.get_multi(
|
||
db,
|
||
skip=skip,
|
||
limit=limit,
|
||
keyword=keyword,
|
||
file_type=file_type,
|
||
uploader_id=uploader_id,
|
||
start_date=start_date,
|
||
end_date=end_date
|
||
)
|
||
|
||
# 添加上传者姓名
|
||
result = []
|
||
for item in items:
|
||
item_dict = UploadedFileResponse.from_orm(item).dict()
|
||
if item.uploader:
|
||
item_dict['uploader_name'] = item.uploader.real_name
|
||
result.append(UploadedFileResponse(**item_dict))
|
||
|
||
return result
|
||
|
||
|
||
@router.get("/statistics", response_model=FileStatistics)
|
||
def get_file_statistics(
|
||
uploader_id: Optional[int] = Query(None, description="上传者ID筛选"),
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
获取文件统计信息
|
||
|
||
- **uploader_id**: 上传者ID筛选
|
||
|
||
返回文件总数、总大小、类型分布等统计信息
|
||
"""
|
||
return file_service.get_statistics(db, uploader_id=uploader_id)
|
||
|
||
|
||
@router.get("/{file_id}", response_model=UploadedFileWithUrl)
|
||
def get_file(
|
||
file_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
获取文件详情
|
||
|
||
- **file_id**: 文件ID
|
||
|
||
返回文件详情及访问URL
|
||
"""
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件不存在"
|
||
)
|
||
|
||
# 生成访问URL
|
||
base_url = "http://localhost:8000"
|
||
file_dict = UploadedFileWithUrl.from_orm(file_obj).dict()
|
||
file_dict['download_url'] = f"{base_url}/api/v1/files/{file_id}/download"
|
||
|
||
if file_obj.file_type and file_obj.file_type.startswith('image/'):
|
||
file_dict['preview_url'] = f"{base_url}/api/v1/files/{file_id}/preview"
|
||
|
||
if file_obj.share_code:
|
||
file_dict['share_url'] = f"{base_url}/api/v1/files/share/{file_obj.share_code}"
|
||
|
||
return UploadedFileWithUrl(**file_dict)
|
||
|
||
|
||
@router.get("/{file_id}/download")
|
||
def download_file(
|
||
file_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
下载文件
|
||
|
||
- **file_id**: 文件ID
|
||
|
||
返回文件流
|
||
"""
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件不存在"
|
||
)
|
||
|
||
# 检查文件是否存在
|
||
if not file_service.file_exists(file_obj):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件已被删除或移动"
|
||
)
|
||
|
||
# 增加下载次数
|
||
uploaded_file.increment_download_count(db, file_id=file_id)
|
||
|
||
# 返回文件
|
||
file_path = file_service.get_file_path(file_obj)
|
||
return FileResponse(
|
||
path=str(file_path),
|
||
filename=file_obj.original_name,
|
||
media_type=file_obj.file_type
|
||
)
|
||
|
||
|
||
@router.get("/{file_id}/preview")
|
||
def preview_file(
|
||
file_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
预览文件
|
||
|
||
- **file_id**: 文件ID
|
||
|
||
支持图片直接预览,其他文件类型可能需要转换为预览格式
|
||
"""
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件不存在"
|
||
)
|
||
|
||
# 检查文件是否存在
|
||
if not file_service.file_exists(file_obj):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件已被删除或移动"
|
||
)
|
||
|
||
# 检查文件类型是否支持预览
|
||
if not file_obj.file_type or not file_obj.file_type.startswith('image/'):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该文件类型不支持在线预览"
|
||
)
|
||
|
||
# 返回缩略图(如果存在)
|
||
if file_obj.thumbnail_path:
|
||
thumbnail_path = file_obj.thumbnail_path
|
||
if Path(thumbnail_path).exists():
|
||
return FileResponse(
|
||
path=thumbnail_path,
|
||
media_type="image/jpeg"
|
||
)
|
||
|
||
# 返回原图
|
||
file_path = file_service.get_file_path(file_obj)
|
||
return FileResponse(
|
||
path=str(file_path),
|
||
media_type=file_obj.file_type
|
||
)
|
||
|
||
|
||
@router.put("/{file_id}", response_model=UploadedFileResponse)
|
||
def update_file(
|
||
file_id: int,
|
||
obj_in: UploadedFileUpdate,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
更新文件信息
|
||
|
||
- **file_id**: 文件ID
|
||
- **remark**: 备注
|
||
"""
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件不存在"
|
||
)
|
||
|
||
# 检查权限:只有上传者可以更新
|
||
if file_obj.uploader_id != current_user.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="无权限修改此文件"
|
||
)
|
||
|
||
# 更新文件
|
||
file_obj = uploaded_file.update(
|
||
db,
|
||
db_obj=file_obj,
|
||
obj_in=obj_in.dict(exclude_unset=True)
|
||
)
|
||
|
||
return UploadedFileResponse.from_orm(file_obj)
|
||
|
||
|
||
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_file(
|
||
file_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
删除文件
|
||
|
||
- **file_id**: 文件ID
|
||
|
||
软删除文件记录和物理删除文件
|
||
"""
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件不存在"
|
||
)
|
||
|
||
# 检查权限:只有上传者可以删除
|
||
if file_obj.uploader_id != current_user.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="无权限删除此文件"
|
||
)
|
||
|
||
# 软删除数据库记录
|
||
uploaded_file.delete(db, db_obj=file_obj, deleter_id=current_user.id)
|
||
|
||
# 从磁盘删除文件
|
||
file_service.delete_file_from_disk(file_obj)
|
||
|
||
return None
|
||
|
||
|
||
@router.delete("/batch", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_files_batch(
|
||
obj_in: FileBatchDelete,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
批量删除文件
|
||
|
||
- **file_ids**: 文件ID列表
|
||
|
||
批量软删除文件记录和物理删除文件
|
||
"""
|
||
# 软删除数据库记录
|
||
count = uploaded_file.delete_batch(
|
||
db,
|
||
file_ids=obj_in.file_ids,
|
||
deleter_id=current_user.id
|
||
)
|
||
|
||
# 从磁盘删除文件
|
||
for file_id in obj_in.file_ids:
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if file_obj and file_obj.uploader_id == current_user.id:
|
||
file_service.delete_file_from_disk(file_obj)
|
||
|
||
return None
|
||
|
||
|
||
@router.post("/{file_id}/share", response_model=FileShareResponse)
|
||
def create_share_link(
|
||
file_id: int,
|
||
share_in: FileShareCreate = FileShareCreate(),
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
生成分享链接
|
||
|
||
- **file_id**: 文件ID
|
||
- **expire_days**: 有效期(天),默认7天,最大30天
|
||
|
||
生成用于文件分享的临时链接
|
||
"""
|
||
file_obj = uploaded_file.get(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件不存在"
|
||
)
|
||
|
||
# 检查权限:只有上传者可以分享
|
||
if file_obj.uploader_id != current_user.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="无权限分享此文件"
|
||
)
|
||
|
||
# 生成分享链接
|
||
base_url = "http://localhost:8000"
|
||
return file_service.generate_share_link(
|
||
db,
|
||
file_id=file_id,
|
||
expire_days=share_in.expire_days,
|
||
base_url=base_url
|
||
)
|
||
|
||
|
||
@router.get("/share/{share_code}")
|
||
def access_shared_file(
|
||
share_code: str,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
访问分享的文件
|
||
|
||
- **share_code**: 分享码
|
||
|
||
通过分享码访问文件(无需登录)
|
||
"""
|
||
file_obj = file_service.get_shared_file(db, share_code)
|
||
if not file_obj:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="分享链接不存在或已过期"
|
||
)
|
||
|
||
# 检查文件是否存在
|
||
if not file_service.file_exists(file_obj):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="文件已被删除或移动"
|
||
)
|
||
|
||
# 增加下载次数
|
||
uploaded_file.increment_download_count(db, file_id=file_obj.id)
|
||
|
||
# 返回文件
|
||
file_path = file_service.get_file_path(file_obj)
|
||
return FileResponse(
|
||
path=str(file_path),
|
||
filename=file_obj.original_name,
|
||
media_type=file_obj.file_type
|
||
)
|
||
|
||
|
||
# ===== 分片上传 =====
|
||
|
||
@router.post("/chunks/init")
|
||
def init_chunk_upload(
|
||
obj_in: ChunkUploadInit,
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
初始化分片上传
|
||
|
||
- **file_name**: 文件名
|
||
- **file_size**: 文件大小(字节)
|
||
- **file_type**: 文件类型
|
||
- **total_chunks**: 总分片数
|
||
- **file_hash**: 文件哈希(可选)
|
||
|
||
返回上传ID,用于后续上传分片
|
||
"""
|
||
upload_id = chunk_upload_manager.init_upload(
|
||
file_name=obj_in.file_name,
|
||
file_size=obj_in.file_size,
|
||
file_type=obj_in.file_type,
|
||
total_chunks=obj_in.total_chunks,
|
||
file_hash=obj_in.file_hash
|
||
)
|
||
|
||
return {"upload_id": upload_id, "message": "初始化成功"}
|
||
|
||
|
||
@router.post("/chunks/upload")
|
||
async def upload_chunk(
|
||
upload_id: str,
|
||
chunk_index: int,
|
||
chunk: UploadFile = File(..., description="分片文件"),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
上传分片
|
||
|
||
- **upload_id**: 上传ID
|
||
- **chunk_index**: 分片索引(从0开始)
|
||
- **chunk**: 分片文件
|
||
"""
|
||
content = await chunk.read()
|
||
success = chunk_upload_manager.save_chunk(upload_id, chunk_index, content)
|
||
|
||
if not success:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="上传会话不存在"
|
||
)
|
||
|
||
return {"message": f"分片 {chunk_index} 上传成功"}
|
||
|
||
|
||
@router.post("/chunks/complete", response_model=FileUploadResponse)
|
||
def complete_chunk_upload(
|
||
obj_in: ChunkUploadComplete,
|
||
db: Session = Depends(get_db),
|
||
current_user = Depends(get_current_user)
|
||
):
|
||
"""
|
||
完成分片上传
|
||
|
||
- **upload_id**: 上传ID
|
||
- **file_name**: 文件名
|
||
- **file_hash**: 文件哈希(可选)
|
||
|
||
合并所有分片并创建文件记录
|
||
"""
|
||
# 合并分片
|
||
try:
|
||
file_obj = chunk_upload_manager.merge_chunks(
|
||
db=db,
|
||
upload_id=obj_in.upload_id,
|
||
uploader_id=current_user.id,
|
||
file_service=file_service
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"合并分片失败: {str(e)}"
|
||
)
|
||
|
||
# 生成访问URL
|
||
base_url = "http://localhost:8000"
|
||
download_url = f"{base_url}/api/v1/files/{file_obj.id}/download"
|
||
preview_url = None
|
||
if file_obj.file_type and file_obj.file_type.startswith('image/'):
|
||
preview_url = f"{base_url}/api/v1/files/{file_obj.id}/preview"
|
||
|
||
return FileUploadResponse(
|
||
id=file_obj.id,
|
||
file_name=file_obj.file_name,
|
||
original_name=file_obj.original_name,
|
||
file_size=file_obj.file_size,
|
||
file_type=file_obj.file_type,
|
||
file_path=file_obj.file_path,
|
||
download_url=download_url,
|
||
preview_url=preview_url,
|
||
message="上传成功"
|
||
)
|