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

165
PLAN.md
View File

@@ -1,89 +1,89 @@
- 数据模型初步规划
- 基类
- IDUUIDv7
- 创建时间
- 更新时间
- User
- 用户名 唯一
- 昵称
- Argon2化的密码
- 用户当前的状态 `正常/手动封禁/系统封禁` 用 StrEnum 存储
- 已用存储空间
- 用户积分
- 默认用户组
- Optional[两步验证密钥]
- Optional[WebAuthn 凭证]
- Optional[头像地址] -> None为昵称首字/图片/Gavatar头像
- Optional[当前用户组过期时间] -> 用户组过期后回退默认用户组
- 用户的个人设置 计划中 考虑 BaseModel 进行嵌套
- Group `与 User 的关系:一个用户组可以有多个用户,但一个用户只能在一个用户组中`
- 组名 唯一
- list[[允许的存储策略, 该策略下最大允许使用的容量, 该策略下允许上传文件的白名单/黑名单]]
- 默认存储策略
- bool[分享内容]
- bool[是否管理员组]
- 速度限制
- bool[打包下载]
- bool[创建压缩/解压缩任务]
- bool[WebDAV]
- bool[WebDAV 反代]
- bool[离线下载]
- Object `计划:把 file 和 folder 合并为一个 Object 表,通过对象类型区分`
- 对象名
- 区分大小写
- 禁止名称为特殊字段 (如 `/`, `\`, `:`, `*`, `?`, `<`, `>`, `:`, `"`)
- 对象类型[file, folder, link]
- 当对象类型为file时
- 源文件名
- 文件大小
- 分块上传会话ID
- 文件MD5, SHA1, SHA256
- 文件元数据
- 音频:歌名、歌手名、专辑、流派...
- 图片: 尺寸、ISO、曝光、拍摄设备、地理位置...
- 其他需要记录的元数据
- 当对象类型为 folder 时
- 当前文件夹的视图(网格/列表/画廊)
- 排序规则(按名称/大小/上传时间/修改时间)
- 排序方式(升序/降序)
- 当对象类型为 link 时
- 目标对象ID
- 外键
- 用户ID 用于确保该对象的归属
- 目录ID 用于定位该对象存在哪个目录
- 策略ID 用于定位该对象存储于哪个存储策略
- Policy
- 存储策略名
- 策略类型(如 `本机`, `从机`, `s3`, `OSS`, `COS` 等)
- 服务器地址(本机则为路径)
- 存储桶名称
- 允许上传文件的最大字节数
- 目录命名规则
- 文件命名规则
- 文件后缀白名单/黑名单
- 分片上传大小
- `待研究`
- Tag
- 标签名称
- 标签图标
- 标签颜色
- 外键
- 用户ID 用于确保该标签的归属
- 基类
- IDUUIDv7
- 创建时间
- 更新时间
- User
- 用户名 唯一
- 昵称
- Argon2化的密码
- 用户当前的状态 `正常/手动封禁/系统封禁` 用 StrEnum 存储
- 已用存储空间
- 用户积分
- 默认用户组
- Optional[两步验证密钥]
- Optional[WebAuthn 凭证]
- Optional[头像地址] -> None为昵称首字/图片/Gavatar头像
- Optional[当前用户组过期时间] -> 用户组过期后回退默认用户组
- 用户的个人设置 计划中 考虑 BaseModel 进行嵌套
- Group `与 User 的关系:一个用户组可以有多个用户,但一个用户只能在一个用户组中`
- 组名 唯一
- list[[允许的存储策略, 该策略下最大允许使用的容量, 该策略下允许上传文件的白名单/黑名单]]
- 默认存储策略
- bool[分享内容]
- bool[是否管理员组]
- 速度限制
- bool[打包下载]
- bool[创建压缩/解压缩任务]
- bool[WebDAV]
- bool[WebDAV 反代]
- bool[离线下载]
- Object `计划:把 file 和 folder 合并为一个 Object 表,通过对象类型区分`
- 对象名
- 区分大小写
- 禁止名称为特殊字段 (如 `/`, `\`, `:`, `*`, `?`, `<`, `>`, `:`, `"`)
- 对象类型[file, folder, link]
- 当对象类型为file时
- 源文件名
- 文件大小
- 分块上传会话ID
- 文件MD5, SHA1, SHA256
- 文件元数据
- 音频:歌名、歌手名、专辑、流派...
- 图片: 尺寸、ISO、曝光、拍摄设备、地理位置...
- 其他需要记录的元数据
- 当对象类型为 folder 时
- 当前文件夹的视图(网格/列表/画廊)
- 排序规则(按名称/大小/上传时间/修改时间)
- 排序方式(升序/降序)
- 当对象类型为 link 时
- 目标对象ID
- 外键
- 用户ID 用于确保该对象的归属
- 目录ID 用于定位该对象存在哪个目录
- 策略ID 用于定位该对象存储于哪个存储策略
- Policy
- 存储策略名
- 策略类型(如 `本机`, `从机`, `s3`, `OSS`, `COS` 等)
- 服务器地址(本机则为路径)
- 存储桶名称
- 允许上传文件的最大字节数
- 目录命名规则
- 文件命名规则
- 文件后缀白名单/黑名单
- 分片上传大小
- `待研究`
- Tag
- 标签名称
- 标签图标
- 标签颜色
- 外键
- 用户ID 用于确保该标签的归属
- 运行环境与目标
- 数据库类型:主要支持 PostgreSQL 18考虑兼容 SQLite/MySQL/早期版本PostgreSQL
- 驱动版本:做一定的向下兼容,主要支持 Python 3.13+
- 异步栈:全量异步 AsyncSession不接受兼容同步
- 数据库类型:主要支持 PostgreSQL 18考虑兼容 SQLite/MySQL/早期版本PostgreSQL
- 驱动版本:做一定的向下兼容,主要支持 Python 3.13+
- 异步栈:全量异步 AsyncSession不接受兼容同步
- 业务语义与数据模型
- 时间与时区:统一存储 UTC再根据用户选择的时区计算本地化时间
- 文件/目录命名规则
- 文件名在同一目录下唯一
- 目录名在同一账户下的同一父目录下唯一
- 资源分享
- 用户可以分享单个文件,也可以分享整个目录
- 时间与时区:统一存储 UTC再根据用户选择的时区计算本地化时间
- 文件/目录命名规则
- 文件名在同一目录下唯一
- 目录名在同一账户下的同一父目录下唯一
- 资源分享
- 用户可以分享单个文件,也可以分享整个目录
- 关系与级联
- 删除文件夹时,同时删除该文件夹内的所有子文件夹及其所有文件
- 删除用户时同时删除该用户的所有文件文件夹分享Tag
- 删除文件夹时,同时删除该文件夹内的所有子文件夹及其所有文件
- 删除用户时同时删除该用户的所有文件文件夹分享Tag
- 文件预览与编辑
- 预览应用 Literal['嵌入网页式应用', 'WOPI协议式应用']
- 是否启用
@@ -96,4 +96,3 @@
- 最大文件大小
- 平台 支持列表 ['all', 'mobile', 'desktop']
- 是否在新窗口打开

View File

@@ -171,7 +171,7 @@ fastapi dev
fastapi run
```
访问 http://localhost:8000/docs 查看 API 文档。
访问 <http://localhost:8000/docs> 查看 API 文档。
## 测试

View File

@@ -21,6 +21,7 @@
### 已完成
#### 基础架构
- [x] FastAPI 应用框架搭建
- [x] SQLModel ORM 集成
- [x] 异步数据库支持 (aiosqlite)
@@ -28,6 +29,7 @@
- [x] 开发规范文档 (CLAUDE.md)
#### 数据模型
- [x] 基类定义 (SQLModelBase, TableBase, UUIDTableBase)
- [x] 用户模型 (User)
- [x] 用户组模型 (Group, GroupOptions)
@@ -40,6 +42,7 @@
- [x] 其他模型 (Order, Redeem, Report, Task, SourceLink, StoragePack, Download, Node)
#### 用户系统
- [x] 用户注册接口
- [x] 用户登录接口 (OAuth2.1 Password Grant)
- [x] JWT 令牌认证
@@ -47,6 +50,7 @@
- [x] 用户存储空间查询
#### 认证安全
- [x] Argon2 密码哈希
- [x] JWT 令牌生成与验证
- [x] 认证中间件
@@ -54,6 +58,7 @@
- [x] WebAuthn 注册初始化
#### 测试
- [x] pytest 测试框架配置
- [x] 单元测试结构
- [x] 集成测试结构
@@ -62,17 +67,20 @@
### 进行中
#### 用户系统
- [ ] WebAuthn 完整流程
- [ ] OAuth 第三方登录 (QQ, GitHub)
- [ ] 用户设置管理
- [ ] 头像上传/Gravatar
#### 目录系统
- [ ] 目录浏览接口
- [ ] 目录创建接口
- [ ] 路径解析优化
#### 存储策略
- [ ] 本地存储策略实现
- [ ] S3 存储策略实现
@@ -408,4 +416,4 @@
---
*最后更新2025年12月*
*最后更新2025年12月*

View File

@@ -38,15 +38,6 @@ async def AuthRequired(
except InvalidTokenError:
raise credentials_exception
async def SignRequired(
session: SessionDep,
token: Annotated[str, Depends(JWT.oauth2_scheme)],
) -> User | None:
"""
SignAuthRequired 需要验证请求签名
"""
pass
async def AdminRequired(
user: Annotated[User, Depends(AuthRequired)],
) -> User:

View File

@@ -8,6 +8,10 @@ from .user import (
UserResponse,
UserSettingResponse,
WebAuthnInfo,
# 管理员DTO
UserAdminUpdateRequest,
UserCalibrateResponse,
UserAdminDetailResponse,
)
from .user_authn import AuthnResponse, UserAuthn
from .color import ThemeResponse
@@ -27,7 +31,11 @@ from .node import (
NodeStatus,
NodeType,
)
from .group import Group, GroupBase, GroupOptions, GroupOptionsBase, GroupResponse
from .group import (
Group, GroupBase, GroupOptions, GroupOptionsBase, GroupResponse,
# 管理员DTO
GroupCreateRequest, GroupUpdateRequest, GroupDetailResponse, GroupListResponse,
)
from .object import (
CreateFileRequest,
CreateUploadSessionRequest,
@@ -50,13 +58,21 @@ from .object import (
UploadSession,
UploadSessionBase,
UploadSessionResponse,
# 管理员DTO
AdminFileResponse,
AdminFileListResponse,
FileBanRequest,
)
from .physical_file import PhysicalFile, PhysicalFileBase
from .order import Order, OrderStatus, OrderType
from .policy import Policy, PolicyOptions, PolicyOptionsBase, PolicyType
from .redeem import Redeem, RedeemType
from .report import Report, ReportReason
from .setting import Setting, SettingsType, SiteConfigResponse
from .setting import (
Setting, SettingsType, SiteConfigResponse,
# 管理员DTO
SettingItem, SettingsUpdateRequest, SettingsGetResponse,
)
from .share import Share
from .source_link import SourceLink
from .storage_pack import StoragePack
@@ -66,13 +82,10 @@ from .webdav import WebDAV
from .database import engine, get_session
import uuid
from sqlmodel import Field
from .base import SQLModelBase
class ResponseBase(SQLModelBase):
"""通用响应模型"""
instance_id: uuid.UUID = Field(default_factory=uuid.uuid4)
"""实例ID用于标识请求的唯一性"""
from .model_base import (
MCPBase,
MCPMethod,
MCPRequestBase,
MCPResponseBase,
ResponseBase,
)

View File

@@ -45,6 +45,151 @@ class GroupOptionsBase(SQLModelBase):
# ==================== DTO 模型 ====================
class GroupCreateRequest(SQLModelBase):
"""创建用户组请求 DTO"""
name: str = Field(max_length=255)
"""用户组名称"""
max_storage: int = Field(default=0, ge=0)
"""最大存储空间字节0表示不限制"""
share_enabled: bool = False
"""是否允许创建分享"""
web_dav_enabled: bool = False
"""是否允许使用WebDAV"""
speed_limit: int = Field(default=0, ge=0)
"""速度限制 (KB/s), 0为不限制"""
# 用户组选项
share_download: bool = False
"""是否允许分享下载"""
share_free: bool = False
"""是否免积分获取需要积分的内容"""
relocate: bool = False
"""是否允许文件重定位"""
source_batch: int = Field(default=0, ge=0)
"""批量获取源地址数量"""
select_node: bool = False
"""是否允许选择节点"""
advance_delete: bool = False
"""是否允许高级删除"""
archive_download: bool = False
"""是否允许打包下载"""
archive_task: bool = False
"""是否允许创建打包任务"""
webdav_proxy: bool = False
"""是否允许WebDAV代理"""
aria2: bool = False
"""是否允许使用aria2"""
redirected_source: bool = False
"""是否使用重定向源"""
policy_ids: list[UUID] = []
"""关联的存储策略UUID列表"""
class GroupUpdateRequest(SQLModelBase):
"""更新用户组请求 DTO所有字段可选"""
name: str | None = Field(default=None, max_length=255)
"""用户组名称"""
max_storage: int | None = Field(default=None, ge=0)
"""最大存储空间(字节)"""
share_enabled: bool | None = None
"""是否允许创建分享"""
web_dav_enabled: bool | None = None
"""是否允许使用WebDAV"""
speed_limit: int | None = Field(default=None, ge=0)
"""速度限制 (KB/s)"""
# 用户组选项
share_download: bool | None = None
share_free: bool | None = None
relocate: bool | None = None
source_batch: int | None = None
select_node: bool | None = None
advance_delete: bool | None = None
archive_download: bool | None = None
archive_task: bool | None = None
webdav_proxy: bool | None = None
aria2: bool | None = None
redirected_source: bool | None = None
policy_ids: list[UUID] | None = None
"""关联的存储策略UUID列表"""
class GroupDetailResponse(SQLModelBase):
"""用户组详情响应 DTO"""
id: UUID
"""用户组UUID"""
name: str
"""用户组名称"""
max_storage: int = 0
"""最大存储空间(字节)"""
share_enabled: bool = False
"""是否允许创建分享"""
web_dav_enabled: bool = False
"""是否允许使用WebDAV"""
admin: bool = False
"""是否为管理员组"""
speed_limit: int = 0
"""速度限制 (KB/s)"""
user_count: int = 0
"""用户数量"""
policy_ids: list[UUID] = []
"""关联的存储策略UUID列表"""
# 选项
share_download: bool = False
share_free: bool = False
relocate: bool = False
source_batch: int = 0
select_node: bool = False
advance_delete: bool = False
archive_download: bool = False
archive_task: bool = False
webdav_proxy: bool = False
aria2: bool = False
redirected_source: bool = False
class GroupListResponse(SQLModelBase):
"""用户组列表响应 DTO"""
groups: list["GroupDetailResponse"] = []
"""用户组列表"""
total: int = 0
"""总数"""
class GroupResponse(GroupBase, GroupOptionsBase):
"""用户组响应 DTO"""

39
models/model_base.py Normal file
View File

@@ -0,0 +1,39 @@
import uuid
from enum import StrEnum
from sqlmodel import Field
from .base import SQLModelBase
class MCPMethod(StrEnum):
"""MCP 方法枚举"""
PING = "ping"
"""Ping 方法,用于测试连接"""
class MCPBase(SQLModelBase):
"""MCP 请求基础模型"""
jsonrpc: str = "2.0"
"""JSON-RPC 版本"""
id: uuid.UUID = Field(default_factory=uuid.uuid4)
"""请求/响应 ID用于标识请求/响应的唯一性"""
class MCPRequestBase(MCPBase):
"""MCP 请求模型基础类"""
method: str
"""方法名称"""
class MCPResponseBase(MCPBase):
"""MCP 响应模型基础类"""
result: str
"""方法返回结果"""
class ResponseBase(SQLModelBase):
"""通用响应模型"""
instance_id: uuid.UUID = Field(default_factory=uuid.uuid4)
"""实例ID用于标识请求的唯一性"""

View File

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Literal
from uuid import UUID
from enum import StrEnum
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index, text
from .base import SQLModelBase
from .mixin import UUIDTableBaseMixin
@@ -258,11 +258,39 @@ class Object(ObjectBase, UUIDTableBaseMixin):
)
"""存储策略UUID文件直接使用目录作为子文件的默认策略"""
# ==================== 封禁相关字段 ====================
is_banned: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")})
"""是否被封禁"""
banned_at: datetime | None = None
"""封禁时间"""
banned_by: UUID | None = Field(
default=None,
foreign_key="user.id",
index=True,
ondelete="SET NULL",
sa_column_kwargs={"name": "banned_by"}
)
"""封禁操作者UUID"""
ban_reason: str | None = Field(default=None, max_length=500)
"""封禁原因"""
# ==================== 关系 ====================
owner: "User" = Relationship(back_populates="objects")
owner: "User" = Relationship(
back_populates="objects",
sa_relationship_kwargs={"foreign_keys": "[Object.owner_id]"}
)
"""所有者"""
banner: "User" = Relationship(
sa_relationship_kwargs={"foreign_keys": "[Object.banned_by]"}
)
"""封禁操作者"""
policy: "Policy" = Relationship(back_populates="objects")
"""存储策略"""
@@ -642,3 +670,47 @@ class ObjectPropertyDetailResponse(ObjectPropertyResponse):
reference_count: int = 1
"""物理文件引用计数(仅文件有效)"""
# ==================== 管理员文件管理 DTO ====================
class AdminFileResponse(ObjectResponse):
"""管理员文件响应 DTO"""
owner_id: UUID
"""所有者UUID"""
owner_username: str
"""所有者用户名"""
policy_name: str
"""存储策略名称"""
is_banned: bool = False
"""是否被封禁"""
banned_at: datetime | None = None
"""封禁时间"""
ban_reason: str | None = None
"""封禁原因"""
class FileBanRequest(SQLModelBase):
"""文件封禁请求 DTO"""
is_banned: bool = True
"""是否封禁"""
reason: str | None = Field(default=None, max_length=500)
"""封禁原因"""
class AdminFileListResponse(SQLModelBase):
"""管理员文件列表响应 DTO"""
files: list[AdminFileResponse] = []
"""文件列表"""
total: int = 0
"""总数"""

View File

@@ -40,6 +40,32 @@ class SiteConfigResponse(SQLModelBase):
"""验证码密钥"""
# ==================== 管理员设置 DTO ====================
class SettingItem(SQLModelBase):
"""设置项 DTO"""
name: str
"""设置项名称"""
value: str | None = None
"""设置值"""
class SettingsUpdateRequest(SQLModelBase):
"""更新设置请求 DTO"""
settings: dict[str, dict[str, str | None]]
"""按类型分组的设置项,格式: {"basic": {"siteName": "xxx", ...}, ...}"""
class SettingsGetResponse(SQLModelBase):
"""获取设置响应 DTO"""
settings: dict[str, dict[str, str | None]] = {}
"""按类型分组的设置项"""
# ==================== 数据库模型 ====================
class SettingsType(StrEnum):

View File

@@ -201,6 +201,68 @@ class UserSettingResponse(SQLModelBase):
"""用户UUID"""
# ==================== 管理员用户管理 DTO ====================
class UserAdminUpdateRequest(SQLModelBase):
"""管理员更新用户请求 DTO"""
nickname: str | None = Field(default=None, max_length=50)
"""昵称"""
password: str | None = None
"""新密码(为空则不修改)"""
group_id: UUID | None = None
"""用户组UUID"""
status: bool | None = None
"""用户状态"""
score: int | None = Field(default=None, ge=0)
"""积分"""
storage: int | None = Field(default=None, ge=0)
"""已用存储空间(用于手动校准)"""
group_expires: datetime | None = None
"""用户组过期时间"""
class UserCalibrateResponse(SQLModelBase):
"""用户存储校准响应 DTO"""
user_id: UUID
"""用户UUID"""
previous_storage: int
"""校准前的存储空间(字节)"""
current_storage: int
"""校准后的存储空间(字节)"""
difference: int
"""差异值(字节)"""
file_count: int
"""实际文件数量"""
class UserAdminDetailResponse(UserPublic):
"""管理员用户详情响应 DTO"""
two_factor_enabled: bool = False
"""是否启用两步验证"""
file_count: int = 0
"""文件数量"""
share_count: int = 0
"""分享数量"""
task_count: int = 0
"""任务数量"""
# 前向引用导入
from .group import GroupResponse # noqa: E402
from .user_authn import AuthnResponse # noqa: E402

View File

@@ -7,12 +7,13 @@ from .admin import admin_group_router
from .admin import admin_policy_router
from .admin import admin_share_router
from .admin import admin_task_router
from .admin import admin_user_router
from .admin import admin_vas_router
from .callback import callback_router
from .directory import directory_router
from .download import download_router
from .file import file_router, file_upload_router
from .file import router as file_router
from .object import object_router
from .share import share_router
from .site import site_router
@@ -30,13 +31,13 @@ router.include_router(admin_group_router)
router.include_router(admin_policy_router)
router.include_router(admin_share_router)
router.include_router(admin_task_router)
router.include_router(admin_user_router)
router.include_router(admin_vas_router)
router.include_router(callback_router)
router.include_router(directory_router)
router.include_router(download_router)
router.include_router(file_router)
router.include_router(file_upload_router)
router.include_router(object_router)
router.include_router(share_router)
router.include_router(site_router)

File diff suppressed because it is too large Load Diff

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="下载会话功能暂未实现")

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
from models import MCPRequestBase, MCPResponseBase, MCPMethod
# MCP 路由
MCP_router = APIRouter(
prefix='/mcp',
tags=["mcp"],
)
@MCP_router.get(
"/",
)
async def mcp_root(
param: MCPRequestBase
):
match param.method:
case MCPMethod.PING:
return MCPResponseBase(result="pong", **param.model_dump())