1033 lines
37 KiB
Python
1033 lines
37 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
文件管理API
|
||
"""
|
||
import os
|
||
import hashlib
|
||
import logging
|
||
from typing import Optional, List
|
||
from datetime import datetime
|
||
from urllib.parse import quote
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request, Form
|
||
from fastapi.responses import StreamingResponse, Response
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database import get_db
|
||
from app.base_schema import PaginatedResponse, ResponseModel
|
||
from core.file_manager.model import FileManager
|
||
from core.file_manager.schema import (
|
||
FileManagerResponse,
|
||
FileManagerSimpleResponse,
|
||
CreateFolderIn,
|
||
MoveItemsIn,
|
||
RenameItemIn,
|
||
BatchDeleteIn,
|
||
FileStorageConfigResponse,
|
||
FileStorageConfigUpdate,
|
||
FileUrlResponse,
|
||
CreateAccessTokenIn,
|
||
AccessTokenResponse,
|
||
AccessTokenUrlResponse,
|
||
OcrRecognizeRequest,
|
||
OcrRecognizeResponse,
|
||
)
|
||
from core.file_manager.service import FileManagerService
|
||
from core.file_manager.storage_backends import get_storage_backend
|
||
from core.file_manager.temp_token_service import FileAccessTokenService
|
||
from core.file_manager.signature_token_service import SignatureTokenService
|
||
|
||
router = APIRouter(prefix="/file_manager", tags=["文件管理"])
|
||
|
||
# 配置日志
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _build_file_response(item: FileManager, has_children: bool = False, parent_name: str = None) -> dict:
|
||
"""构建文件响应"""
|
||
return {
|
||
"id": item.id,
|
||
"name": item.name,
|
||
"file_type": item.type,
|
||
"parent_id": item.parent_id,
|
||
"parent_name": parent_name,
|
||
"path": item.path,
|
||
"file_size": item.size,
|
||
"file_ext": item.file_ext,
|
||
"mime_type": item.mime_type,
|
||
"storage_type": item.storage_type,
|
||
"storage_path": item.storage_path,
|
||
"url": item.url,
|
||
"thumbnail_url": item.thumbnail_url,
|
||
"md5": item.md5,
|
||
"is_public": item.is_public,
|
||
"download_count": item.download_count,
|
||
"is_system": item.is_system or False,
|
||
"source": item.source,
|
||
"sys_creator_id": item.sys_creator_id,
|
||
"has_children": has_children,
|
||
"updated_time": item.sys_update_datetime.isoformat() if item.sys_update_datetime else (
|
||
item.sys_create_datetime.isoformat() if item.sys_create_datetime else None
|
||
),
|
||
"sys_create_datetime": item.sys_create_datetime,
|
||
"sys_update_datetime": item.sys_update_datetime,
|
||
}
|
||
|
||
|
||
# 上传限制配置
|
||
MAX_UPLOAD_SIZE = getattr(settings, 'FILE_MAX_UPLOAD_SIZE', 500 * 1024 * 1024) # 默认500MB
|
||
BLOCKED_EXTENSIONS = {'.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif', '.vbs', '.js', '.wsh', '.wsf'}
|
||
|
||
|
||
@router.post("/upload", response_model=FileManagerResponse, summary="上传文件")
|
||
async def upload_file(
|
||
file: UploadFile = File(...),
|
||
parent_id: Optional[str] = Form(None, alias="parent_id"),
|
||
is_public: bool = Form(False, alias="is_public"),
|
||
source: Optional[str] = Form(None, description="来源模块标识,如 announcement/workflow/chat 等"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""上传文件"""
|
||
# 文件扩展名校验
|
||
file_ext = os.path.splitext(file.filename or '')[1].lower()
|
||
if file_ext in BLOCKED_EXTENSIONS:
|
||
raise HTTPException(status_code=400, detail=f"不允许上传此类型文件: {file_ext}")
|
||
|
||
# 读取文件内容
|
||
file_content = await file.read()
|
||
file_size = len(file_content)
|
||
|
||
# 文件大小校验
|
||
if file_size > MAX_UPLOAD_SIZE:
|
||
max_mb = MAX_UPLOAD_SIZE / (1024 * 1024)
|
||
raise HTTPException(status_code=400, detail=f"文件大小超过限制(最大 {max_mb:.0f}MB)")
|
||
|
||
# 上传文件
|
||
file_obj = await FileManagerService.upload_file(
|
||
db=db,
|
||
file_content=file_content,
|
||
filename=file.filename,
|
||
file_size=file_size,
|
||
parent_id=parent_id,
|
||
is_public=is_public,
|
||
source=source,
|
||
)
|
||
|
||
return _build_file_response(file_obj)
|
||
|
||
|
||
@router.post("/folder", response_model=FileManagerResponse, summary="创建文件夹")
|
||
async def create_folder(
|
||
data: CreateFolderIn,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""创建文件夹"""
|
||
folder = await FileManagerService.create_folder(
|
||
db=db,
|
||
name=data.name,
|
||
parent_id=data.parent_id,
|
||
)
|
||
|
||
if not folder:
|
||
raise HTTPException(status_code=422, detail="同名文件夹已存在")
|
||
|
||
return _build_file_response(folder)
|
||
|
||
|
||
@router.get("/recent/images", summary="获取最近上传的图片")
|
||
async def get_recent_images(
|
||
request: Request,
|
||
limit: int = Query(default=20, ge=1, le=50, description="数量"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取当前用户最近上传的图片文件(跨所有文件夹,按时间倒序)"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
if not user_id:
|
||
return []
|
||
items = await FileManagerService.get_recent_images(db=db, creator_id=user_id, limit=limit)
|
||
return [_build_file_response(item) for item in items]
|
||
|
||
|
||
@router.get("/recent/files", summary="获取最近上传的文件")
|
||
async def get_recent_files(
|
||
request: Request,
|
||
limit: int = Query(default=20, ge=1, le=50, description="数量"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取当前用户最近上传的文件(跨所有文件夹,按时间倒序)"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
if not user_id:
|
||
return []
|
||
items = await FileManagerService.get_recent_files(db=db, creator_id=user_id, limit=limit)
|
||
return [_build_file_response(item) for item in items]
|
||
|
||
|
||
@router.get("", response_model=PaginatedResponse[FileManagerResponse], summary="获取文件列表")
|
||
async def list_files(
|
||
request: Request,
|
||
page: int = Query(default=1, ge=1, description="页码"),
|
||
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
|
||
parent_id: Optional[str] = Query(None, description="父文件夹ID"),
|
||
name: Optional[str] = Query(None, description="文件名搜索"),
|
||
type: Optional[str] = Query(None, description="类型: file/folder"),
|
||
storage_type: Optional[str] = Query(None, alias="storageType", description="存储类型"),
|
||
file_ext: Optional[str] = Query(None, alias="fileExt", description="文件扩展名"),
|
||
is_public: Optional[bool] = Query(None, alias="isPublic", description="是否公开"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取文件列表(分页)"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
items, total = await FileManagerService.get_list(
|
||
db=db,
|
||
page=page,
|
||
page_size=page_size,
|
||
parent_id=parent_id,
|
||
name=name,
|
||
type=type,
|
||
storage_type=storage_type,
|
||
file_ext=file_ext,
|
||
is_public=is_public,
|
||
creator_id=user_id,
|
||
is_superuser=is_superuser,
|
||
)
|
||
|
||
# 批量查询 has_children 和 parent_name,避免 N+1 查询
|
||
folder_ids = [item.id for item in items if item.type == 'folder']
|
||
parent_ids = list({item.parent_id for item in items if item.parent_id})
|
||
|
||
# 如果是文件夹树请求(type=folder),只检查是否有子文件夹;否则检查所有子项
|
||
if type == 'folder':
|
||
children_map = await FileManagerService.batch_has_sub_folders(db, folder_ids) if folder_ids else {}
|
||
else:
|
||
children_map = await FileManagerService.batch_has_children(db, folder_ids) if folder_ids else {}
|
||
parent_names_map = await FileManagerService.batch_get_names(db, parent_ids) if parent_ids else {}
|
||
|
||
# 构建响应
|
||
result_items = []
|
||
for item in items:
|
||
has_children = children_map.get(item.id, False) if item.type == 'folder' else False
|
||
parent_name = parent_names_map.get(item.parent_id) if item.parent_id else None
|
||
result_items.append(_build_file_response(item, has_children, parent_name))
|
||
|
||
return PaginatedResponse(items=result_items, total=total)
|
||
|
||
|
||
@router.get("/tree", response_model=List[FileManagerResponse], summary="获取文件夹树结构")
|
||
async def get_folder_tree(request: Request, db: AsyncSession = Depends(get_db)):
|
||
"""获取文件夹树结构"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
folders = await FileManagerService.get_folder_tree(db, creator_id=user_id, is_superuser=is_superuser)
|
||
|
||
# 批量查询 has_children
|
||
folder_ids = [f.id for f in folders]
|
||
children_map = await FileManagerService.batch_has_children(db, folder_ids) if folder_ids else {}
|
||
|
||
result = []
|
||
for folder in folders:
|
||
has_children = children_map.get(folder.id, False)
|
||
result.append(_build_file_response(folder, has_children))
|
||
|
||
return result
|
||
|
||
|
||
@router.get("/file_info/{file_id}", response_model=FileManagerSimpleResponse, summary="获取文件信息")
|
||
async def get_file_info(
|
||
file_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取文件信息"""
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
return {
|
||
"id": file_obj.id,
|
||
"name": file_obj.name,
|
||
"type": file_obj.type,
|
||
"size": file_obj.size,
|
||
"mime_type": file_obj.mime_type,
|
||
"sys_create_datetime": file_obj.sys_create_datetime.isoformat() if file_obj.sys_create_datetime else None,
|
||
}
|
||
|
||
|
||
@router.put("/{file_id}/rename", response_model=FileManagerResponse, summary="重命名文件/文件夹")
|
||
async def rename_item(
|
||
file_id: str,
|
||
data: RenameItemIn,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""重命名文件/文件夹"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
item = await FileManagerService.rename_item(db, file_id, data.name, creator_id=user_id, is_superuser=is_superuser)
|
||
|
||
if not item:
|
||
raise HTTPException(status_code=400, detail="重命名失败,可能同名文件/文件夹已存在")
|
||
|
||
return _build_file_response(item)
|
||
|
||
|
||
@router.put("/{file_id}/public", response_model=FileManagerResponse, summary="设置文件公开状态")
|
||
async def set_file_public(
|
||
file_id: str,
|
||
request: Request,
|
||
is_public: bool = Query(..., alias="isPublic", description="是否公开"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
设置文件的公开状态
|
||
|
||
- 公开文件(is_public=True):任何人都可以通过URL直接访问,无需认证
|
||
- 私有文件(is_public=False):需要临时访问令牌才能访问
|
||
"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# 权限校验:普通用户只能修改自己的文件公开状态
|
||
if not is_superuser and user_id and file_obj.sys_creator_id != user_id:
|
||
raise HTTPException(status_code=403, detail="无权修改此文件")
|
||
|
||
file_obj.is_public = is_public
|
||
await db.commit()
|
||
await db.refresh(file_obj)
|
||
|
||
return _build_file_response(file_obj)
|
||
|
||
|
||
@router.put("/move", response_model=ResponseModel, summary="移动文件/文件夹")
|
||
async def move_items(
|
||
data: MoveItemsIn,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""移动文件/文件夹"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
success = await FileManagerService.move_items(db, data.ids, data.target_folder_id, creator_id=user_id, is_superuser=is_superuser)
|
||
|
||
if not success:
|
||
raise HTTPException(status_code=400, detail="移动失败")
|
||
|
||
return ResponseModel(message="移动成功")
|
||
|
||
|
||
@router.delete("/{file_id}", response_model=ResponseModel, summary="删除文件/文件夹")
|
||
async def delete_item(
|
||
file_id: str,
|
||
request: Request,
|
||
hard: bool = Query(default=False, description="是否物理删除,默认软删除"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""删除文件/文件夹"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
success = await FileManagerService.delete_item(db, file_id, hard, creator_id=user_id, is_superuser=is_superuser)
|
||
|
||
if not success:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
return ResponseModel(message="删除成功")
|
||
|
||
|
||
@router.post("/batch/delete", response_model=ResponseModel, summary="批量删除文件/文件夹")
|
||
async def batch_delete(
|
||
data: BatchDeleteIn,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""批量删除文件/文件夹"""
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
is_superuser = getattr(request.state, 'is_superuser', False)
|
||
|
||
deleted_count = await FileManagerService.batch_delete(db, data.ids, creator_id=user_id, is_superuser=is_superuser)
|
||
return ResponseModel(message=f"成功删除 {deleted_count} 个文件/文件夹")
|
||
|
||
|
||
@router.get("/file/download", summary="下载文件")
|
||
async def download_file(
|
||
path: str = Query(..., description="文件存储路径"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""下载文件"""
|
||
# 查找文件记录
|
||
file_obj = await FileManagerService.get_by_storage_path(db, path)
|
||
if not file_obj:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# 更新下载次数
|
||
await FileManagerService.increment_download_count(db, file_obj.id)
|
||
|
||
# 获取存储后端
|
||
storage = get_storage_backend()
|
||
|
||
# 如果是本地存储,直接返回文件
|
||
if file_obj.storage_type == 'local':
|
||
full_path = storage.get_full_path(path)
|
||
if not os.path.exists(full_path):
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
return FileResponse(
|
||
full_path,
|
||
filename=file_obj.name,
|
||
media_type=file_obj.mime_type or 'application/octet-stream',
|
||
)
|
||
else:
|
||
# 其他存储类型,重定向到实际URL
|
||
if file_obj.url:
|
||
return Response(status_code=302, headers={'Location': file_obj.url})
|
||
else:
|
||
raise HTTPException(status_code=400, detail="无法获取文件URL")
|
||
|
||
|
||
@router.get("/url/{file_id}", response_model=FileUrlResponse, summary="获取文件访问URL")
|
||
async def get_file_url(
|
||
file_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""通过文件ID获取文件访问URL"""
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj or file_obj.type != 'file':
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# Minio存储,返回临时URL
|
||
if file_obj.storage_type == 'minio':
|
||
storage = get_storage_backend()
|
||
if hasattr(storage, 'get_presigned_url'):
|
||
try:
|
||
temp_url = storage.get_presigned_url(file_obj.storage_path)
|
||
return {"url": temp_url}
|
||
except Exception:
|
||
pass
|
||
|
||
# 如果文件有直接的URL(云存储)
|
||
if file_obj.url:
|
||
return {"url": file_obj.url}
|
||
|
||
# 本地存储,构建访问URL
|
||
if file_obj.storage_type == 'local':
|
||
base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000')
|
||
file_url = f"{base_url}/api/file_manager/file/download?path={file_obj.storage_path}"
|
||
return {"url": file_url}
|
||
|
||
# 其他情况返回存储路径
|
||
return {"url": file_obj.storage_path}
|
||
|
||
|
||
@router.get("/batch/urls", summary="批量获取文件访问URL")
|
||
async def get_batch_file_urls(
|
||
ids: str = Query(..., description="文件ID列表,逗号分隔"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""批量获取文件访问URL"""
|
||
file_ids = [id_str.strip() for id_str in ids.split(',') if id_str.strip()]
|
||
|
||
storage = get_storage_backend()
|
||
has_presigned_method = hasattr(storage, 'get_presigned_url')
|
||
|
||
result = {}
|
||
for file_id in file_ids:
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj or file_obj.type != 'file':
|
||
continue
|
||
|
||
# Minio存储,返回临时URL
|
||
if file_obj.storage_type == 'minio' and has_presigned_method:
|
||
try:
|
||
temp_url = storage.get_presigned_url(file_obj.storage_path)
|
||
result[file_id] = temp_url
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
if file_obj.url:
|
||
result[file_id] = file_obj.url
|
||
elif file_obj.storage_type == 'local':
|
||
base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000')
|
||
file_url = f"{base_url}/api/file_manager/file/download?path={file_obj.storage_path}"
|
||
result[file_id] = file_url
|
||
else:
|
||
result[file_id] = file_obj.storage_path
|
||
|
||
return result
|
||
|
||
|
||
@router.get("/stream/{file_id}", summary="流式传输文件")
|
||
async def stream_file(
|
||
request: Request,
|
||
file_id: str,
|
||
token: Optional[str] = Query(None, description="临时访问令牌"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
通过后端流式传输文件(支持所有存储类型)
|
||
|
||
访问控制逻辑:
|
||
- 公开文件(is_public=True):无需任何认证,直接访问
|
||
- 私有文件(is_public=False):需要提供有效的临时访问令牌
|
||
|
||
支持 ETag / If-None-Match 缓存协商,命中时返回 304 Not Modified
|
||
"""
|
||
# 先获取文件信息,判断是否公开
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj or file_obj.type != 'file':
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# 如果文件不是公开的,需要验证临时访问令牌
|
||
if not file_obj.is_public:
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="需要提供访问令牌")
|
||
token_obj = await FileAccessTokenService.verify_token(db, token)
|
||
if not token_obj:
|
||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||
if token_obj.file_id != file_id:
|
||
raise HTTPException(status_code=403, detail="令牌与文件不匹配")
|
||
|
||
# ETag 缓存协商:文件内容未变化时直接返回 304
|
||
etag = f'"{file_obj.md5}"' if file_obj.md5 else None
|
||
if etag:
|
||
if_none_match = request.headers.get('if-none-match')
|
||
if if_none_match and if_none_match == etag:
|
||
return Response(status_code=304, headers={'ETag': etag, 'Cache-Control': 'public, max-age=86400'})
|
||
|
||
# 更新下载次数
|
||
await FileManagerService.increment_download_count(db, file_obj.id)
|
||
|
||
# 获取存储后端
|
||
storage = get_storage_backend()
|
||
|
||
# 公共缓存头
|
||
cache_headers = {
|
||
'Cache-Control': 'public, max-age=86400',
|
||
}
|
||
if etag:
|
||
cache_headers['ETag'] = etag
|
||
|
||
if file_obj.storage_type == 'local':
|
||
# 本地存储直接读取文件
|
||
full_path = storage.get_full_path(file_obj.storage_path)
|
||
if not os.path.exists(full_path):
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
def file_iterator():
|
||
with open(full_path, 'rb') as f:
|
||
while chunk := f.read(8192):
|
||
yield chunk
|
||
|
||
# 使用 RFC 5987 编码格式支持中文文件名
|
||
encoded_filename = quote(file_obj.name)
|
||
|
||
return StreamingResponse(
|
||
file_iterator(),
|
||
media_type=file_obj.mime_type or 'application/octet-stream',
|
||
headers={
|
||
'Content-Disposition': f'inline; filename*=UTF-8\'\'{encoded_filename}',
|
||
'Content-Length': str(file_obj.size),
|
||
'Accept-Ranges': 'bytes',
|
||
**cache_headers,
|
||
}
|
||
)
|
||
|
||
elif file_obj.storage_type == 'minio' and hasattr(storage, 'get_file_content'):
|
||
# Minio存储,通过后端转发
|
||
try:
|
||
file_response = storage.get_file_content(file_obj.storage_path)
|
||
|
||
# 使用 RFC 5987 编码格式支持中文文件名
|
||
encoded_filename = quote(file_obj.name)
|
||
|
||
return StreamingResponse(
|
||
file_response,
|
||
media_type=file_obj.mime_type or 'application/octet-stream',
|
||
headers={
|
||
'Content-Disposition': f'inline; filename*=UTF-8\'\'{encoded_filename}',
|
||
'Content-Length': str(file_obj.size),
|
||
'Accept-Ranges': 'bytes',
|
||
**cache_headers,
|
||
}
|
||
)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"获取文件失败: {str(e)}")
|
||
|
||
else:
|
||
# 其他存储类型,重定向到原URL
|
||
if file_obj.url:
|
||
return Response(status_code=302, headers={'Location': file_obj.url})
|
||
else:
|
||
raise HTTPException(status_code=400, detail="不支持的存储类型")
|
||
|
||
|
||
@router.get("/proxy/{file_id}", summary="代理文件访问")
|
||
async def proxy_file(
|
||
request: Request,
|
||
file_id: str,
|
||
download: bool = Query(default=False, description="是否作为附件下载"),
|
||
token: Optional[str] = Query(None, description="临时访问令牌"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
代理文件访问(强制通过后端转发)
|
||
|
||
访问控制逻辑:
|
||
- 公开文件(is_public=True):无需任何认证,直接访问
|
||
- 私有文件(is_public=False):需要提供有效的临时访问令牌
|
||
|
||
支持 ETag / If-None-Match 缓存协商,命中时返回 304 Not Modified
|
||
"""
|
||
# 先获取文件信息,判断是否公开
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj or file_obj.type != 'file':
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# 如果文件不是公开的,需要验证临时访问令牌
|
||
if not file_obj.is_public:
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="需要提供访问令牌")
|
||
token_obj = await FileAccessTokenService.verify_token(db, token)
|
||
if not token_obj:
|
||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||
if token_obj.file_id != file_id:
|
||
raise HTTPException(status_code=403, detail="令牌与文件不匹配")
|
||
|
||
# ETag 缓存协商:文件内容未变化时直接返回 304
|
||
etag = f'"{file_obj.md5}"' if file_obj.md5 else None
|
||
if etag:
|
||
if_none_match = request.headers.get('if-none-match')
|
||
if if_none_match and if_none_match == etag:
|
||
return Response(status_code=304, headers={'ETag': etag, 'Cache-Control': 'public, max-age=86400'})
|
||
|
||
# 更新下载次数
|
||
await FileManagerService.increment_download_count(db, file_obj.id)
|
||
|
||
# 获取存储后端
|
||
storage = get_storage_backend()
|
||
|
||
disposition = 'attachment' if download else 'inline'
|
||
|
||
# 公共缓存头
|
||
cache_headers = {
|
||
'Cache-Control': 'public, max-age=86400',
|
||
}
|
||
if etag:
|
||
cache_headers['ETag'] = etag
|
||
|
||
if file_obj.storage_type == 'local':
|
||
# 本地文件处理
|
||
full_path = storage.get_full_path(file_obj.storage_path)
|
||
if not os.path.exists(full_path):
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
with open(full_path, 'rb') as f:
|
||
content = f.read()
|
||
|
||
# 使用 RFC 5987 编码格式支持中文文件名
|
||
encoded_filename = quote(file_obj.name)
|
||
|
||
return Response(
|
||
content=content,
|
||
media_type=file_obj.mime_type or 'application/octet-stream',
|
||
headers={
|
||
'Content-Disposition': f'{disposition}; filename*=UTF-8\'\'{encoded_filename}',
|
||
'Content-Length': str(len(content)),
|
||
**cache_headers,
|
||
}
|
||
)
|
||
|
||
elif file_obj.storage_type == 'minio' and hasattr(storage, 'get_file_content'):
|
||
# Minio存储处理
|
||
try:
|
||
file_response = storage.get_file_content(file_obj.storage_path)
|
||
content = file_response.read()
|
||
|
||
# 使用 RFC 5987 编码格式支持中文文件名
|
||
encoded_filename = quote(file_obj.name)
|
||
|
||
return Response(
|
||
content=content,
|
||
media_type=file_obj.mime_type or 'application/octet-stream',
|
||
headers={
|
||
'Content-Disposition': f'{disposition}; filename*=UTF-8\'\'{encoded_filename}',
|
||
'Content-Length': str(len(content)),
|
||
**cache_headers,
|
||
}
|
||
)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"获取文件失败: {str(e)}")
|
||
|
||
else:
|
||
# 其他存储类型重定向
|
||
if file_obj.url:
|
||
return Response(status_code=302, headers={'Location': file_obj.url})
|
||
else:
|
||
raise HTTPException(status_code=400, detail="不支持的存储类型")
|
||
|
||
|
||
@router.get("/storage/config", response_model=FileStorageConfigResponse, summary="获取存储配置")
|
||
async def get_storage_config():
|
||
"""获取存储配置"""
|
||
config = {
|
||
'storage_type': getattr(settings, 'FILE_STORAGE_TYPE', 'local'),
|
||
'local_base_path': getattr(settings, 'FILE_STORAGE_LOCAL_PATH', None),
|
||
}
|
||
return config
|
||
|
||
|
||
@router.put("/storage/config", response_model=ResponseModel, summary="更新存储配置")
|
||
async def update_storage_config(data: FileStorageConfigUpdate):
|
||
"""更新存储配置(需要管理员权限)"""
|
||
# TODO: 实现配置更新逻辑,可能需要保存到数据库或配置文件
|
||
return ResponseModel(message="配置更新成功")
|
||
|
||
|
||
@router.post("/access-token", response_model=AccessTokenResponse, summary="创建临时访问令牌")
|
||
async def create_access_token(
|
||
data: CreateAccessTokenIn,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""创建文件临时访问令牌"""
|
||
file_obj = await FileManagerService.get_by_id(db, data.file_id)
|
||
if not file_obj or file_obj.type != 'file':
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
ip_address = request.client.host if request.client else None
|
||
user_agent = request.headers.get('user-agent')
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
|
||
token_obj = await FileAccessTokenService.create_token(
|
||
db=db,
|
||
file_id=data.file_id,
|
||
expires_in_seconds=data.expires_in,
|
||
user_id=user_id,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
return AccessTokenResponse(
|
||
token=token_obj.token,
|
||
expires_at=token_obj.expires_at,
|
||
file_id=token_obj.file_id,
|
||
)
|
||
|
||
|
||
@router.get("/access-token/url/{file_id}", response_model=AccessTokenUrlResponse, summary="获取带临时令牌的文件URL")
|
||
async def get_file_url_with_token(
|
||
file_id: str,
|
||
request: Request,
|
||
expires_in: int = Query(default=3600, alias="expiresIn", description="过期时间(秒)"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取带临时令牌的文件访问URL"""
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj or file_obj.type != 'file':
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
ip_address = request.client.host if request.client else None
|
||
user_agent = request.headers.get('user-agent')
|
||
user_id = getattr(request.state, 'user_id', None)
|
||
|
||
token_obj = await FileAccessTokenService.create_token(
|
||
db=db,
|
||
file_id=file_id,
|
||
expires_in_seconds=expires_in,
|
||
user_id=user_id,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000')
|
||
file_url = f"/api/core/file_manager/stream/{file_id}?token={token_obj.token}"
|
||
|
||
return AccessTokenUrlResponse(
|
||
url=file_url,
|
||
token=token_obj.token,
|
||
expires_at=token_obj.expires_at,
|
||
)
|
||
|
||
|
||
@router.delete("/access-token/{token}", response_model=ResponseModel, summary="撤销临时访问令牌")
|
||
async def revoke_access_token(
|
||
token: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""撤销临时访问令牌"""
|
||
success = await FileAccessTokenService.revoke_token(db, token)
|
||
if not success:
|
||
raise HTTPException(status_code=404, detail="令牌不存在")
|
||
return ResponseModel(message="令牌已撤销")
|
||
|
||
|
||
@router.post("/access-token/cleanup", response_model=ResponseModel, summary="清理过期令牌")
|
||
async def cleanup_expired_tokens(
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""清理过期的临时访问令牌"""
|
||
count = await FileAccessTokenService.cleanup_expired_tokens(db)
|
||
return ResponseModel(message=f"已清理 {count} 个过期令牌")
|
||
|
||
|
||
@router.post("/ocr/recognize", response_model=OcrRecognizeResponse, summary="AI文件智能识别")
|
||
async def ocr_recognize(
|
||
request: OcrRecognizeRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
使用AI进行文件智能识别,支持多种文件类型。
|
||
|
||
支持的文件类型:
|
||
- 图片文件:使用 OCR 识别
|
||
- 文本文件:txt, md, log, py, js, json, yaml 等
|
||
- PDF 文件
|
||
- Word 文档:docx, doc
|
||
- Excel 文件:xlsx, xls
|
||
|
||
参数:
|
||
- **file_id**: 文件ID
|
||
- **output_schema**: 结构化输出字段定义(可选)
|
||
- **prompt**: 自定义提示词(可选)
|
||
"""
|
||
# 将 output_schema 转换为字典列表
|
||
output_schema = None
|
||
if request.output_schema:
|
||
output_schema = [field.model_dump() for field in request.output_schema]
|
||
|
||
result = await FileManagerService.recognize_file_with_function_calling(
|
||
db=db,
|
||
file_id=request.file_id,
|
||
output_schema=output_schema,
|
||
custom_prompt=request.prompt,
|
||
)
|
||
|
||
return OcrRecognizeResponse(
|
||
success=result["success"],
|
||
raw_text=result.get("raw_text"),
|
||
extracted_data=result.get("extracted_data"),
|
||
error=result.get("error"),
|
||
)
|
||
|
||
|
||
@router.get("/{file_id}", response_model=FileManagerResponse, summary="获取文件详情")
|
||
async def get_file_detail(
|
||
file_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取文件详情"""
|
||
file_obj = await FileManagerService.get_by_id(db, file_id)
|
||
if not file_obj:
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
has_children = False
|
||
if file_obj.type == 'folder':
|
||
has_children = await FileManagerService.has_children(db, file_obj.id)
|
||
|
||
parent_name = None
|
||
if file_obj.parent_id:
|
||
parent = await FileManagerService.get_by_id(db, file_obj.parent_id)
|
||
if parent:
|
||
parent_name = parent.name
|
||
|
||
return _build_file_response(file_obj, has_children, parent_name)
|
||
|
||
|
||
# ==================== 签名令牌相关 API ====================
|
||
|
||
@router.post("/signature/token", summary="创建签名令牌")
|
||
async def create_signature_token(
|
||
source: str = Form(default="form", description="来源"),
|
||
expire_minutes: int = Form(default=30, description="过期时间(分钟)"),
|
||
request: Request = None,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
创建签名令牌,用于手机扫码签名
|
||
|
||
返回:
|
||
- token: 签名令牌(用于构建二维码URL)
|
||
- callback_key: 回调标识(用于前端轮询签名状态)
|
||
- expired_at: 过期时间
|
||
"""
|
||
user_id = getattr(request.state, 'user_id', None) if request else None
|
||
|
||
sign_token = await SignatureTokenService.create_token(
|
||
db=db,
|
||
source=source,
|
||
expire_minutes=expire_minutes,
|
||
user_id=user_id,
|
||
)
|
||
|
||
return {
|
||
"token": sign_token.token,
|
||
"callback_key": sign_token.callback_key,
|
||
"expired_at": sign_token.expired_at.isoformat(),
|
||
}
|
||
|
||
|
||
@router.get("/signature/status/{callback_key}", summary="检查签名状态")
|
||
async def check_signature_status(
|
||
callback_key: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
检查签名状态(用于前端轮询)
|
||
|
||
返回:
|
||
- status: pending/completed/expired/not_found
|
||
- message: 状态描述
|
||
- file_id: 签名文件ID(仅当status=completed时)
|
||
"""
|
||
return await SignatureTokenService.check_signature_status(db, callback_key)
|
||
|
||
|
||
@router.get("/signature/info/{token}", summary="获取签名令牌信息(无需登录)")
|
||
async def get_signature_token_info(
|
||
token: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
根据令牌获取签名信息(移动端使用,无需登录)
|
||
"""
|
||
sign_token = await SignatureTokenService.get_by_token(db, token)
|
||
if not sign_token:
|
||
raise HTTPException(status_code=404, detail="签名链接无效")
|
||
|
||
is_valid, error_msg = SignatureTokenService.validate_token(sign_token)
|
||
if not is_valid:
|
||
raise HTTPException(status_code=400, detail=error_msg)
|
||
|
||
return {
|
||
"token": sign_token.token,
|
||
"source": sign_token.source,
|
||
"expired_at": sign_token.expired_at.isoformat(),
|
||
}
|
||
|
||
|
||
@router.post("/signature/upload/{token}", summary="上传签名图片(无需登录)")
|
||
async def upload_signature_image(
|
||
token: str,
|
||
file: UploadFile = File(..., description="签名图片文件"),
|
||
request: Request = None,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
上传签名图片并完成签名(移动端使用,无需登录)
|
||
|
||
参数:
|
||
- token: 签名令牌
|
||
- file: 签名图片文件(仅支持 PNG/JPEG)
|
||
"""
|
||
# 验证令牌
|
||
sign_token = await SignatureTokenService.get_by_token(db, token)
|
||
if not sign_token:
|
||
raise HTTPException(status_code=404, detail="签名链接无效")
|
||
|
||
is_valid, error_msg = SignatureTokenService.validate_token(sign_token)
|
||
if not is_valid:
|
||
raise HTTPException(status_code=400, detail=error_msg)
|
||
|
||
# 验证文件类型
|
||
if not file.content_type or not file.content_type.startswith('image/'):
|
||
raise HTTPException(status_code=400, detail="只支持图片文件")
|
||
|
||
allowed_types = ['image/png', 'image/jpeg', 'image/jpg']
|
||
if file.content_type not in allowed_types:
|
||
raise HTTPException(status_code=400, detail="只支持 PNG 或 JPEG 格式")
|
||
|
||
# 验证文件大小(最大 5MB)
|
||
content = await file.read()
|
||
if len(content) > 5 * 1024 * 1024:
|
||
raise HTTPException(status_code=400, detail="文件大小不能超过 5MB")
|
||
|
||
try:
|
||
# 上传文件
|
||
uploaded_file = await FileManagerService.upload_file(
|
||
db=db,
|
||
file_content=content,
|
||
filename=file.filename or f"signature_{datetime.now().strftime('%Y%m%d%H%M%S')}.png",
|
||
file_size=len(content),
|
||
source='form', # 归类到表单附件
|
||
parent_id=None,
|
||
is_public=False,
|
||
)
|
||
|
||
# 获取客户端信息
|
||
ip_address = None
|
||
user_agent = None
|
||
if request:
|
||
ip_address = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
|
||
user_agent = request.headers.get("User-Agent")
|
||
|
||
# 完成签名
|
||
sign_token = await SignatureTokenService.complete_signature(
|
||
db=db,
|
||
sign_token=sign_token,
|
||
signature_file_id=uploaded_file.id,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "签名完成",
|
||
"file_id": sign_token.signature_file_id,
|
||
}
|
||
except Exception as e:
|
||
logger.exception("上传签名图片失败")
|
||
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
|
||
|
||
|
||
@router.post("/signature/complete/{token}", summary="完成签名(无需登录)")
|
||
async def complete_signature(
|
||
token: str,
|
||
signature_file_id: str = Form(..., description="签名文件ID"),
|
||
request: Request = None,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
完成签名(移动端使用,无需登录)
|
||
|
||
参数:
|
||
- token: 签名令牌
|
||
- signature_file_id: 已上传的签名图片文件ID
|
||
"""
|
||
sign_token = await SignatureTokenService.get_by_token(db, token)
|
||
if not sign_token:
|
||
raise HTTPException(status_code=404, detail="签名链接无效")
|
||
|
||
is_valid, error_msg = SignatureTokenService.validate_token(sign_token)
|
||
if not is_valid:
|
||
raise HTTPException(status_code=400, detail=error_msg)
|
||
|
||
# 获取客户端信息
|
||
ip_address = None
|
||
user_agent = None
|
||
if request:
|
||
ip_address = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
|
||
user_agent = request.headers.get("User-Agent")
|
||
|
||
sign_token = await SignatureTokenService.complete_signature(
|
||
db=db,
|
||
sign_token=sign_token,
|
||
signature_file_id=signature_file_id,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "签名完成",
|
||
"file_id": sign_token.signature_file_id,
|
||
}
|