feat: Implement file download token management and restructure file routes

- Added DownloadTokenManager for creating and verifying JWT download tokens.
- Introduced new download routes for creating download tokens and downloading files using tokens.
- Restructured file upload routes into a dedicated sub-router.
- Updated file upload session management with improved error handling and response structures.
- Created a new MCP (Microservice Communication Protocol) router with basic request and response models.
- Added base models for MCP requests and responses, including method enumeration.
This commit is contained in:
2025-12-23 18:12:11 +08:00
parent 4cd13e4075
commit 16cec42181
14 changed files with 1884 additions and 396 deletions

View File

@@ -3,19 +3,21 @@
提供文件上传、下载、创建等核心功能。
路由前缀
路由结构
- /file - 文件操作
- /file/upload - 上传相关操作
- /file/download - 下载相关操作
"""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from loguru import logger as l
from middleware.auth import AuthRequired, SignRequired
from middleware.auth import AuthRequired
from middleware.dependencies import SessionDep
from models import (
CreateFileRequest,
@@ -25,28 +27,61 @@ from models import (
PhysicalFile,
Policy,
PolicyType,
ResponseBase,
UploadChunkResponse,
UploadSession,
UploadSessionResponse,
User,
)
from models import ResponseBase
from service.storage import LocalStorageService, StorageFileNotFoundError
file_router = APIRouter(
prefix="/file",
tags=["file"]
)
file_upload_router = APIRouter(
prefix="/file/upload",
tags=["file"]
)
from service.storage import LocalStorageService
from utils.JWT import SECRET_KEY
# ==================== 上传会话管理 ====================
# ==================== 下载令牌管理 ====================
@file_upload_router.put(
class DownloadTokenManager:
"""下载令牌管理器JWT 无状态)"""
_ttl: timedelta = timedelta(hours=1)
@classmethod
def create(cls, file_id: UUID, owner_id: int) -> str:
"""创建下载令牌"""
payload = {
"file_id": str(file_id),
"owner_id": owner_id,
"exp": datetime.now(timezone.utc) + cls._ttl,
"type": "download",
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
@classmethod
def verify(cls, token: str) -> tuple[UUID, int] | None:
"""
验证令牌并返回 (file_id, owner_id)
:return: (file_id, owner_id) 或 None验证失败
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
if payload.get("type") != "download":
return None
return UUID(payload["file_id"]), payload["owner_id"]
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
# ==================== 主路由 ====================
router = APIRouter(prefix="/file", tags=["file"])
# ==================== 上传子路由 ====================
_upload_router = APIRouter(prefix="/upload")
@_upload_router.put(
path='/',
summary='创建上传会话',
description='创建文件上传会话返回会话ID用于后续分片上传。',
@@ -65,11 +100,6 @@ async def create_upload_session(
3. 验证文件大小限制
4. 创建上传会话并生成存储路径
5. 返回会话信息
:param session: 数据库会话
:param user: 当前登录用户
:param request: 创建请求
:return: 上传会话信息
"""
# 验证文件名
if not request.file_name or '/' in request.file_name or '\\' in request.file_name:
@@ -121,7 +151,6 @@ async def create_upload_session(
)
storage_path = full_path
else:
# S3 后续实现
raise HTTPException(status_code=501, detail="S3 存储暂未实现")
# 创建上传会话
@@ -131,7 +160,7 @@ async def create_upload_session(
chunk_size=chunk_size,
total_chunks=total_chunks,
storage_path=storage_path,
expires_at=datetime.now() + timedelta(hours=24), # 24小时过期
expires_at=datetime.now() + timedelta(hours=24),
owner_id=user.id,
parent_id=request.parent_id,
policy_id=policy_id,
@@ -151,7 +180,7 @@ async def create_upload_session(
)
@file_upload_router.post(
@_upload_router.post(
path='/{session_id}/{chunk_index}',
summary='上传文件分片',
description='上传指定分片分片索引从0开始。',
@@ -171,13 +200,6 @@ async def upload_chunk(
2. 写入分片数据
3. 更新会话进度
4. 如果所有分片上传完成,创建 Object 记录
:param session: 数据库会话
:param user: 当前登录用户
:param session_id: 上传会话UUID
:param chunk_index: 分片索引从0开始
:param file: 上传的文件分片
:return: 上传进度信息
"""
# 获取上传会话
upload_session = await UploadSession.get(session, UploadSession.id == session_id)
@@ -262,7 +284,7 @@ async def upload_chunk(
)
@file_upload_router.delete(
@_upload_router.delete(
path='/{session_id}',
summary='删除上传会话',
description='取消上传并删除会话及已上传的临时文件。',
@@ -272,14 +294,7 @@ async def delete_upload_session(
user: Annotated[User, Depends(AuthRequired)],
session_id: UUID,
) -> ResponseBase:
"""
删除上传会话端点
:param session: 数据库会话
:param user: 当前登录用户
:param session_id: 上传会话UUID
:return: 删除结果
"""
"""删除上传会话端点"""
upload_session = await UploadSession.get(session, UploadSession.id == session_id)
if not upload_session or upload_session.owner_id != user.id:
raise HTTPException(status_code=404, detail="上传会话不存在")
@@ -298,7 +313,7 @@ async def delete_upload_session(
return ResponseBase(data={"deleted": True})
@file_upload_router.delete(
@_upload_router.delete(
path='/',
summary='清除所有上传会话',
description='清除当前用户的所有上传会话。',
@@ -307,13 +322,7 @@ async def clear_upload_sessions(
session: SessionDep,
user: Annotated[User, Depends(AuthRequired)],
) -> ResponseBase:
"""
清除所有上传会话端点
:param session: 数据库会话
:param user: 当前登录用户
:return: 清除结果
"""
"""清除所有上传会话端点"""
# 获取所有会话
sessions = await UploadSession.get(
session,
@@ -337,28 +346,74 @@ async def clear_upload_sessions(
return ResponseBase(data={"deleted": deleted_count})
# ==================== 文件下载 ====================
@file_upload_router.get(
path='/download/{file_id}',
summary='下载文件',
description='下载指定文件。',
@_upload_router.get(
path='/archive/{session_id}/archive.zip',
summary='打包并下载文件',
description='获取打包后的文件。',
)
async def download_file(
async def download_archive(session_id: str) -> ResponseBase:
"""打包下载"""
raise HTTPException(status_code=501, detail="打包下载功能暂未实现")
# ==================== 下载子路由 ====================
_download_router = APIRouter(prefix="/download")
@_download_router.put(
path='/{file_id}',
summary='创建下载令牌',
description='为指定文件创建下载令牌JWT有效期1小时。',
)
async def create_download_token(
session: SessionDep,
user: Annotated[User, Depends(AuthRequired)],
file_id: UUID,
) -> ResponseBase:
"""
创建下载令牌端点
验证文件存在且属于当前用户后,生成 JWT 下载令牌。
"""
file_obj = await Object.get(session, Object.id == file_id)
if not file_obj or file_obj.owner_id != user.id:
raise HTTPException(status_code=404, detail="文件不存在")
if not file_obj.is_file:
raise HTTPException(status_code=400, detail="对象不是文件")
token = DownloadTokenManager.create(file_id, user.id)
l.debug(f"创建下载令牌: file_id={file_id}, user_id={user.id}")
return ResponseBase(data={"token": token, "expires_in": 3600})
@_download_router.get(
path='/{token}',
summary='下载文件',
description='使用下载令牌下载文件。',
)
async def download_file(
session: SessionDep,
token: str,
) -> FileResponse:
"""
下载文件端点
:param session: 数据库会话
:param user: 当前登录用户
:param file_id: 文件UUID
:return: 文件响应
验证 JWT 令牌后返回文件内容。
"""
# 验证令牌
result = DownloadTokenManager.verify(token)
if not result:
raise HTTPException(status_code=401, detail="下载令牌无效或已过期")
file_id, owner_id = result
# 获取文件对象
file_obj = await Object.get(session, Object.id == file_id)
if not file_obj or file_obj.owner_id != user.id:
if not file_obj or file_obj.owner_id != owner_id:
raise HTTPException(status_code=404, detail="文件不存在")
if not file_obj.is_file:
@@ -386,9 +441,15 @@ async def download_file(
raise HTTPException(status_code=501, detail="S3 存储暂未实现")
# ==================== 包含子路由 ====================
router.include_router(_upload_router)
router.include_router(_download_router)
# ==================== 创建空白文件 ====================
@file_router.post(
@router.post(
path='/create',
summary='创建空白文件',
description='在指定目录下创建空白文件。',
@@ -398,14 +459,7 @@ async def create_empty_file(
user: Annotated[User, Depends(AuthRequired)],
request: CreateFileRequest,
) -> ResponseBase:
"""
创建空白文件端点
:param session: 数据库会话
:param user: 当前登录用户
:param request: 创建请求
:return: 创建结果
"""
"""创建空白文件端点"""
# 存储 user.id避免后续 save() 导致 user 过期后无法访问
user_id = user.id
@@ -482,175 +536,146 @@ async def create_empty_file(
# ==================== 文件外链(保留原有端点结构) ====================
@file_router.get(
@router.get(
path='/get/{id}/{name}',
summary='文件外链(直接输出文件数据)',
description='通过外链直接获取文件内容。',
)
async def router_file_get(
async def file_get(
session: SessionDep,
id: str,
name: str,
) -> FileResponse:
"""
文件外链端点(直接输出)
TODO: 实现签名验证和权限控制
"""
"""文件外链端点(直接输出)"""
raise HTTPException(status_code=501, detail="外链功能暂未实现")
@file_router.get(
@router.get(
path='/source/{id}/{name}',
summary='文件外链(301跳转)',
description='通过外链获取文件重定向地址。',
)
async def router_file_source_redirect(id: str, name: str) -> ResponseBase:
"""
文件外链端点301跳转
TODO: 实现签名验证和重定向
"""
async def file_source_redirect(id: str, name: str) -> ResponseBase:
"""文件外链端点301跳转"""
raise HTTPException(status_code=501, detail="外链功能暂未实现")
@file_router.put(
@router.put(
path='/update/{id}',
summary='更新文件',
description='更新文件内容。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_update(id: str) -> ResponseBase:
async def file_update(id: str) -> ResponseBase:
"""更新文件内容"""
raise HTTPException(status_code=501, detail="更新文件功能暂未实现")
@file_router.get(
@router.get(
path='/preview/{id}',
summary='预览文件',
description='获取文件预览。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_preview(id: str) -> ResponseBase:
async def file_preview(id: str) -> ResponseBase:
"""预览文件"""
raise HTTPException(status_code=501, detail="预览功能暂未实现")
@file_router.get(
@router.get(
path='/content/{id}',
summary='获取文本文件内容',
description='获取文本文件内容。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_content(id: str) -> ResponseBase:
async def file_content(id: str) -> ResponseBase:
"""获取文本文件内容"""
raise HTTPException(status_code=501, detail="文本内容功能暂未实现")
@file_router.get(
@router.get(
path='/doc/{id}',
summary='获取Office文档预览地址',
description='获取Office文档在线预览地址。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_doc(id: str) -> ResponseBase:
async def file_doc(id: str) -> ResponseBase:
"""获取Office文档预览地址"""
raise HTTPException(status_code=501, detail="Office预览功能暂未实现")
@file_router.get(
@router.get(
path='/thumb/{id}',
summary='获取文件缩略图',
description='获取文件缩略图。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_thumb(id: str) -> ResponseBase:
async def file_thumb(id: str) -> ResponseBase:
"""获取文件缩略图"""
raise HTTPException(status_code=501, detail="缩略图功能暂未实现")
@file_router.post(
@router.post(
path='/source/{id}',
summary='取得文件外链',
description='获取文件的外链地址。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_source(id: str) -> ResponseBase:
async def file_source(id: str) -> ResponseBase:
"""获取文件外链"""
raise HTTPException(status_code=501, detail="外链功能暂未实现")
@file_router.post(
@router.post(
path='/archive',
summary='打包要下载的文件',
description='将多个文件打包下载。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_archive() -> ResponseBase:
async def file_archive() -> ResponseBase:
"""打包文件"""
raise HTTPException(status_code=501, detail="打包功能暂未实现")
@file_router.post(
@router.post(
path='/compress',
summary='创建文件压缩任务',
description='创建文件压缩任务。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_compress() -> ResponseBase:
async def file_compress() -> ResponseBase:
"""创建压缩任务"""
raise HTTPException(status_code=501, detail="压缩功能暂未实现")
@file_router.post(
@router.post(
path='/decompress',
summary='创建文件解压任务',
description='创建文件解压任务。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_decompress() -> ResponseBase:
async def file_decompress() -> ResponseBase:
"""创建解压任务"""
raise HTTPException(status_code=501, detail="解压功能暂未实现")
@file_router.post(
@router.post(
path='/relocate',
summary='创建文件转移任务',
description='创建文件转移任务。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_relocate() -> ResponseBase:
async def file_relocate() -> ResponseBase:
"""创建转移任务"""
raise HTTPException(status_code=501, detail="转移功能暂未实现")
@file_router.get(
@router.get(
path='/search/{type}/{keyword}',
summary='搜索文件',
description='按关键字搜索文件。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_search(type: str, keyword: str) -> ResponseBase:
async def file_search(type: str, keyword: str) -> ResponseBase:
"""搜索文件"""
raise HTTPException(status_code=501, detail="搜索功能暂未实现")
@file_upload_router.get(
path='/archive/{sessionID}/archive.zip',
summary='打包并下载文件',
description='获取打包后的文件。',
)
async def router_file_archive_download(sessionID: str) -> ResponseBase:
"""打包下载"""
raise HTTPException(status_code=501, detail="打包下载功能暂未实现")
@file_router.put(
path='/download/{id}',
summary='创建文件下载会话',
description='创建文件下载会话。',
dependencies=[Depends(AuthRequired)]
)
async def router_file_download_session(id: str) -> ResponseBase:
"""创建下载会话"""
raise HTTPException(status_code=501, detail="下载会话功能暂未实现")