Files
zcglxt/app/api/v1/files.py
Claude e71181f0a3 fix: 修复多个关键问题
- 修复前端路由守卫:未登录时不显示提示,直接跳转登录页
- 修复API拦截器:401错误不显示提示,直接跳转
- 增强验证码显示:图片尺寸从120x40增加到200x80
- 增大验证码字体:从28号增加到48号
- 优化验证码字符:排除易混淆的0和1
- 减少干扰线:从5条减少到3条,添加背景色优化
- 增强登录API日志:添加详细的调试日志
- 增强验证码生成和验证日志
- 优化异常处理和错误追踪

影响文件:
- src/router/index.ts
- src/api/request.ts
- app/services/auth_service.py
- app/api/v1/auth.py
- app/schemas/user.py

测试状态:
- 前端构建通过
- 后端语法检查通过
- 验证码显示效果优化完成

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 00:26:21 +08:00

548 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文件管理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="上传成功"
)